(feature): formating rules

This commit is contained in:
Maksym Sadovnychyy 2024-06-20 12:22:40 +02:00
parent e321a3237f
commit 7d7ebd298a
33 changed files with 1236 additions and 842 deletions

View File

@ -1,3 +1,12 @@
{ {
"extends": "next/core-web-vitals" "extends": [
"next/core-web-vitals",
"plugin:prettier/recommended"
],
"rules": {
"prettier/prettier": "error",
"semi": "off",
"quotes": "off",
"indent": "off"
}
} }

View File

@ -0,0 +1,25 @@
# Ignore artifacts:
build
coverage
dist
node_modules
# Ignore all configuration files:
*.config.js
*.config.ts
# Ignore specific files:
package-lock.json
yarn.lock
# Ignore logs:
*.log
# Ignore minified files:
*.min.js
# Ignore compiled code:
*.d.ts
# Ignore specific directories:
public

View File

@ -0,0 +1,7 @@
{
"singleQuote": true,
"semi": false,
"tabWidth": 2,
"endOfLine": "lf",
"trailingComma": "none"
}

23
src/ClientApp/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,23 @@
{
"editor.tabSize": 2,
// "editor.codeActionsOnSave": {
// "source.fixAll.eslint": true
// },
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"eslint.validate": [
"javascript",
"typescript",
"typescriptreact"
]
}

View File

@ -1,12 +1,9 @@
enum ApiRoutes { enum ApiRoutes {
CACHE_ACCOUNTS = 'api/cache/accounts', CACHE_ACCOUNTS = 'api/cache/accounts',
CACHE_ACCOUNT = 'api/cache/account/{accountId}', CACHE_ACCOUNT = 'api/cache/account/{accountId}',
CACHE_ACCOUNT_CONTACTS = 'api/cache/account/{accountId}/contacts', CACHE_ACCOUNT_CONTACTS = 'api/cache/account/{accountId}/contacts',
CACHE_ACCOUNT_CONTACT = 'api/cache/account/{accountId}/contacts/{index}', CACHE_ACCOUNT_CONTACT = 'api/cache/account/{accountId}/contacts/{index}',
CACHE_ACCOUNT_HOSTNAMES = 'api/cache/account/{accountId}/hostnames', CACHE_ACCOUNT_HOSTNAMES = 'api/cache/account/{accountId}/hostnames'
// CERTS_FLOW_CONFIGURE_CLIENT = `api/CertsFlow/ConfigureClient`, // CERTS_FLOW_CONFIGURE_CLIENT = `api/CertsFlow/ConfigureClient`,
// CERTS_FLOW_TERMS_OF_SERVICE = `api/CertsFlow/TermsOfService/{sessionId}`, // CERTS_FLOW_TERMS_OF_SERVICE = `api/CertsFlow/TermsOfService/{sessionId}`,
@ -19,15 +16,11 @@ enum ApiRoutes {
} }
const GetApiRoute = (route: ApiRoutes, ...args: string[]): string => { const GetApiRoute = (route: ApiRoutes, ...args: string[]): string => {
let result: string = route; let result: string = route
args.forEach(arg => { args.forEach((arg) => {
result = result.replace(/{.*?}/, arg); result = result.replace(/{.*?}/, arg)
}); })
return `http://localhost:5000/${result}`; return `http://localhost:5000/${result}`
} }
export { GetApiRoute, ApiRoutes }
export {
GetApiRoute,
ApiRoutes
}

View File

@ -1,8 +1,9 @@
const ContactPage = () => { const ContactPage = () => {
return (<> return (
<h1 className="text-2xl font-bold">Contact Us</h1> <>
<p>This is the contact page content.</p> <h1 className="text-2xl font-bold">Contact Us</h1>
</> <p>This is the contact page content.</p>
</>
) )
} }

View File

@ -0,0 +1,41 @@
const deepCopy = <T>(input: T): T => {
const map = new Map()
const clone = (item: any): any => {
if (item === null || typeof item !== 'object') {
return item
}
if (map.has(item)) {
return map.get(item)
}
let result: any
if (Array.isArray(item)) {
result = []
map.set(item, result)
item.forEach((element, index) => {
result[index] = clone(element)
})
} else if (item instanceof Date) {
result = new Date(item)
map.set(item, result)
} else if (item instanceof RegExp) {
result = new RegExp(item)
map.set(item, result)
} else {
result = Object.create(Object.getPrototypeOf(item))
map.set(item, result)
Object.keys(item).forEach((key) => {
result[key] = clone(item[key])
})
}
return result
}
return clone(input)
}
export { deepCopy }

View File

@ -0,0 +1,3 @@
import { deepCopy } from './deepCopy'
export { deepCopy }

View File

@ -1,4 +1,4 @@
"use client" 'use client'
import React, { FC, useState, useEffect, useRef } from 'react' import React, { FC, useState, useEffect, useRef } from 'react'
import { SideMenu } from '@/components/sidemenu' import { SideMenu } from '@/components/sidemenu'
@ -13,8 +13,8 @@ import { store } from '@/redux/store'
import './globals.css' import './globals.css'
const metadata: Metadata = { const metadata: Metadata = {
title: "Create Next App", title: 'Create Next App',
description: "Generated by create next app", description: 'Generated by create next app'
} }
const Layout: FC<{ children: React.ReactNode }> = ({ children }) => { const Layout: FC<{ children: React.ReactNode }> = ({ children }) => {
@ -75,16 +75,16 @@ const Layout: FC<{ children: React.ReactNode }> = ({ children }) => {
<Loader /> <Loader />
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
<SideMenu isCollapsed={isSidebarCollapsed} toggleSidebar={manuallyToggleSidebar} /> <SideMenu
isCollapsed={isSidebarCollapsed}
toggleSidebar={manuallyToggleSidebar}
/>
<div className="flex flex-col flex-1 overflow-hidden"> <div className="flex flex-col flex-1 overflow-hidden">
<TopMenu onToggleOffCanvas={toggleOffCanvas} /> <TopMenu onToggleOffCanvas={toggleOffCanvas} />
<main className="flex-1 p-4 overflow-y-auto"> <main className="flex-1 p-4 overflow-y-auto">{children}</main>
{children}
</main>
<Footer className="flex-shrink-0" /> <Footer className="flex-shrink-0" />
</div> </div>
</div> </div>
<OffCanvas isOpen={isOffCanvasOpen} onClose={toggleOffCanvas} /> <OffCanvas isOpen={isOffCanvasOpen} onClose={toggleOffCanvas} />

View File

@ -1,328 +1,416 @@
"use client" 'use client'
import { ApiRoutes, GetApiRoute } from "@/ApiRoutes" import { ApiRoutes, GetApiRoute } from '@/ApiRoutes'
import { httpService } from "@/services/httpService" import { httpService } from '@/services/httpService'
import { FormEvent, useEffect, useRef, useState } from "react" import { FormEvent, useEffect, useRef, useState } from 'react'
import { useValidation, isValidEmail, isValidHostname } from "@/hooks/useValidation" import {
import { CustomButton, CustomInput } from "@/controls" useValidation,
import { TrashIcon, PlusIcon } from "@heroicons/react/24/solid" isValidEmail,
import { GetAccountResponse } from "@/models/letsEncryptServer/cache/responses/GetAccountResponse" isValidHostname
} from '@/hooks/useValidation'
interface CacheAccountHostname { import { CustomButton, CustomInput } from '@/controls'
hostname: string import { TrashIcon, PlusIcon } from '@heroicons/react/24/solid'
expires: Date import { GetAccountResponse } from '@/models/letsEncryptServer/cache/responses/GetAccountResponse'
isUpcomingExpire: boolean import { deepCopy } from './functions'
} import { CacheAccount } from '@/entities/CacheAccount'
interface CacheAccount {
accountId: string
description?: string
contacts: string[]
hostnames: CacheAccountHostname[]
isEditMode: boolean
}
export default function Page() { export default function Page() {
const [accounts, setAccounts] = useState<CacheAccount[]>([]) const [accounts, setAccounts] = useState<CacheAccount[]>([])
const [initialAccounts, setInitialAccounts] = useState<CacheAccount[]>([]) const [initialAccounts, setInitialAccounts] = useState<CacheAccount[]>([])
const { const {
value: newContact, value: newContact,
error: contactError, error: contactError,
handleChange: handleContactChange handleChange: handleContactChange
} = useValidation({ } = useValidation({
initialValue:"", initialValue: '',
validateFn: isValidEmail, validateFn: isValidEmail,
errorMessage: "Invalid email format." errorMessage: 'Invalid email format.'
}) })
const { const {
value: newHostname, value: newHostname,
error: hostnameError, error: hostnameError,
handleChange: handleHostnameChange handleChange: handleHostnameChange
} = useValidation({ } = useValidation({
initialValue: "", initialValue: '',
validateFn: isValidHostname, validateFn: isValidHostname,
errorMessage: "Invalid hostname format."}) errorMessage: 'Invalid hostname format.'
})
const init = useRef(false) const init = useRef(false)
useEffect(() => { useEffect(() => {
if (init.current) return if (init.current) return
console.log('Fetching accounts')
console.log("Fetching accounts") const fetchAccounts = async () => {
const newAccounts: CacheAccount[] = []
const accounts = await httpService.get<GetAccountResponse[]>(
GetApiRoute(ApiRoutes.CACHE_ACCOUNTS)
)
const fetchAccounts = async () => { accounts?.forEach((account) => {
const newAccounts: CacheAccount[] = [] newAccounts.push({
const accounts = await httpService.get<GetAccountResponse []>(GetApiRoute(ApiRoutes.CACHE_ACCOUNTS)) accountId: account.accountId,
contacts: account.contacts,
hostnames: account.hostnames.map((h) => ({
hostname: h.hostname,
expires: new Date(h.expires),
isUpcomingExpire: h.isUpcomingExpire
})),
isEditMode: false
})
})
accounts?.forEach((account) => { setAccounts(newAccounts)
newAccounts.push({ setInitialAccounts(deepCopy(newAccounts)) // Clone initial state
accountId: account.accountId,
contacts: account.contacts,
hostnames: account.hostnames.map(h => ({
hostname: h.hostname,
expires: new Date(h.expires),
isUpcomingExpire: h.isUpcomingExpire
})),
isEditMode: false
})
});
setAccounts(newAccounts)
setInitialAccounts(JSON.parse(JSON.stringify(newAccounts))) // Clone initial state
}
fetchAccounts()
init.current = true
}, [])
const toggleEditMode = (accountId: string) => {
setAccounts(accounts.map(account =>
account.accountId === accountId ? { ...account, isEditMode: !account.isEditMode } : account
))
} }
const deleteAccount = (accountId: string) => { fetchAccounts()
setAccounts(accounts.filter(account => account.accountId !== accountId)) init.current = true
}, [])
// TODO: Revoke all certificates const toggleEditMode = (accountId: string) => {
// TODO: Remove from cache setAccounts(
} accounts.map((account) =>
account.accountId === accountId
const deleteContact = (accountId: string, contact: string) => { ? { ...account, isEditMode: !account.isEditMode }
const account = accounts.find(account => account.accountId === accountId) : account
if (account?.contacts.length ?? 0 < 1) return )
// TODO: Remove from cache
httpService.delete(GetApiRoute(ApiRoutes.CACHE_ACCOUNT_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
}
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("")
}
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
}
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("")
}
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))
}
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">
<h1 className="text-4xl font-bold text-center mb-8">LetsEncrypt Auto Renew</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>
<CustomButton onClick={() => toggleEditMode(account.accountId)} className="bg-blue-500 text-white px-3 py-1 rounded">
{account.isEditMode ? "View Mode" : "Edit Mode"}
</CustomButton>
</div>
{account.isEditMode ? (
<form onSubmit={(e) => handleSubmit(e, account.accountId)}>
<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 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>
<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>
) )
}
const deleteAccount = (accountId: string) => {
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_ACCOUNT_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
}
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('')
}
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
}
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('')
}
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)
)
}
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(deepCopy(accounts))
toggleEditMode(accountId)
}
return (
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold text-center mb-8">
LetsEncrypt Auto Renew
</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>
<CustomButton
onClick={() => toggleEditMode(account.accountId)}
className="bg-blue-500 text-white px-3 py-1 rounded"
>
{account.isEditMode ? 'View Mode' : 'Edit Mode'}
</CustomButton>
</div>
{account.isEditMode ? (
<form onSubmit={(e) => handleSubmit(e, account.accountId)}>
<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 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>
<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,209 +1,233 @@
"use client" 'use client'
import { ApiRoutes, GetApiRoute } from "@/ApiRoutes" import { ApiRoutes, GetApiRoute } from '@/ApiRoutes'
import { httpService } from "@/services/httpService" import { httpService } from '@/services/httpService'
import { FormEvent, useEffect, useRef, useState } from "react" import { FormEvent, useEffect, useRef, useState } from 'react'
import { useValidation, isValidContact, isValidHostname } from "@/hooks/useValidation" import {
import { CustomButton, CustomInput } from "@/controls" useValidation,
import { FaTrash, FaPlus } from "react-icons/fa" isValidContact,
import { GetAccountResponse } from "@/models/letsEncryptServer/cache/responses/GetAccountResponse" isValidHostname
} from '@/hooks/useValidation'
interface CacheAccountHostname { import { CustomButton, CustomInput } from '@/controls'
hostname: string import { FaTrash, FaPlus } from 'react-icons/fa'
expires: Date import { GetAccountResponse } from '@/models/letsEncryptServer/cache/responses/GetAccountResponse'
isUpcomingExpire: boolean import { deepCopy } from '../functions'
}
interface CacheAccount { interface CacheAccount {
accountId: string description?: string
description?: string contacts: string[]
contacts: string[] hostnames: string[]
hostnames: CacheAccountHostname[]
isEditMode: boolean
} }
const RegisterPage = () => { const RegisterPage = () => {
const [accounts, setAccounts] = useState<CacheAccount[]>([]) const [account, setAccount] = useState<CacheAccount | null>(null)
const [initialAccounts, setInitialAccounts] = useState<CacheAccount[]>([])
const [description, setDescription] = useState("")
const [contacts, setContacts] = useState<string[]>([])
const [hostnames, setHostnames] = useState<string[]>([])
const { const {
value: newContact, value: newContact,
error: contactError, error: contactError,
handleChange: handleContactChange, handleChange: handleContactChange,
reset: resetContact reset: resetContact
} = useValidation({ } = useValidation({
initialValue: "", initialValue: '',
validateFn: isValidContact, validateFn: isValidContact,
errorMessage: "Invalid contact. Must be a valid email or phone number." errorMessage: 'Invalid contact. Must be a valid email or phone number.'
})
const {
value: newHostname,
error: hostnameError,
handleChange: handleHostnameChange,
reset: resetHostname
} = useValidation({
initialValue: '',
validateFn: isValidHostname,
errorMessage: 'Invalid hostname format.'
})
const init = useRef(false)
useEffect(() => {
if (init.current) return
init.current = true
}, [])
const handleDescription = (description: string) => {}
const handleAddContact = () => {
if (newContact !== '' || contactError) return
setAccount((prev) => {
const newAccount: CacheAccount =
prev !== null
? deepCopy(prev)
: {
contacts: [],
hostnames: []
}
newAccount.contacts.push(newContact)
return newAccount
}) })
const { resetContact()
value: newHostname, }
error: hostnameError,
handleChange: handleHostnameChange, const handleAddHostname = () => {
reset: resetHostname if (newHostname !== '' || hostnameError) return
} = useValidation({
initialValue: "", setAccount((prev) => {
validateFn: isValidHostname, const newAccount: CacheAccount =
errorMessage: "Invalid hostname format." prev !== null
? deepCopy(prev)
: {
contacts: [],
hostnames: []
}
newAccount.hostnames.push(newHostname)
return newAccount
}) })
const init = useRef(false) resetHostname()
}
useEffect(() => { const handleDeleteContact = (contact: string) => {
if (init.current) return setAccount((prev) => {
if (prev === null) return null
const fetchAccounts = async () => { const newAccount = deepCopy(prev)
const newAccounts: CacheAccount[] = [] newAccount.contacts = newAccount.contacts.filter((c) => c !== contact)
const accounts = await httpService.get<GetAccountResponse[]>(GetApiRoute(ApiRoutes.CACHE_ACCOUNTS))
accounts?.forEach((account) => { return newAccount
newAccounts.push({ })
accountId: account.accountId, }
contacts: account.contacts,
hostnames: account.hostnames.map(h => ({
hostname: h.hostname,
expires: new Date(h.expires),
isUpcomingExpire: h.isUpcomingExpire
})),
isEditMode: false
})
})
setAccounts(newAccounts) const handleDeleteHostname = (hostname: string) => {
setInitialAccounts(JSON.parse(JSON.stringify(newAccounts))) // Clone initial state setAccount((prev) => {
} if (prev === null) return null
fetchAccounts() const newAccount = deepCopy(prev)
init.current = true newAccount.hostnames = newAccount.hostnames.filter((h) => h !== hostname)
}, [])
const handleAddContact = () => { return newAccount
if (newContact.trim() !== "" && !contactError) { })
setContacts([...contacts, newContact.trim()]) }
resetContact()
}
}
const handleAddHostname = () => { const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
if (newHostname.trim() !== "" && !hostnameError) { e.preventDefault()
setHostnames([...hostnames, newHostname.trim()])
resetHostname()
}
}
const handleDeleteContact = (contact: string) => { console.log(account)
setContacts(contacts.filter(c => c !== contact)) }
}
const handleDeleteHostname = (hostname: string) => { return (
setHostnames(hostnames.filter(h => h !== hostname)) <div className="container mx-auto p-4">
} <h1 className="text-4xl font-bold text-center mb-8">
Register LetsEncrypt Account
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { </h1>
e.preventDefault() <form onSubmit={handleSubmit}>
<div className="mb-4">
if (!description || contacts.length === 0 || hostnames.length === 0) { <CustomInput
return type="text"
} value={account?.description ?? ''}
onChange={handleDescription}
const newAccount = { placeholder="Account Description"
description, title="Description"
contacts, inputClassName="border p-2 rounded w-full"
hostnames: hostnames.map(hostname => ({ hostname, expires: new Date(), isUpcomingExpire: false })) className="mb-4"
} />
// TODO: Implement API call to create new account
console.log("New account data:", newAccount)
// Reset form fields
setDescription("")
setContacts([])
setHostnames([])
}
return (
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold text-center mb-8">Register LetsEncrypt Account</h1>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<CustomInput
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Account Description"
title="Description"
inputClassName="border p-2 rounded w-full"
className="mb-4"
/>
</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">
{contacts.map(contact => (
<li key={contact} className="text-gray-700 flex justify-between items-center mb-2">
{contact}
<button type="button" onClick={() => handleDeleteContact(contact)} className="bg-red-500 text-white px-2 py-1 rounded ml-4">
<FaTrash />
</button>
</li>
))}
</ul>
<div className="flex items-center mb-4">
<CustomInput
value={newContact}
onChange={handleContactChange}
placeholder="Add contact"
type="text"
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 type="button" onClick={handleAddContact} className="bg-green-500 text-white p-2 rounded ml-2 h-10 flex items-center">
<FaPlus />
</button>
</div>
</div>
<div className="mb-4">
<h3 className="text-xl font-medium mb-2">Hostnames:</h3>
<ul className="list-disc list-inside pl-4 mb-2">
{hostnames.map(hostname => (
<li key={hostname} className="text-gray-700 flex justify-between items-center mb-2">
{hostname}
<button type="button" onClick={() => handleDeleteHostname(hostname)} className="bg-red-500 text-white px-2 py-1 rounded ml-4">
<FaTrash />
</button>
</li>
))}
</ul>
<div className="flex items-center">
<CustomInput
value={newHostname}
onChange={handleHostnameChange}
placeholder="Add 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 type="button" onClick={handleAddHostname} className="bg-green-500 text-white p-2 rounded ml-2 h-10 flex items-center">
<FaPlus />
</button>
</div>
</div>
<CustomButton type="submit" className="bg-green-500 text-white px-3 py-1 rounded">
Create Account
</CustomButton>
</form>
</div> </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}
<button
type="button"
onClick={() => handleDeleteContact(contact)}
className="bg-red-500 text-white px-2 py-1 rounded ml-4"
>
<FaTrash />
</button>
</li>
))}
</ul>
<div className="flex items-center mb-4">
<CustomInput
value={newContact}
onChange={handleContactChange}
placeholder="Add contact"
type="text"
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
type="button"
onClick={handleAddContact}
className="bg-green-500 text-white p-2 rounded ml-2 h-10 flex items-center"
>
<FaPlus />
</button>
</div>
</div>
<div className="mb-4">
<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}
className="text-gray-700 flex justify-between items-center mb-2"
>
{hostname}
<button
type="button"
onClick={() => handleDeleteHostname(hostname)}
className="bg-red-500 text-white px-2 py-1 rounded ml-4"
>
<FaTrash />
</button>
</li>
))}
</ul>
<div className="flex items-center">
<CustomInput
value={newHostname}
onChange={handleHostnameChange}
placeholder="Add 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
type="button"
onClick={handleAddHostname}
className="bg-green-500 text-white p-2 rounded ml-2 h-10 flex items-center"
>
<FaPlus />
</button>
</div>
</div>
<CustomButton
type="submit"
className="bg-green-500 text-white px-3 py-1 rounded"
>
Create Account
</CustomButton>
</form>
</div>
)
} }
export default RegisterPage export default RegisterPage

View File

@ -1,12 +1,10 @@
import React from 'react' import React from 'react'
interface FooterProps { interface FooterProps {
className?: string className?: string
} }
const Footer = (props: FooterProps) => { const Footer = (props: FooterProps) => {
const { className } = props const { className } = props
return ( return (
<footer className={`bg-gray-900 text-white text-center p-4 ${className}`}> <footer className={`bg-gray-900 text-white text-center p-4 ${className}`}>
@ -15,6 +13,4 @@ const Footer = (props: FooterProps) => {
) )
} }
export { export { Footer }
Footer
}

View File

@ -7,7 +7,9 @@ import './loader.css'
const Loader: React.FC = () => { const Loader: React.FC = () => {
const dispatch = useDispatch() const dispatch = useDispatch()
const activeRequests = useSelector((state: RootState) => state.loader.activeRequests) const activeRequests = useSelector(
(state: RootState) => state.loader.activeRequests
)
useEffect(() => { useEffect(() => {
let timeout: NodeJS.Timeout | null = null let timeout: NodeJS.Timeout | null = null
@ -35,6 +37,4 @@ const Loader: React.FC = () => {
) )
} }
export { export { Loader }
Loader
}

View File

@ -1,8 +1,8 @@
import React, { FC } from 'react' import React, { FC } from 'react'
interface OffCanvasProps { interface OffCanvasProps {
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
} }
const OffCanvas: FC<OffCanvasProps> = ({ isOpen, onClose }) => { const OffCanvas: FC<OffCanvasProps> = ({ isOpen, onClose }) => {
@ -29,6 +29,4 @@ const OffCanvas: FC<OffCanvasProps> = ({ isOpen, onClose }) => {
) )
} }
export { export { OffCanvas }
OffCanvas
}

View File

@ -1,42 +1,50 @@
import React, { FC } from 'react'; import React, { FC } from 'react'
import { FaHome, FaUserPlus, FaBars, FaSyncAlt } from 'react-icons/fa'; import { FaHome, FaUserPlus, FaBars, FaSyncAlt } from 'react-icons/fa'
import Link from 'next/link'; import Link from 'next/link'
interface SideMenuProps { interface SideMenuProps {
isCollapsed: boolean; isCollapsed: boolean
toggleSidebar: () => void; toggleSidebar: () => void
} }
const menuItems = [ const menuItems = [
{ icon: <FaSyncAlt />, label: 'Auto Renew', path: '/' }, { icon: <FaSyncAlt />, label: 'Auto Renew', path: '/' },
{ icon: <FaUserPlus />, label: 'Register', path: '/register' } { icon: <FaUserPlus />, label: 'Register', path: '/register' }
]; ]
const SideMenu: FC<SideMenuProps> = ({ isCollapsed, toggleSidebar }) => { const SideMenu: FC<SideMenuProps> = ({ isCollapsed, toggleSidebar }) => {
return ( return (
<div className={`flex flex-col bg-gray-800 text-white transition-all duration-300 ${isCollapsed ? 'w-16' : 'w-64'} h-full`}> <div
className={`flex flex-col bg-gray-800 text-white transition-all duration-300 ${isCollapsed ? 'w-16' : 'w-64'} h-full`}
>
<div className="flex items-center h-16 bg-gray-900 relative"> <div className="flex items-center h-16 bg-gray-900 relative">
<button onClick={toggleSidebar} className="absolute left-4"> <button onClick={toggleSidebar} className="absolute left-4">
<FaBars /> <FaBars />
</button> </button>
<h1 className={`${isCollapsed ? 'hidden' : 'block'} text-2xl font-bold ml-12`}>Certs UI</h1> <h1
className={`${isCollapsed ? 'hidden' : 'block'} text-2xl font-bold ml-12`}
>
Certs UI
</h1>
</div> </div>
<nav className="flex-1"> <nav className="flex-1">
<ul> <ul>
{menuItems.map((item, index) => ( {menuItems.map((item, index) => (
<li key={index} className="hover:bg-gray-700"> <li key={index} className="hover:bg-gray-700">
<Link href={item.path} className="flex items-center w-full p-4"> <Link href={item.path} className="flex items-center w-full p-4">
<span className={`${isCollapsed ? 'mr-0' : 'mr-4'}`}>{item.icon}</span> <span className={`${isCollapsed ? 'mr-0' : 'mr-4'}`}>
<span className={`${isCollapsed ? 'hidden' : 'block'}`}>{item.label}</span> {item.icon}
</span>
<span className={`${isCollapsed ? 'hidden' : 'block'}`}>
{item.label}
</span>
</Link> </Link>
</li> </li>
))} ))}
</ul> </ul>
</nav> </nav>
</div> </div>
); )
}; }
export { export { SideMenu }
SideMenu
};

View File

@ -1,4 +1,4 @@
"use client" // Add this line 'use client' // Add this line
import React, { FC, useState } from 'react' import React, { FC, useState } from 'react'
import { FaCog, FaBars } from 'react-icons/fa' import { FaCog, FaBars } from 'react-icons/fa'
@ -48,12 +48,10 @@ const TopMenu: FC<TopMenuProps> = ({ onToggleOffCanvas }) => {
<FaCog /> <FaCog />
</button> </button>
<button onClick={toggleMenu} className="md:hidden"> <button onClick={toggleMenu} className="md:hidden">
<FaBars /> <FaBars />
</button> </button>
</header> </header>
) )
} }
export { export { TopMenu }
TopMenu
}

View File

@ -1,28 +1,33 @@
"use client" 'use client'
import React, { FC } from 'react' import React, { FC } from 'react'
interface CustomButtonProps { interface CustomButtonProps {
onClick?: () => void onClick?: () => void
className?: string className?: string
children: React.ReactNode children: React.ReactNode
disabled?: boolean disabled?: boolean
type?: "button" | "submit" | "reset" type?: 'button' | 'submit' | 'reset'
} }
const CustomButton: FC<CustomButtonProps> = (props) => { const CustomButton: FC<CustomButtonProps> = (props) => {
const {
onClick,
className = '',
children,
disabled = false,
type = 'button'
} = props
const { onClick, className = '', children, disabled = false, type = 'button' } = props return (
<button
return ( onClick={onClick}
<button className={className}
onClick={onClick} disabled={disabled}
className={className} type={type}
disabled={disabled} >
type={type} {children}
> </button>
{children} )
</button>
)
} }
export { CustomButton } export { CustomButton }

View File

@ -1,48 +1,47 @@
// components/CustomInput.tsx // components/CustomInput.tsx
"use client" 'use client'
import React from 'react' import React from 'react'
interface CustomInputProps { interface CustomInputProps {
value: string value: string
onChange?: (value: string) => void onChange?: (value: string) => void
placeholder?: string placeholder?: string
type: 'text' | 'password' | 'email' | 'number' | 'tel' | 'url' type: 'text' | 'password' | 'email' | 'number' | 'tel' | 'url'
error?: string error?: string
title?: string title?: string
inputClassName?: string inputClassName?: string
errorClassName?: string errorClassName?: string
className?: string className?: string
} }
const CustomInput: React.FC<CustomInputProps> = ({ const CustomInput: React.FC<CustomInputProps> = ({
value, value,
onChange, onChange,
placeholder = '', placeholder = '',
type = 'text', type = 'text',
error, error,
title, title,
inputClassName = '', inputClassName = '',
errorClassName = '', errorClassName = '',
className = '' className = ''
}) => { }) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(e.target.value)
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { return (
onChange?.(e.target.value); <div className={className}>
} {title && <label>{title}</label>}
<input
return ( type={type}
<div className={className}> value={value}
{title && <label>{title}</label>} onChange={handleChange}
<input placeholder={placeholder}
type={type} className={inputClassName}
value={value} />
onChange={handleChange} {error && <p className={errorClassName}>{error}</p>}
placeholder={placeholder} </div>
className={inputClassName} )
/>
{error && <p className={errorClassName}>{error}</p>}
</div>
)
} }
export { CustomInput } export { CustomInput }

View File

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

View File

@ -0,0 +1,9 @@
import { CacheAccountHostname } from './CacheAccountHostname'
export interface CacheAccount {
accountId: string
description?: string
contacts: string[]
hostnames: CacheAccountHostname[]
isEditMode: boolean
}

View File

@ -0,0 +1,5 @@
export interface CacheAccountHostname {
hostname: string
expires: Date
isUpcomingExpire: boolean
}

View File

@ -1,55 +1,64 @@
import { useState, useEffect } from "react" import { useState, useEffect } from 'react'
// Helper functions for validation // Helper functions for validation
const isValidEmail = (email: string) => { const isValidEmail = (email: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email) return emailRegex.test(email)
} }
const isValidPhoneNumber = (phone: string) => { const isValidPhoneNumber = (phone: string) => {
const phoneRegex = /^\+?[1-9]\d{1,14}$/ const phoneRegex = /^\+?[1-9]\d{1,14}$/
return phoneRegex.test(phone) return phoneRegex.test(phone)
} }
const isValidContact = (contact: string) => { const isValidContact = (contact: string) => {
return isValidEmail(contact) || isValidPhoneNumber(contact) return isValidEmail(contact) || isValidPhoneNumber(contact)
} }
const isValidHostname = (hostname: string) => { const isValidHostname = (hostname: string) => {
const hostnameRegex = /^(?!:\/\/)([a-zA-Z0-9-_]{1,63}\.?)+[a-zA-Z]{2,6}$/ const hostnameRegex = /^(?!:\/\/)([a-zA-Z0-9-_]{1,63}\.?)+[a-zA-Z]{2,6}$/
return hostnameRegex.test(hostname) return hostnameRegex.test(hostname)
} }
// Props interface for useValidation hook // Props interface for useValidation hook
interface UseValidationProps { interface UseValidationProps {
initialValue: string initialValue: string
validateFn: (value: string) => boolean validateFn: (value: string) => boolean
errorMessage: string errorMessage: string
} }
// Custom hook for input validation // Custom hook for input validation
const useValidation = ({ initialValue, validateFn, errorMessage }: UseValidationProps) => { const useValidation = ({
const [value, setValue] = useState(initialValue) initialValue,
const [error, setError] = useState("") validateFn,
errorMessage
}: UseValidationProps) => {
const [value, setValue] = useState(initialValue)
const [error, setError] = useState('')
const handleChange = (newValue: string) => { const handleChange = (newValue: string) => {
console.log(newValue)
console.log(newValue) setValue(newValue)
setValue(newValue) if (newValue.trim() === '') {
if (newValue.trim() === "") { setError('This field cannot be empty.')
setError("This field cannot be empty.") } else if (!validateFn(newValue.trim())) {
} else if (!validateFn(newValue.trim())) { setError(errorMessage)
setError(errorMessage) } else {
} else { setError('')
setError("")
}
} }
}
useEffect(() => { useEffect(() => {
handleChange(initialValue) handleChange(initialValue)
}, [initialValue]) }, [initialValue])
return { value, error, handleChange, reset: () => setValue("") } return { value, error, handleChange, reset: () => setValue('') }
} }
export { useValidation, isValidEmail, isValidPhoneNumber, isValidContact, isValidHostname } export {
useValidation,
isValidEmail,
isValidPhoneNumber,
isValidContact,
isValidHostname
}

View File

@ -1,9 +1,7 @@
import { HostnameResponse } from "./HostnameResponse"; import { HostnameResponse } from './HostnameResponse'
export interface GetAccountResponse { export interface GetAccountResponse {
accountId: string, accountId: string
contacts: string[], contacts: string[]
hostnames: HostnameResponse[], hostnames: HostnameResponse[]
} }

View File

@ -1,3 +1,3 @@
export interface GetContactsResponse { export interface GetContactsResponse {
contacts: string[] contacts: string[]
} }

View File

@ -1,5 +1,5 @@
import { HostnameResponse } from "./HostnameResponse"; import { HostnameResponse } from './HostnameResponse'
export interface GetHostnamesResponse { export interface GetHostnamesResponse {
hostnames: HostnameResponse[] hostnames: HostnameResponse[]
} }

View File

@ -1,5 +1,5 @@
export interface HostnameResponse { export interface HostnameResponse {
hostname: string hostname: string
expires: string expires: string
isUpcomingExpire: boolean isUpcomingExpire: boolean
} }

View File

@ -23,7 +23,10 @@
"@types/react-dom": "^18", "@types/react-dom": "^18",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "14.2.3", "eslint-config-next": "14.2.3",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"postcss": "^8", "postcss": "^8",
"prettier": "^3.3.2",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5" "typescript": "^5"
} }
@ -435,6 +438,18 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@pkgr/core": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz",
"integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==",
"dev": true,
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/unts"
}
},
"node_modules/@reduxjs/toolkit": { "node_modules/@reduxjs/toolkit": {
"version": "2.2.5", "version": "2.2.5",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.5.tgz", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.5.tgz",
@ -1639,6 +1654,18 @@
} }
} }
}, },
"node_modules/eslint-config-prettier": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
"dev": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
"peerDependencies": {
"eslint": ">=7.0.0"
}
},
"node_modules/eslint-import-resolver-node": { "node_modules/eslint-import-resolver-node": {
"version": "0.3.9", "version": "0.3.9",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
@ -1801,6 +1828,36 @@
"eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8"
} }
}, },
"node_modules/eslint-plugin-prettier": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz",
"integrity": "sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==",
"dev": true,
"dependencies": {
"prettier-linter-helpers": "^1.0.0",
"synckit": "^0.8.6"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint-plugin-prettier"
},
"peerDependencies": {
"@types/eslint": ">=8.0.0",
"eslint": ">=8.0.0",
"eslint-config-prettier": "*",
"prettier": ">=3.0.0"
},
"peerDependenciesMeta": {
"@types/eslint": {
"optional": true
},
"eslint-config-prettier": {
"optional": true
}
}
},
"node_modules/eslint-plugin-react": { "node_modules/eslint-plugin-react": {
"version": "7.34.2", "version": "7.34.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.34.2.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.34.2.tgz",
@ -1976,6 +2033,12 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true "dev": true
}, },
"node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"dev": true
},
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.3.2", "version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
@ -3717,6 +3780,33 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prettier": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz",
"integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==",
"dev": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-linter-helpers": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
"integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
"dev": true,
"dependencies": {
"fast-diff": "^1.1.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@ -4434,6 +4524,22 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/synckit": {
"version": "0.8.8",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz",
"integrity": "sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==",
"dev": true,
"dependencies": {
"@pkgr/core": "^0.1.0",
"tslib": "^2.6.2"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/unts"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "3.4.4", "version": "3.4.4",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.4.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.4.tgz",

View File

@ -24,7 +24,10 @@
"@types/react-dom": "^18", "@types/react-dom": "^18",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "14.2.3", "eslint-config-next": "14.2.3",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"postcss": "^8", "postcss": "^8",
"prettier": "^3.3.2",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5" "typescript": "^5"
} }

View File

@ -2,29 +2,29 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { createSlice, PayloadAction } from '@reduxjs/toolkit'
interface LoaderState { interface LoaderState {
activeRequests: number activeRequests: number
} }
const initialState: LoaderState = { const initialState: LoaderState = {
activeRequests: 0, activeRequests: 0
} }
const loaderSlice = createSlice({ const loaderSlice = createSlice({
name: 'loader', name: 'loader',
initialState, initialState,
reducers: { reducers: {
increment: (state) => { increment: (state) => {
state.activeRequests += 1 state.activeRequests += 1
},
decrement: (state) => {
if (state.activeRequests > 0) {
state.activeRequests -= 1
}
},
reset: (state) => {
state.activeRequests = 0
},
}, },
decrement: (state) => {
if (state.activeRequests > 0) {
state.activeRequests -= 1
}
},
reset: (state) => {
state.activeRequests = 0
}
}
}) })
export const { increment, decrement, reset } = loaderSlice.actions export const { increment, decrement, reset } = loaderSlice.actions

View File

@ -8,25 +8,28 @@ interface ToastState {
const initialState: ToastState = { const initialState: ToastState = {
message: '', message: '',
type: 'info', type: 'info'
} }
const toastSlice = createSlice({ const toastSlice = createSlice({
name: 'toast', name: 'toast',
initialState, initialState,
reducers: { reducers: {
showToast: (state, action: PayloadAction<{ showToast: (
message: string state,
type: 'success' | 'error' | 'info' | 'warning' action: PayloadAction<{
}>) => { message: string
type: 'success' | 'error' | 'info' | 'warning'
}>
) => {
state.message = action.payload.message state.message = action.payload.message
state.type = action.payload.type state.type = action.payload.type
}, },
clearToast: (state) => { clearToast: (state) => {
state.message = '' state.message = ''
state.type = 'info' state.type = 'info'
}, }
}, }
}) })
export const { showToast, clearToast } = toastSlice.actions export const { showToast, clearToast } = toastSlice.actions

View File

@ -3,10 +3,10 @@ import loaderReducer from '@/redux/slices//loaderSlice'
import toastReducer from '@/redux/slices/toastSlice' import toastReducer from '@/redux/slices/toastSlice'
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
loader: loaderReducer, loader: loaderReducer,
toast: toastReducer, toast: toastReducer
}, }
}) })
export type RootState = ReturnType<typeof store.getState> export type RootState = ReturnType<typeof store.getState>

View File

@ -1,188 +1,235 @@
import { store } from '@/redux/store'; import { store } from '@/redux/store'
import { increment, decrement } from '@/redux/slices/loaderSlice'; import { increment, decrement } from '@/redux/slices/loaderSlice'
import { showToast } from '@/redux/slices/toastSlice'; import { showToast } from '@/redux/slices/toastSlice'
interface RequestInterceptor { interface RequestInterceptor {
(req: XMLHttpRequest): void; (req: XMLHttpRequest): void
} }
interface ResponseInterceptor<T> { interface ResponseInterceptor<T> {
(response: T | null, error: ProblemDetails | null): T | void; (response: T | null, error: ProblemDetails | null): T | void
} }
interface ProblemDetails { interface ProblemDetails {
Title: string; Title: string
Detail: string | null; Detail: string | null
Status: number; Status: number
} }
interface HttpServiceCallbacks { interface HttpServiceCallbacks {
onIncrement?: () => void; onIncrement?: () => void
onDecrement?: () => void; onDecrement?: () => void
onShowToast?: (message: string, type: 'info' | 'error') => void; onShowToast?: (message: string, type: 'info' | 'error') => void
} }
class HttpService { class HttpService {
private requestInterceptors: RequestInterceptor[] = []; private requestInterceptors: RequestInterceptor[] = []
private responseInterceptors: Array<ResponseInterceptor<any>> = []; private responseInterceptors: Array<ResponseInterceptor<any>> = []
private callbacks: HttpServiceCallbacks; private callbacks: HttpServiceCallbacks
constructor(callbacks: HttpServiceCallbacks) { constructor(callbacks: HttpServiceCallbacks) {
this.callbacks = callbacks; this.callbacks = callbacks
}
private invokeIncrement(): void {
this.callbacks.onIncrement?.()
}
private invokeDecrement(): void {
this.callbacks.onDecrement?.()
}
private invokeShowToast(message: string, type: 'info' | 'error'): void {
this.callbacks.onShowToast?.(message, type)
}
private async request<TResponse>(
method: string,
url: string,
data?: any
): Promise<TResponse | null> {
const xhr = new XMLHttpRequest()
xhr.open(method, url)
this.handleRequestInterceptors(xhr)
if (data && typeof data !== 'string') {
xhr.setRequestHeader('Content-Type', 'application/json')
} }
private invokeIncrement(): void { this.invokeIncrement()
this.callbacks.onIncrement?.();
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)
})
}
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)
}
})
}
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
}
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)
} }
}
private invokeDecrement(): void { private handleSuccessfulResponse<TResponse>(
this.callbacks.onDecrement?.(); 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 invokeShowToast(message: string, type: 'info' | 'error'): void { private handleErrorResponse<TResponse>(
this.callbacks.onShowToast?.(message, type); 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 async request<TResponse>(method: string, url: string, data?: any): Promise<TResponse | null> { private showProblemDetails(problemDetails: ProblemDetails): void {
const xhr = new XMLHttpRequest(); if (problemDetails.Detail) {
xhr.open(method, url); const errorMessages = problemDetails.Detail.split(',')
errorMessages.forEach((message) => {
this.handleRequestInterceptors(xhr); this.invokeShowToast(message.trim(), 'error')
})
if (data && typeof data !== 'string') { } else {
xhr.setRequestHeader('Content-Type', 'application/json'); this.invokeShowToast('Unknown error', 'error')
}
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);
});
} }
}
private handleRequestInterceptors(xhr: XMLHttpRequest): void { public async get<TResponse>(url: string): Promise<TResponse | null> {
this.requestInterceptors.forEach(interceptor => { return await this.request<TResponse>('GET', url)
try { }
interceptor(xhr);
} catch (error) {
const problemDetails = this.createProblemDetails('Request Interceptor Error', error, 0);
this.showProblemDetails(problemDetails);
}
});
}
private handleResponseInterceptors<TResponse>(response: TResponse | null, error: ProblemDetails | null): TResponse | null { public async post<TRequest, TResponse>(
this.responseInterceptors.forEach(interceptor => { url: string,
try { data: TRequest
interceptor(response, error); ): Promise<TResponse | null> {
} catch (e) { return await this.request<TResponse>('POST', url, data)
const problemDetails = this.createProblemDetails('Response Interceptor Error', e, 0); }
this.showProblemDetails(problemDetails);
}
});
return response;
}
private handleLoad<TResponse>(xhr: XMLHttpRequest, resolve: (value: TResponse | null) => void): void { public async put<TRequest, TResponse>(
this.invokeDecrement(); url: string,
if (xhr.status >= 200 && xhr.status < 300) { data: TRequest
this.handleSuccessfulResponse<TResponse>(xhr, resolve); ): Promise<TResponse | null> {
} else { return await this.request<TResponse>('PUT', url, data)
this.handleErrorResponse(xhr, resolve); }
}
}
private handleSuccessfulResponse<TResponse>(xhr: XMLHttpRequest, resolve: (value: TResponse | null) => void): void { public async delete<TResponse>(url: string): Promise<TResponse | null> {
try { return await this.request<TResponse>('DELETE', url)
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 { public addRequestInterceptor(interceptor: RequestInterceptor): void {
const problemDetails = this.createProblemDetails(xhr.statusText, xhr.responseText, xhr.status); this.requestInterceptors.push(interceptor)
this.showProblemDetails(problemDetails); }
resolve(this.handleResponseInterceptors(null, problemDetails));
}
private handleNetworkError<TResponse>(resolve: (value: TResponse | null) => void): void { public addResponseInterceptor<TResponse>(
const problemDetails = this.createProblemDetails('Network Error', null, 0); interceptor: ResponseInterceptor<TResponse>
this.showProblemDetails(problemDetails); ): void {
resolve(this.handleResponseInterceptors(null, problemDetails)); this.responseInterceptors.push(interceptor)
} }
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 {
this.requestInterceptors.push(interceptor);
}
public addResponseInterceptor<TResponse>(interceptor: ResponseInterceptor<TResponse>): void {
this.responseInterceptors.push(interceptor);
}
} }
// Instance of HttpService // Instance of HttpService
const httpService = new HttpService({ const httpService = new HttpService({
onIncrement: () => store.dispatch(increment()), onIncrement: () => store.dispatch(increment()),
onDecrement: () => store.dispatch(decrement()), onDecrement: () => store.dispatch(decrement()),
onShowToast: (message: string, type: 'info' | 'error') => store.dispatch(showToast({ message, type })), onShowToast: (message: string, type: 'info' | 'error') =>
}); store.dispatch(showToast({ message, type }))
})
// Add loader state handling via interceptors // Add loader state handling via interceptors
httpService.addRequestInterceptor((xhr) => { httpService.addRequestInterceptor((xhr) => {
// Additional request logic can be added here // Additional request logic can be added here
}); })
httpService.addResponseInterceptor((response, error) => { httpService.addResponseInterceptor((response, error) => {
// Additional response logic can be added here // Additional response logic can be added here
return response; return response
}); })
export { httpService }; export { httpService }
// Example usage of the httpService // Example usage of the httpService
// async function fetchData() { // async function fetchData() {
@ -193,4 +240,3 @@ export { httpService };
// console.error('Failed to fetch data'); // console.error('Failed to fetch data');
// } // }
// } // }