diff --git a/public/locales/en-US/translation.json b/public/locales/en-US/translation.json index cf01f8f..c414c7e 100644 --- a/public/locales/en-US/translation.json +++ b/public/locales/en-US/translation.json @@ -296,6 +296,20 @@ "userAmount_one": "Forwards to one user", "userAmount_other": "Forwards to {{count}} users" }, + "ReservedAliasDetailRoute": { + "title": "Reserved Alias Details", + "sections": { + "users": { + "title": "Users", + "fields": { + "users": { + "label": "Users", + "me": "{{email}} (Me)" + } + } + } + } + }, "AdminRoute": { "title": "Site configuration", "forms": { diff --git a/src/App.tsx b/src/App.tsx index 217ee90..f2cfed2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import LogoutRoute from "~/routes/LogoutRoute" import OverviewRoute from "~/routes/OverviewRoute" import ReportDetailRoute from "~/routes/ReportDetailRoute" import ReportsRoute from "~/routes/ReportsRoute" +import ReservedAliasDetailRoute from "~/routes/ReservedAliasDetailRoute" import ReservedAliasesRoute from "~/routes/ReservedAliasesRoute" import RootRoute from "~/routes/Root" import SettingsRoute from "~/routes/SettingsRoute" @@ -104,6 +105,10 @@ const router = createBrowserRouter([ path: "/admin/reserved-aliases", element: , }, + { + path: "/admin/reserved-aliases/:id", + element: , + }, { path: "/admin/reserved-aliases/create", loader: getServerSettings, diff --git a/src/apis/get-reserved-alias.ts b/src/apis/get-reserved-alias.ts new file mode 100644 index 0000000..89a547b --- /dev/null +++ b/src/apis/get-reserved-alias.ts @@ -0,0 +1,13 @@ +import {ReservedAlias} from "~/server-types" +import {client} from "~/constants/axios-client" + +export default async function getReservedAlias(id: string): Promise { + const {data} = await client.get( + `${import.meta.env.VITE_SERVER_BASE_URL}/v1/reserved-alias/${id}`, + { + withCredentials: true, + }, + ) + + return data +} diff --git a/src/apis/index.ts b/src/apis/index.ts index 438a419..e2ae2ad 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -44,3 +44,7 @@ export * from "./get-reserved-aliases" export {default as getReservedAliases} from "./get-reserved-aliases" export * from "./create-reserved-alias" export {default as createReservedAlias} from "./create-reserved-alias" +export * from "./get-reserved-alias" +export {default as getReservedAlias} from "./get-reserved-alias" +export * from "./update-reserved-alias" +export {default as updateReservedAlias} from "./update-reserved-alias" diff --git a/src/apis/update-reserved-alias.ts b/src/apis/update-reserved-alias.ts new file mode 100644 index 0000000..f7d217e --- /dev/null +++ b/src/apis/update-reserved-alias.ts @@ -0,0 +1,27 @@ +import {ReservedAlias} from "~/server-types" +import {client} from "~/constants/axios-client" + +export interface UpdateReservedAliasData { + isActive?: boolean + users?: Array<{ + id: string + }> +} + +export default async function updateReservedAlias( + id: string, + {isActive, users}: UpdateReservedAliasData, +): Promise { + const {data} = await client.patch( + `${import.meta.env.VITE_SERVER_BASE_URL}/v1/reserved-alias/${id}`, + { + isActive, + users, + }, + { + withCredentials: true, + }, + ) + + return data +} diff --git a/src/route-widgets/ReservedAliasDetailRoute/AdminUserPicker.tsx b/src/route-widgets/ReservedAliasDetailRoute/AdminUserPicker.tsx new file mode 100644 index 0000000..a33e68f --- /dev/null +++ b/src/route-widgets/ReservedAliasDetailRoute/AdminUserPicker.tsx @@ -0,0 +1,65 @@ +import {AxiosError} from "axios" +import {useTranslation} from "react-i18next" +import {ReactElement} from "react" + +import {useQuery} from "@tanstack/react-query" +import {MenuItem, TextField} from "@mui/material" + +import {GetAdminUsersResponse, getAdminUsers} from "~/apis" +import {useUser} from "~/hooks" + +export interface AdminUserPickerProps { + onPick: (user: GetAdminUsersResponse["users"][0]) => void + alreadyPicked: GetAdminUsersResponse["users"] +} + +export default function AdminUserPicker({ + onPick, + alreadyPicked, +}: AdminUserPickerProps): ReactElement { + const {t} = useTranslation() + const meUser = useUser() + const {data: {users: availableUsers} = {}} = useQuery( + ["getAdminUsers"], + getAdminUsers, + ) + + if (!availableUsers) { + return <> + } + + const users = availableUsers.filter( + user => !alreadyPicked.find(picked => picked.id === user.id), + ) + + if (users.length === 0) { + return <> + } + + return ( + { + const user = users.find(user => user.id === event.target.value) + if (user) { + onPick(user) + } + + event.preventDefault() + }} + > + {users.map(user => ( + + {user.id === meUser?.id + ? t("routes.AdminRoute.forms.reservedAliases.fields.users.me", { + email: user.email.address, + }) + : user.email.address} + + ))} + + ) +} diff --git a/src/route-widgets/ReservedAliasDetailRoute/AliasActivationSwitch.tsx b/src/route-widgets/ReservedAliasDetailRoute/AliasActivationSwitch.tsx new file mode 100644 index 0000000..6057d11 --- /dev/null +++ b/src/route-widgets/ReservedAliasDetailRoute/AliasActivationSwitch.tsx @@ -0,0 +1,86 @@ +import {ReactElement} from "react" +import {AxiosError} from "axios" +import {useTranslation} from "react-i18next" +import update from "immutability-helper" + +import {useMutation} from "@tanstack/react-query" +import {Switch} from "@mui/material" + +import {useErrorSuccessSnacks} from "~/hooks" +import {ReservedAlias} from "~/server-types" +import {updateReservedAlias} from "~/apis" +import {queryClient} from "~/constants/react-query" + +export interface AliasActivationSwitch { + id: string + isActive: boolean + queryKey: readonly string[] +} + +export default function AliasActivationSwitch({ + id, + isActive, + queryKey, +}: AliasActivationSwitch): ReactElement { + const {t} = useTranslation() + const {showError, showSuccess} = useErrorSuccessSnacks() + const {isLoading, mutateAsync} = useMutation< + ReservedAlias, + AxiosError, + boolean, + {previousAlias: ReservedAlias | undefined} + >( + activeNow => + updateReservedAlias(id, { + isActive: activeNow, + }), + { + onMutate: async activeNow => { + await queryClient.cancelQueries(queryKey) + + const previousAlias = queryClient.getQueryData(queryKey) + + queryClient.setQueryData(queryKey, old => + update(old, { + isActive: { + $set: activeNow!, + }, + }), + ) + + return {previousAlias} + }, + onSuccess: newAlias => { + queryClient.setQueryData(queryKey, newAlias) + }, + onError: (error, values, context) => { + showError(error) + + if (context?.previousAlias) { + queryClient.setQueryData(queryKey, context.previousAlias) + } + }, + }, + ) + + return ( + { + if (isLoading) { + return + } + + try { + await mutateAsync(!isActive) + + showSuccess( + isActive + ? t("relations.alias.mutations.success.aliasChangedToDisabled") + : t("relations.alias.mutations.success.aliasChangedToEnabled"), + ) + } catch {} + }} + /> + ) +} diff --git a/src/route-widgets/ReservedAliasDetailRoute/AliasUsersList.tsx b/src/route-widgets/ReservedAliasDetailRoute/AliasUsersList.tsx new file mode 100644 index 0000000..a5ca752 --- /dev/null +++ b/src/route-widgets/ReservedAliasDetailRoute/AliasUsersList.tsx @@ -0,0 +1,205 @@ +import * as yup from "yup" +import {ReactElement, useState} from "react" +import {AxiosError} from "axios" +import {MdCheckCircle} from "react-icons/md" +import {FaPen} from "react-icons/fa" +import {useTranslation} from "react-i18next" +import {FieldArray, FormikProvider, useFormik} from "formik" +import {TiDelete} from "react-icons/ti" +import deepEqual from "deep-equal" +import update from "immutability-helper" + +import {useMutation} from "@tanstack/react-query" +import { + Divider, + FormHelperText, + Grid, + IconButton, + List, + ListItem, + ListItemSecondaryAction, + ListItemText, + Typography, +} from "@mui/material" + +import {ReservedAlias} from "~/server-types" +import {updateReservedAlias} from "~/apis" +import {parseFastAPIError} from "~/utils" +import {queryClient} from "~/constants/react-query" +import {useErrorSuccessSnacks} from "~/hooks" +import AdminUserPicker from "~/route-widgets/ReservedAliasDetailRoute/AdminUserPicker" + +export interface AliasUsersListProps { + users: ReservedAlias["users"] + id: string + queryKey: readonly string[] +} + +interface Form { + users: ReservedAlias["users"] +} + +export default function AliasUsersList({users, queryKey, id}: AliasUsersListProps): ReactElement { + const {t} = useTranslation() + const {showError, showSuccess} = useErrorSuccessSnacks() + const {mutateAsync} = useMutation< + ReservedAlias, + AxiosError, + ReservedAlias["users"], + {previousAlias?: ReservedAlias} + >( + users => + updateReservedAlias(id, { + users: users.map(user => ({ + id: user.id, + })), + }), + { + onMutate: async users => { + await queryClient.cancelQueries(queryKey) + + const previousAlias = queryClient.getQueryData(queryKey) + + queryClient.setQueryData(queryKey, old => + update(old, { + users: { + $set: users, + }, + }), + ) + + return { + previousAlias, + } + }, + onSuccess: async newAlias => { + showSuccess(t("relations.alias.mutations.success.aliasUpdated")) + + await queryClient.cancelQueries(queryKey) + + queryClient.setQueryData(queryKey, newAlias as any as ReservedAlias) + }, + onError: (error, _, context) => { + showError(error) + + setIsInEditMode(true) + + if (context?.previousAlias) { + queryClient.setQueryData(queryKey, context.previousAlias) + } + }, + }, + ) + const schema = yup.object().shape({ + users: yup + .array() + .of( + yup.object().shape({ + id: yup.string().required(), + email: yup.object().shape({ + address: yup.string().required(), + id: yup.string().required(), + }), + }), + ) + .label(t("routes.AliasDetailRoute.sections.users.fields.users.label")), + }) + const initialValues: Form = { + users: users, + } + const formik = useFormik
({ + initialValues, + validationSchema: schema, + onSubmit: async (values, {setErrors}) => { + try { + await mutateAsync(values.users) + } catch (error) { + setErrors(parseFastAPIError(error as AxiosError)) + } + }, + }) + const [isInEditMode, setIsInEditMode] = useState(false) + + return ( + + + + + + {t("routes.ReservedAliasDetailRoute.sections.users.title")} + + + + { + setIsInEditMode(!isInEditMode) + + if ( + isInEditMode && + !deepEqual(initialValues, formik.values, { + strict: true, + }) + ) { + await formik.submitForm() + } + }} + > + {isInEditMode ? : } + + + + + + {isInEditMode ? ( + + ( + + {formik.values.users.map((user, index) => ( + + + + { + arrayHelpers.remove(index) + }} + > + + + + + ))} + + + arrayHelpers.push(user)} + /> + + + )} + /> + + {formik.touched.users && (formik.errors.users as string)} + + + ) : ( + + {users.map(user => ( + + + + ))} + + )} + + + ) +} diff --git a/src/routes/ReservedAliasDetailRoute.tsx b/src/routes/ReservedAliasDetailRoute.tsx new file mode 100644 index 0000000..b3c9eac --- /dev/null +++ b/src/routes/ReservedAliasDetailRoute.tsx @@ -0,0 +1,59 @@ +import {ReactElement} from "react" +import {useTranslation} from "react-i18next" +import {useParams} from "react-router-dom" +import {AxiosError} from "axios" + +import {useQuery} from "@tanstack/react-query" +import {Grid} from "@mui/material" + +import {QueryResult, SimplePage, SimplePageBuilder} from "~/components" +import {ReservedAlias} from "~/server-types" +import {getReservedAlias} from "~/apis" +import AliasActivationSwitch from "~/route-widgets/ReservedAliasDetailRoute/AliasActivationSwitch" +import AliasAddress from "~/route-widgets/AliasDetailRoute/AliasAddress" +import AliasUsersList from "~/route-widgets/ReservedAliasDetailRoute/AliasUsersList" + +export default function ReservedAliasDetailRoute(): ReactElement { + const {t} = useTranslation() + const params = useParams() + const queryKey = ["get_reserved_alias", params.id!] + + const query = useQuery(queryKey, () => getReservedAlias(params.id!)) + + return ( + + query={query}> + {alias => ( + + {[ + + + + + + + + , + , + ]} + + )} + + + ) +} diff --git a/src/server-types.ts b/src/server-types.ts index 2723c40..42e284e 100644 --- a/src/server-types.ts +++ b/src/server-types.ts @@ -108,7 +108,10 @@ export interface ReservedAlias { local: string users: Array<{ id: string - email: string + email: { + address: string + id: string + } }> } diff --git a/src/utils/parse-fastapi-error.ts b/src/utils/parse-fastapi-error.ts index 9d32c50..554c020 100644 --- a/src/utils/parse-fastapi-error.ts +++ b/src/utils/parse-fastapi-error.ts @@ -29,6 +29,10 @@ export default function parseFastAPIError( return {detail: error.detail} } + if (error.detail[0].loc[0] === "body" && error.detail[0].loc[1] === "__root__") { + return {detail: error.detail[0].msg} + } + return error.detail.reduce((acc, error) => { const [location, field] = error.loc