diff --git a/.env b/.env index 34db31b..75aa822 100644 --- a/.env +++ b/.env @@ -1,2 +1,3 @@ NEXTAUTH_SECRET=changeme -CREDENTIALS_DB_PATH=/home/lea/Downloads/credentials.db \ No newline at end of file +CREDENTIALS_DB_PATH=/home/lea/git/maddy-admin/data/credentials.db +ALIASES_DB_PATH=/home/lea/git/maddy-admin/data/aliases.db diff --git a/.gitignore b/.gitignore index fd3dbb5..424d5a9 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Copy of a Maddy data directory for development +/data diff --git a/README.md b/README.md index c403366..7f1c0e8 100644 --- a/README.md +++ b/README.md @@ -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); +``` diff --git a/src/app/self-service/page.tsx b/src/app/self-service/page.tsx index d293384..093c624 100644 --- a/src/app/self-service/page.tsx +++ b/src/app/self-service/page.tsx @@ -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(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() { Account settings - + Credentials These are the details you can use to authenticate via SMTP and IMAP. @@ -151,19 +162,19 @@ export default function SelfService() { {validPassword ? ( + try { + setPasswordChanged(false); + await changeOwnPassword(newPassword); + setPasswordChanged(true); + } catch (e) { + alert(e); + } + }} + >Update ) : ( @@ -210,6 +221,44 @@ export default function SelfService() { + + + + Email aliases + + + { + aliases == null + ? } header="Loading..." /> + : aliases.length + ? + + + + + Alias + Active + Manage + + + + + { + aliases.map((alias => + {alias.alias} + {alias.pending ? "Pending" : "Active"} + + + + )) + } + + + + + : } header="No aliases" message="Create an alias to begin" /> + } + ); diff --git a/src/lib/actions.ts b/src/lib/actions.ts index 6bf7634..47b9c62 100644 --- a/src/lib/actions.ts +++ b/src/lib/actions.ts @@ -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 { 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); +} diff --git a/src/lib/components/ui/GhostMessage.tsx b/src/lib/components/ui/GhostMessage.tsx new file mode 100644 index 0000000..a0875ce --- /dev/null +++ b/src/lib/components/ui/GhostMessage.tsx @@ -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 ( + + {icon} + {header} + {message && {message}} + + ); +} \ No newline at end of file diff --git a/src/lib/components/ui/LoadingSpinner.tsx b/src/lib/components/ui/LoadingSpinner.tsx new file mode 100644 index 0000000..4c25a1b --- /dev/null +++ b/src/lib/components/ui/LoadingSpinner.tsx @@ -0,0 +1,5 @@ +import { Loader2Icon, LucideProps } from "lucide-react"; + +export default function LoadingSpinner(props: LucideProps) { + return ; +} \ No newline at end of file diff --git a/src/lib/db.ts b/src/lib/db.ts index 83ec813..43cd445 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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((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(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(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, + }))); + }); + }); +} diff --git a/src/lib/hooks/useWindowDimensions.ts b/src/lib/hooks/useWindowDimensions.ts new file mode 100644 index 0000000..ad85927 --- /dev/null +++ b/src/lib/hooks/useWindowDimensions.ts @@ -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; +} diff --git a/tailwind.config.ts b/tailwind.config.ts index 1af3b8f..8dac0f2 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -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;