This commit is contained in:
Lea 2024-01-22 12:32:13 +01:00
parent 7fcbc4d98e
commit 9ee1f0bcbc
Signed by: Lea
GPG key ID: 1BAFFE8347019C42
10 changed files with 305 additions and 48 deletions

View file

@ -9,6 +9,7 @@
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/themes": "^2.0.3",
"@types/bcryptjs": "^2.4.6",
"bcryptjs": "^2.4.3",

View file

@ -5,6 +5,9 @@ settings:
excludeLinksFromLockfile: false
dependencies:
'@radix-ui/react-toast':
specifier: ^1.1.5
version: 1.1.5(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/themes':
specifier: ^2.0.3
version: 2.0.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)
@ -1240,6 +1243,38 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-toast@1.1.5(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-fRLn227WHIBRSzuRzGJ8W+5YALxofH23y0MlPLddaIpLpCDqdE0NZlS2NRQDRiptfxDeeCjgFIpexB1/zkxDlw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.23.8
'@radix-ui/primitive': 1.0.1
'@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-context': 1.0.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.48)(react@18.2.0)
'@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0)
'@types/react': 18.2.48
'@types/react-dom': 18.2.18
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-tooltip@1.0.7(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw==}
peerDependencies:

View file

@ -1,3 +1,97 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* mfw "ui library" doesnt ship styles */
.ToastViewport {
--viewport-padding: 25px;
position: fixed;
bottom: 0;
right: 0;
display: flex;
flex-direction: column;
padding: var(--viewport-padding);
gap: 10px;
width: 390px;
max-width: 100vw;
margin: 0;
list-style: none;
z-index: 2147483647;
outline: none;
}
.ToastRoot {
background-color: var(--color-panel-solid);
border: 1px solid var(--gray-4);
border-radius: 6px;
box-shadow: hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px;
padding: 15px;
display: grid;
grid-template-areas: 'title action' 'description action';
grid-template-columns: auto max-content;
column-gap: 15px;
align-items: center;
}
.ToastRoot[data-state='open'] {
animation: slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ToastRoot[data-state='closed'] {
animation: hide 200ms ease-in;
}
.ToastRoot[data-swipe='move'] {
transform: translateX(var(--radix-toast-swipe-move-x));
}
.ToastRoot[data-swipe='cancel'] {
transform: translateX(0);
transition: transform 200ms ease-out;
}
.ToastRoot[data-swipe='end'] {
animation: swipeOut 100ms ease-out;
}
@keyframes hide {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes slideIn {
from {
transform: translateX(calc(100% + var(--viewport-padding)));
}
to {
transform: translateX(0);
}
}
@keyframes swipeOut {
from {
transform: translateX(var(--radix-toast-swipe-end-x));
}
to {
transform: translateX(calc(100% + var(--viewport-padding)));
}
}
.ToastTitle {
grid-area: title;
margin-bottom: 5px;
font-weight: 500;
color: var(--slate-12);
font-size: 15px;
}
.ToastDescription {
grid-area: description;
margin: 0;
color: var(--slate-11);
font-size: 13px;
line-height: 1.3;
}
.ToastAction {
grid-area: action;
}

View file

@ -5,6 +5,7 @@ import AuthWrapper from '@/lib/components/wrapper/AuthWrapper';
import { getServerSession } from 'next-auth';
import NavigationWrapper from '@/lib/components/wrapper/NavigationWrapper';
import BackgroundImage from '@/lib/components/ui/BackgroundImage';
import ToastProvider from '@/lib/providers/ToastProvider';
export const metadata: Metadata = {
title: 'Maddy Admin',
@ -21,11 +22,13 @@ export default async function RootLayout({
<body className={`p-4 h-full m-0`}>
<ThemeWrapper>
<BackgroundImage />
<AuthWrapper session={await getServerSession()}>
<NavigationWrapper>
{children}
</NavigationWrapper>
</AuthWrapper>
<ToastProvider>
<AuthWrapper session={await getServerSession()}>
<NavigationWrapper>
{children}
</NavigationWrapper>
</AuthWrapper>
</ToastProvider>
</ThemeWrapper>
</body>
</html>

View file

@ -4,44 +4,18 @@ import ConnectionDetailsCard from "@/lib/components/ui/user/ConnectionDetailsCar
import OwnAliasesCard from "@/lib/components/ui/user/OwnAliasesCard";
import OwnCredentialsCard from "@/lib/components/ui/user/OwnCredentialsCard";
import useWindowDimensions from "@/lib/hooks/useWindowDimensions";
import { Callout, Grid, Heading } from "@radix-ui/themes";
import { CheckIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Grid, Heading } from "@radix-ui/themes";
export default function SelfService() {
const [passwordChanged, setPasswordChanged] = useState(false);
const dimensions = useWindowDimensions();
const mobileUi = dimensions.width < 1200;
function PasswordChangedAlert() {
const [hide, setHide] = useState(false);
useEffect(() => {
let timeout = setTimeout(() => {
setHide(true);
setTimeout(() => setPasswordChanged(false), 500);
}, 5000);
return () => clearTimeout(timeout);
});
return (
<Callout.Root className={`transition-all ${hide ? "opacity-0 h-0 m-0 !p-0" : " mb-4"}`}>
<Callout.Icon><CheckIcon /></Callout.Icon>
<Callout.Text>Password updated!</Callout.Text>
</Callout.Root>
);
}
return (
<>
{passwordChanged && (
<PasswordChangedAlert />
)}
<Heading className="pb-4">Account settings</Heading>
<Grid display="inline-grid" columns={mobileUi ? "1" : "3"} gap="4" width={mobileUi ? "100%" : "auto"}>
<OwnCredentialsCard onPasswordChange={() => setPasswordChanged(true)} />
<OwnCredentialsCard />
<ConnectionDetailsCard />
<OwnAliasesCard />
</Grid>

View file

@ -1,11 +1,13 @@
import { createAlias } from "@/lib/actions";
import { AliasEntry } from "@/lib/db";
import { ToastContext } from "@/lib/providers/ToastProvider";
import { Button, Callout, Dialog, Flex, TextField } from "@radix-ui/themes";
import { AtSignIcon, MailWarningIcon } from "lucide-react";
import { useState } from "react";
import { useContext, useState } from "react";
export default function CreateAliasButton({ user, onCreate }: { user: string, onCreate?: (alias: AliasEntry) => any }) {
const [value, setValue] = useState("");
const toast = useContext(ToastContext);
return (
<Dialog.Root onOpenChange={(open) => !open && setValue("")}>
@ -45,9 +47,18 @@ export default function CreateAliasButton({ user, onCreate }: { user: string, on
try {
const alias = await createAlias(user, value);
onCreate?.(alias);
toast({
title: "Alias created",
description: value,
variant: "success",
});
} catch (e) {
console.error(e);
alert(e);
toast({
title: "Failed to create alias",
description: `${e}`,
variant: "error",
});
}
}}
>Create</Button>

View file

@ -4,16 +4,18 @@ import { GRAVATAR_DEFAULT } from "@/lib/constants";
import { AliasEntry } from "@/lib/db";
import { isAdmin, sha256sum } from "@/lib/util";
import { Avatar, Badge, Button, Card, Code, Dialog, Flex, Grid, Heading, Table, Text } from "@radix-ui/themes";
import { useEffect, useState } from "react";
import { useContext, useEffect, useState } from "react";
import GhostMessage from "../GhostMessage";
import { BananaIcon } from "lucide-react";
import LoadingSpinner from "../LoadingSpinner";
import { approveAlias, deleteAlias, fetchUserAliases } from "@/lib/actions";
import GenericConfirmationDialog from "../GenericConfirmationDialog";
import CreateAliasButton from "./CreateAliasButton";
import { ToastContext } from "@/lib/providers/ToastProvider";
export default function ManageUserButton({ email }: { email: string }) {
const [aliases, setAliases] = useState<AliasEntry[] | null>(null);
const toast = useContext(ToastContext);
useEffect(() => {
fetchUserAliases(email).then(setAliases);
@ -77,8 +79,21 @@ export default function ManageUserButton({ email }: { email: string }) {
description={<>Do you want to approve <Code>{alias.address}</Code>&apos;s alias request for <Code>{alias.alias}</Code>?</>}
labelConfirm="Approve"
action={async () => {
await approveAlias(alias.alias);
setAliases(aliases.map((a) => a.id == alias.id ? { ...alias, pending: false } : a));
try {
await approveAlias(alias.alias);
setAliases(aliases.map((a) => a.id == alias.id ? { ...alias, pending: false } : a));
toast({
title: "Alias approved",
description: alias.alias,
variant: "success",
});
} catch(e) {
toast({
title: "Failed to approve alias",
description: `${e}`,
variant: "error",
});
}
}}
>
<Button size="1" variant="surface">Approve</Button>
@ -89,8 +104,21 @@ export default function ManageUserButton({ email }: { email: string }) {
description={<>Are you sure you want to delete <Code>{alias.alias}</Code>?</>}
labelConfirm="Delete"
action={async () => {
await deleteAlias(alias.alias);
setAliases(aliases.filter((a) => a.id != alias.id));
try {
await deleteAlias(alias.alias);
setAliases(aliases.filter((a) => a.id != alias.id));
toast({
title: "Alias deleted",
description: alias.alias,
variant: "success",
});
} catch(e) {
toast({
title: "Failed to delete alias",
description: `${e}`,
variant: "error",
});
}
}}
>
<Button size="1" variant="soft">Delete</Button>

View file

@ -7,9 +7,10 @@ import { aliasesNeedApproval } from "@/lib/util";
import { Badge, Box, Button, Callout, Card, Code, Dialog, DropdownMenu, Flex, Heading, ScrollArea, Table, Text, TextField } from "@radix-ui/themes";
import { ChevronDownIcon, InfoIcon, UsersRoundIcon } from "lucide-react";
import { useSession } from "next-auth/react";
import { useEffect, useState } from "react";
import { useContext, useEffect, useState } from "react";
import LoadingSpinner from "../LoadingSpinner";
import GhostMessage from "../GhostMessage";
import { ToastContext } from "@/lib/providers/ToastProvider";
export default function OwnAliasesCard() {
const session = useSession().data;
@ -17,6 +18,7 @@ export default function OwnAliasesCard() {
const [newAliasUsername, setNewAliasUsername] = useState("");
const [newAliasDomain, setNewAliasDomain] = useState(ALIAS_DOMAINS[0]);
const [allowAlias, setAllowAlias] = useState(false);
const toast = useContext(ToastContext);
useEffect(() => {
fetchOwnAliases().then(setAliases);
@ -98,9 +100,18 @@ export default function OwnAliasesCard() {
try {
const alias = await createAliasSelf(`${newAliasUsername}@${newAliasDomain}`);
setAliases([...(aliases ?? []), alias]);
} catch (e) {
toast({
title: "Alias created",
description: alias.pending ? "Administrator approval pending" : undefined,
variant: "success",
});
} catch(e) {
console.error(e);
alert(e);
toast({
title: "Failed to create alias",
description: `${e}`,
variant: "error",
});
}
}}>Create</Button>
</Dialog.Close>
@ -153,9 +164,18 @@ export default function OwnAliasesCard() {
try {
await deleteAlias(alias.alias);
setAliases(aliases.filter((a) => a.id != alias.id));
toast({
title: "Alias deleted",
description: alias.alias,
variant: "success",
});
} catch (e) {
console.error(e);
alert(e);
toast({
title: "Failed to delete alias",
description: `${e}`,
variant: "error",
});
}
}}
>

View file

@ -1,15 +1,17 @@
"use client";
import { changeOwnPassword } from "@/lib/actions";
import { ToastContext } from "@/lib/providers/ToastProvider";
import { Box, Button, Callout, Card, Dialog, Flex, Heading, IconButton, Link, Popover, Text, TextField, Tooltip } from "@radix-ui/themes";
import { AlertCircleIcon, InfoIcon, LockIcon, UserIcon } from "lucide-react";
import { useSession } from "next-auth/react";
import { useState } from "react";
import { useContext, useState } from "react";
export default function OwnCredentialsCard({ onPasswordChange }: { onPasswordChange?: () => any }) {
export default function OwnCredentialsCard() {
const session = useSession().data;
const [newPassword, setNewPassword] = useState("");
const validPassword = newPassword.length >= 8;
const toast = useContext(ToastContext);
return (
<Card className="h-fit">
@ -91,9 +93,18 @@ export default function OwnCredentialsCard({ onPasswordChange }: { onPasswordCha
try {
await changeOwnPassword(newPassword);
onPasswordChange?.();
toast({
title: "Password changed",
description: "balls",
variant: 'success',
});
} catch (e) {
alert(e);
console.error(e);
toast({
title: "Password change failed",
description: `${e}`,
variant: 'error',
});
}
}}
>Update</Button>

View file

@ -0,0 +1,80 @@
"use client";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { Box, Flex, Text } from "@radix-ui/themes";
import { CheckIcon, CircleSlashIcon, XCircleIcon, XIcon } from "lucide-react";
import { createContext, useState } from "react";
export type ToastData = {
title: string,
description?: string,
variant?: 'normal' | 'error' | 'success',
};
function genId() {
return `${Math.random()}${Date.now()}`; // good enough i guess
}
type ToastDataInternal = ToastData & { id: string };
export const ToastContext = createContext((toast: ToastData) => { });
export default function ToastProvider({
children,
}: {
children: React.ReactNode
}) {
const [toasts, setToasts] = useState<ToastDataInternal[]>([]);
return (
<ToastPrimitives.Provider>
{toasts.map((toast) => (
<Toast
key={toast.id}
toast={toast}
onClosed={() => setToasts(toasts.filter((t) => t.id != toast.id))}
/>
))}
<ToastContext.Provider value={(toast: ToastData) => {
// Updating the "local" `toasts` variable before calling `setToasts()` allows us to
// spawn multiple toasts without re-rendering. Otherwise, calling `toast()` twice
// will only display the last toast.
toasts.push({ ...toast, id: genId() });
setToasts([...toasts]);
}}>
{children}
</ToastContext.Provider>
<ToastPrimitives.Viewport className="ToastViewport" />
</ToastPrimitives.Provider>
);
}
function Toast({ toast, onClosed }: { toast: ToastData, onClosed: () => void }) {
return (
<ToastPrimitives.Root
className={`ToastRoot`}
duration={3000}
onOpenChange={(state) => !state && onClosed()}
>
<Flex justify="between" align="center" gap="4">
<Text>
{
toast.variant == "error"
? <CircleSlashIcon />
: toast.variant == "success"
? <CheckIcon />
: <></>
}
</Text>
<Box grow="1">
<ToastPrimitives.Title className="ToastTitle">{toast.title}</ToastPrimitives.Title>
{toast.description && <ToastPrimitives.Description className="ToastDescription">{toast.description}</ToastPrimitives.Description>}
</Box>
</Flex>
<ToastPrimitives.Close className="bg-transparent border-none p-0">
<Text color="gray"><XIcon size="16" /></Text>
</ToastPrimitives.Close>
</ToastPrimitives.Root>
);
}