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

@ -0,0 +1,54 @@
'use client';
import { ReactNode } from 'react';
import { Grid, Column } from '@umami/react-zen';
import { useLoginQuery, useMessages, useNavigation } from '@/components/hooks';
import { SideMenu } from '@/components/common/SideMenu';
import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel';
import { PageBody } from '@/components/common/PageBody';
export function AdminLayout({ children }: { children: ReactNode }) {
const { user } = useLoginQuery();
const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
if (!user.isAdmin) {
return null;
}
const items = [
{
id: 'users',
label: formatMessage(labels.users),
url: '/admin/users',
},
{
id: 'websites',
label: formatMessage(labels.websites),
url: '/admin/websites',
},
{
id: 'teams',
label: formatMessage(labels.teams),
url: '/admin/teams',
},
];
const value = items.find(({ url }) => pathname.includes(url))?.id;
return (
<PageBody>
<Column gap="6">
<PageHeader title={formatMessage(labels.admin)} />
<Grid columns="160px 1fr" gap>
<Column>
<SideMenu items={items} selectedKey={value} />
</Column>
<Column>
<Panel>{children}</Panel>
</Column>
</Grid>
</Column>
</PageBody>
);
}

View file

@ -0,0 +1,17 @@
import { Metadata } from 'next';
import { AdminLayout } from './AdminLayout';
export default function ({ children }) {
if (process.env.cloudMode) {
return null;
}
return <AdminLayout>{children}</AdminLayout>;
}
export const metadata: Metadata = {
title: {
template: '%s | Admin | Umami',
default: 'Admin | Umami',
},
};

View file

@ -0,0 +1,32 @@
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

@ -0,0 +1,74 @@
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

@ -0,0 +1,35 @@
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

@ -0,0 +1,43 @@
import { AlertDialog, Row } from '@umami/react-zen';
import { useApi, useMessages, useModified } from '@/components/hooks';
export function UserDeleteForm({
userId,
username,
onSave,
onClose,
}: {
userId: string;
username: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { messages, labels, formatMessage } = useMessages();
const { del, useMutation } = useApi();
const { mutate } = useMutation({ mutationFn: () => del(`/users/${userId}`) });
const { touch } = useModified();
const handleConfirm = async () => {
mutate(null, {
onSuccess: async () => {
touch('users');
onSave?.();
onClose?.();
},
});
};
return (
<AlertDialog
title={formatMessage(labels.delete)}
onConfirm={handleConfirm}
onCancel={onClose}
confirmLabel={formatMessage(labels.delete)}
isDanger
>
<Row gap="1">
{formatMessage(messages.confirmDelete, { target: <b key={username}>{username}</b> })}
</Row>
</AlertDialog>
);
}

View file

@ -0,0 +1,14 @@
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

@ -0,0 +1,21 @@
'use client';
import { UsersDataTable } from './UsersDataTable';
import { Column } from '@umami/react-zen';
import { SectionHeader } from '@/components/common/SectionHeader';
import { useMessages } from '@/components/hooks';
import { UserAddButton } from './UserAddButton';
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

@ -0,0 +1,101 @@
import { useState } from 'react';
import {
Row,
Text,
Icon,
DataTable,
DataColumn,
MenuItem,
MenuSeparator,
Modal,
} 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 './UserDeleteForm';
export function UsersTable({
data = [],
showActions = true,
}: {
data: any[];
showActions?: boolean;
}) {
const { formatMessage, labels } = useMessages();
const { dateLocale } = useLocale();
const [deleteUser, setDeleteUser] = useState(null);
return (
<>
<DataTable data={data}>
<DataColumn id="username" label={formatMessage(labels.username)} width="2fr">
{(row: any) => <Link href={`/admin/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={`/admin/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}>
<UserDeleteForm
userId={deleteUser?.id}
username={deleteUser?.username}
onClose={() => {
setDeleteUser(null);
}}
/>
</Modal>
</>
);
}

View file

@ -0,0 +1,88 @@
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

@ -0,0 +1,11 @@
'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

@ -0,0 +1,22 @@
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

@ -0,0 +1,31 @@
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

@ -0,0 +1,15 @@
import { DataGrid } from '@/components/common/DataGrid';
import { useWebsitesQuery } from '@/components/hooks';
import { WebsitesTable } from '@/app/(main)/settings/websites/WebsitesTable';
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

@ -0,0 +1,12 @@
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

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