Merge pull request #1 from Myzel394/add-admin-pages

Add admin pages
This commit is contained in:
Myzel394 2023-02-12 17:17:03 +00:00 committed by GitHub
commit e5403552cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 2299 additions and 14 deletions

View File

@ -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",

View File

@ -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"
}
}
}
},

View File

@ -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: <EnterDecryptionPassword />,
},
{
path: "/admin",
element: <AdminRoute />,
},
{
path: "/admin/reserved-aliases",
element: <ReservedAliasesRoute />,
},
{
path: "/admin/reserved-aliases/:id",
element: <ReservedAliasDetailRoute />,
},
{
path: "/admin/reserved-aliases/create",
loader: getServerSettings,
element: <CreateReservedAliasRoute />,
},
{
path: "/admin/settings",
loader: getServerSettings,
element: <GlobalSettingsRoute />,
},
],
},
],

View File

@ -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<ReservedAlias> {
const {data} = await client.post(
`${import.meta.env.VITE_SERVER_BASE_URL}/v1/reserved-alias`,
aliasData,
{
withCredentials: true,
},
)
return data
}

View File

@ -0,0 +1,10 @@
import {client} from "~/constants/axios-client"
import {AdminSettings} from "~/server-types"
export default async function getAdminSettings(): Promise<Partial<AdminSettings>> {
const {data} = await client.get(`${import.meta.env.VITE_SERVER_BASE_URL}/v1/admin/settings`, {
withCredentials: true,
})
return data
}

View File

@ -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<GetAdminUsersResponse> {
const {data} = await client.get(`${import.meta.env.VITE_SERVER_BASE_URL}/v1/admin/users`, {
withCredentials: true,
})
return data
}

View File

@ -0,0 +1,13 @@
import {ReservedAlias} from "~/server-types"
import {client} from "~/constants/axios-client"
export default async function getReservedAlias(id: string): Promise<ReservedAlias> {
const {data} = await client.get(
`${import.meta.env.VITE_SERVER_BASE_URL}/v1/reserved-alias/${id}`,
{
withCredentials: true,
},
)
return data
}

View File

@ -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<PaginationResult<ReservedAlias>> {
const {data} = await client.get(`${import.meta.env.VITE_SERVER_BASE_URL}/v1/reserved-alias/`, {
withCredentials: true,
params: {
query,
size,
page,
},
})
return data
}

View File

@ -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"

View File

@ -0,0 +1,16 @@
import {client} from "~/constants/axios-client"
import {AdminSettings} from "~/server-types"
export default async function updateAdminSettings(
settings: Partial<AdminSettings>,
): Promise<AdminSettings> {
const {data} = await client.patch(
`${import.meta.env.VITE_SERVER_BASE_URL}/v1/admin/settings`,
settings,
{
withCredentials: true,
},
)
return data
}

View File

@ -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<ReservedAlias> {
const {data} = await client.patch(
`${import.meta.env.VITE_SERVER_BASE_URL}/v1/reserved-alias/${id}`,
{
isActive,
users,
},
{
withCredentials: true,
},
)
return data
}

View File

@ -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])

View File

@ -73,7 +73,7 @@ export default function SimpleForm({
<Grid item>
<Grid container spacing={3} direction="column" alignItems="center">
{children.map(input => (
<Grid item key={input.key}>
<Grid item key={input.key} width="100%">
{input}
</Grid>
))}

View File

@ -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<string>("")
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>{t("components.StringPoolField.forms.addNew.title")}</DialogTitle>
<DialogContent>
<DialogContentText>
{t("components.StringPoolField.forms.addNew.description")}
</DialogContentText>
<Box my={2}>
<TextField
value={value}
onChange={e => setValue(e.target.value)}
label={t("components.StringPoolField.forms.addNew.label")}
name="addNew"
fullWidth
autoFocus
onKeyUp={whenEnterPressed(() => onCreated(value))}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} startIcon={<TiCancel />} variant="text">
{t("general.cancelLabel")}
</Button>
<Button
onClick={() => onCreated(value)}
variant="contained"
startIcon={<MdCheck />}
>
{t("components.StringPoolField.forms.addNew.submit")}
</Button>
</DialogActions>
</Dialog>
)
}

View File

@ -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<SelectProps<string[]>, "onChange" | "value" | "multiple" | "labelId" | "label"> {
pools: Record<string, string>
label: string
value: string
onChange: SelectProps<string>["onChange"]
id: string
allowCustom?: boolean
helperText?: string | string[]
error?: boolean
}
export function createPool(pools: Record<string, string>): Record<string, string> {
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<boolean>(false)
const [uiRemainingValue, setUiRemainingValue] = useState<string>("")
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 (
<>
<FormControl sx={{minWidth: 180}} fullWidth={fullWidth} error={error}>
<InputLabel id={id} error={error}>
{label}
</InputLabel>
<Select<string[]>
multiple
name={name}
labelId={id}
renderValue={(selected: string[]) => (
<Box sx={{display: "flex", flexWrap: "wrap", gap: 0.5}}>
{selected.map(value => (
<Chip key={value} label={value} />
))}
</Box>
)}
value={selectValue}
label={label}
onOpen={event => {
if (!remainingValue) {
setUiRemainingValue("")
}
onOpen?.(event)
}}
onChange={(event, child) => {
if (!Array.isArray(event.target.value)) {
return
}
const value = event.target.value.reduce((acc, value) => {
if (reversedPoolsMap[value]) {
return acc + reversedPoolsMap[value]
}
return acc + value
}, "")
onChange!(
// @ts-ignore
{
...event,
target: {
...event.target,
value: value as string,
},
},
child,
)
}}
{...props}
>
{Object.entries(pools).map(([poolValue, label]) => (
<MenuItem key={poolValue} value={label} title={poolValue}>
<Checkbox checked={value.includes(poolValue)} />
<ListItemText primary={label} />
</MenuItem>
))}
{uiRemainingValue && (
<MenuItem value={uiRemainingValue}>
<Checkbox checked={uiRemainingValue === remainingValue} />
<ListItemText primary={uiRemainingValue} />
</MenuItem>
)}
{allowCustom && (
<MenuItem
onClick={event => {
event.preventDefault()
setIsInAddMode(true)
}}
>
<ListItemIcon>
<MdAdd />
</ListItemIcon>
<ListItemText
primary={t("components.StringPoolField.addCustom.label")}
/>
</MenuItem>
)}
</Select>
{helperText ? <FormHelperText error={error}>{helperText}</FormHelperText> : null}
</FormControl>
<AddNewDialog
onCreated={newValue => {
setIsInAddMode(false)
// @ts-ignore: This is enough for formik.
onChange({
target: {
name,
value: value + newValue,
},
})
}}
onClose={() => setIsInAddMode(false)}
open={isInAddMode}
/>
</>
)
}

View File

@ -0,0 +1,2 @@
export * from "./StringPoolField"
export {default as StringPoolField} from "./StringPoolField"

View File

@ -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"

View File

@ -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,
}

View File

@ -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()

View File

@ -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<PaginationResult<ReservedAlias>, AxiosError>(
["getReservedAliases"],
() => getReservedAliases(),
)
return (
<QueryResult<PaginationResult<ReservedAlias>, AxiosError> query={query}>
{({items}) => (
<List>
{items.map(alias => (
<ListItem key={alias.id}>
<ListItemText primary={`${alias.local}@${alias.domain}`} />
</ListItem>
))}
</List>
)}
</QueryResult>
)
}

View File

@ -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, ReactElement> = {
[NavigationSection.Aliases]: <FaMask />,
[NavigationSection.Reports]: <Icon path={mdiTextBoxMultiple} size={0.8} />,
[NavigationSection.Settings]: <MdSettings />,
[NavigationSection.Admin]: <FaServer />,
}
const SECTION_TEXT_MAP: Record<NavigationSection, string> = {
@ -34,6 +36,7 @@ const SECTION_TEXT_MAP: Record<NavigationSection, string> = {
[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<string, NavigationSection> = {
@ -41,6 +44,7 @@ const PATH_SECTION_MAP: Record<string, NavigationSection> = {
aliases: NavigationSection.Aliases,
reports: NavigationSection.Reports,
settings: NavigationSection.Settings,
admin: NavigationSection.Admin,
}
export default function NavigationButton({section}: NavigationButtonProps): ReactElement {

View File

@ -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 (
<Grid
container
direction="row"
padding={4}
gap={4}
borderRadius={theme.shape.borderRadius}
border={1}
borderColor={theme.palette.text.disabled}
bgcolor={theme.palette.background.default}
flexWrap="nowrap"
>
<Grid item>
<Grid container direction="column" spacing={1} alignItems="center">
<Grid item>
<MdMail size={24} />
</Grid>
<Grid item>
<Typography variant="caption" textAlign="center">
{t("routes.AdminRoute.forms.reservedAliases.explanation.step1")}
</Typography>
</Grid>
</Grid>
</Grid>
<Grid item>
<Grid container direction="column" spacing={1} alignItems="center">
<Grid item>
<BsArrowRight size={24} />
</Grid>
<Grid item>
<Typography variant="caption" textAlign="center">
{t("routes.AdminRoute.forms.reservedAliases.explanation.step2")}
</Typography>
</Grid>
</Grid>
</Grid>
<Grid item>
<Grid container direction="column" spacing={1} alignItems="center">
<Grid item>
<FaMask size={24} />
</Grid>
<Grid item>
<Typography variant="body1" textAlign="center">
<span style={{display: "block"}}>{local}</span>
<span style={{opacity: 0.4, wordBreak: "break-word"}}>
@{serverSettings.mailDomain}
</span>
</Typography>
</Grid>
</Grid>
</Grid>
<Grid item>
<Grid container direction="column" spacing={1} alignItems="center">
<Grid item>
<BsArrowRight size={24} />
</Grid>
<Grid item>
<Typography variant="caption" textAlign="center">
{t("routes.AdminRoute.forms.reservedAliases.explanation.step4")}
</Typography>
</Grid>
</Grid>
</Grid>
<Grid item>
<Grid container direction="column" spacing={1} alignItems="center">
<Grid item>
<HiUsers size={24} />
</Grid>
<Grid item>
<List dense>
{emails.map(email => (
<ListItem key={email}>
<ListItemText primary={email} />
</ListItem>
))}
</List>
</Grid>
</Grid>
</Grid>
</Grid>
)
}

View File

@ -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<SelectProps, "onChange" | "value" | "multiple" | "labelId"> {
onChange: SelectProps<GetAdminUsersResponse["users"]>["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<GetAdminUsersResponse, AxiosError>(
["getAdminUsers"],
getAdminUsers,
)
const findUser = (id: string) => users?.find(user => user.id === id)
const userIds = value?.map(user => user.id) || []
return (
<FormControl sx={{minWidth: 180}}>
<InputLabel id="users-select" error={error}>
{t("routes.AdminRoute.forms.reservedAliases.fields.users.label")}
</InputLabel>
<Select<string[]>
{...props}
multiple
labelId="users-select"
defaultValue={[]}
value={userIds}
startAdornment={
<InputAdornment position="start">
<HiUsers />
</InputAdornment>
}
renderValue={(selected: string[]) => (
<Box sx={{display: "flex", flexWrap: "wrap", gap: 0.5}}>
{selected.map(value => (
<Chip key={value} label={findUser(value)!.email.address} />
))}
</Box>
)}
onChange={(event, child) => {
if (!Array.isArray(event.target.value)) {
return
}
// Since there will probably only be a few admin users, n^2 is fine
const selectedUsers = (event.target.value as string[]).map(id =>
users!.find(user => user.id === id),
)
if (!selectedUsers) {
return
}
onChange!(
// @ts-ignore
{
...event,
target: {
...event.target,
value: selectedUsers as GetAdminUsersResponse["users"],
},
},
child,
)
}}
name="users"
id="users"
error={error}
label={t("routes.AdminRoute.forms.reservedAliases.fields.users.label")}
>
{users ? (
users.map(user => (
<MenuItem key={user.id} value={user.id}>
<Checkbox checked={userIds.includes(user.id)} />
<ListItemText
primary={(() => {
// 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
})()}
/>
</MenuItem>
))
) : (
<MenuItem value={""}>{t("general.loading")}</MenuItem>
)}
</Select>
{helperText ? <FormHelperText error={error}>{helperText}</FormHelperText> : null}
</FormControl>
)
}

View File

@ -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 (
<Alert severity="info" variant="standard">
<Typography variant="subtitle1" component="h5">
{t("routes.AdminRoute.settings.randomAliasesIncreaseExplanation", {
originalLength: length,
increasedLength: length + 1,
amount,
})}
</Typography>
</Alert>
)
}

View File

@ -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<string>(generateLocal)
const email = `${local}@${serverSettings.mailDomain}`
useUpdateEffect(() => {
setLocal(generateLocal())
}, [generateLocal])
return (
<Alert severity="info" variant="standard">
<Typography variant="subtitle1" component="h5">
{t("routes.AdminRoute.settings.randomAliasesPreview.title")}
</Typography>
<Grid container spacing={2} direction="row" alignItems="center">
<Grid item>
<Typography variant="body2">{email}</Typography>
</Grid>
<Grid item>
<IconButton size="small" onClick={() => setLocal(generateLocal())}>
<BiRefresh />
</IconButton>
</Grid>
</Grid>
<FormHelperText>
{t("routes.AdminRoute.settings.randomAliasesPreview.helperText")}
</FormHelperText>
</Alert>
)
}

View File

@ -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<AdminSettings, AxiosError, Partial<AdminSettings>>(
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<AdminSettings>(queryKey, newSettings)
},
},
)
const formik = useFormik<AdminSettings & {detail?: string}>({
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 (
<form onSubmit={formik.handleSubmit}>
<Grid
container
spacing={4}
paddingX={2}
paddingY={4}
direction="column"
alignItems="center"
justifyContent="center"
>
<Grid item>
<Grid container spacing={2} direction="column">
<Grid item>
<Typography variant="h4" component="h1" align="center">
{t("routes.AdminRoute.settings.title")}
</Typography>
</Grid>
<Grid item>
<Typography variant="subtitle1" component="p">
{t("routes.AdminRoute.settings.description")}
</Typography>
</Grid>
</Grid>
</Grid>
<Grid item>
<Grid container spacing={3} direction="row" alignItems="start">
<Grid item md={6}>
<TextField
key="random_email_id_min_length"
fullWidth
label={t(
"routes.AdminRoute.forms.settings.randomEmailIdMinLength.label",
)}
name="randomEmailIdMinLength"
value={formik.values.randomEmailIdMinLength}
onChange={formik.handleChange}
error={
formik.touched.randomEmailIdMinLength &&
Boolean(formik.errors.randomEmailIdMinLength)
}
helperText={
(formik.touched.randomEmailIdMinLength &&
formik.errors.randomEmailIdMinLength) ||
t(
"routes.AdminRoute.forms.settings.randomEmailIdMinLength.description",
)
}
type="number"
disabled={formik.isSubmitting}
inputMode="numeric"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<TbCursorText />
</InputAdornment>
),
}}
/>
</Grid>
<Grid item md={6}>
<StringPoolField
fullWidth
allowCustom
key="random_email_id_chars"
pools={DEFAULT_POOLS}
id="random_email_id_chars"
label={t(
"routes.AdminRoute.forms.settings.randomEmailIdChars.label",
)}
name="randomEmailIdChars"
value={formik.values.randomEmailIdChars!}
onChange={formik.handleChange}
error={
formik.touched.randomEmailIdChars &&
Boolean(formik.errors.randomEmailIdChars)
}
helperText={
(formik.touched.randomEmailIdChars &&
formik.errors.randomEmailIdChars) ||
(t(
"routes.AdminRoute.forms.settings.randomEmailIdChars.description",
) as string)
}
disabled={formik.isSubmitting}
startAdornment={
<InputAdornment position="start">
<MdTextFormat />
</InputAdornment>
}
/>
</Grid>
<Grid item marginX="auto">
<RandomAliasGenerator
characters={formik.values.randomEmailIdChars!}
length={formik.values.randomEmailIdMinLength!}
/>
</Grid>
<Grid item>
<TextField
key="random_email_length_increase_on_percentage"
fullWidth
label={t(
"routes.AdminRoute.forms.settings.randomEmailLengthIncreaseOnPercentage.label",
)}
name="randomEmailLengthIncreaseOnPercentage"
value={formik.values.randomEmailLengthIncreaseOnPercentage}
onChange={formik.handleChange}
error={
formik.touched.randomEmailLengthIncreaseOnPercentage &&
Boolean(formik.errors.randomEmailLengthIncreaseOnPercentage)
}
helperText={
(formik.touched.randomEmailLengthIncreaseOnPercentage &&
formik.errors.randomEmailLengthIncreaseOnPercentage) ||
t(
"routes.AdminRoute.forms.settings.randomEmailLengthIncreaseOnPercentage.description",
)
}
type="number"
disabled={formik.isSubmitting}
inputMode="numeric"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<MdOutlineChangeCircle />
</InputAdornment>
),
}}
/>
</Grid>
<Grid item>
<AliasesPercentageAmount
percentage={formik.values.randomEmailLengthIncreaseOnPercentage!}
length={formik.values.randomEmailIdMinLength!}
characters={formik.values.randomEmailIdChars!}
/>
</Grid>
<Grid item md={6}>
<TextField
key="custom_email_suffix_length"
fullWidth
label={t(
"routes.AdminRoute.forms.settings.customEmailSuffixLength.label",
)}
name="customEmailSuffixLength"
value={formik.values.customEmailSuffixLength}
onChange={formik.handleChange}
error={
formik.touched.customEmailSuffixLength &&
Boolean(formik.errors.customEmailSuffixLength)
}
helperText={
(formik.touched.customEmailSuffixLength &&
formik.errors.customEmailSuffixLength) ||
t(
"routes.AdminRoute.forms.settings.customEmailSuffixLength.description",
)
}
type="number"
disabled={formik.isSubmitting}
inputMode="numeric"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<TbCursorText />
</InputAdornment>
),
}}
/>
</Grid>
<Grid item md={6}>
<StringPoolField
key="custom_email_suffix_chars"
allowCustom
fullWidth
pools={DEFAULT_POOLS}
id="custom_email_suffix_chars"
label={t(
"routes.AdminRoute.forms.settings.customEmailSuffixChars.label",
)}
name="customEmailSuffixChars"
value={formik.values.customEmailSuffixChars!}
onChange={formik.handleChange}
error={
formik.touched.customEmailSuffixChars &&
Boolean(formik.errors.customEmailSuffixChars)
}
helperText={
(formik.touched.customEmailSuffixChars &&
formik.errors.customEmailSuffixChars) ||
(t(
"routes.AdminRoute.forms.settings.customEmailSuffixChars.description",
) as string)
}
disabled={formik.isSubmitting}
startAdornment={
<InputAdornment position="start">
<MdTextFormat />
</InputAdornment>
}
/>
</Grid>
<Grid item>
<TextField
key="image_proxy_storage_life_time_in_hours"
fullWidth
label={t(
"routes.AdminRoute.forms.settings.imageProxyStorageLifeTimeInHours.label",
)}
name="imageProxyStorageLifeTimeInHours"
value={formik.values.imageProxyStorageLifeTimeInHours}
onChange={formik.handleChange}
error={
formik.touched.imageProxyStorageLifeTimeInHours &&
Boolean(formik.errors.imageProxyStorageLifeTimeInHours)
}
helperText={
(formik.touched.imageProxyStorageLifeTimeInHours &&
formik.errors.imageProxyStorageLifeTimeInHours) ||
t(
"routes.AdminRoute.forms.settings.imageProxyStorageLifeTimeInHours.description",
)
}
type="number"
disabled={formik.isSubmitting}
inputMode="numeric"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<BsImage />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
{t(
"routes.AdminRoute.forms.settings.imageProxyStorageLifeTimeInHours.unit",
{
count:
formik.values
.imageProxyStorageLifeTimeInHours || 0,
},
)}
</InputAdornment>
),
}}
/>
</Grid>
<Grid item>
<FormGroup key="user_email_enable_disposable_emails">
<FormControlLabel
control={
<Checkbox
checked={formik.values.userEmailEnableDisposableEmails}
onChange={formik.handleChange}
name="userEmailEnableDisposableEmails"
/>
}
disabled={formik.isSubmitting}
label={t(
"routes.AdminRoute.forms.settings.userEmailEnableDisposableEmails.label",
)}
/>
<FormHelperText
error={
formik.touched.userEmailEnableDisposableEmails &&
Boolean(formik.errors.userEmailEnableDisposableEmails)
}
>
{(formik.touched.userEmailEnableDisposableEmails &&
formik.errors.userEmailEnableDisposableEmails) ||
t(
"routes.AdminRoute.forms.settings.userEmailEnableDisposableEmails.description",
)}
</FormHelperText>
</FormGroup>
</Grid>
<Grid item>
<FormGroup key="user_email_enable_other_relays">
<FormControlLabel
control={
<Checkbox
checked={formik.values.userEmailEnableOtherRelays!}
onChange={formik.handleChange}
name="userEmailEnableOtherRelays"
/>
}
disabled={formik.isSubmitting}
label={t(
"routes.AdminRoute.forms.settings.userEmailEnableOtherRelays.label",
)}
/>
<FormHelperText
error={
formik.touched.userEmailEnableOtherRelays &&
Boolean(formik.errors.userEmailEnableOtherRelays)
}
>
{(formik.touched.userEmailEnableOtherRelays &&
formik.errors.userEmailEnableOtherRelays) ||
t(
"routes.AdminRoute.forms.settings.userEmailEnableOtherRelays.description",
)}
</FormHelperText>
</FormGroup>
</Grid>
<Grid item>
<FormGroup key="enable_image_proxy">
<FormControlLabel
control={
<Checkbox
checked={formik.values.enableImageProxy!}
onChange={formik.handleChange}
name="enableImageProxy"
/>
}
disabled={formik.isSubmitting}
label={t(
"routes.AdminRoute.forms.settings.enableImageProxy.label",
)}
/>
<FormHelperText
error={
formik.touched.enableImageProxy &&
Boolean(formik.errors.enableImageProxy)
}
>
{(formik.touched.enableImageProxy &&
formik.errors.enableImageProxy) ||
t(
"routes.AdminRoute.forms.settings.enableImageProxy.description",
)}
</FormHelperText>
</FormGroup>
</Grid>
<Grid item>
<FormGroup key="allow_statistics">
<FormControlLabel
control={
<Checkbox
checked={formik.values.allowStatistics!}
onChange={formik.handleChange}
name="allowStatistics"
/>
}
disabled={formik.isSubmitting}
label={t(
"routes.AdminRoute.forms.settings.allowStatistics.label",
)}
/>
<FormHelperText
error={
formik.touched.allowStatistics &&
Boolean(formik.errors.allowStatistics)
}
>
{(formik.touched.allowStatistics &&
formik.errors.allowStatistics) ||
t(
"routes.AdminRoute.forms.settings.allowStatistics.description",
)}
</FormHelperText>
</FormGroup>
</Grid>
</Grid>
</Grid>
<Grid item>
<Grid container justifyContent="center" gap={2}>
<Grid item>
<LoadingButton
loading={formik.isSubmitting}
variant="outlined"
type="reset"
startIcon={<MdClear />}
color="warning"
onClick={() => {
formik.setValues(DEFAULT_ADMIN_SETTINGS)
formik.submitForm()
}}
>
{t("routes.AdminRoute.settings.resetLabel")}
</LoadingButton>
</Grid>
<Grid item>
<LoadingButton
loading={formik.isSubmitting}
variant="contained"
type="submit"
startIcon={<MdCheck />}
>
{t("general.saveLabel")}
</LoadingButton>
</Grid>
</Grid>
</Grid>
</Grid>
</form>
)
}

View File

@ -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<GetAdminUsersResponse, AxiosError>(
["getAdminUsers"],
getAdminUsers,
)
if (!availableUsers) {
return <></>
}
const users = availableUsers.filter(
user => !alreadyPicked.find(picked => picked.id === user.id),
)
if (users.length === 0) {
return <></>
}
return (
<TextField
fullWidth
select
value={null}
label="Admin User"
onChange={event => {
const user = users.find(user => user.id === event.target.value)
if (user) {
onPick(user)
}
event.preventDefault()
}}
>
{users.map(user => (
<MenuItem key={user.id} value={user.id}>
{user.id === meUser?.id
? t("routes.AdminRoute.forms.reservedAliases.fields.users.me", {
email: user.email.address,
})
: user.email.address}
</MenuItem>
))}
</TextField>
)
}

View File

@ -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<ReservedAlias>(queryKey)
queryClient.setQueryData<ReservedAlias>(queryKey, old =>
update(old, {
isActive: {
$set: activeNow!,
},
}),
)
return {previousAlias}
},
onSuccess: newAlias => {
queryClient.setQueryData<ReservedAlias>(queryKey, newAlias)
},
onError: (error, values, context) => {
showError(error)
if (context?.previousAlias) {
queryClient.setQueryData<ReservedAlias>(queryKey, context.previousAlias)
}
},
},
)
return (
<Switch
checked={isActive}
onChange={async () => {
if (isLoading) {
return
}
try {
await mutateAsync(!isActive)
showSuccess(
isActive
? t("relations.alias.mutations.success.aliasChangedToDisabled")
: t("relations.alias.mutations.success.aliasChangedToEnabled"),
)
} catch {}
}}
/>
)
}

View File

@ -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<ReservedAlias>(queryKey)
queryClient.setQueryData<ReservedAlias>(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<ReservedAlias>(queryKey, newAlias as any as ReservedAlias)
},
onError: (error, _, context) => {
showError(error)
setIsInEditMode(true)
if (context?.previousAlias) {
queryClient.setQueryData<ReservedAlias>(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<Form>({
initialValues,
validationSchema: schema,
onSubmit: async (values, {setErrors}) => {
try {
await mutateAsync(values.users)
} catch (error) {
setErrors(parseFastAPIError(error as AxiosError))
}
},
})
const [isInEditMode, setIsInEditMode] = useState<boolean>(false)
return (
<Grid container direction="column" spacing={1}>
<Grid item>
<Grid container spacing={1} direction="row">
<Grid item>
<Typography variant="h6" component="h3">
{t("routes.ReservedAliasDetailRoute.sections.users.title")}
</Typography>
</Grid>
<Grid item>
<IconButton
size="small"
disabled={formik.isSubmitting}
onClick={async () => {
setIsInEditMode(!isInEditMode)
if (
isInEditMode &&
!deepEqual(initialValues, formik.values, {
strict: true,
})
) {
await formik.submitForm()
}
}}
>
{isInEditMode ? <MdCheckCircle /> : <FaPen />}
</IconButton>
</Grid>
</Grid>
</Grid>
<Grid item>
{isInEditMode ? (
<FormikProvider value={formik}>
<FieldArray
name="users"
render={arrayHelpers => (
<List>
{formik.values.users.map((user, index) => (
<ListItem key={user.id}>
<ListItemText primary={user.email.address} />
<ListItemSecondaryAction>
<IconButton
edge="end"
aria-label="delete"
onClick={async () => {
arrayHelpers.remove(index)
}}
>
<TiDelete />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
<Divider />
<ListItem>
<AdminUserPicker
alreadyPicked={formik.values.users}
onPick={user => arrayHelpers.push(user)}
/>
</ListItem>
</List>
)}
/>
<FormHelperText
error={Boolean(formik.touched.users && formik.errors.users)}
>
{formik.touched.users && (formik.errors.users as string)}
</FormHelperText>
</FormikProvider>
) : (
<List>
{users.map(user => (
<ListItem key={user.id}>
<ListItemText primary={user.email.address} />
</ListItem>
))}
</List>
)}
</Grid>
</Grid>
)
}

View File

@ -0,0 +1,37 @@
import {ReactElement} from "react"
import {useTranslation} from "react-i18next"
import {Container, Grid, Typography} from "@mui/material"
import {BsStarFill} from "react-icons/bs"
export default function EmptyStateScreen(): ReactElement {
const {t} = useTranslation()
return (
<Container maxWidth="xs">
<Grid
container
spacing={4}
direction="column"
alignItems="center"
maxWidth="80%"
alignSelf="center"
marginX="auto"
>
<Grid item>
<Typography variant="h6" component="h2">
{t("routes.ReservedAliasesRoute.emptyState.title")}
</Typography>
</Grid>
<Grid item>
<BsStarFill size={40} />
</Grid>
<Grid item>
<Typography variant="body1">
{t("routes.ReservedAliasesRoute.emptyState.description")}
</Typography>
</Grid>
</Grid>
</Container>
)
}

43
src/routes/AdminRoute.tsx Normal file
View File

@ -0,0 +1,43 @@
import {ReactElement, useLayoutEffect} from "react"
import {useTranslation} from "react-i18next"
import {BsStarFill} from "react-icons/bs"
import {AiFillEdit} from "react-icons/ai"
import {Link} from "react-router-dom"
import {List, ListItemButton, ListItemIcon, ListItemText} from "@mui/material"
import {SimplePageBuilder} from "~/components"
import {useNavigateToNext, useUser} from "~/hooks"
export default function AdminRoute(): ReactElement {
const {t} = useTranslation()
const navigateToNext = useNavigateToNext()
const user = useUser()
console.log(user)
useLayoutEffect(() => {
if (!user.isAdmin) {
navigateToNext()
}
}, [user.isAdmin, navigateToNext])
return (
<SimplePageBuilder.Page title={t("routes.AdminRoute.title")}>
<List>
<ListItemButton component={Link} to="/admin/reserved-aliases">
<ListItemIcon>
<BsStarFill />
</ListItemIcon>
<ListItemText primary={t("routes.AdminRoute.routes.reservedAliases")} />
</ListItemButton>
<ListItemButton component={Link} to="/admin/settings">
<ListItemIcon>
<AiFillEdit />
</ListItemIcon>
<ListItemText primary={t("routes.AdminRoute.routes.settings")} />
</ListItemButton>
</List>
</SimplePageBuilder.Page>
)
}

View File

@ -11,15 +11,17 @@ import NavigationButton, {
NavigationSection,
} from "~/route-widgets/AuthenticateRoute/NavigationButton"
const sections = (Object.keys(NavigationSection) as Array<keyof typeof NavigationSection>).filter(
value => isNaN(Number(value)),
)
export default function AuthenticatedRoute(): ReactElement {
const {t} = useTranslation()
const theme = useTheme()
const user = useUser()
useUser()
const sections = [
NavigationSection.Aliases,
NavigationSection.Reports,
NavigationSection.Settings,
user?.isAdmin && NavigationSection.Admin,
].filter(value => value !== false) as NavigationSection[]
return (
<LockNavigationContextProvider>
@ -50,7 +52,7 @@ export default function AuthenticatedRoute(): ReactElement {
<List component="nav">
{sections.map(key => (
<ListItem key={key}>
<NavigationButton section={NavigationSection[key]} />
<NavigationButton section={key} />
</ListItem>
))}
</List>

View File

@ -0,0 +1,142 @@
import * as yup from "yup"
import {ReactElement} from "react"
import {AxiosError} from "axios"
import {useTranslation} from "react-i18next"
import {useFormik} from "formik"
import {BiText} from "react-icons/bi"
import {useMutation} from "@tanstack/react-query"
import {Grid, InputAdornment, TextField} from "@mui/material"
import {CreateReservedAliasData, GetAdminUsersResponse, createReservedAlias} from "~/apis"
import {useErrorSuccessSnacks, useNavigateToNext} from "~/hooks"
import {ReservedAlias} from "~/server-types"
import {parseFastAPIError} from "~/utils"
import {SimpleForm} from "~/components"
import AliasExplanation from "~/route-widgets/CreateReservedAliasRoute/AliasExplanation"
import UsersSelectField from "~/route-widgets/CreateReservedAliasRoute/UsersSelectField"
interface Form {
local: string
users: GetAdminUsersResponse["users"]
isActive?: boolean
detail?: string
}
export default function CreateReservedAliasRoute(): ReactElement {
const {t} = useTranslation()
const {showSuccess} = useErrorSuccessSnacks()
const navigateToNext = useNavigateToNext("/admin/reserved-aliases")
const {mutateAsync: createAlias} = useMutation<
ReservedAlias,
AxiosError,
CreateReservedAliasData
>(createReservedAlias, {
onSuccess: () => {
showSuccess(t("relations.alias.mutations.success.aliasCreation"))
navigateToNext()
},
})
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.object().shape({
id: yup.string(),
email: yup.object().shape({
id: yup.string(),
address: yup.string(),
}),
}),
)
.label(t("routes.AdminRoute.forms.reservedAliases.fields.users.label")),
})
const formik = useFormik<Form>({
validationSchema: schema,
initialValues: {
local: "",
users: [],
},
onSubmit: async (values, {setErrors, resetForm}) => {
try {
await createAlias({
local: values.local,
users: values.users.map(user => ({
id: user.id,
})),
})
} catch (error) {
setErrors(parseFastAPIError(error as AxiosError))
}
},
})
return (
<Grid container spacing={2} flexDirection="column" alignItems="center">
<Grid item>
<form onSubmit={formik.handleSubmit}>
<SimpleForm
title={t("routes.AdminRoute.forms.reservedAliases.title")}
description={t("routes.AdminRoute.forms.reservedAliases.description")}
isSubmitting={formik.isSubmitting}
continueActionLabel={t(
"routes.AdminRoute.forms.reservedAliases.saveAction",
)}
nonFieldError={formik.errors.detail}
>
{[
// We can improve this by using a custom component
// that directly shows whether the alias is available or not
<TextField
key="local"
fullWidth
InputProps={{
startAdornment: (
<InputAdornment position="start">
<BiText />
</InputAdornment>
),
}}
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}
/>,
<UsersSelectField
key="users"
value={formik.values.users}
onChange={formik.handleChange}
disabled={formik.isSubmitting}
error={formik.touched.users && Boolean(formik.errors.users)}
helperText={formik.errors.users as string}
/>,
]}
</SimpleForm>
</form>
</Grid>
<Grid item>
<AliasExplanation
local={formik.values.local}
emails={formik.values.users.map(user => user.email.address)}
/>
</Grid>
</Grid>
)
}

View File

@ -0,0 +1,28 @@
import {ReactElement} from "react"
import {AxiosError} from "axios"
import _ from "lodash"
import {useQuery} from "@tanstack/react-query"
import {AdminSettings} from "~/server-types"
import {getAdminSettings} from "~/apis"
import {QueryResult} from "~/components"
import {DEFAULT_ADMIN_SETTINGS} from "~/constants/admin-settings"
import SettingsForm from "~/route-widgets/GlobalSettingsRoute/SettingsForm"
export default function GlobalSettingsRoute(): ReactElement {
const queryKey = ["get_admin_settings"]
const query = useQuery<AdminSettings, AxiosError>(queryKey, async () => {
const settings = getAdminSettings()
return _.mergeWith({}, DEFAULT_ADMIN_SETTINGS, settings, (o, s) =>
_.isNull(s) ? o : s,
) as AdminSettings
})
return (
<QueryResult<AdminSettings> query={query}>
{settings => <SettingsForm settings={settings} queryKey={queryKey} />}
</QueryResult>
)
}

View File

@ -25,7 +25,7 @@ export default function LoginRoute(): ReactElement {
if (user?.encryptedPassword) {
navigate("/enter-password")
} else {
navigate("/")
navigate("/aliases")
}
}, [user, navigate])

View File

@ -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<ReservedAlias, AxiosError>(queryKey, () => getReservedAlias(params.id!))
return (
<SimplePage title={t("routes.ReservedAliasDetailRoute.title")}>
<QueryResult<ReservedAlias, AxiosError> query={query}>
{alias => (
<SimplePageBuilder.MultipleSections>
{[
<Grid
key="basic"
container
spacing={1}
direction="row"
alignItems="center"
>
<Grid item>
<AliasAddress address={`${alias.local}@${alias.domain}`} />
</Grid>
<Grid item>
<AliasActivationSwitch
id={alias.id}
isActive={alias.isActive}
queryKey={queryKey}
/>
</Grid>
</Grid>,
<AliasUsersList
key="users"
users={alias.users}
id={alias.id}
queryKey={queryKey}
/>,
]}
</SimplePageBuilder.MultipleSections>
)}
</QueryResult>
</SimplePage>
)
}

View File

@ -0,0 +1,126 @@
import {ReactElement, useState, useTransition} from "react"
import {AxiosError} from "axios"
import {useTranslation} from "react-i18next"
import {MdAdd, MdSearch} from "react-icons/md"
import {Link} from "react-router-dom"
import {
Button,
InputAdornment,
List,
ListItemButton,
ListItemSecondaryAction,
ListItemText,
Switch,
TextField,
} from "@mui/material"
import {useQuery} from "@tanstack/react-query"
import {PaginationResult, ReservedAlias} from "~/server-types"
import {getReservedAliases} from "~/apis"
import {NoSearchResults, QueryResult, SimplePage} from "~/components"
import EmptyStateScreen from "~/route-widgets/ReservedAliasesRoute/EmptyStateScreen"
export default function ReservedAliasesRoute(): ReactElement {
const {t} = useTranslation()
const [showSearch, setShowSearch] = useState<boolean>(false)
const [searchValue, setSearchValue] = useState<string>("")
const [queryValue, setQueryValue] = useState<string>("")
const [, startTransition] = useTransition()
const query = useQuery<PaginationResult<ReservedAlias>, AxiosError>(
["getReservedAliases", {queryValue}],
() =>
getReservedAliases({
query: queryValue,
}),
{
onSuccess: ({items}) => {
if (items.length) {
setShowSearch(true)
}
},
},
)
return (
<SimplePage
title={t("routes.ReservedAliasesRoute.title")}
pageOptionsActions={
showSearch && (
<TextField
key="search"
fullWidth
value={searchValue}
onChange={event => {
setSearchValue(event.target.value)
startTransition(() => {
setQueryValue(event.target.value)
})
}}
label={t("routes.ReservedAliasesRoute.pageActions.search.label")}
placeholder={t(
"routes.ReservedAliasesRoute.pageActions.search.placeholder",
)}
id="search"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<MdSearch />
</InputAdornment>
),
}}
/>
)
}
actions={
<Button
component={Link}
startIcon={<MdAdd />}
to="/admin/reserved-aliases/create"
variant="contained"
>
{t("routes.ReservedAliasesRoute.actions.create.label")}
</Button>
}
>
<QueryResult<PaginationResult<ReservedAlias>, AxiosError> query={query}>
{({items: aliases}) => {
if (aliases.length === 0) {
if (searchValue === "") {
return <EmptyStateScreen />
} else {
return <NoSearchResults />
}
}
return (
<List>
{aliases.map(alias => (
<ListItemButton
to={`/admin/reserved-aliases/${alias.id}`}
component={Link}
key={alias.id}
>
<ListItemText
primary={
<>
<span>{alias.local}</span>
<span style={{opacity: 0.4}}>@{alias.domain}</span>
</>
}
secondary={t("routes.ReservedAliasesRoute.userAmount", {
count: alias.users.length,
})}
/>
<ListItemSecondaryAction>
<Switch checked={alias.isActive} />
</ListItemSecondaryAction>
</ListItemButton>
))}
</List>
)
}}
</QueryResult>
</SimplePage>
)
}

View File

@ -45,7 +45,6 @@ export default function VerifyEmailRoute(): ReactElement {
verifyEmail,
{
onSuccess: ({user}) => {
setEmail("")
login(user)
navigate("/auth/complete-account")
},

View File

@ -41,6 +41,7 @@ export interface ServerUser {
isDecrypted: false
encryptedPassword: string
salt: string
isAdmin: boolean
email: {
address: string
isVerified: boolean
@ -100,6 +101,20 @@ export interface Alias {
prefExpandUrlShorteners: boolean | null
}
export interface ReservedAlias {
id: string
isActive: boolean
domain: string
local: string
users: Array<{
id: string
email: {
address: string
id: string
}
}>
}
export interface AliasNote {
version: "1.0"
data: {
@ -183,3 +198,16 @@ export interface GetPageData {
page?: number
size?: number
}
export interface AdminSettings {
randomEmailIdMinLength: number | null
randomEmailIdChars: string | null
randomEmailLengthIncreaseOnPercentage: number | null
customEmailSuffixLength: number
customEmailSuffixChars: string | null
imageProxyStorageLifeTimeInHours: number
enableImageProxy: boolean | null
userEmailEnableDisposableEmails: boolean
userEmailEnableOtherRelays: boolean | null
allowStatistics: boolean | null
}

View File

@ -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