(feature): frontend edit accounts form

This commit is contained in:
Maksym Sadovnychyy 2024-06-13 23:32:40 +02:00
parent 2f87b455ca
commit 1f56ac19e6
19 changed files with 443 additions and 72 deletions

View File

@ -1,15 +1,30 @@
enum ApiRoutes { enum ApiRoutes {
CACHE_GET_ACCOUNTS = `/api/Cache/GetAccounts`, CACHE_GET_ACCOUNTS = `api/Cache/GetAccounts`,
CACHE_GET_CONTACTS = `/api/Cache/GetContacts/{accountId}`, CACHE_GET_CONTACTS = `api/Cache/GetContacts/{accountId}`,
CACHE_SET_CONTACTS = `/api/Cache/SetContacts/{accountId}`, CACHE_SET_CONTACTS = `api/Cache/SetContacts/{accountId}`,
CACHE_GET_HOSTNAMES = `api/Cache/GetHostnames/{accountId}`,
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}`,
CERTS_FLOW_INIT = `/api/CertsFlow/Init/{sessionId}/{accountId}`, CERTS_FLOW_INIT = `api/CertsFlow/Init/{sessionId}/{accountId}`,
CERTS_FLOW_NEW_ORDER = `/api/CertsFlow/NewOrder/{sessionId}`, CERTS_FLOW_NEW_ORDER = `api/CertsFlow/NewOrder/{sessionId}`,
CERTS_FLOW_GET_ORDER = `/api/CertsFlow/GetOrder/{sessionId}`, CERTS_FLOW_GET_ORDER = `api/CertsFlow/GetOrder/{sessionId}`,
CERTS_FLOW_GET_CERTIFICATES = `/api/CertsFlow/GetCertificates/{sessionId}`, CERTS_FLOW_GET_CERTIFICATES = `api/CertsFlow/GetCertificates/{sessionId}`,
CERTS_FLOW_APPLY_CERTIFICATES = `/api/CertsFlow/ApplyCertificates/{sessionId}`, CERTS_FLOW_APPLY_CERTIFICATES = `api/CertsFlow/ApplyCertificates/{sessionId}`,
CERTS_FLOW_HOSRS_WITH_UPCOMING_SSL_EXPIRY = `/api/CertsFlow/HostsWithUpcomingSslExpiry/{sessionId}` CERTS_FLOW_HOSRS_WITH_UPCOMING_SSL_EXPIRY = `api/CertsFlow/HostsWithUpcomingSslExpiry/{sessionId}`
}
const GetApiRoute = (route: ApiRoutes, ...args: string[]): string => {
let result: string = route;
args.forEach(arg => {
result = result.replace(/{.*?}/, arg);
});
return 'http://localhost:5000/' + result;
}
export {
GetApiRoute,
ApiRoutes
} }

View File

@ -1,9 +1,228 @@
"use client"; // Add this line
import { ApiRoutes, GetApiRoute } from "@/ApiRoutes";
import { GetAccountsResponse } from "@/models/letsEncryptServer/cache/GetAccountsResponse";
import { GetContactsResponse } from "@/models/letsEncryptServer/cache/GetContactsResponse";
import { GetHostnamesResponse } from "@/models/letsEncryptServer/cache/GetHostnamesResponse";
import { httpService } from "@/services/HttpService";
import { useEffect, useRef, useState } from "react";
import { useValidation, isValidEmail, isValidHostname } from "@/hooks/useValidation"; // Assuming hooks are in a hooks directory
interface CacheAccountHostname {
hostname: string
expires: Date,
isUpcomingExpire: boolean
}
interface CacheAccount {
accountId: string
contacts: string[]
hostnames: CacheAccountHostname[]
}
// `app/page.tsx` is the UI for the `/` URL // `app/page.tsx` is the UI for the `/` URL
export default function Page() { export default function Page() {
const [accounts, setAccounts] = useState<CacheAccount[]>([]);
const [isEditMode, setIsEditMode] = useState(false);
const {
value: newContact,
error: contactError,
handleChange: handleContactChange
} = useValidation("", isValidEmail, "Invalid email format.");
const {
value: newHostname,
error: hostnameError,
handleChange: handleHostnameChange
} = useValidation("", isValidHostname, "Invalid hostname format.");
const init = useRef(false);
useEffect(() => {
if (init.current)
return;
const fetchAccounts = async () => {
const newAccounts: CacheAccount[] = [];
const accountsResponse = await httpService.get<GetAccountsResponse>(GetApiRoute(ApiRoutes.CACHE_GET_ACCOUNTS));
for (const accountId of accountsResponse.accountIds) {
const contactsResponse = await httpService.get<GetContactsResponse>(GetApiRoute(ApiRoutes.CACHE_GET_CONTACTS, accountId));
const hostnamesResponse = await httpService.get<GetHostnamesResponse>(GetApiRoute(ApiRoutes.CACHE_GET_HOSTNAMES, accountId));
newAccounts.push({
accountId: accountId,
contacts: contactsResponse.contacts,
hostnames: hostnamesResponse.hostnames.map(h => ({
hostname: h.hostname,
expires: new Date(h.expires),
isUpcomingExpire: h.isUpcomingExpire
}))
});
}
setAccounts(newAccounts);
};
fetchAccounts();
init.current = true;
}, []);
const deleteAccount = (accountId: string) => {
setAccounts(accounts.filter(account => account.accountId !== accountId));
}
const deleteContact = (accountId: string, contact: string) => {
setAccounts(accounts.map(account =>
account.accountId === accountId
? { ...account, contacts: account.contacts.filter(c => c !== contact) }
: account
));
}
const addContact = (accountId: string) => {
if (newContact.trim() === "" || contactError) {
return;
}
setAccounts(accounts.map(account =>
account.accountId === accountId
? { ...account, contacts: [...account.contacts, newContact.trim()] }
: account
));
handleContactChange("");
}
const deleteHostname = (accountId: string, hostname: string) => {
setAccounts(accounts.map(account =>
account.accountId === accountId
? { ...account, hostnames: account.hostnames.filter(h => h.hostname !== hostname) }
: account
));
}
const addHostname = (accountId: string) => {
if (newHostname.trim() === "" || hostnameError) {
return;
}
setAccounts(accounts.map(account =>
account.accountId === accountId
? { ...account, hostnames: [...account.hostnames, { hostname: newHostname.trim(), expires: new Date(), isUpcomingExpire: false }] }
: account
));
handleHostnameChange("");
}
useEffect(() => {
if (isEditMode) {
handleContactChange(newContact);
handleHostnameChange(newHostname);
}
}, [isEditMode, newContact, newHostname]);
return ( return (
<> <div className="container mx-auto p-4">
<h1 className="text-2xl font-bold">Home</h1> <div className="flex justify-between items-center mb-8">
<p>Hello, Home page!</p> <h1 className="text-4xl font-bold text-center">LetsEncrypt Client Dashboard</h1>
</> <button
onClick={() => setIsEditMode(!isEditMode)}
className="bg-blue-500 text-white px-3 py-1 rounded">
{isEditMode ? "View Mode" : "Edit Mode"}
</button>
</div>
{
accounts.map(account => (
<div key={account.accountId} className="bg-white shadow-lg rounded-lg p-6 mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-semibold">Account: {account.accountId}</h2>
{isEditMode && (
<button
onClick={() => deleteAccount(account.accountId)}
className="bg-red-500 text-white px-3 py-1 rounded h-10">
Delete Account
</button>
)}
</div>
<div className="mb-4">
<h3 className="text-xl font-medium mb-2">Contacts:</h3>
<ul className="list-disc list-inside pl-4 mb-2">
{
account.contacts.map(contact => (
<li key={contact} className="text-gray-700 flex justify-between items-center mb-2">
{contact}
{isEditMode && (
<button
onClick={() => deleteContact(account.accountId, contact)}
className="bg-red-500 text-white px-2 py-1 rounded ml-4 h-10">
Delete
</button>
)}
</li>
))
}
</ul>
{isEditMode && (
<div className="flex mb-4">
<input
type="text"
value={newContact}
onChange={(e) => handleContactChange(e.target.value)}
className="border p-2 rounded mr-2 flex-grow h-10"
placeholder="Add new contact"
/>
<button
onClick={() => addContact(account.accountId)}
className="bg-blue-500 text-white px-3 py-1 rounded h-10">
Add Contact
</button>
</div>
)}
{isEditMode && contactError && <p className="text-red-500">{contactError}</p>}
</div>
<div>
<h3 className="text-xl font-medium mb-2">Hostnames:</h3>
<ul className="list-disc list-inside pl-4 mb-2">
{
account.hostnames.map(hostname => (
<li key={hostname.hostname} className="text-gray-700 flex justify-between items-center mb-2">
<div>
{hostname.hostname} - {hostname.expires.toDateString()} -
<span className={`ml-2 px-2 py-1 rounded ${hostname.isUpcomingExpire ? 'bg-yellow-200 text-yellow-800' : 'bg-green-200 text-green-800'}`}>
{hostname.isUpcomingExpire ? 'Upcoming' : 'Not Upcoming'}
</span>
</div>
{isEditMode && (
<button
onClick={() => deleteHostname(account.accountId, hostname.hostname)}
className="bg-red-500 text-white px-2 py-1 rounded ml-4 h-10">
Delete
</button>
)}
</li>
))
}
</ul>
{isEditMode && (
<div className="flex">
<input
type="text"
value={newHostname}
onChange={(e) => handleHostnameChange(e.target.value)}
className="border p-2 rounded mr-2 flex-grow h-10"
placeholder="Add new hostname"
/>
<button
onClick={() => addHostname(account.accountId)}
className="bg-blue-500 text-white px-3 py-1 rounded h-10">
Add Hostname
</button>
</div>
)}
{isEditMode && hostnameError && <p className="text-red-500">{hostnameError}</p>}
</div>
</div>
))
}
</div>
); );
} }

View File

@ -5,8 +5,8 @@ const Footer = () => {
<footer className="bg-gray-900 text-white text-center p-4"> <footer className="bg-gray-900 text-white text-center p-4">
<p>&copy; {new Date().getFullYear()} MAKS-IT</p> <p>&copy; {new Date().getFullYear()} MAKS-IT</p>
</footer> </footer>
); )
}; }
export { export {
Footer Footer

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react'
import './loader.css'; // Add your loader styles here import './loader.css' // Add your loader styles here
const Loader: React.FC = () => { const Loader: React.FC = () => {
return ( return (
@ -7,7 +7,7 @@ const Loader: React.FC = () => {
<div className="spinner"></div> <div className="spinner"></div>
<div className="loading-text">Loading...</div> <div className="loading-text">Loading...</div>
</div> </div>
); )
}; }
export default Loader; export default Loader

View File

@ -1,8 +1,8 @@
import React, { FC } from 'react'; import React, { FC } from 'react';
interface OffCanvasProps { interface OffCanvasProps {
isOpen: boolean; isOpen: boolean
onClose: () => void; onClose: () => void
} }
const OffCanvas: FC<OffCanvasProps> = ({ isOpen, onClose }) => { const OffCanvas: FC<OffCanvasProps> = ({ isOpen, onClose }) => {
@ -26,9 +26,9 @@ const OffCanvas: FC<OffCanvasProps> = ({ isOpen, onClose }) => {
{/* Your off-canvas content goes here */} {/* Your off-canvas content goes here */}
</div> </div>
</div> </div>
); )
}; }
export { export {
OffCanvas OffCanvas
}; }

View File

@ -1,19 +1,19 @@
"use client"; // Add this line "use client" // Add this line
import React, { FC, useState } from 'react'; import React, { FC, useState } from 'react'
import { FaCog, FaBars } from 'react-icons/fa'; import { FaCog, FaBars } from 'react-icons/fa'
import Link from 'next/link'; import Link from 'next/link'
interface TopMenuProps { interface TopMenuProps {
onToggleOffCanvas: () => void; onToggleOffCanvas: () => void
} }
const TopMenu: FC<TopMenuProps> = ({ onToggleOffCanvas }) => { const TopMenu: FC<TopMenuProps> = ({ onToggleOffCanvas }) => {
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
const toggleMenu = () => { const toggleMenu = () => {
setIsMenuOpen(!isMenuOpen); setIsMenuOpen(!isMenuOpen)
}; }
return ( return (
<header className="bg-gray-900 text-white flex items-center p-4"> <header className="bg-gray-900 text-white flex items-center p-4">
@ -51,9 +51,9 @@ const TopMenu: FC<TopMenuProps> = ({ onToggleOffCanvas }) => {
<FaBars /> <FaBars />
</button> </button>
</header> </header>
); )
}; }
export { export {
TopMenu TopMenu
}; }

View File

@ -0,0 +1,37 @@
import { useState, useEffect } from "react";
// Helper functions for validation
const isValidEmail = (email: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
const isValidHostname = (hostname: string) => {
const hostnameRegex = /^(?!:\/\/)([a-zA-Z0-9-_]{1,63}\.?)+[a-zA-Z]{2,6}$/;
return hostnameRegex.test(hostname);
}
// Custom hook for input validation
const useValidation = (initialValue: string, validateFn: (value: string) => boolean, errorMessage: string) => {
const [value, setValue] = useState(initialValue);
const [error, setError] = useState("");
const handleChange = (newValue: string) => {
setValue(newValue);
if (newValue.trim() === "") {
setError("This field cannot be empty.");
} else if (!validateFn(newValue.trim())) {
setError(errorMessage);
} else {
setError("");
}
};
useEffect(() => {
handleChange(initialValue);
}, [initialValue]);
return { value, error, handleChange };
};
export { useValidation, isValidEmail, isValidHostname };

View File

@ -1,12 +0,0 @@
import { HttpService } from "./services/HttpService";
const httpService = new HttpService();
httpService.addRequestInterceptor(xhr => {
});
httpService.addResponseInterceptor(response => {
return response;
});

View File

@ -0,0 +1,3 @@
export interface GetAccountsResponse {
accountIds: string[]
}

View File

@ -0,0 +1,3 @@
export interface GetContactsResponse {
contacts: string[]
}

View File

@ -0,0 +1,9 @@
interface HostnameResponse {
hostname: string
expires: string,
isUpcomingExpire: boolean
}
export interface GetHostnamesResponse {
hostnames: HostnameResponse[]
}

View File

@ -92,19 +92,19 @@ class HttpService {
}); });
} }
public get<TResponse>(url: string): Promise<TResponse | ProblemDetails> { public get<TResponse>(url: string): Promise<TResponse> {
return this.request<TResponse>('GET', url); return this.request<TResponse>('GET', url);
} }
public post<TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse | ProblemDetails> { public post<TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse> {
return this.request<TResponse>('POST', url, data); return this.request<TResponse>('POST', url, data);
} }
public put<TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse | ProblemDetails> { public put<TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse> {
return this.request<TResponse>('PUT', url, data); return this.request<TResponse>('PUT', url, data);
} }
public delete<TResponse>(url: string): Promise<TResponse | ProblemDetails> { public delete<TResponse>(url: string): Promise<TResponse> {
return this.request<TResponse>('DELETE', url); return this.request<TResponse>('DELETE', url);
} }
@ -112,11 +112,24 @@ class HttpService {
this.requestInterceptors.push(interceptor); this.requestInterceptors.push(interceptor);
} }
public addResponseInterceptor<TResponse>(interceptor: ResponseInterceptor<TResponse | ProblemDetails>): void { public addResponseInterceptor<TResponse>(interceptor: ResponseInterceptor<TResponse>): void {
this.responseInterceptors.push(interceptor); this.responseInterceptors.push(interceptor);
} }
} }
const httpService = new HttpService();
httpService.addRequestInterceptor(xhr => {
});
httpService.addResponseInterceptor(response => {
return response;
});
export { export {
HttpService httpService
}; }

View File

@ -30,13 +30,13 @@ namespace MaksIT.LetsEncryptServer.BackgroundServices {
while (!stoppingToken.IsCancellationRequested) { while (!stoppingToken.IsCancellationRequested) {
_logger.LogInformation("Background service is running."); _logger.LogInformation("Background service is running.");
var (accountIds, getAccountIdsResult) = await _cacheService.ListCachedAccountsAsync(); var (accountsResponse, getAccountIdsResult) = await _cacheService.GetAccountsAsync();
if (!getAccountIdsResult.IsSuccess || accountIds == null) { if (!getAccountIdsResult.IsSuccess || accountsResponse == null) {
LogErrors(getAccountIdsResult.Errors); LogErrors(getAccountIdsResult.Errors);
continue; continue;
} }
foreach (var accountId in accountIds) { foreach (var accountId in accountsResponse.AccountIds) {
await ProcessAccountAsync(accountId); await ProcessAccountAsync(accountId);
} }

View File

@ -29,7 +29,7 @@ public class CacheController {
[HttpGet("[action]")] [HttpGet("[action]")]
public async Task<IActionResult> GetAccounts() { public async Task<IActionResult> GetAccounts() {
var result = await _cacheService.ListCachedAccountsAsync(); var result = await _cacheService.GetAccountsAsync();
return result.ToActionResult(); return result.ToActionResult();
} }
@ -45,5 +45,11 @@ public class CacheController {
var result = await _cacheService.SetContactsAsync(accountId, requestData); var result = await _cacheService.SetContactsAsync(accountId, requestData);
return result.ToActionResult(); return result.ToActionResult();
} }
[HttpGet("[action]/{accountId}")]
public async Task<IActionResult> GetHostnames(Guid accountId) {
var result = await _cacheService.GetHostnames(accountId);
return result.ToActionResult();
}
} }

View File

@ -1,9 +1,12 @@
using System.Text.Json; using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using DomainResults.Common; using DomainResults.Common;
using MaksIT.Core.Extensions; using MaksIT.Core.Extensions;
using MaksIT.LetsEncrypt.Entities; using MaksIT.LetsEncrypt.Entities;
using MaksIT.Models.LetsEncryptServer.Cache.Requests; using MaksIT.Models.LetsEncryptServer.Cache.Requests;
using Models.LetsEncryptServer.Cache.Responses;
namespace MaksIT.LetsEncryptServer.Services; namespace MaksIT.LetsEncryptServer.Services;
@ -11,9 +14,11 @@ public interface ICacheService {
Task<(RegistrationCache?, IDomainResult)> LoadFromCacheAsync(Guid accountId); Task<(RegistrationCache?, IDomainResult)> LoadFromCacheAsync(Guid accountId);
Task<IDomainResult> SaveToCacheAsync(Guid accountId, RegistrationCache cache); Task<IDomainResult> SaveToCacheAsync(Guid accountId, RegistrationCache cache);
Task<IDomainResult> DeleteFromCacheAsync(Guid accountId); Task<IDomainResult> DeleteFromCacheAsync(Guid accountId);
Task<(Guid[]?, IDomainResult)> ListCachedAccountsAsync(); Task<(GetAccountsResponse?, IDomainResult)> GetAccountsAsync();
Task<(string[]?, IDomainResult)> GetContactsAsync(Guid accountId); Task<(GetContactsResponse?, IDomainResult)> GetContactsAsync(Guid accountId);
Task<IDomainResult> SetContactsAsync(Guid accountId, SetContactsRequest requestData); Task<IDomainResult> SetContactsAsync(Guid accountId, SetContactsRequest requestData);
Task<(GetHostnamesResponse?, IDomainResult)> GetHostnames(Guid accountId);
} }
public class CacheService : ICacheService, IDisposable { public class CacheService : ICacheService, IDisposable {
@ -121,35 +126,41 @@ public class CacheService : ICacheService, IDisposable {
} }
} }
public async Task<(Guid[]?, IDomainResult)> ListCachedAccountsAsync() { public async Task<(GetAccountsResponse?, IDomainResult)> GetAccountsAsync() {
await _cacheLock.WaitAsync(); await _cacheLock.WaitAsync();
try { try {
var cacheFiles = Directory.GetFiles(_cacheDirectory); var cacheFiles = Directory.GetFiles(_cacheDirectory);
if (cacheFiles == null) if (cacheFiles == null)
return IDomainResult.Success(new Guid[0]); return IDomainResult.Success(new GetAccountsResponse {
AccountIds = Array.Empty<Guid>()
});
var accountIds = cacheFiles.Select(x => Path.GetFileNameWithoutExtension(x).ToGuid()).ToArray(); var accountIds = cacheFiles.Select(x => Path.GetFileNameWithoutExtension(x).ToGuid()).ToArray();
return IDomainResult.Success(accountIds); return IDomainResult.Success(new GetAccountsResponse {
AccountIds = accountIds
});
} }
catch (Exception ex) { catch (Exception ex) {
var message = "Error listing cache files"; var message = "Error listing cache files";
_logger.LogError(ex, message); _logger.LogError(ex, message);
return IDomainResult.Failed<Guid[]?> (message); return IDomainResult.Failed<GetAccountsResponse?> (message);
} }
finally { finally {
_cacheLock.Release(); _cacheLock.Release();
} }
} }
public async Task<(string[]?, IDomainResult)> GetContactsAsync(Guid accountId) { public async Task<(GetContactsResponse?, IDomainResult)> GetContactsAsync(Guid accountId) {
var (cache, loadResult) = await LoadFromCacheAsync(accountId); var (cache, loadResult) = await LoadFromCacheAsync(accountId);
if (!loadResult.IsSuccess || cache == null) if (!loadResult.IsSuccess || cache == null)
return (null, loadResult); return (null, loadResult);
return IDomainResult.Success(cache.Contacts); return IDomainResult.Success(new GetContactsResponse {
Contacts = cache.Contacts ?? Array.Empty<string>()
});
} }
@ -162,6 +173,33 @@ public class CacheService : ICacheService, IDisposable {
return await SaveToCacheAsync(accountId, cache); return await SaveToCacheAsync(accountId, cache);
} }
public async Task<(GetHostnamesResponse?, IDomainResult)> GetHostnames(Guid accountId) {
var (cache, loadResult) = await LoadFromCacheAsync(accountId);
if (!loadResult.IsSuccess || cache?.CachedCerts == null)
return (null, loadResult);
var hoststWithUpcomingSslExpire = cache.GetHostsWithUpcomingSslExpiry();
var response = new GetHostnamesResponse {
Hostnames = new List<HostnameResponse>()
};
foreach (var result in cache.CachedCerts) {
var (subject, cachedChert) = result;
var cert = new X509Certificate2(Encoding.ASCII.GetBytes(cachedChert.Cert));
response.Hostnames.Add(new HostnameResponse {
Hostname = subject,
Expires = cert.NotBefore,
IsUpcomingExpire = hoststWithUpcomingSslExpire.Contains(subject)
});
}
return IDomainResult.Success(response);
}
public void Dispose() { public void Dispose() {
_cacheLock?.Dispose(); _cacheLock?.Dispose();

View File

@ -0,0 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Models.LetsEncryptServer.Cache.Responses {
public class GetAccountsResponse {
public Guid[] AccountIds { get; set; }
}
}

View File

@ -0,0 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Models.LetsEncryptServer.Cache.Responses {
public class GetContactsResponse {
public string[] Contacts { get; set; }
}
}

View File

@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Models.LetsEncryptServer.Cache.Responses {
public class HostnameResponse {
public string Hostname { get; set; }
public DateTime Expires { get; set; }
public bool IsUpcomingExpire { get; set; }
}
public class GetHostnamesResponse {
public List<HostnameResponse> Hostnames { get; set; }
}
}

View File

@ -8,7 +8,6 @@
<ItemGroup> <ItemGroup>
<Folder Include="Agent\Responses\" /> <Folder Include="Agent\Responses\" />
<Folder Include="LetsEncryptServer\Cache\Responses\" />
<Folder Include="LetsEncryptServer\CertsFlow\Responses\" /> <Folder Include="LetsEncryptServer\CertsFlow\Responses\" />
</ItemGroup> </ItemGroup>