mirror of
https://github.com/MAKS-IT-COM/maksit-certs-ui.git
synced 2025-12-31 04:00:03 +01:00
(feature): create account from UI, general improvements
This commit is contained in:
parent
4000026b7a
commit
c817bc1038
@ -1,9 +1,14 @@
|
|||||||
enum ApiRoutes {
|
enum ApiRoutes {
|
||||||
ACCOUNTS = 'api/accounts',
|
ACCOUNTS = 'api/accounts',
|
||||||
ACCOUNT = 'api/account/{accountId}',
|
|
||||||
ACCOUNT_CONTACTS = 'api/account/{accountId}/contacts',
|
ACCOUNT = 'api/account',
|
||||||
ACCOUNT_CONTACT = 'api/account/{accountId}/contact/{index}',
|
ACCOUNT_ID = 'api/account/{accountId}',
|
||||||
ACCOUNT_HOSTNAMES = 'api/account/{accountId}/hostnames'
|
|
||||||
|
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_CONFIGURE_CLIENT = `api/CertsFlow/ConfigureClient`,
|
||||||
// CERTS_FLOW_TERMS_OF_SERVICE = `api/CertsFlow/TermsOfService/{sessionId}`,
|
// 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 { deepCopy } from './deepCopy'
|
||||||
|
import { enumToArray, enumToObject } from './enums'
|
||||||
|
|
||||||
export { deepCopy }
|
export { deepCopy, enumToArray, enumToObject }
|
||||||
|
|||||||
@ -8,11 +8,19 @@ import {
|
|||||||
isValidEmail,
|
isValidEmail,
|
||||||
isValidHostname
|
isValidHostname
|
||||||
} from '@/hooks/useValidation'
|
} from '@/hooks/useValidation'
|
||||||
import { CustomButton, CustomInput } from '@/controls'
|
import {
|
||||||
import { TrashIcon, PlusIcon } from '@heroicons/react/24/solid'
|
CustomButton,
|
||||||
|
CustomCheckbox,
|
||||||
|
CustomEnumSelect,
|
||||||
|
CustomInput,
|
||||||
|
CustomRadioGroup
|
||||||
|
} from '@/controls'
|
||||||
import { GetAccountResponse } from '@/models/letsEncryptServer/account/responses/GetAccountResponse'
|
import { GetAccountResponse } from '@/models/letsEncryptServer/account/responses/GetAccountResponse'
|
||||||
import { deepCopy } from './functions'
|
import { deepCopy, enumToArray } from './functions'
|
||||||
import { CacheAccount } from '@/entities/CacheAccount'
|
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() {
|
export default function Page() {
|
||||||
const [accounts, setAccounts] = useState<CacheAccount[]>([])
|
const [accounts, setAccounts] = useState<CacheAccount[]>([])
|
||||||
@ -56,6 +64,7 @@ export default function Page() {
|
|||||||
accounts?.forEach((account) => {
|
accounts?.forEach((account) => {
|
||||||
newAccounts.push({
|
newAccounts.push({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
|
isDisabled: account.isDisabled,
|
||||||
description: account.description,
|
description: account.description,
|
||||||
contacts: account.contacts,
|
contacts: account.contacts,
|
||||||
challengeType: account.challengeType,
|
challengeType: account.challengeType,
|
||||||
@ -63,9 +72,11 @@ export default function Page() {
|
|||||||
account.hostnames?.map((h) => ({
|
account.hostnames?.map((h) => ({
|
||||||
hostname: h.hostname,
|
hostname: h.hostname,
|
||||||
expires: new Date(h.expires),
|
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) => {
|
const deleteAccount = (accountId: string) => {
|
||||||
setAccounts(accounts.filter((account) => account.accountId !== accountId))
|
setAccounts(accounts.filter((account) => account.accountId !== accountId))
|
||||||
|
|
||||||
@ -99,20 +144,20 @@ export default function Page() {
|
|||||||
if (account?.contacts.length ?? 0 < 1) return
|
if (account?.contacts.length ?? 0 < 1) return
|
||||||
|
|
||||||
// TODO: Remove from cache
|
// TODO: Remove from cache
|
||||||
httpService.delete(
|
// httpService.delete(
|
||||||
GetApiRoute(ApiRoutes.ACCOUNT_CONTACT, accountId, contact)
|
// GetApiRoute(ApiRoutes.ACCOUNT_CONTACT, accountId, contact)
|
||||||
)
|
// )
|
||||||
|
|
||||||
setAccounts(
|
// setAccounts(
|
||||||
accounts.map((account) =>
|
// accounts.map((account) =>
|
||||||
account.accountId === accountId
|
// account.accountId === accountId
|
||||||
? {
|
// ? {
|
||||||
...account,
|
// ...account,
|
||||||
contacts: account.contacts.filter((c) => c !== contact)
|
// contacts: account.contacts.filter((c) => c !== contact)
|
||||||
}
|
// }
|
||||||
: account
|
// : account
|
||||||
)
|
// )
|
||||||
)
|
// )
|
||||||
}
|
}
|
||||||
|
|
||||||
const addContact = (accountId: string) => {
|
const addContact = (accountId: string) => {
|
||||||
@ -180,7 +225,8 @@ export default function Page() {
|
|||||||
{
|
{
|
||||||
hostname: newHostname,
|
hostname: newHostname,
|
||||||
expires: new Date(),
|
expires: new Date(),
|
||||||
isUpcomingExpire: false
|
isUpcomingExpire: false,
|
||||||
|
isDisabled: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -251,10 +297,7 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-4">
|
<PageContainer title="LetsEncrypt Auto Renew">
|
||||||
<h1 className="text-4xl font-bold text-center mb-8">
|
|
||||||
LetsEncrypt Auto Renew
|
|
||||||
</h1>
|
|
||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
<div
|
<div
|
||||||
key={account.accountId}
|
key={account.accountId}
|
||||||
@ -274,8 +317,31 @@ export default function Page() {
|
|||||||
{account.isEditMode ? (
|
{account.isEditMode ? (
|
||||||
<form onSubmit={(e) => handleSubmit(e, account.accountId)}>
|
<form onSubmit={(e) => handleSubmit(e, account.accountId)}>
|
||||||
<div className="mb-4">
|
<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>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<CustomCheckbox
|
||||||
|
checked={account.isDisabled}
|
||||||
|
label="Disabled"
|
||||||
|
onChange={(value) =>
|
||||||
|
handleIsDisabledChange(account.accountId, value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-xl font-medium mb-2">Contacts:</h3>
|
<h3 className="text-xl font-medium mb-2">Contacts:</h3>
|
||||||
<ul className="list-disc list-inside pl-4 mb-2">
|
<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"
|
className="bg-red-500 text-white p-2 rounded ml-2"
|
||||||
>
|
>
|
||||||
<TrashIcon className="h-5 w-5 text-white" />
|
<FaTrash />
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@ -314,7 +380,7 @@ export default function Page() {
|
|||||||
onClick={() => addContact(account.accountId)}
|
onClick={() => addContact(account.accountId)}
|
||||||
className="bg-green-500 text-white p-2 rounded ml-2"
|
className="bg-green-500 text-white p-2 rounded ml-2"
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-5 w-5 text-white" />
|
<FaPlus />
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
</CustomInput>
|
</CustomInput>
|
||||||
</div>
|
</div>
|
||||||
@ -345,7 +411,7 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
className="bg-red-500 text-white p-2 rounded ml-2"
|
className="bg-red-500 text-white p-2 rounded ml-2"
|
||||||
>
|
>
|
||||||
<TrashIcon className="h-5 w-5 text-white" />
|
<FaTrash />
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@ -367,7 +433,7 @@ export default function Page() {
|
|||||||
onClick={() => addHostname(account.accountId)}
|
onClick={() => addHostname(account.accountId)}
|
||||||
className="bg-green-500 text-white p-2 rounded ml-2"
|
className="bg-green-500 text-white p-2 rounded ml-2"
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-5 w-5 text-white" />
|
<FaPlus />
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
</CustomInput>
|
</CustomInput>
|
||||||
</div>
|
</div>
|
||||||
@ -377,7 +443,7 @@ export default function Page() {
|
|||||||
onClick={() => deleteAccount(account.accountId)}
|
onClick={() => deleteAccount(account.accountId)}
|
||||||
className="bg-red-500 text-white p-2 rounded ml-2"
|
className="bg-red-500 text-white p-2 rounded ml-2"
|
||||||
>
|
>
|
||||||
<TrashIcon className="h-5 w-5 text-white" />
|
<FaTrash />
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
<CustomButton
|
<CustomButton
|
||||||
type="submit"
|
type="submit"
|
||||||
@ -394,6 +460,15 @@ export default function Page() {
|
|||||||
Description: {account.description}
|
Description: {account.description}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<CustomCheckbox
|
||||||
|
checked={account.isDisabled}
|
||||||
|
label="Disabled"
|
||||||
|
disabled={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-xl font-medium mb-2">Contacts:</h3>
|
<h3 className="text-xl font-medium mb-2">Contacts:</h3>
|
||||||
<ul className="list-disc list-inside pl-4 mb-2">
|
<ul className="list-disc list-inside pl-4 mb-2">
|
||||||
@ -405,31 +480,61 @@ export default function Page() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-xl font-medium mb-2">
|
<CustomEnumSelect
|
||||||
Challenge type: {account.challengeType}
|
title="Challenge Type"
|
||||||
</h3>
|
enumType={ChallengeTypes}
|
||||||
|
selectedValue={account.challengeType}
|
||||||
|
onChange={(option) =>
|
||||||
|
handleChallengeTypeChange(account.accountId, option)
|
||||||
|
}
|
||||||
|
disabled={true}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="mb-4">
|
||||||
<h3 className="text-xl font-medium mb-2">Hostnames:</h3>
|
<h3 className="text-xl font-medium mb-2">Hostnames:</h3>
|
||||||
<ul className="list-disc list-inside pl-4 mb-2">
|
<ul className="list-disc list-inside pl-4 mb-2">
|
||||||
{account.hostnames.map((hostname) => (
|
{account.hostnames.map((hostname) => (
|
||||||
<li key={hostname.hostname} className="text-gray-700 mb-2">
|
<li key={hostname.hostname} className="text-gray-700 mb-2">
|
||||||
{hostname.hostname} - {hostname.expires.toDateString()} -
|
{hostname.hostname} - {hostname.expires.toDateString()} -
|
||||||
<span
|
<span
|
||||||
className={`ml-2 px-2 py-1 rounded ${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
|
{hostname.isUpcomingExpire
|
||||||
? 'Upcoming'
|
? 'Upcoming'
|
||||||
: 'Not Upcoming'}
|
: 'Not Upcoming'}
|
||||||
</span>
|
</span>
|
||||||
|
<CustomCheckbox
|
||||||
|
checked={hostname.isDisabled}
|
||||||
|
label="Disabled"
|
||||||
|
disabled={true}
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<CustomRadioGroup
|
||||||
|
options={[
|
||||||
|
{ value: 'staging', label: 'Staging' },
|
||||||
|
{ value: 'production', label: 'Production' }
|
||||||
|
]}
|
||||||
|
initialValue={account.isStaging ? 'staging' : 'production'}
|
||||||
|
title="LetsEncrypt Environment"
|
||||||
|
className=""
|
||||||
|
radioClassName=""
|
||||||
|
errorClassName="text-red-500 text-sm mt-1"
|
||||||
|
disabled={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</PageContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,31 +1,56 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { ApiRoutes, GetApiRoute } from '@/ApiRoutes'
|
|
||||||
import { httpService } from '@/services/httpService'
|
|
||||||
import { FormEvent, useEffect, useRef, useState } from 'react'
|
import { FormEvent, useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
useValidation,
|
useValidation,
|
||||||
|
isBypass,
|
||||||
isValidContact,
|
isValidContact,
|
||||||
isValidHostname
|
isValidHostname
|
||||||
} from '@/hooks/useValidation'
|
} from '@/hooks/useValidation'
|
||||||
import { CustomButton, CustomInput } from '@/controls'
|
import {
|
||||||
|
CustomButton,
|
||||||
|
CustomEnumSelect,
|
||||||
|
CustomInput,
|
||||||
|
CustomRadioGroup
|
||||||
|
} from '@/controls'
|
||||||
import { FaTrash, FaPlus } from 'react-icons/fa'
|
import { FaTrash, FaPlus } from 'react-icons/fa'
|
||||||
import { deepCopy } from '../functions'
|
import { deepCopy } from '../functions'
|
||||||
import {
|
import {
|
||||||
PostAccountRequest,
|
PostAccountRequest,
|
||||||
validatePostAccountRequest
|
validatePostAccountRequest
|
||||||
} from '@/models/letsEncryptServer/certsFlow/PostAccountRequest'
|
} from '@/models/letsEncryptServer/certsFlow/PostAccountRequest'
|
||||||
import App from 'next/app'
|
|
||||||
import { useAppDispatch } from '@/redux/store'
|
import { useAppDispatch } from '@/redux/store'
|
||||||
import { showToast } from '@/redux/slices/toastSlice'
|
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 RegisterPage = () => {
|
||||||
const [account, setAccount] = useState<PostAccountRequest | null>(null)
|
const [account, setAccount] = useState<PostAccountRequest>({
|
||||||
|
description: '',
|
||||||
|
contacts: [],
|
||||||
|
challengeType: '',
|
||||||
|
hostnames: [],
|
||||||
|
isStaging: true
|
||||||
|
})
|
||||||
|
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
value: newContact,
|
value: description,
|
||||||
|
error: descriptionError,
|
||||||
|
handleChange: handleDescriptionChange,
|
||||||
|
reset: resetDescription
|
||||||
|
} = useValidation<string>({
|
||||||
|
initialValue: '',
|
||||||
|
validateFn: isBypass,
|
||||||
|
errorMessage: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
value: contact,
|
||||||
error: contactError,
|
error: contactError,
|
||||||
handleChange: handleContactChange,
|
handleChange: handleContactChange,
|
||||||
reset: resetContact
|
reset: resetContact
|
||||||
@ -36,7 +61,18 @@ const RegisterPage = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
value: newHostname,
|
value: challengeType,
|
||||||
|
error: challengeTypeError,
|
||||||
|
handleChange: handleChallengeTypeChange,
|
||||||
|
reset: resetChallengeType
|
||||||
|
} = useValidation<string>({
|
||||||
|
initialValue: ChallengeTypes.http01,
|
||||||
|
validateFn: isBypass,
|
||||||
|
errorMessage: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
value: hostname,
|
||||||
error: hostnameError,
|
error: hostnameError,
|
||||||
handleChange: handleHostnameChange,
|
handleChange: handleHostnameChange,
|
||||||
reset: resetHostname
|
reset: resetHostname
|
||||||
@ -47,19 +83,25 @@ const RegisterPage = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const init = useRef(false)
|
const init = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (init.current) return
|
if (init.current) return
|
||||||
|
|
||||||
init.current = true
|
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 = () => {
|
const handleAddContact = () => {
|
||||||
if (
|
if (
|
||||||
newContact === '' ||
|
contact === '' ||
|
||||||
account?.contacts.includes(newContact) ||
|
account?.contacts.includes(contact) ||
|
||||||
contactError !== ''
|
contactError !== ''
|
||||||
) {
|
) {
|
||||||
resetContact()
|
resetContact()
|
||||||
@ -67,26 +109,26 @@ const RegisterPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setAccount((prev) => {
|
setAccount((prev) => {
|
||||||
const newAccount: PostAccountRequest =
|
const newAccount = deepCopy(prev)
|
||||||
prev !== null
|
newAccount.contacts.push(contact)
|
||||||
? deepCopy(prev)
|
|
||||||
: {
|
|
||||||
contacts: [],
|
|
||||||
hostnames: []
|
|
||||||
}
|
|
||||||
|
|
||||||
newAccount.contacts.push(newContact)
|
|
||||||
|
|
||||||
return newAccount
|
return newAccount
|
||||||
})
|
})
|
||||||
|
|
||||||
resetContact()
|
resetContact()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDeleteContact = (contact: string) => {
|
||||||
|
setAccount((prev) => {
|
||||||
|
const newAccount = deepCopy(prev)
|
||||||
|
newAccount.contacts = newAccount.contacts.filter((c) => c !== contact)
|
||||||
|
return newAccount
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleAddHostname = () => {
|
const handleAddHostname = () => {
|
||||||
if (
|
if (
|
||||||
newHostname === '' ||
|
hostname === '' ||
|
||||||
account?.hostnames.includes(newHostname) ||
|
account?.hostnames.includes(hostname) ||
|
||||||
hostnameError !== ''
|
hostnameError !== ''
|
||||||
) {
|
) {
|
||||||
resetHostname()
|
resetHostname()
|
||||||
@ -94,40 +136,18 @@ const RegisterPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setAccount((prev) => {
|
setAccount((prev) => {
|
||||||
const newAccount: PostAccountRequest =
|
const newAccount = deepCopy(prev)
|
||||||
prev !== null
|
newAccount.hostnames.push(hostname)
|
||||||
? deepCopy(prev)
|
|
||||||
: {
|
|
||||||
contacts: [],
|
|
||||||
hostnames: []
|
|
||||||
}
|
|
||||||
|
|
||||||
newAccount.hostnames.push(newHostname)
|
|
||||||
|
|
||||||
return newAccount
|
return newAccount
|
||||||
})
|
})
|
||||||
|
|
||||||
resetHostname()
|
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) => {
|
const handleDeleteHostname = (hostname: string) => {
|
||||||
setAccount((prev) => {
|
setAccount((prev) => {
|
||||||
if (prev === null) return null
|
|
||||||
|
|
||||||
const newAccount = deepCopy(prev)
|
const newAccount = deepCopy(prev)
|
||||||
newAccount.hostnames = newAccount.hostnames.filter((h) => h !== hostname)
|
newAccount.hostnames = newAccount.hostnames.filter((h) => h !== hostname)
|
||||||
|
|
||||||
return newAccount
|
return newAccount
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -135,41 +155,49 @@ const RegisterPage = () => {
|
|||||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
const error = validatePostAccountRequest(account)
|
const errors = validatePostAccountRequest(account)
|
||||||
if (error) {
|
|
||||||
console.error(`Validation failed: ${error}`)
|
|
||||||
// dipatch toasterror
|
|
||||||
dispatch(showToast({ message: error, type: 'error' }))
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="container mx-auto p-4">
|
<PageContainer title="Register LetsEncrypt Account">
|
||||||
<h1 className="text-4xl font-bold text-center mb-8">
|
|
||||||
Register LetsEncrypt Account
|
|
||||||
</h1>
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<CustomInput
|
<CustomInput
|
||||||
type="text"
|
value={account.description}
|
||||||
value={account?.description ?? ''}
|
onChange={handleDescriptionChange}
|
||||||
onChange={handleDescription}
|
|
||||||
placeholder="Account Description"
|
placeholder="Account Description"
|
||||||
|
type="text"
|
||||||
|
error={descriptionError}
|
||||||
title="Description"
|
title="Description"
|
||||||
inputClassName="border p-2 rounded w-full"
|
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>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-xl font-medium mb-2">Contacts:</h3>
|
<h3 className="text-xl font-medium mb-2">Contacts:</h3>
|
||||||
<ul className="list-disc list-inside pl-4 mb-2">
|
<ul className="list-disc list-inside pl-4 mb-2">
|
||||||
{account?.contacts.map((contact) => (
|
{account.contacts.map((contact) => (
|
||||||
<li
|
<li
|
||||||
key={contact}
|
key={contact}
|
||||||
className="text-gray-700 flex justify-between items-center mb-2"
|
className="text-gray-700 flex justify-between items-center mb-2"
|
||||||
@ -187,7 +215,7 @@ const RegisterPage = () => {
|
|||||||
</ul>
|
</ul>
|
||||||
<div className="flex items-center mb-4">
|
<div className="flex items-center mb-4">
|
||||||
<CustomInput
|
<CustomInput
|
||||||
value={newContact}
|
value={contact}
|
||||||
onChange={handleContactChange}
|
onChange={handleContactChange}
|
||||||
placeholder="Add contact"
|
placeholder="Add contact"
|
||||||
type="text"
|
type="text"
|
||||||
@ -207,10 +235,23 @@ const RegisterPage = () => {
|
|||||||
</CustomInput>
|
</CustomInput>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="mb-4">
|
||||||
<h3 className="text-xl font-medium mb-2">Hostnames:</h3>
|
<h3 className="text-xl font-medium mb-2">Hostnames:</h3>
|
||||||
<ul className="list-disc list-inside pl-4 mb-2">
|
<ul className="list-disc list-inside pl-4 mb-2">
|
||||||
{account?.hostnames.map((hostname) => (
|
{account.hostnames.map((hostname) => (
|
||||||
<li
|
<li
|
||||||
key={hostname}
|
key={hostname}
|
||||||
className="text-gray-700 flex justify-between items-center mb-2"
|
className="text-gray-700 flex justify-between items-center mb-2"
|
||||||
@ -228,7 +269,7 @@ const RegisterPage = () => {
|
|||||||
</ul>
|
</ul>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<CustomInput
|
<CustomInput
|
||||||
value={newHostname}
|
value={hostname}
|
||||||
onChange={handleHostnameChange}
|
onChange={handleHostnameChange}
|
||||||
placeholder="Add hostname"
|
placeholder="Add hostname"
|
||||||
type="text"
|
type="text"
|
||||||
@ -248,6 +289,28 @@ const RegisterPage = () => {
|
|||||||
</CustomInput>
|
</CustomInput>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<CustomButton
|
||||||
type="submit"
|
type="submit"
|
||||||
className="bg-green-500 text-white px-3 py-1 rounded"
|
className="bg-green-500 text-white px-3 py-1 rounded"
|
||||||
@ -255,7 +318,7 @@ const RegisterPage = () => {
|
|||||||
Create Account
|
Create Account
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
</form>
|
</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 'react-toastify/dist/ReactToastify.css'
|
||||||
import { useDispatch, useSelector } from 'react-redux'
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
import { RootState } from '@/redux/store'
|
import { RootState } from '@/redux/store'
|
||||||
import { clearToast } from '@/redux/slices/toastSlice'
|
import { clearToast, removeToast } from '@/redux/slices/toastSlice'
|
||||||
|
|
||||||
const Toast = () => {
|
const Toast = () => {
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
const toastState = useSelector((state: RootState) => state.toast)
|
const toastState = useSelector((state: RootState) => state.toast)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (toastState.message) {
|
toastState.messages.forEach((toastMessage) => {
|
||||||
switch (toastState.type) {
|
switch (toastMessage.type) {
|
||||||
case 'success':
|
case 'success':
|
||||||
toast.success(toastState.message)
|
toast.success(toastMessage.message)
|
||||||
break
|
break
|
||||||
case 'error':
|
case 'error':
|
||||||
toast.error(toastState.message)
|
toast.error(toastMessage.message)
|
||||||
break
|
break
|
||||||
case 'info':
|
case 'info':
|
||||||
toast.info(toastState.message)
|
toast.info(toastMessage.message)
|
||||||
break
|
break
|
||||||
case 'warning':
|
case 'warning':
|
||||||
toast.warn(toastState.message)
|
toast.warn(toastMessage.message)
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
toast(toastState.message)
|
toast(toastMessage.message)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
dispatch(clearToast())
|
|
||||||
}
|
dispatch(removeToast(toastMessage.id))
|
||||||
|
})
|
||||||
}, [toastState, dispatch])
|
}, [toastState, dispatch])
|
||||||
|
|
||||||
return (
|
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
|
inputClassName?: string
|
||||||
errorClassName?: string
|
errorClassName?: string
|
||||||
className?: string
|
className?: string
|
||||||
|
readOnly?: boolean
|
||||||
|
disabled?: boolean
|
||||||
children?: React.ReactNode // Added for additional elements
|
children?: React.ReactNode // Added for additional elements
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,6 +27,8 @@ const CustomInput: FC<CustomInputProps> = ({
|
|||||||
inputClassName = '',
|
inputClassName = '',
|
||||||
errorClassName = '',
|
errorClassName = '',
|
||||||
className = '',
|
className = '',
|
||||||
|
readOnly = false,
|
||||||
|
disabled = false,
|
||||||
children // Added for additional elements
|
children // Added for additional elements
|
||||||
}) => {
|
}) => {
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@ -41,6 +45,8 @@ const CustomInput: FC<CustomInputProps> = ({
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={`flex-grow ${inputClassName}`}
|
className={`flex-grow ${inputClassName}`}
|
||||||
|
readOnly={readOnly}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
{children && <div className="ml-2">{children}</div>}
|
{children && <div className="ml-2">{children}</div>}
|
||||||
</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 { CustomButton } from './customButton'
|
||||||
import { CustomInput } from './customInput'
|
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 {
|
export interface CacheAccount {
|
||||||
accountId: string
|
accountId: string
|
||||||
|
isDisabled: boolean
|
||||||
description?: string
|
description?: string
|
||||||
contacts: string[]
|
contacts: string[]
|
||||||
challengeType?: string
|
challengeType?: string
|
||||||
hostnames: CacheAccountHostname[]
|
hostnames: CacheAccountHostname[]
|
||||||
isEditMode: boolean
|
isEditMode: boolean
|
||||||
|
isStaging: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,4 +2,5 @@ export interface CacheAccountHostname {
|
|||||||
hostname: string
|
hostname: string
|
||||||
expires: Date
|
expires: Date
|
||||||
isUpcomingExpire: boolean
|
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'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
// Helper functions for validation
|
// Helper functions for validation
|
||||||
|
const isBypass = (value: any) => {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const isValidEmail = (email: string) => {
|
const isValidEmail = (email: string) => {
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
return emailRegex.test(email)
|
return emailRegex.test(email)
|
||||||
@ -87,6 +91,7 @@ const useValidation = <T extends string | number | Date>(
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
useValidation,
|
useValidation,
|
||||||
|
isBypass,
|
||||||
isValidEmail,
|
isValidEmail,
|
||||||
isValidPhoneNumber,
|
isValidPhoneNumber,
|
||||||
isValidContact,
|
isValidContact,
|
||||||
|
|||||||
@ -2,8 +2,10 @@ import { HostnameResponse } from './HostnameResponse'
|
|||||||
|
|
||||||
export interface GetAccountResponse {
|
export interface GetAccountResponse {
|
||||||
accountId: string
|
accountId: string
|
||||||
description?: string
|
isDisabled: boolean
|
||||||
|
description: string
|
||||||
contacts: string[]
|
contacts: string[]
|
||||||
challengeType?: string
|
challengeType?: string
|
||||||
hostnames?: HostnameResponse[]
|
hostnames?: HostnameResponse[]
|
||||||
|
isStaging: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,4 +2,5 @@ export interface HostnameResponse {
|
|||||||
hostname: string
|
hostname: string
|
||||||
expires: string
|
expires: string
|
||||||
isUpcomingExpire: boolean
|
isUpcomingExpire: boolean
|
||||||
|
isDisabled: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,32 +1,57 @@
|
|||||||
import { isValidContact, isValidHostname } from '@/hooks/useValidation'
|
import { isValidContact, isValidHostname } from '@/hooks/useValidation'
|
||||||
|
|
||||||
export interface PostAccountRequest {
|
export interface PostAccountRequest {
|
||||||
description?: string
|
description: string
|
||||||
contacts: string[]
|
contacts: string[]
|
||||||
|
challengeType: string
|
||||||
hostnames: string[]
|
hostnames: string[]
|
||||||
|
isStaging: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const validatePostAccountRequest = (
|
const validatePostAccountRequest = (
|
||||||
request: PostAccountRequest | null
|
request: PostAccountRequest | null
|
||||||
): string | null => {
|
): string[] => {
|
||||||
if (request === null) return 'Request is null'
|
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
|
// 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)) {
|
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
|
// Validate hostnames
|
||||||
for (const hostname of request.hostnames) {
|
if (request.hostnames.length === 0) {
|
||||||
if (!isValidHostname(hostname)) {
|
errors.push('Hostnames cannot be empty')
|
||||||
return `Invalid hostname: ${hostname}`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If all validations pass, return null
|
request.hostnames.forEach((hostname) => {
|
||||||
return null
|
if (!isValidHostname(hostname)) {
|
||||||
|
errors.push(`Invalid hostname: ${hostname}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return the array of errors
|
||||||
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
export { validatePostAccountRequest }
|
export { validatePostAccountRequest }
|
||||||
|
|||||||
22
src/ClientApp/package-lock.json
generated
22
src/ClientApp/package-lock.json
generated
@ -15,12 +15,14 @@
|
|||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-icons": "^5.2.1",
|
"react-icons": "^5.2.1",
|
||||||
"react-redux": "^9.1.2",
|
"react-redux": "^9.1.2",
|
||||||
"react-toastify": "^10.0.5"
|
"react-toastify": "^10.0.5",
|
||||||
|
"uuid": "^10.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.2.3",
|
"eslint-config-next": "14.2.3",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"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",
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz",
|
||||||
"integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA=="
|
"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": {
|
"node_modules/@typescript-eslint/parser": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz",
|
||||||
@ -4814,6 +4822,18 @@
|
|||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@ -16,12 +16,14 @@
|
|||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-icons": "^5.2.1",
|
"react-icons": "^5.2.1",
|
||||||
"react-redux": "^9.1.2",
|
"react-redux": "^9.1.2",
|
||||||
"react-toastify": "^10.0.5"
|
"react-toastify": "^10.0.5",
|
||||||
|
"uuid": "^10.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.2.3",
|
"eslint-config-next": "14.2.3",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
|||||||
@ -1,36 +1,42 @@
|
|||||||
// store/toastSlice.ts
|
// store/toastSlice.ts
|
||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
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
|
message: string
|
||||||
type: 'success' | 'error' | 'info' | 'warning'
|
type: 'success' | 'error' | 'info' | 'warning'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ToastState {
|
||||||
|
messages: ToastMessage[]
|
||||||
|
}
|
||||||
|
|
||||||
const initialState: ToastState = {
|
const initialState: ToastState = {
|
||||||
message: '',
|
messages: []
|
||||||
type: 'info'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toastSlice = createSlice({
|
const toastSlice = createSlice({
|
||||||
name: 'toast',
|
name: 'toast',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
showToast: (
|
showToast: (state, action: PayloadAction<Omit<ToastMessage, 'id'>>) => {
|
||||||
state,
|
// Generate a unique ID for each toast message
|
||||||
action: PayloadAction<{
|
const id = uuidv4()
|
||||||
message: string
|
const newMessage = { ...action.payload, id }
|
||||||
type: 'success' | 'error' | 'info' | 'warning'
|
state.messages.push(newMessage)
|
||||||
}>
|
|
||||||
) => {
|
|
||||||
state.message = action.payload.message
|
|
||||||
state.type = action.payload.type
|
|
||||||
},
|
},
|
||||||
clearToast: (state) => {
|
clearToast: (state) => {
|
||||||
state.message = ''
|
state.messages = []
|
||||||
state.type = 'info'
|
},
|
||||||
|
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
|
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;
|
using MaksIT.LetsEncrypt.Entities.Jws;
|
||||||
|
|
||||||
namespace MaksIT.LetsEncrypt.Entities;
|
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 {
|
public class RegistrationCache {
|
||||||
|
|
||||||
@ -30,9 +14,12 @@ public class RegistrationCache {
|
|||||||
/// Field used to identify cache by account id
|
/// Field used to identify cache by account id
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required Guid AccountId { get; set; }
|
public required Guid AccountId { get; set; }
|
||||||
|
public bool IsDisabled { get; set; }
|
||||||
public string? Description { 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 string? ChallengeType { get; set; }
|
||||||
|
|
||||||
|
public required bool IsStaging { get; set; }
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
|
||||||
@ -54,7 +41,7 @@ public class RegistrationCache {
|
|||||||
foreach (var result in CachedCerts) {
|
foreach (var result in CachedCerts) {
|
||||||
var (subject, cachedChert) = result;
|
var (subject, cachedChert) = result;
|
||||||
|
|
||||||
if (cachedChert.Cert != null) {
|
if (cachedChert.Cert != null && !cachedChert.IsDisabled) {
|
||||||
var cert = new X509Certificate2(Encoding.ASCII.GetBytes(cachedChert.Cert));
|
var cert = new X509Certificate2(Encoding.ASCII.GetBytes(cachedChert.Cert));
|
||||||
|
|
||||||
// if it is about to expire, we need to refresh
|
// if it is about to expire, we need to refresh
|
||||||
@ -79,7 +66,12 @@ public class RegistrationCache {
|
|||||||
if (cachedChert.Cert != null) {
|
if (cachedChert.Cert != null) {
|
||||||
var cert = new X509Certificate2(Encoding.ASCII.GetBytes(cachedChert.Cert));
|
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="subject"></param>
|
||||||
/// <param name="value"></param>
|
/// <param name="value"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public bool TryGetCachedCertificate(string subject, out CachedCertificateResult? value) {
|
public bool TryGetCachedCertificate(string subject, out CertificateCache? value) {
|
||||||
value = null;
|
value = null;
|
||||||
|
|
||||||
if (CachedCerts == null)
|
if (CachedCerts == null)
|
||||||
@ -110,9 +102,10 @@ public class RegistrationCache {
|
|||||||
var rsa = new RSACryptoServiceProvider(4096);
|
var rsa = new RSACryptoServiceProvider(4096);
|
||||||
rsa.ImportCspBlob(cache.Private);
|
rsa.ImportCspBlob(cache.Private);
|
||||||
|
|
||||||
value = new CachedCertificateResult {
|
value = new CertificateCache {
|
||||||
Certificate = cache.Cert,
|
Cert = cache.Cert,
|
||||||
PrivateKey = rsa
|
Private = rsa.ExportCspBlob(true),
|
||||||
|
PrivatePem = rsa.ExportRSAPrivateKeyPem()
|
||||||
};
|
};
|
||||||
return true;
|
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 @@
|
|||||||
|
namespace MaksIT.LetsEncrypt.Models.Responses;
|
||||||
using MaksIT.LetsEncrypt.Entities;
|
|
||||||
|
|
||||||
namespace MaksIT.LetsEncrypt.Models.Responses;
|
|
||||||
|
|
||||||
public class AuthorizationChallengeResponse {
|
public class AuthorizationChallengeResponse {
|
||||||
public OrderIdentifier? Identifier { get; set; }
|
public OrderIdentifier? Identifier { get; set; }
|
||||||
@ -12,7 +9,7 @@ public class AuthorizationChallengeResponse {
|
|||||||
|
|
||||||
public bool Wildcard { get; set; }
|
public bool Wildcard { get; set; }
|
||||||
|
|
||||||
public AuthorizationChallenge[]? Challenges { get; set; }
|
public AuthorizationChallengeChallenge[]? Challenges { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AuthorizeChallenge {
|
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.Interfaces;
|
||||||
using MaksIT.LetsEncrypt.Models.Requests;
|
using MaksIT.LetsEncrypt.Models.Requests;
|
||||||
using MaksIT.LetsEncrypt.Entities.Jws;
|
using MaksIT.LetsEncrypt.Entities.Jws;
|
||||||
|
using MaksIT.LetsEncrypt.Entities.LetsEncrypt;
|
||||||
|
|
||||||
namespace MaksIT.LetsEncrypt.Services;
|
namespace MaksIT.LetsEncrypt.Services;
|
||||||
|
|
||||||
public interface ILetsEncryptService {
|
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);
|
Task<IDomainResult> Init(Guid sessionId,Guid accountId, string description, string[] contacts, RegistrationCache? registrationCache);
|
||||||
(RegistrationCache?, IDomainResult) GetRegistrationCache(Guid sessionId);
|
(RegistrationCache?, IDomainResult) GetRegistrationCache(Guid sessionId);
|
||||||
(string?, IDomainResult) GetTermsOfServiceUri(Guid sessionId);
|
(string?, IDomainResult) GetTermsOfServiceUri(Guid sessionId);
|
||||||
@ -27,8 +28,6 @@ public interface ILetsEncryptService {
|
|||||||
Task<IDomainResult> CompleteChallenges(Guid sessionId);
|
Task<IDomainResult> CompleteChallenges(Guid sessionId);
|
||||||
Task<IDomainResult> GetOrder(Guid sessionId, string[] hostnames);
|
Task<IDomainResult> GetOrder(Guid sessionId, string[] hostnames);
|
||||||
Task<IDomainResult> GetCertificate(Guid sessionId, string subject);
|
Task<IDomainResult> GetCertificate(Guid sessionId, string subject);
|
||||||
(string[]?, IDomainResult) HostsWithUpcomingSslExpiry(Guid sessionId);
|
|
||||||
(CachedCertificateResult?, IDomainResult) TryGetCachedCertificate(Guid sessionId, string subject);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class LetsEncryptService : ILetsEncryptService {
|
public class LetsEncryptService : ILetsEncryptService {
|
||||||
@ -54,11 +53,17 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#region ConfigureClient
|
#region ConfigureClient
|
||||||
public async Task<IDomainResult> ConfigureClient(Guid sessionId, string url) {
|
public async Task<IDomainResult> ConfigureClient(Guid sessionId, bool isStaging) {
|
||||||
try {
|
try {
|
||||||
var state = GetOrCreateState(sessionId);
|
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) {
|
if (state.Directory == null) {
|
||||||
var (directory, getAcmeDirectoryResult) = await SendAsync<AcmeDirectory>(sessionId, HttpMethod.Get, new Uri("directory", UriKind.Relative), false, 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,
|
AccountId = accountId,
|
||||||
Description = description,
|
Description = description,
|
||||||
Contacts = contacts,
|
Contacts = contacts,
|
||||||
|
IsStaging = state.IsStaging,
|
||||||
|
|
||||||
Location = account.Result.Location,
|
Location = account.Result.Location,
|
||||||
AccountKey = accountKey.ExportCspBlob(true),
|
AccountKey = accountKey.ExportCspBlob(true),
|
||||||
@ -307,7 +313,7 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
await Task.Delay(1000);
|
await Task.Delay(1000);
|
||||||
|
|
||||||
if ((DateTime.UtcNow - start).Seconds > 120)
|
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 ??= new Dictionary<string, CertificateCache>();
|
||||||
state.Cache.CachedCerts[subject] = new CertificateCache {
|
state.Cache.CachedCerts[subject] = new CertificateCache {
|
||||||
Cert = pem.Result,
|
Cert = pem.Result,
|
||||||
Private = key.ExportCspBlob(true)
|
Private = key.ExportCspBlob(true),
|
||||||
|
PrivatePem = key.ExportRSAPrivateKeyPem()
|
||||||
};
|
};
|
||||||
|
|
||||||
var cert = new X509Certificate2(Encoding.UTF8.GetBytes(pem.Result));
|
var cert = new X509Certificate2(Encoding.UTF8.GetBytes(pem.Result));
|
||||||
@ -437,29 +444,6 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
}
|
}
|
||||||
#endregion
|
#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) {
|
public Task<IDomainResult> KeyChange(Guid sessionId) {
|
||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
@ -679,13 +663,4 @@ public class LetsEncryptService : ILetsEncryptService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
#endregion
|
#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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var account in accountsResponse) {
|
foreach (var account in accountsResponse.Where(x => !x.IsDisabled)) {
|
||||||
await ProcessAccountAsync(account);
|
await ProcessAccountAsync(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,7 +61,7 @@ namespace MaksIT.LetsEncryptServer.BackgroundServices {
|
|||||||
return IDomainResult.Success();
|
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)
|
if (!renewResult.IsSuccess)
|
||||||
return renewResult;
|
return renewResult;
|
||||||
|
|
||||||
@ -70,8 +70,8 @@ namespace MaksIT.LetsEncryptServer.BackgroundServices {
|
|||||||
return IDomainResult.Success();
|
return IDomainResult.Success();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IDomainResult> RenewCertificatesForHostnames(Guid accountId, string description, string[] contacts, string[] hostnames, string challengeType) {
|
private async Task<IDomainResult> RenewCertificatesForHostnames(Guid accountId, string description, string[] contacts, string[] hostnames, string challengeType, bool isStaging) {
|
||||||
var (sessionId, configureClientResult) = await _certsFlowService.ConfigureClientAsync();
|
var (sessionId, configureClientResult) = await _certsFlowService.ConfigureClientAsync(isStaging);
|
||||||
if (!configureClientResult.IsSuccess || sessionId == null) {
|
if (!configureClientResult.IsSuccess || sessionId == null) {
|
||||||
LogErrors(configureClientResult.Errors);
|
LogErrors(configureClientResult.Errors);
|
||||||
return configureClientResult;
|
return configureClientResult;
|
||||||
|
|||||||
@ -11,7 +11,6 @@
|
|||||||
public class Configuration {
|
public class Configuration {
|
||||||
public required string Production { get; set; }
|
public required string Production { get; set; }
|
||||||
public required string Staging { get; set; }
|
public required string Staging { get; set; }
|
||||||
public required bool DevMode { get; set; }
|
|
||||||
public required Agent Agent { get; set; }
|
public required Agent Agent { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,8 +27,8 @@ namespace MaksIT.LetsEncryptServer.Controllers {
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>sessionId</returns>
|
/// <returns>sessionId</returns>
|
||||||
[HttpPost("configure-client")]
|
[HttpPost("configure-client")]
|
||||||
public async Task<IActionResult> ConfigureClient() {
|
public async Task<IActionResult> ConfigureClient([FromBody] ConfigureClientRequest requestData) {
|
||||||
var result = await _certsFlowService.ConfigureClientAsync();
|
var result = await _certsFlowService.ConfigureClientAsync(requestData);
|
||||||
return result.ToActionResult();
|
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.Entities;
|
||||||
using MaksIT.LetsEncrypt.Models.Responses;
|
|
||||||
using MaksIT.Models;
|
using MaksIT.Models;
|
||||||
using MaksIT.Models.LetsEncryptServer.Account.Requests;
|
using MaksIT.Models.LetsEncryptServer.Account.Requests;
|
||||||
using MaksIT.Models.LetsEncryptServer.Account.Responses;
|
using MaksIT.Models.LetsEncryptServer.Account.Responses;
|
||||||
@ -59,35 +55,24 @@ public class AccountService : IAccountService {
|
|||||||
return (null, result);
|
return (null, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
var accounts = caches.Select(cache => new GetAccountResponse {
|
var accounts = caches
|
||||||
AccountId = cache.AccountId,
|
.Select(x => CreateGetAccountResponse(x.AccountId, x))
|
||||||
Description = cache.Description,
|
.ToArray();
|
||||||
Contacts = cache.Contacts,
|
|
||||||
ChallengeType = cache.ChallengeType,
|
|
||||||
Hostnames = GetHostnamesFromCache(cache).ToArray()
|
|
||||||
});
|
|
||||||
|
|
||||||
return IDomainResult.Success(accounts.ToArray());
|
return IDomainResult.Success(accounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(GetAccountResponse?, IDomainResult)> GetAccountAsync(Guid accountId) {
|
public async Task<(GetAccountResponse?, IDomainResult)> GetAccountAsync(Guid accountId) {
|
||||||
var (cache, result) = await _cacheService.LoadAccountFromCacheAsync(accountId);
|
var (cache, result) = await _cacheService.LoadAccountFromCacheAsync(accountId);
|
||||||
if (!result.IsSuccess || cache == null) {
|
if (!result.IsSuccess || cache == null) {
|
||||||
return (null, result);
|
return (null, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
var response = new GetAccountResponse {
|
return IDomainResult.Success(CreateGetAccountResponse(accountId, cache));
|
||||||
AccountId = accountId,
|
|
||||||
Description = cache.Description,
|
|
||||||
Contacts = cache.Contacts,
|
|
||||||
Hostnames = GetHostnamesFromCache(cache).ToArray()
|
|
||||||
};
|
|
||||||
|
|
||||||
return IDomainResult.Success(response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(GetAccountResponse?, IDomainResult)> PostAccountAsync(PostAccountRequest requestData) {
|
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) {
|
if (!configureClientResult.IsSuccess || sessionId == null) {
|
||||||
//LogErrors(configureClientResult.Errors);
|
//LogErrors(configureClientResult.Errors);
|
||||||
return (null, configureClientResult);
|
return (null, configureClientResult);
|
||||||
@ -147,7 +132,7 @@ public class AccountService : IAccountService {
|
|||||||
return (null, saveResult);
|
return (null, saveResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
return CreateGetAccountResponse(accountId, cache);
|
return IDomainResult.Success(CreateGetAccountResponse(accountId, cache));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(GetAccountResponse?, IDomainResult)> PatchAccountAsync(Guid accountId, PatchAccountRequest requestData) {
|
public async Task<(GetAccountResponse?, IDomainResult)> PatchAccountAsync(Guid accountId, PatchAccountRequest requestData) {
|
||||||
@ -190,7 +175,7 @@ public class AccountService : IAccountService {
|
|||||||
return (null, saveResult);
|
return (null, saveResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
return CreateGetAccountResponse(accountId, cache);
|
return IDomainResult.Success(CreateGetAccountResponse(accountId, cache));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IDomainResult> DeleteAccountAsync(Guid accountId) {
|
public async Task<IDomainResult> DeleteAccountAsync(Guid accountId) {
|
||||||
@ -227,7 +212,7 @@ public class AccountService : IAccountService {
|
|||||||
return (null, saveResult);
|
return (null, saveResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
return CreateGetAccountResponse(accountId, cache);
|
return IDomainResult.Success(CreateGetAccountResponse(accountId, cache));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(GetAccountResponse?, IDomainResult)> PatchContactsAsync(Guid accountId, PatchContactsRequest requestData) {
|
public async Task<(GetAccountResponse?, IDomainResult)> PatchContactsAsync(Guid accountId, PatchContactsRequest requestData) {
|
||||||
@ -266,7 +251,7 @@ public class AccountService : IAccountService {
|
|||||||
return (null, saveResult);
|
return (null, saveResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
return CreateGetAccountResponse(accountId, cache);
|
return IDomainResult.Success(CreateGetAccountResponse(accountId, cache));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IDomainResult> DeleteContactAsync(Guid accountId, int index) {
|
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 {
|
var hosts = cache.GetHosts().Select(x => new HostnameResponse {
|
||||||
Hostname = x.Hostname,
|
Hostname = x.Hostname,
|
||||||
Expires = x.Expires,
|
Expires = x.Expires,
|
||||||
IsUpcomingExpire = x.IsUpcomingExpire
|
IsUpcomingExpire = x.IsUpcomingExpire,
|
||||||
|
IsDisabled = x.IsDisabled
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
return hosts;
|
return hosts;
|
||||||
@ -321,15 +307,18 @@ public class AccountService : IAccountService {
|
|||||||
|
|
||||||
#region Helper Methods
|
#region Helper Methods
|
||||||
|
|
||||||
private (GetAccountResponse?, IDomainResult) CreateGetAccountResponse(Guid accountId, RegistrationCache cache) {
|
private GetAccountResponse CreateGetAccountResponse(Guid accountId, RegistrationCache cache) {
|
||||||
var hostnames = GetHostnamesFromCache(cache) ?? new List<HostnameResponse>();
|
var hostnames = GetHostnamesFromCache(cache) ?? [];
|
||||||
|
|
||||||
return (new GetAccountResponse {
|
return new GetAccountResponse {
|
||||||
AccountId = accountId,
|
AccountId = accountId,
|
||||||
|
IsDisabled = cache.IsDisabled,
|
||||||
Description = cache.Description,
|
Description = cache.Description,
|
||||||
Contacts = cache.Contacts,
|
Contacts = cache.Contacts,
|
||||||
Hostnames = hostnames.ToArray()
|
ChallengeType = cache.ChallengeType,
|
||||||
}, IDomainResult.Success());
|
Hostnames = [.. hostnames],
|
||||||
|
IsStaging = cache.IsStaging
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -5,17 +5,19 @@ using DomainResults.Common;
|
|||||||
using MaksIT.LetsEncrypt.Entities;
|
using MaksIT.LetsEncrypt.Entities;
|
||||||
using MaksIT.LetsEncrypt.Services;
|
using MaksIT.LetsEncrypt.Services;
|
||||||
using MaksIT.Models.LetsEncryptServer.CertsFlow.Requests;
|
using MaksIT.Models.LetsEncryptServer.CertsFlow.Requests;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
|
||||||
namespace MaksIT.LetsEncryptServer.Services;
|
namespace MaksIT.LetsEncryptServer.Services;
|
||||||
|
|
||||||
public interface ICertsCommonService {
|
public interface ICertsCommonService {
|
||||||
Task<(Guid?, IDomainResult)> ConfigureClientAsync();
|
|
||||||
(string?, IDomainResult) GetTermsOfService(Guid sessionId);
|
(string?, IDomainResult) GetTermsOfService(Guid sessionId);
|
||||||
Task<IDomainResult> CompleteChallengesAsync(Guid sessionId);
|
Task<IDomainResult> CompleteChallengesAsync(Guid sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface ICertsInternalService : ICertsCommonService {
|
public interface ICertsInternalService : ICertsCommonService {
|
||||||
|
Task<(Guid?, IDomainResult)> ConfigureClientAsync(bool isStaging);
|
||||||
Task<(Guid?, IDomainResult)> InitAsync(Guid sessionId, Guid? accountId, string description, string[] contacts);
|
Task<(Guid?, IDomainResult)> InitAsync(Guid sessionId, Guid? accountId, string description, string[] contacts);
|
||||||
Task<(List<string>?, IDomainResult)> NewOrderAsync(Guid sessionId, string[] hostnames, string challengeType);
|
Task<(List<string>?, IDomainResult)> NewOrderAsync(Guid sessionId, string[] hostnames, string challengeType);
|
||||||
Task<IDomainResult> GetOrderAsync(Guid sessionId, string[] hostnames);
|
Task<IDomainResult> GetOrderAsync(Guid sessionId, string[] hostnames);
|
||||||
@ -24,6 +26,7 @@ public interface ICertsInternalService : ICertsCommonService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public interface ICertsRestService : ICertsCommonService {
|
public interface ICertsRestService : ICertsCommonService {
|
||||||
|
Task<(Guid?, IDomainResult)> ConfigureClientAsync(ConfigureClientRequest requestData);
|
||||||
Task<(Guid?, IDomainResult)> InitAsync(Guid sessionId, Guid? accountId, InitRequest requestData);
|
Task<(Guid?, IDomainResult)> InitAsync(Guid sessionId, Guid? accountId, InitRequest requestData);
|
||||||
Task<(List<string>?, IDomainResult)> NewOrderAsync(Guid sessionId, NewOrderRequest requestData);
|
Task<(List<string>?, IDomainResult)> NewOrderAsync(Guid sessionId, NewOrderRequest requestData);
|
||||||
Task<IDomainResult> GetOrderAsync(Guid sessionId, GetOrderRequest requestData);
|
Task<IDomainResult> GetOrderAsync(Guid sessionId, GetOrderRequest requestData);
|
||||||
@ -38,7 +41,7 @@ public interface ICertsRestChallengeService {
|
|||||||
public interface ICertsFlowService
|
public interface ICertsFlowService
|
||||||
: ICertsInternalService,
|
: ICertsInternalService,
|
||||||
ICertsRestService,
|
ICertsRestService,
|
||||||
ICertsRestChallengeService {}
|
ICertsRestChallengeService { }
|
||||||
|
|
||||||
public class CertsFlowService : ICertsFlowService {
|
public class CertsFlowService : ICertsFlowService {
|
||||||
|
|
||||||
@ -70,19 +73,7 @@ public class CertsFlowService : ICertsFlowService {
|
|||||||
|
|
||||||
#region Common methods
|
#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) {
|
public (string?, IDomainResult) GetTermsOfService(Guid sessionId) {
|
||||||
var (terms, getTermsResult) = _letsEncryptService.GetTermsOfServiceUri(sessionId);
|
var (terms, getTermsResult) = _letsEncryptService.GetTermsOfServiceUri(sessionId);
|
||||||
@ -99,6 +90,15 @@ public class CertsFlowService : ICertsFlowService {
|
|||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Internal methods
|
#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) {
|
public async Task<(Guid?, IDomainResult)> InitAsync(Guid sessionId, Guid? accountId, string description, string[] contacts) {
|
||||||
RegistrationCache? cache = null;
|
RegistrationCache? cache = null;
|
||||||
@ -161,18 +161,22 @@ public class CertsFlowService : ICertsFlowService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(Dictionary<string, string>?, IDomainResult)> ApplyCertificatesAsync(Guid sessionId, string[] hostnames) {
|
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>();
|
var results = new Dictionary<string, string>();
|
||||||
|
foreach (var hostname in hostnames) {
|
||||||
foreach (var subject in hostnames) {
|
CertificateCache? cert;
|
||||||
var (cert, getCertResult) = _letsEncryptService.TryGetCachedCertificate(sessionId, subject);
|
if (cache.TryGetCachedCertificate(hostname, out cert)) {
|
||||||
if (!getCertResult.IsSuccess || cert == null)
|
var content = $"{cert.Cert}\n{cert.PrivatePem}";
|
||||||
return (null, getCertResult);
|
results.Add(hostname, content);
|
||||||
|
}
|
||||||
var content = $"{cert.Certificate}\n{cert.PrivateKeyPem}";
|
|
||||||
results.Add(subject, content);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: send the certificates to the server
|
// Send the certificates to the via agent
|
||||||
var uploadResult = await _agentService.UploadCerts(results);
|
var uploadResult = await _agentService.UploadCerts(results);
|
||||||
if (!uploadResult.IsSuccess)
|
if (!uploadResult.IsSuccess)
|
||||||
return (null, uploadResult);
|
return (null, uploadResult);
|
||||||
@ -186,7 +190,9 @@ public class CertsFlowService : ICertsFlowService {
|
|||||||
|
|
||||||
#endregion
|
#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) =>
|
public Task<(Guid?, IDomainResult)> InitAsync(Guid sessionId, Guid? accountId, InitRequest requestData) =>
|
||||||
InitAsync(sessionId, accountId, requestData.Description, requestData.Contacts);
|
InitAsync(sessionId, accountId, requestData.Description, requestData.Contacts);
|
||||||
@ -205,7 +211,7 @@ public class CertsFlowService : ICertsFlowService {
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Acme Challenge Webapi specific methods
|
#region Acme Challenge REST methods
|
||||||
|
|
||||||
public (string?, IDomainResult) AcmeChallenge(string fileName) {
|
public (string?, IDomainResult) AcmeChallenge(string fileName) {
|
||||||
DeleteExporedChallenges();
|
DeleteExporedChallenges();
|
||||||
|
|||||||
@ -11,8 +11,6 @@
|
|||||||
"Production": "https://acme-v02.api.letsencrypt.org/directory",
|
"Production": "https://acme-v02.api.letsencrypt.org/directory",
|
||||||
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory",
|
"Staging": "https://acme-staging-v02.api.letsencrypt.org/directory",
|
||||||
|
|
||||||
"DevMode": false,
|
|
||||||
|
|
||||||
"Agent": {
|
"Agent": {
|
||||||
"AgentHostname": "http://lblsrv0001.corp.maks-it.com",
|
"AgentHostname": "http://lblsrv0001.corp.maks-it.com",
|
||||||
"AgentPort": 5000,
|
"AgentPort": 5000,
|
||||||
|
|||||||
@ -4,9 +4,9 @@ namespace MaksIT.Models.LetsEncryptServer.Account.Requests {
|
|||||||
public class PostAccountRequest : IValidatableObject {
|
public class PostAccountRequest : IValidatableObject {
|
||||||
public required string Description { get; set; }
|
public required string Description { get; set; }
|
||||||
public required string[] Contacts { get; set; }
|
public required string[] Contacts { get; set; }
|
||||||
public required string[] Hostnames { get; set; }
|
|
||||||
|
|
||||||
public required string ChallengeType { 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) {
|
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) {
|
||||||
if (string.IsNullOrWhiteSpace(Description))
|
if (string.IsNullOrWhiteSpace(Description))
|
||||||
|
|||||||
@ -7,13 +7,16 @@ using System.Threading.Tasks;
|
|||||||
namespace MaksIT.Models.LetsEncryptServer.Account.Responses {
|
namespace MaksIT.Models.LetsEncryptServer.Account.Responses {
|
||||||
public class GetAccountResponse {
|
public class GetAccountResponse {
|
||||||
public Guid AccountId { get; set; }
|
public Guid AccountId { get; set; }
|
||||||
|
public required bool IsDisabled { get; set; }
|
||||||
|
|
||||||
public string? Description { 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 string? ChallengeType { get; set; }
|
||||||
|
|
||||||
public HostnameResponse[]? Hostnames { 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 {
|
namespace MaksIT.Models.LetsEncryptServer.Account.Responses {
|
||||||
public class HostnameResponse {
|
public class HostnameResponse {
|
||||||
public string Hostname { get; set; }
|
public required string Hostname { get; set; }
|
||||||
public DateTime Expires { get; set; }
|
public DateTime Expires { get; set; }
|
||||||
public bool IsUpcomingExpire { 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