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

@ -138,6 +138,11 @@ const redirects = [
destination: '/teams/:id/settings/team',
permanent: true,
},
{
source: '/admin',
destination: '/admin/users',
permanent: true,
},
];
// Adding rewrites + headers for all alternative tracker script names.

View file

@ -8,7 +8,7 @@ import { useNavigation, useGlobalState } from '@/components/hooks';
export function MenuBar() {
const [isCollapsed, setCollapsed] = useGlobalState('sidenav-collapsed');
const { websiteId } = useNavigation();
const { teamId, websiteId } = useNavigation();
const handleSelect = () => {};
@ -35,7 +35,12 @@ export function MenuBar() {
<Icon strokeColor="7" rotate={-25}>
<Slash />
</Icon>
<WebsiteSelect variant="quiet" websiteId={websiteId} onSelect={handleSelect} />
<WebsiteSelect
variant="quiet"
websiteId={websiteId}
teamId={teamId}
onSelect={handleSelect}
/>
</>
)}
</Row>

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,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

@ -2,8 +2,8 @@
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';
import { UserAddButton } from './UserAddButton';
export function UsersSettingsPage() {
const { formatMessage, labels } = useMessages();

View file

@ -8,7 +8,6 @@ import {
MenuItem,
MenuSeparator,
Modal,
Dialog,
} from '@umami/react-zen';
import Link from 'next/link';
import { formatDistance } from 'date-fns';
@ -17,7 +16,7 @@ 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';
import { UserDeleteForm } from './UserDeleteForm';
export function UsersTable({
data = [],
@ -29,13 +28,12 @@ export function UsersTable({
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>}
{(row: any) => <Link href={`/admin/users/${row.id}`}>{row.username}</Link>}
</DataColumn>
<DataColumn id="role" label={formatMessage(labels.role)}>
{(row: any) =>
@ -62,7 +60,7 @@ export function UsersTable({
return (
<MenuButton>
<MenuItem href={`/settings/users/${id}`} data-test="link-button-edit">
<MenuItem href={`/admin/users/${id}`} data-test="link-button-edit">
<Row alignItems="center" gap>
<Icon>
<Edit />
@ -90,19 +88,13 @@ export function UsersTable({
)}
</DataTable>
<Modal isOpen={!!deleteUser}>
<Dialog title={formatMessage(labels.deleteUser)}>
{({ close }) => (
<UserDeleteForm
userId={deleteUser?.id}
username={deleteUser?.username}
onSave={handleDelete}
onClose={() => {
close();
setDeleteUser(null);
}}
/>
)}
</Dialog>
<UserDeleteForm
userId={deleteUser?.id}
username={deleteUser?.username}
onClose={() => {
setDeleteUser(null);
}}
/>
</Modal>
</>
);

View file

@ -1,6 +1,6 @@
import { WebsitesTable } from '@/app/(main)/settings/websites/WebsitesTable';
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 });

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,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();

View file

@ -1,13 +1,14 @@
import { z } from 'zod';
import { unauthorized, json } from '@/lib/response';
import { canViewTeam } from '@/lib/auth';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { pagingParams } from '@/lib/schema';
import { parseRequest } from '@/lib/request';
import { pagingParams, searchParams } from '@/lib/schema';
import { getTeamWebsites } from '@/queries';
export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) {
const schema = z.object({
...pagingParams,
...searchParams,
});
const { teamId } = await params;
const { auth, query, error } = await parseRequest(request, schema);
@ -20,9 +21,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ team
return unauthorized();
}
const filters = await getQueryFilters(query);
const websites = await getTeamWebsites(teamId, filters);
const websites = await getTeamWebsites(teamId, query);
return json(websites);
}

View file

@ -1,12 +1,13 @@
import { z } from 'zod';
import { unauthorized, json } from '@/lib/response';
import { getUserWebsites } from '@/queries/prisma/website';
import { pagingParams } from '@/lib/schema';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { pagingParams, searchParams } from '@/lib/schema';
import { parseRequest } from '@/lib/request';
export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
const schema = z.object({
...pagingParams,
...searchParams,
});
const { auth, query, error } = await parseRequest(request, schema);
@ -21,9 +22,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ user
return unauthorized();
}
const filters = await getQueryFilters(query);
const websites = await getUserWebsites(userId, filters);
const websites = await getUserWebsites(userId, query);
return json(websites);
}

View file

@ -3,22 +3,9 @@ import { canCreateTeamWebsite, canCreateWebsite } from '@/lib/auth';
import { json, unauthorized } from '@/lib/response';
import { uuid } from '@/lib/crypto';
import { parseRequest } from '@/lib/request';
import { createWebsite, getUserWebsites } from '@/queries';
import { pagingParams } from '@/lib/schema';
import { createWebsite } from '@/queries';
export async function GET(request: Request) {
const schema = z.object({ ...pagingParams });
const { auth, query, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const websites = await getUserWebsites(auth.user.id, query);
return json(websites);
}
export { GET } from '@/app/api/users/[userId]/websites/route';
export async function POST(request: Request) {
const schema = z.object({

View file

@ -7,8 +7,8 @@ export function useTeamsQuery(userId: string) {
return useQuery({
queryKey: ['teams', { userId, modified }],
queryFn: (params: any) => {
return get(`/users/${userId}/teams`, params);
queryFn: () => {
return get(`/users/${userId}/teams`, { userId });
},
enabled: !!userId,
});

View file

@ -25,17 +25,17 @@ export function TeamsButton({
}) {
const { user } = useLoginQuery();
const { formatMessage, labels } = useMessages();
const { result } = useTeamsQuery(user.id);
const { data } = useTeamsQuery(user.id);
const { teamId } = useNavigation();
const router = useRouter();
const team = result?.data?.find(({ id }) => id === teamId);
const team = data?.data?.find(({ id }) => id === teamId);
const selectedKeys = new Set([teamId || user.id]);
const handleSelect = (id: Key) => {
router.push(id === user.id ? '/websites' : `/teams/${id}/websites`);
};
if (!result?.count) {
if (!data?.count) {
return null;
}
@ -68,7 +68,7 @@ export function TeamsButton({
</MenuSection>
<MenuSeparator />
<MenuSection title={formatMessage(labels.teams)}>
{result?.data?.map(({ id, name }) => (
{data?.data?.map(({ id, name }) => (
<MenuItem key={id} id={id}>
<Icon size="sm">
<Users />

View file

@ -29,10 +29,6 @@ export function WebsiteSelect({
setSearch(value);
};
if (!data) {
return null;
}
return (
<Select
{...props}

View file

@ -35,6 +35,9 @@ export const filterParams = {
hostname: z.string().optional(),
language: z.string().optional(),
event: z.string().optional(),
};
export const searchParams = {
search: z.string().optional(),
};