This commit is contained in:
Lea 2024-01-22 23:20:59 +01:00
parent be4aaf0dd4
commit c90bfd4b47
Signed by: Lea
GPG key ID: 1BAFFE8347019C42
8 changed files with 228 additions and 97 deletions

View file

@ -5,7 +5,7 @@ import GenericConfirmationDialog from "@/lib/components/ui/GenericConfirmationDi
import GhostMessage from "@/lib/components/ui/GhostMessage"; import GhostMessage from "@/lib/components/ui/GhostMessage";
import LoadingSpinner from "@/lib/components/ui/LoadingSpinner"; import LoadingSpinner from "@/lib/components/ui/LoadingSpinner";
import { AliasEntry } from "@/lib/db"; import { AliasEntry } from "@/lib/db";
import { Button, Card, Code, Dialog, Flex, Heading, Table } from "@radix-ui/themes"; import { Badge, Button, Card, Code, Dialog, Flex, Heading, Table } from "@radix-ui/themes";
import { ListChecksIcon } from "lucide-react"; import { ListChecksIcon } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -96,7 +96,10 @@ export default function Aliases() {
{active.map((alias) => ( {active.map((alias) => (
<Table.Row key={alias.id}> <Table.Row key={alias.id}>
<Table.Cell justify='start'>{alias.address}</Table.Cell> <Table.Cell justify='start'>{alias.address}</Table.Cell>
<Table.Cell justify='start'>{alias.alias}</Table.Cell> <Table.Cell justify='start'>
{alias.alias}
{alias.temporary && <Badge variant="soft" ml="3" color="gray">Temporary</Badge>}
</Table.Cell>
<Table.Cell justify='end'> <Table.Cell justify='end'>
<Flex gap='3' justify="end"> <Flex gap='3' justify="end">
<Dialog.Root> <Dialog.Root>

View file

@ -9,13 +9,14 @@ import { sha256sum } from "@/lib/util";
import { Avatar, Card, Code, Flex, Grid, Heading, IconButton, Text } from "@radix-ui/themes"; import { Avatar, Card, Code, Flex, Grid, Heading, IconButton, Text } from "@radix-ui/themes";
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import { ArrowLeftIcon, ArrowRightIcon, AsteriskSquareIcon, GhostIcon, LogInIcon, UserCheckIcon, UserPlusIcon, UsersIcon, XIcon } from "lucide-react"; import { ArrowLeftIcon, ArrowRightIcon, AsteriskSquareIcon, GhostIcon, LogInIcon, UserCheckIcon, UserPlusIcon, UsersIcon, UsersRoundIcon, XIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
const LOG_ACTION_HUMAN_READABLE: { [key in AuditLogAction]?: string } = { const LOG_ACTION_HUMAN_READABLE: { [key in AuditLogAction]?: string } = {
requestAlias: "Requested an alias", requestAlias: "Requested an alias",
createTempAlias: "Generated a temporary alias",
createAlias: "Created an alias", createAlias: "Created an alias",
approveAlias: "Approved an alias", approveAlias: "Approved an alias",
deleteAlias: "Deleted an alias", deleteAlias: "Deleted an alias",
@ -42,6 +43,7 @@ export default function Audit() {
const logActionIcons: { [key in AuditLogAction]?: React.ReactNode } = { const logActionIcons: { [key in AuditLogAction]?: React.ReactNode } = {
requestAlias: <UsersIcon />, requestAlias: <UsersIcon />,
createTempAlias: <UsersRoundIcon />,
createAlias: <UsersIcon />, createAlias: <UsersIcon />,
approveAlias: <UserCheckIcon />, approveAlias: <UserCheckIcon />,
deleteAlias: <XIcon />, deleteAlias: <XIcon />,

View file

@ -9,7 +9,7 @@ import { Grid, Heading } from "@radix-ui/themes";
export default function SelfService() { export default function SelfService() {
const dimensions = useWindowDimensions(); const dimensions = useWindowDimensions();
const mobileUi = dimensions.width < 1200; const mobileUi = dimensions.width < 1450;
return ( return (
<> <>

View file

@ -144,7 +144,7 @@ export async function requestTemporaryAlias(
break; break;
case 'random': case 'random':
randomString = crypto randomString = crypto
.randomBytes(12) .randomBytes(8)
.toString('base64') .toString('base64')
.replace(/\W/, ''); // Delete special characters .replace(/\W/, ''); // Delete special characters
break; break;
@ -195,6 +195,8 @@ export async function claimTemporaryAlias(key: string): Promise<AliasEntry> {
temporary: true, temporary: true,
}; };
auditLog('createTempAlias', alias);
return alias; return alias;
} }

View file

@ -1,7 +1,7 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import fs from "fs/promises"; import fs from "fs/promises";
export type AuditLogAction = 'login' | 'changeOwnPassword' | 'requestAlias' | 'deleteAlias' // Unprivileged export type AuditLogAction = 'login' | 'changeOwnPassword' | 'requestAlias' | 'deleteAlias' | 'createTempAlias' // Unprivileged
| 'createUser' | 'createAlias' | 'approveAlias' | 'deleteAlias'; // Privileged | 'createUser' | 'createAlias' | 'approveAlias' | 'deleteAlias'; // Privileged
export type AuditLog = { export type AuditLog = {

View file

@ -68,7 +68,9 @@ export default function ManageUserButton({ email }: { email: string }) {
aliases.map((alias) => ( aliases.map((alias) => (
<Table.Row key={alias.id}> <Table.Row key={alias.id}>
<Table.Cell justify="start"> <Table.Cell justify="start">
{alias.alias} {alias.pending && <Badge variant="soft" ml="3">Pending</Badge>} {alias.alias}
{alias.pending && <Badge variant="soft" ml="3">Pending</Badge>}
{alias.temporary && <Badge variant="soft" ml="3" color="gray">Temporary</Badge>}
</Table.Cell> </Table.Cell>
<Table.Cell justify="end"> <Table.Cell justify="end">
<Flex gap="3" justify="end"> <Flex gap="3" justify="end">

View file

@ -1,11 +1,14 @@
import { disposeTempAliasRequest, fetchOwnAliases, requestTemporaryAlias, claimTemporaryAlias } from "@/lib/actions"; import { disposeTempAliasRequest, fetchOwnAliases, requestTemporaryAlias, claimTemporaryAlias, deleteAlias } from "@/lib/actions";
import { GRAVATAR_DEFAULT } from "@/lib/constants"; import { GRAVATAR_DEFAULT } from "@/lib/constants";
import { AliasEntry, AliasRequestEntry } from "@/lib/db"; import { AliasEntry, AliasRequestEntry } from "@/lib/db";
import { ToastContext } from "@/lib/providers/ToastProvider";
import { sha256sum } from "@/lib/util"; import { sha256sum } from "@/lib/util";
import { Avatar, Button, Card, Dialog, Flex, Heading, IconButton, Select, Text, TextField } from "@radix-ui/themes"; import { Avatar, Badge, Box, Button, Card, Code, Dialog, Flex, Heading, IconButton, ScrollArea, Select, Table, Text, TextField } from "@radix-ui/themes";
import { CircleUserIcon, RefreshCcwIcon } from "lucide-react"; import { CircleUserIcon, CopyIcon, RefreshCcwIcon, UsersRoundIcon } from "lucide-react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useEffect, useState } from "react"; import { useContext, useEffect, useState } from "react";
import GhostMessage from "../GhostMessage";
import LoadingSpinner from "../LoadingSpinner";
export default function TempAliasesCard() { export default function TempAliasesCard() {
const session = useSession().data; const session = useSession().data;
@ -15,7 +18,8 @@ export default function TempAliasesCard() {
const [labelAtEnd, setLabelAtEnd] = useState(false); const [labelAtEnd, setLabelAtEnd] = useState(false);
const [aliasStyle, setAliasStyle] = useState<"words" | "random">("words"); const [aliasStyle, setAliasStyle] = useState<"words" | "random">("words");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [created, setCreated] = useState(false); const [state, setState] = useState<'input' | 'loading' | 'created'>('input');
const toast = useContext(ToastContext);
useEffect(() => { useEffect(() => {
fetchOwnAliases(true).then(setAliases); fetchOwnAliases(true).then(setAliases);
@ -50,7 +54,7 @@ export default function TempAliasesCard() {
setAliasLabel(""); setAliasLabel("");
setLabelAtEnd(false); setLabelAtEnd(false);
setAliasStyle("words"); setAliasStyle("words");
setCreated(false); setState('input');
} }
}}> }}>
<Dialog.Trigger> <Dialog.Trigger>
@ -58,96 +62,214 @@ export default function TempAliasesCard() {
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Content> <Dialog.Content>
<Dialog.Title>Generate alias</Dialog.Title> <Dialog.Title>Generate alias</Dialog.Title>
<Dialog.Description>
Temporary aliases can be used to sign up with services without exposing your primary email address.
If an alias starts receiving spam or you no longer use the service, you can delete the alias.
</Dialog.Description>
<Flex direction="row" gap="3" mt="4"> {
<Flex direction="column" gap="3" grow="1"> state == "input"
<Text ml="1" size="1" color="gray" mb="-1">Alias label</Text> ? <>
<TextField.Root> <Dialog.Description>
<TextField.Slot> Temporary aliases can be used to sign up with services without exposing your primary email address.
<CircleUserIcon size="16" /> If an alias starts receiving spam or you no longer use the service, you can delete the alias.
</TextField.Slot> </Dialog.Description>
<TextField.Input
placeholder="Alias label"
value={aliasLabel}
onChange={(e) => setAliasLabel(e.currentTarget.value.substring(0, 16))}
/>
</TextField.Root>
</Flex>
<Flex direction="column" gap="3"> <Flex direction="row" gap="3" mt="4">
<Text ml="1" size="1" color="gray" mb="-1">Alias style</Text> <Flex direction="column" gap="3" grow="1">
<Select.Root value={aliasStyle} onValueChange={(value) => setAliasStyle(value as any)}> <Text ml="1" size="1" color="gray" mb="-1">Alias label</Text>
<Select.Trigger /> <TextField.Root>
<Select.Content> <TextField.Slot>
<Select.Group> <CircleUserIcon size="16" />
<Select.Label>Alias style</Select.Label> </TextField.Slot>
<Select.Item value="words">Words</Select.Item> <TextField.Input
<Select.Item value="random">Random</Select.Item> placeholder="Alias label"
</Select.Group> value={aliasLabel}
</Select.Content> onChange={(e) => setAliasLabel(e.currentTarget.value.substring(0, 16))}
</Select.Root> />
</Flex> </TextField.Root>
</Flex>
<Flex direction="column" gap="3"> <Flex direction="column" gap="3">
<Text ml="1" size="1" color="gray" mb="-1">Label position</Text> <Text ml="1" size="1" color="gray" mb="-1">Alias style</Text>
<Select.Root value={labelAtEnd ? "after" : "before"} onValueChange={(value) => setLabelAtEnd(value == "after")}> <Select.Root value={aliasStyle} onValueChange={(value) => setAliasStyle(value as any)}>
<Select.Trigger /> <Select.Trigger />
<Select.Content> <Select.Content>
<Select.Group> <Select.Group>
<Select.Label>Alias style</Select.Label> <Select.Label>Alias style</Select.Label>
<Select.Item value="before">Label first</Select.Item> <Select.Item value="words">Words</Select.Item>
<Select.Item value="after">Label at end</Select.Item> <Select.Item value="random">Random</Select.Item>
</Select.Group> </Select.Group>
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
</Flex> </Flex>
</Flex>
<Card mt="4"> <Flex direction="column" gap="3">
<Flex direction="row" justify="between" align="center"> <Text ml="1" size="1" color="gray" mb="-1">Label position</Text>
<Flex direction="row" align="center" gap="3"> <Select.Root value={labelAtEnd ? "after" : "before"} onValueChange={(value) => setLabelAtEnd(value == "after")}>
<Avatar <Select.Trigger />
size="3" <Select.Content>
src={`https://gravatar.com/avatar/${sha256sum(aliasPreview?.alias || "")}?d=${GRAVATAR_DEFAULT}`} <Select.Group>
fallback={"@"} <Select.Label>Alias style</Select.Label>
/> <Select.Item value="before">Label first</Select.Item>
<Flex direction="column" gap="0"> <Select.Item value="after">Label at end</Select.Item>
<Text size="3" weight="medium">Generated alias</Text> </Select.Group>
<Text size="2" weight="light">{aliasPreview?.alias || "Enter a label first"}</Text> </Select.Content>
</Select.Root>
</Flex>
</Flex> </Flex>
</Flex>
<IconButton onClick={() => refreshAlias()} disabled={!aliasLabel} variant="surface" size="2">
<RefreshCcwIcon size="16" />
</IconButton>
</Flex>
</Card>
<Flex gap="3" mt="4" justify="end"> <Card mt="4">
<Dialog.Close> <Flex direction="row" justify="between" align="center">
<Button variant="outline">Close</Button> <Flex direction="row" align="center" gap="3">
</Dialog.Close> <Avatar
<Button size="3"
disabled={!aliasPreview} src={`https://gravatar.com/avatar/${sha256sum(aliasPreview?.alias || "")}?d=${GRAVATAR_DEFAULT}`}
variant="soft" fallback={"@"}
onClick={async () => { />
try { <Flex direction="column" gap="0">
//const alias = await claimTemporaryAlias(aliasPreview!.key); <Text size="3" weight="medium">Generated alias</Text>
} catch(e) { <Text size="2" weight="light">{aliasPreview?.alias || "Enter a label first"}</Text>
setOpen(false); </Flex>
} </Flex>
}} <IconButton onClick={() => refreshAlias()} disabled={!aliasLabel} variant="surface" size="2">
> <RefreshCcwIcon size="16" />
Create </IconButton>
</Button> </Flex>
</Flex> </Card>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button variant="outline">Close</Button>
</Dialog.Close>
<Button
disabled={!aliasPreview}
variant="soft"
onClick={async () => {
try {
setState('loading');
const alias = await claimTemporaryAlias(aliasPreview!.key);
setAliases([...(aliases ?? []), alias]);
setState('created');
} catch (e) {
setOpen(false);
toast({
title: "Failed to create alias",
description: `${e}`,
variant: "error",
});
}
}}
>
Create
</Button>
</Flex>
</>
: state == "loading"
? <>
<GhostMessage icon={<LoadingSpinner />} header="Creating your alias..." />
</>
: <>
<Card mt="4">
<Flex direction="row" justify="between" align="center">
<Flex direction="row" align="center" gap="3">
<Avatar
size="3"
src={`https://gravatar.com/avatar/${sha256sum(aliasPreview!.alias)}?d=${GRAVATAR_DEFAULT}`}
fallback={"@"}
/>
<Flex direction="column" gap="0">
<Text size="3" weight="medium">Alias created</Text>
<Text size="2" weight="light">{aliasPreview!.alias}</Text>
</Flex>
</Flex>
<IconButton onClick={() => navigator.clipboard.writeText(aliasPreview!.alias)} variant="surface" size="2">
<CopyIcon size="16" />
</IconButton>
</Flex>
</Card>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button variant="outline">Close</Button>
</Dialog.Close>
</Flex>
</>
}
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
</Flex> </Flex>
<Text weight="light" size="2">Freely generate and dispose of randomized aliases.</Text> <Text weight="light" size="2">Freely generate and dispose of randomized aliases.</Text>
{
aliases == null
? <GhostMessage icon={<LoadingSpinner />} header="Loading..." />
: aliases.length
? <Box className="h-52">
<ScrollArea>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell justify='start'>Alias</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'>
<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 className="whitespace-nowrap">{alias.alias}</Code>?
You will not be able to recreate it afterwards.
</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));
toast({
title: "Temporary alias deleted",
description: alias.alias,
variant: "success",
});
} catch (e) {
console.error(e);
toast({
title: "Failed to delete alias",
description: `${e}`,
variant: "error",
});
}
}}
>
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="Generate an alias to begin" />
}
</Card> </Card>
); );
} }

View file

@ -82,7 +82,7 @@ export function getAllAliases() {
return new Promise<AliasEntry[]>(async (resolve, reject) => { return new Promise<AliasEntry[]>(async (resolve, reject) => {
const db = database('aliases'); const db = database('aliases');
db.all("SELECT id, address, alias, pending FROM aliases", (err, res: any[]) => { db.all("SELECT id, address, alias, pending, temporary FROM aliases", (err, res: any[]) => {
db.close(); db.close();
if (err) return reject(err); if (err) return reject(err);
resolve(res.map((data) => ({ resolve(res.map((data) => ({
@ -99,10 +99,10 @@ export function getUserAliases(email: string, tempAliases?: boolean) {
const db = database('aliases'); const db = database('aliases');
db.all( db.all(
"SELECT id, address, alias, pending FROM aliases WHERE address = ?1 " + (typeof tempAliases != 'undefined' ? "AND temporary = ?2" : ""), "SELECT id, address, alias, pending, temporary FROM aliases WHERE address = ?1 " + (typeof tempAliases != 'undefined' ? "AND temporary = ?2" : ""),
{ {
1: email, 1: email,
2: tempAliases ? 1 : 0, 2: typeof tempAliases == "boolean" ? (tempAliases ? 1 : 0) : undefined,
}, },
(err, res: any[]) => { (err, res: any[]) => {
db.close(); db.close();
@ -120,7 +120,7 @@ export function getAlias(alias: string) {
return new Promise<AliasEntry | undefined>(async (resolve, reject) => { return new Promise<AliasEntry | undefined>(async (resolve, reject) => {
const db = database('aliases'); const db = database('aliases');
db.get("SELECT id, address, alias, pending FROM aliases WHERE alias = ?", alias, (err, res: AliasEntry) => { db.get("SELECT id, address, alias, pending, temporary FROM aliases WHERE alias = ?", alias, (err, res: AliasEntry) => {
db.close(); db.close();
if (err) return reject(err); if (err) return reject(err);
if (!res) return resolve(undefined); if (!res) return resolve(undefined);