From 6367d356970b81d45506eb4d78e14b32bdcac056 Mon Sep 17 00:00:00 2001 From: amphineko Date: Sun, 31 Mar 2024 19:14:26 +0100 Subject: [PATCH 1/3] re(web): refactor tables and migrate clients,mpsks to swr --- packages/web/app/clients/actions.ts | 8 +- packages/web/app/clients/page.tsx | 361 ++++++++++++-------------- packages/web/app/clients/queries.ts | 100 ++++++++ packages/web/app/mpsks/actions.ts | 8 +- packages/web/app/mpsks/page.tsx | 385 ++++++++++++---------------- packages/web/app/mpsks/queries.ts | 83 ++++++ packages/web/lib/forms/index.tsx | 46 ++++ packages/web/lib/upload/index.tsx | 79 +++--- 8 files changed, 599 insertions(+), 471 deletions(-) create mode 100644 packages/web/app/clients/queries.ts create mode 100644 packages/web/app/mpsks/queries.ts diff --git a/packages/web/app/clients/actions.ts b/packages/web/app/clients/actions.ts index 5b7a3fb..db19869 100644 --- a/packages/web/app/clients/actions.ts +++ b/packages/web/app/clients/actions.ts @@ -10,18 +10,18 @@ import * as t from "io-ts" import { deleteEndpoint, getTypedEndpoint, postTypedEndpoint } from "../../lib/actions" -export async function bulkCreateOrUpdate(clients: readonly Client[]): Promise { +export async function bulkCreateOrUpdateClient(clients: readonly Client[]): Promise { await postTypedEndpoint(t.any, BulkCreateOrUpdateClientsRequestType, "api/v1/clients", clients) } -export async function createOrUpdateByName(name: string, client: Client): Promise { +export async function createOrUpdateClientByName(name: string, client: Client): Promise { await postTypedEndpoint(t.any, CreateOrUpdateClientRequestType, `api/v1/clients/${name}`, client) } -export async function deleteByName(name: string): Promise { +export async function deleteClientByName(name: string): Promise { await deleteEndpoint(`api/v1/clients/${name}`) } -export async function getAllClients(): Promise { +export async function listClients(): Promise { return await getTypedEndpoint(ListClientsResponseType, "api/v1/clients") } diff --git a/packages/web/app/clients/page.tsx b/packages/web/app/clients/page.tsx index 588e39f..e7ab2a1 100644 --- a/packages/web/app/clients/page.tsx +++ b/packages/web/app/clients/page.tsx @@ -1,7 +1,8 @@ "use client" -import { Add, Delete, Save } from "@mui/icons-material" +import { Add, Delete, Download, Save, Upload } from "@mui/icons-material" import { + Button, IconButton, Table, TableBody, @@ -10,237 +11,198 @@ import { TableFooter, TableHead, TableRow, + TextField, } from "@mui/material" import { ListClientsResponseType } from "@yonagi/common/api/clients" -import { Client, ClientType } from "@yonagi/common/types/Client" +import { Client } from "@yonagi/common/types/Client" +import { IpNetwork } from "@yonagi/common/types/IpNetwork" import { IpNetworkFromStringType } from "@yonagi/common/types/IpNetworkFromString" import { Name, NameType } from "@yonagi/common/types/Name" -import { SecretType } from "@yonagi/common/types/Secret" -import { getOrThrow } from "@yonagi/common/utils/TaskEither" +import { Secret, SecretType } from "@yonagi/common/types/Secret" import * as E from "fp-ts/lib/Either" -import * as TE from "fp-ts/lib/TaskEither" import * as F from "fp-ts/lib/function" import * as t from "io-ts/lib/index" -import { useMemo, useState } from "react" -import { useMutation, useQuery } from "react-query" +import React, { useEffect, useMemo, useState } from "react" -import { bulkCreateOrUpdate, createOrUpdateByName, deleteByName, getAllClients } from "./actions" -import { useQueryHelpers } from "../../lib/client" -import { mapLeftValidationError } from "../../lib/fp" +import { useCreateClient, useDeleteClient, useImportClients, useRadiusClients, useUpdateClient } from "./queries" +import { useNonce } from "../../lib/client" +import { CodecTextField } from "../../lib/forms" import { useNotifications } from "../../lib/notifications" -import { ValidatedTableCell } from "../../lib/tables" -import { ExportButton, ImportButton } from "../../lib/upload" +import { useExportDownload, useImportUpload } from "../../lib/upload" -const CLIENT_QUERY_KEY = ["clients"] - -function useBulkCreateOrUpdate() { - const { invalidate } = useQueryHelpers(CLIENT_QUERY_KEY) - const { notifyError, notifySuccess } = useNotifications() - return useMutation({ - mutationFn: async (clients: readonly Client[]) => { - await bulkCreateOrUpdate(clients) - }, - mutationKey: ["clients", "bulk-update"], - onError: (error) => { - notifyError(`Failed importing clients`, String(error)) - }, - onSuccess: (_, clients) => { - notifySuccess(`Imported ${clients.length} clients`) - }, - onSettled: invalidate, - }) -} - -function useCreateOrUpdateClient({ name, onSuccess }: { name: string; onSuccess: () => void }) { - const { invalidate } = useQueryHelpers(CLIENT_QUERY_KEY) - const { notifyError, notifySuccess } = useNotifications() - return useMutation({ - mutationFn: async (validation: t.Validation) => { - await F.pipe( - validation, - mapLeftValidationError((error) => new Error(`Cannot validate input: ${error}`)), - TE.fromEither, - TE.flatMap((client) => - TE.tryCatch( - () => createOrUpdateByName(name, client), - (error) => new Error(String(error)), - ), - ), - getOrThrow(), - )() - }, - mutationKey: ["clients", "update", name], - onError: (error) => { - notifyError(`Failed updating client ${name}`, String(error)) - }, - onSuccess: () => { - notifySuccess(`Updated client ${name}`) - setTimeout(onSuccess, 0) - }, - onSettled: invalidate, - }) -} - -function useDeleteClient(name: string) { - const { invalidate } = useQueryHelpers(CLIENT_QUERY_KEY) - const { notifyError, notifySuccess } = useNotifications() - return useMutation({ - mutationFn: (name) => deleteByName(name), - mutationKey: ["clients", "delete", name], - onError: (error, name) => { - notifyError(`Failed deleting client ${name}`, String(error)) - }, - onSuccess: (_, name) => { - notifySuccess(`Deleted client ${name}`, "") - }, - onSettled: invalidate, - }) -} - -function useListClients() { - const { invalidate } = useQueryHelpers(CLIENT_QUERY_KEY) - const { notifyError } = useNotifications() - return useQuery({ - queryFn: async (): Promise => await getAllClients(), - queryKey: CLIENT_QUERY_KEY, - onError: (error) => { - notifyError("Failed reading clients", String(error)) - }, - onSettled: () => { - void invalidate() - }, - }) +interface ClientTableRowValidations { + name: t.Validation + ipaddr: t.Validation + secret: t.Validation } function ClientTableRow({ - isCreateOrUpdate, - name: serverName, - serverValue, + actions, + serverValues, + onChange, + onKeyDown, }: { - isCreateOrUpdate: "create" | "update" - key: string - name?: Name - serverValue: Partial + actions: React.ReactNode + key?: React.Key + serverValues?: Client + onChange: (validations: ClientTableRowValidations) => void + onKeyDown?: (event: React.KeyboardEvent) => void }): JSX.Element { - const [name, setName] = useState(serverName ?? "") - const isNameModified = useMemo(() => name !== (serverName ?? ""), [serverName, name]) - - const [ipaddr, setIpaddr] = useState( - serverValue.ipaddr ? IpNetworkFromStringType.encode(serverValue.ipaddr) : "", - ) - const isIpaddrModified = useMemo( - () => - ipaddr !== - (serverValue.ipaddr - ? // check against initial value if present (for updating) - IpNetworkFromStringType.encode(serverValue.ipaddr) - : // otherwise check against empty string (for creating) - ""), - [serverValue.ipaddr, ipaddr], + /** + * states of **latest validations** of each field + * new values are updated to the input fields, and only their validations will be propagated here + */ + const [name, setName] = useState(serverValues ? t.success(serverValues.name) : NameType.decode("")) + const [ipaddr, setIpaddr] = useState( + serverValues ? t.success(serverValues.ipaddr) : IpNetworkFromStringType.decode(""), ) + const [secret, setSecret] = useState(serverValues ? t.success(serverValues.secret) : SecretType.decode("")) - const [secret, setSecret] = useState(serverValue.secret ?? "") - const isSecretModified = useMemo(() => secret !== (serverValue.secret ?? ""), [serverValue.secret, secret]) + const textFieldProps: React.ComponentProps = { + variant: "standard", + } - const formValidation = useMemo>( - () => - F.pipe( - IpNetworkFromStringType.decode(ipaddr), - E.flatMap((ipaddr) => ClientType.decode({ name, ipaddr, secret })), - ), - [ipaddr, name, secret], + useEffect(() => { + // propagates the latest validations to the parent component + onChange({ name, ipaddr, secret }) + }, [name, ipaddr, secret, onChange]) + + return ( + + + + + + + + + + + {actions} + ) +} - const { mutate: submitUpdate } = useCreateOrUpdateClient({ - name, - onSuccess: () => { - if (isCreateOrUpdate === "create") { - setName(serverName ?? "") - setIpaddr("") - setSecret("") - } - }, +function useClientValidatedSubmit(submit: (client: Client) => void) { + const [validations, setValidations] = useState(null) + + const handleSubmit = () => { + if (validations === null) { + return + } + + F.pipe( + E.Do, + E.bind("name", () => validations.name), + E.bind("ipaddr", () => validations.ipaddr), + E.bind("secret", () => validations.secret), + E.map((client) => { + submit(client) + }), + ) + } + + return { handleSubmit, setValidations } +} + +function CreateClientTableRow(): JSX.Element { + const { trigger: createClient } = useCreateClient() + const { handleSubmit, setValidations } = useClientValidatedSubmit((client) => { + void createClient(client) }) - const { mutate: submitDelete } = useDeleteClient(name) return ( - { - if (event.key === "Enter") submitUpdate(formValidation) - }} - > - NameType.decode(value)} - value={name} - /> - IpNetworkFromStringType.decode(value)} - value={ipaddr} - /> - SecretType.decode(value)} - value={secret} - /> - - {isCreateOrUpdate === "create" && ( - - { - submitUpdate(formValidation) - }} - > + + - - )} + + } + onChange={setValidations} + onKeyDown={(e) => { + e.key === "Enter" && handleSubmit() + }} + /> + ) +} - {isCreateOrUpdate === "update" && ( - - { - submitUpdate(formValidation) - }} - > +function UpdateClientTableRow({ client }: { client: Client; key: React.Key }): JSX.Element { + const { trigger: updateClient } = useUpdateClient() + const { trigger: deleteClient } = useDeleteClient() + const { handleSubmit, setValidations } = useClientValidatedSubmit((client) => { + void updateClient(client) + }) + + return ( + + { - submitDelete(name) + void deleteClient(client.name) }} > - - )} - + + } + onChange={setValidations} + onKeyDown={(e) => { + e.key === "Enter" && handleSubmit() + }} + serverValues={client} + /> ) } function ClientTable(): JSX.Element { - const { data: clients } = useListClients() - const { mutate: importClients } = useBulkCreateOrUpdate() + const { data: clients } = useRadiusClients() + const { trigger: importClients } = useImportClients() + + const { notifyError } = useNotifications() + + const { download } = useExportDownload("clients.json", ListClientsResponseType) + const { upload } = useImportUpload( + ListClientsResponseType, + (clients) => void importClients(clients), + (error) => { + notifyError(`Failed on upload`, String(error)) + }, + ) const tableItems = useMemo(() => { if (clients === undefined) { return [] } - return clients.map((client) => ( - - )) + return clients.map((client) => ) }, [clients]) + const { nonce: createRowKey, increaseNonce: increaseCreateRowKey } = useNonce() + useEffect(() => { + increaseCreateRowKey() + }, [clients, increaseCreateRowKey]) + return ( @@ -254,22 +216,27 @@ function ClientTable(): JSX.Element { {tableItems} - + - - { - importClients(clients) + + diff --git a/packages/web/app/clients/queries.ts b/packages/web/app/clients/queries.ts new file mode 100644 index 0000000..78c6d83 --- /dev/null +++ b/packages/web/app/clients/queries.ts @@ -0,0 +1,100 @@ +import { Client } from "@yonagi/common/types/Client" +import useSWR from "swr" +import useSWRMutation from "swr/mutation" + +import { bulkCreateOrUpdateClient, createOrUpdateClientByName, deleteClientByName, listClients } from "./actions" +import { useNotifications } from "../../lib/notifications" + +const RADIUS_CLIENTS_KEY = ["clients"] + +export function useRadiusClients() { + const { notifyError } = useNotifications() + + return useSWR( + RADIUS_CLIENTS_KEY, + async () => { + return await listClients() + }, + { + onError: (error) => { + notifyError(`Cannot list all clients`, String(error)) + }, + }, + ) +} + +export function useCreateClient() { + const { notifyError, notifySuccess } = useNotifications() + return useSWRMutation( + RADIUS_CLIENTS_KEY, + async (_, { arg: client }: { arg: Client }): Promise => { + await createOrUpdateClientByName(client.name, client) + return client.name + }, + { + onError: (error) => { + notifyError(`Cannot create client`, String(error)) + }, + onSuccess: (name) => { + notifySuccess(`Client "${name}" created`) + }, + }, + ) +} + +export function useUpdateClient() { + const { notifyError, notifySuccess } = useNotifications() + return useSWRMutation( + RADIUS_CLIENTS_KEY, + async (_, { arg: client }: { arg: Client }): Promise => { + await createOrUpdateClientByName(client.name, client) + return client + }, + { + onError: (error) => { + notifyError(`Cannot update client`, String(error)) + }, + onSuccess: ({ name }) => { + notifySuccess(`Client "${name}" updated`) + }, + }, + ) +} + +export function useDeleteClient() { + const { notifyError, notifySuccess } = useNotifications() + return useSWRMutation( + RADIUS_CLIENTS_KEY, + async (_, { arg: name }: { arg: string }): Promise => { + await deleteClientByName(name) + return name + }, + { + onError: (error) => { + notifyError(`Cannot delete client`, String(error)) + }, + onSuccess: (name) => { + notifySuccess(`Client "${name}" deleted`) + }, + }, + ) +} + +export function useImportClients() { + const { notifyError, notifySuccess } = useNotifications() + return useSWRMutation( + RADIUS_CLIENTS_KEY, + async (_, { arg: clients }: { arg: readonly Client[] }): Promise => { + await bulkCreateOrUpdateClient(clients) + return clients.length + }, + { + onError: (error) => { + notifyError(`Cannot import clients`, String(error)) + }, + onSuccess: (count) => { + notifySuccess(`Imported ${count} clients`) + }, + }, + ) +} diff --git a/packages/web/app/mpsks/actions.ts b/packages/web/app/mpsks/actions.ts index 3b1a8c0..eab7044 100644 --- a/packages/web/app/mpsks/actions.ts +++ b/packages/web/app/mpsks/actions.ts @@ -10,18 +10,18 @@ import * as t from "io-ts" import { deleteEndpoint, getTypedEndpoint, postTypedEndpoint } from "../../lib/actions" -export async function bulkCreateOrUpdate(mpsks: readonly CallingStationIdAuthentication[]): Promise { +export async function bulkCreateOrUpdateMpsks(mpsks: readonly CallingStationIdAuthentication[]): Promise { await postTypedEndpoint(t.any, BulkCreateOrUpdateMPSKsRequestType, "api/v1/mpsks", mpsks) } -export async function createOrUpdateByName(name: string, mpsk: CallingStationIdAuthentication): Promise { +export async function createOrUpdateMpskByName(name: string, mpsk: CallingStationIdAuthentication): Promise { await postTypedEndpoint(t.any, CreateOrUpdateMPSKRequestType, `api/v1/mpsks/${name}`, mpsk) } -export async function deleteByName(name: string): Promise { +export async function deleteMpskByName(name: string): Promise { await deleteEndpoint(`api/v1/mpsks/${name}`) } -export async function getAllMpsks(): Promise { +export async function listMpsks(): Promise { return await getTypedEndpoint(ListMPSKsResponseType, "api/v1/mpsks") } diff --git a/packages/web/app/mpsks/page.tsx b/packages/web/app/mpsks/page.tsx index 6a5f27a..31dcfce 100644 --- a/packages/web/app/mpsks/page.tsx +++ b/packages/web/app/mpsks/page.tsx @@ -1,7 +1,8 @@ "use client" -import { Add, Delete, Save } from "@mui/icons-material" +import { Add, Delete, Download, Save, Upload } from "@mui/icons-material" import { + Button, IconButton, LinearProgress, Table, @@ -11,254 +12,197 @@ import { TableFooter, TableHead, TableRow, - Tooltip, + TextField, } from "@mui/material" -import { BulkCreateOrUpdateMPSKsRequestType, ListMPSKsResponseType } from "@yonagi/common/api/mpsks" +import { ListMPSKsResponseType } from "@yonagi/common/api/mpsks" import { Name, NameType } from "@yonagi/common/types/Name" -import { CallingStationIdType } from "@yonagi/common/types/mpsks/CallingStationId" -import { CallingStationIdAuthentication, MPSKType } from "@yonagi/common/types/mpsks/MPSK" -import { PSKType } from "@yonagi/common/types/mpsks/PSK" -import { getOrThrow } from "@yonagi/common/utils/TaskEither" +import { CallingStationId, CallingStationIdType } from "@yonagi/common/types/mpsks/CallingStationId" +import { CallingStationIdAuthentication } from "@yonagi/common/types/mpsks/MPSK" +import { PSK, PSKType } from "@yonagi/common/types/mpsks/PSK" import * as E from "fp-ts/lib/Either" -import * as TE from "fp-ts/lib/TaskEither" import * as F from "fp-ts/lib/function" -import * as PR from "io-ts/lib/PathReporter" import * as t from "io-ts/lib/index" -import { useEffect, useMemo, useState } from "react" -import { useMutation, useQuery } from "react-query" +import { ComponentProps, Key, useEffect, useMemo, useState } from "react" -import { bulkCreateOrUpdate, createOrUpdateByName, deleteByName, getAllMpsks } from "./actions" -import { useQueryHelpers } from "../../lib/client" -import { mapLeftValidationError } from "../../lib/fp" +import { useDeleteMpsk, useImportMpsks, useMpsks, useUpdateMpsk } from "./queries" +import { useNonce } from "../../lib/client" +import { CodecTextField } from "../../lib/forms" import { useNotifications } from "../../lib/notifications" -import { ValidatedTableCell } from "../../lib/tables" -import { ExportButton, ImportButton } from "../../lib/upload" +import { useExportDownload, useImportUpload } from "../../lib/upload" -const MPSK_QUERY_KEY = ["mpsks"] - -function useBulkCreateOrUpdateMpsks() { - const { invalidate } = useQueryHelpers(MPSK_QUERY_KEY) - const { notifyError, notifySuccess } = useNotifications() - return useMutation({ - mutationFn: async (mpsks: readonly CallingStationIdAuthentication[]) => { - await bulkCreateOrUpdate(mpsks) - }, - mutationKey: ["mpsks", "bulk-update"], - onError: (error) => { - notifyError(`Failed importing MPSKs`, String(error)) - }, - onSuccess: (_, mpsks) => { - notifySuccess(`Imported ${mpsks.length} MPSKs`) - }, - onSettled: invalidate, - }) -} - -function useDeleteMpsk(name: string) { - const { notifyError, notifySuccess } = useNotifications() - const { invalidate } = useQueryHelpers(MPSK_QUERY_KEY) - return useMutation({ - mutationFn: async () => { - await deleteByName(name) - }, - mutationKey: ["mpsks", "delete", name], - onError: (error) => { - notifyError(`Failed deleting ${name}`, String(error)) - }, - onSuccess: () => { - notifySuccess(`Deleted ${name}`) - }, - onSettled: () => { - void invalidate() - }, - }) -} - -function useUpdateMpsk({ name, onSuccess }: { name: string; onSuccess: () => void }) { - const { notifyError, notifySuccess } = useNotifications() - const { invalidate } = useQueryHelpers(MPSK_QUERY_KEY) - return useMutation({ - mutationFn: async (validation: t.Validation) => { - await F.pipe( - validation, - mapLeftValidationError((error) => new Error(`Cannot validate input: ${error}`)), - TE.fromEither, - TE.flatMap((mpsk) => TE.tryCatch(() => createOrUpdateByName(name, mpsk), E.toError)), - getOrThrow(), - )() - }, - mutationKey: ["mpsks", "update", name], - onError: (error) => { - notifyError(`Failed updating ${name}`, String(error)) - }, - onSuccess: () => { - notifySuccess(`Updated ${name}`) - setTimeout(onSuccess, 1000) - }, - onSettled: () => { - void invalidate() - }, - }) -} - -function useListMpsks() { - const { notifyError } = useNotifications() - const { invalidate } = useQueryHelpers(MPSK_QUERY_KEY) - return useQuery({ - queryFn: async () => await getAllMpsks(), - queryKey: MPSK_QUERY_KEY, - onError: (error) => { - notifyError(`Failed listing MPSKs`, String(error)) - }, - onSettled: () => { - void invalidate() - }, - }) +interface MpskTableRowValidations { + name: t.Validation + callingStationId: t.Validation + psk: t.Validation } function MpskTableRow({ - isCreateOrUpdate, - name: serverName, - serverValue, + actions, + serverValues, + onChange, + onKeyDown, }: { - isCreateOrUpdate: "create" | "update" - name?: Name - serverValue: Partial + actions: React.ReactNode + key?: React.Key + serverValues?: CallingStationIdAuthentication + onChange: (validations: MpskTableRowValidations) => void + onKeyDown?: (event: React.KeyboardEvent) => void }): JSX.Element { - const [name, setName] = useState(serverName ?? "") - const isNameModified = useMemo(() => name !== (serverName ?? ""), [serverName, name]) - - const [callingStationId, setCallingStationId] = useState(serverValue.callingStationId ?? "") - const isCallingStationIdModified = useMemo( - () => callingStationId !== (serverValue.callingStationId ?? ""), - [serverValue.callingStationId, callingStationId], + /** + * states of **latest validations** of each field + * new values are updated to the input fields, and only their validations will be propagated here + */ + const [name, setName] = useState(serverValues ? t.success(serverValues.name) : NameType.decode("")) + const [callingStationId, setCallingStationId] = useState( + serverValues ? t.success(serverValues.callingStationId) : CallingStationIdType.decode(""), ) + const [psk, setPsk] = useState(serverValues ? t.success(serverValues.psk) : PSKType.decode("")) - const [psk, setPsk] = useState(serverValue.psk ?? "") - const isPskModified = useMemo(() => psk !== (serverValue.psk ?? ""), [serverValue.psk, psk]) + const textFieldProps: ComponentProps = { + variant: "standard", + } - const formValidation = useMemo>( - () => MPSKType.decode({ callingStationId, name, psk }), - [name, callingStationId, psk], - ) - const formError = useMemo( - () => - F.pipe( - formValidation, - E.mapLeft((errors) => PR.failure(errors).join("\n")), - E.fold( - (error) => error, - () => "", - ), - ), - [formValidation], + useEffect(() => { + // propagates the latest validations to the parent component + onChange({ name, callingStationId, psk }) + }, [name, callingStationId, psk, onChange]) + + return ( + + + + + + + + + + + {actions} + ) +} - const { mutate: submitUpdate, isLoading: isUpdating } = useUpdateMpsk({ - name, - onSuccess: () => { - if (isCreateOrUpdate === "create") { - setName(serverName ?? "") - setCallingStationId(serverValue.callingStationId ?? "") - setPsk(serverValue.psk ?? "") - } - }, - }) +function useMpskValidatedSubmit(submit: (mpsk: CallingStationIdAuthentication) => void) { + const [validations, setValidations] = useState(null) - const { mutate: submitDelete } = useDeleteMpsk(name) + const handleSubmit = () => { + if (validations === null) { + return + } - useEffect(() => { - setName(serverName ?? "") - setCallingStationId(serverValue.callingStationId ?? "") - setPsk(serverValue.psk ?? "") - }, [serverName, serverValue.callingStationId, serverValue.psk]) + F.pipe( + E.Do, + E.bind("name", () => validations.name), + E.bind("callingStationId", () => validations.callingStationId), + E.bind("psk", () => validations.psk), + E.map((mpsk) => { + submit(mpsk) + }), + ) + } + + return { handleSubmit, setValidations } +} + +function CreateMpskTableRow() { + const { trigger: create } = useUpdateMpsk() + const { handleSubmit, setValidations } = useMpskValidatedSubmit((mpsk) => { + void create(mpsk) + }) return ( - { - if (event.key === "Enter") submitUpdate(formValidation) + + + + } + onChange={setValidations} + onKeyDown={(e) => { + e.key === "Enter" && handleSubmit() }} - > - NameType.decode(value)} - value={name} - /> - CallingStationIdType.decode(value)} - value={callingStationId} - /> - PSKType.decode(value)} - value={psk} - /> + /> + ) +} - {isCreateOrUpdate === "create" && ( - - - - { - submitUpdate(formValidation) - }} - > - - - - - - )} +function UpdateMpskTableRow({ mpsk }: { mpsk: CallingStationIdAuthentication; key: Key }) { + const { trigger: updateMpsk } = useUpdateMpsk() + const { trigger: deleteMpsk } = useDeleteMpsk() + const { handleSubmit, setValidations } = useMpskValidatedSubmit((mpsk) => { + void updateMpsk(mpsk) + }) - {isCreateOrUpdate === "update" && ( - - - - { - submitUpdate(formValidation) - }} - > - - - - + return ( + + + + { - submitDelete() + void deleteMpsk(mpsk.name) }} > - - )} - + + } + onChange={setValidations} + onKeyDown={(e) => { + e.key === "Enter" && handleSubmit() + }} + serverValues={mpsk} + /> ) } function MpskTable(): JSX.Element { - const { data: mpsks, isLoading } = useListMpsks() - const { mutate: importMpsks } = useBulkCreateOrUpdateMpsks() + const { data: mpsks, isLoading } = useMpsks() + const { trigger: importMpsks } = useImportMpsks() + + const { notifyError } = useNotifications() + + const { download } = useExportDownload("mpsks.json", ListMPSKsResponseType) + const { upload } = useImportUpload( + ListMPSKsResponseType, + (mpsks) => { + void importMpsks(mpsks) + }, + (error) => { + notifyError(`Error during uploading MPSKs`, String(error)) + }, + ) const tableItems = useMemo(() => { if (mpsks === undefined) { return [] } - return mpsks.map((mpsk) => ( - - )) + return mpsks.map((mpsk) => ) }, [mpsks]) + const { nonce: createRowKey, increaseNonce: increaseCreateRowKey } = useNonce() + useEffect(() => { + increaseCreateRowKey() + }, [mpsks, increaseCreateRowKey]) + return ( <> {isLoading && } @@ -274,22 +218,27 @@ function MpskTable(): JSX.Element { {tableItems} - + - - { - importMpsks(mpsks) + + diff --git a/packages/web/app/mpsks/queries.ts b/packages/web/app/mpsks/queries.ts new file mode 100644 index 0000000..c976fa1 --- /dev/null +++ b/packages/web/app/mpsks/queries.ts @@ -0,0 +1,83 @@ +import { CallingStationIdAuthentication } from "@yonagi/common/types/mpsks/MPSK" +import useSWR from "swr" +import useSWRMutation from "swr/mutation" + +import { bulkCreateOrUpdateMpsks, createOrUpdateMpskByName, deleteMpskByName, listMpsks } from "./actions" +import { useNotifications } from "../../lib/notifications" + +const MPSKS_KEY = ["mpsks"] + +export function useMpsks() { + const { notifyError } = useNotifications() + + return useSWR( + MPSKS_KEY, + async () => { + return await listMpsks() + }, + { + onError: (error) => { + notifyError(`Cannot list all MPSKs`, String(error)) + }, + }, + ) +} + +export function useUpdateMpsk() { + const { notifyError, notifySuccess } = useNotifications() + + return useSWRMutation( + MPSKS_KEY, + async (_, { arg: mpsk }: { arg: CallingStationIdAuthentication }): Promise => { + await createOrUpdateMpskByName(mpsk.name, mpsk) + return mpsk + }, + { + onError: (error) => { + notifyError(`Cannot update MPSK`, String(error)) + }, + onSuccess: ({ name }) => { + notifySuccess(`MPSK "${name}" updated`) + }, + }, + ) +} + +export function useDeleteMpsk() { + const { notifyError, notifySuccess } = useNotifications() + + return useSWRMutation( + MPSKS_KEY, + async (_, { arg: name }: { arg: string }): Promise => { + await deleteMpskByName(name) + return name + }, + { + onError: (error) => { + notifyError(`Cannot delete MPSK`, String(error)) + }, + onSuccess: (name) => { + notifySuccess(`MPSK "${name}" deleted`) + }, + }, + ) +} + +export function useImportMpsks() { + const { notifyError, notifySuccess } = useNotifications() + + return useSWRMutation( + MPSKS_KEY, + async (_, { arg: mpsks }: { arg: readonly CallingStationIdAuthentication[] }): Promise => { + await bulkCreateOrUpdateMpsks(mpsks) + }, + { + onError: (error) => { + notifyError(`Cannot import MPSKs`, String(error)) + }, + onSuccess: () => { + notifySuccess(`MPSKs imported`) + }, + }, + ) +} diff --git a/packages/web/lib/forms/index.tsx b/packages/web/lib/forms/index.tsx index 900d856..16fa915 100644 --- a/packages/web/lib/forms/index.tsx +++ b/packages/web/lib/forms/index.tsx @@ -74,3 +74,49 @@ export function ValidatedForm({ F.pipe(decoder.decode(fields), E.map(submit)) }) } + +export function CodecTextField({ + codec, + focusOnModified, + initialValue, + label, + onChange, + textFieldProps, +}: { + codec: t.Decoder & t.Encoder + focusOnModified?: boolean + initialValue: IO + label?: string + onChange: (validation: t.Validation) => void + textFieldProps?: React.ComponentProps +}): JSX.Element { + const [value, setValue] = useState(initialValue) + const [error, setError] = useState(null) + + const handleChange = (newValue: IO) => { + setValue(newValue) + + const validation = codec.decode(newValue) + if (E.isLeft(validation)) { + setError(PR.failure(validation.left).join("/")) + } else { + setError(null) + } + + onChange(validation) + } + + return ( + { + handleChange(e.currentTarget.value as IO) + }} + /> + ) +} diff --git a/packages/web/lib/upload/index.tsx b/packages/web/lib/upload/index.tsx index f43a955..9b8cc78 100644 --- a/packages/web/lib/upload/index.tsx +++ b/packages/web/lib/upload/index.tsx @@ -1,7 +1,5 @@ "use client" -import { Download, Upload } from "@mui/icons-material" -import { Button } from "@mui/material" import { getOrThrow } from "@yonagi/common/utils/TaskEither" import * as E from "fp-ts/lib/Either" import * as TE from "fp-ts/lib/TaskEither" @@ -10,44 +8,33 @@ import * as t from "io-ts" import * as PR from "io-ts/lib/PathReporter" import { useCallback } from "react" -export function ExportButton({ - data, - encoder, - filename, -}: { - data: A - encoder: t.Encoder - filename: string -}) { - const onClick = useCallback(() => { - F.pipe( - encoder.encode(data), - (encoded) => JSON.stringify(encoded), - (json) => { - const a = document.createElement("a") - a.href = URL.createObjectURL(new Blob([json], { type: "application/json" })) - a.download = filename - a.click() - URL.revokeObjectURL(a.href) - }, - ) - }, [data, encoder, filename]) - - return ( - +export function useExportDownload(filename: string, encoder: t.Encoder) { + const download = useCallback( + (data: A) => { + F.pipe( + encoder.encode(data), + (encoded) => JSON.stringify(encoded), + (json) => { + const a = document.createElement("a") + a.href = URL.createObjectURL(new Blob([json], { type: "application/json" })) + a.download = filename + a.click() + URL.revokeObjectURL(a.href) + }, + ) + }, + [encoder, filename], ) + + return { download } } -export function ImportButton({ - decoder, - onImport, -}: { - decoder: t.Decoder - onImport: (data: A) => void -}) { - const onClick = useCallback(() => { +export function useImportUpload( + decoder: t.Decoder, + onImport: (data: A) => void, + onError: (error: Error) => void, +) { + const upload = useCallback(() => { F.pipe( // upload file TE.tryCatch(() => { @@ -66,8 +53,10 @@ export function ImportButton({ } }) }, E.toError), + // read and parse json TE.flatMap((file) => TE.tryCatch(async () => JSON.parse(await file.text()) as unknown, E.toError)), + // decode TE.flatMap( F.flow( @@ -76,17 +65,11 @@ export function ImportButton({ TE.fromEither, ), ), - TE.map(onImport), getOrThrow(), - )().catch((e) => { - console.error(e) - alert(e) - }) - }, [decoder, onImport]) + )() + .then(onImport) + .catch(onError) + }, [decoder, onError, onImport]) - return ( - - ) + return { upload } } From 97c66c466b1818485f4a75ee4c986b1bb75b99cc Mon Sep 17 00:00:00 2001 From: amphineko Date: Sun, 31 Mar 2024 19:46:41 +0100 Subject: [PATCH 2/3] re(web): migrate queries in layout to swr --- packages/web/app/clientLayout.tsx | 54 +++++++++++-------------------- packages/web/app/layout.tsx | 10 +++--- packages/web/app/queries.ts | 20 ++++++++++++ 3 files changed, 43 insertions(+), 41 deletions(-) create mode 100644 packages/web/app/queries.ts diff --git a/packages/web/app/clientLayout.tsx b/packages/web/app/clientLayout.tsx index 42a1b67..ceb4066 100644 --- a/packages/web/app/clientLayout.tsx +++ b/packages/web/app/clientLayout.tsx @@ -4,12 +4,12 @@ import { AccountBox, BugReport, Error, + LinkOff, Lock, Notes, Password, PowerSettingsNew, Refresh, - StopCircle, SvgIconComponent, Traffic, WifiPassword, @@ -31,10 +31,10 @@ import CssBaseline from "@mui/material/CssBaseline" import { ThemeProvider, createTheme } from "@mui/material/styles" import Link from "next/link" import { usePathname, useRouter } from "next/navigation" -import { FC, JSX, PropsWithChildren, ReactNode, useCallback, useEffect, useMemo, useState } from "react" -import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from "react-query" +import { FC, JSX, PropsWithChildren, ReactNode, useEffect, useMemo, useState } from "react" +import { QueryClient, QueryClientProvider } from "react-query" -import { getStatus, reloadRadiusd, restartRadiusd } from "./actions" +import { useRadiusdStatus, useReloadRadiusd, useRestartRadiusd } from "./queries" import { NotificationList, NotificationProvider } from "../lib/notifications" const queryClient = new QueryClient() @@ -54,29 +54,15 @@ function humanize(seconds: number) { } function RadiusdMenu(): JSX.Element { - const queryClient = useQueryClient() - const onSuccess = useCallback(async () => { - await queryClient.invalidateQueries(["index", "radiusd", "status"]) - }, [queryClient]) - - const { mutate: mutateReload } = useMutation({ - mutationFn: reloadRadiusd, - mutationKey: ["index", "radiusd", "reload"], - onSuccess, - }) - - const { mutate: mutateRestart } = useMutation({ - mutationFn: restartRadiusd, - mutationKey: ["index", "radiusd", "restart"], - onSuccess, - }) + const { trigger: reload } = useReloadRadiusd() + const { trigger: restart } = useRestartRadiusd() return ( { - mutateReload() + void reload() }} > @@ -87,7 +73,7 @@ function RadiusdMenu(): JSX.Element { { - mutateRestart() + void restart() }} > @@ -99,16 +85,12 @@ function RadiusdMenu(): JSX.Element { } function StatusChip(): JSX.Element { - const { data } = useQuery({ - queryFn: async () => await getStatus(), - queryKey: ["index", "radiusd", "status"], - refetchInterval: 60 * 1000, - }) + const { data: status } = useRadiusdStatus() - const isRunning = data?.lastExitCode === undefined + const isRunning = status?.lastExitCode === undefined const [now, setNow] = useState(() => Date.now()) - const uptime = Math.floor(((data?.lastRestartedAt?.getTime() ?? now) - now) / 1000) + const uptime = Math.floor(((status?.lastRestartedAt?.getTime() ?? now) - now) / 1000) useEffect(() => { const interval = setInterval(() => { @@ -120,16 +102,16 @@ function StatusChip(): JSX.Element { } }, []) - return isRunning ? ( - - } label={humanize(uptime)} variant="outlined"> + return status === undefined ? ( + + } label="Unknown" variant="outlined" /> - ) : data.lastExitCode === 0 ? ( - - } label="Stopped" variant="outlined" /> + ) : isRunning ? ( + + } label={humanize(uptime)} variant="outlined"> ) : ( - + } label="Failed" variant="outlined" /> ) diff --git a/packages/web/app/layout.tsx b/packages/web/app/layout.tsx index 0994e3c..da040c2 100644 --- a/packages/web/app/layout.tsx +++ b/packages/web/app/layout.tsx @@ -1,13 +1,13 @@ -import React from "react" +import { Metadata } from "next" +import { ReactNode } from "react" import { RootClientLayout } from "./clientLayout" -export const metadata = { - title: "Next.js", - description: "Generated by Next.js", +export const metadata: Metadata = { + title: "Yonagi Web", } -export default function RootLayout({ children }: { children: React.ReactNode }): JSX.Element { +export default function RootLayout({ children }: { children: ReactNode }): JSX.Element { return ( diff --git a/packages/web/app/queries.ts b/packages/web/app/queries.ts new file mode 100644 index 0000000..de3d22b --- /dev/null +++ b/packages/web/app/queries.ts @@ -0,0 +1,20 @@ +"use client" + +import useSWR from "swr" +import useSWRMutation from "swr/mutation" + +import { getStatus, reloadRadiusd, restartRadiusd } from "./actions" + +export function useRadiusdStatus() { + return useSWR(["radiusd", "status"], getStatus, { + refreshInterval: 1000, + }) +} + +export function useRestartRadiusd() { + return useSWRMutation(["radiusd", "status"], restartRadiusd) +} + +export function useReloadRadiusd() { + return useSWRMutation(["radiusd", "status"], reloadRadiusd) +} From b6d6af1882da48774e31bba2164693a53af15e8b Mon Sep 17 00:00:00 2001 From: amphineko Date: Mon, 1 Apr 2024 22:59:02 +0100 Subject: [PATCH 3/3] re(web): refactor pki and migrate to swr --- packages/web/app/clientLayout.tsx | 4 - packages/web/app/pki/create.tsx | 128 +++++++ .../app/pki/{exportDialog.tsx => export.tsx} | 54 ++- packages/web/app/pki/page.tsx | 358 ++++++------------ packages/web/app/pki/queries.ts | 69 ++++ packages/web/app/radiusd/logs/page.tsx | 7 +- packages/web/lib/client.ts | 36 +- packages/web/package.json | 1 - yarn.lock | 77 +--- 9 files changed, 352 insertions(+), 382 deletions(-) create mode 100644 packages/web/app/pki/create.tsx rename packages/web/app/pki/{exportDialog.tsx => export.tsx} (71%) create mode 100644 packages/web/app/pki/queries.ts diff --git a/packages/web/app/clientLayout.tsx b/packages/web/app/clientLayout.tsx index ceb4066..1f2afce 100644 --- a/packages/web/app/clientLayout.tsx +++ b/packages/web/app/clientLayout.tsx @@ -32,13 +32,10 @@ import { ThemeProvider, createTheme } from "@mui/material/styles" import Link from "next/link" import { usePathname, useRouter } from "next/navigation" import { FC, JSX, PropsWithChildren, ReactNode, useEffect, useMemo, useState } from "react" -import { QueryClient, QueryClientProvider } from "react-query" import { useRadiusdStatus, useReloadRadiusd, useRestartRadiusd } from "./queries" import { NotificationList, NotificationProvider } from "../lib/notifications" -const queryClient = new QueryClient() - const humanizer = new Intl.RelativeTimeFormat("en", { numeric: "always", style: "short" }) function humanize(seconds: number) { @@ -207,7 +204,6 @@ export function RootClientLayout({ children }: { children: React.ReactNode }): J {children}, - ({ children }) => {children}, ({ children }) => {children}, ]} > diff --git a/packages/web/app/pki/create.tsx b/packages/web/app/pki/create.tsx new file mode 100644 index 0000000..23daede --- /dev/null +++ b/packages/web/app/pki/create.tsx @@ -0,0 +1,128 @@ +import { Add } from "@mui/icons-material" +import { Accordion, AccordionDetails, AccordionSummary, Box, Button, Stack } from "@mui/material" +import { CreateCertificateRequest } from "@yonagi/common/api/pki" +import { PositiveIntegerFromString } from "@yonagi/common/types/Integers" +import { NonEmptyStringType } from "@yonagi/common/types/StringWithLengthRange" +import * as E from "fp-ts/lib/Either" +import * as F from "fp-ts/lib/function" +import { BaseSyntheticEvent, useMemo, useState } from "react" +import useSWRMutation from "swr/mutation" + +import { createCertificateAuthority, createClientCertificate, createServerCertificate } from "./actions" +import { CodecTextField } from "../../lib/forms" +import { useNotifications } from "../../lib/notifications" + +function CreateCertificateAccordion({ + onSubmit, + title, +}: { + onSubmit: (form: CreateCertificateRequest) => void + title: string +}) { + const [commonName, setCommonName] = useState(NonEmptyStringType.decode("")) + const [organizationName, setOrganizationName] = useState(NonEmptyStringType.decode("")) + const [validity, setValidity] = useState(PositiveIntegerFromString.decode("")) + + const validation = useMemo( + () => + F.pipe( + E.Do, + E.bind("subject", () => + F.pipe( + E.Do, + E.bind("commonName", () => commonName), + E.bind("organizationName", () => organizationName), + ), + ), + E.bind("organizationName", () => organizationName), + E.bind("validity", () => validity), + ), + [commonName, organizationName, validity], + ) + + const submit = (e: BaseSyntheticEvent) => { + e.preventDefault() + E.isRight(validation) && onSubmit(validation.right) + } + + return ( + + {title} + +
+ + + + + + + + + +
+
+ ) +} + +function useCreateQuery(fn: (form: CreateCertificateRequest) => Promise) { + const { notifyError, notifySuccess } = useNotifications() + + return useSWRMutation( + ["pki"], + async (_, { arg: form }: { arg: CreateCertificateRequest }) => { + await fn(form) + }, + { + onError: (error) => { + notifyError("Cannot create certificate", String(error)) + }, + onSuccess: () => { + notifySuccess("Certificate created") + }, + }, + ).trigger +} + +export function CreateCertificateAuthorityAccordion() { + const create = useCreateQuery(async (form) => { + await createCertificateAuthority(form) + }) + return void create(form)} /> +} + +export function CreateServerCertificateAccordion() { + const create = useCreateQuery(async (form) => { + await createServerCertificate(form) + }) + return void create(form)} /> +} + +export function CreateClientCertificateAccordion() { + const create = useCreateQuery(async (form) => { + await createClientCertificate(form) + }) + return void create(form)} /> +} diff --git a/packages/web/app/pki/exportDialog.tsx b/packages/web/app/pki/export.tsx similarity index 71% rename from packages/web/app/pki/exportDialog.tsx rename to packages/web/app/pki/export.tsx index 8b03e91..f17c060 100644 --- a/packages/web/app/pki/exportDialog.tsx +++ b/packages/web/app/pki/export.tsx @@ -13,12 +13,20 @@ import { } from "@mui/material" import { SerialNumberString } from "@yonagi/common/types/pki/SerialNumberString" import { FormEvent, useState } from "react" -import { useQuery } from "react-query" +import useSWRMutation from "swr/mutation" -import { exportClientCertificateP12 } from "./actions" +import { exportCertificateAuthorityPem, exportClientCertificateP12 } from "./actions" import { base64ToBlob, downloadBlob } from "../../lib/client" import { useNotifications } from "../../lib/notifications" +export function useExportCertificateAuthorityPem(filename: string) { + return useSWRMutation(["pki"], async () => { + const pem = await exportCertificateAuthorityPem() + const blob = new Blob([pem], { type: "application/x-pem-file" }) + downloadBlob(blob, `${filename}.crt`) + }) +} + export function ExportPkcs12Dialog({ onClose, open, @@ -30,31 +38,24 @@ export function ExportPkcs12Dialog({ }): JSX.Element { const [password, setPassword] = useState("") - const { isLoading, refetch } = useQuery({ - enabled: false, - queryFn: async () => { + const { notifyError, notifySuccess } = useNotifications() + const { trigger, isMutating } = useSWRMutation( + ["pki"], + async (_, { arg: password }: { arg: string }) => { const base64 = await exportClientCertificateP12(serialNumber, password) const blob = base64ToBlob(base64, "application/x-pkcs12") downloadBlob(blob, `${serialNumber}.p12`) }, - onError: (error) => { - notifyError("Failed to export PKCS#12", String(error)) - }, - queryKey: ["pki", "download", serialNumber], - retry: false, - }) - - const handleSubmit = () => { - refetch() - .then(() => { + { + onError: (error) => { + notifyError("Failed to export PKCS#12", String(error)) + }, + onSuccess: () => { + notifySuccess("PKCS#12 exported") onClose() - }) - .catch((error) => { - notifyError("Failed to export as PKCS#12", String(error)) - }) - } - - const { notifyError } = useNotifications() + }, + }, + ) return ( ) => { e.preventDefault() - handleSubmit() + void trigger(password) }, }} > @@ -89,11 +90,8 @@ export function ExportPkcs12Dialog({ + { + setAnchor(null) + }} + open={!!anchor} + transformOrigin={{ + horizontal: "left", + vertical: "center", + }} + > + + + + ) +} + function CertificateDisplayAccordionDetails({ canExportCaPem, canExportP12, cert, - delete: submitDelete, + onDelete, }: { canExportCaPem?: boolean canExportP12?: boolean cert: CertificateSummary - delete: (serial: SerialNumberString) => Promise + onDelete: (serial: SerialNumberString) => void }) { - const { invalidate } = useQueryHelpers(PKI_QUERY_KEY) - const { isLoading: isDeleting, mutate: mutateDelete } = useMutation({ - mutationFn: async () => await submitDelete(cert.serialNumber), - mutationKey: ["pki", "delete", cert.serialNumber], - onSettled: invalidate, - }) - const { notifyError } = useNotifications() - - const { isLoading: isExportingCaPem, refetch: refetchCaPem } = useQuery({ - enabled: false, - queryFn: async () => { - const pem = await exportCertificateAuthorityPem() - const blob = new Blob([pem], { type: "application/x-pem-file" }) - downloadBlob(blob, `${cert.serialNumber}.crt`) - }, - onError: (error) => { - notifyError("Failed to download certificate", String(error)) - }, - queryKey: ["pki", "download", cert.serialNumber], - retry: false, - }) - + const { trigger: exportCaPem } = useExportCertificateAuthorityPem(`${cert.serialNumber}.crt`) const { dialog: exportPkcs12Dialog, open: openExportPkcs12Dialog } = useExportPkcs12Dialog({ serialNumber: cert.serialNumber, }) - const [deletePopoverAnchor, setDeletePopoverAnchor] = useState(null) - return ( @@ -150,27 +153,16 @@ function CertificateDisplayAccordionDetails({ - + /> {canExportCaPem && ( - {canExportP12 && exportPkcs12Dialog} ) } -function CertificateCreateAccordionDetails({ - create: submitCreate, +function CertificateAccordion({ + canExportCaPem, + canExportP12, + cert, + defaultExpanded, + onDelete, + title, }: { - create: (form: CreateCertificateRequest) => Promise + canExportCaPem?: boolean + canExportP12?: boolean + cert?: CertificateSummary + defaultExpanded?: boolean + onDelete: (serial: SerialNumberString) => unknown + key?: string + title: string }) { - const { invalidate } = useQueryHelpers(PKI_QUERY_KEY) - const { isLoading: isCreating, mutate: mutateCreate } = useMutation({ - mutationFn: async (form) => await submitCreate(form), - mutationKey: ["pki", "create"], - onSettled: invalidate, - }) - return ( - - { - mutateCreate(form) - }} - > - {(update, trySubmit) => ( - - { - update((current) => ({ - ...current, - subject: { ...current.subject, commonName }, - })) - }} - /> - { - update((current) => ({ - ...current, - subject: { ...current.subject, organizationName }, - })) - }} - /> - { - update((current) => ({ ...current, validity })) - }} - /> - - - - - )} - - - ) -} - -function CertificateAccordion( - props: { - create?: (form: CreateCertificateRequest) => Promise - defaultExpanded?: boolean - isLoading: boolean - key?: string - title: string - } & ( - | { - canExportCaPem?: boolean - canExportP12?: boolean - cert?: CertificateSummary - delete: (serial: SerialNumberString) => Promise - } - | { - cert?: never - } - ), -) { - const { defaultExpanded, isLoading, title } = props return ( - + }> {title} - {props.cert && ( + {cert && ( - {props.cert.subject.commonName} + {cert.subject.commonName} )} - {props.isLoading ? ( - - - - ) : props.cert ? ( + {cert ? ( props.delete(serial)} - canExportCaPem={props.canExportCaPem} - canExportP12={props.canExportP12} + cert={cert} + onDelete={(serial) => { + onDelete(serial) + }} + canExportCaPem={canExportCaPem} + canExportP12={canExportP12} /> - ) : props.create ? ( - ) : ( - <> + + + )} ) @@ -361,58 +248,59 @@ function DashboardSectionTitle({ children }: { children: React.ReactNode }) { } export default function PkiDashboardPage() { - const { data, status } = useQuery({ - queryFn: async () => await getPkiSummary(), - queryKey: ["pki", "summary"], - }) - const hasData = status === "success" + const { data } = usePkiSummary() const { nonce, increaseNonce } = useNonce() + const { trigger: deleteCertificateAuthority } = useDeleteCertificateAuthority() + const { trigger: deleteServerCertificate } = useDeleteServerCertificate() + const { trigger: deleteClientCertificate } = useDeleteClientCertificate() + + useEffect(() => { + increaseNonce() + }, [data, increaseNonce]) + return ( Infrastructure - createCertificateAuthority(form).finally(increaseNonce)} - defaultExpanded - delete={(serial) => deleteCertificateAuthority(serial)} - isLoading={!hasData} - key={`ca-${nonce}`} - title="Certificate Authority" - /> - createServerCertificate(form).finally(increaseNonce)} - defaultExpanded - delete={(serial) => deleteServerCertificate(serial)} - isLoading={!hasData} - key={`server-${nonce}`} - title="Server Certificate" - /> + {data && data.ca === undefined ? ( + + ) : ( + deleteCertificateAuthority(serial)} + key={`ca`} + title="Certificate Authority" + /> + )} + {data && data.server === undefined ? ( + + ) : ( + deleteServerCertificate(serial)} + key={`server`} + title="Server Certificate" + /> + )} Clients {data?.clients?.map((clientCert) => ( deleteClientCertificate(serial)} + onDelete={(serial) => deleteClientCertificate(serial)} canExportP12 - isLoading={!hasData} key={clientCert.serialNumber} title="Client" /> ))} - createClientCertificate(form).finally(increaseNonce)} - defaultExpanded - isLoading={!hasData} - key={`create-client-${nonce}`} - title="New Client" - /> + ) diff --git a/packages/web/app/pki/queries.ts b/packages/web/app/pki/queries.ts new file mode 100644 index 0000000..548e52d --- /dev/null +++ b/packages/web/app/pki/queries.ts @@ -0,0 +1,69 @@ +import { SerialNumberString } from "@yonagi/common/types/pki/SerialNumberString" +import useSWR from "swr" +import useSWRMutation from "swr/mutation" + +import { deleteCertificateAuthority, deleteClientCertificate, deleteServerCertificate, getPkiSummary } from "./actions" +import { useNotifications } from "../../lib/notifications" + +export function usePkiSummary() { + return useSWR(["pki"], async () => { + return await getPkiSummary() + }) +} + +export function useDeleteCertificateAuthority() { + const { notifyError, notifySuccess } = useNotifications() + + return useSWRMutation( + ["pki"], + async (_, { arg: serial }: { arg: SerialNumberString }) => { + await deleteCertificateAuthority(serial) + }, + { + onError: (error) => { + notifyError(`Cannot delete certificate authority`, String(error)) + }, + onSuccess: () => { + notifySuccess(`Certificate authority deleted`) + }, + }, + ) +} + +export function useDeleteServerCertificate() { + const { notifyError, notifySuccess } = useNotifications() + + return useSWRMutation( + ["pki"], + async (_, { arg: serial }: { arg: SerialNumberString }) => { + await deleteServerCertificate(serial) + }, + { + onError: (error) => { + notifyError(`Cannot delete server certificate`, String(error)) + }, + onSuccess: () => { + notifySuccess(`Server certificate deleted`) + }, + }, + ) +} + +export function useDeleteClientCertificate() { + const { notifyError, notifySuccess } = useNotifications() + + return useSWRMutation( + ["pki"], + async (_, { arg: serial }: { arg: SerialNumberString }) => { + await deleteClientCertificate(serial) + }, + { + onError: (error) => { + notifyError(`Cannot delete client certificate`, String(error)) + }, + onSuccess: () => { + notifySuccess(`Client certificate deleted`) + }, + }, + ) +} diff --git a/packages/web/app/radiusd/logs/page.tsx b/packages/web/app/radiusd/logs/page.tsx index 6efe835..4c030e1 100644 --- a/packages/web/app/radiusd/logs/page.tsx +++ b/packages/web/app/radiusd/logs/page.tsx @@ -1,7 +1,7 @@ "use client" import styled from "@emotion/styled" -import { useQuery } from "react-query" +import useSWR from "swr" import { getRecentLogs } from "../actions" @@ -11,10 +11,7 @@ const LogLine = styled.span` ` export default function RadiusdLogPage() { - const { data: logs } = useQuery({ - queryKey: ["radiusd", "logs"], - queryFn: async () => await getRecentLogs(), - }) + const { data: logs } = useSWR(["radiusd"], async () => await getRecentLogs()) return
{logs?.map((log, idx) => {log})}
} diff --git a/packages/web/lib/client.ts b/packages/web/lib/client.ts index 146225b..c28839d 100644 --- a/packages/web/lib/client.ts +++ b/packages/web/lib/client.ts @@ -1,46 +1,12 @@ "use client" -import { useReducer, useState } from "react" -import { useQueryClient } from "react-query" +import { useReducer } from "react" export function useNonce() { const [nonce, increaseNonce] = useReducer((nonce: number) => nonce + 1, 0) return { nonce, increaseNonce } } -export function useStagedNonce() { - const [nextNonce, increaseNonce] = useReducer((nonce: number) => nonce + 1, 0) - const [nonce, setNonce] = useState(nextNonce) - - return { - nonce, - increaseNonce, - publishNonce: () => { - setNonce(nextNonce) - }, - } -} - -export function withNonce never>( - increase: () => void, - f: F, -): (...args: Parameters) => ReturnType { - return (...args) => { - increase() - return f(...args) - } -} - -export function useQueryHelpers(queryKey: readonly unknown[]) { - const queryClient = useQueryClient() - - return { - invalidate: async () => { - await queryClient.invalidateQueries({ queryKey }) - }, - } -} - export function base64ToBlob(base64: string, type: string) { const byteCharacters = atob(base64) const byteArray = new Uint8Array(byteCharacters.length) diff --git a/packages/web/package.json b/packages/web/package.json index 5be8be7..e15c7ac 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -14,7 +14,6 @@ "next": "^14.0.4", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-query": "^3.39.3", "swr": "^2.2.5" }, "scripts": { diff --git a/yarn.lock b/yarn.lock index f4c78b0..508870b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -263,7 +263,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.22.5" -"@babel/runtime@^7.12.5", "@babel/runtime@^7.18.3", "@babel/runtime@^7.23.6", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.7": +"@babel/runtime@^7.12.5", "@babel/runtime@^7.18.3", "@babel/runtime@^7.23.6", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7": version "7.23.7" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.7.tgz#dd7c88deeb218a0f8bd34d5db1aa242e0f203193" integrity sha512-w06OXVOFso7LcbzMiDGt+3X7Rh7Ho8MmgPoWU3rarH+8upf+wSU/grlGbWzQyr3DkdN6ZeuMFjpdwW0Q+HxobA== @@ -1775,7 +1775,7 @@ base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -big-integer@^1.6.16, big-integer@^1.6.44: +big-integer@^1.6.44: version "1.6.52" resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.52.tgz#60a887f3047614a8e1bffe5d7173490a97dc8c85" integrity sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg== @@ -1825,20 +1825,6 @@ braces@^3.0.2: dependencies: fill-range "^7.0.1" -broadcast-channel@^3.4.1: - version "3.7.0" - resolved "https://registry.yarnpkg.com/broadcast-channel/-/broadcast-channel-3.7.0.tgz#2dfa5c7b4289547ac3f6705f9c00af8723889937" - integrity sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg== - dependencies: - "@babel/runtime" "^7.7.2" - detect-node "^2.1.0" - js-sha3 "0.8.0" - microseconds "0.2.0" - nano-time "1.0.0" - oblivious-set "1.0.0" - rimraf "3.0.2" - unload "2.2.0" - browserslist@^4.22.2: version "4.22.3" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.22.3.tgz#299d11b7e947a6b843981392721169e27d60c5a6" @@ -2284,11 +2270,6 @@ detect-newline@^3.0.0: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== -detect-node@^2.0.4, detect-node@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" - integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== - diff-sequences@^29.6.3: version "29.6.3" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" @@ -3988,11 +3969,6 @@ joycon@^3.1.1: resolved "https://registry.yarnpkg.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03" integrity sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw== -js-sha3@0.8.0: - version "0.8.0" - resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.8.0.tgz#b9b7a5da73afad7dedd0f8c463954cbde6818840" - integrity sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q== - "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -4197,14 +4173,6 @@ makeerror@1.0.12: dependencies: tmpl "1.0.5" -match-sorter@^6.0.2: - version "6.3.1" - resolved "https://registry.yarnpkg.com/match-sorter/-/match-sorter-6.3.1.tgz#98cc37fda756093424ddf3cbc62bfe9c75b92bda" - integrity sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw== - dependencies: - "@babel/runtime" "^7.12.5" - remove-accents "0.4.2" - merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" @@ -4223,11 +4191,6 @@ micromatch@^4.0.4: braces "^3.0.2" picomatch "^2.3.1" -microseconds@0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/microseconds/-/microseconds-0.2.0.tgz#233b25f50c62a65d861f978a4a4f8ec18797dc39" - integrity sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA== - mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" @@ -4367,13 +4330,6 @@ mz@^2.4.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nano-time@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/nano-time/-/nano-time-1.0.0.tgz#b0554f69ad89e22d0907f7a12b0993a5d96137ef" - integrity sha512-flnngywOoQ0lLQOTRNexn2gGSNuM9bKj9RZAWSzhQ+UJYaAFG9bac4DW9VHjUAzrOaIcajHybCTHe/bkvozQqA== - dependencies: - big-integer "^1.6.16" - nanoid@^3.3.6: version "3.3.7" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" @@ -4557,11 +4513,6 @@ obliterator@^2.0.1: resolved "https://registry.yarnpkg.com/obliterator/-/obliterator-2.0.4.tgz#fa650e019b2d075d745e44f1effeb13a2adbe816" integrity sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ== -oblivious-set@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/oblivious-set/-/oblivious-set-1.0.0.tgz#c8316f2c2fb6ff7b11b6158db3234c49f733c566" - integrity sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw== - on-exit-leak-free@^2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz#fed195c9ebddb7d9e4c3842f93f281ac8dadd3b8" @@ -4989,15 +4940,6 @@ react-is@^18.0.0, react-is@^18.2.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== -react-query@^3.39.3: - version "3.39.3" - resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.39.3.tgz#4cea7127c6c26bdea2de5fb63e51044330b03f35" - integrity sha512-nLfLz7GiohKTJDuT4us4X3h/8unOh+00MLb2yJoGTPjxKs2bc1iDhkNx2bd5MKklXnOD3NrVZ+J2UXujA5In4g== - dependencies: - "@babel/runtime" "^7.5.5" - broadcast-channel "^3.4.1" - match-sorter "^6.0.2" - react-transition-group@^4.4.5: version "4.4.5" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" @@ -5059,11 +5001,6 @@ regexp.prototype.flags@^1.5.1: define-properties "^1.2.0" set-function-name "^2.0.0" -remove-accents@0.4.2: - version "0.4.2" - resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.4.2.tgz#0a43d3aaae1e80db919e07ae254b285d9e1c7bb5" - integrity sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA== - require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" @@ -5130,7 +5067,7 @@ rfdc@^1.2.0, rfdc@^1.3.0: resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b" integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA== -rimraf@3.0.2, rimraf@^3.0.2: +rimraf@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== @@ -5849,14 +5786,6 @@ unique-slug@^2.0.0: dependencies: imurmurhash "^0.1.4" -unload@2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/unload/-/unload-2.2.0.tgz#ccc88fdcad345faa06a92039ec0f80b488880ef7" - integrity sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA== - dependencies: - "@babel/runtime" "^7.6.2" - detect-node "^2.0.4" - untildify@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/untildify/-/untildify-4.0.0.tgz#2bc947b953652487e4600949fb091e3ae8cd919b"