(feature): httpClient service update, layout improvemnts, better rest std

This commit is contained in:
Maksym Sadovnychyy 2024-06-16 23:48:41 +02:00
parent 1f56ac19e6
commit 5c204e2c1d
34 changed files with 1146 additions and 481 deletions

View File

@ -1,9 +1,16 @@
enum ApiRoutes { enum ApiRoutes {
CACHE_GET_ACCOUNTS = `api/Cache/GetAccounts`, CACHE_GET_ACCOUNTS = `api/Cache/GetAccounts`,
CACHE_GET_CONTACTS = `api/Cache/GetContacts/{accountId}`, 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}`, 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_CONFIGURE_CLIENT = `api/CertsFlow/ConfigureClient`,
CERTS_FLOW_TERMS_OF_SERVICE = `api/CertsFlow/TermsOfService/{sessionId}`, CERTS_FLOW_TERMS_OF_SERVICE = `api/CertsFlow/TermsOfService/{sessionId}`,

View File

@ -1,12 +1,10 @@
import Layout from "../layout";
const AboutPage = () => { const AboutPage = () => {
return ( return (
<> <>
<h1 className="text-2xl font-bold">About</h1> <h1 className="text-2xl font-bold">About</h1>
<p>This is the about page content.</p> <p>This is the about page content.</p>
</> </>
); )
}; }
export default AboutPage; export default AboutPage

View File

@ -1,11 +1,9 @@
import Layout from "../layout";
const ContactPage = () => { const ContactPage = () => {
return (<> return (<>
<h1 className="text-2xl font-bold">Contact Us</h1> <h1 className="text-2xl font-bold">Contact Us</h1>
<p>This is the contact page content.</p> <p>This is the contact page content.</p>
</> </>
); )
}; }
export default ContactPage; export default ContactPage

View File

@ -1,12 +0,0 @@
import Layout from "./layout";
const HomePage = () => {
return (
<Layout>
<h1 className="text-2xl font-bold">Main Content</h1>
{/* Your main content goes here */}
</Layout>
);
};
export default HomePage;

View File

@ -1,78 +1,80 @@
"use client"; // Add this line "use client" // Add this line
import React, { FC, useState, useEffect, useRef } from 'react'; import React, { FC, useState, useEffect, useRef } from 'react'
import './globals.css'; import './globals.css'
import { SideMenu } from '../components/sidemenu'; import { SideMenu } from '@/components/sidemenu'
import { TopMenu } from '../components/topmenu'; import { TopMenu } from '@/components/topmenu'
import { Footer } from '../components/footer'; import { Footer } from '@/components/footer'
import { OffCanvas } from '../components/offcanvas'; import { OffCanvas } from '@/components/offcanvas'
import Loader from '../components/loader'; // Import the Loader component import { Loader } from '@/components/loader' // Import the Loader component
import { Metadata } from 'next'; import { Metadata } from 'next'
import { Toast } from '@/components/toast'
import { Provider } from 'react-redux'
import { store } from '@/redux/store'
const metadata: Metadata = { const metadata: Metadata = {
title: "Create Next App", title: "Create Next App",
description: "Generated by create next app", description: "Generated by create next app",
}; }
const Layout: FC<{ children: React.ReactNode }> = ({ children }) => { const Layout: FC<{ children: React.ReactNode }> = ({ children }) => {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
const [isManuallyCollapsed, setManuallyCollapsed] = useState(false); const [isManuallyCollapsed, setManuallyCollapsed] = useState(false)
const [isLoading, setIsLoading] = useState(true); // State to control the loader visibility //const [isLoading, setIsLoading] = useState(true) // State to control the loader visibility
const init = useRef(false); const init = useRef(false)
const toggleSidebar = () => { const toggleSidebar = () => {
setIsSidebarCollapsed((prev) => !prev); setIsSidebarCollapsed((prev) => !prev)
}; }
const manuallyToggleSidebar = () => { const manuallyToggleSidebar = () => {
setManuallyCollapsed((prev) => !prev); setManuallyCollapsed((prev) => !prev)
toggleSidebar(); toggleSidebar()
}; }
const handleResize = () => { const handleResize = () => {
if (!isManuallyCollapsed) { if (!isManuallyCollapsed) {
if (window.innerWidth <= 768) { if (window.innerWidth <= 768) {
if (!isSidebarCollapsed) setIsSidebarCollapsed(true); if (!isSidebarCollapsed) setIsSidebarCollapsed(true)
} else { } else {
if (isSidebarCollapsed) setIsSidebarCollapsed(false); if (isSidebarCollapsed) setIsSidebarCollapsed(false)
} }
} else { } else {
if (isManuallyCollapsed) return; if (isManuallyCollapsed) return
// Reset manualCollapse if the window is resized to a state that should automatically collapse/expand the sidebar // Reset manualCollapse if the window is resized to a state that should automatically collapse/expand the sidebar
if (window.innerWidth > 768 && isSidebarCollapsed) { if (window.innerWidth > 768 && isSidebarCollapsed) {
setIsSidebarCollapsed(false); setIsSidebarCollapsed(false)
} else if (window.innerWidth <= 768 && !isSidebarCollapsed) { } else if (window.innerWidth <= 768 && !isSidebarCollapsed) {
setIsSidebarCollapsed(true); setIsSidebarCollapsed(true)
}
} }
} }
};
useEffect(() => { useEffect(() => {
if (!init.current) { if (!init.current) {
handleResize(); // Set the initial state based on the current window width handleResize() // Set the initial state based on the current window width
init.current = true; init.current = true
} }
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize)
setTimeout(() => setIsLoading(false), 2000); // Simulate loading for 2 seconds
return () => { return () => {
window.removeEventListener('resize', handleResize); window.removeEventListener('resize', handleResize)
}; }
}, [isSidebarCollapsed, isManuallyCollapsed]); }, [isSidebarCollapsed, isManuallyCollapsed])
const [isOffCanvasOpen, setIsOffCanvasOpen] = useState(false); const [isOffCanvasOpen, setIsOffCanvasOpen] = useState(false)
const toggleOffCanvas = () => { const toggleOffCanvas = () => {
setIsOffCanvasOpen((prev) => !prev); setIsOffCanvasOpen((prev) => !prev)
}; }
return ( return (
<html lang="en"> <html lang="en">
<body className="h-screen overflow-hidden"> <body className="h-screen overflow-hidden">
<Provider store={store}>
{isLoading && <Loader />} {/* Show loader if isLoading is true */} <Loader />
<div className="flex h-full"> <div className="flex h-full">
<SideMenu isCollapsed={isSidebarCollapsed} toggleSidebar={manuallyToggleSidebar} /> <SideMenu isCollapsed={isSidebarCollapsed} toggleSidebar={manuallyToggleSidebar} />
@ -85,9 +87,11 @@ const Layout: FC<{ children: React.ReactNode }> = ({ children }) => {
</div> </div>
</div> </div>
<OffCanvas isOpen={isOffCanvasOpen} onClose={toggleOffCanvas} /> <OffCanvas isOpen={isOffCanvasOpen} onClose={toggleOffCanvas} />
<Toast />
</Provider>
</body> </body>
</html> </html>
); )
}; }
export default Layout; export default Layout

View File

@ -1,53 +1,58 @@
"use client"; // Add this line "use client"
import { ApiRoutes, GetApiRoute } from "@/ApiRoutes"; import { ApiRoutes, GetApiRoute } from "@/ApiRoutes"
import { GetAccountsResponse } from "@/models/letsEncryptServer/cache/GetAccountsResponse"; import { GetAccountsResponse } from "@/models/letsEncryptServer/cache/GetAccountsResponse"
import { GetContactsResponse } from "@/models/letsEncryptServer/cache/GetContactsResponse"; import { GetContactsResponse } from "@/models/letsEncryptServer/cache/GetContactsResponse"
import { GetHostnamesResponse } from "@/models/letsEncryptServer/cache/GetHostnamesResponse"; import { GetHostnamesResponse } from "@/models/letsEncryptServer/cache/GetHostnamesResponse"
import { httpService } from "@/services/HttpService"; import { httpService } from "@/services/httpService"
import { useEffect, useRef, useState } from "react"; import { FormEvent, useEffect, useRef, useState } from "react"
import { useValidation, isValidEmail, isValidHostname } from "@/hooks/useValidation"; // Assuming hooks are in a hooks directory import { useValidation, isValidEmail, isValidHostname } from "@/hooks/useValidation"
import { CustomButton, CustomInput } from "@/controls"
import { TrashIcon, PlusIcon } from "@heroicons/react/24/solid"
interface CacheAccountHostname { interface CacheAccountHostname {
hostname: string hostname: string
expires: Date, expires: Date
isUpcomingExpire: boolean isUpcomingExpire: boolean
} }
interface CacheAccount { interface CacheAccount {
accountId: string accountId: string
description?: string
contacts: string[] contacts: string[]
hostnames: CacheAccountHostname[] hostnames: CacheAccountHostname[]
isEditMode: boolean
} }
// `app/page.tsx` is the UI for the `/` URL
export default function Page() { export default function Page() {
const [accounts, setAccounts] = useState<CacheAccount[]>([]); const [accounts, setAccounts] = useState<CacheAccount[]>([])
const [isEditMode, setIsEditMode] = useState(false); const [initialAccounts, setInitialAccounts] = useState<CacheAccount[]>([])
const { const {
value: newContact, value: newContact,
error: contactError, error: contactError,
handleChange: handleContactChange handleChange: handleContactChange
} = useValidation("", isValidEmail, "Invalid email format."); } = useValidation("", isValidEmail, "Invalid email format.")
const { const {
value: newHostname, value: newHostname,
error: hostnameError, error: hostnameError,
handleChange: handleHostnameChange handleChange: handleHostnameChange
} = useValidation("", isValidHostname, "Invalid hostname format."); } = useValidation("", isValidHostname, "Invalid hostname format.")
const init = useRef(false); const init = useRef(false)
useEffect(() => { useEffect(() => {
if (init.current) if (init.current) return
return;
const fetchAccounts = async () => { const fetchAccounts = async () => {
const newAccounts: CacheAccount[] = []; const newAccounts: CacheAccount[] = []
const accountsResponse = await httpService.get<GetAccountsResponse>(GetApiRoute(ApiRoutes.CACHE_GET_ACCOUNTS))
const accountsResponse = await httpService.get<GetAccountsResponse>(GetApiRoute(ApiRoutes.CACHE_GET_ACCOUNTS));
for (const accountId of accountsResponse.accountIds) { for (const accountId of accountsResponse.accountIds) {
const contactsResponse = await httpService.get<GetContactsResponse>(GetApiRoute(ApiRoutes.CACHE_GET_CONTACTS, accountId)); const [contactsResponse, hostnamesResponse] = await Promise.all([
const hostnamesResponse = await httpService.get<GetHostnamesResponse>(GetApiRoute(ApiRoutes.CACHE_GET_HOSTNAMES, accountId)); httpService.get<GetContactsResponse>(GetApiRoute(ApiRoutes.CACHE_GET_CONTACTS, accountId)),
httpService.get<GetHostnamesResponse>(GetApiRoute(ApiRoutes.CACHE_GET_HOSTNAMES, accountId))
])
newAccounts.push({ newAccounts.push({
accountId: accountId, accountId: accountId,
@ -56,92 +61,151 @@ export default function Page() {
hostname: h.hostname, hostname: h.hostname,
expires: new Date(h.expires), expires: new Date(h.expires),
isUpcomingExpire: h.isUpcomingExpire isUpcomingExpire: h.isUpcomingExpire
})) })),
}); isEditMode: false
})
} }
setAccounts(newAccounts); setAccounts(newAccounts)
}; setInitialAccounts(JSON.parse(JSON.stringify(newAccounts))) // Clone initial state
}
fetchAccounts(); fetchAccounts()
init.current = true; init.current = true
}, []); }, [])
const toggleEditMode = (accountId: string) => {
setAccounts(accounts.map(account =>
account.accountId === accountId ? { ...account, isEditMode: !account.isEditMode } : account
))
}
const deleteAccount = (accountId: string) => { 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 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 => setAccounts(accounts.map(account =>
account.accountId === accountId account.accountId === accountId
? { ...account, contacts: account.contacts.filter(c => c !== contact) } ? { ...account, contacts: account.contacts.filter(c => c !== contact) }
: account : account
)); ))
} }
const addContact = (accountId: string) => { const addContact = (accountId: string) => {
if (newContact.trim() === "" || contactError) { if (newContact.trim() === "" || contactError) {
return; return
} }
if (accounts.find(account => account.accountId === accountId)?.contacts.includes(newContact.trim()))
return
setAccounts(accounts.map(account => setAccounts(accounts.map(account =>
account.accountId === accountId account.accountId === accountId
? { ...account, contacts: [...account.contacts, newContact.trim()] } ? { ...account, contacts: [...account.contacts, newContact.trim()] }
: account : account
)); ))
handleContactChange(""); handleContactChange("")
} }
const deleteHostname = (accountId: string, hostname: string) => { 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 => setAccounts(accounts.map(account =>
account.accountId === accountId account.accountId === accountId
? { ...account, hostnames: account.hostnames.filter(h => h.hostname !== hostname) } ? { ...account, hostnames: account.hostnames.filter(h => h.hostname !== hostname) }
: account : account
)); ))
} }
const addHostname = (accountId: string) => { const addHostname = (accountId: string) => {
if (newHostname.trim() === "" || hostnameError) { 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 => setAccounts(accounts.map(account =>
account.accountId === accountId account.accountId === accountId
? { ...account, hostnames: [...account.hostnames, { hostname: newHostname.trim(), expires: new Date(), isUpcomingExpire: false }] } ? { ...account, hostnames: [...account.hostnames, { hostname: newHostname.trim(), expires: new Date(), isUpcomingExpire: false }] }
: account : account
)); ))
handleHostnameChange(""); handleHostnameChange("")
} }
useEffect(() => { const handleSubmit = async (e: FormEvent<HTMLFormElement>, accountId: string) => {
if (isEditMode) { e.preventDefault()
handleContactChange(newContact);
handleHostnameChange(newHostname); 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))
}
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)
} }
}, [isEditMode, newContact, newHostname]);
return ( return (
<div className="container mx-auto p-4"> <div className="container mx-auto p-4">
<div className="flex justify-between items-center mb-8"> <h1 className="text-4xl font-bold text-center mb-8">LetsEncrypt Client Dashboard</h1>
<h1 className="text-4xl font-bold text-center">LetsEncrypt Client Dashboard</h1>
<button
onClick={() => setIsEditMode(!isEditMode)}
className="bg-blue-500 text-white px-3 py-1 rounded">
{isEditMode ? "View Mode" : "Edit Mode"}
</button>
</div>
{ {
accounts.map(account => ( accounts.map(account => (
<div key={account.accountId} className="bg-white shadow-lg rounded-lg p-6 mb-6"> <div key={account.accountId} className="bg-white shadow-lg rounded-lg p-6 mb-6">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-semibold">Account: {account.accountId}</h2> <h2 className="text-2xl font-semibold">Account: {account.accountId}</h2>
{isEditMode && ( <CustomButton onClick={() => toggleEditMode(account.accountId)} className="bg-blue-500 text-white px-3 py-1 rounded">
<button {account.isEditMode ? "View Mode" : "Edit Mode"}
onClick={() => deleteAccount(account.accountId)} </CustomButton>
className="bg-red-500 text-white px-3 py-1 rounded h-10"> </div>
Delete Account {account.isEditMode ? (
</button> <form onSubmit={(e) => handleSubmit(e, account.accountId)}>
)} <div className="mb-4">
<h3 className="text-xl font-medium mb-2">Description:</h3>
</div> </div>
<div className="mb-4"> <div className="mb-4">
<h3 className="text-xl font-medium mb-2">Contacts:</h3> <h3 className="text-xl font-medium mb-2">Contacts:</h3>
@ -150,34 +214,29 @@ export default function Page() {
account.contacts.map(contact => ( account.contacts.map(contact => (
<li key={contact} className="text-gray-700 flex justify-between items-center mb-2"> <li key={contact} className="text-gray-700 flex justify-between items-center mb-2">
{contact} {contact}
{isEditMode && ( <button onClick={() => deleteContact(account.accountId, contact)} className="bg-red-500 text-white px-2 py-1 rounded ml-4 h-10">
<button <TrashIcon className="h-5 w-5 text-white" />
onClick={() => deleteContact(account.accountId, contact)}
className="bg-red-500 text-white px-2 py-1 rounded ml-4 h-10">
Delete
</button> </button>
)}
</li> </li>
)) ))
} }
</ul> </ul>
{isEditMode && ( <div className="flex items-center mb-4">
<div className="flex mb-4"> <CustomInput
<input
type="text"
value={newContact} value={newContact}
onChange={(e) => handleContactChange(e.target.value)} onChange={handleContactChange}
className="border p-2 rounded mr-2 flex-grow h-10"
placeholder="Add new contact" placeholder="Add new contact"
type="email"
error={contactError}
title="New Contact"
inputClassName="border p-2 rounded w-full"
errorClassName="text-red-500 text-sm mt-1"
className="mr-2 flex-grow"
/> />
<button <button onClick={() => addContact(account.accountId)} className="bg-green-500 text-white p-2 rounded ml-2 h-10 flex items-center">
onClick={() => addContact(account.accountId)} <PlusIcon className="h-5 w-5 text-white" />
className="bg-blue-500 text-white px-3 py-1 rounded h-10">
Add Contact
</button> </button>
</div> </div>
)}
{isEditMode && contactError && <p className="text-red-500">{contactError}</p>}
</div> </div>
<div> <div>
<h3 className="text-xl font-medium mb-2">Hostnames:</h3> <h3 className="text-xl font-medium mb-2">Hostnames:</h3>
@ -191,38 +250,76 @@ export default function Page() {
{hostname.isUpcomingExpire ? 'Upcoming' : 'Not Upcoming'} {hostname.isUpcomingExpire ? 'Upcoming' : 'Not Upcoming'}
</span> </span>
</div> </div>
{isEditMode && ( <button onClick={() => deleteHostname(account.accountId, hostname.hostname)} className="bg-red-500 text-white px-2 py-1 rounded ml-4 h-10">
<button <TrashIcon className="h-5 w-5 text-white" />
onClick={() => deleteHostname(account.accountId, hostname.hostname)}
className="bg-red-500 text-white px-2 py-1 rounded ml-4 h-10">
Delete
</button> </button>
)}
</li> </li>
)) ))
} }
</ul> </ul>
{isEditMode && ( <div className="flex items-center">
<div className="flex"> <CustomInput
<input
type="text"
value={newHostname} value={newHostname}
onChange={(e) => handleHostnameChange(e.target.value)} onChange={handleHostnameChange}
className="border p-2 rounded mr-2 flex-grow h-10"
placeholder="Add new hostname" placeholder="Add new hostname"
type="text"
error={hostnameError}
title="New Hostname"
inputClassName="border p-2 rounded w-full"
errorClassName="text-red-500 text-sm mt-1"
className="mr-2 flex-grow"
/> />
<button <button onClick={() => addHostname(account.accountId)} className="bg-green-500 text-white p-2 rounded ml-2 h-10 flex items-center">
onClick={() => addHostname(account.accountId)} <PlusIcon className="h-5 w-5 text-white" />
className="bg-blue-500 text-white px-3 py-1 rounded h-10">
Add Hostname
</button> </button>
</div> </div>
)}
{isEditMode && hostnameError && <p className="text-red-500">{hostnameError}</p>}
</div> </div>
<div className="flex justify-between mt-4">
<button onClick={() => deleteAccount(account.accountId)} className="bg-red-500 text-white px-3 py-1 rounded">
<TrashIcon className="h-5 w-5 text-white" />
</button>
<CustomButton type="submit" className="bg-green-500 text-white px-3 py-1 rounded">
Submit
</CustomButton>
</div>
</form>
) : (
<>
<div className="mb-4">
<h3 className="text-xl font-medium mb-2">Description:</h3>
</div>
<div className="mb-4">
<h3 className="text-xl font-medium mb-2">Contacts:</h3>
<ul className="list-disc list-inside pl-4 mb-2">
{
account.contacts.map(contact => (
<li key={contact} className="text-gray-700 mb-2">
{contact}
</li>
))
}
</ul>
</div>
<div>
<h3 className="text-xl font-medium mb-2">Hostnames:</h3>
<ul className="list-disc list-inside pl-4 mb-2">
{
account.hostnames.map(hostname => (
<li key={hostname.hostname} className="text-gray-700 mb-2">
{hostname.hostname} - {hostname.expires.toDateString()} -
<span className={`ml-2 px-2 py-1 rounded ${hostname.isUpcomingExpire ? 'bg-yellow-200 text-yellow-800' : 'bg-green-200 text-green-800'}`}>
{hostname.isUpcomingExpire ? 'Upcoming' : 'Not Upcoming'}
</span>
</li>
))
}
</ul>
</div>
</>
)}
</div> </div>
)) ))
} }
</div> </div>
); )
} }

View File

@ -1,13 +1,13 @@
import React from 'react'; import React from 'react'
const Footer = () => { const Footer = () => {
return ( return (
<footer className="bg-gray-900 text-white text-center p-4"> <footer className="bg-gray-900 text-white text-center p-4">
<p>&copy; {new Date().getFullYear()} MAKS-IT</p> <p>&copy {new Date().getFullYear()} MAKS-IT</p>
</footer> </footer>
) )
} }
export { export {
Footer Footer
}; }

View File

@ -1,7 +1,33 @@
import React from 'react' // components/Loader.tsx
import './loader.css' // Add your loader styles here 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 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 ( return (
<div className="loader-overlay"> <div className="loader-overlay">
<div className="spinner"></div> <div className="spinner"></div>
@ -10,4 +36,6 @@ const Loader: React.FC = () => {
) )
} }
export default Loader export {
Loader
}

View File

@ -1,4 +1,4 @@
import React, { FC } from 'react'; import React, { FC } from 'react'
interface OffCanvasProps { interface OffCanvasProps {
isOpen: boolean isOpen: boolean

View File

@ -1,9 +1,9 @@
import React, { FC, useEffect, useRef } from 'react'; import React, { FC, useEffect, useRef } from 'react'
import { FaHome, FaUser, FaCog, FaBars } from 'react-icons/fa'; import { FaHome, FaUser, FaCog, FaBars } from 'react-icons/fa'
interface SideMenuProps { interface SideMenuProps {
isCollapsed: boolean; isCollapsed: boolean
toggleSidebar: () => void; toggleSidebar: () => void
} }
const SideMenu: FC<SideMenuProps> = ({ isCollapsed, toggleSidebar }) => { const SideMenu: FC<SideMenuProps> = ({ isCollapsed, toggleSidebar }) => {
@ -33,9 +33,9 @@ const SideMenu: FC<SideMenuProps> = ({ isCollapsed, toggleSidebar }) => {
</ul> </ul>
</nav> </nav>
</div> </div>
); )
}; }
export { export {
SideMenu SideMenu
}; }

View File

@ -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 (
<ToastContainer
position="bottom-right"
theme="dark"
autoClose={5000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
/>
)
}
export { Toast }

View File

@ -9,7 +9,7 @@ interface TopMenuProps {
} }
const TopMenu: FC<TopMenuProps> = ({ onToggleOffCanvas }) => { const TopMenu: FC<TopMenuProps> = ({ onToggleOffCanvas }) => {
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false)
const toggleMenu = () => { const toggleMenu = () => {
setIsMenuOpen(!isMenuOpen) setIsMenuOpen(!isMenuOpen)

View File

@ -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<CustomButtonProps> = (props) => {
const { onClick, className = '', children, disabled = false, type = 'button' } = props
return (
<button
onClick={onClick}
className={className}
disabled={disabled}
type={type}
>
{children}
</button>
)
}
export { CustomButton }

View File

@ -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<CustomInputProps> = ({
value,
onChange,
placeholder = '',
type = 'text',
error,
title,
inputClassName = '',
errorClassName = '',
className = ''
}) => {
return (
<div className={className}>
{title && <label>{title}</label>}
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={inputClassName}
/>
{error && <p className={errorClassName}>{error}</p>}
</div>
)
}
export { CustomInput }

View File

@ -0,0 +1,7 @@
import { CustomButton } from "./customButton"
import { CustomInput } from "./customInput"
export {
CustomButton,
CustomInput
}

View File

@ -1,37 +1,37 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react"
// Helper functions for validation // Helper functions for validation
const isValidEmail = (email: string) => { const isValidEmail = (email: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email); return emailRegex.test(email)
} }
const isValidHostname = (hostname: string) => { const isValidHostname = (hostname: string) => {
const hostnameRegex = /^(?!:\/\/)([a-zA-Z0-9-_]{1,63}\.?)+[a-zA-Z]{2,6}$/; const hostnameRegex = /^(?!:\/\/)([a-zA-Z0-9-_]{1,63}\.?)+[a-zA-Z]{2,6}$/
return hostnameRegex.test(hostname); return hostnameRegex.test(hostname)
} }
// Custom hook for input validation // Custom hook for input validation
const useValidation = (initialValue: string, validateFn: (value: string) => boolean, errorMessage: string) => { const useValidation = (initialValue: string, validateFn: (value: string) => boolean, errorMessage: string) => {
const [value, setValue] = useState(initialValue); const [value, setValue] = useState(initialValue)
const [error, setError] = useState(""); const [error, setError] = useState("")
const handleChange = (newValue: string) => { const handleChange = (newValue: string) => {
setValue(newValue); setValue(newValue)
if (newValue.trim() === "") { if (newValue.trim() === "") {
setError("This field cannot be empty."); setError("This field cannot be empty.")
} else if (!validateFn(newValue.trim())) { } else if (!validateFn(newValue.trim())) {
setError(errorMessage); setError(errorMessage)
} else { } else {
setError(""); setError("")
}
} }
};
useEffect(() => { useEffect(() => {
handleChange(initialValue); handleChange(initialValue)
}, [initialValue]); }, [initialValue])
return { value, error, handleChange }; return { value, error, handleChange }
}; }
export { useValidation, isValidEmail, isValidHostname }; export { useValidation, isValidEmail, isValidHostname }

View File

@ -8,12 +8,14 @@
"name": "my-nextjs-app", "name": "my-nextjs-app",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@heroicons/react": "^2.1.3",
"@reduxjs/toolkit": "^2.2.5", "@reduxjs/toolkit": "^2.2.5",
"next": "14.2.3", "next": "14.2.3",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-icons": "^5.2.1", "react-icons": "^5.2.1",
"react-redux": "^9.1.2" "react-redux": "^9.1.2",
"react-toastify": "^10.0.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
@ -106,6 +108,14 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "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": { "node_modules/@humanwhocodes/config-array": {
"version": "0.11.14", "version": "0.11.14",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", "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", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "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": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",

View File

@ -9,12 +9,14 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.1.3",
"@reduxjs/toolkit": "^2.2.5", "@reduxjs/toolkit": "^2.2.5",
"next": "14.2.3", "next": "14.2.3",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-icons": "^5.2.1", "react-icons": "^5.2.1",
"react-redux": "^9.1.2" "react-redux": "^9.1.2",
"react-toastify": "^10.0.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",

View File

@ -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

View File

@ -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

View File

@ -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<typeof store.getState>
export type AppDispatch = typeof store.dispatch

View File

@ -1,9 +1,13 @@
import { store } from '@/redux/store';
import { increment, decrement } from '@/redux/slices/loaderSlice';
import { showToast } from '@/redux/slices/toastSlice';
interface RequestInterceptor { interface RequestInterceptor {
(req: XMLHttpRequest): void; (req: XMLHttpRequest): void;
} }
interface ResponseInterceptor<T> { interface ResponseInterceptor<T> {
(response: T): T; (response: T | null, error: ProblemDetails | null): T | void;
} }
interface ProblemDetails { interface ProblemDetails {
@ -12,100 +16,144 @@ interface ProblemDetails {
Status: number; Status: number;
} }
class HttpService { interface HttpServiceCallbacks {
private requestInterceptors: Array<RequestInterceptor> = []; onIncrement?: () => void;
private responseInterceptors: Array<ResponseInterceptor<any>> = []; onDecrement?: () => void;
onShowToast?: (message: string, type: 'info' | 'error') => void;
}
private request<TResponse>(method: string, url: string, data?: any): Promise<TResponse> { class HttpService {
return new Promise((resolve, reject) => { private requestInterceptors: RequestInterceptor[] = [];
private responseInterceptors: Array<ResponseInterceptor<any>> = [];
private callbacks: HttpServiceCallbacks;
constructor(callbacks: HttpServiceCallbacks) {
this.callbacks = callbacks;
}
private invokeIncrement(): void {
this.callbacks.onIncrement?.();
}
private invokeDecrement(): void {
this.callbacks.onDecrement?.();
}
private invokeShowToast(message: string, type: 'info' | 'error'): void {
this.callbacks.onShowToast?.(message, type);
}
private async request<TResponse>(method: string, url: string, data?: any): Promise<TResponse | null> {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open(method, url); xhr.open(method, url);
// Apply request interceptors this.handleRequestInterceptors(xhr);
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;
}
});
// Set Content-Type header for JSON data
if (data && typeof data !== 'string') { if (data && typeof data !== 'string') {
xhr.setRequestHeader('Content-Type', 'application/json'); xhr.setRequestHeader('Content-Type', 'application/json');
} }
xhr.onload = () => { this.invokeIncrement();
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;
}
// 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;
}
resolve(response);
} else {
const problemDetails: ProblemDetails = {
Title: xhr.statusText,
Detail: xhr.responseText,
Status: xhr.status
};
reject(problemDetails);
}
};
xhr.onerror = () => {
const problemDetails: ProblemDetails = {
Title: 'Network Error',
Detail: null,
Status: 0
};
reject(problemDetails);
};
return new Promise<TResponse | null>((resolve) => {
xhr.onload = () => this.handleLoad<TResponse>(xhr, resolve);
xhr.onerror = () => this.handleNetworkError(resolve);
xhr.send(data ? JSON.stringify(data) : null); xhr.send(data ? JSON.stringify(data) : null);
}); });
} }
public get<TResponse>(url: string): Promise<TResponse> { private handleRequestInterceptors(xhr: XMLHttpRequest): void {
return this.request<TResponse>('GET', url); this.requestInterceptors.forEach(interceptor => {
try {
interceptor(xhr);
} catch (error) {
const problemDetails = this.createProblemDetails('Request Interceptor Error', error, 0);
this.showProblemDetails(problemDetails);
}
});
} }
public post<TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse> { private handleResponseInterceptors<TResponse>(response: TResponse | null, error: ProblemDetails | null): TResponse | null {
return this.request<TResponse>('POST', url, data); 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<TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse> { private handleLoad<TResponse>(xhr: XMLHttpRequest, resolve: (value: TResponse | null) => void): void {
return this.request<TResponse>('PUT', url, data); this.invokeDecrement();
if (xhr.status >= 200 && xhr.status < 300) {
this.handleSuccessfulResponse<TResponse>(xhr, resolve);
} else {
this.handleErrorResponse(xhr, resolve);
}
} }
public delete<TResponse>(url: string): Promise<TResponse> { private handleSuccessfulResponse<TResponse>(xhr: XMLHttpRequest, resolve: (value: TResponse | null) => void): void {
return this.request<TResponse>('DELETE', url); 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<TResponse>(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<TResponse>(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<TResponse>(url: string): Promise<TResponse | null> {
return await this.request<TResponse>('GET', url);
}
public async post<TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse | null> {
return await this.request<TResponse>('POST', url, data);
}
public async put<TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse | null> {
return await this.request<TResponse>('PUT', url, data);
}
public async delete<TResponse>(url: string): Promise<TResponse | null> {
return await this.request<TResponse>('DELETE', url);
} }
public addRequestInterceptor(interceptor: RequestInterceptor): void { public addRequestInterceptor(interceptor: RequestInterceptor): void {
@ -117,19 +165,32 @@ class HttpService {
} }
} }
// Instance of HttpService
const httpService = new HttpService(); const httpService = new HttpService({
onIncrement: () => store.dispatch(increment()),
httpService.addRequestInterceptor(xhr => { 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; return response;
}); });
export { httpService };
// Example usage of the httpService
// async function fetchData() {
// const data = await httpService.get<any>('/api/data');
// if (data) {
// console.log('Data received:', data);
// } else {
// console.error('Failed to fetch data');
// }
// }
export {
httpService
}

View File

@ -13,11 +13,14 @@ public class CertificateCache {
public class RegistrationCache { public class RegistrationCache {
#region Custom Properties
/// <summary> /// <summary>
/// Field used to identify cache by account id /// Field used to identify cache by account id
/// </summary> /// </summary>
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public string? Description { get; set; }
public string[]? Contacts { get; set; } public string[]? Contacts { get; set; }
#endregion
public Dictionary<string, CertificateCache>? CachedCerts { get; set; } public Dictionary<string, CertificateCache>? CachedCerts { get; set; }

View File

@ -1,6 +1,4 @@
 using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using DomainResults.Mvc; using DomainResults.Mvc;
@ -12,44 +10,58 @@ namespace MaksIT.LetsEncryptServer.Controllers;
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
public class CacheController { public class CacheController : ControllerBase {
private readonly Configuration _appSettings; private readonly Configuration _appSettings;
private readonly ICacheService _cacheService; private readonly ICacheRestService _cacheService;
public CacheController( public CacheController(
IOptions<Configuration> appSettings, IOptions<Configuration> appSettings,
ICacheService cacheService ICacheService cacheService
) { ) {
_appSettings = appSettings.Value; _appSettings = appSettings.Value;
_cacheService = cacheService; _cacheService = (ICacheRestService)cacheService;
} }
[HttpGet("accounts")]
[HttpGet("[action]")]
public async Task<IActionResult> GetAccounts() { public async Task<IActionResult> GetAccounts() {
var result = await _cacheService.GetAccountsAsync(); var result = await _cacheService.GetAccountsAsync();
return result.ToActionResult(); return result.ToActionResult();
} }
[HttpGet("[action]/{accountId}")] #region Contacts
[HttpGet("{accountId}/contacts")]
public async Task<IActionResult> GetContacts(Guid accountId) { public async Task<IActionResult> GetContacts(Guid accountId) {
var result = await _cacheService.GetContactsAsync(accountId); var result = await _cacheService.GetContactsAsync(accountId);
return result.ToActionResult(); return result.ToActionResult();
} }
[HttpPut("{accountId}/contacts")]
[HttpPost("[action]/{accountId}")] public async Task<IActionResult> PutContacts(Guid accountId, [FromBody] PutContactsRequest requestData) {
public async Task<IActionResult> SetContacts(Guid accountId, [FromBody] SetContactsRequest requestData) { var result = await _cacheService.PutContactsAsync(accountId, requestData);
var result = await _cacheService.SetContactsAsync(accountId, requestData);
return result.ToActionResult(); return result.ToActionResult();
} }
[HttpGet("[action]/{accountId}")] [HttpPatch("{accountId}/contacts")]
public async Task<IActionResult> PatchContacts(Guid accountId, [FromBody] PatchContactRequest requestData) {
var result = await _cacheService.PatchContactsAsync(accountId, requestData);
return result.ToActionResult();
}
[HttpDelete("{accountId}/contacts/{index}")]
public async Task<IActionResult> DeleteContact(Guid accountId, int index) {
var result = await _cacheService.DeleteContactAsync(accountId, index);
return result.ToActionResult();
}
#endregion
#region Hostnames
[HttpGet("{accountId}/hostnames")]
public async Task<IActionResult> GetHostnames(Guid accountId) { public async Task<IActionResult> GetHostnames(Guid accountId) {
var result = await _cacheService.GetHostnames(accountId); var result = await _cacheService.GetHostnames(accountId);
return result.ToActionResult(); return result.ToActionResult();
} }
}
#endregion
}

View File

@ -1,26 +1,21 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using DomainResults.Mvc; using DomainResults.Mvc;
using MaksIT.LetsEncryptServer.Services; using MaksIT.LetsEncryptServer.Services;
using Models.LetsEncryptServer.CertsFlow.Requests; using Models.LetsEncryptServer.CertsFlow.Requests;
namespace MaksIT.LetsEncryptServer.Controllers {
namespace MaksIT.LetsEncryptServer.Controllers; [ApiController]
[Route("api/[controller]")]
[ApiController] public class CertsFlowController : ControllerBase {
[Route("api/[controller]")] private readonly Configuration _appSettings;
public class CertsFlowController : ControllerBase {
private readonly IOptions<Configuration> _appSettings;
private readonly ICertsFlowService _certsFlowService; private readonly ICertsFlowService _certsFlowService;
public CertsFlowController( public CertsFlowController(
IOptions<Configuration> appSettings, IOptions<Configuration> appSettings,
ICertsFlowService certsFlowService ICertsFlowService certsFlowService
) { ) {
_appSettings = appSettings; _appSettings = appSettings.Value;
_certsFlowService = certsFlowService; _certsFlowService = certsFlowService;
} }
@ -28,99 +23,93 @@ public class CertsFlowController : ControllerBase {
/// Initialize certificate flow session /// Initialize certificate flow session
/// </summary> /// </summary>
/// <returns>sessionId</returns> /// <returns>sessionId</returns>
[HttpPost("[action]")] [HttpPost("configure-client")]
public async Task<IActionResult> ConfigureClient() { public async Task<IActionResult> ConfigureClient() {
var result = await _certsFlowService.ConfigureClientAsync(); var result = await _certsFlowService.ConfigureClientAsync();
return result.ToActionResult(); return result.ToActionResult();
} }
[HttpGet("[action]/{sessionId}")] /// <summary>
/// Retrieves terms of service
/// </summary>
/// <param name="sessionId">Session ID</param>
/// <returns>Terms of service</returns>
[HttpGet("terms-of-service/{sessionId}")]
public IActionResult TermsOfService(Guid sessionId) { public IActionResult TermsOfService(Guid sessionId) {
var result = _certsFlowService.GetTermsOfService(sessionId); var result = _certsFlowService.GetTermsOfService(sessionId);
return result.ToActionResult(); return result.ToActionResult();
} }
/// <summary> /// <summary>
/// When new certificate session is created, create or retrieve cache data by accountId /// When a new certificate session is created, create or retrieve cache data by accountId
/// </summary> /// </summary>
/// <param name="sessionId"></param> /// <param name="sessionId">Session ID</param>
/// <param name="accountId"></param> /// <param name="accountId">Account ID</param>
/// <param name="requestData"></param> /// <param name="requestData">Request data</param>
/// <returns>accountId</returns> /// <returns>Account ID</returns>
[HttpPost("[action]/{sessionId}/{accountId?}")] [HttpPost("{sessionId}/init/{accountId?}")]
public async Task<IActionResult> Init(Guid sessionId, Guid? accountId, [FromBody] InitRequest requestData) { public async Task<IActionResult> Init(Guid sessionId, Guid? accountId, [FromBody] InitRequest requestData) {
var resurt = await _certsFlowService.InitAsync(sessionId, accountId, requestData); var result = await _certsFlowService.InitAsync(sessionId, accountId, requestData);
return resurt.ToActionResult(); return result.ToActionResult();
} }
/// <summary> /// <summary>
/// After account initialization create new order request /// After account initialization, create a new order request
/// </summary> /// </summary>
/// <param name="sessionId"></param> /// <param name="sessionId">Session ID</param>
/// <param name="requestData"></param> /// <param name="requestData">Request data</param>
/// <returns></returns> /// <returns>New order response</returns>
[HttpPost("[action]/{sessionId}")] [HttpPost("{sessionId}/order")]
public async Task<IActionResult> NewOrder(Guid sessionId, [FromBody] NewOrderRequest requestData) { public async Task<IActionResult> NewOrder(Guid sessionId, [FromBody] NewOrderRequest requestData) {
var result = await _certsFlowService.NewOrderAsync(sessionId, requestData); var result = await _certsFlowService.NewOrderAsync(sessionId, requestData);
return result.ToActionResult(); return result.ToActionResult();
} }
/// <summary> /// <summary>
/// After new order request complete challenges /// Complete challenges for the new order
/// </summary> /// </summary>
/// <param name="sessionId"></param> /// <param name="sessionId">Session ID</param>
/// <returns></returns> /// <returns>Challenges completion response</returns>
[HttpPost("[action]/{sessionId}")] [HttpPost("{sessionId}/complete-challenges")]
public async Task<IActionResult> CompleteChallenges(Guid sessionId) { public async Task<IActionResult> CompleteChallenges(Guid sessionId) {
var result = await _certsFlowService.CompleteChallengesAsync(sessionId); var result = await _certsFlowService.CompleteChallengesAsync(sessionId);
return result.ToActionResult(); return result.ToActionResult();
} }
/// <summary> /// <summary>
/// Get order status before certs retrieval /// Get order status before certificate retrieval
/// </summary> /// </summary>
/// <param name="sessionId"></param> /// <param name="sessionId">Session ID</param>
/// <param name="requestData"></param> /// <param name="requestData">Request data</param>
/// <returns></returns> /// <returns>Order status</returns>
[HttpPost("[action]/{sessionId}")] [HttpGet("{sessionId}/order-status")]
public async Task<IActionResult> GetOrder(Guid sessionId, [FromBody] GetOrderRequest requestData) { public async Task<IActionResult> GetOrder(Guid sessionId, [FromBody] GetOrderRequest requestData) {
var result = await _certsFlowService.GetOrderAsync(sessionId, requestData); var result = await _certsFlowService.GetOrderAsync(sessionId, requestData);
return result.ToActionResult(); return result.ToActionResult();
} }
/// <summary> /// <summary>
/// Download certs to local cache /// Download certificates to local cache
/// </summary> /// </summary>
/// <param name="sessionId"></param> /// <param name="sessionId">Session ID</param>
/// <param name="requestData"></param> /// <param name="requestData">Request data</param>
/// <returns></returns> /// <returns>Certificates download response</returns>
[HttpPost("[action]/{sessionId}")] [HttpPost("{sessionId}/certificates/download")]
public async Task<IActionResult> GetCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) { public async Task<IActionResult> GetCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) {
var result = await _certsFlowService.GetCertificatesAsync(sessionId, requestData); var result = await _certsFlowService.GetCertificatesAsync(sessionId, requestData);
return result.ToActionResult(); return result.ToActionResult();
} }
/// <summary> /// <summary>
/// Apply certs from local cache to remote server /// Apply certificates from local cache to remote server
/// </summary> /// </summary>
/// <param name="sessionId"></param> /// <param name="sessionId">Session ID</param>
/// <param name="requestData"></param> /// <param name="requestData">Request data</param>
/// <returns></returns> /// <returns>Certificates application response</returns>
[HttpPost("[action]/{sessionId}")] [HttpPost("{sessionId}/certificates/apply")]
public async Task<IActionResult> ApplyCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) { public async Task<IActionResult> ApplyCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) {
var result = await _certsFlowService.ApplyCertificatesAsync(sessionId, requestData); var result = await _certsFlowService.ApplyCertificatesAsync(sessionId, requestData);
return result.ToActionResult(); return result.ToActionResult();
} }
/// <summary>
/// Returns a list of hosts with upcoming SSL expiry
/// </summary>
/// <param name="sessionId"></param>
/// <returns></returns>
[HttpGet("[action]/{sessionId}")]
public IActionResult HostsWithUpcomingSslExpiry(Guid sessionId) {
var result = _certsFlowService.HostsWithUpcomingSslExpiry(sessionId);
return result.ToActionResult();
} }
} }

View File

@ -5,7 +5,9 @@ using System.Text.Json;
using DomainResults.Common; using DomainResults.Common;
using MaksIT.Core.Extensions; using MaksIT.Core.Extensions;
using MaksIT.LetsEncrypt.Entities; using MaksIT.LetsEncrypt.Entities;
using MaksIT.Models;
using MaksIT.Models.LetsEncryptServer.Cache.Requests; using MaksIT.Models.LetsEncryptServer.Cache.Requests;
using MaksIT.Models.LetsEncryptServer.Cache.Responses;
using Models.LetsEncryptServer.Cache.Responses; using Models.LetsEncryptServer.Cache.Responses;
namespace MaksIT.LetsEncryptServer.Services; namespace MaksIT.LetsEncryptServer.Services;
@ -14,14 +16,25 @@ public interface ICacheService {
Task<(RegistrationCache?, IDomainResult)> LoadFromCacheAsync(Guid accountId); Task<(RegistrationCache?, IDomainResult)> LoadFromCacheAsync(Guid accountId);
Task<IDomainResult> SaveToCacheAsync(Guid accountId, RegistrationCache cache); Task<IDomainResult> SaveToCacheAsync(Guid accountId, RegistrationCache cache);
Task<IDomainResult> DeleteFromCacheAsync(Guid accountId); Task<IDomainResult> DeleteFromCacheAsync(Guid accountId);
Task<(GetAccountsResponse?, IDomainResult)> GetAccountsAsync();
Task<(GetContactsResponse?, IDomainResult)> GetContactsAsync(Guid accountId);
Task<IDomainResult> 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<IDomainResult> DeleteContactAsync(Guid accountId, int index);
#endregion
#region Hostnames
Task<(GetHostnamesResponse?, IDomainResult)> GetHostnames(Guid accountId);
#endregion
}
public class CacheService : ICacheService, ICacheRestService, IDisposable {
private readonly ILogger<CacheService> _logger; private readonly ILogger<CacheService> _logger;
private readonly string _cacheDirectory; private readonly string _cacheDirectory;
@ -126,6 +139,9 @@ public class CacheService : ICacheService, IDisposable {
} }
} }
#region RestService
public async Task<(GetAccountsResponse?, IDomainResult)> GetAccountsAsync() { public async Task<(GetAccountsResponse?, IDomainResult)> GetAccountsAsync() {
await _cacheLock.WaitAsync(); await _cacheLock.WaitAsync();
@ -133,13 +149,22 @@ public class CacheService : ICacheService, IDisposable {
var cacheFiles = Directory.GetFiles(_cacheDirectory); var cacheFiles = Directory.GetFiles(_cacheDirectory);
if (cacheFiles == null) if (cacheFiles == null)
return IDomainResult.Success(new GetAccountsResponse { return IDomainResult.Success(new GetAccountsResponse {
AccountIds = Array.Empty<Guid>() Accounts = Array.Empty<GetAccountResponse>()
}); });
var accountIds = cacheFiles.Select(x => Path.GetFileNameWithoutExtension(x).ToGuid()).ToArray(); var accountIds = cacheFiles.Select(x => Path.GetFileNameWithoutExtension(x).ToGuid());
var accounts = new List<GetAccountResponse>();
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 { return IDomainResult.Success(new GetAccountsResponse {
AccountIds = accountIds Accounts = accounts.ToArray()
}); });
} }
catch (Exception ex) { 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<GetAccountResponse?>(message);
}
finally {
_cacheLock.Release();
}
}
#region Contacts
/// <summary>
/// Retrieves the contacts list for the account.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <returns>The contacts list and domain result.</returns>
public async Task<(GetContactsResponse?, IDomainResult)> GetContactsAsync(Guid accountId) { public async Task<(GetContactsResponse?, IDomainResult)> GetContactsAsync(Guid accountId) {
var (cache, loadResult) = await LoadFromCacheAsync(accountId); var (cache, loadResult) = await LoadFromCacheAsync(accountId);
if (!loadResult.IsSuccess || cache == null) if (!loadResult.IsSuccess || cache == null)
@ -163,16 +222,132 @@ public class CacheService : ICacheService, IDisposable {
}); });
} }
/// <summary>
/// Adds new contacts to the account. This method initializes the contacts list if it is null.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <param name="requestData">The request containing the contacts to add.</param>
/// <returns>The updated account response and domain result.</returns>
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<IDomainResult> SetContactsAsync(Guid accountId, SetContactsRequest requestData) { var contacts = cache.Contacts?.ToList() ?? new List<string>();
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());
}
/// <summary>
/// Replaces the entire contacts list for the account.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <param name="requestData">The request containing the new contacts list.</param>
/// <returns>The updated account response and domain result.</returns>
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());
}
/// <summary>
/// Partially updates the contacts list for the account. Supports add, replace, and remove operations.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <param name="requestData">The request containing the patch operations for contacts.</param>
/// <returns>The updated account response and domain result.</returns>
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<string>();
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());
}
/// <summary>
/// Deletes a contact from the account by index.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <param name="index">The index of the contact to remove.</param>
/// <returns>The domain result indicating success or failure.</returns>
public async Task<IDomainResult> DeleteContactAsync(Guid accountId, int index) {
var (cache, loadResult) = await LoadFromCacheAsync(accountId); var (cache, loadResult) = await LoadFromCacheAsync(accountId);
if (!loadResult.IsSuccess || cache == null) if (!loadResult.IsSuccess || cache == null)
return loadResult; return loadResult;
cache.Contacts = requestData.Contacts; var contacts = cache.Contacts?.ToList() ?? new List<string>();
return await SaveToCacheAsync(accountId, cache);
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) { public async Task<(GetHostnamesResponse?, IDomainResult)> GetHostnames(Guid accountId) {
var (cache, loadResult) = await LoadFromCacheAsync(accountId); var (cache, loadResult) = await LoadFromCacheAsync(accountId);
if (!loadResult.IsSuccess || cache?.CachedCerts == null) if (!loadResult.IsSuccess || cache?.CachedCerts == null)
@ -199,6 +374,9 @@ public class CacheService : ICacheService, IDisposable {
return IDomainResult.Success(response); return IDomainResult.Success(response);
} }
#endregion
#endregion
public void Dispose() { public void Dispose() {

View File

@ -24,8 +24,7 @@ public interface ICertsFlowService : ICertsFlowServiceBase {
Task<IDomainResult> GetOrderAsync(Guid sessionId, GetOrderRequest requestData); Task<IDomainResult> GetOrderAsync(Guid sessionId, GetOrderRequest requestData);
Task<IDomainResult> GetCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData); Task<IDomainResult> GetCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData);
Task<(Dictionary<string, string>?, IDomainResult)> ApplyCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData); Task<(Dictionary<string, string>?, IDomainResult)> ApplyCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData);
(string[]?, IDomainResult) HostsWithUpcomingSslExpiry(Guid sessionId); }
}
public class CertsFlowService : ICertsFlowService { 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);
}

View File

@ -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<PatchAction<string>> Contacts { get; set; }
}
}

View File

@ -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<ValidationResult> Validate(ValidationContext validationContext) {
if (Contacts == null || Contacts.Length == 0)
yield return new ValidationResult("Contacts is required", new[] { nameof(Contacts) });
}
}
}

View File

@ -2,7 +2,7 @@
namespace MaksIT.Models.LetsEncryptServer.Cache.Requests { namespace MaksIT.Models.LetsEncryptServer.Cache.Requests {
public class SetContactsRequest : IValidatableObject { public class PutContactsRequest : IValidatableObject {
public required string[] Contacts { get; set; } public required string[] Contacts { get; set; }

View File

@ -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; }
}
}

View File

@ -1,11 +1,12 @@
using System; using Models.LetsEncryptServer.Cache.Responses;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace Models.LetsEncryptServer.Cache.Responses { namespace MaksIT.Models.LetsEncryptServer.Cache.Responses {
public class GetAccountsResponse { public class GetAccountsResponse {
public Guid[] AccountIds { get; set; } public GetAccountResponse[] Accounts { get; set; }
} }
} }

13
src/Models/PatchAction.cs Normal file
View File

@ -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<T> {
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
}
}

View File

@ -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
}
}