Add board share management UI

This commit is contained in:
Mike Cao
2026-03-08 22:24:24 -07:00
parent b429e7b24d
commit c4226bc63d
30 changed files with 565 additions and 56 deletions
+1 -1
View File
@@ -202,7 +202,7 @@
"number-of-records": "{x} {x, plural, one {record} other {records}}",
"ok": "OK",
"online": "Online",
"open": "Open",
"open": "Mixed",
"organic-search": "Organic search",
"organic-shopping": "Organic shopping",
"organic-social": "Organic social",
+2 -2
View File
@@ -22,7 +22,7 @@ export interface BoardContextValue {
export const BoardContext = createContext<BoardContextValue>(null);
const createDefaultBoard = (): Partial<Board> => ({
type: BOARD_TYPES.open,
type: BOARD_TYPES.mixed,
name: '',
description: '',
parameters: {
@@ -93,7 +93,7 @@ export function BoardProvider({
}
return post('/boards', {
...boardData,
type: boardData.type || BOARD_TYPES.open,
type: boardData.type || BOARD_TYPES.mixed,
slug: '',
teamId,
});
@@ -125,7 +125,7 @@ export function BoardEditForm() {
>
<Box width="100%" maxWidth="360px">
<Select value={boardType} onChange={handleTypeChange} width="100%">
<ListItem id={BOARD_TYPES.open}>{t(labels.open)}</ListItem>
<ListItem id={BOARD_TYPES.mixed}>{t(labels.open)}</ListItem>
<ListItem id={BOARD_TYPES.website}>{t(labels.website)}</ListItem>
<ListItem id={BOARD_TYPES.pixel}>{t(labels.pixel)}</ListItem>
<ListItem id={BOARD_TYPES.link}>{t(labels.link)}</ListItem>
@@ -0,0 +1,19 @@
import { Share } from '@/components/icons';
import { useMessages } from '@/components/hooks';
import { DialogButton } from '@/components/input/DialogButton';
import { BoardShareDialog } from './BoardShareDialog';
export function BoardShareButton({ boardId }: { boardId: string }) {
const { t, labels } = useMessages();
return (
<DialogButton
icon={<Share />}
label={t(labels.share)}
title={null}
width="900px"
>
<BoardShareDialog boardId={boardId} />
</DialogButton>
);
}
@@ -0,0 +1,57 @@
import { Button, Column, Form, FormField, FormSubmitButton, Row, TextField } from '@umami/react-zen';
import { useState } from 'react';
import { useApi, useMessages, useModified } from '@/components/hooks';
export function BoardShareCreateForm({
boardId,
onSave,
onCancel,
}: {
boardId: string;
onSave?: () => void;
onCancel?: () => void;
}) {
const { post } = useApi();
const { touch } = useModified();
const { t, labels, getErrorMessage } = useMessages();
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<unknown>(null);
const handleSubmit = async (data: { name: string }) => {
setIsPending(true);
setError(null);
try {
await post(`/boards/${boardId}/shares`, {
name: data.name,
});
touch('shares');
onSave?.();
} catch (e) {
setError(e);
} finally {
setIsPending(false);
}
};
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ name: '' }}>
<Column gap="4">
<FormField label={t(labels.name)} name="name" rules={{ required: t(labels.required) }}>
<TextField autoComplete="off" autoFocus />
</FormField>
<Row justifyContent="flex-end" gap="3">
{onCancel && (
<Button isDisabled={isPending} onPress={onCancel}>
{t(labels.cancel)}
</Button>
)}
<FormSubmitButton variant="primary" isDisabled={isPending}>
{t(labels.add)}
</FormSubmitButton>
</Row>
</Column>
</Form>
);
}
@@ -0,0 +1,62 @@
import { Button, Column, Heading, Row, Text } from '@umami/react-zen';
import { useState } from 'react';
import { Plus } from '@/components/icons';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { IconLabel } from '@/components/common/IconLabel';
import { useBoardSharesQuery, useMessages } from '@/components/hooks';
import { BoardShareCreateForm } from './BoardShareCreateForm';
import { BoardSharesTable } from './BoardSharesTable';
export function BoardShareDialog({ boardId }: { boardId: string }) {
const { data, error, isLoading } = useBoardSharesQuery({ boardId });
const shares = data?.data || [];
const hasShares = shares.length > 0;
return (
<LoadingPanel data={data} isLoading={isLoading} error={error}>
<BoardShareDialogContent boardId={boardId} hasShares={hasShares} shares={shares} />
</LoadingPanel>
);
}
function BoardShareDialogContent({
boardId,
hasShares,
shares,
}: {
boardId: string;
hasShares: boolean;
shares: any[];
}) {
const { t, labels, messages } = useMessages();
const [isCreating, setIsCreating] = useState(false);
const showCreateForm = !hasShares || isCreating;
return (
<Column gap="4">
<Row justifyContent="space-between" alignItems="center">
<Heading>{t(labels.share)}</Heading>
{hasShares && !isCreating && (
<Button variant="primary" onPress={() => setIsCreating(true)}>
<IconLabel icon={<Plus size={16} />} label={t(labels.add)} />
</Button>
)}
</Row>
{showCreateForm && (
<BoardShareCreateForm
boardId={boardId}
onSave={() => setIsCreating(false)}
onCancel={hasShares ? () => setIsCreating(false) : undefined}
/>
)}
{hasShares ? (
<>
<Text>{t(messages.shareUrl)}</Text>
<BoardSharesTable data={shares} />
</>
) : (
!showCreateForm && <Text color="muted">{t(messages.noDataAvailable)}</Text>
)}
</Column>
);
}
@@ -0,0 +1,52 @@
import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
import { CopyButton } from '@/components/common/CopyButton';
import { DateDistance } from '@/components/common/DateDistance';
import { ExternalLink } from '@/components/common/ExternalLink';
import { useConfig, useMessages, useMobile } from '@/components/hooks';
import { ShareDeleteButton } from '@/app/(main)/websites/[websiteId]/settings/ShareDeleteButton';
export function BoardSharesTable(props: DataTableProps) {
const { t, labels } = useMessages();
const { cloudMode } = useConfig();
const { isMobile } = useMobile();
const getUrl = (slug: string) => {
if (cloudMode) {
return `${process.env.cloudUrl}/share/${slug}`;
}
return `${window?.location.origin}${process.env.basePath || ''}/share/${slug}`;
};
return (
<DataTable {...props} displayMode={isMobile ? 'cards' : 'table'}>
<DataColumn id="name" label={t(labels.name)}>
{({ name }: any) => name}
</DataColumn>
<DataColumn id="slug" label={t(labels.shareUrl)} width="2fr">
{({ slug }: any) => {
const url = getUrl(slug);
return (
<Row alignItems="center" gap="1" overflow="hidden">
<ExternalLink href={url} prefetch={false}>
{isMobile ? slug : url}
</ExternalLink>
<CopyButton value={url} label="Copy URL" />
</Row>
);
}}
</DataColumn>
<DataColumn id="created" label={t(labels.created)}>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
<DataColumn id="action" align="end" width="60px">
{({ id, slug }: any) => (
<Row>
<ShareDeleteButton shareId={id} slug={slug} />
</Row>
)}
</DataColumn>
</DataTable>
);
}
@@ -2,14 +2,14 @@ import { Column } from '@umami/react-zen';
import { useBoard } from '@/components/hooks';
import { BoardViewRow } from './BoardViewRow';
export function BoardViewBody() {
export function BoardViewBody({ showEntityBadges = true }: { showEntityBadges?: boolean }) {
const { board } = useBoard();
const rows = board?.parameters?.rows ?? [];
return (
<Column gap="3">
{rows.map(row => (
<BoardViewRow key={row.id} columns={row.columns} />
<BoardViewRow key={row.id} columns={row.columns} showEntityBadges={showEntityBadges} />
))}
</Column>
);
@@ -8,12 +8,18 @@ import { getComponentDefinition } from '../boardComponentRegistry';
import { useBoardEntityBadgeProps } from '../useBoardEntityBadgeProps';
import { BoardComponentRenderer } from './BoardComponentRenderer';
export function BoardViewColumn({ component }: { component?: BoardComponentConfig }) {
export function BoardViewColumn({
component,
showEntityBadge = true,
}: {
component?: BoardComponentConfig;
showEntityBadge?: boolean;
}) {
const { board } = useBoard();
const boardType = getBoardType(board);
const definition = component ? getComponentDefinition(component.type) : undefined;
const { entityType, entityId } = getResolvedComponentEntity(board, component);
const entityBadge = useBoardEntityBadgeProps(entityType, entityId);
const entityBadge = useBoardEntityBadgeProps(entityType, entityId, showEntityBadge);
if (!component || (!entityId && definition?.requiresWebsite !== false)) {
return null;
@@ -24,7 +30,7 @@ export function BoardViewColumn({ component }: { component?: BoardComponentConfi
return (
<Panel title={title} description={description} height="100%" position="relative">
{isOpenBoardType(boardType) && entityBadge && (
{showEntityBadge && isOpenBoardType(boardType) && entityBadge && (
<Box position="absolute" top="12px" right="12px" zIndex={100}>
<BoardEntityBadge {...entityBadge} />
</Box>
@@ -7,21 +7,33 @@ import { getBoardEntity } from '@/lib/boards';
import { Edit } from '@/components/icons';
import { BoardEntityBadge } from '../BoardEntityBadge';
import { useBoardEntityBadgeProps } from '../useBoardEntityBadgeProps';
import { BoardShareButton } from './BoardShareButton';
export function BoardViewHeader() {
export function BoardViewHeader({
showActions = true,
showEntityBadge = true,
}: {
showActions?: boolean;
showEntityBadge?: boolean;
}) {
const { board } = useBoard();
const { renderUrl } = useNavigation();
const { t, labels } = useMessages();
const { entityType, entityId } = getBoardEntity(board);
const entityBadge = useBoardEntityBadgeProps(entityType, entityId);
const entityBadge = useBoardEntityBadgeProps(entityType, entityId, showEntityBadge);
return (
<PageHeader title={board?.name} description={board?.description}>
<Row alignItems="center" gap>
{entityBadge && <BoardEntityBadge {...entityBadge} />}
<LinkButton href={renderUrl(`/boards/${board?.id}/edit`, false)}>
<IconLabel icon={<Edit />}>{t(labels.edit)}</IconLabel>
</LinkButton>
{showEntityBadge && entityBadge && <BoardEntityBadge {...entityBadge} />}
{showActions && board?.id && (
<>
<BoardShareButton boardId={board.id} />
<LinkButton href={renderUrl(`/boards/${board.id}/edit`, false)}>
<IconLabel icon={<Edit />}>{t(labels.edit)}</IconLabel>
</LinkButton>
</>
)}
</Row>
</PageHeader>
);
@@ -6,14 +6,24 @@ import { BoardControls } from './BoardControls';
import { BoardViewBody } from './BoardViewBody';
import { BoardViewHeader } from './BoardViewHeader';
export function BoardViewPage({ boardId }: { boardId: string }) {
export function BoardViewPage({
boardId,
showActions = true,
showControls = true,
showEntityBadges = true,
}: {
boardId: string;
showActions?: boolean;
showControls?: boolean;
showEntityBadges?: boolean;
}) {
return (
<BoardProvider boardId={boardId}>
<PageBody>
<Column>
<BoardViewHeader />
<BoardControls />
<BoardViewBody />
<BoardViewHeader showActions={showActions} showEntityBadge={showEntityBadges} />
{showControls && <BoardControls />}
<BoardViewBody showEntityBadges={showEntityBadges} />
</Column>
</PageBody>
</BoardProvider>
@@ -3,7 +3,13 @@ import type { BoardColumn } from '@/lib/types';
import { BoardViewColumn } from './BoardViewColumn';
import { MIN_COLUMN_WIDTH } from './boardConstants';
export function BoardViewRow({ columns }: { columns: BoardColumn[] }) {
export function BoardViewRow({
columns,
showEntityBadges = true,
}: {
columns: BoardColumn[];
showEntityBadges?: boolean;
}) {
return (
<Row gap="3" width="100%" overflowX="auto">
{columns.map(column => (
@@ -14,7 +20,7 @@ export function BoardViewRow({ columns }: { columns: BoardColumn[] }) {
flexBasis="0%"
minWidth={`${MIN_COLUMN_WIDTH}px`}
>
<BoardViewColumn component={column.component} />
<BoardViewColumn component={column.component} showEntityBadge={showEntityBadges} />
</Box>
))}
</Row>
@@ -1,10 +1,16 @@
import { useLinkQuery, usePixelQuery, useWebsiteQuery } from '@/components/hooks';
import type { BoardEntityType } from '@/lib/boards';
export function useBoardEntityBadgeProps(entityType?: BoardEntityType, entityId?: string) {
const { data: website } = useWebsiteQuery(entityType === 'website' ? entityId : undefined);
const { data: pixel } = usePixelQuery(entityType === 'pixel' ? entityId : undefined);
const { data: link } = useLinkQuery(entityType === 'link' ? entityId : undefined);
export function useBoardEntityBadgeProps(
entityType?: BoardEntityType,
entityId?: string,
enabled = true,
) {
const { data: website } = useWebsiteQuery(
enabled && entityType === 'website' ? entityId : undefined,
);
const { data: pixel } = usePixelQuery(enabled && entityType === 'pixel' ? entityId : undefined);
const { data: link } = useLinkQuery(enabled && entityType === 'link' ? entityId : undefined);
if (entityType === 'website' && website?.name) {
return { type: entityType, name: website.name };
@@ -1,4 +1,5 @@
import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
import { CopyButton } from '@/components/common/CopyButton';
import { DateDistance } from '@/components/common/DateDistance';
import { ExternalLink } from '@/components/common/ExternalLink';
import { useConfig, useMessages, useMobile } from '@/components/hooks';
@@ -26,9 +27,12 @@ export function SharesTable(props: DataTableProps) {
{({ slug }: any) => {
const url = getUrl(slug);
return (
<ExternalLink href={url} prefetch={false}>
{isMobile ? slug : url}
</ExternalLink>
<Row alignItems="center" gap="1" overflow="hidden">
<ExternalLink href={url} prefetch={false}>
{isMobile ? slug : url}
</ExternalLink>
<CopyButton value={url} label="Copy URL" />
</Row>
);
}}
</DataColumn>
+5 -3
View File
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { BOARD_TYPES } from '@/lib/boards';
import { BOARD_TYPES, normalizeBoardType } from '@/lib/boards';
import { parseRequest } from '@/lib/request';
import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
import { canDeleteBoard, canUpdateBoard, canViewBoard } from '@/permissions';
@@ -28,11 +28,12 @@ export async function POST(request: Request, { params }: { params: Promise<{ boa
type: z
.enum([
BOARD_TYPES.dashboard,
BOARD_TYPES.open,
BOARD_TYPES.mixed,
BOARD_TYPES.website,
BOARD_TYPES.pixel,
BOARD_TYPES.link,
])
.or(z.literal('open'))
.optional(),
name: z.string().optional(),
description: z.string().optional(),
@@ -46,7 +47,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ boa
}
const { boardId } = await params;
const { type, name, description, parameters } = body;
const { name, description, parameters } = body;
const type = normalizeBoardType(body.type);
if (!(await canUpdateBoard(auth, boardId))) {
return unauthorized();
@@ -0,0 +1,73 @@
import { z } from 'zod';
import { ENTITY_TYPE } from '@/lib/constants';
import { uuid } from '@/lib/crypto';
import { getRandomChars } from '@/lib/generate';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { filterParams, pagingParams } from '@/lib/schema';
import { canUpdateBoard, canViewBoard } from '@/permissions';
import { createShare, getSharesByEntityId } from '@/queries/prisma';
export async function GET(
request: Request,
{ params }: { params: Promise<{ boardId: string }> },
) {
const schema = z.object({
...filterParams,
...pagingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { boardId } = await params;
const { page, pageSize, search } = query;
if (!(await canViewBoard(auth, boardId))) {
return unauthorized();
}
const data = await getSharesByEntityId(boardId, {
page,
pageSize,
search,
});
return json(data);
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ boardId: string }> },
) {
const schema = z.object({
name: z.string().max(200),
});
const { auth, body, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { boardId } = await params;
const { name } = body;
if (!(await canUpdateBoard(auth, boardId))) {
return unauthorized();
}
const share = await createShare({
id: uuid(),
entityId: boardId,
shareType: ENTITY_TYPE.board,
name,
slug: getRandomChars(16),
parameters: {},
});
return json(share);
}
+5 -2
View File
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { BOARD_TYPES } from '@/lib/boards';
import { BOARD_TYPES, normalizeBoardType } from '@/lib/boards';
import { uuid } from '@/lib/crypto';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
@@ -28,7 +28,9 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
const schema = z.object({
type: z.enum([BOARD_TYPES.open, BOARD_TYPES.website, BOARD_TYPES.pixel, BOARD_TYPES.link]),
type: z
.enum([BOARD_TYPES.mixed, BOARD_TYPES.website, BOARD_TYPES.pixel, BOARD_TYPES.link])
.or(z.literal('open')),
name: z.string().max(100),
description: z.string().max(500).optional(),
slug: z.string().max(100),
@@ -58,6 +60,7 @@ export async function POST(request: Request) {
const data = {
...body,
type: normalizeBoardType(body.type),
id: uuid(),
parameters: body.parameters ?? {},
slug: uuid(),
+28 -2
View File
@@ -1,11 +1,12 @@
import { ROLES } from '@/lib/constants';
import { ENTITY_TYPE, ROLES } from '@/lib/constants';
import { secret } from '@/lib/crypto';
import { createToken } from '@/lib/jwt';
import { getBoardWebsiteIds } from '@/lib/boards';
import prisma from '@/lib/prisma';
import redis from '@/lib/redis';
import { json, notFound } from '@/lib/response';
import type { WhiteLabel } from '@/lib/types';
import { getShareByCode, getWebsite } from '@/queries/prisma';
import { getBoard, getShareByCode, getWebsite } from '@/queries/prisma';
async function getAccountId(website: { userId?: string; teamId?: string }): Promise<string | null> {
if (website.userId) {
@@ -52,10 +53,35 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu
return notFound();
}
if (share.shareType === ENTITY_TYPE.board) {
const board = await getBoard(share.entityId);
if (!board) {
return notFound();
}
const data: Record<string, any> = {
shareId: share.id,
shareType: share.shareType,
boardId: share.entityId,
parameters: share.parameters,
websiteIds: getBoardWebsiteIds(board),
};
data.token = createToken(data, secret());
return json(data);
}
const website = await getWebsite(share.entityId);
if (!website) {
return notFound();
}
const data: Record<string, any> = {
shareId: share.id,
shareType: share.shareType,
websiteId: share.entityId,
parameters: share.parameters,
};
+8 -3
View File
@@ -3,12 +3,16 @@ import { Loading } from '@umami/react-zen';
import { usePathname, useRouter } from 'next/navigation';
import { createContext, type ReactNode, useEffect } from 'react';
import { useShareTokenQuery } from '@/components/hooks';
import { ENTITY_TYPE } from '@/lib/constants';
import type { WhiteLabel } from '@/lib/types';
export interface ShareData {
shareId: string;
slug: string;
websiteId: string;
shareType: number;
websiteId?: string;
websiteIds?: string[];
boardId?: string;
parameters: any;
token: string;
whiteLabel?: WhiteLabel;
@@ -49,12 +53,13 @@ export function ShareProvider({ slug, children }: { slug: string; children: Reac
const router = useRouter();
const pathname = usePathname();
const path = getSharePath(pathname);
const isBoardShare = share?.shareType === ENTITY_TYPE.board;
const allowedSections = share?.parameters
const allowedSections = !isBoardShare && share?.parameters
? ALL_SECTION_IDS.filter(id => share.parameters[id] !== false)
: [];
const shouldRedirect =
const shouldRedirect = !isBoardShare &&
allowedSections.length === 1 &&
allowedSections[0] !== 'overview' &&
(path === undefined || path === '' || path === 'overview');
+15 -2
View File
@@ -17,9 +17,11 @@ import { SessionsPage } from '@/app/(main)/websites/[websiteId]/sessions/Session
import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader';
import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage';
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
import { BoardViewPage } from '@/app/(main)/boards/[boardId]/BoardViewPage';
import { PageBody } from '@/components/common/PageBody';
import { useShare } from '@/components/hooks';
import { MobileMenuButton } from '@/components/input/MobileMenuButton';
import { ENTITY_TYPE } from '@/lib/constants';
import { ShareNav } from './ShareNav';
const PAGE_COMPONENTS: Record<string, React.ComponentType<{ websiteId: string }>> = {
@@ -57,7 +59,7 @@ export function SharePage() {
const { setTheme } = useTheme();
const pathname = usePathname();
const path = getSharePath(pathname);
const { websiteId, parameters = {} } = share;
const { websiteId, boardId, parameters = {}, shareType } = share;
useEffect(() => {
const url = new URL(window?.location?.href);
@@ -66,7 +68,18 @@ export function SharePage() {
if (theme === 'light' || theme === 'dark') {
setTheme(theme);
}
}, []);
}, [setTheme]);
if (shareType === ENTITY_TYPE.board && boardId) {
return (
<BoardViewPage
boardId={boardId}
showActions={false}
showControls={false}
showEntityBadges={false}
/>
);
}
// Check if the requested path is allowed
const pageKey = path || '';
+45
View File
@@ -0,0 +1,45 @@
import { Button, Icon } from '@umami/react-zen';
import { useEffect, useRef, useState } from 'react';
import { Check, Copy } from '@/components/icons';
export function CopyButton({
value,
label = 'Copy',
}: {
value: string;
label?: string;
}) {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
};
}, []);
const handleCopy = async () => {
if (!navigator?.clipboard) {
return;
}
await navigator.clipboard.writeText(value);
setCopied(true);
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current);
}
timeoutRef.current = window.setTimeout(() => {
setCopied(false);
}, 1500);
};
return (
<Button variant="quiet" onPress={handleCopy} title={label} aria-label={label}>
<Icon size="sm">{copied ? <Check /> : <Copy />}</Icon>
</Button>
);
}
+1
View File
@@ -12,6 +12,7 @@ export * from './context/useWebsite';
// Query hooks
export * from './queries/useActiveUsersQuery';
export * from './queries/useBoardQuery';
export * from './queries/useBoardSharesQuery';
export * from './queries/useBoardsQuery';
export * from './queries/useDashboardQuery';
export * from './queries/useDateRangeQuery';
@@ -0,0 +1,17 @@
import type { ReactQueryOptions } from '@/lib/types';
import { useApi } from '../useApi';
import { useModified } from '../useModified';
import { usePagedQuery } from '../usePagedQuery';
export function useBoardSharesQuery({ boardId }: { boardId: string }, options?: ReactQueryOptions) {
const { modified } = useModified('shares');
const { get } = useApi();
return usePagedQuery({
queryKey: ['boardShares', { boardId, modified }],
queryFn: pageParams => {
return get(`/boards/${boardId}/shares`, pageParams);
},
...options,
});
}
+5 -1
View File
@@ -55,7 +55,11 @@ export function DialogButton({
<IconLabel icon={icon} label={label} />
</Button>
<Modal placement={isMobile ? 'fullscreen' : 'center'}>
<Dialog variant={isMobile ? 'sheet' : undefined} title={title || label} style={style}>
<Dialog
variant={isMobile ? 'sheet' : undefined}
title={title === undefined ? label : title}
style={style}
>
{children}
</Dialog>
</Modal>
+46 -5
View File
@@ -2,7 +2,7 @@ import type { Board, BoardComponentConfig, BoardParameters } from './types';
export const BOARD_TYPES = {
dashboard: 'dashboard',
open: 'open',
mixed: 'mixed',
website: 'website',
pixel: 'pixel',
link: 'link',
@@ -32,7 +32,19 @@ export function getLegacyBoardType(parameters?: BoardParameters): BoardType {
return BOARD_TYPES.website;
}
return BOARD_TYPES.open;
return BOARD_TYPES.mixed;
}
export function normalizeBoardType(type?: string): BoardType | undefined {
if (type === 'open') {
return BOARD_TYPES.mixed;
}
if (type && boardTypes.has(type)) {
return type as BoardType;
}
return undefined;
}
export function getBoardType(
@@ -45,15 +57,17 @@ export function getBoardType(
return getLegacyBoardType(board?.parameters);
}
if (type && boardTypes.has(type)) {
return type as BoardType;
const normalizedType = normalizeBoardType(type);
if (normalizedType) {
return normalizedType;
}
return getLegacyBoardType(board?.parameters);
}
export function isOpenBoardType(type?: string) {
return type === BOARD_TYPES.open || type === BOARD_TYPES.dashboard;
return type === BOARD_TYPES.mixed || type === BOARD_TYPES.dashboard || type === 'open';
}
export function requiresBoardEntity(type?: string) {
@@ -142,6 +156,33 @@ export function getFirstBoardComponentEntity(
return {};
}
export function getBoardWebsiteIds(
board?: Pick<Board, 'type' | 'parameters'> | Partial<Board>,
): string[] {
const ids = new Set<string>();
const boardEntity = getBoardEntity(board);
if (boardEntity.entityType === BOARD_ENTITY_TYPES.website && boardEntity.entityId) {
ids.add(boardEntity.entityId);
}
if (board?.parameters?.websiteId) {
ids.add(board.parameters.websiteId);
}
for (const row of board?.parameters?.rows ?? []) {
for (const column of row.columns ?? []) {
const entity = getComponentEntity(column.component);
if (entity.entityType === BOARD_ENTITY_TYPES.website && entity.entityId) {
ids.add(entity.entityId);
}
}
}
return [...ids];
}
export function clearBoardEntity(parameters: BoardParameters = {}): BoardParameters {
const { websiteId, pixelId, linkId, ...rest } = parameters;
+10 -7
View File
@@ -1,12 +1,15 @@
import type { Link, Pixel, Website } from '@/generated/prisma/client';
import { getLink, getPixel, getWebsite } from '@/queries/prisma';
import type { Board, Link, Pixel, Website } from '@/generated/prisma/client';
import { getBoard, getLink, getPixel, getWebsite } from '@/queries/prisma';
export async function getEntity(entityId: string): Promise<Website | Link | Pixel | null> {
const website = await getWebsite(entityId);
const link = await getLink(entityId);
const pixel = await getPixel(entityId);
export async function getEntity(entityId: string): Promise<Website | Link | Pixel | Board | null> {
const [website, link, pixel, board] = await Promise.all([
getWebsite(entityId),
getLink(entityId),
getPixel(entityId),
getBoard(entityId),
]);
const entity = website || link || pixel;
const entity = website || link || pixel || board;
return entity;
}
+3 -1
View File
@@ -20,7 +20,9 @@ export interface Auth {
isAdmin: boolean;
};
shareToken?: {
websiteId: string;
websiteId?: string;
websiteIds?: string[];
boardId?: string;
};
}
+29 -1
View File
@@ -3,13 +3,25 @@ import { PERMISSIONS } from '@/lib/constants';
import type { Auth } from '@/lib/types';
import { getBoard, getTeamUser } from '@/queries/prisma';
export async function canViewBoard({ user }: Auth, boardId: string) {
export async function canViewBoard({ user, shareToken }: Auth, boardId: string) {
if (user?.isAdmin) {
return true;
}
if (shareToken?.boardId === boardId) {
return true;
}
if (!user) {
return false;
}
const board = await getBoard(boardId);
if (!board) {
return false;
}
if (board.userId) {
return user.id === board.userId;
}
@@ -24,12 +36,20 @@ export async function canViewBoard({ user }: Auth, boardId: string) {
}
export async function canUpdateBoard({ user }: Auth, boardId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
const board = await getBoard(boardId);
if (!board) {
return false;
}
if (board.userId) {
return user.id === board.userId;
}
@@ -44,12 +64,20 @@ export async function canUpdateBoard({ user }: Auth, boardId: string) {
}
export async function canDeleteBoard({ user }: Auth, boardId: string) {
if (!user) {
return false;
}
if (user.isAdmin) {
return true;
}
const board = await getBoard(boardId);
if (!board) {
return false;
}
if (board.userId) {
return user.id === board.userId;
}
+12
View File
@@ -15,6 +15,10 @@ export async function canViewEntity({ user }: Auth, entityId: string) {
const entity = await getEntity(entityId);
if (!entity) {
return false;
}
if (entity.userId) {
return user.id === entity.userId;
}
@@ -39,6 +43,10 @@ export async function canUpdateEntity({ user }: Auth, entityId: string) {
const entity = await getEntity(entityId);
if (!entity) {
return false;
}
if (entity.userId) {
return user.id === entity.userId;
}
@@ -63,6 +71,10 @@ export async function canDeleteEntity({ user }: Auth, entityId: string) {
const entity = await getEntity(entityId);
if (!entity) {
return false;
}
if (entity.userId) {
return user.id === entity.userId;
}
+1 -1
View File
@@ -9,7 +9,7 @@ export async function canViewWebsite({ user, shareToken }: Auth, websiteId: stri
return true;
}
if (shareToken?.websiteId === websiteId) {
if (shareToken?.websiteId === websiteId || shareToken?.websiteIds?.includes(websiteId)) {
return true;
}