feat: add workspace role permission check, hide non permission action

This commit is contained in:
moonrailgun 2024-09-22 01:05:16 +08:00
parent d29785a311
commit 4f4f9b5d3f
19 changed files with 466 additions and 334 deletions

View File

@ -3,7 +3,7 @@ import { Button } from '../ui/button';
import { LuArchive, LuArchiveRestore } from 'react-icons/lu'; import { LuArchive, LuArchiveRestore } from 'react-icons/lu';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { AppRouterOutput, trpc } from '@/api/trpc'; import { AppRouterOutput, trpc } from '@/api/trpc';
import { useCurrentWorkspaceId } from '@/store/user'; import { useCurrentWorkspaceId, useHasAdminPermission } from '@/store/user';
import { DynamicVirtualList } from '../DynamicVirtualList'; import { DynamicVirtualList } from '../DynamicVirtualList';
import { get } from 'lodash-es'; import { get } from 'lodash-es';
import { FeedEventItem } from './FeedEventItem'; import { FeedEventItem } from './FeedEventItem';
@ -28,6 +28,7 @@ export const FeedArchivePageButton: React.FC<FeedArchivePageButtonProps> =
const clearAllArchivedEventsMutation = const clearAllArchivedEventsMutation =
trpc.feed.clearAllArchivedEvents.useMutation(); trpc.feed.clearAllArchivedEvents.useMutation();
const trpcUtils = trpc.useUtils(); const trpcUtils = trpc.useUtils();
const hasAdminPermission = useHasAdminPermission();
const { const {
data, data,
@ -87,9 +88,11 @@ export const FeedArchivePageButton: React.FC<FeedArchivePageButtonProps> =
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-lg font-bold">{t('Archived Events')}</h1> <h1 className="text-lg font-bold">{t('Archived Events')}</h1>
<AlertConfirm onConfirm={handleClear}> {hasAdminPermission && (
<Button size="sm">{t('Clear')}</Button> <AlertConfirm onConfirm={handleClear}>
</AlertConfirm> <Button size="sm">{t('Clear')}</Button>
</AlertConfirm>
)}
</div> </div>
<Separator className="my-2" /> <Separator className="my-2" />

View File

@ -6,7 +6,7 @@ import {
defaultSuccessHandler, defaultSuccessHandler,
trpc, trpc,
} from '../../api/trpc'; } from '../../api/trpc';
import { useCurrentWorkspaceId } from '../../store/user'; import { useCurrentWorkspaceId, useHasAdminPermission } from '../../store/user';
import { Loading } from '../Loading'; import { Loading } from '../Loading';
import { getMonitorLink } from './provider'; import { getMonitorLink } from './provider';
import { NotFoundTip } from '../NotFoundTip'; import { NotFoundTip } from '../NotFoundTip';
@ -35,6 +35,7 @@ export const MonitorInfo: React.FC<MonitorInfoProps> = React.memo((props) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [showBadge, setShowBadge] = useState(false); const [showBadge, setShowBadge] = useState(false);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const hasAdminPermission = useHasAdminPermission();
const { const {
data: monitorInfo, data: monitorInfo,
@ -186,76 +187,78 @@ export const MonitorInfo: React.FC<MonitorInfoProps> = React.memo((props) => {
</span> </span>
</div> </div>
<div className="flex gap-2"> {hasAdminPermission && (
<Button <div className="flex gap-2">
type="primary"
onClick={() => {
navigate({
to: '/monitor/$monitorId/edit',
params: {
monitorId: monitorInfo.id,
},
});
}}
>
{t('Edit')}
</Button>
{monitorInfo.active ? (
<Button <Button
loading={changeActiveMutation.isLoading} type="primary"
onClick={handleStop} onClick={() => {
navigate({
to: '/monitor/$monitorId/edit',
params: {
monitorId: monitorInfo.id,
},
});
}}
> >
{t('Stop')} {t('Edit')}
</Button> </Button>
) : (
<Button {monitorInfo.active ? (
loading={changeActiveMutation.isLoading} <Button
onClick={handleStart} loading={changeActiveMutation.isLoading}
onClick={handleStop}
>
{t('Stop')}
</Button>
) : (
<Button
loading={changeActiveMutation.isLoading}
onClick={handleStart}
>
{t('Start')}
</Button>
)}
<Dropdown
trigger={['click']}
placement="bottomRight"
menu={{
items: [
{
key: 'badge',
label: t('Show Badge'),
onClick: () => setShowBadge(true),
},
{
type: 'divider',
},
{
key: 'delete',
label: t('Delete'),
danger: true,
onClick: handleDelete,
},
],
}}
> >
{t('Start')} <Button icon={<MoreOutlined />} />
</Button> </Dropdown>
)}
<Dropdown <Modal
trigger={['click']} open={showBadge}
placement="bottomRight" onCancel={() => setShowBadge(false)}
menu={{ onOk={() => setShowBadge(false)}
items: [ destroyOnClose={true}
{ centered={true}
key: 'badge', >
label: t('Show Badge'), <MonitorBadgeView
onClick: () => setShowBadge(true), workspaceId={workspaceId}
}, monitorId={monitorId}
{ monitorName={monitorInfo.name}
type: 'divider', />
}, </Modal>
{ </div>
key: 'delete', )}
label: t('Delete'),
danger: true,
onClick: handleDelete,
},
],
}}
>
<Button icon={<MoreOutlined />} />
</Dropdown>
<Modal
open={showBadge}
onCancel={() => setShowBadge(false)}
onOk={() => setShowBadge(false)}
destroyOnClose={true}
centered={true}
>
<MonitorBadgeView
workspaceId={workspaceId}
monitorId={monitorId}
monitorName={monitorInfo.name}
/>
</Modal>
</div>
</Space> </Space>
<div className="text-black text-opacity-75 dark:text-gray-200"> <div className="text-black text-opacity-75 dark:text-gray-200">
@ -298,29 +301,31 @@ export const MonitorInfo: React.FC<MonitorInfoProps> = React.memo((props) => {
<MonitorDataChart monitorId={monitorId} /> <MonitorDataChart monitorId={monitorId} />
</Card> </Card>
<div className="text-right"> {hasAdminPermission && (
<Dropdown <div className="text-right">
trigger={['click']} <Dropdown
menu={{ trigger={['click']}
items: [ menu={{
{ items: [
key: 'events', {
label: t('Events'), key: 'events',
onClick: handleClearEvents, label: t('Events'),
}, onClick: handleClearEvents,
{ },
key: 'heartbeats', {
label: t('Heartbeats'), key: 'heartbeats',
onClick: handleClearData, label: t('Heartbeats'),
}, onClick: handleClearData,
], },
}} ],
> }}
<Button icon={<DeleteOutlined />} danger={true}> >
{t('Clear Data')} <Button icon={<DeleteOutlined />} danger={true}>
</Button> {t('Clear Data')}
</Dropdown> </Button>
</div> </Dropdown>
</div>
)}
<MonitorEventList monitorId={monitorId} /> <MonitorEventList monitorId={monitorId} />
</Space> </Space>

View File

@ -15,5 +15,5 @@ export function useAllowEdit(workspaceId?: string): boolean {
} }
); );
return role === ROLES.owner; return role === ROLES.owner || role === ROLES.admin;
} }

View File

@ -20,13 +20,14 @@ import {
SheetTrigger, SheetTrigger,
} from '../ui/sheet'; } from '../ui/sheet';
import { defaultErrorHandler, defaultSuccessHandler, trpc } from '@/api/trpc'; import { defaultErrorHandler, defaultSuccessHandler, trpc } from '@/api/trpc';
import { useCurrentWorkspaceId } from '@/store/user'; import { useCurrentWorkspaceId, useHasPermission } from '@/store/user';
import { formatDate } from '@/utils/date'; import { formatDate } from '@/utils/date';
import { Input } from '../ui/input'; import { Input } from '../ui/input';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useEvent } from '@/hooks/useEvent'; import { useEvent } from '@/hooks/useEvent';
import { Badge } from '../ui/badge'; import { Badge } from '../ui/badge';
import { LuArrowRight, LuPlus } from 'react-icons/lu'; import { LuArrowRight, LuPlus } from 'react-icons/lu';
import { ROLES } from '@tianji/shared';
interface WebsiteLighthouseBtnProps { interface WebsiteLighthouseBtnProps {
websiteId: string; websiteId: string;
@ -37,6 +38,7 @@ export const WebsiteLighthouseBtn: React.FC<WebsiteLighthouseBtnProps> =
const { t } = useTranslation(); const { t } = useTranslation();
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const hasAdminPermission = useHasPermission(ROLES.admin);
const { data, hasNextPage, fetchNextPage, isFetchingNextPage, refetch } = const { data, hasNextPage, fetchNextPage, isFetchingNextPage, refetch } =
trpc.website.getLighthouseReport.useInfiniteQuery( trpc.website.getLighthouseReport.useInfiniteQuery(
@ -92,47 +94,51 @@ export const WebsiteLighthouseBtn: React.FC<WebsiteLighthouseBtnProps> =
</SheetHeader> </SheetHeader>
<div className="mt-2 flex flex-col gap-2"> <div className="mt-2 flex flex-col gap-2">
<div> {hasAdminPermission && (
<Dialog <div>
open={isCreateDialogOpen} <Dialog
onOpenChange={setIsCreateDialogOpen} open={isCreateDialogOpen}
> onOpenChange={setIsCreateDialogOpen}
<DialogTrigger> >
<Button <DialogTrigger>
variant="outline"
loading={createMutation.isLoading}
Icon={LuPlus}
>
{t('Create Report')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Generate Lighthouse Report')}</DialogTitle>
<DialogDescription>
{t('Its will take a while to generate the report.')}
</DialogDescription>
</DialogHeader>
<div>
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://google.com"
/>
</div>
<DialogFooter>
<Button <Button
variant="outline"
loading={createMutation.isLoading} loading={createMutation.isLoading}
onClick={handleGenerateReport} Icon={LuPlus}
> >
{t('Create')} {t('Create Report')}
</Button> </Button>
</DialogFooter> </DialogTrigger>
</DialogContent> <DialogContent>
</Dialog> <DialogHeader>
</div> <DialogTitle>
{t('Generate Lighthouse Report')}
</DialogTitle>
<DialogDescription>
{t('Its will take a while to generate the report.')}
</DialogDescription>
</DialogHeader>
<div>
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://google.com"
/>
</div>
<DialogFooter>
<Button
loading={createMutation.isLoading}
onClick={handleGenerateReport}
>
{t('Create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{allData.map((report) => { {allData.map((report) => {

View File

@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button';
import { useDataReady } from '@/hooks/useDataReady'; import { useDataReady } from '@/hooks/useDataReady';
import { useEvent } from '@/hooks/useEvent'; import { useEvent } from '@/hooks/useEvent';
import { Layout } from '@/components/layout'; import { Layout } from '@/components/layout';
import { useCurrentWorkspaceId } from '@/store/user'; import { useCurrentWorkspaceId, useHasAdminPermission } from '@/store/user';
import { routeAuthBeforeLoad } from '@/utils/route'; import { routeAuthBeforeLoad } from '@/utils/route';
import { cn } from '@/utils/style'; import { cn } from '@/utils/style';
import { useTranslation } from '@i18next-toolkit/react'; import { useTranslation } from '@i18next-toolkit/react';
@ -32,6 +32,7 @@ function PageComponent() {
const pathname = useRouterState({ const pathname = useRouterState({
select: (state) => state.location.pathname, select: (state) => state.location.pathname,
}); });
const hasAdminPermission = useHasAdminPermission();
const items = channels.map((item) => ({ const items = channels.map((item) => ({
id: item.id, id: item.id,
@ -68,14 +69,18 @@ function PageComponent() {
<CommonHeader <CommonHeader
title={t('Feed')} title={t('Feed')}
actions={ actions={
<Button <>
className={cn(pathname === '/feed/add' && '!bg-muted')} {hasAdminPermission && (
variant="outline" <Button
Icon={LuPlus} className={cn(pathname === '/feed/add' && '!bg-muted')}
onClick={handleClickAdd} variant="outline"
> Icon={LuPlus}
{t('Add')} onClick={handleClickAdd}
</Button> >
{t('Add')}
</Button>
)}
</>
} }
/> />
} }

View File

@ -6,7 +6,7 @@ import {
} from '@/api/trpc'; } from '@/api/trpc';
import { CommonHeader } from '@/components/CommonHeader'; import { CommonHeader } from '@/components/CommonHeader';
import { CommonWrapper } from '@/components/CommonWrapper'; import { CommonWrapper } from '@/components/CommonWrapper';
import { useCurrentWorkspaceId } from '@/store/user'; import { useCurrentWorkspaceId, useHasAdminPermission } from '@/store/user';
import { routeAuthBeforeLoad } from '@/utils/route'; import { routeAuthBeforeLoad } from '@/utils/route';
import { useTranslation } from '@i18next-toolkit/react'; import { useTranslation } from '@i18next-toolkit/react';
import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { createFileRoute, useNavigate } from '@tanstack/react-router';
@ -46,6 +46,7 @@ function PageComponent() {
workspaceId, workspaceId,
channelId, channelId,
}); });
const hasAdminPermission = useHasAdminPermission();
const { const {
data, data,
@ -53,7 +54,6 @@ function PageComponent() {
hasNextPage, hasNextPage,
fetchNextPage, fetchNextPage,
isFetchingNextPage, isFetchingNextPage,
refetch,
} = trpc.feed.fetchEventsByCursor.useInfiniteQuery( } = trpc.feed.fetchEventsByCursor.useInfiniteQuery(
{ {
workspaceId, workspaceId,
@ -122,28 +122,32 @@ function PageComponent() {
<FeedArchivePageButton channelId={channelId} /> <FeedArchivePageButton channelId={channelId} />
<Button {hasAdminPermission && (
variant="outline" <Button
size="icon" variant="outline"
Icon={LuPencil} size="icon"
onClick={() => Icon={LuPencil}
navigate({ onClick={() =>
to: '/feed/$channelId/edit', navigate({
params: { to: '/feed/$channelId/edit',
channelId, params: {
}, channelId,
}) },
} })
/> }
/>
)}
<AlertConfirm {hasAdminPermission && (
title={t('Confirm to delete this channel?')} <AlertConfirm
description={t('All feed will be remove')} title={t('Confirm to delete this channel?')}
content={t('It will permanently delete the relevant data')} description={t('All feed will be remove')}
onConfirm={handleDelete} content={t('It will permanently delete the relevant data')}
> onConfirm={handleDelete}
<Button variant="outline" size="icon" Icon={LuTrash} /> >
</AlertConfirm> <Button variant="outline" size="icon" Icon={LuTrash} />
</AlertConfirm>
)}
</div> </div>
} }
/> />
@ -174,14 +178,16 @@ function PageComponent() {
</Button> </Button>
)} )}
<Button {hasAdminPermission && (
size="icon" <Button
variant="secondary" size="icon"
className="h-6 w-6 overflow-hidden" variant="secondary"
onClick={() => handleArchive(item)} className="h-6 w-6 overflow-hidden"
> onClick={() => handleArchive(item)}
<LuArchive size={12} /> >
</Button> <LuArchive size={12} />
</Button>
)}
</> </>
} }
/> />

View File

@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button';
import { useDataReady } from '@/hooks/useDataReady'; import { useDataReady } from '@/hooks/useDataReady';
import { useEvent } from '@/hooks/useEvent'; import { useEvent } from '@/hooks/useEvent';
import { Layout } from '@/components/layout'; import { Layout } from '@/components/layout';
import { useCurrentWorkspaceId } from '@/store/user'; import { useCurrentWorkspaceId, useHasAdminPermission } from '@/store/user';
import { routeAuthBeforeLoad } from '@/utils/route'; import { routeAuthBeforeLoad } from '@/utils/route';
import { cn } from '@/utils/style'; import { cn } from '@/utils/style';
import { useTranslation } from '@i18next-toolkit/react'; import { useTranslation } from '@i18next-toolkit/react';
@ -34,6 +34,7 @@ function MonitorComponent() {
const pathname = useRouterState({ const pathname = useRouterState({
select: (state) => state.location.pathname, select: (state) => state.location.pathname,
}); });
const hasAdminPermission = useHasAdminPermission();
const items = data.map((item) => ({ const items = data.map((item) => ({
id: item.id, id: item.id,
@ -78,14 +79,18 @@ function MonitorComponent() {
<CommonHeader <CommonHeader
title={t('Monitor')} title={t('Monitor')}
actions={ actions={
<Button <>
className={cn(pathname === '/monitor/add' && '!bg-muted')} {hasAdminPermission && (
variant="outline" <Button
Icon={LuPlus} className={cn(pathname === '/monitor/add' && '!bg-muted')}
onClick={handleClickAdd} variant="outline"
> Icon={LuPlus}
{t('Add')} onClick={handleClickAdd}
</Button> >
{t('Add')}
</Button>
)}
</>
} }
/> />
} }

View File

@ -13,7 +13,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { useCurrentWorkspaceId } from '@/store/user'; import { useCurrentWorkspaceId, useHasAdminPermission } from '@/store/user';
import { routeAuthBeforeLoad } from '@/utils/route'; import { routeAuthBeforeLoad } from '@/utils/route';
import { useTranslation } from '@i18next-toolkit/react'; import { useTranslation } from '@i18next-toolkit/react';
import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { createFileRoute, useNavigate } from '@tanstack/react-router';
@ -34,6 +34,7 @@ function MonitorDetailComponent() {
}); });
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const hasAdminPermission = useHasAdminPermission();
if (!monitorId) { if (!monitorId) {
return <ErrorTip />; return <ErrorTip />;
@ -53,35 +54,42 @@ function MonitorDetailComponent() {
<CommonHeader <CommonHeader
title={monitor.name} title={monitor.name}
actions={ actions={
<DropdownMenu> <>
<DropdownMenuTrigger asChild={true} className="cursor-pointer"> {hasAdminPermission && (
<Button variant="outline" size="icon" className="shrink-0"> <DropdownMenu>
<LuMoreVertical /> <DropdownMenuTrigger
</Button> asChild={true}
</DropdownMenuTrigger> className="cursor-pointer"
>
<Button variant="outline" size="icon" className="shrink-0">
<LuMoreVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
navigate({ navigate({
to: '/monitor/add', to: '/monitor/add',
search: pick(monitor, [ search: pick(monitor, [
'name', 'name',
'type', 'type',
'notifications', 'notifications',
'interval', 'interval',
'maxRetries', 'maxRetries',
'trendingMode', 'trendingMode',
'payload', 'payload',
]), ]),
}) })
} }
> >
<LuCopy className="mr-2" /> <LuCopy className="mr-2" />
{t('Duplicate')} {t('Duplicate')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)}
</>
} }
/> />
} }

View File

@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button';
import { useDataReady } from '@/hooks/useDataReady'; import { useDataReady } from '@/hooks/useDataReady';
import { useEvent } from '@/hooks/useEvent'; import { useEvent } from '@/hooks/useEvent';
import { Layout } from '@/components/layout'; import { Layout } from '@/components/layout';
import { useCurrentWorkspaceId } from '@/store/user'; import { useCurrentWorkspaceId, useHasAdminPermission } from '@/store/user';
import { routeAuthBeforeLoad } from '@/utils/route'; import { routeAuthBeforeLoad } from '@/utils/route';
import { cn } from '@/utils/style'; import { cn } from '@/utils/style';
import { useTranslation } from '@i18next-toolkit/react'; import { useTranslation } from '@i18next-toolkit/react';
@ -32,6 +32,7 @@ function PageComponent() {
const pathname = useRouterState({ const pathname = useRouterState({
select: (state) => state.location.pathname, select: (state) => state.location.pathname,
}); });
const hasAdminPermission = useHasAdminPermission();
const items = data.map((item) => ({ const items = data.map((item) => ({
id: item.id, id: item.id,
@ -68,14 +69,18 @@ function PageComponent() {
<CommonHeader <CommonHeader
title={t('Pages')} title={t('Pages')}
actions={ actions={
<Button <>
className={cn(pathname === '/page/add' && '!bg-muted')} {hasAdminPermission && (
variant="outline" <Button
Icon={LuPlus} className={cn(pathname === '/page/add' && '!bg-muted')}
onClick={handleClickAdd} variant="outline"
> Icon={LuPlus}
{t('Add')} onClick={handleClickAdd}
</Button> >
{t('Add')}
</Button>
)}
</>
} }
/> />
} }

View File

@ -8,6 +8,7 @@ import { NotFoundTip } from '@/components/NotFoundTip';
import { MonitorStatusPage } from '@/components/monitor/StatusPage'; import { MonitorStatusPage } from '@/components/monitor/StatusPage';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useEvent } from '@/hooks/useEvent'; import { useEvent } from '@/hooks/useEvent';
import { useHasAdminPermission } from '@/store/user';
import { routeAuthBeforeLoad } from '@/utils/route'; import { routeAuthBeforeLoad } from '@/utils/route';
import { useTranslation } from '@i18next-toolkit/react'; import { useTranslation } from '@i18next-toolkit/react';
import { Link, createFileRoute, useNavigate } from '@tanstack/react-router'; import { Link, createFileRoute, useNavigate } from '@tanstack/react-router';
@ -26,6 +27,7 @@ function PageComponent() {
slug, slug,
}); });
const trpcUtils = trpc.useUtils(); const trpcUtils = trpc.useUtils();
const hasAdminPermission = useHasAdminPermission();
const deletePageMutation = trpc.monitor.deletePage.useMutation(); const deletePageMutation = trpc.monitor.deletePage.useMutation();
@ -68,13 +70,15 @@ function PageComponent() {
title={pageInfo.title} title={pageInfo.title}
actions={ actions={
<div className="space-x-2"> <div className="space-x-2">
<AlertConfirm {hasAdminPermission && (
title={t('Confirm to delete this page?')} <AlertConfirm
content={t('It will permanently delete the relevant data')} title={t('Confirm to delete this page?')}
onConfirm={handleDelete} content={t('It will permanently delete the relevant data')}
> onConfirm={handleDelete}
<Button variant="outline" size="icon" Icon={LuTrash} /> >
</AlertConfirm> <Button variant="outline" size="icon" Icon={LuTrash} />
</AlertConfirm>
)}
<Link to="/status/$slug" params={{ slug }} target="_blank"> <Link to="/status/$slug" params={{ slug }} target="_blank">
<Button variant="outline" Icon={LuEye}> <Button variant="outline" Icon={LuEye}>

View File

@ -11,10 +11,11 @@ import {
NotificationInfoModal, NotificationInfoModal,
} from '../../components/modals/NotificationInfo'; } from '../../components/modals/NotificationInfo';
import { useEvent } from '../../hooks/useEvent'; import { useEvent } from '../../hooks/useEvent';
import { useCurrentWorkspaceId } from '../../store/user'; import { useCurrentWorkspaceId, useHasAdminPermission } from '../../store/user';
import { CommonHeader } from '@/components/CommonHeader'; import { CommonHeader } from '@/components/CommonHeader';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { LuFileEdit, LuPlus, LuTrash2 } from 'react-icons/lu'; import { LuFileEdit, LuPlus, LuTrash2 } from 'react-icons/lu';
import { compact } from 'lodash-es';
export const Route = createFileRoute('/settings/notifications')({ export const Route = createFileRoute('/settings/notifications')({
beforeLoad: routeAuthBeforeLoad, beforeLoad: routeAuthBeforeLoad,
@ -31,6 +32,7 @@ function PageComponent() {
const [editingFormData, setEditingFormData] = useState< const [editingFormData, setEditingFormData] = useState<
NotificationFormValues | undefined NotificationFormValues | undefined
>(undefined); >(undefined);
const hasAdminPermission = useHasAdminPermission();
const upsertMutation = trpc.notification.upsert.useMutation(); const upsertMutation = trpc.notification.upsert.useMutation();
const deleteMutation = trpc.notification.delete.useMutation(); const deleteMutation = trpc.notification.delete.useMutation();
@ -69,13 +71,15 @@ function PageComponent() {
title={t('Notifications')} title={t('Notifications')}
actions={ actions={
<> <>
<Button {hasAdminPermission && (
variant="outline" <Button
Icon={LuPlus} variant="outline"
onClick={() => handleOpenModal()} Icon={LuPlus}
> onClick={() => handleOpenModal()}
{t('New')} >
</Button> {t('New')}
</Button>
)}
</> </>
} }
/> />
@ -88,35 +92,39 @@ function PageComponent() {
dataSource={list} dataSource={list}
renderItem={(item) => ( renderItem={(item) => (
<List.Item <List.Item
actions={[ actions={compact([
<Button hasAdminPermission && (
variant="default" <Button
Icon={LuFileEdit} variant="default"
onClick={() => { Icon={LuFileEdit}
handleOpenModal({ onClick={() => {
id: item.id, handleOpenModal({
name: item.name, id: item.id,
type: item.type, name: item.name,
payload: item.payload as Record<string, any>, type: item.type,
}); payload: item.payload as Record<string, any>,
}} });
> }}
{t('Edit')} >
</Button>, {t('Edit')}
<Popconfirm
title={t('Is delete this item?')}
okButtonProps={{
danger: true,
}}
onConfirm={() => {
handleDelete(item.id);
}}
>
<Button variant="destructive" size="icon">
<LuTrash2 />
</Button> </Button>
</Popconfirm>, ),
]} hasAdminPermission && (
<Popconfirm
title={t('Is delete this item?')}
okButtonProps={{
danger: true,
}}
onConfirm={() => {
handleDelete(item.id);
}}
>
<Button variant="destructive" size="icon">
<LuTrash2 />
</Button>
</Popconfirm>
),
])}
> >
<List.Item.Meta title={item.name} /> <List.Item.Meta title={item.name} />
</List.Item> </List.Item>

View File

@ -3,7 +3,7 @@ import { createFileRoute } from '@tanstack/react-router';
import { useTranslation } from '@i18next-toolkit/react'; import { useTranslation } from '@i18next-toolkit/react';
import { CommonWrapper } from '@/components/CommonWrapper'; import { CommonWrapper } from '@/components/CommonWrapper';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { useCurrentWorkspace } from '../../store/user'; import { useCurrentWorkspace, useHasAdminPermission } from '../../store/user';
import { CommonHeader } from '@/components/CommonHeader'; import { CommonHeader } from '@/components/CommonHeader';
import { import {
Card, Card,
@ -39,6 +39,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { AlertConfirm } from '@/components/AlertConfirm'; import { AlertConfirm } from '@/components/AlertConfirm';
import { ROLES } from '@tianji/shared'; import { ROLES } from '@tianji/shared';
import { cn } from '@/utils/style';
export const Route = createFileRoute('/settings/workspace')({ export const Route = createFileRoute('/settings/workspace')({
beforeLoad: routeAuthBeforeLoad, beforeLoad: routeAuthBeforeLoad,
@ -57,6 +58,7 @@ const columnHelper = createColumnHelper<MemberInfo>();
function PageComponent() { function PageComponent() {
const { t } = useTranslation(); const { t } = useTranslation();
const { id: workspaceId, name, role } = useCurrentWorkspace(); const { id: workspaceId, name, role } = useCurrentWorkspace();
const hasAdminPermission = useHasAdminPermission();
const { data: members = [], refetch: refetchMembers } = const { data: members = [], refetch: refetchMembers } =
trpc.workspace.members.useQuery({ trpc.workspace.members.useQuery({
workspaceId, workspaceId,
@ -128,6 +130,10 @@ function PageComponent() {
{t('Current Workspace:')} {name} {t('Current Workspace:')} {name}
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div>
<span className="mr-2">{t('Current Role')}:</span>
<span className="font-semibold">{role}</span>
</div>
<div> <div>
<span className="mr-2">{t('Workspace ID')}:</span> <span className="mr-2">{t('Workspace ID')}:</span>
<span> <span>
@ -140,7 +146,10 @@ function PageComponent() {
</Card> </Card>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(handleInvite)}> <form
onSubmit={form.handleSubmit(handleInvite)}
className={cn(!hasAdminPermission && 'opacity-50')}
>
<Card> <Card>
<CardHeader className="text-lg font-bold"> <CardHeader className="text-lg font-bold">
{t('Invite new members by email address')} {t('Invite new members by email address')}
@ -166,7 +175,11 @@ function PageComponent() {
</CardContent> </CardContent>
<CardFooter> <CardFooter>
<Button type="submit" loading={isLoading}> <Button
type="submit"
loading={isLoading}
disabled={!hasAdminPermission}
>
{t('Invite')} {t('Invite')}
</Button> </Button>
</CardFooter> </CardFooter>

View File

@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button';
import { useDataReady } from '@/hooks/useDataReady'; import { useDataReady } from '@/hooks/useDataReady';
import { useEvent } from '@/hooks/useEvent'; import { useEvent } from '@/hooks/useEvent';
import { Layout } from '@/components/layout'; import { Layout } from '@/components/layout';
import { useCurrentWorkspaceId } from '@/store/user'; import { useCurrentWorkspaceId, useHasAdminPermission } from '@/store/user';
import { routeAuthBeforeLoad } from '@/utils/route'; import { routeAuthBeforeLoad } from '@/utils/route';
import { cn } from '@/utils/style'; import { cn } from '@/utils/style';
import { useTranslation } from '@i18next-toolkit/react'; import { useTranslation } from '@i18next-toolkit/react';
@ -35,6 +35,7 @@ function PageComponent() {
const pathname = useRouterState({ const pathname = useRouterState({
select: (state) => state.location.pathname, select: (state) => state.location.pathname,
}); });
const hasAdminPermission = useHasAdminPermission();
const items = data.map((item) => ({ const items = data.map((item) => ({
id: item.id, id: item.id,
@ -71,14 +72,18 @@ function PageComponent() {
<CommonHeader <CommonHeader
title={t('Survey')} title={t('Survey')}
actions={ actions={
<Button <>
className={cn(pathname === '/survey/add' && '!bg-muted')} {hasAdminPermission && (
variant="outline" <Button
Icon={LuPlus} className={cn(pathname === '/survey/add' && '!bg-muted')}
onClick={handleClickAdd} variant="outline"
> Icon={LuPlus}
{t('Add')} onClick={handleClickAdd}
</Button> >
{t('Add')}
</Button>
)}
</>
} }
/> />
} }

View File

@ -6,8 +6,7 @@ import {
} from '@/api/trpc'; } from '@/api/trpc';
import { CommonHeader } from '@/components/CommonHeader'; import { CommonHeader } from '@/components/CommonHeader';
import { CommonWrapper } from '@/components/CommonWrapper'; import { CommonWrapper } from '@/components/CommonWrapper';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; import { useCurrentWorkspaceId, useHasAdminPermission } from '@/store/user';
import { useCurrentWorkspaceId } from '@/store/user';
import { routeAuthBeforeLoad } from '@/utils/route'; import { routeAuthBeforeLoad } from '@/utils/route';
import { useTranslation } from '@i18next-toolkit/react'; import { useTranslation } from '@i18next-toolkit/react';
import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { createFileRoute, useNavigate } from '@tanstack/react-router';
@ -16,12 +15,11 @@ import { AlertConfirm } from '@/components/AlertConfirm';
import { LuPencil, LuTrash } from 'react-icons/lu'; import { LuPencil, LuTrash } from 'react-icons/lu';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { DataTable, createColumnHelper } from '@/components/DataTable'; import { createColumnHelper } from '@/components/DataTable';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { SurveyDownloadBtn } from '@/components/survey/SurveyDownloadBtn'; import { SurveyDownloadBtn } from '@/components/survey/SurveyDownloadBtn';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { SurveyUsageBtn } from '@/components/survey/SurveyUsageBtn'; import { SurveyUsageBtn } from '@/components/survey/SurveyUsageBtn';
import { Scrollbar } from '@radix-ui/react-scroll-area';
import { VirtualizedInfiniteDataTable } from '@/components/VirtualizedInfiniteDataTable'; import { VirtualizedInfiniteDataTable } from '@/components/VirtualizedInfiniteDataTable';
import { Loading } from '@/components/Loading'; import { Loading } from '@/components/Loading';
@ -39,6 +37,7 @@ function PageComponent() {
const { surveyId } = Route.useParams<{ surveyId: string }>(); const { surveyId } = Route.useParams<{ surveyId: string }>();
const workspaceId = useCurrentWorkspaceId(); const workspaceId = useCurrentWorkspaceId();
const { t } = useTranslation(); const { t } = useTranslation();
const hasAdminPermission = useHasAdminPermission();
const { data: info } = trpc.survey.get.useQuery({ const { data: info } = trpc.survey.get.useQuery({
workspaceId, workspaceId,
surveyId, surveyId,
@ -105,31 +104,38 @@ function PageComponent() {
title={info?.name ?? ''} title={info?.name ?? ''}
actions={ actions={
<div className="space-x-2"> <div className="space-x-2">
<Button {hasAdminPermission && (
variant="outline" <Button
size="icon" variant="outline"
Icon={LuPencil} size="icon"
onClick={() => Icon={LuPencil}
navigate({ onClick={() =>
to: '/survey/$surveyId/edit', navigate({
params: { to: '/survey/$surveyId/edit',
surveyId, params: {
}, surveyId,
}) },
} })
/> }
/>
)}
<AlertConfirm {hasAdminPermission && (
title={t('Confirm to delete this survey?')} <AlertConfirm
description={t('Survey name: {{name}} | data count: {{num}}', { title={t('Confirm to delete this survey?')}
name: info?.name ?? '', description={t(
num: count ?? 0, 'Survey name: {{name}} | data count: {{num}}',
})} {
content={t('It will permanently delete the relevant data')} name: info?.name ?? '',
onConfirm={handleDelete} num: count ?? 0,
> }
<Button variant="outline" size="icon" Icon={LuTrash} /> )}
</AlertConfirm> content={t('It will permanently delete the relevant data')}
onConfirm={handleDelete}
>
<Button variant="outline" size="icon" Icon={LuTrash} />
</AlertConfirm>
)}
</div> </div>
} }
/> />

View File

@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button';
import { useDataReady } from '@/hooks/useDataReady'; import { useDataReady } from '@/hooks/useDataReady';
import { useEvent } from '@/hooks/useEvent'; import { useEvent } from '@/hooks/useEvent';
import { Layout } from '@/components/layout'; import { Layout } from '@/components/layout';
import { useCurrentWorkspaceId } from '@/store/user'; import { useCurrentWorkspaceId, useHasAdminPermission } from '@/store/user';
import { routeAuthBeforeLoad } from '@/utils/route'; import { routeAuthBeforeLoad } from '@/utils/route';
import { cn } from '@/utils/style'; import { cn } from '@/utils/style';
import { Trans, useTranslation } from '@i18next-toolkit/react'; import { Trans, useTranslation } from '@i18next-toolkit/react';
@ -35,6 +35,7 @@ function TelemetryComponent() {
const pathname = useRouterState({ const pathname = useRouterState({
select: (state) => state.location.pathname, select: (state) => state.location.pathname,
}); });
const hasAdminPermission = useHasAdminPermission();
const items = data.map((item) => ({ const items = data.map((item) => ({
id: item.id, id: item.id,
@ -101,14 +102,20 @@ function TelemetryComponent() {
</div> </div>
} }
actions={ actions={
<Button <>
className={cn(pathname === '/telemetry/add' && '!bg-muted')} {hasAdminPermission && (
variant="outline" <Button
Icon={LuPlus} className={cn(
onClick={handleClickAdd} pathname === '/telemetry/add' && '!bg-muted'
> )}
{t('Add')} variant="outline"
</Button> Icon={LuPlus}
onClick={handleClickAdd}
>
{t('Add')}
</Button>
)}
</>
} }
/> />
} }

View File

@ -5,7 +5,7 @@ import { CommonWrapper } from '@/components/CommonWrapper';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useEvent } from '@/hooks/useEvent'; import { useEvent } from '@/hooks/useEvent';
import { Layout } from '@/components/layout'; import { Layout } from '@/components/layout';
import { useCurrentWorkspaceId } from '@/store/user'; import { useCurrentWorkspaceId, useHasAdminPermission } from '@/store/user';
import { routeAuthBeforeLoad } from '@/utils/route'; import { routeAuthBeforeLoad } from '@/utils/route';
import { cn } from '@/utils/style'; import { cn } from '@/utils/style';
import { useTranslation } from '@i18next-toolkit/react'; import { useTranslation } from '@i18next-toolkit/react';
@ -24,6 +24,7 @@ export const Route = createFileRoute('/website')({
function WebsiteComponent() { function WebsiteComponent() {
const workspaceId = useCurrentWorkspaceId(); const workspaceId = useCurrentWorkspaceId();
const hasAdminPermission = useHasAdminPermission();
const { t } = useTranslation(); const { t } = useTranslation();
const { data = [], isLoading } = trpc.website.all.useQuery({ const { data = [], isLoading } = trpc.website.all.useQuery({
workspaceId, workspaceId,
@ -83,13 +84,16 @@ function WebsiteComponent() {
> >
{t('Overview')} {t('Overview')}
</Button> </Button>
<Button
className={cn(pathname === '/website/add' && '!bg-muted')} {hasAdminPermission && (
variant="outline" <Button
size="icon" className={cn(pathname === '/website/add' && '!bg-muted')}
Icon={LuPlus} variant="outline"
onClick={handleClickAdd} size="icon"
/> Icon={LuPlus}
onClick={handleClickAdd}
/>
)}
</div> </div>
} }
/> />

View File

@ -12,7 +12,11 @@ import { WebsiteMetricsTable } from '@/components/website/WebsiteMetricsTable';
import { WebsiteOverview } from '@/components/website/WebsiteOverview'; import { WebsiteOverview } from '@/components/website/WebsiteOverview';
import { WebsiteVisitorMapBtn } from '@/components/website/WebsiteVisitorMapBtn'; import { WebsiteVisitorMapBtn } from '@/components/website/WebsiteVisitorMapBtn';
import { useGlobalRangeDate } from '@/hooks/useGlobalRangeDate'; import { useGlobalRangeDate } from '@/hooks/useGlobalRangeDate';
import { useCurrentWorkspaceId } from '@/store/user'; import {
useCurrentWorkspaceId,
useHasAdminPermission,
useHasPermission,
} from '@/store/user';
import { routeAuthBeforeLoad } from '@/utils/route'; import { routeAuthBeforeLoad } from '@/utils/route';
import { useTranslation } from '@i18next-toolkit/react'; import { useTranslation } from '@i18next-toolkit/react';
import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { createFileRoute, useNavigate } from '@tanstack/react-router';
@ -34,6 +38,7 @@ function WebsiteDetailComponent() {
}); });
const { startDate, endDate } = useGlobalRangeDate(); const { startDate, endDate } = useGlobalRangeDate();
const navigate = useNavigate(); const navigate = useNavigate();
const hasAdminPermission = useHasAdminPermission();
if (!websiteId) { if (!websiteId) {
return <ErrorTip />; return <ErrorTip />;
@ -57,20 +62,22 @@ function WebsiteDetailComponent() {
title={website.name} title={website.name}
actions={ actions={
<div className="space-x-2"> <div className="space-x-2">
<Button {hasAdminPermission && (
size="icon" <Button
variant="outline" size="icon"
onClick={() => variant="outline"
navigate({ onClick={() =>
to: '/website/$websiteId/config', navigate({
params: { to: '/website/$websiteId/config',
websiteId, params: {
}, websiteId,
}) },
} })
> }
<LuSettings /> >
</Button> <LuSettings />
</Button>
)}
<WebsiteLighthouseBtn websiteId={website.id} /> <WebsiteLighthouseBtn websiteId={website.id} />

View File

@ -2,6 +2,7 @@ import { shallow } from 'zustand/shallow';
import { createWithEqualityFn } from 'zustand/traditional'; import { createWithEqualityFn } from 'zustand/traditional';
import { createSocketIOClient } from '../api/socketio'; import { createSocketIOClient } from '../api/socketio';
import { AppRouterOutput } from '../api/trpc'; import { AppRouterOutput } from '../api/trpc';
import { ROLES } from '@tianji/shared';
export type UserLoginInfo = NonNullable<AppRouterOutput['user']['info']>; export type UserLoginInfo = NonNullable<AppRouterOutput['user']['info']>;
@ -108,3 +109,36 @@ export function useCurrentWorkspaceId() {
return currentWorkspaceId; return currentWorkspaceId;
} }
/**
* Direct return current workspace role
*/
export function useCurrentWorkspaceRole(): ROLES {
const workspace = useCurrentWorkspace();
return (workspace.role as ROLES) || ROLES.readOnly;
}
export function useHasPermission(role: ROLES): boolean {
const currentWorkspaceRole = useCurrentWorkspaceRole();
if (currentWorkspaceRole === ROLES.owner) {
return true;
}
if (currentWorkspaceRole === ROLES.admin && role !== ROLES.owner) {
return true;
}
if (currentWorkspaceRole === ROLES.readOnly && role === ROLES.readOnly) {
return true;
}
return false;
}
export function useHasAdminPermission(): boolean {
const hasAdminPermission = useHasPermission(ROLES.admin);
return hasAdminPermission;
}

View File

@ -5,5 +5,6 @@ export enum SYSTEM_ROLES {
export enum ROLES { export enum ROLES {
owner = 'owner', owner = 'owner',
admin = 'admin',
readOnly = 'readOnly', readOnly = 'readOnly',
} }