main
Lea 2024-01-17 20:21:08 +01:00
parent a252dbd72e
commit d785a00a35
Signed by: Lea
GPG Key ID: 1BAFFE8347019C42
10 changed files with 162 additions and 33 deletions

3
.env
View File

@ -1,2 +1,3 @@
NEXTAUTH_SECRET=changeme
CREDENTIALS_DB_PATH=/home/lea/Downloads/credentials.db
CREDENTIALS_DB_PATH=/home/lea/git/maddy-admin/data/credentials.db
ALIASES_DB_PATH=/home/lea/git/maddy-admin/data/aliases.db

3
.gitignore vendored
View File

@ -34,3 +34,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# Copy of a Maddy data directory for development
/data

View File

@ -34,3 +34,8 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
### notes to add later
```sql
CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, address TEXT NOT NULL, alias TEXT NOT NULL, pending INTEGER DEFAULT 0);
```

View File

@ -1,8 +1,12 @@
"use client";
import { changeOwnPassword } from "@/lib/actions";
import { Card, Text, Heading, Flex, TextField, Button, IconButton, Popover, Link, Tabs, Box, Grid, Dialog, Callout, Tooltip } from "@radix-ui/themes";
import { AlertCircleIcon, CheckIcon, CopyIcon, ExternalLinkIcon, InfoIcon, LockIcon, UserIcon } from "lucide-react";
import { changeOwnPassword, 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 { useSession } from "next-auth/react";
import { useEffect, useState } from "react";
@ -20,6 +24,13 @@ export default function SelfService() {
const [newPassword, setNewPassword] = useState("");
const validPassword = newPassword.length >= 8;
const [passwordChanged, setPasswordChanged] = useState(false);
const [aliases, setAliases] = useState<AliasEntry[] | null>(null);
const dimensions = useWindowDimensions();
const mobileUi = dimensions.width < 1500;
useEffect(() => {
fetchOwnAliases().then(setAliases);
}, []);
function StaticValueField({ label, value }: { label: string, value: string }) {
const [checked, setChecked] = useState(false);
@ -77,7 +88,7 @@ export default function SelfService() {
<Heading className="pb-4">Account settings</Heading>
<Grid display="inline-grid" rows="1" columns="2" gap="4">
<Grid display="inline-grid" columns={mobileUi ? "1" : "3"} gap="4" width={mobileUi ? "100%" : "auto"}>
<Card className="h-fit">
<Heading size="3">Credentials</Heading>
<Text weight="light" size="2">These are the details you can use to authenticate via SMTP and IMAP.</Text>
@ -151,19 +162,19 @@ export default function SelfService() {
{validPassword
? (
<Button
variant="solid"
onClick={async () => {
setNewPassword("");
variant="solid"
onClick={async () => {
setNewPassword("");
try {
setPasswordChanged(false);
await changeOwnPassword(newPassword);
setPasswordChanged(true);
} catch(e) {
alert(e);
}
}}
>Update</Button>
try {
setPasswordChanged(false);
await changeOwnPassword(newPassword);
setPasswordChanged(true);
} catch (e) {
alert(e);
}
}}
>Update</Button>
)
: (
<Tooltip content="The password must have a minimum length of 8 characters">
@ -210,6 +221,44 @@ export default function SelfService() {
</Tabs.Root>
</Box>
</Card>
<Card className="h-fit">
<Flex direction="row" justify="between" mb="2">
<Heading size="3">Email aliases</Heading>
<Button size="1" variant="outline">New alias</Button>
</Flex>
{
aliases == null
? <GhostMessage icon={<LoadingSpinner />} header="Loading..." />
: aliases.length
? <Box className="h-60">
<ScrollArea>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell justify='start'>Alias</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell justify='end'>Active</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell justify='end'>Manage</Table.ColumnHeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{
aliases.map((alias => <Table.Row key={alias.id}>
<Table.Cell justify='start'>{alias.alias}</Table.Cell>
<Table.Cell justify='end'>{alias.pending ? "Pending" : "Active"}</Table.Cell>
<Table.Cell justify='end'>
<Button variant="solid" size="1" mr="2">Delete</Button>
</Table.Cell>
</Table.Row>))
}
</Table.Body>
</Table.Root>
</ScrollArea>
</Box>
: <GhostMessage icon={<UsersRound />} header="No aliases" message="Create an alias to begin" />
}
</Card>
</Grid>
</>
);

View File

@ -1,14 +1,14 @@
"use server";
import { getServerSession } from "next-auth";
import { database, setUserPassword } from "./db";
import { database, getUserAliases, setUserPassword } from "./db";
import { isAdmin } from "./util";
export async function fetchAllUsers(): Promise<string[]> {
return new Promise((resolve, reject) => {
if (!isAdmin) return reject("Unauthenticated");
const db = database();
const db = database('credentials');
db.all("SELECT key FROM passwords", (err, res: any) => {
if (err) return reject(err);
@ -23,3 +23,9 @@ export async function changeOwnPassword(newPass: string) {
if (!session?.user?.email) throw new Error("Unauthenticated");
await setUserPassword(session.user.email, newPass);
}
export async function fetchOwnAliases() {
const session = await getServerSession();
if (!session?.user?.email) throw new Error("Unauthenticated");
return await getUserAliases(session.user.email);
}

View File

@ -0,0 +1,12 @@
import { Box, Flex, Heading } from "@radix-ui/themes";
import { ReactNode } from "react";
export default function GhostMessage({ icon, header, message }: { icon: ReactNode, header: string, message?: string }) {
return (
<Flex align="center" direction="column" m="6" className="select-none">
<Box mb="2" style={{ color: "gray" }}>{icon}</Box>
<Heading size="3" weight="medium" color="gray">{header}</Heading>
{message && <Heading size="1" weight="medium" color="gray">{message}</Heading>}
</Flex>
);
}

View File

@ -0,0 +1,5 @@
import { Loader2Icon, LucideProps } from "lucide-react";
export default function LoadingSpinner(props: LucideProps) {
return <Loader2Icon className={`animate-spin ${props.className || ""}`} {...props} />;
}

View File

@ -2,20 +2,26 @@ import sqlite from "sqlite3";
import bcrypt from "bcryptjs";
import { PHASE_PRODUCTION_BUILD } from "next/dist/shared/lib/constants";
const { CREDENTIALS_DB_PATH } = process.env;
export const database = () => {
if (!CREDENTIALS_DB_PATH && process.env.NEXT_PHASE != PHASE_PRODUCTION_BUILD) {
throw "$CREDENTIALS_DB_PATH not provided; unable to connect to database";
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]) {
throw `$${v} not provided; unable to connect to database`;
}
}
}
return new sqlite.Database(CREDENTIALS_DB_PATH!, (err: any) => {
const dbPath = type == 'credentials'
? process.env["CREDENTIALS_DB_PATH"]
: process.env["ALIASES_DB_PATH"];
return new sqlite.Database(dbPath!, (err: any) => {
if (err && process.env.NEXT_PHASE != PHASE_PRODUCTION_BUILD) throw err;
});
};
export function validateCredentials(email: string, password: string) {
const db = database();
const db = database('credentials');
return new Promise<boolean>((resolve, reject) => {
db.get(
@ -41,7 +47,7 @@ export function validateCredentials(email: string, password: string) {
export function setUserPassword(email: string, newPass: string) {
return new Promise<void>(async (resolve, reject) => {
const db = database();
const db = database('credentials');
const hash = 'bcrypt:' + await bcrypt.hash(newPass, 10);
db.run("UPDATE passwords SET value = $1 WHERE key = $2",
@ -54,3 +60,19 @@ export function setUserPassword(email: string, newPass: string) {
});
});
}
export type AliasEntry = { id: number, address: string, alias: string, pending: boolean };
export function getUserAliases(email: string) {
return new Promise<AliasEntry[]>(async (resolve, reject) => {
const db = database('aliases');
db.all("SELECT id, address, alias, pending FROM aliases WHERE address = ?", email, (err, res: any[]) => {
db.close();
if (err) return reject(err);
resolve(res.map((data) => ({
...data,
pending: !!data.pending,
})));
});
});
}

View File

@ -0,0 +1,28 @@
import { useState, useEffect } from 'react';
function getWindowDimensions() {
if (typeof window == "undefined") return { width: 0, height: 0 };
const { innerWidth: width, innerHeight: height } = window;
return {
width,
height
};
}
export default function useWindowDimensions() {
const [windowDimensions, setWindowDimensions] = useState({ width: 0, height: 0 });
useEffect(() => {
setWindowDimensions(getWindowDimensions());
function handleResize() {
setWindowDimensions(getWindowDimensions());
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return windowDimensions;
}

View File

@ -1,10 +1,8 @@
import type { Config } from 'tailwindcss'
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
'./src/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
@ -16,5 +14,5 @@ const config: Config = {
},
},
plugins: [],
}
export default config
};
export default config;