Compare commits
5 commits
3f7111bd5c
...
9c1f7d5739
Author | SHA1 | Date | |
---|---|---|---|
|
9c1f7d5739 | ||
|
21405f9d1d | ||
|
c9aacdaf6a | ||
|
06b6bbefd4 | ||
|
2e5cdd7776 |
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { approveAlias, deleteAlias, fetchAllAliases } from "@/lib/actions";
|
import { approveAlias, deleteAlias, fetchAllAliases } from "@/lib/actions";
|
||||||
|
import GenericConfirmationDialog from "@/lib/components/ui/GenericConfirmationDialog";
|
||||||
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";
|
||||||
|
@ -44,22 +45,28 @@ export default function Aliases() {
|
||||||
<Table.Cell justify='start'>{alias.alias}</Table.Cell>
|
<Table.Cell justify='start'>{alias.alias}</Table.Cell>
|
||||||
<Table.Cell justify='end'>
|
<Table.Cell justify='end'>
|
||||||
<Flex gap='3' justify="end">
|
<Flex gap='3' justify="end">
|
||||||
<Button
|
<GenericConfirmationDialog
|
||||||
size="1"
|
title="Approve alias request"
|
||||||
variant="outline"
|
description={<>Are you sure you want to approve <Code>{alias.address}</Code>'s request for <Code>{alias.alias}</Code>?</>}
|
||||||
onClick={() => approveAlias(alias.alias)
|
labelConfirm="Approve"
|
||||||
.then(() => { setAliases(aliases?.map((a) => a.id == alias.id ? { ...a, pending: false } : a)); })}
|
action={() => approveAlias(alias.alias)
|
||||||
|
.then(() => setAliases(aliases?.map((a) => a.id == alias.id ? { ...a, pending: false } : a)))}
|
||||||
>
|
>
|
||||||
Approve
|
<Button size="1" variant="surface">
|
||||||
</Button>
|
Approve
|
||||||
<Button
|
</Button>
|
||||||
size="1"
|
</GenericConfirmationDialog>
|
||||||
variant="soft"
|
<GenericConfirmationDialog
|
||||||
onClick={() => deleteAlias(alias.alias)
|
title="Deny alias request"
|
||||||
|
description={<>Are you sure you want to deny <Code>{alias.address}</Code>'s request for <Code>{alias.alias}</Code>?</>}
|
||||||
|
labelConfirm="Delete"
|
||||||
|
action={() => deleteAlias(alias.alias)
|
||||||
.then(() => setAliases(aliases?.filter(a => a.id != alias.id)))}
|
.then(() => setAliases(aliases?.filter(a => a.id != alias.id)))}
|
||||||
>
|
>
|
||||||
Delete
|
<Button size="1" variant="soft">
|
||||||
</Button>
|
Delete
|
||||||
|
</Button>
|
||||||
|
</GenericConfirmationDialog>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
|
@ -110,6 +117,7 @@ export default function Aliases() {
|
||||||
<Button variant="outline">Cancel</Button>
|
<Button variant="outline">Cancel</Button>
|
||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
<Dialog.Close>
|
<Dialog.Close>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="soft"
|
variant="soft"
|
||||||
onClick={() => deleteAlias(alias.alias)
|
onClick={() => deleteAlias(alias.alias)
|
||||||
|
|
|
@ -4,8 +4,10 @@ import { fetchAllUsers } from "@/lib/actions";
|
||||||
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 CreateUserButton from "@/lib/components/ui/admin/CreateUserButton";
|
import CreateUserButton from "@/lib/components/ui/admin/CreateUserButton";
|
||||||
import { sha256sum } from "@/lib/util";
|
import ManageUserButton from "@/lib/components/ui/admin/ManageUserButton";
|
||||||
import { Avatar, Button, Card, Flex, Heading, Table, Text, TextField } from "@radix-ui/themes";
|
import { GRAVATAR_DEFAULT } from "@/lib/constants";
|
||||||
|
import { isAdmin, sha256sum } from "@/lib/util";
|
||||||
|
import { Avatar, Badge, Button, Card, Flex, Heading, Table, Text, TextField } from "@radix-ui/themes";
|
||||||
import { SearchIcon, UserRoundXIcon } from "lucide-react";
|
import { SearchIcon, UserRoundXIcon } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
@ -57,15 +59,16 @@ export default function Users() {
|
||||||
<Flex direction='row' gap='4' align='center'>
|
<Flex direction='row' gap='4' align='center'>
|
||||||
<Avatar
|
<Avatar
|
||||||
size="2"
|
size="2"
|
||||||
src={`https://gravatar.com/avatar/${sha256sum(email)}?d=monsterid`}
|
src={`https://gravatar.com/avatar/${sha256sum(email)}?d=${GRAVATAR_DEFAULT}`}
|
||||||
radius='full'
|
radius='full'
|
||||||
fallback={email.slice(0, 1) || "@"}
|
fallback={email.slice(0, 1) || "@"}
|
||||||
/>
|
/>
|
||||||
<Text size='2'>{email}</Text>
|
<Text size='2'>{email}</Text>
|
||||||
|
{isAdmin(email) && <Badge variant="surface">Admin</Badge>}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell justify='end'>
|
<Table.Cell justify='end'>
|
||||||
<Button variant="soft">Manage</Button>
|
<ManageUserButton email={email} />
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import NextAuth, { AuthOptions } from "next-auth";
|
||||||
import CredentialProvider from "next-auth/providers/credentials";
|
import CredentialProvider from "next-auth/providers/credentials";
|
||||||
import { sha256sum } from "@/lib/util";
|
import { sha256sum } from "@/lib/util";
|
||||||
import { validateCredentials } from "@/lib/db";
|
import { validateCredentials } from "@/lib/db";
|
||||||
|
import { GRAVATAR_DEFAULT } from "@/lib/constants";
|
||||||
|
|
||||||
const authOptions: AuthOptions = {
|
const authOptions: AuthOptions = {
|
||||||
providers: [
|
providers: [
|
||||||
|
@ -21,7 +22,7 @@ const authOptions: AuthOptions = {
|
||||||
return {
|
return {
|
||||||
id: credentials.email,
|
id: credentials.email,
|
||||||
email: credentials.email,
|
email: credentials.email,
|
||||||
image: `https://gravatar.com/avatar/${emailHash}?d=monsterid`,
|
image: `https://gravatar.com/avatar/${emailHash}?d=${GRAVATAR_DEFAULT}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { AliasEntry, approveAliasEntry, createAlias, createUserEntry, database, deleteAliasEntry, getAlias, getAllAliases, getUserAliases, setUserPassword } from "./db";
|
import { AliasEntry, approveAliasEntry, createAliasEntry, createUserEntry, database, deleteAliasEntry, getAlias, getAllAliases, getUserAliases, setUserPassword } from "./db";
|
||||||
import { aliasesNeedApproval, isAdmin } from "./util";
|
import { aliasesNeedApproval, isAdmin } from "./util";
|
||||||
|
|
||||||
export async function fetchAllUsers(): Promise<string[]> {
|
export async function fetchAllUsers(): Promise<string[]> {
|
||||||
|
@ -31,6 +31,14 @@ export async function fetchOwnAliases() {
|
||||||
return await getUserAliases(session.user.email);
|
return await getUserAliases(session.user.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchUserAliases(email: string) {
|
||||||
|
const session = await getServerSession();
|
||||||
|
if (!session?.user?.email) throw new Error("Unauthenticated");
|
||||||
|
if (!isAdmin(session)) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
return await getUserAliases(email);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchAllAliases() {
|
export async function fetchAllAliases() {
|
||||||
const session = await getServerSession();
|
const session = await getServerSession();
|
||||||
if (!session?.user?.email) throw new Error("Unauthenticated");
|
if (!session?.user?.email) throw new Error("Unauthenticated");
|
||||||
|
@ -60,13 +68,29 @@ export async function aliasAvailable(email: string) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createAlias(user: string, alias: string): Promise<AliasEntry> {
|
||||||
|
const session = await getServerSession();
|
||||||
|
if (!session?.user?.email) throw new Error("Unauthenticated");
|
||||||
|
if (!isAdmin(session)) throw new Error("Unauthenticated");
|
||||||
|
if (!await aliasAvailable(alias)) throw new Error("Alias unavailable");
|
||||||
|
|
||||||
|
const id = await createAliasEntry(user, alias.toLowerCase(), false);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
address: session.user.email,
|
||||||
|
alias: alias,
|
||||||
|
pending: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function createAliasSelf(alias: string): Promise<AliasEntry> {
|
export async function createAliasSelf(alias: string): Promise<AliasEntry> {
|
||||||
const session = await getServerSession();
|
const session = await getServerSession();
|
||||||
if (!session?.user?.email) throw new Error("Unauthenticated");
|
if (!session?.user?.email) throw new Error("Unauthenticated");
|
||||||
|
|
||||||
const pending = aliasesNeedApproval(session);
|
const pending = aliasesNeedApproval(session);
|
||||||
if (!await aliasAvailable(alias)) throw new Error("Alias unavailable");
|
if (!await aliasAvailable(alias)) throw new Error("Alias unavailable");
|
||||||
const id = await createAlias(session.user.email, alias.toLowerCase(), pending);
|
const id = await createAliasEntry(session.user.email, alias.toLowerCase(), pending);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: id,
|
id: id,
|
||||||
|
|
32
src/lib/components/ui/GenericConfirmationDialog.tsx
Normal file
32
src/lib/components/ui/GenericConfirmationDialog.tsx
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { Button, Dialog, DialogDescription, DialogTitle, Flex } from "@radix-ui/themes";
|
||||||
|
|
||||||
|
export default function GenericConfirmationDialog({
|
||||||
|
title, description, labelConfirm, action, children,
|
||||||
|
}: {
|
||||||
|
title: React.ReactNode,
|
||||||
|
description: React.ReactNode,
|
||||||
|
labelConfirm?: string,
|
||||||
|
action?: () => any,
|
||||||
|
children: React.ReactNode,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog.Root>
|
||||||
|
<Dialog.Trigger>
|
||||||
|
{children}
|
||||||
|
</Dialog.Trigger>
|
||||||
|
<Dialog.Content>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
|
||||||
|
<Flex gap="3" mt="4" justify="end">
|
||||||
|
<Dialog.Close>
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
</Dialog.Close>
|
||||||
|
<Dialog.Close>
|
||||||
|
<Button variant="soft" onClick={action}>{labelConfirm ?? "Confirm"}</Button>
|
||||||
|
</Dialog.Close>
|
||||||
|
</Flex>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
59
src/lib/components/ui/admin/CreateAliasButton.tsx
Normal file
59
src/lib/components/ui/admin/CreateAliasButton.tsx
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import { createAlias } from "@/lib/actions";
|
||||||
|
import { AliasEntry } from "@/lib/db";
|
||||||
|
import { Button, Callout, Dialog, Flex, TextField } from "@radix-ui/themes";
|
||||||
|
import { AtSignIcon, MailWarningIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function CreateAliasButton({ user, onCreate }: { user: string, onCreate?: (alias: AliasEntry) => any }) {
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root onOpenChange={(open) => !open && setValue("")}>
|
||||||
|
<Dialog.Trigger>
|
||||||
|
<Button variant="outline" size="1">Add alias</Button>
|
||||||
|
</Dialog.Trigger>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Title>Create alias</Dialog.Title>
|
||||||
|
|
||||||
|
<Callout.Root mb="4">
|
||||||
|
<Callout.Icon>
|
||||||
|
<MailWarningIcon />
|
||||||
|
</Callout.Icon>
|
||||||
|
<Callout.Text>Make sure the alias uses a valid domain!</Callout.Text>
|
||||||
|
</Callout.Root>
|
||||||
|
|
||||||
|
<TextField.Root>
|
||||||
|
<TextField.Slot>
|
||||||
|
<AtSignIcon size="16" />
|
||||||
|
</TextField.Slot>
|
||||||
|
<TextField.Input
|
||||||
|
placeholder={`${user.split("@")[0]}@alias.domain`}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.currentTarget.value.toLowerCase())}
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
|
||||||
|
<Flex gap="3" mt="4" justify="end">
|
||||||
|
<Dialog.Close>
|
||||||
|
<Button variant="surface">Cancel</Button>
|
||||||
|
</Dialog.Close>
|
||||||
|
<Dialog.Close>
|
||||||
|
<Button
|
||||||
|
variant="surface"
|
||||||
|
disabled={!value.includes("@")}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const alias = await createAlias(user, value);
|
||||||
|
onCreate?.(alias);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert(e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>Create</Button>
|
||||||
|
</Dialog.Close>
|
||||||
|
</Flex>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
113
src/lib/components/ui/admin/ManageUserButton.tsx
Normal file
113
src/lib/components/ui/admin/ManageUserButton.tsx
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { GRAVATAR_DEFAULT } from "@/lib/constants";
|
||||||
|
import { AliasEntry } from "@/lib/db";
|
||||||
|
import { isAdmin, sha256sum } from "@/lib/util";
|
||||||
|
import { Avatar, Badge, Button, Card, Code, Dialog, Flex, Grid, Heading, Table, Text } from "@radix-ui/themes";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import GhostMessage from "../GhostMessage";
|
||||||
|
import { BananaIcon } from "lucide-react";
|
||||||
|
import LoadingSpinner from "../LoadingSpinner";
|
||||||
|
import { approveAlias, deleteAlias, fetchUserAliases } from "@/lib/actions";
|
||||||
|
import GenericConfirmationDialog from "../GenericConfirmationDialog";
|
||||||
|
import CreateAliasButton from "./CreateAliasButton";
|
||||||
|
|
||||||
|
export default function ManageUserButton({ email }: { email: string }) {
|
||||||
|
const [aliases, setAliases] = useState<AliasEntry[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUserAliases(email).then(setAliases);
|
||||||
|
}, [email]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root>
|
||||||
|
<Dialog.Trigger>
|
||||||
|
<Button variant="soft">Manage</Button>
|
||||||
|
</Dialog.Trigger>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Title>
|
||||||
|
Manage user
|
||||||
|
</Dialog.Title>
|
||||||
|
|
||||||
|
<Grid gap="4">
|
||||||
|
<Card className="h-fit">
|
||||||
|
<Flex direction="row" gap="3" align="center">
|
||||||
|
<Avatar
|
||||||
|
size="4"
|
||||||
|
src={`https://gravatar.com/avatar/${sha256sum(email)}?d=${GRAVATAR_DEFAULT}`}
|
||||||
|
fallback={email.slice(0, 1) || "@"}
|
||||||
|
/>
|
||||||
|
<Flex direction="column">
|
||||||
|
<Text>{email}</Text>
|
||||||
|
<Text size="2" weight="light">{isAdmin(email) ? "Administrator" : "User"}</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="h-fit min-w-40">
|
||||||
|
<Flex mb="4" justify="between">
|
||||||
|
<Heading size="3">Aliases</Heading>
|
||||||
|
<CreateAliasButton user={email} onCreate={(alias) => setAliases([...(aliases ?? []), alias])} />
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{
|
||||||
|
aliases
|
||||||
|
? aliases.length > 0
|
||||||
|
? <Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.ColumnHeaderCell justify="start">Alias</Table.ColumnHeaderCell>
|
||||||
|
<Table.ColumnHeaderCell justify="end">Options</Table.ColumnHeaderCell>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
|
||||||
|
<Table.Body>
|
||||||
|
{
|
||||||
|
aliases.map((alias) => (
|
||||||
|
<Table.Row key={alias.id}>
|
||||||
|
<Table.Cell justify="start">
|
||||||
|
{alias.alias} {alias.pending && <Badge variant="soft" ml="3">Pending</Badge>}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell justify="end">
|
||||||
|
<Flex gap="3" justify="end">
|
||||||
|
{
|
||||||
|
alias.pending &&
|
||||||
|
<GenericConfirmationDialog
|
||||||
|
title="Approve alias"
|
||||||
|
description={<>Do you want to approve <Code>{alias.address}</Code>'s alias request for <Code>{alias.alias}</Code>?</>}
|
||||||
|
labelConfirm="Approve"
|
||||||
|
action={async () => {
|
||||||
|
await approveAlias(alias.alias);
|
||||||
|
setAliases(aliases.map((a) => a.id == alias.id ? { ...alias, pending: false } : a));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button size="1" variant="surface">Approve</Button>
|
||||||
|
</GenericConfirmationDialog>
|
||||||
|
}
|
||||||
|
<GenericConfirmationDialog
|
||||||
|
title="Delete alias"
|
||||||
|
description={<>Are you sure you want to delete <Code>{alias.alias}</Code>?</>}
|
||||||
|
labelConfirm="Delete"
|
||||||
|
action={async () => {
|
||||||
|
await deleteAlias(alias.alias);
|
||||||
|
setAliases(aliases.filter((a) => a.id != alias.id));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button size="1" variant="soft">Delete</Button>
|
||||||
|
</GenericConfirmationDialog>
|
||||||
|
</Flex>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
: <GhostMessage icon={<BananaIcon />} header="No aliases" message="The user does not have any aliases" />
|
||||||
|
: <GhostMessage icon={<LoadingSpinner />} header="Loading" />
|
||||||
|
}
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
|
@ -7,4 +7,5 @@ export const IMAP_PORT = "993";
|
||||||
export const SMTP_SECURITY = "SSL/TLS";
|
export const SMTP_SECURITY = "SSL/TLS";
|
||||||
export const IMAP_SECURITY = "SSL/TLS";
|
export const IMAP_SECURITY = "SSL/TLS";
|
||||||
export const WEBMAIL_URL = "https://webmail.amogus.cloud";
|
export const WEBMAIL_URL = "https://webmail.amogus.cloud";
|
||||||
export const ALIAS_DOMAINS = ["amogus.cloud", "lea.pet", "futacockinside.me"];
|
export const ALIAS_DOMAINS = ["amogus.cloud", "lea.pet", "futacockinside.me"];
|
||||||
|
export const GRAVATAR_DEFAULT = "retro";
|
|
@ -121,7 +121,7 @@ export function getAlias(alias: string) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAlias(user: string, alias: string, pending: boolean) {
|
export function createAliasEntry(user: string, alias: string, pending: boolean) {
|
||||||
return new Promise<number>(async (resolve, reject) => {
|
return new Promise<number>(async (resolve, reject) => {
|
||||||
const db = database('aliases');
|
const db = database('aliases');
|
||||||
|
|
||||||
|
|
|
@ -7,8 +7,9 @@ export function sha256sum(input: any) {
|
||||||
return hash.digest('hex');
|
return hash.digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAdmin(session: Session | null) {
|
export function isAdmin(session: Session | string | null) {
|
||||||
return session?.user?.email == "lea@amogus.cloud"; // todo
|
let email = typeof session == 'string' ? session : session?.user?.email;
|
||||||
|
return email && ["lea@amogus.cloud", "lexi@futacockinside.me"].includes(email); // todo
|
||||||
}
|
}
|
||||||
|
|
||||||
export function aliasesNeedApproval(session: Session | null) {
|
export function aliasesNeedApproval(session: Session | null) {
|
||||||
|
|
Loading…
Reference in a new issue