(feature): new account ui implementation

This commit is contained in:
Maksym Sadovnychyy 2024-06-29 18:12:54 +02:00
parent c817bc1038
commit a7ea95fd2b
14 changed files with 607 additions and 586 deletions

View File

@ -81,13 +81,12 @@ const Layout: FC<{ children: React.ReactNode }> = ({ children }) => {
/> />
<div className="flex flex-col flex-1 overflow-hidden"> <div className="flex flex-col flex-1 overflow-hidden">
<TopMenu onToggleOffCanvas={toggleOffCanvas} /> <TopMenu />
<main className="flex-1 p-4 overflow-y-auto">{children}</main> <main className="flex-1 p-4 overflow-y-auto">{children}</main>
<Footer className="flex-shrink-0" /> <Footer className="flex-shrink-0" />
</div> </div>
</div> </div>
<OffCanvas isOpen={isOffCanvasOpen} onClose={toggleOffCanvas} />
<Toast /> <Toast />
</Provider> </Provider>
</body> </body>

View File

@ -3,11 +3,6 @@
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, CustomButton,
CustomCheckbox, CustomCheckbox,
@ -16,37 +11,19 @@ import {
CustomRadioGroup CustomRadioGroup
} from '@/controls' } from '@/controls'
import { GetAccountResponse } from '@/models/letsEncryptServer/account/responses/GetAccountResponse' import { GetAccountResponse } from '@/models/letsEncryptServer/account/responses/GetAccountResponse'
import { deepCopy, enumToArray } from './functions' import { deepCopy, enumToArray } from '../functions'
import { CacheAccount } from '@/entities/CacheAccount' import { CacheAccount } from '@/entities/CacheAccount'
import { ChallengeTypes } from '@/entities/ChallengeTypes' import { ChallengeTypes } from '@/entities/ChallengeTypes'
import { FaPlus, FaTrash } from 'react-icons/fa' import { FaPlus, FaTrash } from 'react-icons/fa'
import { PageContainer } from '@/components/pageContainer' import { PageContainer } from '@/components/pageContainer'
import { OffCanvas } from '@/components/offcanvas'
import { AccountEdit } from '@/partials/accoutEdit'
export default function Page() { export default function Page() {
const [accounts, setAccounts] = useState<CacheAccount[]>([]) const [accounts, setAccounts] = useState<CacheAccount[]>([])
const [initialAccounts, setInitialAccounts] = useState<CacheAccount[]>([]) const [editingAccount, setEditingAccount] = useState<CacheAccount | null>(
null
const { )
value: newContact,
error: contactError,
handleChange: handleContactChange,
reset: resetContact
} = useValidation<string>({
initialValue: '',
validateFn: isValidEmail,
errorMessage: 'Invalid email format.'
})
const {
value: newHostname,
error: hostnameError,
handleChange: handleHostnameChange,
reset: resetHostname
} = useValidation<string>({
initialValue: '',
validateFn: isValidHostname,
errorMessage: 'Invalid hostname format.'
})
const init = useRef(false) const init = useRef(false)
@ -66,67 +43,36 @@ export default function Page() {
accountId: account.accountId, accountId: account.accountId,
isDisabled: account.isDisabled, isDisabled: account.isDisabled,
description: account.description, description: account.description,
contacts: account.contacts, contacts: account.contacts.map((contact) => contact),
challengeType: account.challengeType, challengeType: account.challengeType,
hostnames: hostnames:
account.hostnames?.map((h) => ({ account.hostnames?.map((hostname) => ({
hostname: h.hostname, hostname: hostname.hostname,
expires: new Date(h.expires), expires: new Date(hostname.expires),
isUpcomingExpire: h.isUpcomingExpire, isUpcomingExpire: hostname.isUpcomingExpire,
isDisabled: h.isDisabled isDisabled: hostname.isDisabled
})) ?? [], })) ?? [],
isEditMode: false, isStaging: account.isStaging,
isStaging: account.isStaging isEditMode: false
}) })
}) })
setAccounts(newAccounts) setAccounts(newAccounts)
setInitialAccounts(deepCopy(newAccounts)) // Clone initial state
} }
fetchAccounts() fetchAccounts()
init.current = true init.current = true
}, []) }, [])
const toggleEditMode = (accountId: string) => { useEffect(() => {
console.log(editingAccount)
}, [editingAccount])
const handleAccountUpdate = (updatedAccount: CacheAccount) => {
setAccounts( setAccounts(
accounts.map((account) => accounts.map((account) =>
account.accountId === accountId account.accountId === updatedAccount.accountId
? { ...account, isEditMode: !account.isEditMode } ? updatedAccount
: account
)
)
}
const handleDescriptionChange = (accountId: string, value: string) => {
setAccounts(
accounts.map((account) =>
account.accountId === accountId
? { ...account, description: value }
: account
)
)
}
const validateDescription = (description: string) => {
return description.length > 0 ? '' : 'Description is required.'
}
const handleIsDisabledChange = (accountId: string, value: boolean) => {
setAccounts(
accounts.map((account) =>
account.accountId === accountId
? { ...account, isDisabled: value }
: account
)
)
}
const handleChallengeTypeChange = (accountId: string, option: any) => {
setAccounts(
accounts.map((account) =>
account.accountId === accountId
? { ...account, challengeType: option.value }
: account : account
) )
) )
@ -139,362 +85,70 @@ export default function Page() {
// TODO: Remove from cache // 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.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 === '' || contactError) {
return
}
if (
accounts
.find((account) => account.accountId === accountId)
?.contacts.includes(newContact)
)
return
setAccounts(
accounts.map((account) =>
account.accountId === accountId
? { ...account, contacts: [...account.contacts, newContact] }
: account
)
)
resetContact()
}
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 === '' || hostnameError) {
return
}
if (
accounts
.find((account) => account.accountId === accountId)
?.hostnames.some((h) => h.hostname === newHostname)
)
return
setAccounts(
accounts.map((account) =>
account.accountId === accountId
? {
...account,
hostnames: [
...account.hostnames,
{
hostname: newHostname,
expires: new Date(),
isUpcomingExpire: false,
isDisabled: false
}
]
}
: account
)
)
resetHostname()
}
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 ( return (
<PageContainer title="LetsEncrypt Auto Renew"> <>
{accounts.map((account) => ( <PageContainer title="LetsEncrypt Auto Renew">
<div {accounts.map((account) => (
key={account.accountId} <div
className="bg-white shadow-lg rounded-lg p-6 mb-6" 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"> <div className="flex justify-between items-center mb-4">
Account: {account.accountId} <h2 className="text-2xl font-semibold">
</h2> Account: {account.accountId}
<CustomButton </h2>
onClick={() => toggleEditMode(account.accountId)} <CustomButton
className="bg-blue-500 text-white px-3 py-1 rounded" onClick={() => {
> setEditingAccount(account)
{account.isEditMode ? 'View Mode' : 'Edit Mode'} }}
</CustomButton> className="bg-blue-500 text-white px-3 py-1 rounded"
</div> >
{account.isEditMode ? ( Edit
<form onSubmit={(e) => handleSubmit(e, account.accountId)}> </CustomButton>
<div className="mb-4"> </div>
<CustomInput
value={account.description ?? ''}
onChange={(value) =>
handleDescriptionChange(account.accountId, value)
}
placeholder="Add new description"
type="text"
error={validateDescription(account.description ?? '')}
title="Description"
inputClassName="border p-2 rounded w-full"
errorClassName="text-red-500 text-sm mt-1"
className="mr-2 flex-grow"
/>
</div>
<div className="mb-4"> <div className="mb-4">
<CustomCheckbox <h3 className="text-xl font-medium mb-2">
checked={account.isDisabled} Description: {account.description}
label="Disabled" </h3>
onChange={(value) => </div>
handleIsDisabledChange(account.accountId, value)
}
/>
</div>
<div className="mb-4"> <div className="mb-4">
<h3 className="text-xl font-medium mb-2">Contacts:</h3> <CustomCheckbox
<ul className="list-disc list-inside pl-4 mb-2"> checked={account.isDisabled}
{account.contacts.map((contact) => ( label="Disabled"
<li disabled={true}
key={contact} />
className="text-gray-700 flex justify-between items-center mb-2" </div>
>
{contact}
<CustomButton
type="button"
onClick={() =>
deleteContact(account.accountId, contact)
}
className="bg-red-500 text-white p-2 rounded ml-2"
>
<FaTrash />
</CustomButton>
</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"
>
<CustomButton
type="button"
onClick={() => addContact(account.accountId)}
className="bg-green-500 text-white p-2 rounded ml-2"
>
<FaPlus />
</CustomButton>
</CustomInput>
</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>
<CustomButton
type="button"
onClick={() =>
deleteHostname(account.accountId, hostname.hostname)
}
className="bg-red-500 text-white p-2 rounded ml-2"
>
<FaTrash />
</CustomButton>
</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"
>
<CustomButton
type="button"
onClick={() => addHostname(account.accountId)}
className="bg-green-500 text-white p-2 rounded ml-2"
>
<FaPlus />
</CustomButton>
</CustomInput>
</div>
</div>
<div className="flex justify-between mt-4">
<CustomButton
onClick={() => deleteAccount(account.accountId)}
className="bg-red-500 text-white p-2 rounded ml-2"
>
<FaTrash />
</CustomButton>
<CustomButton
type="submit"
className="bg-green-500 text-white p-2 rounded ml-2"
>
Submit
</CustomButton>
</div>
</form>
) : (
<>
<div className="mb-4">
<h3 className="text-xl font-medium mb-2">
Description: {account.description}
</h3>
</div>
<div className="mb-4"> <div className="mb-4">
<CustomCheckbox <h3 className="text-xl font-medium mb-2">Contacts:</h3>
checked={account.isDisabled} <ul className="list-disc list-inside pl-4 mb-2">
label="Disabled" {account.contacts.map((contact) => (
disabled={true} <li key={contact} className="text-gray-700 mb-2">
/> {contact}
</div> </li>
))}
<div className="mb-4"> </ul>
<h3 className="text-xl font-medium mb-2">Contacts:</h3> </div>
<ul className="list-disc list-inside pl-4 mb-2"> <div className="mb-4">
{account.contacts.map((contact) => ( <CustomEnumSelect
<li key={contact} className="text-gray-700 mb-2"> title="Challenge Type"
{contact} enumType={ChallengeTypes}
</li> selectedValue={account.challengeType}
))} onChange={(option) =>
</ul> //handleChallengeTypeChange(account.accountId, option)
</div> console.log('')
<div className="mb-4"> }
<CustomEnumSelect disabled={true}
title="Challenge Type" />
enumType={ChallengeTypes} </div>
selectedValue={account.challengeType} <div className="mb-4">
onChange={(option) => <h3 className="text-xl font-medium mb-2">Hostnames:</h3>
handleChallengeTypeChange(account.accountId, option) <ul className="list-disc list-inside pl-4 mb-2">
} {account.hostnames?.map((hostname) => (
disabled={true} <li key={hostname.hostname} className="text-gray-700 mb-2">
/> <div className="inline-flex">
</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.hostname} className="text-gray-700 mb-2">
{hostname.hostname} - {hostname.expires.toDateString()} - {hostname.hostname} - {hostname.expires.toDateString()} -
<span <span
className={`ml-2 px-2 py-1 rounded ${ className={`ml-2 px-2 py-1 rounded ${
@ -512,29 +166,37 @@ export default function Page() {
label="Disabled" label="Disabled"
disabled={true} disabled={true}
/> />
</li> </div>
))} </li>
</ul> ))}
</div> </ul>
</div>
<div className="mb-4"> <div className="mb-4">
<CustomRadioGroup <CustomRadioGroup
options={[ options={[
{ value: 'staging', label: 'Staging' }, { value: 'staging', label: 'Staging' },
{ value: 'production', label: 'Production' } { value: 'production', label: 'Production' }
]} ]}
initialValue={account.isStaging ? 'staging' : 'production'} initialValue={account.isStaging ? 'staging' : 'production'}
title="LetsEncrypt Environment" title="LetsEncrypt Environment"
className="" className=""
radioClassName="" radioClassName=""
errorClassName="text-red-500 text-sm mt-1" errorClassName="text-red-500 text-sm mt-1"
disabled={true} disabled={true}
/> />
</div> </div>
</> </div>
)} ))}
</div> </PageContainer>
))}
</PageContainer> <OffCanvas
title="Edit Account"
isOpen={editingAccount !== null}
onClose={() => setEditingAccount(null)}
>
{editingAccount && <AccountEdit account={editingAccount} />}
</OffCanvas>
</>
) )
} }

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { FormEvent, useEffect, useRef, useState } from 'react' import { FormEvent, useCallback, useState } from 'react'
import { import {
useValidation, useValidation,
isBypass, isBypass,
@ -14,7 +14,7 @@ import {
CustomRadioGroup CustomRadioGroup
} from '@/controls' } from '@/controls'
import { FaTrash, FaPlus } from 'react-icons/fa' import { FaTrash, FaPlus } from 'react-icons/fa'
import { deepCopy } from '../functions' import { deepCopy } from '../../functions'
import { import {
PostAccountRequest, PostAccountRequest,
validatePostAccountRequest validatePostAccountRequest
@ -31,11 +31,14 @@ const RegisterPage = () => {
const [account, setAccount] = useState<PostAccountRequest>({ const [account, setAccount] = useState<PostAccountRequest>({
description: '', description: '',
contacts: [], contacts: [],
challengeType: '', challengeType: ChallengeTypes.http01,
hostnames: [], hostnames: [],
isStaging: true isStaging: true
}) })
const [newHostname, setNewHostname] = useState('')
const [newContact, setNewContact] = useState('')
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { const {
@ -44,7 +47,15 @@ const RegisterPage = () => {
handleChange: handleDescriptionChange, handleChange: handleDescriptionChange,
reset: resetDescription reset: resetDescription
} = useValidation<string>({ } = useValidation<string>({
initialValue: '', defaultValue: '',
externalValue: account.description,
setExternalValue: (newDescription) => {
setAccount((prev) => {
const newAccount = deepCopy(prev)
newAccount.description = newDescription
return newAccount
})
},
validateFn: isBypass, validateFn: isBypass,
errorMessage: '' errorMessage: ''
}) })
@ -55,62 +66,56 @@ const RegisterPage = () => {
handleChange: handleContactChange, handleChange: handleContactChange,
reset: resetContact reset: resetContact
} = useValidation<string>({ } = useValidation<string>({
initialValue: '', defaultValue: '',
externalValue: newContact,
setExternalValue: setNewContact,
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: challengeType,
error: challengeTypeError,
handleChange: handleChallengeTypeChange,
reset: resetChallengeType
} = useValidation<string>({
initialValue: ChallengeTypes.http01,
validateFn: isBypass,
errorMessage: ''
})
const { const {
value: hostname, value: hostname,
error: hostnameError, error: hostnameError,
handleChange: handleHostnameChange, handleChange: handleHostnameChange,
reset: resetHostname reset: resetHostname
} = useValidation<string>({ } = useValidation<string>({
initialValue: '', defaultValue: '',
externalValue: newHostname,
setExternalValue: setNewHostname,
validateFn: isValidHostname, validateFn: isValidHostname,
errorMessage: 'Invalid hostname format.' errorMessage: 'Invalid hostname format.'
}) })
const init = useRef(false) const {
useEffect(() => { value: challengeType,
if (init.current) return error: challengeTypeError,
handleChange: handleChallengeTypeChange,
init.current = true reset: resetChallengeType
}, []) } = useValidation<string>({
defaultValue: ChallengeTypes.http01,
useEffect(() => { externalValue: account.challengeType,
setAccount((prev) => { setExternalValue: (newChallengeType) => {
const newAccount = deepCopy(prev) setAccount((prev) => {
newAccount.description = description const newAccount = deepCopy(prev)
newAccount.challengeType = challengeType newAccount.challengeType = newChallengeType
return newAccount return newAccount
}) })
}, [description, challengeType]) },
validateFn: isBypass,
errorMessage: ''
})
const handleAddContact = () => { const handleAddContact = () => {
if ( if (contactError !== '') {
contact === '' ||
account?.contacts.includes(contact) ||
contactError !== ''
) {
resetContact() resetContact()
return return
} }
setAccount((prev) => { setAccount((prev) => {
const newAccount = deepCopy(prev) const newAccount = deepCopy(prev)
newAccount.contacts.push(contact) if (!newAccount.contacts.includes(contact)) {
newAccount.contacts.push(contact)
}
return newAccount return newAccount
}) })
@ -126,18 +131,16 @@ const RegisterPage = () => {
} }
const handleAddHostname = () => { const handleAddHostname = () => {
if ( if (hostnameError !== '') {
hostname === '' ||
account?.hostnames.includes(hostname) ||
hostnameError !== ''
) {
resetHostname() resetHostname()
return return
} }
setAccount((prev) => { setAccount((prev) => {
const newAccount = deepCopy(prev) const newAccount = deepCopy(prev)
newAccount.hostnames.push(hostname) if (!newAccount.hostnames.includes(hostname)) {
newAccount.hostnames.push(hostname)
}
return newAccount return newAccount
}) })
@ -183,7 +186,7 @@ const RegisterPage = () => {
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="mb-4"> <div className="mb-4">
<CustomInput <CustomInput
value={account.description} value={description}
onChange={handleDescriptionChange} onChange={handleDescriptionChange}
placeholder="Account Description" placeholder="Account Description"
type="text" type="text"
@ -224,15 +227,14 @@ const RegisterPage = () => {
inputClassName="border p-2 rounded w-full" inputClassName="border p-2 rounded w-full"
errorClassName="text-red-500 text-sm mt-1" errorClassName="text-red-500 text-sm mt-1"
className="mr-2 flex-grow" className="mr-2 flex-grow"
/>
<CustomButton
type="button"
onClick={handleAddContact}
className="bg-green-500 text-white p-2 rounded ml-2"
> >
<CustomButton <FaPlus />
type="button" </CustomButton>
onClick={handleAddContact}
className="bg-green-500 text-white p-2 rounded ml-2"
>
<FaPlus />
</CustomButton>
</CustomInput>
</div> </div>
</div> </div>
<div className="mb-4"> <div className="mb-4">
@ -240,7 +242,7 @@ const RegisterPage = () => {
error={challengeTypeError} error={challengeTypeError}
title="Challenge Type" title="Challenge Type"
enumType={ChallengeTypes} enumType={ChallengeTypes}
selectedValue={account.challengeType} selectedValue={challengeType}
onChange={handleChallengeTypeChange} onChange={handleChallengeTypeChange}
selectBoxClassName="border p-2 rounded w-full" selectBoxClassName="border p-2 rounded w-full"
errorClassName="text-red-500 text-sm mt-1" errorClassName="text-red-500 text-sm mt-1"
@ -278,15 +280,14 @@ const RegisterPage = () => {
inputClassName="border p-2 rounded w-full" inputClassName="border p-2 rounded w-full"
errorClassName="text-red-500 text-sm mt-1" errorClassName="text-red-500 text-sm mt-1"
className="mr-2 flex-grow" className="mr-2 flex-grow"
/>
<CustomButton
type="button"
onClick={handleAddHostname}
className="bg-green-500 text-white p-2 rounded ml-2"
> >
<CustomButton <FaPlus />
type="button" </CustomButton>
onClick={handleAddHostname}
className="bg-green-500 text-white p-2 rounded ml-2"
>
<FaPlus />
</CustomButton>
</CustomInput>
</div> </div>
</div> </div>

View File

@ -16,7 +16,7 @@ const Loader: React.FC = () => {
if (activeRequests > 0) { if (activeRequests > 0) {
timeout = setTimeout(() => { timeout = setTimeout(() => {
dispatch(reset()) dispatch(reset())
}, 10000) // Adjust the timeout as necessary }, 120000) // Adjust the timeout as necessary
} }
return () => { return () => {

View File

@ -1,29 +1,37 @@
import React, { FC } from 'react' import React, { FC } from 'react'
interface OffCanvasProps { interface OffCanvasProps {
title?: string
children: React.ReactNode
isOpen: boolean isOpen: boolean
onClose: () => void onClose?: () => void
} }
const OffCanvas: FC<OffCanvasProps> = ({ isOpen, onClose }) => { const OffCanvas: FC<OffCanvasProps> = (props) => {
const { title, children, isOpen, onClose } = props
const handleOnClose = () => {
onClose?.()
}
return ( return (
<div <div
className={`fixed inset-0 bg-gray-800 bg-opacity-50 z-50 transform transition-transform duration-300 ${ className={`fixed inset-0 bg-gray-800 bg-opacity-50 z-50 transform transition-transform duration-300 ${
isOpen ? 'translate-x-0' : 'translate-x-full' isOpen ? 'translate-x-0' : 'translate-x-full'
}`} }`}
onClick={onClose} onClick={handleOnClose}
> >
<div <div
className="absolute top-0 right-0 bg-white w-64 h-full shadow-lg" className="absolute top-0 right-0 bg-white max-w-full md:max-w-md lg:max-w-lg xl:max-w-xl h-full shadow-lg overflow-y-auto"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="p-4"> <div className="p-4">
<h2 className="text-xl font-bold">Settings</h2> {title && <h2 className="text-xl font-bold">{title}</h2>}
<button onClick={onClose} className="mt-4 text-red-500"> <button onClick={handleOnClose} className="mt-4 text-red-500">
Close Close
</button> </button>
</div> </div>
{/* Your off-canvas content goes here */} <div className="p-4">{children}</div>
</div> </div>
</div> </div>
) )

View File

@ -4,11 +4,9 @@ import React, { FC, useState } from 'react'
import { FaCog, FaBars } from 'react-icons/fa' import { FaCog, FaBars } from 'react-icons/fa'
import Link from 'next/link' import Link from 'next/link'
interface TopMenuProps { interface TopMenuProps {}
onToggleOffCanvas: () => void
}
const TopMenu: FC<TopMenuProps> = ({ onToggleOffCanvas }) => { const TopMenu: FC<TopMenuProps> = () => {
const [isMenuOpen, setIsMenuOpen] = useState(false) const [isMenuOpen, setIsMenuOpen] = useState(false)
const toggleMenu = () => { const toggleMenu = () => {
@ -44,9 +42,6 @@ const TopMenu: FC<TopMenuProps> = ({ onToggleOffCanvas }) => {
</ul> </ul>
)} )}
</nav> </nav>
<button onClick={onToggleOffCanvas} className="ml-4">
<FaCog />
</button>
<button onClick={toggleMenu} className="md:hidden"> <button onClick={toggleMenu} className="md:hidden">
<FaBars /> <FaBars />
</button> </button>

View File

@ -4,7 +4,7 @@ import {
CustomSelectOption, CustomSelectOption,
CustomSelectPropsBase CustomSelectPropsBase
} from './customSelect' } from './customSelect'
import { enumToArray } from '@/app/functions' import { enumToArray } from '@/functions'
interface CustomEnumSelectProps extends CustomSelectPropsBase { interface CustomEnumSelectProps extends CustomSelectPropsBase {
enumType: any enumType: any

View File

@ -3,10 +3,10 @@ import { CacheAccountHostname } from './CacheAccountHostname'
export interface CacheAccount { export interface CacheAccount {
accountId: string accountId: string
isDisabled: boolean isDisabled: boolean
description?: string description: string
contacts: string[] contacts: string[]
challengeType?: string challengeType?: string
hostnames: CacheAccountHostname[] hostnames?: CacheAccountHostname[]
isEditMode: boolean isEditMode: boolean
isStaging: boolean isStaging: boolean
} }

View File

@ -1,63 +1,47 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
// Helper functions for validation // Helper functions for validation
const isBypass = (value: any) => { const isBypass = (value: any) => true
return true const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
} const isValidPhoneNumber = (phone: string) => /^\+?[1-9]\d{1,14}$/.test(phone)
const isValidContact = (contact: string) =>
const isValidEmail = (email: string) => { isValidEmail(contact) || isValidPhoneNumber(contact)
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const isValidHostname = (hostname: string) =>
return emailRegex.test(email) /^(?!:\/\/)([a-zA-Z0-9-_]{1,63}\.?)+[a-zA-Z]{2,6}$/.test(hostname)
}
const isValidPhoneNumber = (phone: string) => {
const phoneRegex = /^\+?[1-9]\d{1,14}$/
return phoneRegex.test(phone)
}
const isValidContact = (contact: string) => {
return isValidEmail(contact) || isValidPhoneNumber(contact)
}
const isValidHostname = (hostname: string) => {
const hostnameRegex = /^(?!:\/\/)([a-zA-Z0-9-_]{1,63}\.?)+[a-zA-Z]{2,6}$/
return hostnameRegex.test(hostname)
}
// Props interface for useValidation hook // Props interface for useValidation hook
interface UseValidationProps<T> { interface UseValidationProps<T> {
initialValue: T externalValue: T
setExternalValue: (value: T) => void
validateFn: (value: T) => boolean validateFn: (value: T) => boolean
errorMessage: string errorMessage: string
emptyFieldMessage?: string // Optional custom message for empty fields emptyFieldMessage?: string
defaultResetValue?: T // Optional default reset value defaultValue: T
} }
// Custom hook for input validation // Custom hook for input validation
const useValidation = <T extends string | number | Date>( const useValidation = <T extends string | number | Date | boolean>(
props: UseValidationProps<T> props: UseValidationProps<T>
) => { ) => {
const { const {
initialValue, externalValue,
setExternalValue,
validateFn, validateFn,
errorMessage, errorMessage,
emptyFieldMessage = 'This field cannot be empty.', // Default message emptyFieldMessage = 'This field cannot be empty.',
defaultResetValue defaultValue
} = props } = props
const [value, setValue] = useState<T>(initialValue) const [internalValue, setInternalValue] = useState(externalValue)
const [error, setError] = useState('') const [error, setError] = useState('')
const handleChange = useCallback( const validate = useCallback(
(newValue: T) => { (value: T) => {
setValue(newValue)
const stringValue = const stringValue =
newValue instanceof Date value instanceof Date ? value.toISOString() : value.toString().trim()
? newValue.toISOString()
: newValue.toString().trim()
if (stringValue === '') { if (stringValue === '') {
setError(emptyFieldMessage) setError(emptyFieldMessage)
} else if (!validateFn(newValue)) { } else if (!validateFn(value)) {
setError(errorMessage) setError(errorMessage)
} else { } else {
setError('') setError('')
@ -66,27 +50,26 @@ const useValidation = <T extends string | number | Date>(
[emptyFieldMessage, errorMessage, validateFn] [emptyFieldMessage, errorMessage, validateFn]
) )
const handleChange = useCallback(
(newValue: T) => {
setInternalValue(newValue)
setExternalValue(newValue)
validate(newValue)
},
[setExternalValue, validate]
)
useEffect(() => { useEffect(() => {
handleChange(initialValue) setInternalValue(externalValue)
}, [initialValue, handleChange]) validate(externalValue)
}, [externalValue, validate])
const reset = useCallback(() => { const reset = useCallback(() => {
const resetValue = setInternalValue(defaultValue)
defaultResetValue !== undefined ? defaultResetValue : initialValue setError('')
}, [defaultValue])
setValue(resetValue) return { value: internalValue, error, handleChange, reset }
const stringValue =
resetValue instanceof Date
? resetValue.toISOString()
: resetValue.toString().trim()
if (stringValue === '') {
setError(emptyFieldMessage)
} else {
setError('')
}
}, [defaultResetValue, initialValue, emptyFieldMessage])
return { value, error, handleChange, reset }
} }
export { export {

View File

@ -0,0 +1,370 @@
'use client'
import { FormEvent, useCallback, useState } from 'react'
import {
useValidation,
isValidEmail,
isValidHostname,
isBypass
} from '@/hooks/useValidation'
import {
CustomButton,
CustomCheckbox,
CustomEnumSelect,
CustomInput,
CustomRadioGroup
} from '@/controls'
import { CacheAccount } from '@/entities/CacheAccount'
import { FaPlus, FaTrash } from 'react-icons/fa'
import { ChallengeTypes } from '@/entities/ChallengeTypes'
interface AccountEditProps {
account: CacheAccount
onCancel?: () => void
onSave?: (account: CacheAccount) => void
onDelete?: (accountId: string) => void
}
const AccountEdit: React.FC<AccountEditProps> = (props) => {
const { account, onCancel, onSave, onDelete } = props
const [editingAccount, setEditingAccount] = useState<CacheAccount>(account)
const [newContact, setNewContact] = useState('')
const [newHostname, setNewHostname] = useState('')
const setDescription = useCallback(
(newDescription: string) => {
if (editingAccount) {
setEditingAccount({ ...editingAccount, description: newDescription })
}
},
[editingAccount]
)
const {
value: description,
error: descriptionError,
handleChange: handleDescriptionChange,
reset: resetDescription
} = useValidation<string>({
defaultValue: '',
externalValue: account.description,
setExternalValue: setDescription,
validateFn: isBypass,
errorMessage: ''
})
const {
value: contact,
error: contactError,
handleChange: handleContactChange,
reset: resetContact
} = useValidation<string>({
defaultValue: '',
externalValue: newContact,
setExternalValue: setNewContact,
validateFn: isValidEmail,
errorMessage: 'Invalid email format.'
})
const {
value: hostname,
error: hostnameError,
handleChange: handleHostnameChange,
reset: resetHostname
} = useValidation<string>({
defaultValue: '',
externalValue: newHostname,
setExternalValue: setNewHostname,
validateFn: isValidHostname,
errorMessage: 'Invalid hostname format.'
})
const handleIsDisabledChange = (value: boolean) => {
// setAccount({ ...account, isDisabled: value })
}
const handleChallengeTypeChange = (option: any) => {
//setAccount({ ...account, challengeType: option.value })
}
const handleHostnameDisabledChange = (hostname: string, value: boolean) => {
// setAccount({
// ...account,
// hostnames: account.hostnames.map((h) =>
// h.hostname === hostname ? { ...h, isDisabled: value } : h
// )
// })
// }
// const handleStagingChange = (value: string) => {
// setAccount({ ...account, isStaging: value === 'staging' })
}
const deleteContact = (contact: string) => {
if (account?.contacts.length ?? 0 < 1) return
// setAccount({
// ...account,
// contacts: account.contacts.filter((c) => c !== contact)
// })
// }
// const addContact = () => {
// if (newContact === '' || contactError) {
// return
// }
// if (account.contacts.includes(newContact)) return
// setAccount({ ...account, contacts: [...account.contacts, newContact] })
// resetContact()
}
const deleteHostname = (hostname: string) => {
//if (account?.hostnames.length ?? 0 < 1) return
// setAccount({
// ...account,
// hostnames: account.hostnames.filter((h) => h.hostname !== hostname)
// })
// }
// const addHostname = () => {
// if (newHostname === '' || hostnameError) {
// return
// }
// if (account.hostnames.some((h) => h.hostname === newHostname)) return
// setAccount({
// ...account,
// hostnames: [
// ...account.hostnames,
// {
// hostname: newHostname,
// expires: new Date(),
// isUpcomingExpire: false,
// isDisabled: false
// }
// ]
// })
resetHostname()
}
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
// const contactChanges = {
// added: account.contacts.filter(
// (contact) => !initialAccountState.contacts.includes(contact)
// ),
// removed: initialAccountState.contacts.filter(
// (contact) => !account.contacts.includes(contact)
// )
// }
// const hostnameChanges = {
// added: account.hostnames.filter(
// (hostname) =>
// !initialAccountState.hostnames.some(
// (h) => h.hostname === hostname.hostname
// )
// ),
// removed: initialAccountState.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)
// }
// onSave(account)
}
return (
<form onSubmit={handleSubmit}>
<div className="mb-4">
<CustomInput
value={description}
onChange={handleDescriptionChange}
placeholder="Add new description"
type="text"
error={descriptionError}
title="Description"
inputClassName="border p-2 rounded w-full"
errorClassName="text-red-500 text-sm mt-1"
className="mr-2 flex-grow"
/>
</div>
<div className="mb-4">
<CustomCheckbox
checked={account.isDisabled}
label="Disabled"
onChange={(value) => handleIsDisabledChange(value)}
className="mr-2 flex-grow"
/>
</div>
<div className="mb-4">
<CustomEnumSelect
title="Challenge Type"
enumType={ChallengeTypes}
selectedValue={account.challengeType}
onChange={(option) => handleChallengeTypeChange(option)}
className="mr-2 flex-grow"
/>
</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 inline-flex">
{contact}
<CustomButton
type="button"
onClick={() => deleteContact(contact)}
className="bg-red-500 text-white p-2 rounded ml-2"
>
<FaTrash />
</CustomButton>
</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"
/>
<CustomButton
type="button"
//onClick={addContact}
className="bg-green-500 text-white p-2 rounded ml-2"
>
<FaPlus />
</CustomButton>
</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 mb-2">
<div className="inline-flex">
{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>{' '}
-{' '}
<CustomCheckbox
className="ml-2"
checked={hostname.isDisabled}
label="Disabled"
onChange={(value) =>
handleHostnameDisabledChange(hostname.hostname, value)
}
/>
</div>
<CustomButton
type="button"
onClick={() => deleteHostname(hostname.hostname)}
className="bg-red-500 text-white p-2 rounded ml-2"
>
<FaTrash />
</CustomButton>
</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"
/>
<CustomButton
type="button"
//onClick={addHostname}
className="bg-green-500 text-white p-2 rounded ml-2"
>
<FaPlus />
</CustomButton>
</div>
</div>
<div className="mb-4">
<CustomRadioGroup
options={[
{ value: 'staging', label: 'Staging' },
{ value: 'production', label: 'Production' }
]}
initialValue={account.isStaging ? 'staging' : 'production'}
title="LetsEncrypt Environment"
className="mr-2 flex-grow"
radioClassName=""
errorClassName="text-red-500 text-sm mt-1"
disabled={true}
/>
</div>
<div className="flex justify-between mt-4">
<CustomButton
//onClick={() => onDelete(account.accountId)}
className="bg-red-500 text-white p-2 rounded ml-2"
>
<FaTrash />
</CustomButton>
<CustomButton
onClick={onCancel}
className="bg-yellow-500 text-white p-2 rounded ml-2"
>
Cancel
</CustomButton>
<CustomButton
type="submit"
className="bg-green-500 text-white p-2 rounded ml-2"
>
Save
</CustomButton>
</div>
</form>
)
}
export { AccountEdit }

View File

@ -72,6 +72,9 @@ public class AccountService : IAccountService {
} }
public async Task<(GetAccountResponse?, IDomainResult)> PostAccountAsync(PostAccountRequest requestData) { public async Task<(GetAccountResponse?, IDomainResult)> PostAccountAsync(PostAccountRequest requestData) {
// TODO: check for overlapping hostnames in already existing accounts
var (sessionId, configureClientResult) = await _certsFlowService.ConfigureClientAsync(requestData.IsStaging); var (sessionId, configureClientResult) = await _certsFlowService.ConfigureClientAsync(requestData.IsStaging);
if (!configureClientResult.IsSuccess || sessionId == null) { if (!configureClientResult.IsSuccess || sessionId == null) {
//LogErrors(configureClientResult.Errors); //LogErrors(configureClientResult.Errors);