(feature): create account from UI, general improvements

This commit is contained in:
Maksym Sadovnychyy 2024-06-27 01:13:33 +02:00
parent 4000026b7a
commit c817bc1038
45 changed files with 931 additions and 329 deletions

View File

@ -1,9 +1,14 @@
enum ApiRoutes {
ACCOUNTS = 'api/accounts',
ACCOUNT = 'api/account/{accountId}',
ACCOUNT_CONTACTS = 'api/account/{accountId}/contacts',
ACCOUNT_CONTACT = 'api/account/{accountId}/contact/{index}',
ACCOUNT_HOSTNAMES = 'api/account/{accountId}/hostnames'
ACCOUNT = 'api/account',
ACCOUNT_ID = 'api/account/{accountId}',
ACCOUNT_ID_CONTACTS = 'api/account/{accountId}/contacts',
ACCOUNT_ID_CONTACT_ID = 'api/account/{accountId}/contact/{index}',
ACCOUNT_ID_HOSTNAMES = 'api/account/{accountId}/hostnames',
ACCOUNT_ID_HOSTNAME_ID = 'api/account/{accountId}/hostname/{index}'
// CERTS_FLOW_CONFIGURE_CLIENT = `api/CertsFlow/ConfigureClient`,
// CERTS_FLOW_TERMS_OF_SERVICE = `api/CertsFlow/TermsOfService/{sessionId}`,

View File

@ -0,0 +1,45 @@
interface EnumKeyValue {
key: string
value: string | number
}
const enumToArray = <T extends { [key: string]: string | number }>(
enumObj: T
): EnumKeyValue[] => {
return Object.keys(enumObj)
.filter((key) => isNaN(Number(key))) // Ensure that only string keys are considered
.map((key) => ({
key,
value: enumObj[key as keyof typeof enumObj]
}))
.map((entry) => ({
key: entry.key,
value:
typeof entry.value === 'string' && !isNaN(Number(entry.value))
? Number(entry.value)
: entry.value
}))
}
const enumToObject = <T extends { [key: string]: string | number }>(
enumObj: T
): { [key: string]: EnumKeyValue } => {
return Object.keys(enumObj)
.filter((key) => isNaN(Number(key))) // Ensure that only string keys are considered
.reduce(
(acc, key) => {
const value = enumObj[key as keyof typeof enumObj]
acc[key] = {
key,
value:
typeof value === 'string' && !isNaN(Number(value))
? Number(value)
: value
}
return acc
},
{} as { [key: string]: EnumKeyValue }
)
}
export { enumToArray, enumToObject }

View File

@ -1,3 +1,4 @@
import { deepCopy } from './deepCopy'
import { enumToArray, enumToObject } from './enums'
export { deepCopy }
export { deepCopy, enumToArray, enumToObject }

View File

@ -8,11 +8,19 @@ import {
isValidEmail,
isValidHostname
} from '@/hooks/useValidation'
import { CustomButton, CustomInput } from '@/controls'
import { TrashIcon, PlusIcon } from '@heroicons/react/24/solid'
import {
CustomButton,
CustomCheckbox,
CustomEnumSelect,
CustomInput,
CustomRadioGroup
} from '@/controls'
import { GetAccountResponse } from '@/models/letsEncryptServer/account/responses/GetAccountResponse'
import { deepCopy } from './functions'
import { deepCopy, enumToArray } from './functions'
import { CacheAccount } from '@/entities/CacheAccount'
import { ChallengeTypes } from '@/entities/ChallengeTypes'
import { FaPlus, FaTrash } from 'react-icons/fa'
import { PageContainer } from '@/components/pageContainer'
export default function Page() {
const [accounts, setAccounts] = useState<CacheAccount[]>([])
@ -56,6 +64,7 @@ export default function Page() {
accounts?.forEach((account) => {
newAccounts.push({
accountId: account.accountId,
isDisabled: account.isDisabled,
description: account.description,
contacts: account.contacts,
challengeType: account.challengeType,
@ -63,9 +72,11 @@ export default function Page() {
account.hostnames?.map((h) => ({
hostname: h.hostname,
expires: new Date(h.expires),
isUpcomingExpire: h.isUpcomingExpire
isUpcomingExpire: h.isUpcomingExpire,
isDisabled: h.isDisabled
})) ?? [],
isEditMode: false
isEditMode: false,
isStaging: account.isStaging
})
})
@ -87,6 +98,40 @@ export default function Page() {
)
}
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
)
)
}
const deleteAccount = (accountId: string) => {
setAccounts(accounts.filter((account) => account.accountId !== accountId))
@ -99,20 +144,20 @@ export default function Page() {
if (account?.contacts.length ?? 0 < 1) return
// TODO: Remove from cache
httpService.delete(
GetApiRoute(ApiRoutes.ACCOUNT_CONTACT, accountId, contact)
)
// httpService.delete(
// GetApiRoute(ApiRoutes.ACCOUNT_CONTACT, accountId, contact)
// )
setAccounts(
accounts.map((account) =>
account.accountId === accountId
? {
...account,
contacts: account.contacts.filter((c) => c !== contact)
}
: account
)
)
// setAccounts(
// accounts.map((account) =>
// account.accountId === accountId
// ? {
// ...account,
// contacts: account.contacts.filter((c) => c !== contact)
// }
// : account
// )
// )
}
const addContact = (accountId: string) => {
@ -180,7 +225,8 @@ export default function Page() {
{
hostname: newHostname,
expires: new Date(),
isUpcomingExpire: false
isUpcomingExpire: false,
isDisabled: false
}
]
}
@ -251,10 +297,7 @@ export default function Page() {
}
return (
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold text-center mb-8">
LetsEncrypt Auto Renew
</h1>
<PageContainer title="LetsEncrypt Auto Renew">
{accounts.map((account) => (
<div
key={account.accountId}
@ -274,8 +317,31 @@ export default function Page() {
{account.isEditMode ? (
<form onSubmit={(e) => handleSubmit(e, account.accountId)}>
<div className="mb-4">
<h3 className="text-xl font-medium mb-2">Description:</h3>
<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">
<CustomCheckbox
checked={account.isDisabled}
label="Disabled"
onChange={(value) =>
handleIsDisabledChange(account.accountId, value)
}
/>
</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">
@ -292,7 +358,7 @@ export default function Page() {
}
className="bg-red-500 text-white p-2 rounded ml-2"
>
<TrashIcon className="h-5 w-5 text-white" />
<FaTrash />
</CustomButton>
</li>
))}
@ -314,7 +380,7 @@ export default function Page() {
onClick={() => addContact(account.accountId)}
className="bg-green-500 text-white p-2 rounded ml-2"
>
<PlusIcon className="h-5 w-5 text-white" />
<FaPlus />
</CustomButton>
</CustomInput>
</div>
@ -345,7 +411,7 @@ export default function Page() {
}
className="bg-red-500 text-white p-2 rounded ml-2"
>
<TrashIcon className="h-5 w-5 text-white" />
<FaTrash />
</CustomButton>
</li>
))}
@ -367,7 +433,7 @@ export default function Page() {
onClick={() => addHostname(account.accountId)}
className="bg-green-500 text-white p-2 rounded ml-2"
>
<PlusIcon className="h-5 w-5 text-white" />
<FaPlus />
</CustomButton>
</CustomInput>
</div>
@ -377,7 +443,7 @@ export default function Page() {
onClick={() => deleteAccount(account.accountId)}
className="bg-red-500 text-white p-2 rounded ml-2"
>
<TrashIcon className="h-5 w-5 text-white" />
<FaTrash />
</CustomButton>
<CustomButton
type="submit"
@ -394,6 +460,15 @@ export default function Page() {
Description: {account.description}
</h3>
</div>
<div className="mb-4">
<CustomCheckbox
checked={account.isDisabled}
label="Disabled"
disabled={true}
/>
</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">
@ -405,31 +480,61 @@ export default function Page() {
</ul>
</div>
<div className="mb-4">
<h3 className="text-xl font-medium mb-2">
Challenge type: {account.challengeType}
</h3>
<CustomEnumSelect
title="Challenge Type"
enumType={ChallengeTypes}
selectedValue={account.challengeType}
onChange={(option) =>
handleChallengeTypeChange(account.accountId, option)
}
disabled={true}
/>
</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.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'}`}
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
checked={hostname.isDisabled}
label="Disabled"
disabled={true}
/>
</li>
))}
</ul>
</div>
<div className="mb-4">
<CustomRadioGroup
options={[
{ value: 'staging', label: 'Staging' },
{ value: 'production', label: 'Production' }
]}
initialValue={account.isStaging ? 'staging' : 'production'}
title="LetsEncrypt Environment"
className=""
radioClassName=""
errorClassName="text-red-500 text-sm mt-1"
disabled={true}
/>
</div>
</>
)}
</div>
))}
</div>
</PageContainer>
)
}

View File

@ -1,31 +1,56 @@
'use client'
import { ApiRoutes, GetApiRoute } from '@/ApiRoutes'
import { httpService } from '@/services/httpService'
import { FormEvent, useEffect, useRef, useState } from 'react'
import {
useValidation,
isBypass,
isValidContact,
isValidHostname
} from '@/hooks/useValidation'
import { CustomButton, CustomInput } from '@/controls'
import {
CustomButton,
CustomEnumSelect,
CustomInput,
CustomRadioGroup
} from '@/controls'
import { FaTrash, FaPlus } from 'react-icons/fa'
import { deepCopy } from '../functions'
import {
PostAccountRequest,
validatePostAccountRequest
} from '@/models/letsEncryptServer/certsFlow/PostAccountRequest'
import App from 'next/app'
import { useAppDispatch } from '@/redux/store'
import { showToast } from '@/redux/slices/toastSlice'
import { ChallengeTypes } from '@/entities/ChallengeTypes'
import { GetAccountResponse } from '@/models/letsEncryptServer/account/responses/GetAccountResponse'
import { httpService } from '@/services/httpService'
import { ApiRoutes, GetApiRoute } from '@/ApiRoutes'
import { PageContainer } from '@/components/pageContainer'
const RegisterPage = () => {
const [account, setAccount] = useState<PostAccountRequest | null>(null)
const [account, setAccount] = useState<PostAccountRequest>({
description: '',
contacts: [],
challengeType: '',
hostnames: [],
isStaging: true
})
const dispatch = useAppDispatch()
const {
value: newContact,
value: description,
error: descriptionError,
handleChange: handleDescriptionChange,
reset: resetDescription
} = useValidation<string>({
initialValue: '',
validateFn: isBypass,
errorMessage: ''
})
const {
value: contact,
error: contactError,
handleChange: handleContactChange,
reset: resetContact
@ -36,7 +61,18 @@ const RegisterPage = () => {
})
const {
value: newHostname,
value: challengeType,
error: challengeTypeError,
handleChange: handleChallengeTypeChange,
reset: resetChallengeType
} = useValidation<string>({
initialValue: ChallengeTypes.http01,
validateFn: isBypass,
errorMessage: ''
})
const {
value: hostname,
error: hostnameError,
handleChange: handleHostnameChange,
reset: resetHostname
@ -47,19 +83,25 @@ const RegisterPage = () => {
})
const init = useRef(false)
useEffect(() => {
if (init.current) return
init.current = true
}, [])
const handleDescription = (description: string) => {}
useEffect(() => {
setAccount((prev) => {
const newAccount = deepCopy(prev)
newAccount.description = description
newAccount.challengeType = challengeType
return newAccount
})
}, [description, challengeType])
const handleAddContact = () => {
if (
newContact === '' ||
account?.contacts.includes(newContact) ||
contact === '' ||
account?.contacts.includes(contact) ||
contactError !== ''
) {
resetContact()
@ -67,26 +109,26 @@ const RegisterPage = () => {
}
setAccount((prev) => {
const newAccount: PostAccountRequest =
prev !== null
? deepCopy(prev)
: {
contacts: [],
hostnames: []
}
newAccount.contacts.push(newContact)
const newAccount = deepCopy(prev)
newAccount.contacts.push(contact)
return newAccount
})
resetContact()
}
const handleDeleteContact = (contact: string) => {
setAccount((prev) => {
const newAccount = deepCopy(prev)
newAccount.contacts = newAccount.contacts.filter((c) => c !== contact)
return newAccount
})
}
const handleAddHostname = () => {
if (
newHostname === '' ||
account?.hostnames.includes(newHostname) ||
hostname === '' ||
account?.hostnames.includes(hostname) ||
hostnameError !== ''
) {
resetHostname()
@ -94,40 +136,18 @@ const RegisterPage = () => {
}
setAccount((prev) => {
const newAccount: PostAccountRequest =
prev !== null
? deepCopy(prev)
: {
contacts: [],
hostnames: []
}
newAccount.hostnames.push(newHostname)
const newAccount = deepCopy(prev)
newAccount.hostnames.push(hostname)
return newAccount
})
resetHostname()
}
const handleDeleteContact = (contact: string) => {
setAccount((prev) => {
if (prev === null) return null
const newAccount = deepCopy(prev)
newAccount.contacts = newAccount.contacts.filter((c) => c !== contact)
return newAccount
})
}
const handleDeleteHostname = (hostname: string) => {
setAccount((prev) => {
if (prev === null) return null
const newAccount = deepCopy(prev)
newAccount.hostnames = newAccount.hostnames.filter((h) => h !== hostname)
return newAccount
})
}
@ -135,41 +155,49 @@ const RegisterPage = () => {
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
const error = validatePostAccountRequest(account)
if (error) {
console.error(`Validation failed: ${error}`)
// dipatch toasterror
dispatch(showToast({ message: error, type: 'error' }))
const errors = validatePostAccountRequest(account)
return
if (errors.length > 0) {
errors.forEach((error) => {
dispatch(showToast({ message: error, type: 'error' }))
})
} else {
dispatch(
showToast({ message: 'Request model is valid', type: 'success' })
)
httpService
.post<
PostAccountRequest,
GetAccountResponse
>(GetApiRoute(ApiRoutes.ACCOUNT), account)
.then((response) => {
console.log(response)
dispatch(showToast({ message: 'Account created', type: 'success' }))
})
}
// httpService.post<PostAccountRequest, GetAccountResponse>('', account)
console.log(account)
}
return (
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold text-center mb-8">
Register LetsEncrypt Account
</h1>
<PageContainer title="Register LetsEncrypt Account">
<form onSubmit={handleSubmit}>
<div className="mb-4">
<CustomInput
type="text"
value={account?.description ?? ''}
onChange={handleDescription}
value={account.description}
onChange={handleDescriptionChange}
placeholder="Account Description"
type="text"
error={descriptionError}
title="Description"
inputClassName="border p-2 rounded w-full"
className="mb-4"
errorClassName="text-red-500 text-sm mt-1"
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) => (
{account.contacts.map((contact) => (
<li
key={contact}
className="text-gray-700 flex justify-between items-center mb-2"
@ -187,7 +215,7 @@ const RegisterPage = () => {
</ul>
<div className="flex items-center mb-4">
<CustomInput
value={newContact}
value={contact}
onChange={handleContactChange}
placeholder="Add contact"
type="text"
@ -207,10 +235,23 @@ const RegisterPage = () => {
</CustomInput>
</div>
</div>
<div className="mb-4">
<CustomEnumSelect
error={challengeTypeError}
title="Challenge Type"
enumType={ChallengeTypes}
selectedValue={account.challengeType}
onChange={handleChallengeTypeChange}
selectBoxClassName="border p-2 rounded w-full"
errorClassName="text-red-500 text-sm mt-1"
className="mr-2 flex-grow"
/>
</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) => (
{account.hostnames.map((hostname) => (
<li
key={hostname}
className="text-gray-700 flex justify-between items-center mb-2"
@ -228,7 +269,7 @@ const RegisterPage = () => {
</ul>
<div className="flex items-center">
<CustomInput
value={newHostname}
value={hostname}
onChange={handleHostnameChange}
placeholder="Add hostname"
type="text"
@ -248,6 +289,28 @@ const RegisterPage = () => {
</CustomInput>
</div>
</div>
<div className="mb-4">
<CustomRadioGroup
options={[
{ value: 'staging', label: 'Staging' },
{ value: 'production', label: 'Production' }
]}
initialValue={account.isStaging ? 'staging' : 'production'}
onChange={(value) => {
setAccount((prev) => {
const newAccount = deepCopy(prev)
newAccount.isStaging = value === 'staging'
return newAccount
})
}}
title="LetsEncrypt Environment"
className=""
radioClassName=""
errorClassName="text-red-500 text-sm mt-1"
/>
</div>
<CustomButton
type="submit"
className="bg-green-500 text-white px-3 py-1 rounded"
@ -255,7 +318,7 @@ const RegisterPage = () => {
Create Account
</CustomButton>
</form>
</div>
</PageContainer>
)
}

View File

@ -0,0 +1,19 @@
interface PageContainerProps {
title?: string
children: React.ReactNode
}
const PageContainer: React.FC<PageContainerProps> = (props) => {
const { title, children } = props
return (
<div className="container mx-auto p-4">
{title && (
<h1 className="text-4xl font-bold text-center mb-8">{title}</h1>
)}
{children}
</div>
)
}
export { PageContainer }

View File

@ -4,33 +4,34 @@ import { ToastContainer, toast } from 'react-toastify'
import 'react-toastify/dist/ReactToastify.css'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '@/redux/store'
import { clearToast } from '@/redux/slices/toastSlice'
import { clearToast, removeToast } from '@/redux/slices/toastSlice'
const Toast = () => {
const dispatch = useDispatch()
const toastState = useSelector((state: RootState) => state.toast)
useEffect(() => {
if (toastState.message) {
switch (toastState.type) {
toastState.messages.forEach((toastMessage) => {
switch (toastMessage.type) {
case 'success':
toast.success(toastState.message)
toast.success(toastMessage.message)
break
case 'error':
toast.error(toastState.message)
toast.error(toastMessage.message)
break
case 'info':
toast.info(toastState.message)
toast.info(toastMessage.message)
break
case 'warning':
toast.warn(toastState.message)
toast.warn(toastMessage.message)
break
default:
toast(toastState.message)
toast(toastMessage.message)
break
}
dispatch(clearToast())
}
dispatch(removeToast(toastMessage.id))
})
}, [toastState, dispatch])
return (

View File

@ -0,0 +1,57 @@
// components/CustomCheckbox.tsx
import React, { FC } from 'react'
interface CustomCheckboxProps {
checked: boolean
onChange?: (checked: boolean) => void
label?: string
checkboxClassName?: string
labelClassName?: string
error?: string
errorClassName?: string
className?: string
readOnly?: boolean
disabled?: boolean
}
const CustomCheckbox: FC<CustomCheckboxProps> = (props) => {
const {
checked,
onChange,
label,
checkboxClassName = '',
labelClassName = '',
error,
errorClassName = '',
className = '',
readOnly = false,
disabled = false
} = props
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!readOnly && !disabled) {
onChange?.(e.target.checked)
}
}
return (
<div className={`flex flex-col ${className}`}>
<label className={`flex items-center ${labelClassName}`}>
<input
type="checkbox"
checked={checked}
onChange={handleChange}
className={`mr-2 ${checkboxClassName}`}
readOnly={readOnly}
disabled={disabled}
/>
{label && <span>{label}</span>}
</label>
{error && (
<p className={`text-red-500 mt-1 ${errorClassName}`}>{error}</p>
)}
</div>
)
}
export { CustomCheckbox }

View File

@ -0,0 +1,28 @@
import React from 'react'
import {
CustomSelect,
CustomSelectOption,
CustomSelectPropsBase
} from './customSelect'
import { enumToArray } from '@/app/functions'
interface CustomEnumSelectProps extends CustomSelectPropsBase {
enumType: any
}
const CustomEnumSelect: React.FC<CustomEnumSelectProps> = (props) => {
const { enumType, ...customSelectProps } = props
const options = enumToArray(enumType).map((item) => {
const option: CustomSelectOption = {
value: `${item.value}`,
label: item.key
}
return option
})
return <CustomSelect options={options} {...customSelectProps} />
}
export { CustomEnumSelect }

View File

@ -12,6 +12,8 @@ interface CustomInputProps {
inputClassName?: string
errorClassName?: string
className?: string
readOnly?: boolean
disabled?: boolean
children?: React.ReactNode // Added for additional elements
}
@ -25,6 +27,8 @@ const CustomInput: FC<CustomInputProps> = ({
inputClassName = '',
errorClassName = '',
className = '',
readOnly = false,
disabled = false,
children // Added for additional elements
}) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -41,6 +45,8 @@ const CustomInput: FC<CustomInputProps> = ({
onChange={handleChange}
placeholder={placeholder}
className={`flex-grow ${inputClassName}`}
readOnly={readOnly}
disabled={disabled}
/>
{children && <div className="ml-2">{children}</div>}
</div>

View File

@ -0,0 +1,77 @@
// components/CustomRadioGroup.tsx
import React, { useState, useEffect } from 'react'
interface CustomRadioOption {
value: string
label: string
}
interface CustomRadioGroupProps {
options: CustomRadioOption[]
initialValue?: string
onChange?: (value: string) => void
title?: string
error?: string
className?: string
radioClassName?: string
errorClassName?: string
readOnly?: boolean
disabled?: boolean
}
const CustomRadioGroup: React.FC<CustomRadioGroupProps> = ({
options,
initialValue,
onChange,
title,
error,
className = '',
radioClassName = '',
errorClassName = '',
readOnly = false,
disabled = false
}) => {
const [selectedValue, setSelectedValue] = useState(initialValue || '')
useEffect(() => {
if (initialValue) {
setSelectedValue(initialValue)
}
}, [initialValue])
const handleOptionChange = (value: string) => {
if (!readOnly && !disabled) {
setSelectedValue(value)
onChange?.(value)
}
}
return (
<div className={`flex flex-col ${className}`}>
{title && <label className="mb-1">{title}</label>}
<div className="flex flex-col">
{options.map((option) => (
<label
key={option.value}
className={`flex items-center mb-2 ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
>
<input
type="radio"
value={option.value}
checked={selectedValue === option.value}
onChange={() => handleOptionChange(option.value)}
className={`mr-2 ${radioClassName}`}
disabled={disabled}
/>
{option.label}
</label>
))}
</div>
{error && (
<p className={`text-red-500 mt-1 ${errorClassName}`}>{error}</p>
)}
</div>
)
}
export { CustomRadioGroup }

View File

@ -0,0 +1,110 @@
import React, { useRef, useEffect, useState } from 'react'
import { FaChevronDown, FaChevronUp } from 'react-icons/fa'
export interface CustomSelectOption {
value: string
label: string
}
export interface CustomSelectPropsBase {
selectedValue: string | null | undefined
onChange: (value: string) => void
readOnly?: boolean
disabled?: boolean
title?: string
error?: string
className?: string
selectBoxClassName?: string
errorClassName?: string
}
interface CustomSelectProps extends CustomSelectPropsBase {
options: CustomSelectOption[]
}
const CustomSelect: React.FC<CustomSelectProps> = ({
options,
selectedValue,
onChange,
readOnly = false,
disabled = false,
title,
error,
className = '',
selectBoxClassName = '',
errorClassName = ''
}) => {
const [isOpen, setIsOpen] = useState(false)
const selectBoxRef = useRef<HTMLDivElement>(null)
const handleToggle = () => {
if (!readOnly && !disabled) {
setIsOpen(!isOpen)
}
}
const handleOptionClick = (option: CustomSelectOption) => {
if (!readOnly && !disabled) {
onChange(option.value)
setIsOpen(false)
}
}
const handleClickOutside = (event: MouseEvent) => {
if (
selectBoxRef.current &&
!selectBoxRef.current.contains(event.target as Node)
) {
setIsOpen(false)
}
}
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
const selectedOption =
options.find((option) => option.value === selectedValue) || null
return (
<div className={`flex flex-col ${className}`} ref={selectBoxRef}>
{title && <label className="mb-1">{title}</label>}
<div
className={`relative w-64 ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div
className={`p-2 border ${disabled ? 'border-gray-200' : 'border-gray-300'} rounded cursor-pointer flex justify-between items-center ${disabled ? 'cursor-not-allowed' : ''} ${selectBoxClassName}`}
onClick={handleToggle}
>
{selectedOption ? selectedOption.label : 'Select an option'}
{isOpen ? (
<FaChevronUp className="ml-2" />
) : (
<FaChevronDown className="ml-2" />
)}
</div>
{isOpen && (
<ul className="absolute z-10 w-full mt-1 overflow-y-auto bg-white border border-gray-300 max-h-60">
{options.map((option) => (
<li
key={option.value}
className={`p-2 hover:bg-gray-200 ${readOnly || disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
onClick={() => handleOptionClick(option)}
>
{option.label}
</li>
))}
</ul>
)}
</div>
{error && (
<p className={`text-red-500 mt-1 ${errorClassName}`}>{error}</p>
)}
</div>
)
}
export { CustomSelect }

View File

@ -1,4 +1,15 @@
import { CustomButton } from './customButton'
import { CustomInput } from './customInput'
import { CustomCheckbox } from './customCheckbox'
import { CustomSelect } from './customSelect'
import { CustomEnumSelect } from './customEnumSelect'
import { CustomRadioGroup } from './customRadioGroup'
export { CustomButton, CustomInput }
export {
CustomButton,
CustomInput,
CustomCheckbox,
CustomSelect,
CustomEnumSelect,
CustomRadioGroup
}

View File

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

View File

@ -2,4 +2,5 @@ export interface CacheAccountHostname {
hostname: string
expires: Date
isUpcomingExpire: boolean
isDisabled: boolean
}

View File

@ -0,0 +1,4 @@
export enum ChallengeTypes {
http01 = 'http-01',
dns01 = 'dns-01'
}

View File

@ -1,6 +1,10 @@
import { useState, useEffect, useCallback } from 'react'
// Helper functions for validation
const isBypass = (value: any) => {
return true
}
const isValidEmail = (email: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
@ -87,6 +91,7 @@ const useValidation = <T extends string | number | Date>(
export {
useValidation,
isBypass,
isValidEmail,
isValidPhoneNumber,
isValidContact,

View File

@ -2,8 +2,10 @@ import { HostnameResponse } from './HostnameResponse'
export interface GetAccountResponse {
accountId: string
description?: string
isDisabled: boolean
description: string
contacts: string[]
challengeType?: string
hostnames?: HostnameResponse[]
isStaging: boolean
}

View File

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

View File

@ -1,32 +1,57 @@
import { isValidContact, isValidHostname } from '@/hooks/useValidation'
export interface PostAccountRequest {
description?: string
description: string
contacts: string[]
challengeType: string
hostnames: string[]
isStaging: boolean
}
const validatePostAccountRequest = (
request: PostAccountRequest | null
): string | null => {
if (request === null) return 'Request is null'
): string[] => {
const errors: string[] = []
if (request === null) {
errors.push('Request is null')
return errors
}
// Validate description
if (request.description === '') {
errors.push('Description cannot be empty')
}
// Validate contacts
for (const contact of request.contacts) {
if (request.contacts.length === 0) {
errors.push('Contacts cannot be empty')
}
request.contacts.forEach((contact) => {
if (!isValidContact(contact)) {
return `Invalid contact: ${contact}`
errors.push(`Invalid contact: ${contact}`)
}
})
// Validate challenge type
if (request.challengeType === '') {
errors.push('Challenge type cannot be empty')
}
// Validate hostnames
for (const hostname of request.hostnames) {
if (!isValidHostname(hostname)) {
return `Invalid hostname: ${hostname}`
}
if (request.hostnames.length === 0) {
errors.push('Hostnames cannot be empty')
}
// If all validations pass, return null
return null
request.hostnames.forEach((hostname) => {
if (!isValidHostname(hostname)) {
errors.push(`Invalid hostname: ${hostname}`)
}
})
// Return the array of errors
return errors
}
export { validatePostAccountRequest }

View File

@ -15,12 +15,14 @@
"react-dom": "^18",
"react-icons": "^5.2.1",
"react-redux": "^9.1.2",
"react-toastify": "^10.0.5"
"react-toastify": "^10.0.5",
"uuid": "^10.0.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/uuid": "^10.0.0",
"eslint": "^8",
"eslint-config-next": "14.2.3",
"eslint-config-prettier": "^9.1.0",
@ -538,6 +540,12 @@
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz",
"integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA=="
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true
},
"node_modules/@typescript-eslint/parser": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz",
@ -4814,6 +4822,18 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -16,12 +16,14 @@
"react-dom": "^18",
"react-icons": "^5.2.1",
"react-redux": "^9.1.2",
"react-toastify": "^10.0.5"
"react-toastify": "^10.0.5",
"uuid": "^10.0.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/uuid": "^10.0.0",
"eslint": "^8",
"eslint-config-next": "14.2.3",
"eslint-config-prettier": "^9.1.0",

View File

@ -1,36 +1,42 @@
// store/toastSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { v4 as uuidv4 } from 'uuid' // Assuming UUID is used for generating unique IDs
interface ToastState {
interface ToastMessage {
id: string // Add an id field
message: string
type: 'success' | 'error' | 'info' | 'warning'
}
interface ToastState {
messages: ToastMessage[]
}
const initialState: ToastState = {
message: '',
type: 'info'
messages: []
}
const toastSlice = createSlice({
name: 'toast',
initialState,
reducers: {
showToast: (
state,
action: PayloadAction<{
message: string
type: 'success' | 'error' | 'info' | 'warning'
}>
) => {
state.message = action.payload.message
state.type = action.payload.type
showToast: (state, action: PayloadAction<Omit<ToastMessage, 'id'>>) => {
// Generate a unique ID for each toast message
const id = uuidv4()
const newMessage = { ...action.payload, id }
state.messages.push(newMessage)
},
clearToast: (state) => {
state.message = ''
state.type = 'info'
state.messages = []
},
removeToast: (state, action: PayloadAction<string>) => {
// Remove a specific toast message by ID
state.messages = state.messages.filter(
(message) => message.id !== action.payload
)
}
}
})
export const { showToast, clearToast } = toastSlice.actions
export const { showToast, clearToast, removeToast } = toastSlice.actions
export default toastSlice.reducer

View File

@ -1,12 +0,0 @@
using System;
namespace MaksIT.LetsEncrypt.Entities;
public class AuthorizationChallenge {
public Uri? Url { get; set; }
public string? Type { get; set; }
public string? Status { get; set; }
public string? Token { get; set; }
}

View File

@ -1,11 +0,0 @@
using System.Security.Cryptography;
namespace MaksIT.LetsEncrypt.Entities;
public class CachedCertificateResult {
public RSACryptoServiceProvider? PrivateKey { get; set; }
public string PrivateKeyPem => PrivateKey == null ? "" : PrivateKey.ExportRSAPrivateKeyPem();
public string? Certificate { get; set; }
}

View File

@ -0,0 +1,16 @@
namespace MaksIT.LetsEncrypt.Entities {
public class CachedHostname {
public string Hostname { get; set; }
public DateTime Expires { get; set; }
public bool IsUpcomingExpire { get; set; }
public bool IsDisabled { get; set; }
public CachedHostname(string hostname, DateTime expires, bool isUpcomingExpire, bool isDisabled) {
Hostname = hostname;
Expires = expires;
IsUpcomingExpire = isUpcomingExpire;
IsDisabled = isDisabled;
}
}
}

View File

@ -0,0 +1,8 @@
namespace MaksIT.LetsEncrypt.Entities;
public class CertificateCache {
public required string Cert { get; set; }
public required byte[]? Private { get; set; }
public required string? PrivatePem { get; set; }
public bool IsDisabled { get; set; }
}

View File

@ -6,22 +6,6 @@ using System.Security.Cryptography.X509Certificates;
using MaksIT.LetsEncrypt.Entities.Jws;
namespace MaksIT.LetsEncrypt.Entities;
public class CertificateCache {
public string? Cert { get; set; }
public byte[]? Private { get; set; }
}
public class CachedHostname {
public string Hostname { get; set; }
public DateTime Expires { get; set; }
public bool IsUpcomingExpire { get; set; }
public CachedHostname(string hostname, DateTime expires, bool isUpcomingExpire) {
Hostname = hostname;
Expires = expires;
IsUpcomingExpire = isUpcomingExpire;
}
}
public class RegistrationCache {
@ -30,9 +14,12 @@ public class RegistrationCache {
/// Field used to identify cache by account id
/// </summary>
public required Guid AccountId { get; set; }
public bool IsDisabled { get; set; }
public string? Description { get; set; }
public required string[] Contacts { get; set; }
public string? ChallengeType { get; set; }
public required bool IsStaging { get; set; }
#endregion
@ -54,7 +41,7 @@ public class RegistrationCache {
foreach (var result in CachedCerts) {
var (subject, cachedChert) = result;
if (cachedChert.Cert != null) {
if (cachedChert.Cert != null && !cachedChert.IsDisabled) {
var cert = new X509Certificate2(Encoding.ASCII.GetBytes(cachedChert.Cert));
// if it is about to expire, we need to refresh
@ -79,7 +66,12 @@ public class RegistrationCache {
if (cachedChert.Cert != null) {
var cert = new X509Certificate2(Encoding.ASCII.GetBytes(cachedChert.Cert));
hosts.Add(new CachedHostname(subject, cert.NotAfter, (cert.NotAfter - DateTime.UtcNow).TotalDays < 30));
hosts.Add(new CachedHostname(
subject,
cert.NotAfter,
(cert.NotAfter - DateTime.UtcNow).TotalDays < 30,
cachedChert.IsDisabled
));
}
}
@ -92,7 +84,7 @@ public class RegistrationCache {
/// <param name="subject"></param>
/// <param name="value"></param>
/// <returns></returns>
public bool TryGetCachedCertificate(string subject, out CachedCertificateResult? value) {
public bool TryGetCachedCertificate(string subject, out CertificateCache? value) {
value = null;
if (CachedCerts == null)
@ -110,9 +102,10 @@ public class RegistrationCache {
var rsa = new RSACryptoServiceProvider(4096);
rsa.ImportCspBlob(cache.Private);
value = new CachedCertificateResult {
Certificate = cache.Cert,
PrivateKey = rsa
value = new CertificateCache {
Cert = cache.Cert,
Private = rsa.ExportCspBlob(true),
PrivatePem = rsa.ExportRSAPrivateKeyPem()
};
return true;
}

View File

@ -1,10 +0,0 @@
namespace MaksIT.LetsEncrypt.Entities {
public class SendResult<TResult> {
public TResult? Result { get; set; }
public string? ResponseText { get; set; }
}
}

View File

@ -0,0 +1,19 @@
using MaksIT.LetsEncrypt.Models.Responses;
using MaksIT.LetsEncrypt.Services;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace MaksIT.LetsEncrypt.Entities.LetsEncrypt {
public class State {
public bool IsStaging { get; set; }
public AcmeDirectory? Directory { get; set; }
public JwsService? JwsService { get; set; }
public Order? CurrentOrder { get; set; }
public List<AuthorizationChallengeChallenge> Challenges { get; } = new List<AuthorizationChallengeChallenge>();
public string? Nonce { get; set; }
public RegistrationCache? Cache { get; set; }
}
}

View File

@ -0,0 +1,13 @@
using System;
namespace MaksIT.LetsEncrypt.Models.Responses;
public class AuthorizationChallengeChallenge
{
public Uri? Url { get; set; }
public string? Type { get; set; }
public string? Status { get; set; }
public string? Token { get; set; }
}

View File

@ -1,7 +1,4 @@

using MaksIT.LetsEncrypt.Entities;
namespace MaksIT.LetsEncrypt.Models.Responses;
namespace MaksIT.LetsEncrypt.Models.Responses;
public class AuthorizationChallengeResponse {
public OrderIdentifier? Identifier { get; set; }
@ -12,7 +9,7 @@ public class AuthorizationChallengeResponse {
public bool Wildcard { get; set; }
public AuthorizationChallenge[]? Challenges { get; set; }
public AuthorizationChallengeChallenge[]? Challenges { get; set; }
}
public class AuthorizeChallenge {

View File

@ -0,0 +1,12 @@
namespace MaksIT.LetsEncrypt.Models.Responses
{
public class SendResult<TResult>
{
public TResult? Result { get; set; }
public string? ResponseText { get; set; }
}
}

View File

@ -15,11 +15,12 @@ using MaksIT.LetsEncrypt.Models.Responses;
using MaksIT.LetsEncrypt.Models.Interfaces;
using MaksIT.LetsEncrypt.Models.Requests;
using MaksIT.LetsEncrypt.Entities.Jws;
using MaksIT.LetsEncrypt.Entities.LetsEncrypt;
namespace MaksIT.LetsEncrypt.Services;
public interface ILetsEncryptService {
Task<IDomainResult> ConfigureClient(Guid sessionId, string url);
Task<IDomainResult> ConfigureClient(Guid sessionId, bool isStaging);
Task<IDomainResult> Init(Guid sessionId,Guid accountId, string description, string[] contacts, RegistrationCache? registrationCache);
(RegistrationCache?, IDomainResult) GetRegistrationCache(Guid sessionId);
(string?, IDomainResult) GetTermsOfServiceUri(Guid sessionId);
@ -27,8 +28,6 @@ public interface ILetsEncryptService {
Task<IDomainResult> CompleteChallenges(Guid sessionId);
Task<IDomainResult> GetOrder(Guid sessionId, string[] hostnames);
Task<IDomainResult> GetCertificate(Guid sessionId, string subject);
(string[]?, IDomainResult) HostsWithUpcomingSslExpiry(Guid sessionId);
(CachedCertificateResult?, IDomainResult) TryGetCachedCertificate(Guid sessionId, string subject);
}
public class LetsEncryptService : ILetsEncryptService {
@ -54,11 +53,17 @@ public class LetsEncryptService : ILetsEncryptService {
}
#region ConfigureClient
public async Task<IDomainResult> ConfigureClient(Guid sessionId, string url) {
public async Task<IDomainResult> ConfigureClient(Guid sessionId, bool isStaging) {
try {
var state = GetOrCreateState(sessionId);
_httpClient.BaseAddress ??= new Uri(url);
state.IsStaging = isStaging;
// TODO: need to propagate from Configuration
_httpClient.BaseAddress ??= new Uri(isStaging
? "https://acme-staging-v02.api.letsencrypt.org/directory"
: "https://acme-v02.api.letsencrypt.org/directory");
if (state.Directory == null) {
var (directory, getAcmeDirectoryResult) = await SendAsync<AcmeDirectory>(sessionId, HttpMethod.Get, new Uri("directory", UriKind.Relative), false, null);
@ -133,6 +138,7 @@ public class LetsEncryptService : ILetsEncryptService {
AccountId = accountId,
Description = description,
Contacts = contacts,
IsStaging = state.IsStaging,
Location = account.Result.Location,
AccountKey = accountKey.ExportCspBlob(true),
@ -307,7 +313,7 @@ public class LetsEncryptService : ILetsEncryptService {
await Task.Delay(1000);
if ((DateTime.UtcNow - start).Seconds > 120)
throw new TimeoutException();
return IDomainResult.Failed("Timeout");
}
}
@ -421,7 +427,8 @@ public class LetsEncryptService : ILetsEncryptService {
state.Cache.CachedCerts ??= new Dictionary<string, CertificateCache>();
state.Cache.CachedCerts[subject] = new CertificateCache {
Cert = pem.Result,
Private = key.ExportCspBlob(true)
Private = key.ExportCspBlob(true),
PrivatePem = key.ExportRSAPrivateKeyPem()
};
var cert = new X509Certificate2(Encoding.UTF8.GetBytes(pem.Result));
@ -437,29 +444,6 @@ public class LetsEncryptService : ILetsEncryptService {
}
#endregion
#region TryGetCachedCertificate
public (string[]?, IDomainResult) HostsWithUpcomingSslExpiry(Guid sessionId) {
var state = GetOrCreateState(sessionId);
if (state.Cache == null)
return IDomainResult.Failed<string[]?>();
return IDomainResult.Success(state.Cache.GetHostsWithUpcomingSslExpiry());
}
public (CachedCertificateResult?, IDomainResult) TryGetCachedCertificate(Guid sessionId, string subject) {
var state = GetOrCreateState(sessionId);
var certRes = new CachedCertificateResult();
if (state.Cache != null && state.Cache.TryGetCachedCertificate(subject, out certRes)) {
return IDomainResult.Success(certRes);
}
return IDomainResult.Failed<CachedCertificateResult?>();
}
#endregion
public Task<IDomainResult> KeyChange(Guid sessionId) {
throw new NotImplementedException();
@ -679,13 +663,4 @@ public class LetsEncryptService : ILetsEncryptService {
};
}
#endregion
private class State {
public AcmeDirectory? Directory { get; set; }
public JwsService? JwsService { get; set; }
public Order? CurrentOrder { get; set; }
public List<AuthorizationChallenge> Challenges { get; } = new List<AuthorizationChallenge>();
public string? Nonce { get; set; }
public RegistrationCache? Cache { get; set; }
}
}

View File

@ -37,7 +37,7 @@ namespace MaksIT.LetsEncryptServer.BackgroundServices {
continue;
}
foreach (var account in accountsResponse) {
foreach (var account in accountsResponse.Where(x => !x.IsDisabled)) {
await ProcessAccountAsync(account);
}
@ -61,7 +61,7 @@ namespace MaksIT.LetsEncryptServer.BackgroundServices {
return IDomainResult.Success();
}
var renewResult = await RenewCertificatesForHostnames(cache.AccountId, cache.Description, cache.Contacts, hostnames, cache.ChallengeType);
var renewResult = await RenewCertificatesForHostnames(cache.AccountId, cache.Description, cache.Contacts, hostnames, cache.ChallengeType, cache.IsStaging);
if (!renewResult.IsSuccess)
return renewResult;
@ -70,8 +70,8 @@ namespace MaksIT.LetsEncryptServer.BackgroundServices {
return IDomainResult.Success();
}
private async Task<IDomainResult> RenewCertificatesForHostnames(Guid accountId, string description, string[] contacts, string[] hostnames, string challengeType) {
var (sessionId, configureClientResult) = await _certsFlowService.ConfigureClientAsync();
private async Task<IDomainResult> RenewCertificatesForHostnames(Guid accountId, string description, string[] contacts, string[] hostnames, string challengeType, bool isStaging) {
var (sessionId, configureClientResult) = await _certsFlowService.ConfigureClientAsync(isStaging);
if (!configureClientResult.IsSuccess || sessionId == null) {
LogErrors(configureClientResult.Errors);
return configureClientResult;

View File

@ -11,7 +11,6 @@
public class Configuration {
public required string Production { get; set; }
public required string Staging { get; set; }
public required bool DevMode { get; set; }
public required Agent Agent { get; set; }
}
}

View File

@ -27,8 +27,8 @@ namespace MaksIT.LetsEncryptServer.Controllers {
/// </summary>
/// <returns>sessionId</returns>
[HttpPost("configure-client")]
public async Task<IActionResult> ConfigureClient() {
var result = await _certsFlowService.ConfigureClientAsync();
public async Task<IActionResult> ConfigureClient([FromBody] ConfigureClientRequest requestData) {
var result = await _certsFlowService.ConfigureClientAsync(requestData);
return result.ToActionResult();
}

View File

@ -1,10 +1,6 @@
using System.Text.Json;
using DomainResults.Common;
using DomainResults.Common;
using MaksIT.Core.Extensions;
using MaksIT.LetsEncrypt.Entities;
using MaksIT.LetsEncrypt.Models.Responses;
using MaksIT.Models;
using MaksIT.Models.LetsEncryptServer.Account.Requests;
using MaksIT.Models.LetsEncryptServer.Account.Responses;
@ -53,41 +49,30 @@ public class AccountService : IAccountService {
#region Accounts
public async Task<(GetAccountResponse[]?, IDomainResult)> GetAccountsAsync() {
var (caches, result) = await _cacheService.LoadAccountsFromCacheAsync();
if (!result.IsSuccess || caches == null) {
return (null, result);
}
var accounts = caches.Select(cache => new GetAccountResponse {
AccountId = cache.AccountId,
Description = cache.Description,
Contacts = cache.Contacts,
ChallengeType = cache.ChallengeType,
Hostnames = GetHostnamesFromCache(cache).ToArray()
});
var accounts = caches
.Select(x => CreateGetAccountResponse(x.AccountId, x))
.ToArray();
return IDomainResult.Success(accounts.ToArray());
return IDomainResult.Success(accounts);
}
public async Task<(GetAccountResponse?, IDomainResult)> GetAccountAsync(Guid accountId) {
var (cache, result) = await _cacheService.LoadAccountFromCacheAsync(accountId);
if (!result.IsSuccess || cache == null) {
return (null, result);
}
var (cache, result) = await _cacheService.LoadAccountFromCacheAsync(accountId);
if (!result.IsSuccess || cache == null) {
return (null, result);
}
var response = new GetAccountResponse {
AccountId = accountId,
Description = cache.Description,
Contacts = cache.Contacts,
Hostnames = GetHostnamesFromCache(cache).ToArray()
};
return IDomainResult.Success(response);
return IDomainResult.Success(CreateGetAccountResponse(accountId, cache));
}
public async Task<(GetAccountResponse?, IDomainResult)> PostAccountAsync(PostAccountRequest requestData) {
var (sessionId, configureClientResult) = await _certsFlowService.ConfigureClientAsync();
var (sessionId, configureClientResult) = await _certsFlowService.ConfigureClientAsync(requestData.IsStaging);
if (!configureClientResult.IsSuccess || sessionId == null) {
//LogErrors(configureClientResult.Errors);
return (null, configureClientResult);
@ -147,7 +132,7 @@ public class AccountService : IAccountService {
return (null, saveResult);
}
return CreateGetAccountResponse(accountId, cache);
return IDomainResult.Success(CreateGetAccountResponse(accountId, cache));
}
public async Task<(GetAccountResponse?, IDomainResult)> PatchAccountAsync(Guid accountId, PatchAccountRequest requestData) {
@ -190,7 +175,7 @@ public class AccountService : IAccountService {
return (null, saveResult);
}
return CreateGetAccountResponse(accountId, cache);
return IDomainResult.Success(CreateGetAccountResponse(accountId, cache));
}
public async Task<IDomainResult> DeleteAccountAsync(Guid accountId) {
@ -227,7 +212,7 @@ public class AccountService : IAccountService {
return (null, saveResult);
}
return CreateGetAccountResponse(accountId, cache);
return IDomainResult.Success(CreateGetAccountResponse(accountId, cache));
}
public async Task<(GetAccountResponse?, IDomainResult)> PatchContactsAsync(Guid accountId, PatchContactsRequest requestData) {
@ -266,7 +251,7 @@ public class AccountService : IAccountService {
return (null, saveResult);
}
return CreateGetAccountResponse(accountId, cache);
return IDomainResult.Success(CreateGetAccountResponse(accountId, cache));
}
public async Task<IDomainResult> DeleteContactAsync(Guid accountId, int index) {
@ -311,7 +296,8 @@ public class AccountService : IAccountService {
var hosts = cache.GetHosts().Select(x => new HostnameResponse {
Hostname = x.Hostname,
Expires = x.Expires,
IsUpcomingExpire = x.IsUpcomingExpire
IsUpcomingExpire = x.IsUpcomingExpire,
IsDisabled = x.IsDisabled
}).ToList();
return hosts;
@ -321,15 +307,18 @@ public class AccountService : IAccountService {
#region Helper Methods
private (GetAccountResponse?, IDomainResult) CreateGetAccountResponse(Guid accountId, RegistrationCache cache) {
var hostnames = GetHostnamesFromCache(cache) ?? new List<HostnameResponse>();
private GetAccountResponse CreateGetAccountResponse(Guid accountId, RegistrationCache cache) {
var hostnames = GetHostnamesFromCache(cache) ?? [];
return (new GetAccountResponse {
return new GetAccountResponse {
AccountId = accountId,
IsDisabled = cache.IsDisabled,
Description = cache.Description,
Contacts = cache.Contacts,
Hostnames = hostnames.ToArray()
}, IDomainResult.Success());
ChallengeType = cache.ChallengeType,
Hostnames = [.. hostnames],
IsStaging = cache.IsStaging
};
}

View File

@ -5,17 +5,19 @@ using DomainResults.Common;
using MaksIT.LetsEncrypt.Entities;
using MaksIT.LetsEncrypt.Services;
using MaksIT.Models.LetsEncryptServer.CertsFlow.Requests;
using System.Security.Cryptography;
namespace MaksIT.LetsEncryptServer.Services;
public interface ICertsCommonService {
Task<(Guid?, IDomainResult)> ConfigureClientAsync();
(string?, IDomainResult) GetTermsOfService(Guid sessionId);
Task<IDomainResult> CompleteChallengesAsync(Guid sessionId);
}
public interface ICertsInternalService : ICertsCommonService {
Task<(Guid?, IDomainResult)> ConfigureClientAsync(bool isStaging);
Task<(Guid?, IDomainResult)> InitAsync(Guid sessionId, Guid? accountId, string description, string[] contacts);
Task<(List<string>?, IDomainResult)> NewOrderAsync(Guid sessionId, string[] hostnames, string challengeType);
Task<IDomainResult> GetOrderAsync(Guid sessionId, string[] hostnames);
@ -24,6 +26,7 @@ public interface ICertsInternalService : ICertsCommonService {
}
public interface ICertsRestService : ICertsCommonService {
Task<(Guid?, IDomainResult)> ConfigureClientAsync(ConfigureClientRequest requestData);
Task<(Guid?, IDomainResult)> InitAsync(Guid sessionId, Guid? accountId, InitRequest requestData);
Task<(List<string>?, IDomainResult)> NewOrderAsync(Guid sessionId, NewOrderRequest requestData);
Task<IDomainResult> GetOrderAsync(Guid sessionId, GetOrderRequest requestData);
@ -38,7 +41,7 @@ public interface ICertsRestChallengeService {
public interface ICertsFlowService
: ICertsInternalService,
ICertsRestService,
ICertsRestChallengeService {}
ICertsRestChallengeService { }
public class CertsFlowService : ICertsFlowService {
@ -69,20 +72,8 @@ public class CertsFlowService : ICertsFlowService {
}
#region Common methods
public async Task<(Guid?, IDomainResult)> ConfigureClientAsync() {
var sessionId = Guid.NewGuid();
var url = _appSettings.DevMode
? _appSettings.Staging
: _appSettings.Production;
var result = await _letsEncryptService.ConfigureClient(sessionId, url);
if (!result.IsSuccess)
return (null, result);
return IDomainResult.Success(sessionId);
}
public (string?, IDomainResult) GetTermsOfService(Guid sessionId) {
var (terms, getTermsResult) = _letsEncryptService.GetTermsOfServiceUri(sessionId);
@ -95,11 +86,20 @@ public class CertsFlowService : ICertsFlowService {
public async Task<IDomainResult> CompleteChallengesAsync(Guid sessionId) {
return await _letsEncryptService.CompleteChallenges(sessionId);
}
#endregion
#region Internal methods
public async Task<(Guid?, IDomainResult)> ConfigureClientAsync(bool isStaging) {
var sessionId = Guid.NewGuid();
var result = await _letsEncryptService.ConfigureClient(sessionId, isStaging);
if (!result.IsSuccess)
return (null, result);
return IDomainResult.Success(sessionId);
}
public async Task<(Guid?, IDomainResult)> InitAsync(Guid sessionId, Guid? accountId, string description, string[] contacts) {
RegistrationCache? cache = null;
@ -161,18 +161,22 @@ public class CertsFlowService : ICertsFlowService {
}
public async Task<(Dictionary<string, string>?, IDomainResult)> ApplyCertificatesAsync(Guid sessionId, string[] hostnames) {
var (cache, getCacheResult) = _letsEncryptService.GetRegistrationCache(sessionId);
if (!getCacheResult.IsSuccess || cache?.CachedCerts == null)
return (null, getCacheResult);
var results = new Dictionary<string, string>();
foreach (var subject in hostnames) {
var (cert, getCertResult) = _letsEncryptService.TryGetCachedCertificate(sessionId, subject);
if (!getCertResult.IsSuccess || cert == null)
return (null, getCertResult);
var content = $"{cert.Certificate}\n{cert.PrivateKeyPem}";
results.Add(subject, content);
foreach (var hostname in hostnames) {
CertificateCache? cert;
if (cache.TryGetCachedCertificate(hostname, out cert)) {
var content = $"{cert.Cert}\n{cert.PrivatePem}";
results.Add(hostname, content);
}
}
// TODO: send the certificates to the server
// Send the certificates to the via agent
var uploadResult = await _agentService.UploadCerts(results);
if (!uploadResult.IsSuccess)
return (null, uploadResult);
@ -183,10 +187,12 @@ public class CertsFlowService : ICertsFlowService {
return IDomainResult.Success(results);
}
#endregion
#region Webapi specific methods
#region REST methods
public Task<(Guid?, IDomainResult)> ConfigureClientAsync(ConfigureClientRequest requestData) =>
ConfigureClientAsync(requestData.IsStaging);
public Task<(Guid?, IDomainResult)> InitAsync(Guid sessionId, Guid? accountId, InitRequest requestData) =>
InitAsync(sessionId, accountId, requestData.Description, requestData.Contacts);
@ -202,10 +208,10 @@ public class CertsFlowService : ICertsFlowService {
public Task<(Dictionary<string, string>?, IDomainResult)> ApplyCertificatesAsync(Guid sessionId, GetCertificatesRequest requestData) =>
ApplyCertificatesAsync(sessionId, requestData.Hostnames);
#endregion
#region Acme Challenge Webapi specific methods
#region Acme Challenge REST methods
public (string?, IDomainResult) AcmeChallenge(string fileName) {
DeleteExporedChallenges();

View File

@ -11,8 +11,6 @@
"Production": "https://acme-v02.api.letsencrypt.org/directory",
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory",
"DevMode": false,
"Agent": {
"AgentHostname": "http://lblsrv0001.corp.maks-it.com",
"AgentPort": 5000,

View File

@ -4,9 +4,9 @@ namespace MaksIT.Models.LetsEncryptServer.Account.Requests {
public class PostAccountRequest : IValidatableObject {
public required string Description { get; set; }
public required string[] Contacts { get; set; }
public required string[] Hostnames { get; set; }
public required string ChallengeType { get; set; }
public required string[] Hostnames { get; set; }
public required bool IsStaging { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) {
if (string.IsNullOrWhiteSpace(Description))

View File

@ -7,13 +7,16 @@ using System.Threading.Tasks;
namespace MaksIT.Models.LetsEncryptServer.Account.Responses {
public class GetAccountResponse {
public Guid AccountId { get; set; }
public required bool IsDisabled { get; set; }
public string? Description { get; set; }
public required string [] Contacts { get; set; }
public required string[] Contacts { get; set; }
public string? ChallengeType { get; set; }
public HostnameResponse[]? Hostnames { get; set; }
public required bool IsStaging { get; set; }
}
}

View File

@ -6,9 +6,10 @@ using System.Threading.Tasks;
namespace MaksIT.Models.LetsEncryptServer.Account.Responses {
public class HostnameResponse {
public string Hostname { get; set; }
public required string Hostname { get; set; }
public DateTime Expires { get; set; }
public bool IsUpcomingExpire { get; set; }
public bool IsDisabled { get; set; }
}
}

View File

@ -0,0 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace MaksIT.Models.LetsEncryptServer.CertsFlow.Requests {
public class ConfigureClientRequest {
public bool IsStaging { get; set; }
}
}

File diff suppressed because one or more lines are too long