mirror of
https://github.com/MAKS-IT-COM/maksit-certs-ui.git
synced 2025-12-31 04:00:03 +01:00
(feature): httpClient service update, layout improvemnts, better rest std
This commit is contained in:
parent
1f56ac19e6
commit
5c204e2c1d
@ -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}`,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
@ -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">
|
||||
<Provider store={store}>
|
||||
<Loader />
|
||||
|
||||
{isLoading && <Loader />} {/* Show loader if isLoading is true */}
|
||||
|
||||
<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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>© {new Date().getFullYear()} MAKS-IT</p>
|
||||
<p>© {new Date().getFullYear()} MAKS-IT</p>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Footer
|
||||
};
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { FC } from 'react';
|
||||
import React, { FC } from 'react'
|
||||
|
||||
interface OffCanvasProps {
|
||||
isOpen: boolean
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
52
src/ClientApp/components/toast.tsx
Normal file
52
src/ClientApp/components/toast.tsx
Normal 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 }
|
||||
@ -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)
|
||||
|
||||
28
src/ClientApp/controls/customButton.tsx
Normal file
28
src/ClientApp/controls/customButton.tsx
Normal 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 }
|
||||
43
src/ClientApp/controls/customInput.tsx
Normal file
43
src/ClientApp/controls/customInput.tsx
Normal 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 }
|
||||
7
src/ClientApp/controls/index.ts
Normal file
7
src/ClientApp/controls/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { CustomButton } from "./customButton"
|
||||
import { CustomInput } from "./customInput"
|
||||
|
||||
export {
|
||||
CustomButton,
|
||||
CustomInput
|
||||
}
|
||||
@ -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 }
|
||||
|
||||
32
src/ClientApp/package-lock.json
generated
32
src/ClientApp/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
31
src/ClientApp/redux/slices/loaderSlice.ts
Normal file
31
src/ClientApp/redux/slices/loaderSlice.ts
Normal 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
|
||||
33
src/ClientApp/redux/slices/toastSlice.ts
Normal file
33
src/ClientApp/redux/slices/toastSlice.ts
Normal 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
|
||||
13
src/ClientApp/redux/store.ts
Normal file
13
src/ClientApp/redux/store.ts
Normal 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
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
@ -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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
|
||||
namespace MaksIT.Models.LetsEncryptServer.Cache.Requests {
|
||||
public class SetContactsRequest : IValidatableObject {
|
||||
public class PutContactsRequest : IValidatableObject {
|
||||
|
||||
public required string[] Contacts { get; set; }
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
@ -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
13
src/Models/PatchAction.cs
Normal 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
|
||||
}
|
||||
}
|
||||
13
src/Models/PatchOperation.cs
Normal file
13
src/Models/PatchOperation.cs
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user