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 LoadingSpinner from "@/lib/components/ui/LoadingSpinner";
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 Image from "next/image";
import { useEffect, useState } from "react";
@ -96,7 +96,10 @@ export default function Aliases() {
{active.map((alias) => (
<Table.Row key={alias.id}>
<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'>
<Flex gap='3' justify="end">
<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 dayjs from "dayjs";
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";
dayjs.extend(relativeTime);
const LOG_ACTION_HUMAN_READABLE: { [key in AuditLogAction]?: string } = {
requestAlias: "Requested an alias",
createTempAlias: "Generated a temporary alias",
createAlias: "Created an alias",
approveAlias: "Approved an alias",
deleteAlias: "Deleted an alias",
@ -42,6 +43,7 @@ export default function Audit() {
const logActionIcons: { [key in AuditLogAction]?: React.ReactNode } = {
requestAlias: <UsersIcon />,
createTempAlias: <UsersRoundIcon />,
createAlias: <UsersIcon />,
approveAlias: <UserCheckIcon />,
deleteAlias: <XIcon />,

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import { getServerSession } from "next-auth";
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
export type AuditLog = {

View file

@ -68,7 +68,9 @@ export default function ManageUserButton({ email }: { email: string }) {
aliases.map((alias) => (
<Table.Row key={alias.id}>
<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 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 { AliasEntry, AliasRequestEntry } from "@/lib/db";
import { ToastContext } from "@/lib/providers/ToastProvider";
import { sha256sum } from "@/lib/util";
import { Avatar, Button, Card, Dialog, Flex, Heading, IconButton, Select, Text, TextField } from "@radix-ui/themes";
import { CircleUserIcon, RefreshCcwIcon } from "lucide-react";
import { Avatar, Badge, Box, Button, Card, Code, Dialog, Flex, Heading, IconButton, ScrollArea, Select, Table, Text, TextField } from "@radix-ui/themes";
import { CircleUserIcon, CopyIcon, RefreshCcwIcon, UsersRoundIcon } from "lucide-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() {
const session = useSession().data;
@ -15,7 +18,8 @@ export default function TempAliasesCard() {
const [labelAtEnd, setLabelAtEnd] = useState(false);
const [aliasStyle, setAliasStyle] = useState<"words" | "random">("words");
const [open, setOpen] = useState(false);
const [created, setCreated] = useState(false);
const [state, setState] = useState<'input' | 'loading' | 'created'>('input');
const toast = useContext(ToastContext);
useEffect(() => {
fetchOwnAliases(true).then(setAliases);
@ -50,7 +54,7 @@ export default function TempAliasesCard() {
setAliasLabel("");
setLabelAtEnd(false);
setAliasStyle("words");
setCreated(false);
setState('input');
}
}}>
<Dialog.Trigger>
@ -58,96 +62,214 @@ export default function TempAliasesCard() {
</Dialog.Trigger>
<Dialog.Content>
<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">
<Text ml="1" size="1" color="gray" mb="-1">Alias label</Text>
<TextField.Root>
<TextField.Slot>
<CircleUserIcon size="16" />
</TextField.Slot>
<TextField.Input
placeholder="Alias label"
value={aliasLabel}
onChange={(e) => setAliasLabel(e.currentTarget.value.substring(0, 16))}
/>
</TextField.Root>
</Flex>
{
state == "input"
? <>
<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="column" gap="3">
<Text ml="1" size="1" color="gray" mb="-1">Alias style</Text>
<Select.Root value={aliasStyle} onValueChange={(value) => setAliasStyle(value as any)}>
<Select.Trigger />
<Select.Content>
<Select.Group>
<Select.Label>Alias style</Select.Label>
<Select.Item value="words">Words</Select.Item>
<Select.Item value="random">Random</Select.Item>
</Select.Group>
</Select.Content>
</Select.Root>
</Flex>
<Flex direction="row" gap="3" mt="4">
<Flex direction="column" gap="3" grow="1">
<Text ml="1" size="1" color="gray" mb="-1">Alias label</Text>
<TextField.Root>
<TextField.Slot>
<CircleUserIcon size="16" />
</TextField.Slot>
<TextField.Input
placeholder="Alias label"
value={aliasLabel}
onChange={(e) => setAliasLabel(e.currentTarget.value.substring(0, 16))}
/>
</TextField.Root>
</Flex>
<Flex direction="column" gap="3">
<Text ml="1" size="1" color="gray" mb="-1">Label position</Text>
<Select.Root value={labelAtEnd ? "after" : "before"} onValueChange={(value) => setLabelAtEnd(value == "after")}>
<Select.Trigger />
<Select.Content>
<Select.Group>
<Select.Label>Alias style</Select.Label>
<Select.Item value="before">Label first</Select.Item>
<Select.Item value="after">Label at end</Select.Item>
</Select.Group>
</Select.Content>
</Select.Root>
</Flex>
</Flex>
<Flex direction="column" gap="3">
<Text ml="1" size="1" color="gray" mb="-1">Alias style</Text>
<Select.Root value={aliasStyle} onValueChange={(value) => setAliasStyle(value as any)}>
<Select.Trigger />
<Select.Content>
<Select.Group>
<Select.Label>Alias style</Select.Label>
<Select.Item value="words">Words</Select.Item>
<Select.Item value="random">Random</Select.Item>
</Select.Group>
</Select.Content>
</Select.Root>
</Flex>
<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">Generated alias</Text>
<Text size="2" weight="light">{aliasPreview?.alias || "Enter a label first"}</Text>
<Flex direction="column" gap="3">
<Text ml="1" size="1" color="gray" mb="-1">Label position</Text>
<Select.Root value={labelAtEnd ? "after" : "before"} onValueChange={(value) => setLabelAtEnd(value == "after")}>
<Select.Trigger />
<Select.Content>
<Select.Group>
<Select.Label>Alias style</Select.Label>
<Select.Item value="before">Label first</Select.Item>
<Select.Item value="after">Label at end</Select.Item>
</Select.Group>
</Select.Content>
</Select.Root>
</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">
<Dialog.Close>
<Button variant="outline">Close</Button>
</Dialog.Close>
<Button
disabled={!aliasPreview}
variant="soft"
onClick={async () => {
try {
//const alias = await claimTemporaryAlias(aliasPreview!.key);
} catch(e) {
setOpen(false);
}
}}
>
Create
</Button>
</Flex>
<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">Generated alias</Text>
<Text size="2" weight="light">{aliasPreview?.alias || "Enter a label first"}</Text>
</Flex>
</Flex>
<IconButton onClick={() => refreshAlias()} disabled={!aliasLabel} variant="surface" size="2">
<RefreshCcwIcon size="16" />
</IconButton>
</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.Root>
</Flex>
<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>
);
}

View file

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