(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 {
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}`,

View File

@ -1,12 +1,10 @@
import Layout from "../layout";
const AboutPage = () => {
return (
<>
<h1 className="text-2xl font-bold">About</h1>
<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 = () => {
return (<>
<h1 className="text-2xl font-bold">Contact Us</h1>
<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,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 (
<html lang="en">
<body className="h-screen overflow-hidden">
{isLoading && <Loader />} {/* Show loader if isLoading is true */}
<Provider store={store}>
<Loader />
<div className="flex h-full">
<SideMenu isCollapsed={isSidebarCollapsed} toggleSidebar={manuallyToggleSidebar} />
<div className="flex flex-col flex-1">
<TopMenu onToggleOffCanvas={toggleOffCanvas} />
<main className="flex-1 p-4 transition-transform duration-300">
{children}
</main>
<Footer />
<div className="flex h-full">
<SideMenu isCollapsed={isSidebarCollapsed} toggleSidebar={manuallyToggleSidebar} />
<div className="flex flex-col flex-1">
<TopMenu onToggleOffCanvas={toggleOffCanvas} />
<main className="flex-1 p-4 transition-transform duration-300">
{children}
</main>
<Footer />
</div>
</div>
</div>
<OffCanvas isOpen={isOffCanvasOpen} onClose={toggleOffCanvas} />
<OffCanvas isOpen={isOffCanvasOpen} onClose={toggleOffCanvas} />
<Toast />
</Provider>
</body>
</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 { 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<CacheAccount[]>([]);
const [isEditMode, setIsEditMode] = useState(false);
const [accounts, setAccounts] = useState<CacheAccount[]>([])
const [initialAccounts, setInitialAccounts] = useState<CacheAccount[]>([])
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<GetAccountsResponse>(GetApiRoute(ApiRoutes.CACHE_GET_ACCOUNTS))
const accountsResponse = await httpService.get<GetAccountsResponse>(GetApiRoute(ApiRoutes.CACHE_GET_ACCOUNTS));
for (const accountId of accountsResponse.accountIds) {
const contactsResponse = await httpService.get<GetContactsResponse>(GetApiRoute(ApiRoutes.CACHE_GET_CONTACTS, accountId));
const hostnamesResponse = await httpService.get<GetHostnamesResponse>(GetApiRoute(ApiRoutes.CACHE_GET_HOSTNAMES, accountId));
const [contactsResponse, hostnamesResponse] = await Promise.all([
httpService.get<GetContactsResponse>(GetApiRoute(ApiRoutes.CACHE_GET_CONTACTS, accountId)),
httpService.get<GetHostnamesResponse>(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<HTMLFormElement>, 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 (
<div className="container mx-auto p-4">
<div className="flex justify-between items-center mb-8">
<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>
<h1 className="text-4xl font-bold text-center mb-8">LetsEncrypt Client Dashboard</h1>
{
accounts.map(account => (
<div key={account.accountId} className="bg-white shadow-lg rounded-lg p-6 mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-semibold">Account: {account.accountId}</h2>
{isEditMode && (
<button
onClick={() => deleteAccount(account.accountId)}
className="bg-red-500 text-white px-3 py-1 rounded h-10">
Delete Account
</button>
)}
<CustomButton onClick={() => toggleEditMode(account.accountId)} className="bg-blue-500 text-white px-3 py-1 rounded">
{account.isEditMode ? "View Mode" : "Edit Mode"}
</CustomButton>
</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 flex justify-between items-center mb-2">
{contact}
{isEditMode && (
<button
onClick={() => deleteContact(account.accountId, contact)}
className="bg-red-500 text-white px-2 py-1 rounded ml-4 h-10">
Delete
</button>
)}
</li>
))
}
</ul>
{isEditMode && (
<div className="flex mb-4">
<input
type="text"
value={newContact}
onChange={(e) => handleContactChange(e.target.value)}
className="border p-2 rounded mr-2 flex-grow h-10"
placeholder="Add new contact"
/>
<button
onClick={() => addContact(account.accountId)}
className="bg-blue-500 text-white px-3 py-1 rounded h-10">
Add Contact
</button>
{account.isEditMode ? (
<form onSubmit={(e) => handleSubmit(e, account.accountId)}>
<div className="mb-4">
<h3 className="text-xl font-medium mb-2">Description:</h3>
</div>
)}
{isEditMode && contactError && <p className="text-red-500">{contactError}</p>}
</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 flex justify-between items-center mb-2">
<div>
{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>
</div>
{isEditMode && (
<button
onClick={() => deleteHostname(account.accountId, hostname.hostname)}
className="bg-red-500 text-white px-2 py-1 rounded ml-4 h-10">
Delete
</button>
)}
</li>
))
}
</ul>
{isEditMode && (
<div className="flex">
<input
type="text"
value={newHostname}
onChange={(e) => handleHostnameChange(e.target.value)}
className="border p-2 rounded mr-2 flex-grow h-10"
placeholder="Add new hostname"
/>
<button
onClick={() => addHostname(account.accountId)}
className="bg-blue-500 text-white px-3 py-1 rounded h-10">
Add Hostname
</button>
<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 flex justify-between items-center mb-2">
{contact}
<button onClick={() => deleteContact(account.accountId, contact)} className="bg-red-500 text-white px-2 py-1 rounded ml-4 h-10">
<TrashIcon className="h-5 w-5 text-white" />
</button>
</li>
))
}
</ul>
<div className="flex items-center mb-4">
<CustomInput
value={newContact}
onChange={handleContactChange}
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 onClick={() => addContact(account.accountId)} className="bg-green-500 text-white p-2 rounded ml-2 h-10 flex items-center">
<PlusIcon className="h-5 w-5 text-white" />
</button>
</div>
</div>
)}
{isEditMode && hostnameError && <p className="text-red-500">{hostnameError}</p>}
</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 flex justify-between items-center mb-2">
<div>
{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>
</div>
<button onClick={() => deleteHostname(account.accountId, hostname.hostname)} className="bg-red-500 text-white px-2 py-1 rounded ml-4 h-10">
<TrashIcon className="h-5 w-5 text-white" />
</button>
</li>
))
}
</ul>
<div className="flex items-center">
<CustomInput
value={newHostname}
onChange={handleHostnameChange}
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 onClick={() => addHostname(account.accountId)} className="bg-green-500 text-white p-2 rounded ml-2 h-10 flex items-center">
<PlusIcon className="h-5 w-5 text-white" />
</button>
</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>
);
)
}

View File

@ -1,13 +1,13 @@
import React from 'react';
import React from 'react'
const Footer = () => {
return (
<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>
)
}
export {
Footer
};
}

View File

@ -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 (
<div className="loader-overlay">
<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 {
isOpen: boolean

View File

@ -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<SideMenuProps> = ({ isCollapsed, toggleSidebar }) => {
@ -33,9 +33,9 @@ const SideMenu: FC<SideMenuProps> = ({ isCollapsed, toggleSidebar }) => {
</ul>
</nav>
</div>
);
};
)
}
export {
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 [isMenuOpen, setIsMenuOpen] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false)
const toggleMenu = () => {
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
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 }

View File

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

View File

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

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 {
(req: XMLHttpRequest): void;
}
interface ResponseInterceptor<T> {
(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<RequestInterceptor> = [];
private requestInterceptors: RequestInterceptor[] = [];
private responseInterceptors: Array<ResponseInterceptor<any>> = [];
private callbacks: HttpServiceCallbacks;
private request<TResponse>(method: string, url: string, data?: any): Promise<TResponse> {
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<TResponse>(method: string, url: string, data?: any): Promise<TResponse | null> {
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<TResponse | null>((resolve) => {
xhr.onload = () => this.handleLoad<TResponse>(xhr, resolve);
xhr.onerror = () => this.handleNetworkError(resolve);
xhr.send(data ? JSON.stringify(data) : null);
});
}
public get<TResponse>(url: string): Promise<TResponse> {
return this.request<TResponse>('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<TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse> {
return this.request<TResponse>('POST', url, data);
private handleResponseInterceptors<TResponse>(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<TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse> {
return this.request<TResponse>('PUT', url, data);
private handleLoad<TResponse>(xhr: XMLHttpRequest, resolve: (value: TResponse | null) => void): void {
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> {
return this.request<TResponse>('DELETE', url);
private handleSuccessfulResponse<TResponse>(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<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 {
@ -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<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 {
#region Custom Properties
/// <summary>
/// Field used to identify cache by account id
/// </summary>
public Guid AccountId { get; set; }
public string? Description { get; set; }
public string[]? Contacts { get; set; }
#endregion
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 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<Configuration> appSettings,
ICacheService cacheService
IOptions<Configuration> appSettings,
ICacheService cacheService
) {
_appSettings = appSettings.Value;
_cacheService = cacheService;
_cacheService = (ICacheRestService)cacheService;
}
[HttpGet("[action]")]
[HttpGet("accounts")]
public async Task<IActionResult> GetAccounts() {
var result = await _cacheService.GetAccountsAsync();
return result.ToActionResult();
}
[HttpGet("[action]/{accountId}")]
#region Contacts
[HttpGet("{accountId}/contacts")]
public async Task<IActionResult> GetContacts(Guid accountId) {
var result = await _cacheService.GetContactsAsync(accountId);
return result.ToActionResult();
}
[HttpPost("[action]/{accountId}")]
public async Task<IActionResult> SetContacts(Guid accountId, [FromBody] SetContactsRequest requestData) {
var result = await _cacheService.SetContactsAsync(accountId, requestData);
[HttpPut("{accountId}/contacts")]
public async Task<IActionResult> 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<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) {
var result = await _cacheService.GetHostnames(accountId);
return result.ToActionResult();
}
}
#endregion
}

View File

@ -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<Configuration> appSettings,
ICertsFlowService certsFlowService
) {
_appSettings = appSettings.Value;
_certsFlowService = certsFlowService;
}
[ApiController]
[Route("api/[controller]")]
public class CertsFlowController : ControllerBase {
/// <summary>
/// Initialize certificate flow session
/// </summary>
/// <returns>sessionId</returns>
[HttpPost("configure-client")]
public async Task<IActionResult> ConfigureClient() {
var result = await _certsFlowService.ConfigureClientAsync();
return result.ToActionResult();
}
private readonly IOptions<Configuration> _appSettings;
private readonly ICertsFlowService _certsFlowService;
/// <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) {
var result = _certsFlowService.GetTermsOfService(sessionId);
return result.ToActionResult();
}
public CertsFlowController(
IOptions<Configuration> appSettings,
ICertsFlowService certsFlowService
) {
_appSettings = appSettings;
_certsFlowService = certsFlowService;
}
/// <summary>
/// When a new certificate session is created, create or retrieve cache data by accountId
/// </summary>
/// <param name="sessionId">Session ID</param>
/// <param name="accountId">Account ID</param>
/// <param name="requestData">Request data</param>
/// <returns>Account ID</returns>
[HttpPost("{sessionId}/init/{accountId?}")]
public async Task<IActionResult> Init(Guid sessionId, Guid? accountId, [FromBody] InitRequest requestData) {
var result = await _certsFlowService.InitAsync(sessionId, accountId, requestData);
return result.ToActionResult();
}
/// <summary>
/// Initialize certificate flow session
/// </summary>
/// <returns>sessionId</returns>
[HttpPost("[action]")]
public async Task<IActionResult> ConfigureClient() {
var result = await _certsFlowService.ConfigureClientAsync();
return result.ToActionResult();
}
/// <summary>
/// After account initialization, create a new order request
/// </summary>
/// <param name="sessionId">Session ID</param>
/// <param name="requestData">Request data</param>
/// <returns>New order response</returns>
[HttpPost("{sessionId}/order")]
public async Task<IActionResult> 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();
}
/// <summary>
/// Complete challenges for the new order
/// </summary>
/// <param name="sessionId">Session ID</param>
/// <returns>Challenges completion response</returns>
[HttpPost("{sessionId}/complete-challenges")]
public async Task<IActionResult> CompleteChallenges(Guid sessionId) {
var result = await _certsFlowService.CompleteChallengesAsync(sessionId);
return result.ToActionResult();
}
/// <summary>
/// When new certificate session is created, create or retrieve cache data by accountId
/// </summary>
/// <param name="sessionId"></param>
/// <param name="accountId"></param>
/// <param name="requestData"></param>
/// <returns>accountId</returns>
[HttpPost("[action]/{sessionId}/{accountId?}")]
public async Task<IActionResult> Init(Guid sessionId, Guid? accountId, [FromBody] InitRequest requestData) {
var resurt = await _certsFlowService.InitAsync(sessionId, accountId, requestData);
return resurt.ToActionResult();
}
/// <summary>
/// Get order status before certificate retrieval
/// </summary>
/// <param name="sessionId">Session ID</param>
/// <param name="requestData">Request data</param>
/// <returns>Order status</returns>
[HttpGet("{sessionId}/order-status")]
public async Task<IActionResult> GetOrder(Guid sessionId, [FromBody] GetOrderRequest requestData) {
var result = await _certsFlowService.GetOrderAsync(sessionId, requestData);
return result.ToActionResult();
}
/// <summary>
/// After account initialization create new order request
/// </summary>
/// <param name="sessionId"></param>
/// <param name="requestData"></param>
/// <returns></returns>
[HttpPost("[action]/{sessionId}")]
public async Task<IActionResult> NewOrder(Guid sessionId, [FromBody] NewOrderRequest requestData) {
var result = await _certsFlowService.NewOrderAsync(sessionId, requestData);
return result.ToActionResult();
}
/// <summary>
/// Download certificates to local cache
/// </summary>
/// <param name="sessionId">Session ID</param>
/// <param name="requestData">Request data</param>
/// <returns>Certificates download response</returns>
[HttpPost("{sessionId}/certificates/download")]
public async Task<IActionResult> GetCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) {
var result = await _certsFlowService.GetCertificatesAsync(sessionId, requestData);
return result.ToActionResult();
}
/// <summary>
/// After new order request complete challenges
/// </summary>
/// <param name="sessionId"></param>
/// <returns></returns>
[HttpPost("[action]/{sessionId}")]
public async Task<IActionResult> CompleteChallenges(Guid sessionId) {
var result = await _certsFlowService.CompleteChallengesAsync(sessionId);
return result.ToActionResult();
}
/// <summary>
/// Get order status before certs retrieval
/// </summary>
/// <param name="sessionId"></param>
/// <param name="requestData"></param>
/// <returns></returns>
[HttpPost("[action]/{sessionId}")]
public async Task<IActionResult> GetOrder(Guid sessionId, [FromBody] GetOrderRequest requestData) {
var result = await _certsFlowService.GetOrderAsync(sessionId, requestData);
return result.ToActionResult();
}
/// <summary>
/// Download certs to local cache
/// </summary>
/// <param name="sessionId"></param>
/// <param name="requestData"></param>
/// <returns></returns>
[HttpPost("[action]/{sessionId}")]
public async Task<IActionResult> GetCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) {
var result = await _certsFlowService.GetCertificatesAsync(sessionId, requestData);
return result.ToActionResult();
}
/// <summary>
/// Apply certs from local cache to remote server
/// </summary>
/// <param name="sessionId"></param>
/// <param name="requestData"></param>
/// <returns></returns>
[HttpPost("[action]/{sessionId}")]
public async Task<IActionResult> ApplyCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) {
var result = await _certsFlowService.ApplyCertificatesAsync(sessionId, requestData);
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();
/// <summary>
/// Apply certificates from local cache to remote server
/// </summary>
/// <param name="sessionId">Session ID</param>
/// <param name="requestData">Request data</param>
/// <returns>Certificates application response</returns>
[HttpPost("{sessionId}/certificates/apply")]
public async Task<IActionResult> ApplyCertificates(Guid sessionId, [FromBody] GetCertificatesRequest requestData) {
var result = await _certsFlowService.ApplyCertificatesAsync(sessionId, requestData);
return result.ToActionResult();
}
}
}

View File

@ -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<IDomainResult> SaveToCacheAsync(Guid accountId, RegistrationCache cache);
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 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<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 {
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<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) {
var (cache, loadResult) = await LoadFromCacheAsync(accountId);
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);
if (!loadResult.IsSuccess || cache == null)
return loadResult;
cache.Contacts = requestData.Contacts;
return await SaveToCacheAsync(accountId, cache);
var contacts = cache.Contacts?.ToList() ?? new List<string>();
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() {

View File

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

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 {
public class SetContactsRequest : IValidatableObject {
public class PutContactsRequest : IValidatableObject {
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.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; }
}
}

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