mirror of
https://github.com/umami-software/umami.git
synced 2025-12-06 01:18:00 +01:00
New admin section.
This commit is contained in:
parent
cdf391d5c2
commit
b78ff3b477
28 changed files with 161 additions and 100 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
54
src/app/(main)/admin/AdminLayout.tsx
Normal file
54
src/app/(main)/admin/AdminLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/app/(main)/admin/layout.tsx
Normal file
17
src/app/(main)/admin/layout.tsx
Normal 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',
|
||||
},
|
||||
};
|
||||
43
src/app/(main)/admin/users/UserDeleteForm.tsx
Normal file
43
src/app/(main)/admin/users/UserDeleteForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
@ -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 });
|
||||
|
|
@ -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}> {username}</b>,
|
||||
})}
|
||||
onConfirm={handleConfirm}
|
||||
onClose={onClose}
|
||||
buttonLabel={formatMessage(labels.delete)}
|
||||
buttonVariant="danger"
|
||||
isLoading={isPending}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -29,10 +29,6 @@ export function WebsiteSelect({
|
|||
setSearch(value);
|
||||
};
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue