Compare commits

...

3 commits

Author SHA1 Message Date
Lea 52c4971762
user creation 2024-01-18 09:45:19 +01:00
Lea 1744c7a96a
refactor 2024-01-18 08:01:23 +01:00
Lea 01c4c0ba2e
fix mobile styling 2024-01-18 07:57:31 +01:00
9 changed files with 253 additions and 52 deletions

View file

@ -1,45 +1,81 @@
import { fetchAllUsers } from "@/lib/actions"; "use client";
import { sha256sum } from "@/lib/util";
import { Avatar, Button, Flex, Heading, Table, Text } from "@radix-ui/themes";
export default async function Users() { import { fetchAllUsers } from "@/lib/actions";
const data = await fetchAllUsers(); 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 ( return (
<> <>
<Heading className="pb-4">Users</Heading> <Flex pb="4" justify="between">
<Heading>Users</Heading>
<Flex direction='column' className="flex-1"> <Flex gap="3" ml="3">
<Table.Root className="w-full" variant="surface"> <TextField.Root>
<Table.Header> <TextField.Slot>
<Table.Row> <SearchIcon size="16" />
<Table.ColumnHeaderCell justify='start'>Email</Table.ColumnHeaderCell> </TextField.Slot>
<Table.ColumnHeaderCell justify='end'>Actions</Table.ColumnHeaderCell> <TextField.Input
</Table.Row> value={filter}
</Table.Header> onChange={(e) => setFilter(e.currentTarget.value)}
placeholder="Filter table..."
<Table.Body> />
{data.map((email) => ( </TextField.Root>
<Table.Row key={email}> <CreateUserButton onCreate={(user) => setData([...(data ?? []), user])} />
<Table.Cell justify='start'> </Flex>
<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> </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,9 +1,9 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import ThemeWrapper from '@/lib/components/ThemeWrapper'; import ThemeWrapper from '@/lib/components/wrapper/ThemeWrapper';
import './globals.css'; import './globals.css';
import AuthWrapper from '@/lib/components/AuthWrapper'; import AuthWrapper from '@/lib/components/wrapper/AuthWrapper';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import NavigationWrapper from '@/lib/components/NavigationWrapper'; import NavigationWrapper from '@/lib/components/wrapper/NavigationWrapper';
import { ThemePanel } from '@radix-ui/themes'; import { ThemePanel } from '@radix-ui/themes';
import BackgroundImage from '@/lib/components/ui/BackgroundImage'; import BackgroundImage from '@/lib/components/ui/BackgroundImage';

View file

@ -23,7 +23,7 @@ export default function SelfService() {
const [newAliasDomain, setNewAliasDomain] = useState(ALIAS_DOMAINS[0]); const [newAliasDomain, setNewAliasDomain] = useState(ALIAS_DOMAINS[0]);
const [allowAlias, setAllowAlias] = useState(false); const [allowAlias, setAllowAlias] = useState(false);
const dimensions = useWindowDimensions(); const dimensions = useWindowDimensions();
const mobileUi = dimensions.width < 1500; const mobileUi = dimensions.width < 1200;
useEffect(() => { useEffect(() => {
fetchOwnAliases().then(setAliases); fetchOwnAliases().then(setAliases);

View file

@ -1,12 +1,13 @@
"use server"; "use server";
import { getServerSession } from "next-auth"; 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"; import { aliasesNeedApproval, isAdmin } from "./util";
export async function fetchAllUsers(): Promise<string[]> { export async function fetchAllUsers(): Promise<string[]> {
return new Promise((resolve, reject) => { return new Promise(async (resolve, reject) => {
if (!isAdmin) return reject("Unauthenticated"); const session = await getServerSession();
if (!isAdmin(session)) return reject("Unauthenticated");
const db = database('credentials'); const db = database('credentials');
db.all("SELECT key FROM passwords", (err, res: any) => { db.all("SELECT key FROM passwords", (err, res: any) => {
@ -93,3 +94,19 @@ export async function approveAlias(alias: string) {
await approveAliasEntry(alias); 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

@ -3,8 +3,8 @@
import { Button, Flex, Heading, Text, Link } from "@radix-ui/themes"; import { Button, Flex, Heading, Text, Link } from "@radix-ui/themes";
import { Session } from "next-auth"; import { Session } from "next-auth";
import { SessionProvider, signIn } from "next-auth/react"; import { SessionProvider, signIn } from "next-auth/react";
import useThemePreference from "../hooks/useThemePreference"; import useThemePreference from "../../hooks/useThemePreference";
import { WEBMAIL_URL } from "../constants"; import { WEBMAIL_URL } from "../../constants";
export default function AuthWrapper({ export default function AuthWrapper({
children, children,

View file

@ -1,8 +1,8 @@
"use client"; "use client";
import { Flex } from "@radix-ui/themes"; import { Flex } from "@radix-ui/themes";
import useWindowDimensions from "../hooks/useWindowDimensions"; import useWindowDimensions from "../../hooks/useWindowDimensions";
import NavigationPanel from "./ui/NavigationPanel"; import NavigationPanel from "../ui/NavigationPanel";
export default function NavigationWrapper({ export default function NavigationWrapper({
children, children,
@ -15,14 +15,14 @@ export default function NavigationWrapper({
return ( return (
<Flex direction='row' gap='4'> <Flex direction='row' gap='4'>
<div><NavigationPanel /></div> <div><NavigationPanel /></div>
<div className='w-full p-4'>{children}</div> <div className='w-full p-0'>{children}</div>
</Flex> </Flex>
); );
} else { } else {
return ( return (
<Flex direction='column' gap='4'> <Flex direction='column' gap='4'>
<div><NavigationPanel mobileUi /></div> <div><NavigationPanel mobileUi /></div>
<div className='w-full p-4 pt-0'>{children}</div> <div className='w-full p-0'>{children}</div>
</Flex> </Flex>
); );
}; };

View file

@ -3,7 +3,7 @@
import '@radix-ui/themes/styles.css'; import '@radix-ui/themes/styles.css';
import { Theme } from "@radix-ui/themes"; import { Theme } from "@radix-ui/themes";
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import useThemePreference from '../hooks/useThemePreference'; import useThemePreference from '../../hooks/useThemePreference';
export default function ThemeWrapper({ export default function ThemeWrapper({
children, children,

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) { export function setUserPassword(email: string, newPass: string) {
return new Promise<void>(async (resolve, reject) => { return new Promise<void>(async (resolve, reject) => {
const db = database('credentials'); const db = database('credentials');
@ -55,7 +70,6 @@ export function setUserPassword(email: string, newPass: string) {
async (err: any, res: any) => { async (err: any, res: any) => {
db.close(); db.close();
if (err) return reject(err); if (err) return reject(err);
console.log(res);
resolve(); resolve();
}); });
}); });