mirror of
https://github.com/Myzel394/kleckrelay-website.git
synced 2025-06-19 15:55:26 +02:00
commit
e5403552cf
@ -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",
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
30
src/App.tsx
30
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: <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 />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
25
src/apis/create-reserved-alias.ts
Normal file
25
src/apis/create-reserved-alias.ts
Normal 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
|
||||
}
|
10
src/apis/get-admin-settings.ts
Normal file
10
src/apis/get-admin-settings.ts
Normal 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
|
||||
}
|
19
src/apis/get-admin-users.ts
Normal file
19
src/apis/get-admin-users.ts
Normal 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
|
||||
}
|
13
src/apis/get-reserved-alias.ts
Normal file
13
src/apis/get-reserved-alias.ts
Normal 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
|
||||
}
|
23
src/apis/get-reserved-aliases.ts
Normal file
23
src/apis/get-reserved-aliases.ts
Normal 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
|
||||
}
|
@ -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"
|
||||
|
16
src/apis/update-admin-settings.ts
Normal file
16
src/apis/update-admin-settings.ts
Normal 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
|
||||
}
|
27
src/apis/update-reserved-alias.ts
Normal file
27
src/apis/update-reserved-alias.ts
Normal 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
|
||||
}
|
@ -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])
|
||||
|
@ -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>
|
||||
))}
|
||||
|
68
src/components/widgets/StringPoolField/AddNewDialog.tsx
Normal file
68
src/components/widgets/StringPoolField/AddNewDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
183
src/components/widgets/StringPoolField/StringPoolField.tsx
Normal file
183
src/components/widgets/StringPoolField/StringPoolField.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
2
src/components/widgets/StringPoolField/index.ts
Normal file
2
src/components/widgets/StringPoolField/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./StringPoolField"
|
||||
export {default as StringPoolField} from "./StringPoolField"
|
@ -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"
|
||||
|
14
src/constants/admin-settings.ts
Normal file
14
src/constants/admin-settings.ts
Normal 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,
|
||||
}
|
@ -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()
|
||||
|
||||
|
32
src/route-widgets/AdminPage/ReservedAliasesList.tsx
Normal file
32
src/route-widgets/AdminPage/ReservedAliasesList.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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 {
|
||||
|
102
src/route-widgets/CreateReservedAliasRoute/AliasExplanation.tsx
Normal file
102
src/route-widgets/CreateReservedAliasRoute/AliasExplanation.tsx
Normal 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>
|
||||
)
|
||||
}
|
131
src/route-widgets/CreateReservedAliasRoute/UsersSelectField.tsx
Normal file
131
src/route-widgets/CreateReservedAliasRoute/UsersSelectField.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
525
src/route-widgets/GlobalSettingsRoute/SettingsForm.tsx
Normal file
525
src/route-widgets/GlobalSettingsRoute/SettingsForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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 {}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
205
src/route-widgets/ReservedAliasDetailRoute/AliasUsersList.tsx
Normal file
205
src/route-widgets/ReservedAliasDetailRoute/AliasUsersList.tsx
Normal 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>
|
||||
)
|
||||
}
|
37
src/route-widgets/ReservedAliasesRoute/EmptyStateScreen.tsx
Normal file
37
src/route-widgets/ReservedAliasesRoute/EmptyStateScreen.tsx
Normal 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
43
src/routes/AdminRoute.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
|
142
src/routes/CreateReservedAliasRoute.tsx
Normal file
142
src/routes/CreateReservedAliasRoute.tsx
Normal 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>
|
||||
)
|
||||
}
|
28
src/routes/GlobalSettingsRoute.tsx
Normal file
28
src/routes/GlobalSettingsRoute.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -25,7 +25,7 @@ export default function LoginRoute(): ReactElement {
|
||||
if (user?.encryptedPassword) {
|
||||
navigate("/enter-password")
|
||||
} else {
|
||||
navigate("/")
|
||||
navigate("/aliases")
|
||||
}
|
||||
}, [user, navigate])
|
||||
|
||||
|
59
src/routes/ReservedAliasDetailRoute.tsx
Normal file
59
src/routes/ReservedAliasDetailRoute.tsx
Normal 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>
|
||||
)
|
||||
}
|
126
src/routes/ReservedAliasesRoute.tsx
Normal file
126
src/routes/ReservedAliasesRoute.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -45,7 +45,6 @@ export default function VerifyEmailRoute(): ReactElement {
|
||||
verifyEmail,
|
||||
{
|
||||
onSuccess: ({user}) => {
|
||||
setEmail("")
|
||||
login(user)
|
||||
navigate("/auth/complete-account")
|
||||
},
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user