refactor self service panel

This commit is contained in:
Lea 2024-01-18 10:09:32 +01:00
parent 52c4971762
commit 5be2aaa289
Signed by: Lea
GPG key ID: 1BAFFE8347019C42
4 changed files with 372 additions and 334 deletions

View file

@ -1,72 +1,18 @@
"use client";
import { aliasAvailable, changeOwnPassword, createAliasSelf, deleteAlias, fetchOwnAliases } from "@/lib/actions";
import GhostMessage from "@/lib/components/ui/GhostMessage";
import LoadingSpinner from "@/lib/components/ui/LoadingSpinner";
import { ALIAS_DOMAINS, EMAIL_HOST, IMAP_PORT, IMAP_SECURITY, SMTP_PORT, SMTP_SECURITY, WEBMAIL_URL } from "@/lib/constants";
import { AliasEntry } from "@/lib/db";
import ConnectionDetailsCard from "@/lib/components/ui/user/ConnectionDetailsCard";
import OwnAliasesCard from "@/lib/components/ui/user/OwnAliasesCard";
import OwnCredentialsCard from "@/lib/components/ui/user/OwnCredentialsCard";
import useWindowDimensions from "@/lib/hooks/useWindowDimensions";
import { aliasesNeedApproval } from "@/lib/util";
import { Card, Text, Heading, Flex, TextField, Button, IconButton, Popover, Link, Tabs, Box, Grid, Dialog, Callout, Tooltip, Table, ScrollArea, DropdownMenu, Code } from "@radix-ui/themes";
import { AlertCircleIcon, CheckIcon, ChevronDownIcon, CopyIcon, ExternalLinkIcon, InfoIcon, LockIcon, UserIcon, UsersRound } from "lucide-react";
import { useSession } from "next-auth/react";
import { Callout, Grid, Heading } from "@radix-ui/themes";
import { CheckIcon } from "lucide-react";
import { useEffect, useState } from "react";
export default function SelfService() {
const session = useSession().data;
const [newPassword, setNewPassword] = useState("");
const validPassword = newPassword.length >= 8;
const [passwordChanged, setPasswordChanged] = useState(false);
const [aliases, setAliases] = useState<AliasEntry[] | null>(null);
const [newAliasUsername, setNewAliasUsername] = useState("");
const [newAliasDomain, setNewAliasDomain] = useState(ALIAS_DOMAINS[0]);
const [allowAlias, setAllowAlias] = useState(false);
const dimensions = useWindowDimensions();
const mobileUi = dimensions.width < 1200;
useEffect(() => {
fetchOwnAliases().then(setAliases);
}, []);
useEffect(() => {
if (!newAliasUsername || !newAliasDomain) return setAllowAlias(false);
aliasAvailable(`${newAliasUsername}@${newAliasDomain}`)
.then((res) => {
setAllowAlias(res);
});
}, [newAliasUsername, newAliasDomain]);
function StaticValueField({ label, value }: { label: string, value: string }) {
const [checked, setChecked] = useState(false);
return (
<>
<Text weight="light" size="2">{label}</Text>
<TextField.Root>
<TextField.Input disabled value={value} />
<TextField.Slot>
<IconButton
size="1"
variant="ghost"
onClick={() => {
navigator.clipboard.writeText(value);
setChecked(true);
setTimeout(() => setChecked(false), 1000);
}}
>
{checked
? <CheckIcon width={16} height={16} />
: <CopyIcon width={16} height={16} />
}
</IconButton>
</TextField.Slot>
</TextField.Root>
</>
);
}
function PasswordChangedAlert() {
const [hide, setHide] = useState(false);
@ -95,281 +41,9 @@ export default function SelfService() {
<Heading className="pb-4">Account settings</Heading>
<Grid display="inline-grid" columns={mobileUi ? "1" : "3"} gap="4" width={mobileUi ? "100%" : "auto"}>
<Card className="h-fit">
<Heading size="3">Credentials</Heading>
<Text weight="light" size="2">These are the details you can use to authenticate via SMTP and IMAP.</Text>
<Flex direction="column" gap="3" className="pt-2">
<TextField.Root>
<TextField.Slot>
<UserIcon height="16" width="16" />
</TextField.Slot>
<TextField.Input disabled value={session?.user?.email ?? 'n/a'} />
<TextField.Slot>
<Popover.Root>
<Popover.Trigger>
<IconButton asChild size="1" variant="ghost">
<InfoIcon height="16" width="16" />
</IconButton>
</Popover.Trigger>
<Popover.Content className="w-96">
<Text>
Your username uniquely identifies your account and therefore cannot be changed. If you need a different email address, you can <Link onClick={() => alert("todo")}>request an alias</Link> instead.
</Text>
</Popover.Content>
</Popover.Root>
</TextField.Slot>
</TextField.Root>
<Flex direction="row" gap="3">
<TextField.Root className="flex-1">
<TextField.Slot>
<LockIcon height="16" width="16" />
</TextField.Slot>
<TextField.Input disabled value={"\u25CF".repeat(10)} />
</TextField.Root>
<Dialog.Root>
<Dialog.Trigger>
<Button variant="outline">
Change
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Update password</Dialog.Title>
<Box pb="4">
<Callout.Root>
<Callout.Icon>
<AlertCircleIcon />
</Callout.Icon>
<Callout.Text>You will be logged out of every configured mail client!</Callout.Text>
</Callout.Root>
</Box>
<TextField.Root>
<TextField.Slot>
<LockIcon height="16" width="16" />
</TextField.Slot>
<TextField.Input
type="password"
placeholder="New password"
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
/>
</TextField.Root>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button variant="outline" onClick={() => setNewPassword("")}>Cancel</Button>
</Dialog.Close>
<Dialog.Close>
{validPassword
? (
<Button
variant="soft"
onClick={async () => {
setNewPassword("");
try {
setPasswordChanged(false);
await changeOwnPassword(newPassword);
setPasswordChanged(true);
} catch (e) {
alert(e);
}
}}
>Update</Button>
)
: (
<Tooltip content="The password must have a minimum length of 8 characters">
<Button variant="soft" disabled>Update</Button>
</Tooltip>
)
}
</Dialog.Close>
</Flex>
</Dialog.Content>
</Dialog.Root>
</Flex>
</Flex>
</Card>
<Card className="h-fit">
<Heading size="3">Server configuration</Heading>
<Text weight="light" size="2">You can use these details to log in using any mail client.</Text>
<Box pt="2">
<Tabs.Root defaultValue="smtp">
<Tabs.List size="2">
<Tabs.Trigger value="smtp">Outgoing</Tabs.Trigger>
<Tabs.Trigger value="imap">Incoming</Tabs.Trigger>
<Flex align="center" justify="end" grow="1" pr="3">
<Link href={WEBMAIL_URL} target="_blank">
<Button variant="ghost">Webmail <ExternalLinkIcon size="14" /></Button>
</Link>
</Flex>
</Tabs.List>
<Box pt="3" pb="2">
<Tabs.Content value="smtp">
<StaticValueField label="SMTP Server" value={EMAIL_HOST} />
<StaticValueField label="SMTP Port" value={SMTP_PORT} />
<StaticValueField label="Connection security" value={SMTP_SECURITY} />
</Tabs.Content>
<Tabs.Content value="imap">
<StaticValueField label="IMAP Server" value={EMAIL_HOST} />
<StaticValueField label="IMAP Port" value={IMAP_PORT} />
<StaticValueField label="Connection security" value={IMAP_SECURITY} />
</Tabs.Content>
</Box>
</Tabs.Root>
</Box>
</Card>
<Card className="h-fit">
<Flex direction="row" justify="between" mb="2">
<Heading size="3">Email aliases</Heading>
<Dialog.Root>
<Dialog.Trigger>
<Button size="1" variant="outline">New alias</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Create alias</Dialog.Title>
{(aliasesNeedApproval(session)) && (
<Box pt="2" pb="5">
<Callout.Root>
<Callout.Icon>
<InfoIcon />
</Callout.Icon>
<Callout.Text>Aliases you create will have to be approved by an administrator first</Callout.Text>
</Callout.Root>
</Box>
)}
<Flex gap="2">
<TextField.Input
placeholder={session?.user?.email?.split('@')[0] || "username"}
value={newAliasUsername}
onChange={(e) => setNewAliasUsername(e.currentTarget.value.toLowerCase())}
/>
<Text>@</Text>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="outline" color="gray">{newAliasDomain} <ChevronDownIcon /></Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{ALIAS_DOMAINS.map((alias) => (
<DropdownMenu.Item
onClick={() => setNewAliasDomain(alias)}
key={alias}
>
{alias}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</Flex>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button
variant="outline"
onClick={() => {
setNewAliasUsername("");
setNewAliasDomain(ALIAS_DOMAINS[0]);
}}>Cancel</Button>
</Dialog.Close>
<Dialog.Close>
<Button
variant="soft"
disabled={!allowAlias}
onClick={async () => {
setNewAliasUsername("");
setNewAliasDomain(ALIAS_DOMAINS[0]);
try {
const alias = await createAliasSelf(`${newAliasUsername}@${newAliasDomain}`);
setAliases([...(aliases ?? []), alias]);
} catch(e) {
console.error(e);
alert(e);
}
}}>Create</Button>
</Dialog.Close>
</Flex>
</Dialog.Content>
</Dialog.Root>
</Flex>
{
aliases == null
? <GhostMessage icon={<LoadingSpinner />} header="Loading..." />
: aliases.length
? <Box className="h-60">
<ScrollArea>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell justify='start'>Alias</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell justify='end'>Active</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell justify='end'>Manage</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{
aliases.map((alias => <Table.Row key={alias.id}>
<Table.Cell justify='start'>{alias.alias}</Table.Cell>
<Table.Cell justify='end'>{alias.pending ? "Pending" : "Active"}</Table.Cell>
<Table.Cell justify='end'>
<Dialog.Root>
<Dialog.Trigger>
<Button variant="soft" size="1" mr="2">Delete</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Delete alias</Dialog.Title>
<Dialog.Description>
Are you sure you want to delete the alias <Code>{alias.alias}</Code>?
It will become available for other users to claim immediately.
</Dialog.Description>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button variant="outline">Cancel</Button>
</Dialog.Close>
<Dialog.Close>
<Button
variant="soft"
onClick={async () => {
try {
await deleteAlias(alias.alias);
setAliases(aliases.filter((a) => a.id != alias.id));
} catch(e) {
console.error(e);
alert(e);
}
}}
>
Delete
</Button>
</Dialog.Close>
</Flex>
</Dialog.Content>
</Dialog.Root>
</Table.Cell>
</Table.Row>))
}
</Table.Body>
</Table.Root>
</ScrollArea>
</Box>
: <GhostMessage icon={<UsersRound />} header="No aliases" message="Create an alias to begin" />
}
</Card>
<OwnCredentialsCard onPasswordChange={() => setPasswordChanged(true)} />
<ConnectionDetailsCard />
<OwnAliasesCard />
</Grid>
</>
);

View file

@ -0,0 +1,71 @@
"use client";
import { EMAIL_HOST, IMAP_PORT, IMAP_SECURITY, SMTP_PORT, SMTP_SECURITY, WEBMAIL_URL } from "@/lib/constants";
import { Box, Button, Card, Flex, Heading, IconButton, Link, Tabs, Text, TextField } from "@radix-ui/themes";
import { CheckIcon, CopyIcon, ExternalLinkIcon } from "lucide-react";
import { useState } from "react";
function StaticValueField({ label, value }: { label: string, value: string }) {
const [checked, setChecked] = useState(false);
return (
<>
<Text weight="light" size="2">{label}</Text>
<TextField.Root>
<TextField.Input disabled value={value} />
<TextField.Slot>
<IconButton
size="1"
variant="ghost"
onClick={() => {
navigator.clipboard.writeText(value);
setChecked(true);
setTimeout(() => setChecked(false), 1000);
}}
>
{checked
? <CheckIcon width={16} height={16} />
: <CopyIcon width={16} height={16} />
}
</IconButton>
</TextField.Slot>
</TextField.Root>
</>
);
}
export default function ConnectionDetailsCard() {
return (
<Card className="h-fit">
<Heading size="3">Server configuration</Heading>
<Text weight="light" size="2">You can use these details to log in using any mail client.</Text>
<Box pt="2">
<Tabs.Root defaultValue="smtp">
<Tabs.List size="2">
<Tabs.Trigger value="smtp">Outgoing</Tabs.Trigger>
<Tabs.Trigger value="imap">Incoming</Tabs.Trigger>
<Flex align="center" justify="end" grow="1" pr="3">
<Link href={WEBMAIL_URL} target="_blank">
<Button variant="ghost">Webmail <ExternalLinkIcon size="14" /></Button>
</Link>
</Flex>
</Tabs.List>
<Box pt="3" pb="2">
<Tabs.Content value="smtp">
<StaticValueField label="SMTP Server" value={EMAIL_HOST} />
<StaticValueField label="SMTP Port" value={SMTP_PORT} />
<StaticValueField label="Connection security" value={SMTP_SECURITY} />
</Tabs.Content>
<Tabs.Content value="imap">
<StaticValueField label="IMAP Server" value={EMAIL_HOST} />
<StaticValueField label="IMAP Port" value={IMAP_PORT} />
<StaticValueField label="Connection security" value={IMAP_SECURITY} />
</Tabs.Content>
</Box>
</Tabs.Root>
</Box>
</Card>
);
}

View file

@ -0,0 +1,178 @@
"use client";
import { aliasAvailable, createAliasSelf, deleteAlias, fetchOwnAliases } from "@/lib/actions";
import { ALIAS_DOMAINS } from "@/lib/constants";
import { AliasEntry } from "@/lib/db";
import { aliasesNeedApproval } from "@/lib/util";
import { Box, Button, Callout, Card, Code, Dialog, DropdownMenu, Flex, Heading, ScrollArea, Table, Text, TextField } from "@radix-ui/themes";
import { ChevronDownIcon, InfoIcon, UsersRoundIcon } from "lucide-react";
import { useSession } from "next-auth/react";
import { useEffect, useState } from "react";
import LoadingSpinner from "../LoadingSpinner";
import GhostMessage from "../GhostMessage";
export default function OwnAliasesCard() {
const session = useSession().data;
const [aliases, setAliases] = useState<AliasEntry[] | null>(null);
const [newAliasUsername, setNewAliasUsername] = useState("");
const [newAliasDomain, setNewAliasDomain] = useState(ALIAS_DOMAINS[0]);
const [allowAlias, setAllowAlias] = useState(false);
useEffect(() => {
fetchOwnAliases().then(setAliases);
}, []);
useEffect(() => {
if (!newAliasUsername || !newAliasDomain) return setAllowAlias(false);
aliasAvailable(`${newAliasUsername}@${newAliasDomain}`)
.then((res) => {
setAllowAlias(res);
});
}, [newAliasUsername, newAliasDomain]);
return (
<Card className="h-fit">
<Flex direction="row" justify="between" mb="2">
<Heading size="3">Email aliases</Heading>
<Dialog.Root>
<Dialog.Trigger>
<Button size="1" variant="outline">New alias</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Create alias</Dialog.Title>
{(aliasesNeedApproval(session)) && (
<Box pt="2" pb="5">
<Callout.Root>
<Callout.Icon>
<InfoIcon />
</Callout.Icon>
<Callout.Text>Aliases you create will have to be approved by an administrator first</Callout.Text>
</Callout.Root>
</Box>
)}
<Flex gap="2">
<TextField.Input
placeholder={session?.user?.email?.split('@')[0] || "username"}
value={newAliasUsername}
onChange={(e) => setNewAliasUsername(e.currentTarget.value.toLowerCase())}
/>
<Text>@</Text>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="outline" color="gray">{newAliasDomain} <ChevronDownIcon /></Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{ALIAS_DOMAINS.map((alias) => (
<DropdownMenu.Item
onClick={() => setNewAliasDomain(alias)}
key={alias}
>
{alias}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</Flex>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button
variant="outline"
onClick={() => {
setNewAliasUsername("");
setNewAliasDomain(ALIAS_DOMAINS[0]);
}}>Cancel</Button>
</Dialog.Close>
<Dialog.Close>
<Button
variant="soft"
disabled={!allowAlias}
onClick={async () => {
setNewAliasUsername("");
setNewAliasDomain(ALIAS_DOMAINS[0]);
try {
const alias = await createAliasSelf(`${newAliasUsername}@${newAliasDomain}`);
setAliases([...(aliases ?? []), alias]);
} catch (e) {
console.error(e);
alert(e);
}
}}>Create</Button>
</Dialog.Close>
</Flex>
</Dialog.Content>
</Dialog.Root>
</Flex>
{
aliases == null
? <GhostMessage icon={<LoadingSpinner />} header="Loading..." />
: aliases.length
? <Box className="h-60">
<ScrollArea>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell justify='start'>Alias</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell justify='end'>Active</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell justify='end'>Manage</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{
aliases.map((alias => <Table.Row key={alias.id}>
<Table.Cell justify='start'>{alias.alias}</Table.Cell>
<Table.Cell justify='end'>{alias.pending ? "Pending" : "Active"}</Table.Cell>
<Table.Cell justify='end'>
<Dialog.Root>
<Dialog.Trigger>
<Button variant="soft" size="1" mr="2">Delete</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Delete alias</Dialog.Title>
<Dialog.Description>
Are you sure you want to delete the alias <Code>{alias.alias}</Code>?
It will become available for other users to claim immediately.
</Dialog.Description>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button variant="outline">Cancel</Button>
</Dialog.Close>
<Dialog.Close>
<Button
variant="soft"
onClick={async () => {
try {
await deleteAlias(alias.alias);
setAliases(aliases.filter((a) => a.id != alias.id));
} catch (e) {
console.error(e);
alert(e);
}
}}
>
Delete
</Button>
</Dialog.Close>
</Flex>
</Dialog.Content>
</Dialog.Root>
</Table.Cell>
</Table.Row>))
}
</Table.Body>
</Table.Root>
</ScrollArea>
</Box>
: <GhostMessage icon={<UsersRoundIcon />} header="No aliases" message="Create an alias to begin" />
}
</Card>
);
}

View file

@ -0,0 +1,115 @@
"use client";
import { changeOwnPassword } from "@/lib/actions";
import { Box, Button, Callout, Card, Dialog, Flex, Heading, IconButton, Link, Popover, Text, TextField, Tooltip } from "@radix-ui/themes";
import { AlertCircleIcon, InfoIcon, LockIcon, UserIcon } from "lucide-react";
import { useSession } from "next-auth/react";
import { useState } from "react";
export default function OwnCredentialsCard({ onPasswordChange }: { onPasswordChange?: () => any }) {
const session = useSession().data;
const [newPassword, setNewPassword] = useState("");
const validPassword = newPassword.length >= 8;
return (
<Card className="h-fit">
<Heading size="3">Credentials</Heading>
<Text weight="light" size="2">These are the details you can use to authenticate via SMTP and IMAP.</Text>
<Flex direction="column" gap="3" className="pt-2">
<TextField.Root>
<TextField.Slot>
<UserIcon height="16" width="16" />
</TextField.Slot>
<TextField.Input disabled value={session?.user?.email ?? 'n/a'} />
<TextField.Slot>
<Popover.Root>
<Popover.Trigger>
<IconButton asChild size="1" variant="ghost">
<InfoIcon height="16" width="16" />
</IconButton>
</Popover.Trigger>
<Popover.Content className="w-96">
<Text>
Your username uniquely identifies your account and therefore cannot be changed. If you need a different email address, you can <Link onClick={() => alert("todo")}>request an alias</Link> instead.
</Text>
</Popover.Content>
</Popover.Root>
</TextField.Slot>
</TextField.Root>
<Flex direction="row" gap="3">
<TextField.Root className="flex-1">
<TextField.Slot>
<LockIcon height="16" width="16" />
</TextField.Slot>
<TextField.Input disabled value={"\u25CF".repeat(10)} />
</TextField.Root>
<Dialog.Root>
<Dialog.Trigger>
<Button variant="outline">
Change
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Update password</Dialog.Title>
<Box pb="4">
<Callout.Root>
<Callout.Icon>
<AlertCircleIcon />
</Callout.Icon>
<Callout.Text>You will be logged out of every configured mail client!</Callout.Text>
</Callout.Root>
</Box>
<TextField.Root>
<TextField.Slot>
<LockIcon height="16" width="16" />
</TextField.Slot>
<TextField.Input
type="password"
placeholder="New password"
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
/>
</TextField.Root>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button variant="outline" onClick={() => setNewPassword("")}>Cancel</Button>
</Dialog.Close>
<Dialog.Close>
{validPassword
? (
<Button
variant="soft"
onClick={async () => {
setNewPassword("");
try {
await changeOwnPassword(newPassword);
onPasswordChange?.();
} catch (e) {
alert(e);
}
}}
>Update</Button>
)
: (
<Tooltip content="The password must have a minimum length of 8 characters">
<Button variant="soft" disabled>Update</Button>
</Tooltip>
)
}
</Dialog.Close>
</Flex>
</Dialog.Content>
</Dialog.Root>
</Flex>
</Flex>
</Card>
);
}