diff --git a/src/ClientApp/ApiRoutes.tsx b/src/ClientApp/ApiRoutes.tsx index 2324ad8..69c9fa6 100644 --- a/src/ClientApp/ApiRoutes.tsx +++ b/src/ClientApp/ApiRoutes.tsx @@ -1,15 +1,30 @@ enum ApiRoutes { - CACHE_GET_ACCOUNTS = `/api/Cache/GetAccounts`, - CACHE_GET_CONTACTS = `/api/Cache/GetContacts/{accountId}`, - CACHE_SET_CONTACTS = `/api/Cache/SetContacts/{accountId}`, + CACHE_GET_ACCOUNTS = `api/Cache/GetAccounts`, + CACHE_GET_CONTACTS = `api/Cache/GetContacts/{accountId}`, + CACHE_SET_CONTACTS = `api/Cache/SetContacts/{accountId}`, + CACHE_GET_HOSTNAMES = `api/Cache/GetHostnames/{accountId}`, - CERTS_FLOW_CONFIGURE_CLIENT = `/api/CertsFlow/ConfigureClient`, - CERTS_FLOW_TERMS_OF_SERVICE = `/api/CertsFlow/TermsOfService/{sessionId}`, - CERTS_FLOW_INIT = `/api/CertsFlow/Init/{sessionId}/{accountId}`, - CERTS_FLOW_NEW_ORDER = `/api/CertsFlow/NewOrder/{sessionId}`, - CERTS_FLOW_GET_ORDER = `/api/CertsFlow/GetOrder/{sessionId}`, - CERTS_FLOW_GET_CERTIFICATES = `/api/CertsFlow/GetCertificates/{sessionId}`, - CERTS_FLOW_APPLY_CERTIFICATES = `/api/CertsFlow/ApplyCertificates/{sessionId}`, - CERTS_FLOW_HOSRS_WITH_UPCOMING_SSL_EXPIRY = `/api/CertsFlow/HostsWithUpcomingSslExpiry/{sessionId}` -} \ No newline at end of file + CERTS_FLOW_CONFIGURE_CLIENT = `api/CertsFlow/ConfigureClient`, + CERTS_FLOW_TERMS_OF_SERVICE = `api/CertsFlow/TermsOfService/{sessionId}`, + CERTS_FLOW_INIT = `api/CertsFlow/Init/{sessionId}/{accountId}`, + CERTS_FLOW_NEW_ORDER = `api/CertsFlow/NewOrder/{sessionId}`, + CERTS_FLOW_GET_ORDER = `api/CertsFlow/GetOrder/{sessionId}`, + CERTS_FLOW_GET_CERTIFICATES = `api/CertsFlow/GetCertificates/{sessionId}`, + CERTS_FLOW_APPLY_CERTIFICATES = `api/CertsFlow/ApplyCertificates/{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 +} diff --git a/src/ClientApp/app/page.tsx b/src/ClientApp/app/page.tsx index f0e4636..e085c53 100644 --- a/src/ClientApp/app/page.tsx +++ b/src/ClientApp/app/page.tsx @@ -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 export default function Page() { + const [accounts, setAccounts] = useState([]); + 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(GetApiRoute(ApiRoutes.CACHE_GET_ACCOUNTS)); + for (const accountId of accountsResponse.accountIds) { + const contactsResponse = await httpService.get(GetApiRoute(ApiRoutes.CACHE_GET_CONTACTS, accountId)); + const hostnamesResponse = await httpService.get(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 ( - <> -

Home

-

Hello, Home page!

- +
+
+

LetsEncrypt Client Dashboard

+ +
+ { + accounts.map(account => ( +
+
+

Account: {account.accountId}

+ {isEditMode && ( + + )} +
+
+

Contacts:

+
    + { + account.contacts.map(contact => ( +
  • + {contact} + {isEditMode && ( + + )} +
  • + )) + } +
+ {isEditMode && ( +
+ handleContactChange(e.target.value)} + className="border p-2 rounded mr-2 flex-grow h-10" + placeholder="Add new contact" + /> + +
+ )} + {isEditMode && contactError &&

{contactError}

} +
+
+

Hostnames:

+
    + { + account.hostnames.map(hostname => ( +
  • +
    + {hostname.hostname} - {hostname.expires.toDateString()} - + + {hostname.isUpcomingExpire ? 'Upcoming' : 'Not Upcoming'} + +
    + {isEditMode && ( + + )} +
  • + )) + } +
+ {isEditMode && ( +
+ handleHostnameChange(e.target.value)} + className="border p-2 rounded mr-2 flex-grow h-10" + placeholder="Add new hostname" + /> + +
+ )} + {isEditMode && hostnameError &&

{hostnameError}

} +
+
+ )) + } +
); - } \ No newline at end of file +} diff --git a/src/ClientApp/components/footer.tsx b/src/ClientApp/components/footer.tsx index 23ac5f1..7619904 100644 --- a/src/ClientApp/components/footer.tsx +++ b/src/ClientApp/components/footer.tsx @@ -5,8 +5,8 @@ const Footer = () => {

© {new Date().getFullYear()} MAKS-IT

- ); -}; + ) +} export { Footer diff --git a/src/ClientApp/components/loader/index.tsx b/src/ClientApp/components/loader/index.tsx index b55cd74..9d08275 100644 --- a/src/ClientApp/components/loader/index.tsx +++ b/src/ClientApp/components/loader/index.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import './loader.css'; // Add your loader styles here +import React from 'react' +import './loader.css' // Add your loader styles here const Loader: React.FC = () => { return ( @@ -7,7 +7,7 @@ const Loader: React.FC = () => {
Loading...
- ); -}; + ) +} -export default Loader; +export default Loader diff --git a/src/ClientApp/components/offcanvas.tsx b/src/ClientApp/components/offcanvas.tsx index bb44fa5..ac28720 100644 --- a/src/ClientApp/components/offcanvas.tsx +++ b/src/ClientApp/components/offcanvas.tsx @@ -1,8 +1,8 @@ import React, { FC } from 'react'; interface OffCanvasProps { - isOpen: boolean; - onClose: () => void; + isOpen: boolean + onClose: () => void } const OffCanvas: FC = ({ isOpen, onClose }) => { @@ -26,9 +26,9 @@ const OffCanvas: FC = ({ isOpen, onClose }) => { {/* Your off-canvas content goes here */} - ); -}; + ) +} export { OffCanvas -}; +} diff --git a/src/ClientApp/components/topmenu.tsx b/src/ClientApp/components/topmenu.tsx index 736dbe1..33a9860 100644 --- a/src/ClientApp/components/topmenu.tsx +++ b/src/ClientApp/components/topmenu.tsx @@ -1,19 +1,19 @@ -"use client"; // Add this line +"use client" // Add this line -import React, { FC, useState } from 'react'; -import { FaCog, FaBars } from 'react-icons/fa'; -import Link from 'next/link'; +import React, { FC, useState } from 'react' +import { FaCog, FaBars } from 'react-icons/fa' +import Link from 'next/link' interface TopMenuProps { - onToggleOffCanvas: () => void; + onToggleOffCanvas: () => void } const TopMenu: FC = ({ onToggleOffCanvas }) => { const [isMenuOpen, setIsMenuOpen] = useState(false); const toggleMenu = () => { - setIsMenuOpen(!isMenuOpen); - }; + setIsMenuOpen(!isMenuOpen) + } return (
@@ -51,9 +51,9 @@ const TopMenu: FC = ({ onToggleOffCanvas }) => {
- ); -}; + ) +} export { TopMenu -}; +} diff --git a/src/ClientApp/hooks/useValidation.tsx b/src/ClientApp/hooks/useValidation.tsx new file mode 100644 index 0000000..18e31a7 --- /dev/null +++ b/src/ClientApp/hooks/useValidation.tsx @@ -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 }; diff --git a/src/ClientApp/http-common.tsx b/src/ClientApp/http-common.tsx deleted file mode 100644 index 1b1e74b..0000000 --- a/src/ClientApp/http-common.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { HttpService } from "./services/HttpService"; - -const httpService = new HttpService(); - -httpService.addRequestInterceptor(xhr => { - -}); - -httpService.addResponseInterceptor(response => { - - return response; -}); \ No newline at end of file diff --git a/src/ClientApp/models/letsEncryptServer/cache/GetAccountsResponse.ts b/src/ClientApp/models/letsEncryptServer/cache/GetAccountsResponse.ts new file mode 100644 index 0000000..65f6566 --- /dev/null +++ b/src/ClientApp/models/letsEncryptServer/cache/GetAccountsResponse.ts @@ -0,0 +1,3 @@ +export interface GetAccountsResponse { + accountIds: string[] +} \ No newline at end of file diff --git a/src/ClientApp/models/letsEncryptServer/cache/GetContactsResponse.ts b/src/ClientApp/models/letsEncryptServer/cache/GetContactsResponse.ts new file mode 100644 index 0000000..7e10101 --- /dev/null +++ b/src/ClientApp/models/letsEncryptServer/cache/GetContactsResponse.ts @@ -0,0 +1,3 @@ +export interface GetContactsResponse { + contacts: string[] +} \ No newline at end of file diff --git a/src/ClientApp/models/letsEncryptServer/cache/GetHostnamesResponse.ts b/src/ClientApp/models/letsEncryptServer/cache/GetHostnamesResponse.ts new file mode 100644 index 0000000..da07b14 --- /dev/null +++ b/src/ClientApp/models/letsEncryptServer/cache/GetHostnamesResponse.ts @@ -0,0 +1,9 @@ +interface HostnameResponse { + hostname: string + expires: string, + isUpcomingExpire: boolean +} + +export interface GetHostnamesResponse { + hostnames: HostnameResponse[] +} \ No newline at end of file diff --git a/src/ClientApp/services/HttpService.tsx b/src/ClientApp/services/HttpService.tsx index 6426e83..267bdb4 100644 --- a/src/ClientApp/services/HttpService.tsx +++ b/src/ClientApp/services/HttpService.tsx @@ -92,19 +92,19 @@ class HttpService { }); } - public get(url: string): Promise { + public get(url: string): Promise { return this.request('GET', url); } - public post(url: string, data: TRequest): Promise { + public post(url: string, data: TRequest): Promise { return this.request('POST', url, data); } - public put(url: string, data: TRequest): Promise { + public put(url: string, data: TRequest): Promise { return this.request('PUT', url, data); } - public delete(url: string): Promise { + public delete(url: string): Promise { return this.request('DELETE', url); } @@ -112,11 +112,24 @@ class HttpService { this.requestInterceptors.push(interceptor); } - public addResponseInterceptor(interceptor: ResponseInterceptor): void { + public addResponseInterceptor(interceptor: ResponseInterceptor): void { this.responseInterceptors.push(interceptor); } } + +const httpService = new HttpService(); + +httpService.addRequestInterceptor(xhr => { + +}); + +httpService.addResponseInterceptor(response => { + + return response; +}); + + export { - HttpService -}; + httpService +} diff --git a/src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs b/src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs index 5e7532d..ae7d1cf 100644 --- a/src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs +++ b/src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs @@ -30,13 +30,13 @@ namespace MaksIT.LetsEncryptServer.BackgroundServices { while (!stoppingToken.IsCancellationRequested) { _logger.LogInformation("Background service is running."); - var (accountIds, getAccountIdsResult) = await _cacheService.ListCachedAccountsAsync(); - if (!getAccountIdsResult.IsSuccess || accountIds == null) { + var (accountsResponse, getAccountIdsResult) = await _cacheService.GetAccountsAsync(); + if (!getAccountIdsResult.IsSuccess || accountsResponse == null) { LogErrors(getAccountIdsResult.Errors); continue; } - foreach (var accountId in accountIds) { + foreach (var accountId in accountsResponse.AccountIds) { await ProcessAccountAsync(accountId); } diff --git a/src/LetsEncryptServer/Controllers/CacheController.cs b/src/LetsEncryptServer/Controllers/CacheController.cs index 4f056ab..ce91fe2 100644 --- a/src/LetsEncryptServer/Controllers/CacheController.cs +++ b/src/LetsEncryptServer/Controllers/CacheController.cs @@ -29,7 +29,7 @@ public class CacheController { [HttpGet("[action]")] public async Task GetAccounts() { - var result = await _cacheService.ListCachedAccountsAsync(); + var result = await _cacheService.GetAccountsAsync(); return result.ToActionResult(); } @@ -45,5 +45,11 @@ public class CacheController { var result = await _cacheService.SetContactsAsync(accountId, requestData); return result.ToActionResult(); } + + [HttpGet("[action]/{accountId}")] + public async Task GetHostnames(Guid accountId) { + var result = await _cacheService.GetHostnames(accountId); + return result.ToActionResult(); + } } diff --git a/src/LetsEncryptServer/Services/CacheService.cs b/src/LetsEncryptServer/Services/CacheService.cs index 2548f4a..b307683 100644 --- a/src/LetsEncryptServer/Services/CacheService.cs +++ b/src/LetsEncryptServer/Services/CacheService.cs @@ -1,9 +1,12 @@ -using System.Text.Json; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; using DomainResults.Common; using MaksIT.Core.Extensions; using MaksIT.LetsEncrypt.Entities; using MaksIT.Models.LetsEncryptServer.Cache.Requests; +using Models.LetsEncryptServer.Cache.Responses; namespace MaksIT.LetsEncryptServer.Services; @@ -11,9 +14,11 @@ public interface ICacheService { Task<(RegistrationCache?, IDomainResult)> LoadFromCacheAsync(Guid accountId); Task SaveToCacheAsync(Guid accountId, RegistrationCache cache); Task DeleteFromCacheAsync(Guid accountId); - Task<(Guid[]?, IDomainResult)> ListCachedAccountsAsync(); - Task<(string[]?, IDomainResult)> GetContactsAsync(Guid accountId); + Task<(GetAccountsResponse?, IDomainResult)> GetAccountsAsync(); + Task<(GetContactsResponse?, IDomainResult)> GetContactsAsync(Guid accountId); Task SetContactsAsync(Guid accountId, SetContactsRequest requestData); + + Task<(GetHostnamesResponse?, IDomainResult)> GetHostnames(Guid accountId); } 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(); try { var cacheFiles = Directory.GetFiles(_cacheDirectory); if (cacheFiles == null) - return IDomainResult.Success(new Guid[0]); + return IDomainResult.Success(new GetAccountsResponse { + AccountIds = Array.Empty() + }); var accountIds = cacheFiles.Select(x => Path.GetFileNameWithoutExtension(x).ToGuid()).ToArray(); - return IDomainResult.Success(accountIds); + return IDomainResult.Success(new GetAccountsResponse { + AccountIds = accountIds + }); } catch (Exception ex) { var message = "Error listing cache files"; _logger.LogError(ex, message); - return IDomainResult.Failed (message); + return IDomainResult.Failed (message); } finally { _cacheLock.Release(); } } - public async Task<(string[]?, IDomainResult)> GetContactsAsync(Guid accountId) { + public async Task<(GetContactsResponse?, IDomainResult)> GetContactsAsync(Guid accountId) { var (cache, loadResult) = await LoadFromCacheAsync(accountId); if (!loadResult.IsSuccess || cache == null) return (null, loadResult); - return IDomainResult.Success(cache.Contacts); + return IDomainResult.Success(new GetContactsResponse { + Contacts = cache.Contacts ?? Array.Empty() + }); } @@ -162,6 +173,33 @@ public class CacheService : ICacheService, IDisposable { 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() + }; + + 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() { _cacheLock?.Dispose(); diff --git a/src/Models/LetsEncryptServer/Cache/Responses/GetAccountsResponse.cs b/src/Models/LetsEncryptServer/Cache/Responses/GetAccountsResponse.cs new file mode 100644 index 0000000..db76fae --- /dev/null +++ b/src/Models/LetsEncryptServer/Cache/Responses/GetAccountsResponse.cs @@ -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; } + } +} diff --git a/src/Models/LetsEncryptServer/Cache/Responses/GetContactsResponse.cs b/src/Models/LetsEncryptServer/Cache/Responses/GetContactsResponse.cs new file mode 100644 index 0000000..fac9d70 --- /dev/null +++ b/src/Models/LetsEncryptServer/Cache/Responses/GetContactsResponse.cs @@ -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; } + } +} diff --git a/src/Models/LetsEncryptServer/Cache/Responses/GetHostnamesResponse.cs b/src/Models/LetsEncryptServer/Cache/Responses/GetHostnamesResponse.cs new file mode 100644 index 0000000..787e2c1 --- /dev/null +++ b/src/Models/LetsEncryptServer/Cache/Responses/GetHostnamesResponse.cs @@ -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 Hostnames { get; set; } + } +} diff --git a/src/Models/Models.csproj b/src/Models/Models.csproj index 4cfcf65..0361520 100644 --- a/src/Models/Models.csproj +++ b/src/Models/Models.csproj @@ -8,7 +8,6 @@ -