diff --git a/src/app/(main)/settings/websites/[websiteId]/WebsiteData.tsx b/src/app/(main)/settings/websites/[websiteId]/WebsiteData.tsx index 44872f88..410f1783 100644 --- a/src/app/(main)/settings/websites/[websiteId]/WebsiteData.tsx +++ b/src/app/(main)/settings/websites/[websiteId]/WebsiteData.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/navigation'; import { useMessages, useModified, useTeamUrl } from 'components/hooks'; import WebsiteDeleteForm from './WebsiteDeleteForm'; import WebsiteResetForm from './WebsiteResetForm'; +import WebsiteTransferForm from './WebsiteTransferForm'; export function WebsiteData({ websiteId, onSave }: { websiteId: string; onSave?: () => void }) { const { formatMessage, labels, messages } = useMessages(); @@ -11,23 +12,42 @@ export function WebsiteData({ websiteId, onSave }: { websiteId: string; onSave?: const { touch } = useModified(); const { teamId, renderTeamUrl } = useTeamUrl(); + const handleTransfer = () => { + touch('websites'); + + router.push(renderTeamUrl(`/settings/websites`)); + }; + const handleReset = async () => { showToast({ message: formatMessage(messages.saved), variant: 'success' }); onSave?.(); }; const handleDelete = async () => { + touch('websites'); + if (teamId) { - touch('teams:websites'); router.push(renderTeamUrl('/settings/websites')); } else { - touch('websites'); router.push('/settings/websites'); } }; return ( <> + + + + + {(close: () => void) => ( + + )} + + + void; + onClose?: () => void; +}) { + const { user } = useLogin(); + const website = useContext(WebsiteContext); + const [teamId, setTeamId] = useState(null); + const { formatMessage, labels, messages } = useMessages(); + const { post, useMutation } = useApi(); + const { mutate, isPending, error } = useMutation({ + mutationFn: (data: any) => post(`/websites/${websiteId}/transfer`, data), + }); + const { result, query } = useTeams(user.id); + const isTeamWebsite = !!website?.teamId; + const { showToast } = useToasts(); + + const handleSubmit = async () => { + mutate( + { + userId: website.teamId ? user.id : undefined, + teamId: website.userId ? teamId : undefined, + }, + { + onSuccess: async () => { + showToast({ message: formatMessage(messages.saved), variant: 'success' }); + onSave?.(); + onClose?.(); + }, + }, + ); + }; + + const handleChange = (key: Key) => { + setTeamId(key as string); + }; + + const renderValue = (teamId: string) => result?.data?.find(({ id }) => id === teamId)?.name; + + if (query.isLoading) { + return ; + } + + return ( +
+ + + {formatMessage( + isTeamWebsite ? messages.transferTeamWebsiteToUser : messages.transferUserWebsiteToTeam, + )} + {!isTeamWebsite && ( + + {result.data + .filter(({ teamUser }) => + teamUser.find( + ({ role, userId }) => role === ROLES.teamOwner && userId === user.id, + ), + ) + .map(({ id, name }) => { + return {name}; + })} + + )} + + + + + {formatMessage(labels.transfer)} + + + +
+ ); +} + +export default WebsiteTransferForm; diff --git a/src/components/common/DataTable.tsx b/src/components/common/DataTable.tsx index 9ef41875..4bca7fc3 100644 --- a/src/components/common/DataTable.tsx +++ b/src/components/common/DataTable.tsx @@ -5,7 +5,7 @@ import { useMessages } from 'components/hooks'; import Empty from 'components/common/Empty'; import Pager from 'components/common/Pager'; import styles from './DataTable.module.css'; -import { FilterQueryResult } from 'components/hooks'; +import { FilterQueryResult } from 'lib/types'; const DEFAULT_SEARCH_DELAY = 600; @@ -64,7 +64,7 @@ export function DataTable({ className={classNames(styles.body, { [styles.status]: isLoading || noResults || !hasData })} > {hasData ? (typeof children === 'function' ? children(result) : children) : null} - {isLoading && } + {isLoading && } {!isLoading && !hasData && !query && } {noResults && } diff --git a/src/components/hooks/queries/useFilterQuery.ts b/src/components/hooks/queries/useFilterQuery.ts index 7e4c9a86..e51d70a1 100644 --- a/src/components/hooks/queries/useFilterQuery.ts +++ b/src/components/hooks/queries/useFilterQuery.ts @@ -1,14 +1,7 @@ import { UseQueryOptions } from '@tanstack/react-query'; -import { useState, Dispatch, SetStateAction } from 'react'; +import { useState } from 'react'; import { useApi } from './useApi'; -import { FilterResult, SearchFilter } from 'lib/types'; - -export interface FilterQueryResult { - result: FilterResult; - query: any; - params: SearchFilter; - setParams: Dispatch>; -} +import { FilterResult, SearchFilter, FilterQueryResult } from 'lib/types'; export function useFilterQuery({ queryKey, diff --git a/src/components/layout/SideNav.module.css b/src/components/layout/SideNav.module.css index ba347916..5d9af915 100644 --- a/src/components/layout/SideNav.module.css +++ b/src/components/layout/SideNav.module.css @@ -17,5 +17,4 @@ .selected { font-weight: 700; - background: var(--base75); } diff --git a/src/components/messages.ts b/src/components/messages.ts index b201c198..f9d518ed 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -53,6 +53,7 @@ export const labels = defineMessages({ websiteId: { id: 'label.website-id', defaultMessage: 'Website ID' }, resetWebsite: { id: 'label.reset-website', defaultMessage: 'Reset website' }, deleteWebsite: { id: 'label.delete-website', defaultMessage: 'Delete website' }, + transferWebsite: { id: 'label.transfer-website', defaultMessage: 'Transfer website' }, deleteReport: { id: 'label.delete-report', defaultMessage: 'Delete report' }, reset: { id: 'label.reset', defaultMessage: 'Reset' }, addWebsite: { id: 'label.add-website', defaultMessage: 'Add website' }, @@ -207,7 +208,7 @@ export const labels = defineMessages({ }, select: { id: 'label.select', defaultMessage: 'Select' }, myAccount: { id: 'label.my-account', defaultMessage: 'My account' }, - switch: { id: 'label.switch', defaultMessage: 'Switch' }, + transfer: { id: 'label.transfer', defaultMessage: 'Transfer' }, }); export const messages = defineMessages({ @@ -327,4 +328,16 @@ export const messages = defineMessages({ id: 'message.new-version-available', defaultMessage: 'A new version of Umami {version} is available!', }, + transferWebsite: { + id: 'message.transfer-website', + defaultMessage: 'Transfer website ownership to another user or team.', + }, + transferTeamWebsiteToUser: { + id: 'message.transfer-team-website-to-user', + defaultMessage: 'Do you want to transfer this website to your account?', + }, + transferUserWebsiteToTeam: { + id: 'message.transfer-user-website-to-team', + defaultMessage: 'Which team do you want to transfer this website to?', + }, }); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index eb310015..ee3defea 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,7 +1,7 @@ import { Report } from '@prisma/client'; import redis from '@umami/redis-client'; import debug from 'debug'; -import { PERMISSIONS, ROLE_PERMISSIONS, SHARE_TOKEN_HEADER } from 'lib/constants'; +import { PERMISSIONS, ROLE_PERMISSIONS, SHARE_TOKEN_HEADER, ROLES } from 'lib/constants'; import { secret } from 'lib/crypto'; import { NextApiRequest } from 'next'; import { createSecureToken, ensureArray, getRandomChars, parseToken } from 'next-basics'; @@ -101,6 +101,38 @@ export async function canUpdateWebsite({ user }: Auth, websiteId: string) { return false; } +export async function canTransferWebsiteToUser({ user }: Auth, websiteId: string, userId: string) { + if (user.isAdmin) { + return true; + } + + const website = await loadWebsite(websiteId); + + if (website.teamId && user.id === userId) { + const teamUser = await getTeamUser(website.teamId, userId); + + return teamUser?.role === ROLES.teamOwner; + } + + return false; +} + +export async function canTransferWebsiteToTeam({ user }: Auth, websiteId: string, teamId: string) { + if (user.isAdmin) { + return true; + } + + const website = await loadWebsite(websiteId); + + if (website.userId === user.id) { + const teamUser = await getTeamUser(teamId, user.id); + + return teamUser?.role === ROLES.teamOwner; + } + + return false; +} + export async function canDeleteWebsite({ user }: Auth, websiteId: string) { if (user.isAdmin) { return true; diff --git a/src/lib/types.ts b/src/lib/types.ts index b885d1ae..ecba0a6f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -10,6 +10,7 @@ import { } from './constants'; import * as yup from 'yup'; import { TIME_UNIT } from './date'; +import { Dispatch, SetStateAction } from 'react'; type ObjectValues = T[keyof T]; @@ -64,6 +65,13 @@ export interface FilterResult { sortDescending?: boolean; } +export interface FilterQueryResult { + result: FilterResult; + query: any; + params: SearchFilter; + setParams: Dispatch>; +} + export interface DynamicData { [key: string]: number | string | DynamicData | number[] | string[] | DynamicData[]; } diff --git a/src/pages/api/websites/[websiteId]/transfer.ts b/src/pages/api/websites/[websiteId]/transfer.ts new file mode 100644 index 00000000..56cf6bac --- /dev/null +++ b/src/pages/api/websites/[websiteId]/transfer.ts @@ -0,0 +1,66 @@ +import { NextApiRequestQueryBody } from 'lib/types'; +import { canTransferWebsiteToTeam, canTransferWebsiteToUser } from 'lib/auth'; +import { useAuth, useCors, useValidate } from 'lib/middleware'; +import { NextApiResponse } from 'next'; +import { badRequest, methodNotAllowed, ok, unauthorized } from 'next-basics'; +import { updateWebsite } from 'queries'; +import * as yup from 'yup'; + +export interface WebsiteTransferRequestQuery { + websiteId: string; +} + +export interface WebsiteTransferRequestBody { + userId?: string; + teamId?: string; +} + +const schema = { + POST: yup.object().shape({ + websiteId: yup.string().uuid().required(), + userId: yup.string().uuid(), + teamId: yup.string().uuid(), + }), +}; + +export default async ( + req: NextApiRequestQueryBody, + res: NextApiResponse, +) => { + await useCors(req, res); + await useAuth(req, res); + await useValidate(schema, req, res); + + const { websiteId } = req.query; + const { userId, teamId } = req.body; + + if (req.method === 'POST') { + if (userId) { + if (!(await canTransferWebsiteToUser(req.auth, websiteId, userId))) { + return unauthorized(res); + } + + const website = await updateWebsite(websiteId, { + userId, + teamId: null, + }); + + return ok(res, website); + } else if (teamId) { + if (!(await canTransferWebsiteToTeam(req.auth, websiteId, teamId))) { + return unauthorized(res); + } + + const website = await updateWebsite(websiteId, { + userId: null, + teamId, + }); + + return ok(res, website); + } + + return badRequest(res); + } + + return methodNotAllowed(res); +};