user creation

This commit is contained in:
Lea 2024-01-18 09:45:19 +01:00
parent 1744c7a96a
commit 52c4971762
Signed by: Lea
GPG key ID: 1BAFFE8347019C42
4 changed files with 242 additions and 41 deletions

View file

@ -1,45 +1,81 @@
import { fetchAllUsers } from "@/lib/actions";
import { sha256sum } from "@/lib/util";
import { Avatar, Button, Flex, Heading, Table, Text } from "@radix-ui/themes";
"use client";
export default async function Users() {
const data = await fetchAllUsers();
import { fetchAllUsers } from "@/lib/actions";
import GhostMessage from "@/lib/components/ui/GhostMessage";
import LoadingSpinner from "@/lib/components/ui/LoadingSpinner";
import CreateUserButton from "@/lib/components/ui/admin/CreateUserButton";
import { sha256sum } from "@/lib/util";
import { Avatar, Button, Card, Flex, Heading, Table, Text, TextField } from "@radix-ui/themes";
import { SearchIcon, UserRoundXIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
export default function Users() {
const [data, setData] = useState<string[] | null>(null);
const [filter, setFilter] = useState("");
const filtered = useMemo(() => filter.length ? data?.filter((email) => email.includes(filter)) : data, [data, filter]);
useEffect(() => {
fetchAllUsers().then(setData);
}, []);
return (
<>
<Heading className="pb-4">Users</Heading>
<Flex direction='column' className="flex-1">
<Table.Root className="w-full" variant="surface">
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell justify='start'>Email</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell justify='end'>Actions</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{data.map((email) => (
<Table.Row key={email}>
<Table.Cell justify='start'>
<Flex direction='row' gap='4' align='center'>
<Avatar
size="2"
src={`https://gravatar.com/avatar/${sha256sum(email)}?d=monsterid`}
radius='full'
fallback={email.slice(0, 1) || "@"}
/>
<Text size='2'>{email}</Text>
</Flex>
</Table.Cell>
<Table.Cell justify='end'>
<Button variant="soft">Manage</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
<Flex pb="4" justify="between">
<Heading>Users</Heading>
<Flex gap="3" ml="3">
<TextField.Root>
<TextField.Slot>
<SearchIcon size="16" />
</TextField.Slot>
<TextField.Input
value={filter}
onChange={(e) => setFilter(e.currentTarget.value)}
placeholder="Filter table..."
/>
</TextField.Root>
<CreateUserButton onCreate={(user) => setData([...(data ?? []), user])} />
</Flex>
</Flex>
<Card>
{
filtered
? filtered.length
? <Flex direction='column' className="flex-1">
<Table.Root className="w-full">
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell justify='start'>Email</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell justify='end'>Actions</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{filtered.map((email) => (
<Table.Row key={email}>
<Table.Cell justify='start'>
<Flex direction='row' gap='4' align='center'>
<Avatar
size="2"
src={`https://gravatar.com/avatar/${sha256sum(email)}?d=monsterid`}
radius='full'
fallback={email.slice(0, 1) || "@"}
/>
<Text size='2'>{email}</Text>
</Flex>
</Table.Cell>
<Table.Cell justify='end'>
<Button variant="soft">Manage</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Flex>
: <GhostMessage icon={<UserRoundXIcon />} header="No users" message="Try adjusting your search query" />
: <GhostMessage icon={<LoadingSpinner />} header="Loading" />
}
</Card>
</>
);
}

View file

@ -1,12 +1,13 @@
"use server";
import { getServerSession } from "next-auth";
import { AliasEntry, approveAliasEntry, createAlias, database, deleteAliasEntry, getAlias, getAllAliases, getUserAliases, setUserPassword } from "./db";
import { AliasEntry, approveAliasEntry, createAlias, createUserEntry, database, deleteAliasEntry, getAlias, getAllAliases, getUserAliases, setUserPassword } from "./db";
import { aliasesNeedApproval, isAdmin } from "./util";
export async function fetchAllUsers(): Promise<string[]> {
return new Promise((resolve, reject) => {
if (!isAdmin) return reject("Unauthenticated");
return new Promise(async (resolve, reject) => {
const session = await getServerSession();
if (!isAdmin(session)) return reject("Unauthenticated");
const db = database('credentials');
db.all("SELECT key FROM passwords", (err, res: any) => {
@ -93,3 +94,19 @@ export async function approveAlias(alias: string) {
await approveAliasEntry(alias);
}
export async function createUser(email: string, password: string) {
const session = await getServerSession();
if (!session?.user?.email) throw new Error("Unauthenticated");
if (!isAdmin(session)) throw new Error("Unauthorized");
if (!await aliasAvailable(email.toLowerCase())) {
throw new Error("Alias unavailable");
}
if (password.length < 8 || !email.includes("@")) {
throw new Error("Validation failed");
}
await createUserEntry(email, password);
}

View file

@ -0,0 +1,134 @@
import { Button, Callout, Dialog, Flex, IconButton, TextArea, TextField } from "@radix-ui/themes";
import crypto from "crypto";
import { AsteriskIcon, AtSignIcon, EyeIcon, EyeOffIcon, UserIcon } from "lucide-react";
import { useState } from "react";
import GhostMessage from "../GhostMessage";
import LoadingSpinner from "../LoadingSpinner";
import { createUser } from "@/lib/actions";
export default function CreateUserButton({ onCreate }: { onCreate?: (email: string) => any }) {
const [passwordHidden, setPasswordHidden] = useState(true);
const [password, setPassword] = useState("");
const [email, setEmail] = useState("");
const [open, setOpen] = useState(false);
const [state, setState] = useState<"input" | "loading" | "created">("input");
const textAreaString = `Email: ${email}\nPassword: ${password}\nLogin: https://mail.amogus.cloud`;
return (
<Dialog.Root open={open} onOpenChange={(open) => {
setOpen(open);
if (!open) {
setState("input");
setPasswordHidden(true);
setPassword("");
setEmail("");
}
}}>
<Dialog.Trigger>
<Button variant="outline">Create user</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Create user</Dialog.Title>
{
state == "input"
? <>
<Dialog.Description>Provide the user&apos;s primary email address and a password. The user can change their password later.</Dialog.Description>
<Callout.Root my="4">
<Callout.Icon>
<AtSignIcon />
</Callout.Icon>
<Callout.Text>
Make sure that the provided email address uses a configured domain!
</Callout.Text>
</Callout.Root>
<Flex direction="column" gap="3">
<TextField.Root>
<TextField.Slot>
<UserIcon />
</TextField.Slot>
<TextField.Input
placeholder="username@amogus.cloud"
type="text"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
/>
</TextField.Root>
<Flex gap="3">
<TextField.Root className="grow">
<TextField.Slot>
<AsteriskIcon />
</TextField.Slot>
<TextField.Input
placeholder={passwordHidden ? "\u25CF".repeat(10) : "sussyballs"}
type={passwordHidden ? "password" : "text"}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
/>
<TextField.Slot>
<IconButton onClick={() => setPasswordHidden(!passwordHidden)} size="1" variant="ghost">
{
passwordHidden
? <EyeOffIcon size="16" />
: <EyeIcon size="16" />
}
</IconButton>
</TextField.Slot>
</TextField.Root>
<Button variant="outline"
onClick={() => setPassword(crypto.randomBytes(24).toString("base64"))}
>
Generate
</Button>
</Flex>
</Flex>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button variant="outline">Cancel</Button>
</Dialog.Close>
<Button
variant="soft"
disabled={password.length < 8 || !email.length || !email.includes("@")}
onClick={async () => {
setState("loading");
await createUser(email, password);
setState("created");
onCreate?.(email);
}}
>
Create
</Button>
</Flex>
</>
: state == "loading"
? <GhostMessage icon={<LoadingSpinner />} header="Creating user..." />
: <>
<Dialog.Description>
Provide the user with the following text block to allow them to log in:
</Dialog.Description>
<Flex direction="column">
<TextArea
variant="surface"
value={textAreaString}
className="h-[4.5rem] mt-4 w-full"
disabled
/>
</Flex>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button variant="surface" onClick={() => navigator.clipboard.writeText(textAreaString)}>Copy and close</Button>
</Dialog.Close>
<Dialog.Close>
<Button variant="surface">Close</Button>
</Dialog.Close>
</Flex>
</>
}
</Dialog.Content>
</Dialog.Root>
);
}

View file

@ -45,6 +45,21 @@ export function validateCredentials(email: string, password: string) {
});
}
export function createUserEntry(email: string, pass: string) {
return new Promise<void>(async (resolve, reject) => {
const db = database('credentials');
const hash = 'bcrypt:' + await bcrypt.hash(pass, 10);
db.run("INSERT INTO passwords (key, value) VALUES ($1, $2)",
{ 1: email, 2: hash },
async (err: any, res: any) => {
db.close();
if (err) return reject(err);
resolve();
});
});
}
export function setUserPassword(email: string, newPass: string) {
return new Promise<void>(async (resolve, reject) => {
const db = database('credentials');
@ -55,7 +70,6 @@ export function setUserPassword(email: string, newPass: string) {
async (err: any, res: any) => {
db.close();
if (err) return reject(err);
console.log(res);
resolve();
});
});