stuff`s
This commit is contained in:
parent
a252dbd72e
commit
d785a00a35
3
.env
3
.env
|
@ -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
3
.gitignore
vendored
|
@ -34,3 +34,6 @@ yarn-error.log*
|
|||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Copy of a Maddy data directory for development
|
||||
/data
|
||||
|
|
|
@ -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);
|
||||
```
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
12
src/lib/components/ui/GhostMessage.tsx
Normal file
12
src/lib/components/ui/GhostMessage.tsx
Normal 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>
|
||||
);
|
||||
}
|
5
src/lib/components/ui/LoadingSpinner.tsx
Normal file
5
src/lib/components/ui/LoadingSpinner.tsx
Normal 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} />;
|
||||
}
|
|
@ -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,
|
||||
})));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
28
src/lib/hooks/useWindowDimensions.ts
Normal file
28
src/lib/hooks/useWindowDimensions.ts
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue