From 3e0c25158e982879cd6dd7488fa34f5ffa639fc2 Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Thu, 4 Jul 2024 23:34:23 +0200 Subject: [PATCH] (feature): patch account form complete --- src/ClientApp/app/page.tsx | 29 +- src/ClientApp/controls/customSelect.tsx | 4 +- src/ClientApp/models/PatchAction.ts | 2 + src/ClientApp/models/PatchOperation.ts | 5 +- .../account/requests/PatchAccountRequest.ts | 7 + src/ClientApp/partials/accoutEdit.tsx | 383 ++++++++++-------- src/ClientApp/services/HttpService.tsx | 98 ++++- .../Services/AccoutService.cs | 3 + .../Services/CertsFlowService.cs | 5 +- 9 files changed, 340 insertions(+), 196 deletions(-) diff --git a/src/ClientApp/app/page.tsx b/src/ClientApp/app/page.tsx index 2cd72a8..7560b6d 100644 --- a/src/ClientApp/app/page.tsx +++ b/src/ClientApp/app/page.tsx @@ -18,6 +18,7 @@ import { FaPlus, FaTrash } from 'react-icons/fa' import { PageContainer } from '@/components/pageContainer' import { OffCanvas } from '@/components/offcanvas' import { AccountEdit } from '@/partials/accoutEdit' +import { get } from 'http' export default function Page() { const [accounts, setAccounts] = useState([]) @@ -34,11 +35,13 @@ export default function Page() { const fetchAccounts = async () => { const newAccounts: CacheAccount[] = [] - const accounts = await httpService.get( + const gatAccountsResult = await httpService.get( GetApiRoute(ApiRoutes.ACCOUNTS) ) - accounts?.forEach((account) => { + if (!gatAccountsResult.isSuccess) return + + gatAccountsResult.data?.forEach((account) => { newAccounts.push({ accountId: account.accountId, isDisabled: account.isDisabled, @@ -79,10 +82,15 @@ export default function Page() { } const deleteAccount = (accountId: string) => { - setAccounts(accounts.filter((account) => account.accountId !== accountId)) - - // TODO: Revoke all certificates - // TODO: Remove from cache + httpService + .delete(GetApiRoute(ApiRoutes.ACCOUNT_ID, accountId)) + .then((response) => { + if (response.isSuccess) { + setAccounts( + accounts.filter((account) => account.accountId !== accountId) + ) + } + }) } return ( @@ -195,7 +203,14 @@ export default function Page() { isOpen={editingAccount !== null} onClose={() => setEditingAccount(null)} > - {editingAccount && } + {editingAccount && ( + setEditingAccount(null)} + onDelete={deleteAccount} + /> + )} ) diff --git a/src/ClientApp/controls/customSelect.tsx b/src/ClientApp/controls/customSelect.tsx index 3dbcfff..ac8d782 100644 --- a/src/ClientApp/controls/customSelect.tsx +++ b/src/ClientApp/controls/customSelect.tsx @@ -8,7 +8,7 @@ export interface CustomSelectOption { export interface CustomSelectPropsBase { selectedValue: string | null | undefined - onChange: (value: string) => void + onChange?: (value: string) => void readOnly?: boolean disabled?: boolean title?: string @@ -45,7 +45,7 @@ const CustomSelect: React.FC = ({ const handleOptionClick = (option: CustomSelectOption) => { if (!readOnly && !disabled) { - onChange(option.value) + onChange?.(option.value) setIsOpen(false) } } diff --git a/src/ClientApp/models/PatchAction.ts b/src/ClientApp/models/PatchAction.ts index cb4e914..545e2e8 100644 --- a/src/ClientApp/models/PatchAction.ts +++ b/src/ClientApp/models/PatchAction.ts @@ -1,3 +1,5 @@ +import { PatchOperation } from './PatchOperation' + export interface PatchAction { op: PatchOperation // Enum for operation type index?: number // Index for the operation (for arrays/lists) diff --git a/src/ClientApp/models/PatchOperation.ts b/src/ClientApp/models/PatchOperation.ts index a00d27b..f8a0c1a 100644 --- a/src/ClientApp/models/PatchOperation.ts +++ b/src/ClientApp/models/PatchOperation.ts @@ -1,5 +1,6 @@ -enum PatchOperation { +export enum PatchOperation { Add, Remove, - Replace + Replace, + None } diff --git a/src/ClientApp/models/letsEncryptServer/account/requests/PatchAccountRequest.ts b/src/ClientApp/models/letsEncryptServer/account/requests/PatchAccountRequest.ts index 966a8d0..cf18b06 100644 --- a/src/ClientApp/models/letsEncryptServer/account/requests/PatchAccountRequest.ts +++ b/src/ClientApp/models/letsEncryptServer/account/requests/PatchAccountRequest.ts @@ -1,6 +1,13 @@ import { PatchAction } from '@/models/PatchAction' +export interface PatchHostnameRequest { + hostname?: PatchAction + isDisabled?: PatchAction +} + export interface PatchAccountRequest { description?: PatchAction + isDisabled?: PatchAction contacts?: PatchAction[] + hostnames?: PatchHostnameRequest[] } diff --git a/src/ClientApp/partials/accoutEdit.tsx b/src/ClientApp/partials/accoutEdit.tsx index 3a9a260..abba49d 100644 --- a/src/ClientApp/partials/accoutEdit.tsx +++ b/src/ClientApp/partials/accoutEdit.tsx @@ -1,6 +1,6 @@ 'use client' -import { FormEvent, useCallback, useState } from 'react' +import { Dispatch, FormEvent, SetStateAction, useEffect, useState } from 'react' import { useValidation, isValidEmail, @@ -17,39 +17,71 @@ import { import { CacheAccount } from '@/entities/CacheAccount' import { FaPlus, FaTrash } from 'react-icons/fa' import { ChallengeTypes } from '@/entities/ChallengeTypes' +import { deepCopy } from '@/functions' +import { ApiRoutes, GetApiRoute } from '@/ApiRoutes' +import { httpService } from '@/services/httpService' +import { PatchAccountRequest } from '@/models/letsEncryptServer/account/requests/PatchAccountRequest' +import { PatchOperation } from '@/models/PatchOperation' +import { useAppDispatch } from '@/redux/store' +import { showToast } from '@/redux/slices/toastSlice' interface AccountEditProps { account: CacheAccount + setAccount: Dispatch> onCancel?: () => void - onSave?: (account: CacheAccount) => void - onDelete?: (accountId: string) => void + onSubmit?: (account: CacheAccount) => void + onDelete: (accountId: string) => void } -const AccountEdit: React.FC = (props) => { - const { account, onCancel, onSave, onDelete } = props +const AccountEdit: React.FC = ({ + account, + setAccount, + onCancel, + onSubmit, + onDelete +}) => { + const dispatch = useAppDispatch() + + const [newAccount, setNewAccount] = useState({ + description: { op: PatchOperation.None, value: account.description }, + isDisabled: { op: PatchOperation.None, value: account.isDisabled }, + contacts: account.contacts.map((contact) => ({ + op: PatchOperation.None, + value: contact + })), + hostnames: account.hostnames?.map((hostname) => ({ + hostname: { op: PatchOperation.None, value: hostname.hostname }, + isDisabled: { op: PatchOperation.None, value: hostname.isDisabled } + })) + }) - const [editingAccount, setEditingAccount] = useState(account) const [newContact, setNewContact] = useState('') const [newHostname, setNewHostname] = useState('') - const setDescription = useCallback( - (newDescription: string) => { - if (editingAccount) { - setEditingAccount({ ...editingAccount, description: newDescription }) - } - }, - [editingAccount] - ) + useEffect(() => { + console.log(newAccount) + }, [newAccount]) const { value: description, error: descriptionError, - handleChange: handleDescriptionChange, - reset: resetDescription + handleChange: handleDescriptionChange } = useValidation({ defaultValue: '', - externalValue: account.description, - setExternalValue: setDescription, + externalValue: newAccount.description?.value ?? '', + setExternalValue: (newDescription) => { + setNewAccount((prev) => { + const newAccount = deepCopy(prev) + newAccount.description = { + op: + newDescription !== account.description + ? PatchOperation.Replace + : PatchOperation.None, + value: newDescription + } + return newAccount + }) + }, validateFn: isBypass, errorMessage: '' }) @@ -81,122 +113,147 @@ const AccountEdit: React.FC = (props) => { }) const handleIsDisabledChange = (value: boolean) => { - // setAccount({ ...account, isDisabled: value }) + setNewAccount((prev) => { + const newAccount = deepCopy(prev) + newAccount.isDisabled = { + op: + value !== account.isDisabled + ? PatchOperation.Replace + : PatchOperation.None, + value + } + return newAccount + }) } - const handleChallengeTypeChange = (option: any) => { - //setAccount({ ...account, challengeType: option.value }) + const handleAddContact = () => { + if (newContact === '' || contactError) return + + // Check if the contact already exists in the account + const contactExists = newAccount.contacts?.some( + (contact) => contact.value === newContact + ) + + if (contactExists) { + // Optionally, handle the duplicate contact case, e.g., show an error message + dispatch( + showToast({ message: 'Contact already exists.', type: 'warning' }) + ) + resetContact() + return + } + + // If the contact does not exist, add it + setNewAccount((prev) => { + const newAccount = deepCopy(prev) + newAccount.contacts?.push({ op: PatchOperation.Add, value: newContact }) + return newAccount + }) + + resetContact() + } + + const handleDeleteContact = (contact: string) => { + setNewAccount((prev) => { + const newAccount = deepCopy(prev) + newAccount.contacts = newAccount.contacts + ?.map((c) => { + if (c.value === contact && c.op !== PatchOperation.Add) + c.op = PatchOperation.Remove + return c + }) + .filter((c) => !(c.value === contact && c.op === PatchOperation.Add)) + return newAccount + }) + } + + const handleAddHostname = () => { + if (newHostname === '' || hostnameError) return + + // Check if the hostname already exists in the account + const hostnameExists = newAccount.hostnames?.some( + (hostname) => hostname.hostname?.value === newHostname + ) + + if (hostnameExists) { + // Optionally, handle the duplicate hostname case, e.g., show an error message + dispatch( + showToast({ message: 'Hostname already exists.', type: 'warning' }) + ) + resetHostname() + return + } + + // If the hostname does not exist, add it + setNewAccount((prev) => { + const newAccount = deepCopy(prev) + newAccount.hostnames?.push({ + hostname: { op: PatchOperation.Add, value: newHostname }, + isDisabled: { op: PatchOperation.Add, value: false } + }) + return newAccount + }) + + resetHostname() } const handleHostnameDisabledChange = (hostname: string, value: boolean) => { - // setAccount({ - // ...account, - // hostnames: account.hostnames.map((h) => - // h.hostname === hostname ? { ...h, isDisabled: value } : h - // ) - // }) - // } - // const handleStagingChange = (value: string) => { - // setAccount({ ...account, isStaging: value === 'staging' }) + setNewAccount((prev) => { + const newAccount = deepCopy(prev) + const targetHostname = newAccount.hostnames?.find( + (h) => h.hostname?.value === hostname + ) + if (targetHostname) { + targetHostname.isDisabled = { + op: + value !== targetHostname.isDisabled?.value + ? PatchOperation.Replace + : PatchOperation.None, + value + } + } + return newAccount + }) } - const deleteContact = (contact: string) => { - if (account?.contacts.length ?? 0 < 1) return - - // setAccount({ - // ...account, - // contacts: account.contacts.filter((c) => c !== contact) - // }) - // } - - // const addContact = () => { - // if (newContact === '' || contactError) { - // return - // } - - // if (account.contacts.includes(newContact)) return - - // setAccount({ ...account, contacts: [...account.contacts, newContact] }) - // resetContact() + const handleDeleteHostname = (hostname: string) => { + setNewAccount((prev) => { + const newAccount = deepCopy(prev) + newAccount.hostnames = newAccount.hostnames + ?.map((h) => { + if ( + h.hostname?.value === hostname && + h.hostname?.op !== PatchOperation.Add + ) + h.hostname.op = PatchOperation.Remove + return h + }) + .filter( + (h) => + !( + h.hostname?.value === hostname && + h.hostname?.op === PatchOperation.Add + ) + ) + return newAccount + }) } - const deleteHostname = (hostname: string) => { - //if (account?.hostnames.length ?? 0 < 1) return - - // setAccount({ - // ...account, - // hostnames: account.hostnames.filter((h) => h.hostname !== hostname) - // }) - // } - - // const addHostname = () => { - // if (newHostname === '' || hostnameError) { - // return - // } - - // if (account.hostnames.some((h) => h.hostname === newHostname)) return - - // setAccount({ - // ...account, - // hostnames: [ - // ...account.hostnames, - // { - // hostname: newHostname, - // expires: new Date(), - // isUpcomingExpire: false, - // isDisabled: false - // } - // ] - // }) - resetHostname() + const handleCancel = () => { + onCancel?.() } const handleSubmit = async (e: FormEvent) => { e.preventDefault() - // const contactChanges = { - // added: account.contacts.filter( - // (contact) => !initialAccountState.contacts.includes(contact) - // ), - // removed: initialAccountState.contacts.filter( - // (contact) => !account.contacts.includes(contact) - // ) - // } + httpService.patch( + GetApiRoute(ApiRoutes.ACCOUNT_ID, account.accountId), + newAccount + ) + } - // const hostnameChanges = { - // added: account.hostnames.filter( - // (hostname) => - // !initialAccountState.hostnames.some( - // (h) => h.hostname === hostname.hostname - // ) - // ), - // removed: initialAccountState.hostnames.filter( - // (hostname) => - // !account.hostnames.some((h) => h.hostname === hostname.hostname) - // ) - // } - - // // Handle contact changes - // if (contactChanges.added.length > 0) { - // // TODO: POST new contacts - // console.log('Added contacts:', contactChanges.added) - // } - // if (contactChanges.removed.length > 0) { - // // TODO: DELETE removed contacts - // console.log('Removed contacts:', contactChanges.removed) - // } - - // // Handle hostname changes - // if (hostnameChanges.added.length > 0) { - // // TODO: POST new hostnames - // console.log('Added hostnames:', hostnameChanges.added) - // } - // if (hostnameChanges.removed.length > 0) { - // // TODO: DELETE removed hostnames - // console.log('Removed hostnames:', hostnameChanges.removed) - // } - - // onSave(account) + const handleDelete = (accountId: string) => { + onDelete?.(accountId) } return ( @@ -217,19 +274,9 @@ const AccountEdit: React.FC = (props) => {
handleIsDisabledChange(value)} - className="mr-2 flex-grow" - /> -
- -
- handleChallengeTypeChange(option)} + onChange={handleIsDisabledChange} className="mr-2 flex-grow" />
@@ -237,22 +284,24 @@ const AccountEdit: React.FC = (props) => {

Contacts:

    - {account.contacts.map((contact) => ( -
  • - {contact} - deleteContact(contact)} - className="bg-red-500 text-white p-2 rounded ml-2" - > - - + {newAccount.contacts?.map((contact) => ( +
  • +
    + {contact.value} + handleDeleteContact(contact.value ?? '')} + className="bg-red-500 text-white p-2 rounded ml-2" + > + + +
  • ))}
= (props) => { />
+ +
+ +
+

Hostnames:

    - {account.hostnames?.map((hostname) => ( -
  • + {newAccount.hostnames?.map((hostname) => ( +
  • - {hostname.hostname} - {hostname.expires.toDateString()} -{' '} - - {hostname.isUpcomingExpire ? 'Upcoming' : 'Not Upcoming'} - {' '} - -{' '} + {hostname.hostname?.value} -{' '} - handleHostnameDisabledChange(hostname.hostname, value) + handleHostnameDisabledChange( + hostname.hostname?.value ?? '', + value + ) } />
    deleteHostname(hostname.hostname)} + onClick={() => + handleDeleteHostname(hostname.hostname?.value ?? '') + } className="bg-red-500 text-white p-2 rounded ml-2" > @@ -310,7 +365,7 @@ const AccountEdit: React.FC = (props) => {
= (props) => { /> @@ -345,13 +400,15 @@ const AccountEdit: React.FC = (props) => {
onDelete(account.accountId)} + type="button" + onClick={() => handleDelete(account.accountId)} className="bg-red-500 text-white p-2 rounded ml-2" > Cancel diff --git a/src/ClientApp/services/HttpService.tsx b/src/ClientApp/services/HttpService.tsx index 596fe24..e7b6223 100644 --- a/src/ClientApp/services/HttpService.tsx +++ b/src/ClientApp/services/HttpService.tsx @@ -1,6 +1,13 @@ import { store } from '@/redux/store' import { increment, decrement } from '@/redux/slices/loaderSlice' import { showToast } from '@/redux/slices/toastSlice' +import { PatchOperation } from '@/models/PatchOperation' + +interface HttpResponse { + data: T | null + status: number + isSuccess: boolean +} interface RequestInterceptor { (req: XMLHttpRequest): void @@ -47,7 +54,7 @@ class HttpService { method: string, url: string, data?: any - ): Promise { + ): Promise> { const xhr = new XMLHttpRequest() xhr.open(method, url) @@ -59,7 +66,7 @@ class HttpService { this.invokeIncrement() - return new Promise((resolve) => { + return new Promise>((resolve) => { xhr.onload = () => this.handleLoad(xhr, resolve) xhr.onerror = () => this.handleNetworkError(resolve) xhr.send(data ? JSON.stringify(data) : null) @@ -102,7 +109,7 @@ class HttpService { private handleLoad( xhr: XMLHttpRequest, - resolve: (value: TResponse | null) => void + resolve: (value: HttpResponse) => void ): void { this.invokeDecrement() if (xhr.status >= 200 && xhr.status < 300) { @@ -114,14 +121,22 @@ class HttpService { private handleSuccessfulResponse( xhr: XMLHttpRequest, - resolve: (value: TResponse | null) => void + resolve: (value: HttpResponse) => void ): void { try { if (xhr.response) { const response = JSON.parse(xhr.response) - resolve(this.handleResponseInterceptors(response, null) as TResponse) + resolve({ + data: this.handleResponseInterceptors(response, null) as TResponse, + status: xhr.status, + isSuccess: true + }) } else { - resolve(null) + resolve({ + data: null, + status: xhr.status, + isSuccess: true + }) } } catch (error) { const problemDetails = this.createProblemDetails( @@ -130,13 +145,17 @@ class HttpService { xhr.status ) this.showProblemDetails(problemDetails) - resolve(null) + resolve({ + data: null, + status: xhr.status, + isSuccess: false + }) } } private handleErrorResponse( xhr: XMLHttpRequest, - resolve: (value: TResponse | null) => void + resolve: (value: HttpResponse) => void ): void { const problemDetails = this.createProblemDetails( xhr.statusText, @@ -144,15 +163,23 @@ class HttpService { xhr.status ) this.showProblemDetails(problemDetails) - resolve(this.handleResponseInterceptors(null, problemDetails)) + resolve({ + data: this.handleResponseInterceptors(null, problemDetails), + status: xhr.status, + isSuccess: false + }) } private handleNetworkError( - resolve: (value: TResponse | null) => void + resolve: (value: HttpResponse) => void ): void { const problemDetails = this.createProblemDetails('Network Error', null, 0) this.showProblemDetails(problemDetails) - resolve(this.handleResponseInterceptors(null, problemDetails)) + resolve({ + data: this.handleResponseInterceptors(null, problemDetails), + status: 0, + isSuccess: false + }) } private createProblemDetails( @@ -178,26 +205,57 @@ class HttpService { } } - public async get(url: string): Promise { + public async get(url: string): Promise> { return await this.request('GET', url) } public async post( url: string, data: TRequest - ): Promise { + ): Promise> { return await this.request('POST', url, data) } public async put( url: string, data: TRequest - ): Promise { + ): Promise> { return await this.request('PUT', url, data) } - public async delete(url: string): Promise { - return await this.request('DELETE', url) + private cleanPatchRequest(obj: any): any { + if (Array.isArray(obj)) { + const cleanedArray = obj + .map(this.cleanPatchRequest) + .filter((item) => item !== null && item !== undefined) + return cleanedArray.length > 0 ? cleanedArray : null + } else if (typeof obj === 'object' && obj !== null) { + if (obj.op !== undefined && obj.op === PatchOperation.None) { + return null + } + + const cleanedObject: any = {} + Object.keys(obj).forEach((key) => { + const cleanedValue = this.cleanPatchRequest(obj[key]) + if (cleanedValue !== null) { + cleanedObject[key] = cleanedValue + } + }) + return Object.keys(cleanedObject).length > 0 ? cleanedObject : null + } + return obj + } + + public async patch( + url: string, + data: TRequest + ): Promise> { + const cleanedData = this.cleanPatchRequest(data) + return await this.request('PATCH', url, cleanedData) + } + + public async delete(url: string): Promise> { + return await this.request('DELETE', url) } public addRequestInterceptor(interceptor: RequestInterceptor): void { @@ -233,10 +291,10 @@ export { httpService } // Example usage of the httpService // async function fetchData() { -// const data = await httpService.get('/api/data'); -// if (data) { -// console.log('Data received:', data); +// const response = await httpService.get('/api/data'); +// if (response.isSuccess) { +// console.log('Data received:', response.data); // } else { -// console.error('Failed to fetch data'); +// console.error('Failed to fetch data, status code:', response.status); // } // } diff --git a/src/LetsEncryptServer/Services/AccoutService.cs b/src/LetsEncryptServer/Services/AccoutService.cs index fe4e3d9..66d40ad 100644 --- a/src/LetsEncryptServer/Services/AccoutService.cs +++ b/src/LetsEncryptServer/Services/AccoutService.cs @@ -182,6 +182,9 @@ public class AccountService : IAccountService { } public async Task DeleteAccountAsync(Guid accountId) { + // TODO: Revoke all certificates + + // Remove from cache return await _cacheService.DeleteFromCacheAsync(accountId); } #endregion diff --git a/src/LetsEncryptServer/Services/CertsFlowService.cs b/src/LetsEncryptServer/Services/CertsFlowService.cs index 6a2525e..5eebade 100644 --- a/src/LetsEncryptServer/Services/CertsFlowService.cs +++ b/src/LetsEncryptServer/Services/CertsFlowService.cs @@ -216,10 +216,11 @@ public class CertsFlowService : ICertsFlowService { public (string?, IDomainResult) AcmeChallenge(string fileName) { DeleteExporedChallenges(); - var fileContent = File.ReadAllText(Path.Combine(_acmePath, fileName)); - if (fileContent == null) + var challengePath = Path.Combine(_acmePath, fileName); + if(!File.Exists(challengePath)) return IDomainResult.NotFound(); + var fileContent = File.ReadAllText(Path.Combine(_acmePath, fileName)); return IDomainResult.Success(fileContent); }