Compare commits

...

3 commits

Author SHA1 Message Date
Lea f7eb59e89e
allow deleting aliases 2024-01-17 23:27:37 +01:00
Lea e10dbc9a62
alias creation 2024-01-17 23:10:47 +01:00
Lea 5be5e328ae
Default to account settings 2024-01-17 22:11:13 +01:00
5 changed files with 239 additions and 22 deletions

View file

@ -1,11 +1,7 @@
"use client";
import { Heading } from '@radix-ui/themes';
import { redirect } from 'next/navigation';
export default function Home() {
return (
<>
<Heading size="9">Welcome back.</Heading>
</>
);
return redirect("/self-service");
}

View file

@ -1,12 +1,13 @@
"use client";
import { changeOwnPassword, fetchOwnAliases } from "@/lib/actions";
import { aliasAvailable, changeOwnPassword, createAliasSelf, deleteAlias, fetchOwnAliases } from "@/lib/actions";
import GhostMessage from "@/lib/components/ui/GhostMessage";
import LoadingSpinner from "@/lib/components/ui/LoadingSpinner";
import { AliasEntry } from "@/lib/db";
import useWindowDimensions from "@/lib/hooks/useWindowDimensions";
import { Card, Text, Heading, Flex, TextField, Button, IconButton, Popover, Link, Tabs, Box, Grid, Dialog, Callout, Tooltip, Table, ScrollArea } from "@radix-ui/themes";
import { AlertCircleIcon, CheckIcon, CopyIcon, ExternalLinkIcon, InfoIcon, LockIcon, UserIcon, UsersRound } from "lucide-react";
import { aliasesNeedApproval } from "@/lib/util";
import { Card, Text, Heading, Flex, TextField, Button, IconButton, Popover, Link, Tabs, Box, Grid, Dialog, Callout, Tooltip, Table, ScrollArea, DropdownMenu, Code } from "@radix-ui/themes";
import { AlertCircleIcon, CheckIcon, ChevronDownIcon, CopyIcon, ExternalLinkIcon, InfoIcon, LockIcon, UserIcon, UsersRound } from "lucide-react";
import { useSession } from "next-auth/react";
import { useEffect, useState } from "react";
@ -17,6 +18,7 @@ const IMAP_PORT = "993";
const SMTP_SECURITY = "SSL/TLS";
const IMAP_SECURITY = "SSL/TLS";
export const WEBMAIL_URL = "https://webmail.amogus.cloud";
export const ALIAS_DOMAINS = ["amogus.cloud", "lea.pet", "futacockinside.me"];
export default function SelfService() {
const session = useSession().data;
@ -25,6 +27,9 @@ export default function SelfService() {
const validPassword = newPassword.length >= 8;
const [passwordChanged, setPasswordChanged] = useState(false);
const [aliases, setAliases] = useState<AliasEntry[] | null>(null);
const [newAliasUsername, setNewAliasUsername] = useState("");
const [newAliasDomain, setNewAliasDomain] = useState(ALIAS_DOMAINS[0]);
const [allowAlias, setAllowAlias] = useState(false);
const dimensions = useWindowDimensions();
const mobileUi = dimensions.width < 1500;
@ -32,6 +37,15 @@ export default function SelfService() {
fetchOwnAliases().then(setAliases);
}, []);
useEffect(() => {
if (!newAliasUsername || !newAliasDomain) return setAllowAlias(false);
aliasAvailable(`${newAliasUsername}@${newAliasDomain}`)
.then((res) => {
setAllowAlias(res);
});
}, [newAliasUsername, newAliasDomain]);
function StaticValueField({ label, value }: { label: string, value: string }) {
const [checked, setChecked] = useState(false);
@ -225,7 +239,78 @@ export default function SelfService() {
<Card className="h-fit">
<Flex direction="row" justify="between" mb="2">
<Heading size="3">Email aliases</Heading>
<Dialog.Root>
<Dialog.Trigger>
<Button size="1" variant="outline">New alias</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Create alias</Dialog.Title>
{(aliasesNeedApproval(session)) && (
<Box pt="2" pb="5">
<Callout.Root>
<Callout.Icon>
<InfoIcon />
</Callout.Icon>
<Callout.Text>Aliases you create will have to be approved by an administrator first</Callout.Text>
</Callout.Root>
</Box>
)}
<Flex gap="2">
<TextField.Input
placeholder={session?.user?.email?.split('@')[0] || "username"}
value={newAliasUsername}
onChange={(e) => setNewAliasUsername(e.currentTarget.value.toLowerCase())}
/>
<Text>@</Text>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="outline" color="gray">{newAliasDomain} <ChevronDownIcon /></Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{ALIAS_DOMAINS.map((alias) => (
<DropdownMenu.Item
onClick={() => setNewAliasDomain(alias)}
key={alias}
>
{alias}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</Flex>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button
variant="outline"
onClick={() => {
setNewAliasUsername("");
setNewAliasDomain(ALIAS_DOMAINS[0]);
}}>Cancel</Button>
</Dialog.Close>
<Dialog.Close>
<Button
variant="solid"
disabled={!allowAlias}
onClick={async () => {
setNewAliasUsername("");
setNewAliasDomain(ALIAS_DOMAINS[0]);
try {
const alias = await createAliasSelf(`${newAliasUsername}@${newAliasDomain}`);
setAliases([...(aliases ?? []), alias]);
} catch(e) {
console.error(e);
alert(e);
}
}}>Change</Button>
</Dialog.Close>
</Flex>
</Dialog.Content>
</Dialog.Root>
</Flex>
{
aliases == null
@ -248,7 +333,41 @@ export default function SelfService() {
<Table.Cell justify='start'>{alias.alias}</Table.Cell>
<Table.Cell justify='end'>{alias.pending ? "Pending" : "Active"}</Table.Cell>
<Table.Cell justify='end'>
<Dialog.Root>
<Dialog.Trigger>
<Button variant="solid" 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>{alias.alias}</Code>?
It will become available for other users to claim immediately.
</Dialog.Description>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button variant="outline">Cancel</Button>
</Dialog.Close>
<Dialog.Close>
<Button
variant="solid"
onClick={async () => {
try {
await deleteAlias(alias.alias);
setAliases(aliases.filter((a) => a.id != alias.id));
} catch(e) {
console.error(e);
alert(e);
}
}}
>
Delete
</Button>
</Dialog.Close>
</Flex>
</Dialog.Content>
</Dialog.Root>
</Table.Cell>
</Table.Row>))
}

View file

@ -1,8 +1,8 @@
"use server";
import { getServerSession } from "next-auth";
import { database, getUserAliases, setUserPassword } from "./db";
import { isAdmin } from "./util";
import { AliasEntry, createAlias, database, deleteAliasEntry, getAlias, getUserAliases, setUserPassword } from "./db";
import { aliasesNeedApproval, isAdmin } from "./util";
export async function fetchAllUsers(): Promise<string[]> {
return new Promise((resolve, reject) => {
@ -29,3 +29,51 @@ export async function fetchOwnAliases() {
if (!session?.user?.email) throw new Error("Unauthenticated");
return await getUserAliases(session.user.email);
}
export async function aliasAvailable(email: string) {
const session = await getServerSession();
if (!session?.user) throw new Error("Unauthenticated");
return new Promise<boolean>((resolve, reject) => {
const db = database('aliases');
db.get('SELECT id FROM aliases WHERE alias = ?', email.toLowerCase(), (err, res) => {
db.close();
if (err) return reject(err);
if (res != undefined) return resolve(false);
const authDb = database('credentials');
authDb.get('SELECT key FROM passwords WHERE key = ?', email.toLowerCase(), (err, res) => {
authDb.close();
if (err) return reject(err);
resolve(res == undefined);
});
});
});
}
export async function createAliasSelf(alias: string): Promise<AliasEntry> {
const session = await getServerSession();
if (!session?.user?.email) throw new Error("Unauthenticated");
const pending = aliasesNeedApproval(session);
if (!await aliasAvailable(alias)) throw new Error("Alias unavailable");
const id = await createAlias(session.user.email, alias.toLowerCase(), pending);
return {
id: id,
address: session.user.email,
alias: alias,
pending: pending
};
}
export async function deleteAlias(alias: string) {
const session = await getServerSession();
if (!session?.user?.email) throw new Error("Unauthenticated");
if (!isAdmin(session) && (await getAlias(alias))?.address != session.user.email) {
throw new Error("Unauthorized");
}
await deleteAliasEntry(alias);
}

View file

@ -2,7 +2,7 @@ import sqlite from "sqlite3";
import bcrypt from "bcryptjs";
import { PHASE_PRODUCTION_BUILD } from "next/dist/shared/lib/constants";
export const database = (type: 'credentials'|'aliases') => {
export const database = (type: 'credentials' | 'aliases') => {
if (process.env.NEXT_PHASE != PHASE_PRODUCTION_BUILD) {
for (const v of ["CREDENTIALS_DB_PATH", "ALIASES_DB_PATH"]) {
if (!process.env[v]) {
@ -37,7 +37,7 @@ export function validateCredentials(email: string, password: string) {
const hash: string = row.value.replace("bcrypt:", "");
const isValid = await bcrypt.compare(password, hash);
resolve(isValid);
} catch(e) {
} catch (e) {
reject(e);
}
}
@ -76,3 +76,53 @@ export function getUserAliases(email: string) {
});
});
}
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 address = ?", alias, (err, res: AliasEntry) => {
db.close();
if (err) return reject(err);
if (!res) return resolve(undefined);
resolve({
...res,
pending: !!res.pending,
});
});
});
}
export function createAlias(user: string, alias: string, pending: boolean) {
return new Promise<number>(async (resolve, reject) => {
const db = database('aliases');
db.run(
"INSERT INTO aliases (address, alias, pending) VALUES (?1, ?2, ?3)",
{
1: user,
2: alias,
3: pending ? 1 : 0,
},
function(err: any) {
db.close();
if (err) return reject(err);
resolve(this.lastID);
});
});
}
export function deleteAliasEntry(alias: string) {
return new Promise<void>(async (resolve, reject) => {
const db = database('aliases');
db.run(
"DELETE FROM aliases WHERE alias = ?",
alias,
function(err: any) {
db.close();
if (err) return reject(err);
resolve();
});
});
}

View file

@ -10,3 +10,7 @@ export function sha256sum(input: any) {
export function isAdmin(session: Session | null) {
return session?.user?.email == "lea@amogus.cloud"; // todo
}
export function aliasesNeedApproval(session: Session | null) {
return !isAdmin(session); // also todo
}