user creation
This commit is contained in:
parent
1744c7a96a
commit
52c4971762
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
134
src/lib/components/ui/admin/CreateUserButton.tsx
Normal file
134
src/lib/components/ui/admin/CreateUserButton.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue