New admin section.

This commit is contained in:
Mike Cao 2025-07-06 08:22:29 -07:00
parent cdf391d5c2
commit b78ff3b477
28 changed files with 161 additions and 100 deletions

View file

@ -1,32 +0,0 @@
import { Button, Icon, Text, Modal, DialogTrigger, Dialog, useToast } from '@umami/react-zen';
import { UserAddForm } from './UserAddForm';
import { useMessages, useModified } from '@/components/hooks';
import { Plus } from '@/components/icons';
export function UserAddButton({ onSave }: { onSave?: () => void }) {
const { formatMessage, labels, messages } = useMessages();
const { toast } = useToast();
const { touch } = useModified();
const handleSave = () => {
toast(formatMessage(messages.saved));
touch('users');
onSave?.();
};
return (
<DialogTrigger>
<Button variant="primary" data-test="button-create-user">
<Icon>
<Plus />
</Icon>
<Text>{formatMessage(labels.createUser)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.createUser)}>
{({ close }) => <UserAddForm onSave={handleSave} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
);
}

View file

@ -1,74 +0,0 @@
import {
Select,
ListItem,
Form,
FormField,
FormButtons,
FormSubmitButton,
TextField,
PasswordField,
Button,
} from '@umami/react-zen';
import { useApi, useMessages } from '@/components/hooks';
import { ROLES } from '@/lib/constants';
export function UserAddForm({ onSave, onClose }) {
const { post, useMutation } = useApi();
const { mutate, error, isPending } = useMutation({
mutationFn: (data: any) => post(`/users`, data),
});
const { formatMessage, labels } = useMessages();
const handleSubmit = async (data: any) => {
mutate(data, {
onSuccess: async () => {
onSave(data);
onClose();
},
});
};
return (
<Form onSubmit={handleSubmit} error={error}>
<FormField
label={formatMessage(labels.username)}
name="username"
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoComplete="new-username" data-test="input-username" />
</FormField>
<FormField
label={formatMessage(labels.password)}
name="password"
rules={{ required: formatMessage(labels.required) }}
>
<PasswordField autoComplete="new-password" data-test="input-password" />
</FormField>
<FormField
label={formatMessage(labels.role)}
name="role"
rules={{ required: formatMessage(labels.required) }}
>
<Select>
<ListItem id={ROLES.viewOnly} data-test="dropdown-item-viewOnly">
{formatMessage(labels.viewOnly)}
</ListItem>
<ListItem id={ROLES.user} data-test="dropdown-item-user">
{formatMessage(labels.user)}
</ListItem>
<ListItem id={ROLES.admin} data-test="dropdown-item-admin">
{formatMessage(labels.admin)}
</ListItem>
</Select>
</FormField>
<FormButtons>
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
<FormSubmitButton variant="primary" data-test="button-submit" isDisabled={false}>
{formatMessage(labels.save)}
</FormSubmitButton>
</FormButtons>
</Form>
);
}

View file

@ -1,35 +0,0 @@
import { Button, Icon, Modal, DialogTrigger, Dialog, Text } from '@umami/react-zen';
import { useMessages, useLoginQuery } from '@/components/hooks';
import { Trash } from '@/components/icons';
import { UserDeleteForm } from './UserDeleteForm';
export function UserDeleteButton({
userId,
username,
onDelete,
}: {
userId: string;
username: string;
onDelete?: () => void;
}) {
const { formatMessage, labels } = useMessages();
const { user } = useLoginQuery();
return (
<DialogTrigger>
<Button isDisabled={userId === user?.id} data-test="button-delete">
<Icon size="sm">
<Trash />
</Icon>
<Text>{formatMessage(labels.delete)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.deleteUser)}>
{({ close }) => (
<UserDeleteForm userId={userId} username={username} onSave={onDelete} onClose={close} />
)}
</Dialog>
</Modal>
</DialogTrigger>
);
}

View file

@ -1,36 +0,0 @@
import { useToast } from '@umami/react-zen';
import { useApi, useMessages, useModified } from '@/components/hooks';
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
export function UserDeleteForm({ userId, username, onSave, onClose }) {
const { messages, labels, formatMessage } = useMessages();
const { del, useMutation } = useApi();
const { mutate, error, isPending } = useMutation({ mutationFn: () => del(`/users/${userId}`) });
const { touch } = useModified();
const { toast } = useToast();
const handleConfirm = async () => {
mutate(null, {
onSuccess: async () => {
touch('users');
toast(formatMessage(messages.successMessage));
onSave?.();
onClose?.();
},
});
};
return (
<ConfirmationForm
message={formatMessage(messages.confirmDelete, {
target: <b key={messages.confirmDelete.id}>&nbsp;{username}</b>,
})}
onConfirm={handleConfirm}
onClose={onClose}
buttonLabel={formatMessage(labels.delete)}
buttonVariant="danger"
isLoading={isPending}
error={error}
/>
);
}

View file

@ -1,14 +0,0 @@
import { DataGrid } from '@/components/common/DataGrid';
import { useUsersQuery } from '@/components/hooks';
import { UsersTable } from './UsersTable';
import { ReactNode } from 'react';
export function UsersDataTable({ showActions }: { showActions?: boolean; children?: ReactNode }) {
const queryResult = useUsersQuery();
return (
<DataGrid queryResult={queryResult} allowSearch={true}>
{({ data }) => <UsersTable data={data} showActions={showActions} />}
</DataGrid>
);
}

View file

@ -1,21 +0,0 @@
'use client';
import { UsersDataTable } from './UsersDataTable';
import { Column } from '@umami/react-zen';
import { SectionHeader } from '@/components/common/SectionHeader';
import { UserAddButton } from '@/app/(main)/settings/users/UserAddButton';
import { useMessages } from '@/components/hooks';
export function UsersSettingsPage() {
const { formatMessage, labels } = useMessages();
const handleSave = () => {};
return (
<Column gap>
<SectionHeader title={formatMessage(labels.users)}>
<UserAddButton onSave={handleSave} />
</SectionHeader>
<UsersDataTable />
</Column>
);
}

View file

@ -1,109 +0,0 @@
import { useState } from 'react';
import {
Row,
Text,
Icon,
DataTable,
DataColumn,
MenuItem,
MenuSeparator,
Modal,
Dialog,
} from '@umami/react-zen';
import Link from 'next/link';
import { formatDistance } from 'date-fns';
import { ROLES } from '@/lib/constants';
import { Trash } from '@/components/icons';
import { useMessages, useLocale } from '@/components/hooks';
import { Edit } from '@/components/icons';
import { MenuButton } from '@/components/input/MenuButton';
import { UserDeleteForm } from '@/app/(main)/settings/users/UserDeleteForm';
export function UsersTable({
data = [],
showActions = true,
}: {
data: any[];
showActions?: boolean;
}) {
const { formatMessage, labels } = useMessages();
const { dateLocale } = useLocale();
const [deleteUser, setDeleteUser] = useState(null);
const handleDelete = () => {};
return (
<>
<DataTable data={data}>
<DataColumn id="username" label={formatMessage(labels.username)} width="2fr">
{(row: any) => <Link href={`/settings/users/${row.id}`}>{row.username}</Link>}
</DataColumn>
<DataColumn id="role" label={formatMessage(labels.role)}>
{(row: any) =>
formatMessage(
labels[Object.keys(ROLES).find(key => ROLES[key] === row.role)] || labels.unknown,
)
}
</DataColumn>
<DataColumn id="created" label={formatMessage(labels.created)}>
{(row: any) =>
formatDistance(new Date(row.createdAt), new Date(), {
addSuffix: true,
locale: dateLocale,
})
}
</DataColumn>
<DataColumn id="websites" label={formatMessage(labels.websites)} width="100px">
{(row: any) => row._count.websiteUser}
</DataColumn>
{showActions && (
<DataColumn id="action" align="end" width="100px">
{(row: any) => {
const { id } = row;
return (
<MenuButton>
<MenuItem href={`/settings/users/${id}`} data-test="link-button-edit">
<Row alignItems="center" gap>
<Icon>
<Edit />
</Icon>
<Text>{formatMessage(labels.edit)}</Text>
</Row>
</MenuItem>
<MenuSeparator />
<MenuItem
id="delete"
onAction={() => setDeleteUser(row)}
data-test="link-button-delete"
>
<Row alignItems="center" gap>
<Icon>
<Trash />
</Icon>
<Text>{formatMessage(labels.delete)}</Text>
</Row>
</MenuItem>
</MenuButton>
);
}}
</DataColumn>
)}
</DataTable>
<Modal isOpen={!!deleteUser}>
<Dialog title={formatMessage(labels.deleteUser)}>
{({ close }) => (
<UserDeleteForm
userId={deleteUser?.id}
username={deleteUser?.username}
onSave={handleDelete}
onClose={() => {
close();
setDeleteUser(null);
}}
/>
)}
</Dialog>
</Modal>
</>
);
}

View file

@ -1,88 +0,0 @@
import {
Select,
ListItem,
Form,
FormField,
FormButtons,
TextField,
FormSubmitButton,
PasswordField,
useToast,
} from '@umami/react-zen';
import { useApi, useLoginQuery, useMessages, useModified } from '@/components/hooks';
import { ROLES } from '@/lib/constants';
import { useContext } from 'react';
import { UserContext } from './UserProvider';
export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () => void }) {
const { formatMessage, labels, messages, getMessage } = useMessages();
const { post, useMutation } = useApi();
const user = useContext(UserContext);
const { user: login } = useLoginQuery();
const { toast } = useToast();
const { touch } = useModified();
const { mutate, error } = useMutation({
mutationFn: ({
username,
password,
role,
}: {
username: string;
password: string;
role: string;
}) => post(`/users/${userId}`, { username, password, role }),
});
const handleSubmit = async (data: any) => {
mutate(data, {
onSuccess: async () => {
toast(formatMessage(messages.saved));
touch(`user:${user.id}`);
onSave?.();
},
});
};
return (
<Form onSubmit={handleSubmit} error={getMessage(error)} values={user} style={{ width: 300 }}>
<FormField name="username" label={formatMessage(labels.username)}>
<TextField data-test="input-username" />
</FormField>
<FormField
name="password"
label={formatMessage(labels.password)}
rules={{
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: 8 }) },
}}
>
<PasswordField autoComplete="new-password" data-test="input-password" />
</FormField>
{user.id !== login.id && (
<FormField
name="role"
label={formatMessage(labels.role)}
rules={{ required: formatMessage(labels.required) }}
>
<Select defaultSelectedKey={user.role}>
<ListItem id={ROLES.viewOnly} data-test="dropdown-item-viewOnly">
{formatMessage(labels.viewOnly)}
</ListItem>
<ListItem id={ROLES.user} data-test="dropdown-item-user">
{formatMessage(labels.user)}
</ListItem>
<ListItem id={ROLES.admin} data-test="dropdown-item-admin">
{formatMessage(labels.admin)}
</ListItem>
</Select>
</FormField>
)}
<FormButtons>
<FormSubmitButton data-test="button-submit" variant="primary">
{formatMessage(labels.save)}
</FormSubmitButton>
</FormButtons>
</Form>
);
}

View file

@ -1,11 +0,0 @@
'use client';
import { UserSettings } from './UserSettings';
import { UserProvider } from './UserProvider';
export function UserPage({ userId }: { userId: string }) {
return (
<UserProvider userId={userId}>
<UserSettings userId={userId} />
</UserProvider>
);
}

View file

@ -1,22 +0,0 @@
import { createContext, ReactNode, useEffect } from 'react';
import { Loading } from '@umami/react-zen';
import { useModified, useUserQuery } from '@/components/hooks';
export const UserContext = createContext(null);
export function UserProvider({ userId, children }: { userId: string; children: ReactNode }) {
const { modified } = useModified(`user:${userId}`);
const { data: user, isFetching, isLoading, refetch } = useUserQuery(userId);
useEffect(() => {
if (modified) {
refetch();
}
}, [modified]);
if (isFetching && isLoading) {
return <Loading position="page" />;
}
return <UserContext.Provider value={{ ...user, modified }}>{children}</UserContext.Provider>;
}

View file

@ -1,31 +0,0 @@
import { useContext } from 'react';
import { Tabs, Tab, TabList, TabPanel } from '@umami/react-zen';
import { User } from '@/components/icons';
import { UserEditForm } from './UserEditForm';
import { SectionHeader } from '@/components/common/SectionHeader';
import { useMessages } from '@/components/hooks';
import { UserWebsites } from './UserWebsites';
import { UserContext } from './UserProvider';
export function UserSettings({ userId }: { userId: string }) {
const { formatMessage, labels } = useMessages();
const user = useContext(UserContext);
return (
<>
<SectionHeader title={user?.username} icon={<User />} />
<Tabs>
<TabList>
<Tab id="details">{formatMessage(labels.details)}</Tab>
<Tab id="websites">{formatMessage(labels.websites)}</Tab>
</TabList>
<TabPanel id="details">
<UserEditForm userId={userId} />
</TabPanel>
<TabPanel id="websites">
<UserWebsites userId={userId} />
</TabPanel>
</Tabs>
</>
);
}

View file

@ -1,15 +0,0 @@
import { WebsitesTable } from '@/app/(main)/settings/websites/WebsitesTable';
import { DataGrid } from '@/components/common/DataGrid';
import { useWebsitesQuery } from '@/components/hooks';
export function UserWebsites({ userId }) {
const queryResult = useWebsitesQuery({ userId });
return (
<DataGrid queryResult={queryResult}>
{({ data }) => (
<WebsitesTable data={data} showActions={true} allowEdit={true} allowView={true} />
)}
</DataGrid>
);
}

View file

@ -1,12 +0,0 @@
import { UserPage } from './UserPage';
import { Metadata } from 'next';
export default async function ({ params }: { params: Promise<{ userId: string }> }) {
const { userId } = await params;
return <UserPage userId={userId} />;
}
export const metadata: Metadata = {
title: 'User Settings',
};

View file

@ -1,9 +0,0 @@
import { Metadata } from 'next';
import { UsersSettingsPage } from './UsersSettingsPage';
export default function () {
return <UsersSettingsPage />;
}
export const metadata: Metadata = {
title: 'Users',
};

View file

@ -1,5 +1,4 @@
import { ReactNode } from 'react';
import { WebsitesTable } from '@/app/(main)/settings/websites/WebsitesTable';
import { WebsitesTable } from './WebsitesTable';
import { DataGrid } from '@/components/common/DataGrid';
import { useWebsitesQuery } from '@/components/hooks';
@ -8,18 +7,16 @@ export function WebsitesDataTable({
allowEdit = true,
allowView = true,
showActions = true,
children,
}: {
teamId?: string;
allowEdit?: boolean;
allowView?: boolean;
showActions?: boolean;
children?: ReactNode;
}) {
const queryResult = useWebsitesQuery({ teamId });
return (
<DataGrid queryResult={queryResult} renderEmpty={() => children} allowSearch allowPaging>
<DataGrid queryResult={queryResult} allowSearch allowPaging>
{({ data }) => (
<WebsitesTable
teamId={teamId}

View file

@ -1,10 +1,10 @@
'use client';
import { Column } from '@umami/react-zen';
import { useLoginQuery, useMessages } from '@/components/hooks';
import { WebsitesDataTable } from './WebsitesDataTable';
import { ROLES } from '@/lib/constants';
import { WebsiteAddButton } from '@/app/(main)/settings/websites/WebsiteAddButton';
import { SectionHeader } from '@/components/common/SectionHeader';
import { Column } from '@umami/react-zen';
import { WebsiteAddButton } from './WebsiteAddButton';
export function WebsitesSettingsPage({ teamId }: { teamId: string }) {
const { user } = useLoginQuery();