Admin section updates.

This commit is contained in:
Mike Cao 2025-07-10 20:04:17 -07:00
parent 87449ece9e
commit 1b81074752
20 changed files with 274 additions and 647 deletions

File diff suppressed because it is too large Load diff

View file

@ -19,22 +19,22 @@ export function SideNav(props: any) {
const links = [ const links = [
{ {
label: formatMessage(labels.websites), label: formatMessage(labels.websites),
href: '/websites', href: renderUrl('/websites', false),
icon: <Globe />, icon: <Globe />,
}, },
{ {
label: formatMessage(labels.boards), label: formatMessage(labels.boards),
href: '/boards', href: renderUrl('/boards', false),
icon: <LayoutDashboard />, icon: <LayoutDashboard />,
}, },
{ {
label: formatMessage(labels.links), label: formatMessage(labels.links),
href: '/links', href: renderUrl('/links', false),
icon: <LinkIcon />, icon: <LinkIcon />,
}, },
{ {
label: formatMessage(labels.pixels), label: formatMessage(labels.pixels),
href: '/pixels', href: renderUrl('/pixels', false),
icon: <Grid2X2 />, icon: <Grid2X2 />,
}, },
{ {
@ -57,7 +57,7 @@ export function SideNav(props: any) {
<SidebarSection> <SidebarSection>
{links.map(({ href, label, icon }) => { {links.map(({ href, label, icon }) => {
return ( return (
<Link key={href} href={renderUrl(href, false)} role="button"> <Link key={href} href={href} role="button">
<SidebarItem label={label} icon={icon} isSelected={pathname.startsWith(href)} /> <SidebarItem label={label} icon={icon} isSelected={pathname.startsWith(href)} />
</Link> </Link>
); );

View file

@ -1,10 +1,11 @@
'use client'; 'use client';
import { TeamDetails } from '@/app/(main)/teams/[teamId]/settings/team/TeamDetails'; import { TeamDetails } from '@/app/(main)/teams/[teamId]/settings/team/TeamDetails';
import { TeamProvider } from '@/app/(main)/teams/[teamId]/TeamProvider';
export function AdminTeamPage({ teamId }: { teamId: string }) { export function AdminTeamPage({ teamId }: { teamId: string }) {
return ( return (
<> <TeamProvider teamId={teamId}>
<TeamDetails teamId={teamId} /> <TeamDetails teamId={teamId} />
</> </TeamProvider>
); );
} }

View file

@ -1,15 +1,10 @@
import { AdminTeamPage } from './AdminTeamPage'; import { AdminTeamPage } from './AdminTeamPage';
import { TeamProvider } from '@/app/(main)/teams/[teamId]/TeamProvider';
import { Metadata } from 'next'; import { Metadata } from 'next';
export default async function ({ params }: { params: Promise<{ teamId: string }> }) { export default async function ({ params }: { params: Promise<{ teamId: string }> }) {
const { teamId } = await params; const { teamId } = await params;
return ( return <AdminTeamPage teamId={teamId} />;
<TeamProvider teamId={teamId}>
<AdminTeamPage teamId={teamId} />
</TeamProvider>
);
} }
export const metadata: Metadata = { export const metadata: Metadata = {

View file

@ -8,5 +8,5 @@ export default async function ({ params }: { params: Promise<{ userId: string }>
} }
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Users', title: 'User',
}; };

View file

@ -0,0 +1,11 @@
'use client';
import { WebsiteSettings } from '@/app/(main)/settings/websites/[websiteId]/WebsiteSettings';
import { WebsiteProvider } from '@/app/(main)/websites/[websiteId]/WebsiteProvider';
export function AdminWebsitePage({ websiteId }: { websiteId: string }) {
return (
<WebsiteProvider websiteId={websiteId}>
<WebsiteSettings websiteId={websiteId} />
</WebsiteProvider>
);
}

View file

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

View file

@ -1,11 +0,0 @@
.filters {
display: flex;
justify-content: flex-start;
align-items: flex-start;
}
.tag {
text-align: center;
margin-bottom: 10px;
margin-inline-end: 20px;
}

View file

@ -1,38 +0,0 @@
import { Button, Icon, Text } from '@umami/react-zen';
import { Close } from '@/components/icons';
import styles from './WebsiteTags.module.css';
export function WebsiteTags({
items = [],
websites = [],
onClick,
}: {
items: any[];
websites: any[];
onClick: (e: Event) => void;
}) {
if (websites.length === 0) {
return null;
}
return (
<div className={styles.filters}>
{websites.map(websiteId => {
const website = items.find(a => a.id === websiteId);
return (
<div key={websiteId} className={styles.tag}>
<Button onPress={() => onClick(websiteId)} variant="primary" size="sm">
<Text>
<b>{`${website.name}`}</b>
</Text>
<Icon>
<Close />
</Icon>
</Button>
</div>
);
})}
</div>
);
}

View file

@ -19,19 +19,21 @@ export function WebsiteData({ websiteId, onSave }: { websiteId: string; onSave?:
const { touch } = useModified(); const { touch } = useModified();
const { teamId, renderUrl } = useNavigation(); const { teamId, renderUrl } = useNavigation();
const router = useRouter(); const router = useRouter();
const { data } = useUserTeamsQuery(user.id); const { data: teams } = useUserTeamsQuery(user.id);
const canTransferWebsite = const canTransferWebsite =
( (
!teamId && (!teamId &&
data.filter(({ teamUser }) => teams?.data?.filter(({ teamUser }) =>
teamUser.find( teamUser.find(
({ role, userId }) => ({ role, userId }) =>
[ROLES.teamOwner, ROLES.teamManager].includes(role) && userId === user.id, [ROLES.teamOwner, ROLES.teamManager].includes(role) && userId === user.id,
), ),
) )) ||
[]
).length > 0 || ).length > 0 ||
(teamId && (teamId &&
!!data !!teams?.data
?.find(({ id }) => id === teamId) ?.find(({ id }) => id === teamId)
?.teamUser.find(({ role, userId }) => role === ROLES.teamOwner && userId === user.id)); ?.teamUser.find(({ role, userId }) => role === ROLES.teamOwner && userId === user.id));

View file

@ -6,17 +6,13 @@ import {
Switch, Switch,
FormSubmitButton, FormSubmitButton,
Column, Column,
Icon,
Grid,
Label, Label,
useToast, useToast,
TooltipTrigger, Row,
Tooltip,
} from '@umami/react-zen'; } from '@umami/react-zen';
import { useState } from 'react'; import { useState } from 'react';
import { getRandomChars } from '@/lib/crypto'; import { getRandomChars } from '@/lib/crypto';
import { useApi, useMessages, useModified } from '@/components/hooks'; import { useApi, useMessages, useModified } from '@/components/hooks';
import { Refresh } from '@/components/icons';
const generateId = () => getRandomChars(16); const generateId = () => getRandomChars(16);
@ -70,24 +66,19 @@ export function WebsiteShareForm({ websiteId, shareId, onSave, onClose }: Websit
{id && ( {id && (
<Column> <Column>
<Label>{formatMessage(labels.shareUrl)}</Label> <Label>{formatMessage(labels.shareUrl)}</Label>
<Grid columns="1fr auto" gap>
<TextField value={url} isReadOnly allowCopy /> <TextField value={url} isReadOnly allowCopy />
<TooltipTrigger>
<Button onPress={handleGenerate} variant="quiet" size="sm">
<Icon>
<Refresh />
</Icon>
</Button>
<Tooltip>{formatMessage(labels.regenerate)}</Tooltip>
</TooltipTrigger>
</Grid>
</Column> </Column>
)} )}
<FormButtons> <FormButtons justifyContent="space-between">
<Button onPress={onClose}>{formatMessage(labels.cancel)}</Button> <Row>
{id && <Button onPress={handleGenerate}>{formatMessage(labels.regenerate)}</Button>}
</Row>
<Row>
{onClose && <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>}
<FormSubmitButton isDisabled={false} isLoading={isPending}> <FormSubmitButton isDisabled={false} isLoading={isPending}>
{formatMessage(labels.save)} {formatMessage(labels.save)}
</FormSubmitButton> </FormSubmitButton>
</Row>
</FormButtons> </FormButtons>
</Column> </Column>
</Form> </Form>

View file

@ -31,15 +31,16 @@ export function WebsiteTransferForm({
const { mutate, error } = useMutation({ const { mutate, error } = useMutation({
mutationFn: (data: any) => post(`/websites/${websiteId}/transfer`, data), mutationFn: (data: any) => post(`/websites/${websiteId}/transfer`, data),
}); });
const { result, query } = useUserTeamsQuery(user.id); const { data: teams, isLoading } = useUserTeamsQuery(user.id);
const isTeamWebsite = !!website?.teamId; const isTeamWebsite = !!website?.teamId;
const items = result.data.filter(({ teamUser }) => const items =
teams?.data?.filter(({ teamUser }) =>
teamUser.find( teamUser.find(
({ role, userId }) => ({ role, userId }) =>
[ROLES.teamOwner, ROLES.teamManager].includes(role) && userId === user.id, [ROLES.teamOwner, ROLES.teamManager].includes(role) && userId === user.id,
), ),
); ) || [];
const handleSubmit = async () => { const handleSubmit = async () => {
mutate( mutate(
@ -60,7 +61,7 @@ export function WebsiteTransferForm({
setTeamId(key as string); setTeamId(key as string);
}; };
if (query.isLoading) { if (isLoading) {
return <Loading icon="dots" position="center" />; return <Loading icon="dots" position="center" />;
} }

View file

@ -1,12 +1,15 @@
import { useContext, useState } from 'react';
import { Column, Tabs, TabList, Tab, TabPanel } from '@umami/react-zen';
import { TeamContext } from '@/app/(main)/teams/[teamId]/TeamProvider'; import { TeamContext } from '@/app/(main)/teams/[teamId]/TeamProvider';
import { useLoginQuery, useMessages } from '@/components/hooks'; import { useLoginQuery, useMessages } from '@/components/hooks';
import { SectionHeader } from '@/components/common/SectionHeader'; import { SectionHeader } from '@/components/common/SectionHeader';
import { ROLES } from '@/lib/constants'; import { ROLES } from '@/lib/constants';
import { useContext, useState } from 'react'; import { Users } from '@/components/icons';
import { Column, Tabs, TabList, Tab, TabPanel } from '@umami/react-zen';
import { TeamLeaveButton } from '@/app/(main)/settings/teams/TeamLeaveButton'; import { TeamLeaveButton } from '@/app/(main)/settings/teams/TeamLeaveButton';
import { TeamManage } from './TeamManage'; import { TeamManage } from './TeamManage';
import { TeamEditForm } from './TeamEditForm'; import { TeamEditForm } from './TeamEditForm';
import { TeamWebsitesDataTable } from '@/app/(main)/teams/[teamId]/settings/websites/TeamWebsitesDataTable';
import { TeamMembersDataTable } from '@/app/(main)/teams/[teamId]/settings/members/TeamMembersDataTable';
export function TeamDetails({ teamId }: { teamId: string }) { export function TeamDetails({ teamId }: { teamId: string }) {
const team = useContext(TeamContext); const team = useContext(TeamContext);
@ -26,17 +29,25 @@ export function TeamDetails({ teamId }: { teamId: string }) {
return ( return (
<Column gap> <Column gap>
<SectionHeader title={team?.name}> <SectionHeader title={team?.name} icon={<Users />}>
{!isTeamOwner && <TeamLeaveButton teamId={team.id} teamName={team.name} />} {!isTeamOwner && <TeamLeaveButton teamId={team.id} teamName={team.name} />}
</SectionHeader> </SectionHeader>
<Tabs selectedKey={tab} onSelectionChange={(value: any) => setTab(value)}> <Tabs selectedKey={tab} onSelectionChange={(value: any) => setTab(value)}>
<TabList> <TabList>
<Tab id="details">{formatMessage(labels.details)}</Tab> <Tab id="details">{formatMessage(labels.details)}</Tab>
<Tab id="members">{formatMessage(labels.members)}</Tab>
<Tab id="websites">{formatMessage(labels.websites)}</Tab>
{isTeamOwner && <Tab id="manage">{formatMessage(labels.manage)}</Tab>} {isTeamOwner && <Tab id="manage">{formatMessage(labels.manage)}</Tab>}
</TabList> </TabList>
<TabPanel id="details"> <TabPanel id="details">
<TeamEditForm teamId={teamId} allowEdit={canEdit} /> <TeamEditForm teamId={teamId} allowEdit={canEdit} />
</TabPanel> </TabPanel>
<TabPanel id="members">
<TeamMembersDataTable teamId={teamId} allowEdit />
</TabPanel>
<TabPanel id="websites">
<TeamWebsitesDataTable teamId={teamId} allowEdit />
</TabPanel>
<TabPanel id="manage"> <TabPanel id="manage">
<TeamManage teamId={teamId} /> <TeamManage teamId={teamId} />
</TabPanel> </TabPanel>

View file

@ -6,6 +6,7 @@ import {
TextField, TextField,
Button, Button,
useToast, useToast,
Text,
} from '@umami/react-zen'; } from '@umami/react-zen';
import { getRandomChars } from '@/lib/crypto'; import { getRandomChars } from '@/lib/crypto';
import { useContext } from 'react'; import { useContext } from 'react';
@ -47,8 +48,7 @@ export function TeamEditForm({ teamId, allowEdit }: { teamId: string; allowEdit?
label={formatMessage(labels.name)} label={formatMessage(labels.name)}
rules={{ required: formatMessage(labels.required) }} rules={{ required: formatMessage(labels.required) }}
> >
{allowEdit && <TextField />} {allowEdit ? <TextField /> : <Text>{team?.name}</Text>}
{!allowEdit && team?.name}
</FormField> </FormField>
{!cloudMode && allowEdit && ( {!cloudMode && allowEdit && (
<FormField name="accessCode" label={formatMessage(labels.accessCode)}> <FormField name="accessCode" label={formatMessage(labels.accessCode)}>

View file

@ -32,6 +32,9 @@ export async function GET(request: Request) {
}, },
}, },
}, },
omit: {
password: true,
},
orderBy: { orderBy: {
createdAt: 'desc', createdAt: 'desc',
}, },

View file

@ -18,7 +18,7 @@ export function SectionHeader({
return ( return (
<Row {...props} justifyContent="space-between" alignItems="center" height="60px"> <Row {...props} justifyContent="space-between" alignItems="center" height="60px">
<Row gap="3" alignItems="center"> <Row gap="3" alignItems="center">
{icon && <Icon>{icon}</Icon>} {icon && <Icon size="md">{icon}</Icon>}
{title && <Heading size="3">{title}</Heading>} {title && <Heading size="3">{title}</Heading>}
{description && <Text color="muted">{description}</Text>} {description && <Text color="muted">{description}</Text>}
</Row> </Row>

View file

@ -1,6 +1,6 @@
import { useApi } from '../useApi'; import { useApi } from '../useApi';
import { useModified } from '../useModified'; import { useModified } from '../useModified';
import { usePagedQuery } from '@/components/hooks'; import { usePagedQuery } from '../usePagedQuery';
import { ReactQueryOptions } from '@/lib/types'; import { ReactQueryOptions } from '@/lib/types';
export function useTeamsQuery(params?: Record<string, any>, options?: ReactQueryOptions) { export function useTeamsQuery(params?: Record<string, any>, options?: ReactQueryOptions) {
@ -8,7 +8,7 @@ export function useTeamsQuery(params?: Record<string, any>, options?: ReactQuery
const { modified } = useModified(`teams`); const { modified } = useModified(`teams`);
return usePagedQuery({ return usePagedQuery({
queryKey: ['websites', { modified, ...params }], queryKey: ['teams:admin', { modified, ...params }],
queryFn: pageParams => { queryFn: pageParams => {
return get(`/admin/teams`, { return get(`/admin/teams`, {
...params, ...params,

View file

@ -7,7 +7,7 @@ export function useUsersQuery() {
const { modified } = useModified(`users`); const { modified } = useModified(`users`);
return usePagedQuery({ return usePagedQuery({
queryKey: ['users', { modified }], queryKey: ['users:admin', { modified }],
queryFn: (pageParams: any) => { queryFn: (pageParams: any) => {
return get('/admin/users', { return get('/admin/users', {
...pageParams, ...pageParams,

View file

@ -8,7 +8,7 @@ export function useWebsitesQuery(params?: Record<string, any>, options?: ReactQu
const { modified } = useModified(`websites`); const { modified } = useModified(`websites`);
return usePagedQuery({ return usePagedQuery({
queryKey: ['websites', { modified, ...params }], queryKey: ['websites:admin', { modified, ...params }],
queryFn: pageParams => { queryFn: pageParams => {
return get(`/admin/websites`, { return get(`/admin/websites`, {
...pageParams, ...pageParams,

View file

@ -24,7 +24,6 @@ export * from '@/app/(main)/settings/teams/TeamsDataTable';
export * from '@/app/(main)/settings/teams/TeamsHeader'; export * from '@/app/(main)/settings/teams/TeamsHeader';
export * from '@/app/(main)/settings/teams/TeamsJoinButton'; export * from '@/app/(main)/settings/teams/TeamsJoinButton';
export * from '@/app/(main)/settings/teams/TeamsTable'; export * from '@/app/(main)/settings/teams/TeamsTable';
export * from '@/app/(main)/settings/teams/WebsiteTags';
export * from '@/app/(main)/settings/websites/[websiteId]/WebsiteShareForm'; export * from '@/app/(main)/settings/websites/[websiteId]/WebsiteShareForm';
export * from '@/app/(main)/settings/websites/[websiteId]/WebsiteTrackingCode'; export * from '@/app/(main)/settings/websites/[websiteId]/WebsiteTrackingCode';