mirror of
https://github.com/MAKS-IT-COM/maksit-certs-ui.git
synced 2025-12-30 19:50:07 +01:00
(feature): create account from UI, general improvements
This commit is contained in:
parent
4000026b7a
commit
c817bc1038
@ -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}`,
|
||||
|
||||
45
src/ClientApp/app/functions/enums.ts
Normal file
45
src/ClientApp/app/functions/enums.ts
Normal 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 }
|
||||
@ -1,3 +1,4 @@
|
||||
import { deepCopy } from './deepCopy'
|
||||
import { enumToArray, enumToObject } from './enums'
|
||||
|
||||
export { deepCopy }
|
||||
export { deepCopy, enumToArray, enumToObject }
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
19
src/ClientApp/components/pageContainer.tsx
Normal file
19
src/ClientApp/components/pageContainer.tsx
Normal 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 }
|
||||
@ -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 (
|
||||
|
||||
57
src/ClientApp/controls/customCheckbox.tsx
Normal file
57
src/ClientApp/controls/customCheckbox.tsx
Normal 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 }
|
||||
28
src/ClientApp/controls/customEnumSelect.tsx
Normal file
28
src/ClientApp/controls/customEnumSelect.tsx
Normal 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 }
|
||||
@ -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>
|
||||
|
||||
77
src/ClientApp/controls/customRadioGroup.tsx
Normal file
77
src/ClientApp/controls/customRadioGroup.tsx
Normal 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 }
|
||||
110
src/ClientApp/controls/customSelect.tsx
Normal file
110
src/ClientApp/controls/customSelect.tsx
Normal 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 }
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -2,4 +2,5 @@ export interface CacheAccountHostname {
|
||||
hostname: string
|
||||
expires: Date
|
||||
isUpcomingExpire: boolean
|
||||
isDisabled: boolean
|
||||
}
|
||||
|
||||
4
src/ClientApp/entities/ChallengeTypes.ts
Normal file
4
src/ClientApp/entities/ChallengeTypes.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum ChallengeTypes {
|
||||
http01 = 'http-01',
|
||||
dns01 = 'dns-01'
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -2,4 +2,5 @@ export interface HostnameResponse {
|
||||
hostname: string
|
||||
expires: string
|
||||
isUpcomingExpire: boolean
|
||||
isDisabled: boolean
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
22
src/ClientApp/package-lock.json
generated
22
src/ClientApp/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
16
src/LetsEncrypt/Entities/LetsEncrypt/CachedHostname.cs
Normal file
16
src/LetsEncrypt/Entities/LetsEncrypt/CachedHostname.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/LetsEncrypt/Entities/LetsEncrypt/CertificateCache.cs
Normal file
8
src/LetsEncrypt/Entities/LetsEncrypt/CertificateCache.cs
Normal 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; }
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
namespace MaksIT.LetsEncrypt.Entities {
|
||||
public class SendResult<TResult> {
|
||||
|
||||
public TResult? Result { get; set; }
|
||||
|
||||
public string? ResponseText { get; set; }
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
19
src/LetsEncrypt/Entities/LetsEncrypt/State.cs
Normal file
19
src/LetsEncrypt/Entities/LetsEncrypt/State.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
12
src/LetsEncrypt/Models/Responses/SendResult.cs
Normal file
12
src/LetsEncrypt/Models/Responses/SendResult.cs
Normal file
@ -0,0 +1,12 @@
|
||||
namespace MaksIT.LetsEncrypt.Models.Responses
|
||||
{
|
||||
public class SendResult<TResult>
|
||||
{
|
||||
|
||||
public TResult? Result { get; set; }
|
||||
|
||||
public string? ResponseText { get; set; }
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user