Selaa lähdekoodia

API lib cleanup, 404 hosts WIP

Jamie Curnow 2 kuukautta sitten
vanhempi
sitoutus
54e036276a
76 muutettua tiedostoa jossa 921 lisäystä ja 544 poistoa
  1. 6 9
      frontend/src/api/backend/createAccessList.ts
  2. 6 9
      frontend/src/api/backend/createCertificate.ts
  3. 6 9
      frontend/src/api/backend/createDeadHost.ts
  4. 6 9
      frontend/src/api/backend/createProxyHost.ts
  5. 6 12
      frontend/src/api/backend/createRedirectionHost.ts
  6. 6 9
      frontend/src/api/backend/createStream.ts
  7. 7 10
      frontend/src/api/backend/createUser.ts
  8. 4 7
      frontend/src/api/backend/deleteAccessList.ts
  9. 4 7
      frontend/src/api/backend/deleteCertificate.ts
  10. 4 7
      frontend/src/api/backend/deleteDeadHost.ts
  11. 4 7
      frontend/src/api/backend/deleteProxyHost.ts
  12. 4 7
      frontend/src/api/backend/deleteRedirectionHost.ts
  13. 4 7
      frontend/src/api/backend/deleteStream.ts
  14. 4 7
      frontend/src/api/backend/deleteUser.ts
  15. 4 7
      frontend/src/api/backend/downloadCertificate.ts
  16. 6 0
      frontend/src/api/backend/expansions.ts
  17. 8 6
      frontend/src/api/backend/getAccessList.ts
  18. 1 2
      frontend/src/api/backend/getAccessLists.ts
  19. 1 1
      frontend/src/api/backend/getAuditLog.ts
  20. 1 2
      frontend/src/api/backend/getAuditLogs.ts
  21. 8 6
      frontend/src/api/backend/getCertificate.ts
  22. 1 2
      frontend/src/api/backend/getCertificates.ts
  23. 8 6
      frontend/src/api/backend/getDeadHost.ts
  24. 2 3
      frontend/src/api/backend/getDeadHosts.ts
  25. 4 7
      frontend/src/api/backend/getHealth.ts
  26. 4 7
      frontend/src/api/backend/getHostsReport.ts
  27. 8 6
      frontend/src/api/backend/getProxyHost.ts
  28. 1 2
      frontend/src/api/backend/getProxyHosts.ts
  29. 8 6
      frontend/src/api/backend/getRedirectionHost.ts
  30. 2 5
      frontend/src/api/backend/getRedirectionHosts.ts
  31. 7 6
      frontend/src/api/backend/getSetting.ts
  32. 8 6
      frontend/src/api/backend/getStream.ts
  33. 2 3
      frontend/src/api/backend/getStreams.ts
  34. 5 15
      frontend/src/api/backend/getToken.ts
  35. 6 2
      frontend/src/api/backend/getUser.ts
  36. 1 2
      frontend/src/api/backend/getUsers.ts
  37. 1 0
      frontend/src/api/backend/index.ts
  38. 4 7
      frontend/src/api/backend/refreshToken.ts
  39. 4 7
      frontend/src/api/backend/renewCertificate.ts
  40. 5 12
      frontend/src/api/backend/setPermissions.ts
  41. 6 12
      frontend/src/api/backend/testHttpCertificate.ts
  42. 4 11
      frontend/src/api/backend/toggleDeadHost.ts
  43. 4 11
      frontend/src/api/backend/toggleProxyHost.ts
  44. 4 11
      frontend/src/api/backend/toggleRedirectionHost.ts
  45. 4 7
      frontend/src/api/backend/toggleStream.ts
  46. 5 8
      frontend/src/api/backend/updateAccessList.ts
  47. 5 13
      frontend/src/api/backend/updateAuth.ts
  48. 5 8
      frontend/src/api/backend/updateDeadHost.ts
  49. 5 8
      frontend/src/api/backend/updateProxyHost.ts
  50. 5 11
      frontend/src/api/backend/updateRedirectionHost.ts
  51. 5 8
      frontend/src/api/backend/updateSetting.ts
  52. 5 8
      frontend/src/api/backend/updateStream.ts
  53. 5 8
      frontend/src/api/backend/updateUser.ts
  54. 4 8
      frontend/src/api/backend/uploadCertificate.ts
  55. 4 8
      frontend/src/api/backend/validateCertificate.ts
  56. 2 4
      frontend/src/components/ErrorNotFound.tsx
  57. 119 0
      frontend/src/components/Form/DomainNamesField.tsx
  58. 112 0
      frontend/src/components/Form/SSLCertificateField.tsx
  59. 2 0
      frontend/src/components/Form/index.ts
  60. 2 4
      frontend/src/components/Table/TableBody.tsx
  61. 1 0
      frontend/src/components/index.ts
  62. 1 1
      frontend/src/context/AuthContext.tsx
  63. 1 0
      frontend/src/hooks/index.ts
  64. 57 0
      frontend/src/hooks/useDeadHost.ts
  65. 3 3
      frontend/src/hooks/useDeadHosts.ts
  66. 3 3
      frontend/src/hooks/useRedirectionHosts.ts
  67. 3 3
      frontend/src/hooks/useStreams.ts
  68. 1 1
      frontend/src/hooks/useUser.ts
  69. 8 1
      frontend/src/locale/lang/en.json
  70. 21 0
      frontend/src/locale/src/en.json
  71. 285 0
      frontend/src/modals/DeadHostModal.tsx
  72. 1 0
      frontend/src/modals/index.ts
  73. 0 132
      frontend/src/pages/Certificates/CertificateTable.tsx
  74. 5 2
      frontend/src/pages/Nginx/DeadHosts/Empty.tsx
  75. 15 4
      frontend/src/pages/Nginx/DeadHosts/Table.tsx
  76. 28 2
      frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx

+ 6 - 9
frontend/src/api/backend/createAccessList.ts

@@ -1,13 +1,10 @@
 import * as api from "./base";
 import type { AccessList } from "./models";
 
-export async function createAccessList(item: AccessList, abortController?: AbortController): Promise<AccessList> {
-	return await api.post(
-		{
-			url: "/nginx/access-lists",
-			// todo: only use whitelist of fields for this data
-			data: item,
-		},
-		abortController,
-	);
+export async function createAccessList(item: AccessList): Promise<AccessList> {
+	return await api.post({
+		url: "/nginx/access-lists",
+		// todo: only use whitelist of fields for this data
+		data: item,
+	});
 }

+ 6 - 9
frontend/src/api/backend/createCertificate.ts

@@ -1,13 +1,10 @@
 import * as api from "./base";
 import type { Certificate } from "./models";
 
-export async function createCertificate(item: Certificate, abortController?: AbortController): Promise<Certificate> {
-	return await api.post(
-		{
-			url: "/nginx/certificates",
-			// todo: only use whitelist of fields for this data
-			data: item,
-		},
-		abortController,
-	);
+export async function createCertificate(item: Certificate): Promise<Certificate> {
+	return await api.post({
+		url: "/nginx/certificates",
+		// todo: only use whitelist of fields for this data
+		data: item,
+	});
 }

+ 6 - 9
frontend/src/api/backend/createDeadHost.ts

@@ -1,13 +1,10 @@
 import * as api from "./base";
 import type { DeadHost } from "./models";
 
-export async function createDeadHost(item: DeadHost, abortController?: AbortController): Promise<DeadHost> {
-	return await api.post(
-		{
-			url: "/nginx/dead-hosts",
-			// todo: only use whitelist of fields for this data
-			data: item,
-		},
-		abortController,
-	);
+export async function createDeadHost(item: DeadHost): Promise<DeadHost> {
+	return await api.post({
+		url: "/nginx/dead-hosts",
+		// todo: only use whitelist of fields for this data
+		data: item,
+	});
 }

+ 6 - 9
frontend/src/api/backend/createProxyHost.ts

@@ -1,13 +1,10 @@
 import * as api from "./base";
 import type { ProxyHost } from "./models";
 
-export async function createProxyHost(item: ProxyHost, abortController?: AbortController): Promise<ProxyHost> {
-	return await api.post(
-		{
-			url: "/nginx/proxy-hosts",
-			// todo: only use whitelist of fields for this data
-			data: item,
-		},
-		abortController,
-	);
+export async function createProxyHost(item: ProxyHost): Promise<ProxyHost> {
+	return await api.post({
+		url: "/nginx/proxy-hosts",
+		// todo: only use whitelist of fields for this data
+		data: item,
+	});
 }

+ 6 - 12
frontend/src/api/backend/createRedirectionHost.ts

@@ -1,16 +1,10 @@
 import * as api from "./base";
 import type { RedirectionHost } from "./models";
 
-export async function createRedirectionHost(
-	item: RedirectionHost,
-	abortController?: AbortController,
-): Promise<RedirectionHost> {
-	return await api.post(
-		{
-			url: "/nginx/redirection-hosts",
-			// todo: only use whitelist of fields for this data
-			data: item,
-		},
-		abortController,
-	);
+export async function createRedirectionHost(item: RedirectionHost): Promise<RedirectionHost> {
+	return await api.post({
+		url: "/nginx/redirection-hosts",
+		// todo: only use whitelist of fields for this data
+		data: item,
+	});
 }

+ 6 - 9
frontend/src/api/backend/createStream.ts

@@ -1,13 +1,10 @@
 import * as api from "./base";
 import type { Stream } from "./models";
 
-export async function createStream(item: Stream, abortController?: AbortController): Promise<Stream> {
-	return await api.post(
-		{
-			url: "/nginx/streams",
-			// todo: only use whitelist of fields for this data
-			data: item,
-		},
-		abortController,
-	);
+export async function createStream(item: Stream): Promise<Stream> {
+	return await api.post({
+		url: "/nginx/streams",
+		// todo: only use whitelist of fields for this data
+		data: item,
+	});
 }

+ 7 - 10
frontend/src/api/backend/createUser.ts

@@ -15,14 +15,11 @@ export interface NewUser {
 	roles?: string[];
 }
 
-export async function createUser(item: NewUser, noAuth?: boolean, abortController?: AbortController): Promise<User> {
-	return await api.post(
-		{
-			url: "/users",
-			// todo: only use whitelist of fields for this data
-			data: item,
-			noAuth,
-		},
-		abortController,
-	);
+export async function createUser(item: NewUser, noAuth?: boolean): Promise<User> {
+	return await api.post({
+		url: "/users",
+		// todo: only use whitelist of fields for this data
+		data: item,
+		noAuth,
+	});
 }

+ 4 - 7
frontend/src/api/backend/deleteAccessList.ts

@@ -1,10 +1,7 @@
 import * as api from "./base";
 
-export async function deleteAccessList(id: number, abortController?: AbortController): Promise<boolean> {
-	return await api.del(
-		{
-			url: `/nginx/access-lists/${id}`,
-		},
-		abortController,
-	);
+export async function deleteAccessList(id: number): Promise<boolean> {
+	return await api.del({
+		url: `/nginx/access-lists/${id}`,
+	});
 }

+ 4 - 7
frontend/src/api/backend/deleteCertificate.ts

@@ -1,10 +1,7 @@
 import * as api from "./base";
 
-export async function deleteCertificate(id: number, abortController?: AbortController): Promise<boolean> {
-	return await api.del(
-		{
-			url: `/nginx/certificates/${id}`,
-		},
-		abortController,
-	);
+export async function deleteCertificate(id: number): Promise<boolean> {
+	return await api.del({
+		url: `/nginx/certificates/${id}`,
+	});
 }

+ 4 - 7
frontend/src/api/backend/deleteDeadHost.ts

@@ -1,10 +1,7 @@
 import * as api from "./base";
 
-export async function deleteDeadHost(id: number, abortController?: AbortController): Promise<boolean> {
-	return await api.del(
-		{
-			url: `/nginx/dead-hosts/${id}`,
-		},
-		abortController,
-	);
+export async function deleteDeadHost(id: number): Promise<boolean> {
+	return await api.del({
+		url: `/nginx/dead-hosts/${id}`,
+	});
 }

+ 4 - 7
frontend/src/api/backend/deleteProxyHost.ts

@@ -1,10 +1,7 @@
 import * as api from "./base";
 
-export async function deleteProxyHost(id: number, abortController?: AbortController): Promise<boolean> {
-	return await api.del(
-		{
-			url: `/nginx/proxy-hosts/${id}`,
-		},
-		abortController,
-	);
+export async function deleteProxyHost(id: number): Promise<boolean> {
+	return await api.del({
+		url: `/nginx/proxy-hosts/${id}`,
+	});
 }

+ 4 - 7
frontend/src/api/backend/deleteRedirectionHost.ts

@@ -1,10 +1,7 @@
 import * as api from "./base";
 
-export async function deleteRedirectionHost(id: number, abortController?: AbortController): Promise<boolean> {
-	return await api.del(
-		{
-			url: `/nginx/redirection-hosts/${id}`,
-		},
-		abortController,
-	);
+export async function deleteRedirectionHost(id: number): Promise<boolean> {
+	return await api.del({
+		url: `/nginx/redirection-hosts/${id}`,
+	});
 }

+ 4 - 7
frontend/src/api/backend/deleteStream.ts

@@ -1,10 +1,7 @@
 import * as api from "./base";
 
-export async function deleteStream(id: number, abortController?: AbortController): Promise<boolean> {
-	return await api.del(
-		{
-			url: `/nginx/streams/${id}`,
-		},
-		abortController,
-	);
+export async function deleteStream(id: number): Promise<boolean> {
+	return await api.del({
+		url: `/nginx/streams/${id}`,
+	});
 }

+ 4 - 7
frontend/src/api/backend/deleteUser.ts

@@ -1,10 +1,7 @@
 import * as api from "./base";
 
-export async function deleteUser(id: number, abortController?: AbortController): Promise<boolean> {
-	return await api.del(
-		{
-			url: `/users/${id}`,
-		},
-		abortController,
-	);
+export async function deleteUser(id: number): Promise<boolean> {
+	return await api.del({
+		url: `/users/${id}`,
+	});
 }

+ 4 - 7
frontend/src/api/backend/downloadCertificate.ts

@@ -1,11 +1,8 @@
 import * as api from "./base";
 import type { Binary } from "./responseTypes";
 
-export async function downloadCertificate(id: number, abortController?: AbortController): Promise<Binary> {
-	return await api.get(
-		{
-			url: `/nginx/certificates/${id}/download`,
-		},
-		abortController,
-	);
+export async function downloadCertificate(id: number): Promise<Binary> {
+	return await api.get({
+		url: `/nginx/certificates/${id}/download`,
+	});
 }

+ 6 - 0
frontend/src/api/backend/expansions.ts

@@ -0,0 +1,6 @@
+export type AccessListExpansion = "owner" | "items" | "clients";
+export type AuditLogExpansion = "user";
+export type CertificateExpansion = "owner" | "proxy_hosts" | "redirection_hosts" | "dead_hosts";
+export type HostExpansion = "owner" | "certificate";
+export type ProxyHostExpansion = "owner" | "access_list" | "certificate";
+export type UserExpansion = "permissions";

+ 8 - 6
frontend/src/api/backend/getAccessList.ts

@@ -1,11 +1,13 @@
 import * as api from "./base";
+import type { AccessListExpansion } from "./expansions";
 import type { AccessList } from "./models";
 
-export async function getAccessList(id: number, abortController?: AbortController): Promise<AccessList> {
-	return await api.get(
-		{
-			url: `/nginx/access-lists/${id}`,
+export async function getAccessList(id: number, expand?: AccessListExpansion[], params = {}): Promise<AccessList> {
+	return await api.get({
+		url: `/nginx/access-lists/${id}`,
+		params: {
+			expand: expand?.join(","),
+			...params,
 		},
-		abortController,
-	);
+	});
 }

+ 1 - 2
frontend/src/api/backend/getAccessLists.ts

@@ -1,8 +1,7 @@
 import * as api from "./base";
+import type { AccessListExpansion } from "./expansions";
 import type { AccessList } from "./models";
 
-export type AccessListExpansion = "owner" | "items" | "clients";
-
 export async function getAccessLists(expand?: AccessListExpansion[], params = {}): Promise<AccessList[]> {
 	return await api.get({
 		url: "/nginx/access-lists",

+ 1 - 1
frontend/src/api/backend/getAuditLog.ts

@@ -1,5 +1,5 @@
 import * as api from "./base";
-import type { AuditLogExpansion } from "./getAuditLogs";
+import type { AuditLogExpansion } from "./expansions";
 import type { AuditLog } from "./models";
 
 export async function getAuditLog(id: number, expand?: AuditLogExpansion[], params = {}): Promise<AuditLog> {

+ 1 - 2
frontend/src/api/backend/getAuditLogs.ts

@@ -1,8 +1,7 @@
 import * as api from "./base";
+import type { AuditLogExpansion } from "./expansions";
 import type { AuditLog } from "./models";
 
-export type AuditLogExpansion = "user";
-
 export async function getAuditLogs(expand?: AuditLogExpansion[], params = {}): Promise<AuditLog[]> {
 	return await api.get({
 		url: "/audit-log",

+ 8 - 6
frontend/src/api/backend/getCertificate.ts

@@ -1,11 +1,13 @@
 import * as api from "./base";
+import type { CertificateExpansion } from "./expansions";
 import type { Certificate } from "./models";
 
-export async function getCertificate(id: number, abortController?: AbortController): Promise<Certificate> {
-	return await api.get(
-		{
-			url: `/nginx/certificates/${id}`,
+export async function getCertificate(id: number, expand?: CertificateExpansion[], params = {}): Promise<Certificate> {
+	return await api.get({
+		url: `/nginx/certificates/${id}`,
+		params: {
+			expand: expand?.join(","),
+			...params,
 		},
-		abortController,
-	);
+	});
 }

+ 1 - 2
frontend/src/api/backend/getCertificates.ts

@@ -1,8 +1,7 @@
 import * as api from "./base";
+import type { CertificateExpansion } from "./expansions";
 import type { Certificate } from "./models";
 
-export type CertificateExpansion = "owner" | "proxy_hosts" | "redirection_hosts" | "dead_hosts";
-
 export async function getCertificates(expand?: CertificateExpansion[], params = {}): Promise<Certificate[]> {
 	return await api.get({
 		url: "/nginx/certificates",

+ 8 - 6
frontend/src/api/backend/getDeadHost.ts

@@ -1,11 +1,13 @@
 import * as api from "./base";
+import type { HostExpansion } from "./expansions";
 import type { DeadHost } from "./models";
 
-export async function getDeadHost(id: number, abortController?: AbortController): Promise<DeadHost> {
-	return await api.get(
-		{
-			url: `/nginx/dead-hosts/${id}`,
+export async function getDeadHost(id: number, expand?: HostExpansion[], params = {}): Promise<DeadHost> {
+	return await api.get({
+		url: `/nginx/dead-hosts/${id}`,
+		params: {
+			expand: expand?.join(","),
+			...params,
 		},
-		abortController,
-	);
+	});
 }

+ 2 - 3
frontend/src/api/backend/getDeadHosts.ts

@@ -1,9 +1,8 @@
 import * as api from "./base";
+import type { HostExpansion } from "./expansions";
 import type { DeadHost } from "./models";
 
-export type DeadHostExpansion = "owner" | "certificate";
-
-export async function getDeadHosts(expand?: DeadHostExpansion[], params = {}): Promise<DeadHost[]> {
+export async function getDeadHosts(expand?: HostExpansion[], params = {}): Promise<DeadHost[]> {
 	return await api.get({
 		url: "/nginx/dead-hosts",
 		params: {

+ 4 - 7
frontend/src/api/backend/getHealth.ts

@@ -1,11 +1,8 @@
 import * as api from "./base";
 import type { HealthResponse } from "./responseTypes";
 
-export async function getHealth(abortController?: AbortController): Promise<HealthResponse> {
-	return await api.get(
-		{
-			url: "/",
-		},
-		abortController,
-	);
+export async function getHealth(): Promise<HealthResponse> {
+	return await api.get({
+		url: "/",
+	});
 }

+ 4 - 7
frontend/src/api/backend/getHostsReport.ts

@@ -1,10 +1,7 @@
 import * as api from "./base";
 
-export async function getHostsReport(abortController?: AbortController): Promise<Record<string, number>> {
-	return await api.get(
-		{
-			url: "/reports/hosts",
-		},
-		abortController,
-	);
+export async function getHostsReport(): Promise<Record<string, number>> {
+	return await api.get({
+		url: "/reports/hosts",
+	});
 }

+ 8 - 6
frontend/src/api/backend/getProxyHost.ts

@@ -1,11 +1,13 @@
 import * as api from "./base";
+import type { ProxyHostExpansion } from "./expansions";
 import type { ProxyHost } from "./models";
 
-export async function getProxyHost(id: number, abortController?: AbortController): Promise<ProxyHost> {
-	return await api.get(
-		{
-			url: `/nginx/proxy-hosts/${id}`,
+export async function getProxyHost(id: number, expand?: ProxyHostExpansion[], params = {}): Promise<ProxyHost> {
+	return await api.get({
+		url: `/nginx/proxy-hosts/${id}`,
+		params: {
+			expand: expand?.join(","),
+			...params,
 		},
-		abortController,
-	);
+	});
 }

+ 1 - 2
frontend/src/api/backend/getProxyHosts.ts

@@ -1,8 +1,7 @@
 import * as api from "./base";
+import type { ProxyHostExpansion } from "./expansions";
 import type { ProxyHost } from "./models";
 
-export type ProxyHostExpansion = "owner" | "access_list" | "certificate";
-
 export async function getProxyHosts(expand?: ProxyHostExpansion[], params = {}): Promise<ProxyHost[]> {
 	return await api.get({
 		url: "/nginx/proxy-hosts",

+ 8 - 6
frontend/src/api/backend/getRedirectionHost.ts

@@ -1,11 +1,13 @@
 import * as api from "./base";
+import type { HostExpansion } from "./expansions";
 import type { ProxyHost } from "./models";
 
-export async function getRedirectionHost(id: number, abortController?: AbortController): Promise<ProxyHost> {
-	return await api.get(
-		{
-			url: `/nginx/redirection-hosts/${id}`,
+export async function getRedirectionHost(id: number, expand?: HostExpansion[], params = {}): Promise<ProxyHost> {
+	return await api.get({
+		url: `/nginx/redirection-hosts/${id}`,
+		params: {
+			expand: expand?.join(","),
+			...params,
 		},
-		abortController,
-	);
+	});
 }

+ 2 - 5
frontend/src/api/backend/getRedirectionHosts.ts

@@ -1,11 +1,8 @@
 import * as api from "./base";
+import type { HostExpansion } from "./expansions";
 import type { RedirectionHost } from "./models";
 
-export type RedirectionHostExpansion = "owner" | "certificate";
-export async function getRedirectionHosts(
-	expand?: RedirectionHostExpansion[],
-	params = {},
-): Promise<RedirectionHost[]> {
+export async function getRedirectionHosts(expand?: HostExpansion[], params = {}): Promise<RedirectionHost[]> {
 	return await api.get({
 		url: "/nginx/redirection-hosts",
 		params: {

+ 7 - 6
frontend/src/api/backend/getSetting.ts

@@ -1,11 +1,12 @@
 import * as api from "./base";
 import type { Setting } from "./models";
 
-export async function getSetting(id: string, abortController?: AbortController): Promise<Setting> {
-	return await api.get(
-		{
-			url: `/settings/${id}`,
+export async function getSetting(id: string, expand?: string[], params = {}): Promise<Setting> {
+	return await api.get({
+		url: `/settings/${id}`,
+		params: {
+			expand: expand?.join(","),
+			...params,
 		},
-		abortController,
-	);
+	});
 }

+ 8 - 6
frontend/src/api/backend/getStream.ts

@@ -1,11 +1,13 @@
 import * as api from "./base";
+import type { HostExpansion } from "./expansions";
 import type { Stream } from "./models";
 
-export async function getStream(id: number, abortController?: AbortController): Promise<Stream> {
-	return await api.get(
-		{
-			url: `/nginx/streams/${id}`,
+export async function getStream(id: number, expand?: HostExpansion[], params = {}): Promise<Stream> {
+	return await api.get({
+		url: `/nginx/streams/${id}`,
+		params: {
+			expand: expand?.join(","),
+			...params,
 		},
-		abortController,
-	);
+	});
 }

+ 2 - 3
frontend/src/api/backend/getStreams.ts

@@ -1,9 +1,8 @@
 import * as api from "./base";
+import type { HostExpansion } from "./expansions";
 import type { Stream } from "./models";
 
-export type StreamExpansion = "owner" | "certificate";
-
-export async function getStreams(expand?: StreamExpansion[], params = {}): Promise<Stream[]> {
+export async function getStreams(expand?: HostExpansion[], params = {}): Promise<Stream[]> {
 	return await api.get({
 		url: "/nginx/streams",
 		params: {

+ 5 - 15
frontend/src/api/backend/getToken.ts

@@ -1,19 +1,9 @@
 import * as api from "./base";
 import type { TokenResponse } from "./responseTypes";
 
-interface Options {
-	payload: {
-		identity: string;
-		secret: string;
-	};
-}
-
-export async function getToken({ payload }: Options, abortController?: AbortController): Promise<TokenResponse> {
-	return await api.post(
-		{
-			url: "/tokens",
-			data: payload,
-		},
-		abortController,
-	);
+export async function getToken(identity: string, secret: string): Promise<TokenResponse> {
+	return await api.post({
+		url: "/tokens",
+		data: { identity, secret },
+	});
 }

+ 6 - 2
frontend/src/api/backend/getUser.ts

@@ -1,10 +1,14 @@
 import * as api from "./base";
+import type { UserExpansion } from "./expansions";
 import type { User } from "./models";
 
-export async function getUser(id: number | string = "me", params = {}): Promise<User> {
+export async function getUser(id: number | string = "me", expand?: UserExpansion[], params = {}): Promise<User> {
 	const userId = id ? id : "me";
 	return await api.get({
 		url: `/users/${userId}`,
-		params,
+		params: {
+			expand: expand?.join(","),
+			...params,
+		},
 	});
 }

+ 1 - 2
frontend/src/api/backend/getUsers.ts

@@ -1,8 +1,7 @@
 import * as api from "./base";
+import type { UserExpansion } from "./expansions";
 import type { User } from "./models";
 
-export type UserExpansion = "permissions";
-
 export async function getUsers(expand?: UserExpansion[], params = {}): Promise<User[]> {
 	return await api.get({
 		url: "/users",

+ 1 - 0
frontend/src/api/backend/index.ts

@@ -13,6 +13,7 @@ export * from "./deleteRedirectionHost";
 export * from "./deleteStream";
 export * from "./deleteUser";
 export * from "./downloadCertificate";
+export * from "./expansions";
 export * from "./getAccessList";
 export * from "./getAccessLists";
 export * from "./getAuditLog";

+ 4 - 7
frontend/src/api/backend/refreshToken.ts

@@ -1,11 +1,8 @@
 import * as api from "./base";
 import type { TokenResponse } from "./responseTypes";
 
-export async function refreshToken(abortController?: AbortController): Promise<TokenResponse> {
-	return await api.get(
-		{
-			url: "/tokens",
-		},
-		abortController,
-	);
+export async function refreshToken(): Promise<TokenResponse> {
+	return await api.get({
+		url: "/tokens",
+	});
 }

+ 4 - 7
frontend/src/api/backend/renewCertificate.ts

@@ -1,11 +1,8 @@
 import * as api from "./base";
 import type { Certificate } from "./models";
 
-export async function renewCertificate(id: number, abortController?: AbortController): Promise<Certificate> {
-	return await api.post(
-		{
-			url: `/nginx/certificates/${id}/renew`,
-		},
-		abortController,
-	);
+export async function renewCertificate(id: number): Promise<Certificate> {
+	return await api.post({
+		url: `/nginx/certificates/${id}/renew`,
+	});
 }

+ 5 - 12
frontend/src/api/backend/setPermissions.ts

@@ -1,17 +1,10 @@
 import * as api from "./base";
 import type { UserPermissions } from "./models";
 
-export async function setPermissions(
-	userId: number,
-	data: UserPermissions,
-	abortController?: AbortController,
-): Promise<boolean> {
+export async function setPermissions(userId: number, data: UserPermissions): Promise<boolean> {
 	// Remove readonly fields
-	return await api.put(
-		{
-			url: `/users/${userId}/permissions`,
-			data,
-		},
-		abortController,
-	);
+	return await api.put({
+		url: `/users/${userId}/permissions`,
+		data,
+	});
 }

+ 6 - 12
frontend/src/api/backend/testHttpCertificate.ts

@@ -1,16 +1,10 @@
 import * as api from "./base";
 
-export async function testHttpCertificate(
-	domains: string[],
-	abortController?: AbortController,
-): Promise<Record<string, string>> {
-	return await api.get(
-		{
-			url: "/nginx/certificates/test-http",
-			params: {
-				domains: domains.join(","),
-			},
+export async function testHttpCertificate(domains: string[]): Promise<Record<string, string>> {
+	return await api.get({
+		url: "/nginx/certificates/test-http",
+		params: {
+			domains: domains.join(","),
 		},
-		abortController,
-	);
+	});
 }

+ 4 - 11
frontend/src/api/backend/toggleDeadHost.ts

@@ -1,14 +1,7 @@
 import * as api from "./base";
 
-export async function toggleDeadHost(
-	id: number,
-	enabled: boolean,
-	abortController?: AbortController,
-): Promise<boolean> {
-	return await api.post(
-		{
-			url: `/nginx/dead-hosts/${id}/${enabled ? "enable" : "disable"}`,
-		},
-		abortController,
-	);
+export async function toggleDeadHost(id: number, enabled: boolean): Promise<boolean> {
+	return await api.post({
+		url: `/nginx/dead-hosts/${id}/${enabled ? "enable" : "disable"}`,
+	});
 }

+ 4 - 11
frontend/src/api/backend/toggleProxyHost.ts

@@ -1,14 +1,7 @@
 import * as api from "./base";
 
-export async function toggleProxyHost(
-	id: number,
-	enabled: boolean,
-	abortController?: AbortController,
-): Promise<boolean> {
-	return await api.post(
-		{
-			url: `/nginx/proxy-hosts/${id}/${enabled ? "enable" : "disable"}`,
-		},
-		abortController,
-	);
+export async function toggleProxyHost(id: number, enabled: boolean): Promise<boolean> {
+	return await api.post({
+		url: `/nginx/proxy-hosts/${id}/${enabled ? "enable" : "disable"}`,
+	});
 }

+ 4 - 11
frontend/src/api/backend/toggleRedirectionHost.ts

@@ -1,14 +1,7 @@
 import * as api from "./base";
 
-export async function toggleRedirectionHost(
-	id: number,
-	enabled: boolean,
-	abortController?: AbortController,
-): Promise<boolean> {
-	return await api.post(
-		{
-			url: `/nginx/redirection-hosts/${id}/${enabled ? "enable" : "disable"}`,
-		},
-		abortController,
-	);
+export async function toggleRedirectionHost(id: number, enabled: boolean): Promise<boolean> {
+	return await api.post({
+		url: `/nginx/redirection-hosts/${id}/${enabled ? "enable" : "disable"}`,
+	});
 }

+ 4 - 7
frontend/src/api/backend/toggleStream.ts

@@ -1,10 +1,7 @@
 import * as api from "./base";
 
-export async function toggleStream(id: number, enabled: boolean, abortController?: AbortController): Promise<boolean> {
-	return await api.post(
-		{
-			url: `/nginx/streams/${id}/${enabled ? "enable" : "disable"}`,
-		},
-		abortController,
-	);
+export async function toggleStream(id: number, enabled: boolean): Promise<boolean> {
+	return await api.post({
+		url: `/nginx/streams/${id}/${enabled ? "enable" : "disable"}`,
+	});
 }

+ 5 - 8
frontend/src/api/backend/updateAccessList.ts

@@ -1,15 +1,12 @@
 import * as api from "./base";
 import type { AccessList } from "./models";
 
-export async function updateAccessList(item: AccessList, abortController?: AbortController): Promise<AccessList> {
+export async function updateAccessList(item: AccessList): Promise<AccessList> {
 	// Remove readonly fields
 	const { id, createdOn: _, modifiedOn: __, ...data } = item;
 
-	return await api.put(
-		{
-			url: `/nginx/access-lists/${id}`,
-			data: data,
-		},
-		abortController,
-	);
+	return await api.put({
+		url: `/nginx/access-lists/${id}`,
+		data: data,
+	});
 }

+ 5 - 13
frontend/src/api/backend/updateAuth.ts

@@ -1,12 +1,7 @@
 import * as api from "./base";
 import type { User } from "./models";
 
-export async function updateAuth(
-	userId: number | "me",
-	newPassword: string,
-	current?: string,
-	abortController?: AbortController,
-): Promise<User> {
+export async function updateAuth(userId: number | "me", newPassword: string, current?: string): Promise<User> {
 	const data = {
 		type: "password",
 		current: current,
@@ -16,11 +11,8 @@ export async function updateAuth(
 		data.current = current;
 	}
 
-	return await api.put(
-		{
-			url: `/users/${userId}/auth`,
-			data,
-		},
-		abortController,
-	);
+	return await api.put({
+		url: `/users/${userId}/auth`,
+		data,
+	});
 }

+ 5 - 8
frontend/src/api/backend/updateDeadHost.ts

@@ -1,15 +1,12 @@
 import * as api from "./base";
 import type { DeadHost } from "./models";
 
-export async function updateDeadHost(item: DeadHost, abortController?: AbortController): Promise<DeadHost> {
+export async function updateDeadHost(item: DeadHost): Promise<DeadHost> {
 	// Remove readonly fields
 	const { id, createdOn: _, modifiedOn: __, ...data } = item;
 
-	return await api.put(
-		{
-			url: `/nginx/dead-hosts/${id}`,
-			data: data,
-		},
-		abortController,
-	);
+	return await api.put({
+		url: `/nginx/dead-hosts/${id}`,
+		data: data,
+	});
 }

+ 5 - 8
frontend/src/api/backend/updateProxyHost.ts

@@ -1,15 +1,12 @@
 import * as api from "./base";
 import type { ProxyHost } from "./models";
 
-export async function updateProxyHost(item: ProxyHost, abortController?: AbortController): Promise<ProxyHost> {
+export async function updateProxyHost(item: ProxyHost): Promise<ProxyHost> {
 	// Remove readonly fields
 	const { id, createdOn: _, modifiedOn: __, ...data } = item;
 
-	return await api.put(
-		{
-			url: `/nginx/proxy-hosts/${id}`,
-			data: data,
-		},
-		abortController,
-	);
+	return await api.put({
+		url: `/nginx/proxy-hosts/${id}`,
+		data: data,
+	});
 }

+ 5 - 11
frontend/src/api/backend/updateRedirectionHost.ts

@@ -1,18 +1,12 @@
 import * as api from "./base";
 import type { RedirectionHost } from "./models";
 
-export async function updateRedirectionHost(
-	item: RedirectionHost,
-	abortController?: AbortController,
-): Promise<RedirectionHost> {
+export async function updateRedirectionHost(item: RedirectionHost): Promise<RedirectionHost> {
 	// Remove readonly fields
 	const { id, createdOn: _, modifiedOn: __, ...data } = item;
 
-	return await api.put(
-		{
-			url: `/nginx/redirection-hosts/${id}`,
-			data: data,
-		},
-		abortController,
-	);
+	return await api.put({
+		url: `/nginx/redirection-hosts/${id}`,
+		data: data,
+	});
 }

+ 5 - 8
frontend/src/api/backend/updateSetting.ts

@@ -1,15 +1,12 @@
 import * as api from "./base";
 import type { Setting } from "./models";
 
-export async function updateSetting(item: Setting, abortController?: AbortController): Promise<Setting> {
+export async function updateSetting(item: Setting): Promise<Setting> {
 	// Remove readonly fields
 	const { id, ...data } = item;
 
-	return await api.put(
-		{
-			url: `/settings/${id}`,
-			data: data,
-		},
-		abortController,
-	);
+	return await api.put({
+		url: `/settings/${id}`,
+		data: data,
+	});
 }

+ 5 - 8
frontend/src/api/backend/updateStream.ts

@@ -1,15 +1,12 @@
 import * as api from "./base";
 import type { Stream } from "./models";
 
-export async function updateStream(item: Stream, abortController?: AbortController): Promise<Stream> {
+export async function updateStream(item: Stream): Promise<Stream> {
 	// Remove readonly fields
 	const { id, createdOn: _, modifiedOn: __, ...data } = item;
 
-	return await api.put(
-		{
-			url: `/nginx/streams/${id}`,
-			data: data,
-		},
-		abortController,
-	);
+	return await api.put({
+		url: `/nginx/streams/${id}`,
+		data: data,
+	});
 }

+ 5 - 8
frontend/src/api/backend/updateUser.ts

@@ -1,15 +1,12 @@
 import * as api from "./base";
 import type { User } from "./models";
 
-export async function updateUser(item: User, abortController?: AbortController): Promise<User> {
+export async function updateUser(item: User): Promise<User> {
 	// Remove readonly fields
 	const { id, createdOn: _, modifiedOn: __, ...data } = item;
 
-	return await api.put(
-		{
-			url: `/users/${id}`,
-			data: data,
-		},
-		abortController,
-	);
+	return await api.put({
+		url: `/users/${id}`,
+		data: data,
+	});
 }

+ 4 - 8
frontend/src/api/backend/uploadCertificate.ts

@@ -6,13 +6,9 @@ export async function uploadCertificate(
 	certificate: string,
 	certificateKey: string,
 	intermediateCertificate?: string,
-	abortController?: AbortController,
 ): Promise<Certificate> {
-	return await api.post(
-		{
-			url: `/nginx/certificates/${id}/upload`,
-			data: { certificate, certificateKey, intermediateCertificate },
-		},
-		abortController,
-	);
+	return await api.post({
+		url: `/nginx/certificates/${id}/upload`,
+		data: { certificate, certificateKey, intermediateCertificate },
+	});
 }

+ 4 - 8
frontend/src/api/backend/validateCertificate.ts

@@ -5,13 +5,9 @@ export async function validateCertificate(
 	certificate: string,
 	certificateKey: string,
 	intermediateCertificate?: string,
-	abortController?: AbortController,
 ): Promise<ValidatedCertificateResponse> {
-	return await api.post(
-		{
-			url: "/nginx/certificates/validate",
-			data: { certificate, certificateKey, intermediateCertificate },
-		},
-		abortController,
-	);
+	return await api.post({
+		url: "/nginx/certificates/validate",
+		data: { certificate, certificateKey, intermediateCertificate },
+	});
 }

+ 2 - 4
frontend/src/components/ErrorNotFound.tsx

@@ -1,6 +1,6 @@
-import { intl } from "src/locale";
 import { useNavigate } from "react-router-dom";
 import { Button } from "src/components";
+import { intl } from "src/locale";
 
 export function ErrorNotFound() {
 	const navigate = useNavigate();
@@ -9,9 +9,7 @@ export function ErrorNotFound() {
 		<div className="container-tight py-4">
 			<div className="empty">
 				<p className="empty-title">{intl.formatMessage({ id: "notfound.title" })}</p>
-				<p className="empty-subtitle text-secondary">
-					{intl.formatMessage({ id: "notfound.text" })}
-				</p>
+				<p className="empty-subtitle text-secondary">{intl.formatMessage({ id: "notfound.text" })}</p>
 				<div className="empty-action">
 					<Button type="button" size="md" onClick={() => navigate("/")}>
 						{intl.formatMessage({ id: "notfound.action" })}

+ 119 - 0
frontend/src/components/Form/DomainNamesField.tsx

@@ -0,0 +1,119 @@
+import { Field, useFormikContext } from "formik";
+import type { ActionMeta, MultiValue } from "react-select";
+import CreatableSelect from "react-select/creatable";
+import { intl } from "src/locale";
+
+export type SelectOption = {
+	label: string;
+	value: string;
+	color?: string;
+};
+
+interface Props {
+	id?: string;
+	maxDomains?: number;
+	isWildcardPermitted?: boolean;
+	dnsProviderWildcardSupported?: boolean;
+	name?: string;
+	label?: string;
+}
+export function DomainNamesField({
+	name = "domainNames",
+	label = "domain-names",
+	id = "domainNames",
+	maxDomains,
+	isWildcardPermitted,
+	dnsProviderWildcardSupported,
+}: Props) {
+	const { values, setFieldValue } = useFormikContext();
+
+	const getDomainCount = (v: string[] | undefined): number => {
+		if (v?.length) {
+			return v.length;
+		}
+		return 0;
+	};
+
+	const handleChange = (v: MultiValue<SelectOption>, _actionMeta: ActionMeta<SelectOption>) => {
+		const doms = v?.map((i: SelectOption) => {
+			return i.value;
+		});
+		setFieldValue(name, doms);
+	};
+
+	const isDomainValid = (d: string): boolean => {
+		const dom = d.trim().toLowerCase();
+		const v: any = values;
+
+		// Deny if the list of domains is hit
+		if (maxDomains && getDomainCount(v?.[name]) >= maxDomains) {
+			return false;
+		}
+
+		if (dom.length < 3) {
+			return false;
+		}
+
+		// Prevent wildcards
+		if ((!isWildcardPermitted || !dnsProviderWildcardSupported) && dom.indexOf("*") !== -1) {
+			return false;
+		}
+
+		// Prevent duplicate * in domain
+		if ((dom.match(/\*/g) || []).length > 1) {
+			return false;
+		}
+
+		// Prevent some invalid characters
+		if ((dom.match(/(@|,|!|&|\$|#|%|\^|\(|\))/g) || []).length > 0) {
+			return false;
+		}
+
+		// This will match *.com type domains,
+		return dom.match(/\*\.[^.]+$/m) === null;
+	};
+
+	const helperTexts: string[] = [];
+	if (maxDomains) {
+		helperTexts.push(intl.formatMessage({ id: "domain_names.max" }, { count: maxDomains }));
+	}
+	if (!isWildcardPermitted) {
+		helperTexts.push(intl.formatMessage({ id: "wildcards-not-permitted" }));
+	} else if (!dnsProviderWildcardSupported) {
+		helperTexts.push(intl.formatMessage({ id: "wildcards-not-supported" }));
+	}
+
+	return (
+		<Field name={name}>
+			{({ field, form }: any) => (
+				<div className="mb-3">
+					<label className="form-label" htmlFor={id}>
+						{intl.formatMessage({ id: label })}
+					</label>
+					<CreatableSelect
+						name={field.name}
+						id={id}
+						closeMenuOnSelect={true}
+						isClearable={false}
+						isValidNewOption={isDomainValid}
+						isMulti
+						placeholder="Start typing to add domain..."
+						onChange={handleChange}
+						value={field.value?.map((d: string) => ({ label: d, value: d }))}
+					/>
+					{form.errors[field.name] ? (
+						<div className="invalid-feedback">
+							{form.errors[field.name] && form.touched[field.name] ? form.errors[field.name] : null}
+						</div>
+					) : helperTexts.length ? (
+						helperTexts.map((i) => (
+							<div key={i} className="invalid-feedback text-info">
+								{i}
+							</div>
+						))
+					) : null}
+				</div>
+			)}
+		</Field>
+	);
+}

+ 112 - 0
frontend/src/components/Form/SSLCertificateField.tsx

@@ -0,0 +1,112 @@
+import { IconShield } from "@tabler/icons-react";
+import { Field, useFormikContext } from "formik";
+import Select, { type ActionMeta, components, type OptionProps } from "react-select";
+import type { Certificate } from "src/api/backend";
+import { useCertificates } from "src/hooks";
+import { DateTimeFormat, intl } from "src/locale";
+
+interface CertOption {
+	readonly value: number | "new";
+	readonly label: string;
+	readonly subLabel: string;
+	readonly icon: React.ReactNode;
+}
+
+const Option = (props: OptionProps<CertOption>) => {
+	return (
+		<components.Option {...props}>
+			<div className="flex-fill">
+				<div className="font-weight-medium">
+					{props.data.icon} <strong>{props.data.label}</strong>
+				</div>
+				<div className="text-secondary mt-1 ps-3">{props.data.subLabel}</div>
+			</div>
+		</components.Option>
+	);
+};
+
+interface Props {
+	id?: string;
+	name?: string;
+	label?: string;
+	required?: boolean;
+	allowNew?: boolean;
+}
+export function SSLCertificateField({
+	name = "certificateId",
+	label = "ssl-certificate",
+	id = "certificateId",
+	required,
+	allowNew,
+}: Props) {
+	const { isLoading, isError, error, data } = useCertificates();
+
+	const { setFieldValue } = useFormikContext();
+
+	const handleChange = (v: any, _actionMeta: ActionMeta<CertOption>) => {
+		setFieldValue(name, v?.value);
+	};
+
+	const options: CertOption[] =
+		data?.map((cert: Certificate) => ({
+			value: cert.id,
+			label: cert.niceName,
+			subLabel: `${cert.provider === "letsencrypt" ? "Let's Encrypt" : cert.provider} &mdash; Expires: ${
+				cert.expiresOn ? DateTimeFormat(cert.expiresOn) : "N/A"
+			}`,
+			icon: <IconShield size={14} className="text-pink" />,
+		})) || [];
+
+	// Prepend the Add New option
+	if (allowNew) {
+		options?.unshift({
+			value: "new",
+			label: "Request a new HTTP certificate",
+			subLabel: "with Let's Encrypt",
+			icon: <IconShield size={14} className="text-lime" />,
+		});
+	}
+
+	// Prepend the None option
+	if (!required) {
+		options?.unshift({
+			value: 0,
+			label: "None",
+			subLabel: "This host will not use HTTPS",
+			icon: <IconShield size={14} className="text-red" />,
+		});
+	}
+
+	return (
+		<Field name={name}>
+			{({ field, form }: any) => (
+				<div className="mb-3">
+					<label className="form-label" htmlFor={id}>
+						{intl.formatMessage({ id: label })}
+					</label>
+					{isLoading ? <div className="placeholder placeholder-lg col-12 my-3 placeholder-glow" /> : null}
+					{isError ? <div className="invalid-feedback">{`${error}`}</div> : null}
+					{!isLoading && !isError ? (
+						<Select
+							defaultValue={options[0]}
+							options={options}
+							components={{ Option }}
+							styles={{
+								option: (base) => ({
+									...base,
+									height: "100%",
+								}),
+							}}
+							onChange={handleChange}
+						/>
+					) : null}
+					{form.errors[field.name] ? (
+						<div className="invalid-feedback">
+							{form.errors[field.name] && form.touched[field.name] ? form.errors[field.name] : null}
+						</div>
+					) : null}
+				</div>
+			)}
+		</Field>
+	);
+}

+ 2 - 0
frontend/src/components/Form/index.ts

@@ -0,0 +1,2 @@
+export * from "./DomainNamesField";
+export * from "./SSLCertificateField";

+ 2 - 4
frontend/src/components/Table/TableBody.tsx

@@ -7,11 +7,9 @@ function TableBody<T>(props: TableLayoutProps<T>) {
 	const rows = tableInstance.getRowModel().rows;
 
 	if (rows.length === 0) {
-		return emptyState ? (
-			emptyState
-		) : (
+		return (
 			<tbody className="table-tbody">
-				<EmptyRow tableInstance={tableInstance} />
+				{emptyState ? emptyState : <EmptyRow tableInstance={tableInstance} />}
 			</tbody>
 		);
 	}

+ 1 - 0
frontend/src/components/index.ts

@@ -1,6 +1,7 @@
 export * from "./Button";
 export * from "./ErrorNotFound";
 export * from "./Flag";
+export * from "./Form";
 export * from "./HasPermission";
 export * from "./Loading";
 export * from "./LoadingPage";

+ 1 - 1
frontend/src/context/AuthContext.tsx

@@ -30,7 +30,7 @@ function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props)
 	};
 
 	const login = async (identity: string, secret: string) => {
-		const response = await getToken({ payload: { identity, secret } });
+		const response = await getToken(identity, secret);
 		handleTokenUpdate(response);
 	};
 

+ 1 - 0
frontend/src/hooks/index.ts

@@ -2,6 +2,7 @@ export * from "./useAccessLists";
 export * from "./useAuditLog";
 export * from "./useAuditLogs";
 export * from "./useCertificates";
+export * from "./useDeadHost";
 export * from "./useDeadHosts";
 export * from "./useHealth";
 export * from "./useHostReport";

+ 57 - 0
frontend/src/hooks/useDeadHost.ts

@@ -0,0 +1,57 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { createDeadHost, type DeadHost, getDeadHost, updateDeadHost } from "src/api/backend";
+
+const fetchDeadHost = (id: number | "new") => {
+	if (id === "new") {
+		return Promise.resolve({
+			id: 0,
+			createdOn: "",
+			modifiedOn: "",
+			ownerUserId: 0,
+			domainNames: [],
+			certificateId: 0,
+			sslForced: false,
+			advancedConfig: "",
+			meta: {},
+			http2Support: false,
+			enabled: true,
+			hstsEnabled: false,
+			hstsSubdomains: false,
+		} as DeadHost);
+	}
+	return getDeadHost(id, ["owner"]);
+};
+
+const useDeadHost = (id: number | "new", options = {}) => {
+	return useQuery<DeadHost, Error>({
+		queryKey: ["dead-host", id],
+		queryFn: () => fetchDeadHost(id),
+		staleTime: 60 * 1000, // 1 minute
+		...options,
+	});
+};
+
+const useSetDeadHost = () => {
+	const queryClient = useQueryClient();
+	return useMutation({
+		mutationFn: (values: DeadHost) => (values.id ? updateDeadHost(values) : createDeadHost(values)),
+		onMutate: (values: DeadHost) => {
+			if (!values.id) {
+				return;
+			}
+			const previousObject = queryClient.getQueryData(["dead-host", values.id]);
+			queryClient.setQueryData(["dead-host", values.id], (old: DeadHost) => ({
+				...old,
+				...values,
+			}));
+			return () => queryClient.setQueryData(["dead-host", values.id], previousObject);
+		},
+		onError: (_, __, rollback: any) => rollback(),
+		onSuccess: async ({ id }: DeadHost) => {
+			queryClient.invalidateQueries({ queryKey: ["dead-host", id] });
+			queryClient.invalidateQueries({ queryKey: ["dead-hosts"] });
+		},
+	});
+};
+
+export { useDeadHost, useSetDeadHost };

+ 3 - 3
frontend/src/hooks/useDeadHosts.ts

@@ -1,11 +1,11 @@
 import { useQuery } from "@tanstack/react-query";
-import { type DeadHost, type DeadHostExpansion, getDeadHosts } from "src/api/backend";
+import { type DeadHost, getDeadHosts, type HostExpansion } from "src/api/backend";
 
-const fetchDeadHosts = (expand?: DeadHostExpansion[]) => {
+const fetchDeadHosts = (expand?: HostExpansion[]) => {
 	return getDeadHosts(expand);
 };
 
-const useDeadHosts = (expand?: DeadHostExpansion[], options = {}) => {
+const useDeadHosts = (expand?: HostExpansion[], options = {}) => {
 	return useQuery<DeadHost[], Error>({
 		queryKey: ["dead-hosts", { expand }],
 		queryFn: () => fetchDeadHosts(expand),

+ 3 - 3
frontend/src/hooks/useRedirectionHosts.ts

@@ -1,11 +1,11 @@
 import { useQuery } from "@tanstack/react-query";
-import { getRedirectionHosts, type RedirectionHost, type RedirectionHostExpansion } from "src/api/backend";
+import { getRedirectionHosts, type HostExpansion, type RedirectionHost } from "src/api/backend";
 
-const fetchRedirectionHosts = (expand?: RedirectionHostExpansion[]) => {
+const fetchRedirectionHosts = (expand?: HostExpansion[]) => {
 	return getRedirectionHosts(expand);
 };
 
-const useRedirectionHosts = (expand?: RedirectionHostExpansion[], options = {}) => {
+const useRedirectionHosts = (expand?: HostExpansion[], options = {}) => {
 	return useQuery<RedirectionHost[], Error>({
 		queryKey: ["redirection-hosts", { expand }],
 		queryFn: () => fetchRedirectionHosts(expand),

+ 3 - 3
frontend/src/hooks/useStreams.ts

@@ -1,11 +1,11 @@
 import { useQuery } from "@tanstack/react-query";
-import { getStreams, type Stream, type StreamExpansion } from "src/api/backend";
+import { getStreams, type HostExpansion, type Stream } from "src/api/backend";
 
-const fetchStreams = (expand?: StreamExpansion[]) => {
+const fetchStreams = (expand?: HostExpansion[]) => {
 	return getStreams(expand);
 };
 
-const useStreams = (expand?: StreamExpansion[], options = {}) => {
+const useStreams = (expand?: HostExpansion[], options = {}) => {
 	return useQuery<Stream[], Error>({
 		queryKey: ["streams", { expand }],
 		queryFn: () => fetchStreams(expand),

+ 1 - 1
frontend/src/hooks/useUser.ts

@@ -15,7 +15,7 @@ const fetchUser = (id: number | string) => {
 			avatar: "",
 		} as User);
 	}
-	return getUser(id, { expand: "permissions" });
+	return getUser(id, ["permissions"]);
 };
 
 const useUser = (id: string | number, options = {}) => {

+ 8 - 1
frontend/src/locale/lang/en.json

@@ -24,6 +24,7 @@
   "column.access": "Access",
   "column.authorization": "Authorization",
   "column.destination": "Destination",
+  "column.details": "Details",
   "column.email": "Email",
   "column.event": "Event",
   "column.expires": "Expires",
@@ -40,12 +41,15 @@
   "column.status": "Status",
   "created-on": "Created: {date}",
   "dashboard.title": "Dashboard",
+  "dead-host.edit": "Edit 404 Host",
+  "dead-host.new": "New 404 Host",
   "dead-hosts.actions-title": "404 Host #{id}",
   "dead-hosts.add": "Add 404 Host",
   "dead-hosts.count": "{count} 404 Hosts",
   "dead-hosts.empty": "There are no 404 Hosts",
   "dead-hosts.title": "404 Hosts",
   "disabled": "Disabled",
+  "domain-names": "Domain Names",
   "email-address": "Email address",
   "empty-subtitle": "Why don't you create one?",
   "error.invalid-auth": "Invalid email or password",
@@ -96,6 +100,7 @@
   "setup.preamble": "Get started by creating your admin account.",
   "setup.title": "Welcome!",
   "sign-in": "Sign in",
+  "ssl-certificate": "SSL Certificate",
   "streams.actions-title": "Stream #{id}",
   "streams.add": "Add Stream",
   "streams.count": "{count} Streams",
@@ -122,5 +127,7 @@
   "user.switch-light": "Switch to Light mode",
   "users.actions-title": "User #{id}",
   "users.add": "Add User",
-  "users.title": "Users"
+  "users.title": "Users",
+  "wildcards-not-permitted": "Wildcards not permitted for this type",
+  "wildcards-not-supported": "Wildcards not supported for this CA"
 }

+ 21 - 0
frontend/src/locale/src/en.json

@@ -77,6 +77,9 @@
 	"column.destination": {
 		"defaultMessage": "Destination"
 	},
+	"column.details": {
+		"defaultMessage": "Details"
+	},
 	"column.email": {
 		"defaultMessage": "Email"
 	},
@@ -131,15 +134,24 @@
 	"dead-hosts.count": {
 		"defaultMessage": "{count} 404 Hosts"
 	},
+	"dead-host.edit": {
+		"defaultMessage": "Edit 404 Host"
+	},
 	"dead-hosts.empty": {
 		"defaultMessage": "There are no 404 Hosts"
 	},
+	"dead-host.new": {
+		"defaultMessage": "New 404 Host"
+	},
 	"dead-hosts.title": {
 		"defaultMessage": "404 Hosts"
 	},
 	"disabled": {
 		"defaultMessage": "Disabled"
 	},
+	"domain-names": {
+		"defaultMessage": "Domain Names"
+	},
 	"email-address": {
 		"defaultMessage": "Email address"
 	},
@@ -290,6 +302,9 @@
 	"sign-in": {
 		"defaultMessage": "Sign in"
 	},
+	"ssl-certificate": {
+		"defaultMessage": "SSL Certificate"
+	},
 	"streams.actions-title": {
 		"defaultMessage": "Stream #{id}"
 	},
@@ -370,5 +385,11 @@
 	},
 	"users.title": {
 		"defaultMessage": "Users"
+	},
+	"wildcards-not-permitted": {
+		"defaultMessage": "Wildcards not permitted for this type"
+	},
+	"wildcards-not-supported": {
+		"defaultMessage": "Wildcards not supported for this CA"
 	}
 }

+ 285 - 0
frontend/src/modals/DeadHostModal.tsx

@@ -0,0 +1,285 @@
+import { IconSettings } from "@tabler/icons-react";
+import { Form, Formik } from "formik";
+import { useState } from "react";
+import { Alert } from "react-bootstrap";
+import Modal from "react-bootstrap/Modal";
+import { Button, DomainNamesField, Loading, SSLCertificateField } from "src/components";
+import { useDeadHost } from "src/hooks";
+import { intl } from "src/locale";
+
+interface Props {
+	id: number | "new";
+	onClose: () => void;
+}
+export function DeadHostModal({ id, onClose }: Props) {
+	const { data, isLoading, error } = useDeadHost(id);
+	// const { mutate: setDeadHost } = useSetDeadHost();
+	const [errorMsg, setErrorMsg] = useState<string | null>(null);
+
+	const onSubmit = async (values: any, { setSubmitting }: any) => {
+		setSubmitting(true);
+		setErrorMsg(null);
+		console.log("SUBMIT:", values);
+		setSubmitting(false);
+		// const { ...payload } = {
+		// 	id: id === "new" ? undefined : id,
+		// 	roles: [],
+		// 	...values,
+		// };
+
+		// setDeadHost(payload, {
+		// 	onError: (err: any) => setErrorMsg(err.message),
+		// 	onSuccess: () => {
+		// 		showSuccess(intl.formatMessage({ id: "notification.dead-host-saved" }));
+		// 		onClose();
+		// 	},
+		// 	onSettled: () => setSubmitting(false),
+		// });
+	};
+
+	return (
+		<Modal show onHide={onClose} animation={false}>
+			{!isLoading && error && (
+				<Alert variant="danger" className="m-3">
+					{error?.message || "Unknown error"}
+				</Alert>
+			)}
+			{isLoading && <Loading noLogo />}
+			{!isLoading && data && (
+				<Formik
+					initialValues={
+						{
+							domainNames: data?.domainNames,
+							certificateId: data?.certificateId,
+							sslForced: data?.sslForced,
+							advancedConfig: data?.advancedConfig,
+							http2Support: data?.http2Support,
+							hstsEnabled: data?.hstsEnabled,
+							hstsSubdomains: data?.hstsSubdomains,
+						} as any
+					}
+					onSubmit={onSubmit}
+				>
+					{({ isSubmitting }) => (
+						<Form>
+							<Modal.Header closeButton>
+								<Modal.Title>
+									{intl.formatMessage({ id: data?.id ? "dead-host.edit" : "dead-host.new" })}
+								</Modal.Title>
+							</Modal.Header>
+							<Modal.Body className="p-0">
+								<Alert variant="danger" show={!!errorMsg} onClose={() => setErrorMsg(null)} dismissible>
+									{errorMsg}
+								</Alert>
+
+								<div className="card m-0 border-0">
+									<div className="card-header">
+										<ul className="nav nav-tabs card-header-tabs" data-bs-toggle="tabs">
+											<li className="nav-item" role="presentation">
+												<a
+													href="#tab-details"
+													className="nav-link active"
+													data-bs-toggle="tab"
+													aria-selected="true"
+													role="tab"
+												>
+													{intl.formatMessage({ id: "column.details" })}
+												</a>
+											</li>
+											<li className="nav-item" role="presentation">
+												<a
+													href="#tab-ssl"
+													className="nav-link"
+													data-bs-toggle="tab"
+													aria-selected="false"
+													tabIndex={-1}
+													role="tab"
+												>
+													{intl.formatMessage({ id: "column.ssl" })}
+												</a>
+											</li>
+											<li className="nav-item ms-auto" role="presentation">
+												<a
+													href="#tab-advanced"
+													className="nav-link"
+													title="Settings"
+													data-bs-toggle="tab"
+													aria-selected="false"
+													tabIndex={-1}
+													role="tab"
+												>
+													<IconSettings size={20} />
+												</a>
+											</li>
+										</ul>
+									</div>
+									<div className="card-body">
+										<div className="tab-content">
+											<div className="tab-pane active show" id="tab-details" role="tabpanel">
+												<DomainNamesField isWildcardPermitted />
+											</div>
+											<div className="tab-pane" id="tab-ssl" role="tabpanel">
+												<SSLCertificateField
+													name="certificateId"
+													label="ssl-certificate"
+													allowNew
+												/>
+											</div>
+											<div className="tab-pane" id="tab-advanced" role="tabpanel">
+												<h4>Advanced</h4>
+											</div>
+										</div>
+									</div>
+								</div>
+
+								{/* <div className="row">
+									<div className="col-lg-6">
+										<div className="mb-3">
+											<Field name="name" validate={validateString(1, 50)}>
+												{({ field, form }: any) => (
+													<div className="form-floating mb-3">
+														<input
+															id="name"
+															className={`form-control ${form.errors.name && form.touched.name ? "is-invalid" : ""}`}
+															placeholder={intl.formatMessage({ id: "user.full-name" })}
+															{...field}
+														/>
+														<label htmlFor="name">
+															{intl.formatMessage({ id: "user.full-name" })}
+														</label>
+														{form.errors.name ? (
+															<div className="invalid-feedback">
+																{form.errors.name && form.touched.name
+																	? form.errors.name
+																	: null}
+															</div>
+														) : null}
+													</div>
+												)}
+											</Field>
+										</div>
+									</div>
+									<div className="col-lg-6">
+										<div className="mb-3">
+											<Field name="nickname" validate={validateString(1, 30)}>
+												{({ field, form }: any) => (
+													<div className="form-floating mb-3">
+														<input
+															id="nickname"
+															className={`form-control ${form.errors.nickname && form.touched.nickname ? "is-invalid" : ""}`}
+															placeholder={intl.formatMessage({ id: "user.nickname" })}
+															{...field}
+														/>
+														<label htmlFor="nickname">
+															{intl.formatMessage({ id: "user.nickname" })}
+														</label>
+														{form.errors.nickname ? (
+															<div className="invalid-feedback">
+																{form.errors.nickname && form.touched.nickname
+																	? form.errors.nickname
+																	: null}
+															</div>
+														) : null}
+													</div>
+												)}
+											</Field>
+										</div>
+									</div>
+								</div>
+								<div className="mb-3">
+									<Field name="email" validate={validateEmail()}>
+										{({ field, form }: any) => (
+											<div className="form-floating mb-3">
+												<input
+													id="email"
+													type="email"
+													className={`form-control ${form.errors.email && form.touched.email ? "is-invalid" : ""}`}
+													placeholder={intl.formatMessage({ id: "email-address" })}
+													{...field}
+												/>
+												<label htmlFor="email">
+													{intl.formatMessage({ id: "email-address" })}
+												</label>
+												{form.errors.email ? (
+													<div className="invalid-feedback">
+														{form.errors.email && form.touched.email
+															? form.errors.email
+															: null}
+													</div>
+												) : null}
+											</div>
+										)}
+									</Field>
+								</div>
+								{currentUser && data && currentUser?.id !== data?.id ? (
+									<div className="my-3">
+										<h3 className="py-2">{intl.formatMessage({ id: "user.flags.title" })}</h3>
+										<div className="divide-y">
+											<div>
+												<label className="row" htmlFor="isAdmin">
+													<span className="col">
+														{intl.formatMessage({ id: "role.admin" })}
+													</span>
+													<span className="col-auto">
+														<Field name="isAdmin" type="checkbox">
+															{({ field }: any) => (
+																<label className="form-check form-check-single form-switch">
+																	<input
+																		{...field}
+																		id="isAdmin"
+																		className="form-check-input"
+																		type="checkbox"
+																	/>
+																</label>
+															)}
+														</Field>
+													</span>
+												</label>
+											</div>
+											<div>
+												<label className="row" htmlFor="isDisabled">
+													<span className="col">
+														{intl.formatMessage({ id: "disabled" })}
+													</span>
+													<span className="col-auto">
+														<Field name="isDisabled" type="checkbox">
+															{({ field }: any) => (
+																<label className="form-check form-check-single form-switch">
+																	<input
+																		{...field}
+																		id="isDisabled"
+																		className="form-check-input"
+																		type="checkbox"
+																	/>
+																</label>
+															)}
+														</Field>
+													</span>
+												</label>
+											</div>
+										</div>
+									</div>
+								) : null} */}
+							</Modal.Body>
+							<Modal.Footer>
+								<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
+									{intl.formatMessage({ id: "cancel" })}
+								</Button>
+								<Button
+									type="submit"
+									actionType="primary"
+									className="ms-auto"
+									data-bs-dismiss="modal"
+									isLoading={isSubmitting}
+									disabled={isSubmitting}
+								>
+									{intl.formatMessage({ id: "save" })}
+								</Button>
+							</Modal.Footer>
+						</Form>
+					)}
+				</Formik>
+			)}
+		</Modal>
+	);
+}

+ 1 - 0
frontend/src/modals/index.ts

@@ -1,4 +1,5 @@
 export * from "./ChangePasswordModal";
+export * from "./DeadHostModal";
 export * from "./DeleteConfirmModal";
 export * from "./EventDetailsModal";
 export * from "./PermissionsModal";

+ 0 - 132
frontend/src/pages/Certificates/CertificateTable.tsx

@@ -1,132 +0,0 @@
-import { IconDotsVertical, IconEdit, IconPower, IconSearch, IconTrash } from "@tabler/icons-react";
-import { Button } from "src/components";
-import { intl } from "src/locale";
-
-export default function CertificateTable() {
-	return (
-		<div className="card mt-4">
-			<div className="card-status-top bg-pink" />
-			<div className="card-table">
-				<div className="card-header">
-					<div className="row w-full">
-						<div className="col">
-							<h2 className="mt-1 mb-0">{intl.formatMessage({ id: "certificates.title" })}</h2>
-						</div>
-						<div className="col-md-auto col-sm-12">
-							<div className="ms-auto d-flex flex-wrap btn-list">
-								<div className="input-group input-group-flat w-auto">
-									<span className="input-group-text input-group-text-sm">
-										<IconSearch size={16} />
-									</span>
-									<input
-										id="advanced-table-search"
-										type="text"
-										className="form-control form-control-sm"
-										autoComplete="off"
-									/>
-								</div>
-								<Button size="sm" className="btn-pink">
-									Add Certificate (dropdown)
-								</Button>
-							</div>
-						</div>
-					</div>
-				</div>
-				<div id="advanced-table">
-					<div className="table-responsive">
-						<table className="table table-vcenter table-selectable">
-							<thead>
-								<tr>
-									<th className="w-1" />
-									<th>
-										<button type="button" className="table-sort d-flex justify-content-between">
-											Source
-										</button>
-									</th>
-									<th>
-										<button type="button" className="table-sort d-flex justify-content-between">
-											Destination
-										</button>
-									</th>
-									<th>
-										<button type="button" className="table-sort d-flex justify-content-between">
-											SSL
-										</button>
-									</th>
-									<th>
-										<button type="button" className="table-sort d-flex justify-content-between">
-											Access
-										</button>
-									</th>
-									<th>
-										<button type="button" className="table-sort d-flex justify-content-between">
-											Status
-										</button>
-									</th>
-									<th className="w-1" />
-								</tr>
-							</thead>
-							<tbody className="table-tbody">
-								<tr>
-									<td data-label="Owner">
-										<div className="d-flex py-1 align-items-center">
-											<span
-												className="avatar avatar-2 me-2"
-												style={{
-													backgroundImage:
-														"url(//www.gravatar.com/avatar/6193176330f8d38747f038c170ddb193?default=mm)",
-												}}
-											/>
-										</div>
-									</td>
-									<td data-label="Destination">
-										<div className="flex-fill">
-											<div className="font-weight-medium">
-												<span className="badge badge-lg domain-name">blog.jc21.com</span>
-											</div>
-											<div className="text-secondary mt-1">Created: 20th September 2024</div>
-										</div>
-									</td>
-									<td data-label="Source">http://172.17.0.1:3001</td>
-									<td data-label="SSL">Let's Encrypt</td>
-									<td data-label="Access">Public</td>
-									<td data-label="Status">
-										<span className="badge bg-lime-lt">Online</span>
-									</td>
-									<td data-label="Status" className="text-end">
-										<span className="dropdown">
-											<button
-												type="button"
-												className="btn dropdown-toggle btn-action btn-sm px-1"
-												data-bs-boundary="viewport"
-												data-bs-toggle="dropdown"
-											>
-												<IconDotsVertical />
-											</button>
-											<div className="dropdown-menu dropdown-menu-end">
-												<span className="dropdown-header">Proxy Host #2</span>
-												<a className="dropdown-item" href="#">
-													<IconEdit size={16} />
-													Edit
-												</a>
-												<a className="dropdown-item" href="#">
-													<IconPower size={16} />
-													Disable
-												</a>
-												<div className="dropdown-divider" />
-												<a className="dropdown-item" href="#">
-													<IconTrash size={16} />
-													Delete
-												</a>
-											</div>
-										</span>
-									</td>
-								</tr>
-							</tbody>
-						</table>
-					</div>
-				</div>
-			</div>
-		</div>
-	);
-}

+ 5 - 2
frontend/src/pages/Nginx/DeadHosts/Empty.tsx

@@ -4,15 +4,18 @@ import { intl } from "src/locale";
 
 interface Props {
 	tableInstance: ReactTable<any>;
+	onNew?: () => void;
 }
-export default function Empty({ tableInstance }: Props) {
+export default function Empty({ tableInstance, onNew }: Props) {
 	return (
 		<tr>
 			<td colSpan={tableInstance.getVisibleFlatColumns().length}>
 				<div className="text-center my-4">
 					<h2>{intl.formatMessage({ id: "dead-hosts.empty" })}</h2>
 					<p className="text-muted">{intl.formatMessage({ id: "empty-subtitle" })}</p>
-					<Button className="btn-red my-3">{intl.formatMessage({ id: "dead-hosts.add" })}</Button>
+					<Button className="btn-red my-3" onClick={onNew}>
+						{intl.formatMessage({ id: "dead-hosts.add" })}
+					</Button>
 				</div>
 			</td>
 		</tr>

+ 15 - 4
frontend/src/pages/Nginx/DeadHosts/Table.tsx

@@ -10,8 +10,10 @@ import Empty from "./Empty";
 interface Props {
 	data: DeadHost[];
 	isFetching?: boolean;
+	onDelete?: (id: number) => void;
+	onNew?: () => void;
 }
-export default function Table({ data, isFetching }: Props) {
+export default function Table({ data, isFetching, onDelete, onNew }: Props) {
 	const columnHelper = createColumnHelper<DeadHost>();
 	const columns = useMemo(
 		() => [
@@ -78,7 +80,14 @@ export default function Table({ data, isFetching }: Props) {
 									{intl.formatMessage({ id: "action.disable" })}
 								</a>
 								<div className="dropdown-divider" />
-								<a className="dropdown-item" href="#">
+								<a
+									className="dropdown-item"
+									href="#"
+									onClick={(e) => {
+										e.preventDefault();
+										onDelete?.(info.row.original.id);
+									}}
+								>
 									<IconTrash size={16} />
 									{intl.formatMessage({ id: "action.delete" })}
 								</a>
@@ -91,7 +100,7 @@ export default function Table({ data, isFetching }: Props) {
 				},
 			}),
 		],
-		[columnHelper],
+		[columnHelper, onDelete],
 	);
 
 	const tableInstance = useReactTable<DeadHost>({
@@ -105,5 +114,7 @@ export default function Table({ data, isFetching }: Props) {
 		enableSortingRemoval: false,
 	});
 
-	return <TableLayout tableInstance={tableInstance} emptyState={<Empty tableInstance={tableInstance} />} />;
+	return (
+		<TableLayout tableInstance={tableInstance} emptyState={<Empty tableInstance={tableInstance} onNew={onNew} />} />
+	);
 }

+ 28 - 2
frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx

@@ -1,11 +1,16 @@
 import { IconSearch } from "@tabler/icons-react";
+import { useState } from "react";
 import Alert from "react-bootstrap/Alert";
 import { Button, LoadingPage } from "src/components";
 import { useDeadHosts } from "src/hooks";
 import { intl } from "src/locale";
+import { DeadHostModal, DeleteConfirmModal } from "src/modals";
+import { showSuccess } from "src/notifications";
 import Table from "./Table";
 
 export default function TableWrapper() {
+	const [deleteId, setDeleteId] = useState(0);
+	const [editId, setEditId] = useState(0 as number | "new");
 	const { isFetching, isLoading, isError, error, data } = useDeadHosts(["owner", "certificate"]);
 
 	if (isLoading) {
@@ -16,6 +21,11 @@ export default function TableWrapper() {
 		return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>;
 	}
 
+	const handleDelete = async () => {
+		// await deleteUser(deleteId);
+		showSuccess(intl.formatMessage({ id: "notification.host-deleted" }));
+	};
+
 	return (
 		<div className="card mt-4">
 			<div className="card-status-top bg-red" />
@@ -38,14 +48,30 @@ export default function TableWrapper() {
 										autoComplete="off"
 									/>
 								</div>
-								<Button size="sm" className="btn-red">
+								<Button size="sm" className="btn-red" onClick={() => setEditId("new")}>
 									{intl.formatMessage({ id: "dead-hosts.add" })}
 								</Button>
 							</div>
 						</div>
 					</div>
 				</div>
-				<Table data={data ?? []} isFetching={isFetching} />
+				<Table
+					data={data ?? []}
+					isFetching={isFetching}
+					onDelete={(id: number) => setDeleteId(id)}
+					onNew={() => setEditId("new")}
+				/>
+				{editId ? <DeadHostModal id={editId} onClose={() => setEditId(0)} /> : null}
+				{deleteId ? (
+					<DeleteConfirmModal
+						title={intl.formatMessage({ id: "user.delete.title" })}
+						onConfirm={handleDelete}
+						onClose={() => setDeleteId(0)}
+						invalidations={[["dead-hosts"], ["dead-host", deleteId]]}
+					>
+						{intl.formatMessage({ id: "user.delete.content" })}
+					</DeleteConfirmModal>
+				) : null}
 			</div>
 		</div>
 	);