diff --git a/public/locales/en-US/translation.json b/public/locales/en-US/translation.json index 8f0ede7..4ff93a5 100644 --- a/public/locales/en-US/translation.json +++ b/public/locales/en-US/translation.json @@ -281,7 +281,28 @@ "description": "We are logging you out..." }, "AdminRoute": { - "title": "Site configuration" + "title": "Site configuration", + "forms": { + "reservedAliases": { + "title": "Reserved Aliases", + "description": "Define which aliases will be reserved for your domain.", + "saveAction": "Create Alias", + "fields": { + "local": { + "label": "Local" + }, + "users": { + "label": "Users", + "me": "{{email}} (Me)" + } + }, + "explanation": { + "step1": "User from outside", + "step2": "Sends mail to", + "step4": "KleckRelay forwards to" + } + } + } } }, diff --git a/src/App.tsx b/src/App.tsx index 8771a11..62d9b06 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -102,6 +102,7 @@ const router = createBrowserRouter([ }, { path: "/admin", + loader: getServerSettings, element: , }, ], diff --git a/src/apis/create-reserved-alias.ts b/src/apis/create-reserved-alias.ts new file mode 100644 index 0000000..28c1b96 --- /dev/null +++ b/src/apis/create-reserved-alias.ts @@ -0,0 +1,25 @@ +import {ReservedAlias} from "~/server-types" +import {client} from "~/constants/axios-client" + +export interface CreateReservedAliasData { + local: string + users: Array<{ + id: string + }> + + isActive?: boolean +} + +export default async function createReservedAlias( + aliasData: CreateReservedAliasData, +): Promise { + const {data} = await client.post( + `${import.meta.env.VITE_SERVER_BASE_URL}/v1/reserved-alias`, + aliasData, + { + withCredentials: true, + }, + ) + + return data +} diff --git a/src/apis/index.ts b/src/apis/index.ts index ddf3005..438a419 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -42,3 +42,5 @@ export * from "./get-admin-users" export {default as getAdminUsers} from "./get-admin-users" 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" diff --git a/src/components/widgets/SimpleForm.tsx b/src/components/widgets/SimpleForm.tsx index d8524d4..36fc510 100644 --- a/src/components/widgets/SimpleForm.tsx +++ b/src/components/widgets/SimpleForm.tsx @@ -73,7 +73,7 @@ export default function SimpleForm({ {children.map(input => ( - + {input} ))} diff --git a/src/route-widgets/AdminPage/AliasExplanation.tsx b/src/route-widgets/AdminPage/AliasExplanation.tsx new file mode 100644 index 0000000..70d3528 --- /dev/null +++ b/src/route-widgets/AdminPage/AliasExplanation.tsx @@ -0,0 +1,99 @@ +import {Grid, List, ListItem, ListItemText, Typography, useTheme} from "@mui/material" +import {ReactElement} from "react" +import {MdMail} from "react-icons/md" +import {useLoaderData} from "react-router" +import {ServerSettings} from "~/server-types" +import {useTranslation} from "react-i18next" +import {BsArrowDown} from "react-icons/bs" +import {FaMask} from "react-icons/fa" +import {HiUsers} from "react-icons/hi" + +export interface AliasExplanationProps { + local: string + emails: string[] +} + +export default function AliasExplanation({local, emails}: AliasExplanationProps): ReactElement { + const {t} = useTranslation() + const theme = useTheme() + const serverSettings = useLoaderData() as ServerSettings + + return ( + + + + + + + + + {t("routes.AdminRoute.forms.reservedAliases.explanation.step1")} + + + + + + + + + + + + {t("routes.AdminRoute.forms.reservedAliases.explanation.step2")} + + + + + + + + + + + + {local} + @{serverSettings.mailDomain} + + + + + + + + + + + + {t("routes.AdminRoute.forms.reservedAliases.explanation.step4")} + + + + + + + + + + + + {emails.map(email => ( + + + + ))} + + + + + + ) +} diff --git a/src/route-widgets/AdminPage/ReservedAliasesForm.tsx b/src/route-widgets/AdminPage/ReservedAliasesForm.tsx index 86fac79..e8309ed 100644 --- a/src/route-widgets/AdminPage/ReservedAliasesForm.tsx +++ b/src/route-widgets/AdminPage/ReservedAliasesForm.tsx @@ -1,17 +1,182 @@ +import * as yup from "yup" import {ReactElement} from "react" import {AxiosError} from "axios" +import {useTranslation} from "react-i18next" +import {useFormik} from "formik" -import {useQuery} from "@tanstack/react-query" +import {useMutation, useQuery} from "@tanstack/react-query" -import {GetAdminUsersResponse, getAdminUsers} from "~/apis" +import { + CreateReservedAliasData, + GetAdminUsersResponse, + createReservedAlias, + getAdminUsers, +} from "~/apis" +import {Grid, InputAdornment, MenuItem, TextField} from "@mui/material" +import {BiText} from "react-icons/bi" +import {HiUsers} from "react-icons/hi" +import {useErrorSuccessSnacks, useNavigateToNext, useUser} from "~/hooks" +import {ReservedAlias, ServerUser} from "~/server-types" +import {parseFastAPIError} from "~/utils" +import {SimpleForm} from "~/components" +import AliasExplanation from "~/route-widgets/AdminPage/AliasExplanation" + +interface Form { + local: string + users: string[] + + isActive?: boolean + + nonFieldError?: string +} export interface ReservedAliasesFormProps {} export default function ReservedAliasesForm({}: ReservedAliasesFormProps): ReactElement { + const {t} = useTranslation() + const meUser = useUser() + const {showError, showSuccess} = useErrorSuccessSnacks() + const navigateToNext = useNavigateToNext("/admin/reserved-aliases") const {data: {users} = {}} = useQuery( ["getAdminUsers"], getAdminUsers, ) + const {mutateAsync: createAlias} = useMutation< + ReservedAlias, + AxiosError, + CreateReservedAliasData + >(createReservedAlias, { + onSuccess: () => { + showSuccess(t("relations.alias.mutations.success.aliasCreation")) + navigateToNext() + }, + }) - console.log(users) + const schema = yup.object().shape({ + local: yup + .string() + .required() + .label(t("routes.AdminRoute.forms.reservedAliases.fields.local.label")), + isActive: yup + .boolean() + .label(t("routes.AdminRoute.forms.reservedAliases.fields.isActive.label")), + // Only store IDs of users, as they provide a reference to the user + users: yup + .array() + .of(yup.string()) + .label(t("routes.AdminRoute.forms.reservedAliases.fields.users.label")), + }) + const formik = useFormik
({ + validationSchema: schema, + initialValues: { + local: "", + users: [], + }, + onSubmit: async (values, {setErrors, resetForm}) => { + try { + await createAlias({ + local: values.local, + users: values.users.map(id => ({ + id, + })), + }) + } catch (error) { + setErrors(parseFastAPIError(error as AxiosError)) + } + }, + }) + const getUser = (id: string) => users?.find(user => user.id === id) as any as ServerUser + + if (!users) return null + + return ( + + + + + {[ + + + + ), + }} + name="local" + id="local" + label={t( + "routes.AdminRoute.forms.reservedAliases.fields.local.label", + )} + value={formik.values.local} + onChange={formik.handleChange} + disabled={formik.isSubmitting} + error={formik.touched.local && Boolean(formik.errors.local)} + helperText={formik.touched.local && formik.errors.local} + />, + + + + ), + }} + name="users" + id="users" + label={t( + "routes.AdminRoute.forms.reservedAliases.fields.users.label", + )} + SelectProps={{ + multiple: true, + value: formik.values.users, + onChange: formik.handleChange, + }} + disabled={formik.isSubmitting} + error={formik.touched.users && Boolean(formik.errors.users)} + helperText={formik.touched.users && formik.errors.users} + > + {users.map(user => ( + + {(() => { + // Check if user is me + if (user.id === meUser.id) { + return t( + "routes.AdminRoute.forms.reservedAliases.fields.users.me", + { + email: user.email.address, + }, + ) + } + + return user.email.address + })()} + + ))} + , + ]} + + + + + getUser(userId).email.address)} + /> + +
+ ) }