diff --git a/package.json b/package.json
index d458915..8d6e10d 100755
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
"immutability-helper": "^3.1.1",
"in-milliseconds": "^1.2.0",
"in-seconds": "^1.2.0",
+ "lodash": "^4.17.21",
"notistack": "^2.0.8",
"openpgp": "^5.5.0",
"react": "^18.2.0",
@@ -56,6 +57,7 @@
"@types/deep-equal": "^1.0.1",
"@types/group-array": "^1.0.1",
"@types/jest": "^29.2.4",
+ "@types/lodash": "^4.14.191",
"@types/node": "^18.11.18",
"@types/openpgp": "^4.4.18",
"@types/react-icons": "^3.0.0",
diff --git a/public/locales/en-US/translation.json b/public/locales/en-US/translation.json
index 8cffdb8..f67df95 100644
--- a/public/locales/en-US/translation.json
+++ b/public/locales/en-US/translation.json
@@ -3,6 +3,7 @@
"cancelLabel": "Cancel",
"emptyValue": "-",
"emptyUnavailableValue": "Unavailable",
+ "saveLabel": "Save",
"defaultValueSelection": "Default <{{value}}>",
"defaultValueSelectionRaw": "<{{value}}>",
@@ -279,6 +280,123 @@
"LogoutRoute": {
"title": "Log out",
"description": "We are logging you out..."
+ },
+ "ReservedAliasesRoute": {
+ "title": "Reserved Aliases",
+ "pageActions": {
+ "search": {
+ "label": "Search",
+ "placeholder": "Search for aliases"
+ }
+ },
+ "actions": {
+ "create": {
+ "label": "Create new Reserved Alias"
+ }
+ },
+ "userAmount_one": "Forwards to one user",
+ "userAmount_other": "Forwards to {{count}} users",
+ "emptyState": {
+ "title": "Create your first reserved alias",
+ "description": "Reserved aliases are aliases that will be forwarded to selected admin users. This is useful if you want to create aliases that are meant to be public, like contact@example.com or hello@example.com."
+ }
+ },
+ "ReservedAliasDetailRoute": {
+ "title": "Reserved Alias Details",
+ "sections": {
+ "users": {
+ "title": "Users",
+ "fields": {
+ "users": {
+ "label": "Users",
+ "me": "{{email}} (Me)"
+ }
+ }
+ }
+ }
+ },
+ "AdminRoute": {
+ "title": "Site configuration",
+ "routes": {
+ "reservedAliases": "Reserved Aliases",
+ "settings": "Global Settings"
+ },
+ "forms": {
+ "reservedAliases": {
+ "title": "Reserved Aliases",
+ "description": "Define what alias should forward to whom.",
+ "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"
+ }
+ },
+ "settings": {
+ "randomEmailIdMinLength": {
+ "label": "Minimum random alias ID length",
+ "description": "The minimum length for randomly generated emails. The server will automatically increase the length if required so."
+ },
+ "randomEmailIdChars": {
+ "label": "Random alias character pool",
+ "description": "Characters that are used to generate random emails."
+ },
+ "randomEmailLengthIncreaseOnPercentage": {
+ "label": "Percentage of used aliases",
+ "description": "If the percentage of used random email IDs is higher than this value, the length of the random email ID will be increased. This is used to prevent spammers from guessing the email ID."
+ },
+ "customEmailSuffixLength": {
+ "label": "Custom email suffix length",
+ "description": "The length of the custom email suffix."
+ },
+ "customEmailSuffixChars": {
+ "label": "Custom email suffix character pool",
+ "description": "Characters that are used to generate custom email suffixes."
+ },
+ "imageProxyStorageLifeTimeInHours": {
+ "label": "Image proxy storage lifetime",
+ "description": "The lifetime of images that are stored on the server in hours. After this time, the image will be deleted.",
+ "unit_one": "hour",
+ "unit_other": "hours"
+ },
+ "enableImageProxy": {
+ "label": "Enable image proxy",
+ "description": "If enabled, images will be stored on the server and forwarded to the user. This is useful if you want to prevent the user from seeing the IP address of the server. This will only affect new images."
+ },
+ "userEmailEnableDisposableEmails": {
+ "label": "Enable disposable emails for new accounts",
+ "description": "If enabled, users will be able to use disposable emails when creating a new account. This will only affect new accounts."
+ },
+ "userEmailEnableOtherRelays": {
+ "label": "Enable other relays for new accounts",
+ "description": "If enabled, users will be able to use other relays (such as SimpleLogin or DuckDuckGo's Email Tracking Protection) when creating a new account. This will only affect new accounts."
+ },
+ "allowStatistics": {
+ "label": "Allow statistics",
+ "description": "If enabled, your instance will collect anonymous statistics and share them. They will only be stored locally on this instance but made public."
+ }
+ }
+ },
+ "settings": {
+ "title": "Global Settings",
+ "description": "Configure global settings for your instance.",
+ "successMessage": "Settings have been saved successfully!",
+ "randomAliasesPreview": {
+ "title": "Random aliases will look like this",
+ "helperText": "This is just a preview. Those are not real aliases."
+ },
+ "randomAliasesIncreaseExplanation": "Random aliases' length will be increased from {{originalLength}} to {{increasedLength}} characters after {{amount}} aliases have been created.",
+ "resetLabel": "Reset to defaults"
+ }
}
},
@@ -287,7 +405,8 @@
"overview": "Overview",
"aliases": "Aliases",
"reports": "Reports",
- "settings": "Settings"
+ "settings": "Settings",
+ "admin": "Admin"
},
"AuthenticateRoute": {
"signup": "Sign up",
@@ -357,6 +476,19 @@
"doNotShare": "Do not share",
"decideLater": "Decide later",
"doNotAskAgain": "Do not ask again"
+ },
+ "StringPoolField": {
+ "addCustom": {
+ "label": "Add custom"
+ },
+ "forms": {
+ "addNew": {
+ "title": "Add new value",
+ "description": "Enter your characters you would like to include",
+ "label": "Characters",
+ "submit": "Add"
+ }
+ }
}
},
diff --git a/src/App.tsx b/src/App.tsx
index f2578fd..75f1d89 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -8,23 +8,27 @@ import {CssBaseline, ThemeProvider} from "@mui/material"
import {queryClient} from "~/constants/react-query"
import {getServerSettings} from "~/apis"
import {lightTheme} from "~/constants/themes"
+import AdminRoute from "~/routes/AdminRoute"
import AliasDetailRoute from "~/routes/AliasDetailRoute"
import AliasesRoute from "~/routes/AliasesRoute"
import AuthenticateRoute from "~/routes/AuthenticateRoute"
import AuthenticatedRoute from "~/routes/AuthenticatedRoute"
import CompleteAccountRoute from "~/routes/CompleteAccountRoute"
+import CreateReservedAliasRoute from "~/routes/CreateReservedAliasRoute"
import EnterDecryptionPassword from "~/routes/EnterDecryptionPassword"
+import GlobalSettingsRoute from "~/routes/GlobalSettingsRoute"
+import I18nHandler from "./I18nHandler"
import LoginRoute from "~/routes/LoginRoute"
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"
import SignupRoute from "~/routes/SignupRoute"
import VerifyEmailRoute from "~/routes/VerifyEmailRoute"
-
-import I18nHandler from "./I18nHandler"
import "./init-i18n"
const router = createBrowserRouter([
@@ -99,6 +103,28 @@ const router = createBrowserRouter([
loader: getServerSettings,
element: ,
},
+ {
+ path: "/admin",
+ element: ,
+ },
+ {
+ path: "/admin/reserved-aliases",
+ element: ,
+ },
+ {
+ path: "/admin/reserved-aliases/:id",
+ element: ,
+ },
+ {
+ path: "/admin/reserved-aliases/create",
+ loader: getServerSettings,
+ element: ,
+ },
+ {
+ path: "/admin/settings",
+ 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/get-admin-settings.ts b/src/apis/get-admin-settings.ts
new file mode 100644
index 0000000..afa9427
--- /dev/null
+++ b/src/apis/get-admin-settings.ts
@@ -0,0 +1,10 @@
+import {client} from "~/constants/axios-client"
+import {AdminSettings} from "~/server-types"
+
+export default async function getAdminSettings(): Promise> {
+ const {data} = await client.get(`${import.meta.env.VITE_SERVER_BASE_URL}/v1/admin/settings`, {
+ withCredentials: true,
+ })
+
+ return data
+}
diff --git a/src/apis/get-admin-users.ts b/src/apis/get-admin-users.ts
new file mode 100644
index 0000000..8d34dc6
--- /dev/null
+++ b/src/apis/get-admin-users.ts
@@ -0,0 +1,19 @@
+import {client} from "~/constants/axios-client"
+
+export interface GetAdminUsersResponse {
+ users: Array<{
+ id: string
+ email: {
+ id: string
+ address: string
+ }
+ }>
+}
+
+export default async function getAdminUsers(): Promise {
+ const {data} = await client.get(`${import.meta.env.VITE_SERVER_BASE_URL}/v1/admin/users`, {
+ withCredentials: true,
+ })
+
+ return data
+}
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/get-reserved-aliases.ts b/src/apis/get-reserved-aliases.ts
new file mode 100644
index 0000000..8cfb5ce
--- /dev/null
+++ b/src/apis/get-reserved-aliases.ts
@@ -0,0 +1,23 @@
+import {GetPageData, PaginationResult, ReservedAlias} from "~/server-types"
+import {client} from "~/constants/axios-client"
+
+export interface GetReservedAliasesData extends GetPageData {
+ query?: string
+}
+
+export default async function getReservedAliases({
+ query,
+ size,
+ page,
+}: GetReservedAliasesData = {}): Promise> {
+ const {data} = await client.get(`${import.meta.env.VITE_SERVER_BASE_URL}/v1/reserved-alias/`, {
+ withCredentials: true,
+ params: {
+ query,
+ size,
+ page,
+ },
+ })
+
+ return data
+}
diff --git a/src/apis/index.ts b/src/apis/index.ts
index 81a7522..bfb40f1 100644
--- a/src/apis/index.ts
+++ b/src/apis/index.ts
@@ -38,3 +38,17 @@ export * from "./delete-report"
export {default as deleteReport} from "./delete-report"
export * from "./get-me"
export {default as getMe} from "./get-me"
+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"
+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"
+export * from "./get-admin-settings"
+export {default as getAdminSettings} from "./get-admin-settings"
+export * from "./update-admin-settings"
+export {default as updateAdminSettings} from "./update-admin-settings"
diff --git a/src/apis/update-admin-settings.ts b/src/apis/update-admin-settings.ts
new file mode 100644
index 0000000..5be9fd9
--- /dev/null
+++ b/src/apis/update-admin-settings.ts
@@ -0,0 +1,16 @@
+import {client} from "~/constants/axios-client"
+import {AdminSettings} from "~/server-types"
+
+export default async function updateAdminSettings(
+ settings: Partial,
+): Promise {
+ const {data} = await client.patch(
+ `${import.meta.env.VITE_SERVER_BASE_URL}/v1/admin/settings`,
+ settings,
+ {
+ withCredentials: true,
+ },
+ )
+
+ return data
+}
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/components/AuthContext/AuthContextProvider.tsx b/src/components/AuthContext/AuthContextProvider.tsx
index 777e493..9ca2205 100644
--- a/src/components/AuthContext/AuthContextProvider.tsx
+++ b/src/components/AuthContext/AuthContextProvider.tsx
@@ -35,6 +35,7 @@ export default function AuthContextProvider({children}: AuthContextProviderProps
user as User,
)
const logout = useCallback(() => {
+ localStorage.removeItem("signup-form-state-email")
logoutMasterPassword()
setUser(null)
}, [logoutMasterPassword])
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/components/widgets/StringPoolField/AddNewDialog.tsx b/src/components/widgets/StringPoolField/AddNewDialog.tsx
new file mode 100644
index 0000000..9ef899f
--- /dev/null
+++ b/src/components/widgets/StringPoolField/AddNewDialog.tsx
@@ -0,0 +1,68 @@
+import {ReactElement, useState} from "react"
+import {useTranslation} from "react-i18next"
+import {MdCheck} from "react-icons/md"
+import {TiCancel} from "react-icons/ti"
+
+import {
+ Box,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ DialogTitle,
+ TextField,
+} from "@mui/material"
+
+import {whenEnterPressed} from "~/utils"
+
+export interface StringPoolFieldProps {
+ onCreated: (value: string) => void
+ onClose: () => void
+
+ open?: boolean
+}
+
+export default function AddNewDialog({
+ onCreated,
+ open = false,
+ onClose,
+}: StringPoolFieldProps): ReactElement {
+ const {t} = useTranslation()
+
+ const [value, setValue] = useState("")
+
+ return (
+
+ )
+}
diff --git a/src/components/widgets/StringPoolField/StringPoolField.tsx b/src/components/widgets/StringPoolField/StringPoolField.tsx
new file mode 100644
index 0000000..b3f91c0
--- /dev/null
+++ b/src/components/widgets/StringPoolField/StringPoolField.tsx
@@ -0,0 +1,183 @@
+import {useTranslation} from "react-i18next"
+import {MdAdd} from "react-icons/md"
+import React, {ReactElement, useLayoutEffect, useMemo, useState} from "react"
+
+import {
+ Box,
+ Checkbox,
+ Chip,
+ FormControl,
+ FormHelperText,
+ InputLabel,
+ ListItemIcon,
+ ListItemText,
+ MenuItem,
+ Select,
+ SelectProps,
+} from "@mui/material"
+
+import AddNewDialog from "./AddNewDialog"
+
+export interface StringPoolFieldProps
+ extends Omit, "onChange" | "value" | "multiple" | "labelId" | "label"> {
+ pools: Record
+ label: string
+ value: string
+ onChange: SelectProps["onChange"]
+ id: string
+
+ allowCustom?: boolean
+ helperText?: string | string[]
+ error?: boolean
+}
+
+export function createPool(pools: Record): Record {
+ return Object.fromEntries(
+ Object.entries(pools).map(([key, value]) => [key.split("").sort().join(""), value]),
+ )
+}
+
+export default function StringPoolField({
+ pools,
+ value,
+ helperText,
+ id,
+ error,
+ label,
+ onChange,
+ onOpen,
+ allowCustom,
+ name,
+ fullWidth,
+ ...props
+}: StringPoolFieldProps): ReactElement {
+ const {t} = useTranslation()
+
+ const reversedPoolsMap = useMemo(
+ () => Object.fromEntries(Object.entries(pools).map(([key, value]) => [value, key])),
+ [pools],
+ )
+ const [isInAddMode, setIsInAddMode] = useState(false)
+ const [uiRemainingValue, setUiRemainingValue] = useState("")
+
+ const selectedValueMaps = Object.entries(pools)
+ .filter(([key]) => value.includes(key))
+ .map(([, value]) => value)
+ const remainingValue = (() => {
+ // List of all characters inside the pools
+ const charactersInPools = Object.keys(pools).join("")
+
+ return value
+ .split("")
+ .filter(char => !charactersInPools.includes(char))
+ .join("")
+ })()
+ const selectValue = [...selectedValueMaps, remainingValue].filter(Boolean)
+
+ useLayoutEffect(() => {
+ if (remainingValue) {
+ setUiRemainingValue(remainingValue)
+ }
+ }, [remainingValue])
+
+ return (
+ <>
+
+
+ {label}
+
+
+ {helperText ? {helperText} : null}
+
+ {
+ setIsInAddMode(false)
+
+ // @ts-ignore: This is enough for formik.
+ onChange({
+ target: {
+ name,
+ value: value + newValue,
+ },
+ })
+ }}
+ onClose={() => setIsInAddMode(false)}
+ open={isInAddMode}
+ />
+ >
+ )
+}
diff --git a/src/components/widgets/StringPoolField/index.ts b/src/components/widgets/StringPoolField/index.ts
new file mode 100644
index 0000000..a9a2018
--- /dev/null
+++ b/src/components/widgets/StringPoolField/index.ts
@@ -0,0 +1,2 @@
+export * from "./StringPoolField"
+export {default as StringPoolField} from "./StringPoolField"
diff --git a/src/components/widgets/index.ts b/src/components/widgets/index.ts
index b8b3f5e..d25c848 100644
--- a/src/components/widgets/index.ts
+++ b/src/components/widgets/index.ts
@@ -45,5 +45,6 @@ export {default as LoadingData} from "./LoadingData"
export * from "./ExternalLinkIndication"
export {default as ExternalLinkIndication} from "./ExternalLinkIndication"
export {default as ExtensionSignalHandler} from "./ExtensionalSignalHandler"
+export * from "./StringPoolField"
export * as SimplePageBuilder from "./simple-page-builder"
diff --git a/src/constants/admin-settings.ts b/src/constants/admin-settings.ts
new file mode 100644
index 0000000..5d340a0
--- /dev/null
+++ b/src/constants/admin-settings.ts
@@ -0,0 +1,14 @@
+import {AdminSettings} from "~/server-types"
+
+export const DEFAULT_ADMIN_SETTINGS: AdminSettings = {
+ randomEmailIdMinLength: 6,
+ randomEmailIdChars: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
+ randomEmailLengthIncreaseOnPercentage: 0.0005,
+ customEmailSuffixLength: 4,
+ customEmailSuffixChars: "0123456789",
+ userEmailEnableOtherRelays: true,
+ userEmailEnableDisposableEmails: false,
+ imageProxyStorageLifeTimeInHours: 24,
+ enableImageProxy: true,
+ allowStatistics: true,
+}
diff --git a/src/hooks/use-navigate-to-next.ts b/src/hooks/use-navigate-to-next.ts
index 5dc3cdf..0346133 100644
--- a/src/hooks/use-navigate-to-next.ts
+++ b/src/hooks/use-navigate-to-next.ts
@@ -3,7 +3,7 @@ import {useCallback} from "react"
import {getNextUrl} from "~/utils"
-export default function useNavigateToNext(defaultNextUrl = "/"): () => void {
+export default function useNavigateToNext(defaultNextUrl = "/aliases"): () => void {
const navigate = useNavigate()
const location = useLocation()
diff --git a/src/route-widgets/AdminPage/ReservedAliasesList.tsx b/src/route-widgets/AdminPage/ReservedAliasesList.tsx
new file mode 100644
index 0000000..b856845
--- /dev/null
+++ b/src/route-widgets/AdminPage/ReservedAliasesList.tsx
@@ -0,0 +1,32 @@
+import {ReactElement} from "react"
+import {AxiosError} from "axios"
+
+import {useQuery} from "@tanstack/react-query"
+import {List, ListItem, ListItemText} from "@mui/material"
+
+import {getReservedAliases} from "~/apis"
+import {PaginationResult, ReservedAlias} from "~/server-types"
+import {QueryResult} from "~/components"
+
+export interface ReservedAliasesListProps {}
+
+export default function ReservedAliasesList({}: ReservedAliasesListProps): ReactElement {
+ const query = useQuery, AxiosError>(
+ ["getReservedAliases"],
+ () => getReservedAliases(),
+ )
+
+ return (
+ , AxiosError> query={query}>
+ {({items}) => (
+
+ {items.map(alias => (
+
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/src/route-widgets/AuthenticateRoute/NavigationButton.tsx b/src/route-widgets/AuthenticateRoute/NavigationButton.tsx
index 0ef1b8e..022fc05 100644
--- a/src/route-widgets/AuthenticateRoute/NavigationButton.tsx
+++ b/src/route-widgets/AuthenticateRoute/NavigationButton.tsx
@@ -1,7 +1,7 @@
import {ReactElement, useContext} from "react"
import {BiStats} from "react-icons/bi"
import {MdSettings} from "react-icons/md"
-import {FaMask} from "react-icons/fa"
+import {FaMask, FaServer} from "react-icons/fa"
import {Link as RouterLink, useLocation} from "react-router-dom"
import {useTranslation} from "react-i18next"
@@ -16,6 +16,7 @@ export enum NavigationSection {
Aliases,
Reports,
Settings,
+ Admin,
}
export interface NavigationButtonProps {
@@ -27,6 +28,7 @@ const SECTION_ICON_MAP: Record = {
[NavigationSection.Aliases]: ,
[NavigationSection.Reports]: ,
[NavigationSection.Settings]: ,
+ [NavigationSection.Admin]: ,
}
const SECTION_TEXT_MAP: Record = {
@@ -34,6 +36,7 @@ const SECTION_TEXT_MAP: Record = {
[NavigationSection.Aliases]: "components.NavigationButton.aliases",
[NavigationSection.Reports]: "components.NavigationButton.reports",
[NavigationSection.Settings]: "components.NavigationButton.settings",
+ [NavigationSection.Admin]: "components.NavigationButton.admin",
}
const PATH_SECTION_MAP: Record = {
@@ -41,6 +44,7 @@ const PATH_SECTION_MAP: Record = {
aliases: NavigationSection.Aliases,
reports: NavigationSection.Reports,
settings: NavigationSection.Settings,
+ admin: NavigationSection.Admin,
}
export default function NavigationButton({section}: NavigationButtonProps): ReactElement {
diff --git a/src/route-widgets/CreateReservedAliasRoute/AliasExplanation.tsx b/src/route-widgets/CreateReservedAliasRoute/AliasExplanation.tsx
new file mode 100644
index 0000000..5462f1f
--- /dev/null
+++ b/src/route-widgets/CreateReservedAliasRoute/AliasExplanation.tsx
@@ -0,0 +1,102 @@
+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 {BsArrowRight} 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/CreateReservedAliasRoute/UsersSelectField.tsx b/src/route-widgets/CreateReservedAliasRoute/UsersSelectField.tsx
new file mode 100644
index 0000000..029d50e
--- /dev/null
+++ b/src/route-widgets/CreateReservedAliasRoute/UsersSelectField.tsx
@@ -0,0 +1,131 @@
+import {ReactElement} from "react"
+import {HiUsers} from "react-icons/hi"
+import {useTranslation} from "react-i18next"
+import {AxiosError} from "axios"
+
+import {useQuery} from "@tanstack/react-query"
+import {
+ Box,
+ Checkbox,
+ Chip,
+ FormControl,
+ FormHelperText,
+ InputAdornment,
+ InputLabel,
+ ListItemText,
+ MenuItem,
+ Select,
+ SelectProps,
+} from "@mui/material"
+
+import {GetAdminUsersResponse, getAdminUsers} from "~/apis"
+import {useUser} from "~/hooks"
+
+export interface UsersSelectFieldProps
+ extends Omit {
+ onChange: SelectProps["onChange"]
+ value: GetAdminUsersResponse["users"]
+
+ helperText?: string | string[]
+ error?: boolean
+}
+
+export default function UsersSelectField({
+ value,
+ onChange,
+ helperText,
+ error,
+ ...props
+}: UsersSelectFieldProps): ReactElement {
+ const {t} = useTranslation()
+ const meUser = useUser()
+ const {data: {users} = {}} = useQuery(
+ ["getAdminUsers"],
+ getAdminUsers,
+ )
+ const findUser = (id: string) => users?.find(user => user.id === id)
+ const userIds = value?.map(user => user.id) || []
+
+ return (
+
+
+ {t("routes.AdminRoute.forms.reservedAliases.fields.users.label")}
+
+
+ {helperText ? {helperText} : null}
+
+ )
+}
diff --git a/src/route-widgets/GlobalSettingsRoute/AliasPercentageAmount.tsx b/src/route-widgets/GlobalSettingsRoute/AliasPercentageAmount.tsx
new file mode 100644
index 0000000..117a1da
--- /dev/null
+++ b/src/route-widgets/GlobalSettingsRoute/AliasPercentageAmount.tsx
@@ -0,0 +1,32 @@
+import {ReactElement} from "react"
+import {useTranslation} from "react-i18next"
+
+import {Alert, Typography} from "@mui/material"
+
+export interface AliasPercentageAmountProps {
+ characters: string
+ length: number
+ percentage: number
+}
+
+export default function AliasesPercentageAmount({
+ characters,
+ length,
+ percentage,
+}: AliasPercentageAmountProps): ReactElement {
+ const {t} = useTranslation()
+
+ const amount = Math.floor(Math.pow(characters.length, length) * percentage)
+
+ return (
+
+
+ {t("routes.AdminRoute.settings.randomAliasesIncreaseExplanation", {
+ originalLength: length,
+ increasedLength: length + 1,
+ amount,
+ })}
+
+
+ )
+}
diff --git a/src/route-widgets/GlobalSettingsRoute/RandomAliasGenerator.tsx b/src/route-widgets/GlobalSettingsRoute/RandomAliasGenerator.tsx
new file mode 100644
index 0000000..37cb4b6
--- /dev/null
+++ b/src/route-widgets/GlobalSettingsRoute/RandomAliasGenerator.tsx
@@ -0,0 +1,59 @@
+import {useLoaderData} from "react-router-dom"
+import {ReactElement, useCallback, useState} from "react"
+import {useUpdateEffect} from "react-use"
+import {BiRefresh} from "react-icons/bi"
+import {useTranslation} from "react-i18next"
+
+import {Alert, FormHelperText, Grid, IconButton, Typography, useTheme} from "@mui/material"
+
+import {ServerSettings} from "~/server-types"
+
+export interface RandomAliasGeneratorProps {
+ characters: string
+ length: number
+}
+
+export default function RandomAliasGenerator({
+ characters,
+ length,
+}: RandomAliasGeneratorProps): ReactElement {
+ const serverSettings = useLoaderData() as ServerSettings
+ const {t} = useTranslation()
+ const theme = useTheme()
+
+ const generateLocal = useCallback(
+ () =>
+ Array.from({length}, () =>
+ characters.charAt(Math.floor(Math.random() * characters.length)),
+ ).join(""),
+ [characters, length],
+ )
+ const [local, setLocal] = useState(generateLocal)
+
+ const email = `${local}@${serverSettings.mailDomain}`
+
+ useUpdateEffect(() => {
+ setLocal(generateLocal())
+ }, [generateLocal])
+
+ return (
+
+
+ {t("routes.AdminRoute.settings.randomAliasesPreview.title")}
+
+
+
+ {email}
+
+
+ setLocal(generateLocal())}>
+
+
+
+
+
+ {t("routes.AdminRoute.settings.randomAliasesPreview.helperText")}
+
+
+ )
+}
diff --git a/src/route-widgets/GlobalSettingsRoute/SettingsForm.tsx b/src/route-widgets/GlobalSettingsRoute/SettingsForm.tsx
new file mode 100644
index 0000000..5207c2f
--- /dev/null
+++ b/src/route-widgets/GlobalSettingsRoute/SettingsForm.tsx
@@ -0,0 +1,525 @@
+import * as yup from "yup"
+import {useFormik} from "formik"
+import {TbCursorText} from "react-icons/tb"
+import {useTranslation} from "react-i18next"
+import {MdCheck, MdClear, MdOutlineChangeCircle, MdTextFormat} from "react-icons/md"
+import {BsImage} from "react-icons/bs"
+import {AxiosError} from "axios"
+
+import {
+ Checkbox,
+ FormControlLabel,
+ FormGroup,
+ FormHelperText,
+ Grid,
+ InputAdornment,
+ TextField,
+ Typography,
+} from "@mui/material"
+import {LoadingButton} from "@mui/lab"
+import {useMutation} from "@tanstack/react-query"
+
+import {AdminSettings} from "~/server-types"
+import {StringPoolField, createPool} from "~/components"
+import {updateAdminSettings} from "~/apis"
+import {useErrorSuccessSnacks} from "~/hooks"
+import {queryClient} from "~/constants/react-query"
+import {parseFastAPIError} from "~/utils"
+import {DEFAULT_ADMIN_SETTINGS} from "~/constants/admin-settings"
+import AliasesPercentageAmount from "./AliasPercentageAmount"
+import RandomAliasGenerator from "~/route-widgets/GlobalSettingsRoute/RandomAliasGenerator"
+
+export interface SettingsFormProps {
+ settings: AdminSettings
+ queryKey: readonly string[]
+}
+
+const DEFAULT_POOLS = createPool({
+ abcdefghijklmnopqrstuvwxyz: "a-z",
+ ABCDEFGHIJKLMNOPQRSTUVWXYZ: "A-Z",
+ "0123456789": "0-9",
+})
+
+export default function SettingsForm({settings, queryKey}: SettingsFormProps) {
+ const {t} = useTranslation()
+ const {showSuccess, showError} = useErrorSuccessSnacks()
+
+ const validationSchema = yup.object().shape({
+ randomEmailIdMinLength: yup
+ .number()
+ .min(1)
+ .max(1_023)
+ .label(t("routes.AdminRoute.forms.settings.randomEmailIdMinLength.label")),
+ randomEmailIdChars: yup
+ .string()
+ .label(t("routes.AdminRoute.forms.settings.randomEmailIdChars.label")),
+ randomEmailLengthIncreaseOnPercentage: yup
+ .number()
+ .min(0)
+ .max(1)
+ .label(
+ t("routes.AdminRoute.forms.settings.randomEmailLengthIncreaseOnPercentage.label"),
+ ),
+ imageProxyStorageLifeTimeInHours: yup
+ .number()
+ .label(t("routes.AdminRoute.forms.settings.imageProxyStorageLifeTimeInHours.label")),
+ customEmailSuffixLength: yup
+ .number()
+ .min(1)
+ .max(1_023)
+ .label(t("routes.AdminRoute.forms.settings.customEmailSuffixLength-label")),
+ customEmailSuffixChars: yup
+ .string()
+ .label(t("routes.AdminRoute.forms.settings.customEmailSuffixChars.label")),
+ userEmailEnableDisposableEmails: yup
+ .boolean()
+ .label(t("routes.AdminRoute.forms.settings.userEmailEnableDisposableEmails.label")),
+ userEmailEnableOtherRelays: yup
+ .boolean()
+ .label(t("routes.AdminRoute.forms.settings.userEmailEnableOtherRelays.label")),
+ enableImageProxy: yup
+ .boolean()
+ .label(t("routes.AdminRoute.forms.settings.enableImageProxy.label")),
+ allowStatistics: yup
+ .boolean()
+ .label(t("routes.AdminRoute.forms.settings.allowStatistics.label")),
+ })
+
+ const {mutateAsync} = useMutation>(
+ async settings => {
+ // Set values to `null` that are their defaults
+ const strippedSettings = Object.fromEntries(
+ Object.entries(settings as AdminSettings).map(([key, value]) => {
+ if (value === DEFAULT_ADMIN_SETTINGS[key as keyof AdminSettings]) {
+ return [key, null]
+ }
+
+ return [key, value]
+ }),
+ )
+
+ return updateAdminSettings(strippedSettings)
+ },
+ {
+ onError: showError,
+ onSuccess: newSettings => {
+ showSuccess(t("routes.AdminRoute.settings.successMessage"))
+
+ queryClient.setQueryData(queryKey, newSettings)
+ },
+ },
+ )
+
+ const formik = useFormik({
+ validationSchema,
+ onSubmit: async (values, {setErrors}) => {
+ try {
+ await mutateAsync(values)
+ } catch (error) {
+ setErrors(parseFastAPIError(error as AxiosError))
+ }
+ },
+ initialValues: settings,
+ })
+
+ // Fields will either have a value or be filled from the default values.
+ // That means we will never have a `null` value.
+ return (
+
+ )
+}
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 => (
+
+ ))}
+
+ )
+}
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