diff --git a/next.config.ts b/next.config.ts index eac6f327a..40c94fa2b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,7 +5,7 @@ const TRACKER_SCRIPT = '/script.js'; const basePath = process.env.BASE_PATH; const collectApiEndpoint = process.env.COLLECT_API_ENDPOINT; -const cloudMode = !!process.env.CLOUD_MODE; +const cloudUrl = process.env.CLOUD_URL; const corsMaxAge = process.env.CORS_MAX_AGE; const defaultLocale = process.env.DEFAULT_LOCALE; const forceSSL = process.env.FORCE_SSL; @@ -157,12 +157,20 @@ if (trackerScriptName) { } } +if (cloudUrl) { + redirects.push({ + source: '/login', + destination: cloudUrl, + permanent: false, + }); +} + /** @type {import('next').NextConfig} */ export default { reactStrictMode: false, env: { basePath, - cloudMode, + cloudUrl, currentVersion: pkg.version, defaultLocale, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 865d02cf1..6c035c862 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -364,8 +364,6 @@ importers: specifier: ^5.9.2 version: 5.9.2 - dist: {} - packages: '@ampproject/remapping@2.3.0': diff --git a/src/app/(collect)/p/[slug]/route.ts b/src/app/(collect)/p/[slug]/route.ts index 23ac03cc1..97d9a3f2a 100644 --- a/src/app/(collect)/p/[slug]/route.ts +++ b/src/app/(collect)/p/[slug]/route.ts @@ -29,7 +29,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug const req = new Request(request.url, { method: 'POST', - headers: request.headers, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); diff --git a/src/app/(collect)/q/[slug]/route.ts b/src/app/(collect)/q/[slug]/route.ts index 3a6806565..4c0f683c3 100644 --- a/src/app/(collect)/q/[slug]/route.ts +++ b/src/app/(collect)/q/[slug]/route.ts @@ -27,7 +27,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug const req = new Request(request.url, { method: 'POST', - headers: request.headers, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); diff --git a/src/app/(main)/UpdateNotice.tsx b/src/app/(main)/UpdateNotice.tsx index 357287912..c971d4038 100644 --- a/src/app/(main)/UpdateNotice.tsx +++ b/src/app/(main)/UpdateNotice.tsx @@ -18,7 +18,7 @@ export function UpdateNotice({ user, config }) { !config?.updatesDisabled && !config?.privateMode && !pathname.includes('/share/') && - !process.env.cloudMode && + !process.env.cloudUrl && !dismissed; const updateCheck = useCallback(() => { diff --git a/src/app/(main)/admin/AdminLayout.tsx b/src/app/(main)/admin/AdminLayout.tsx index 8b1387854..eb4c2ffa5 100644 --- a/src/app/(main)/admin/AdminLayout.tsx +++ b/src/app/(main)/admin/AdminLayout.tsx @@ -11,7 +11,7 @@ export function AdminLayout({ children }: { children: ReactNode }) { const { formatMessage, labels } = useMessages(); const { pathname } = useNavigation(); - if (!user.isAdmin || process.env.cloudMode) { + if (!user.isAdmin || process.env.cloudUrl) { return null; } diff --git a/src/app/(main)/admin/layout.tsx b/src/app/(main)/admin/layout.tsx index 634fc6588..3dea41422 100644 --- a/src/app/(main)/admin/layout.tsx +++ b/src/app/(main)/admin/layout.tsx @@ -2,7 +2,7 @@ import { Metadata } from 'next'; import { AdminLayout } from './AdminLayout'; export default function ({ children }) { - if (process.env.cloudMode) { + if (process.env.cloudUrl) { return null; } diff --git a/src/app/(main)/links/LinkAddButton.tsx b/src/app/(main)/links/LinkAddButton.tsx index 4ad81a46f..dc819ef38 100644 --- a/src/app/(main)/links/LinkAddButton.tsx +++ b/src/app/(main)/links/LinkAddButton.tsx @@ -1,10 +1,17 @@ -import { useMessages } from '@/components/hooks'; -import { Button, Icon, Modal, Dialog, DialogTrigger, Text } from '@umami/react-zen'; +import { useMessages, useModified } from '@/components/hooks'; +import { Button, Icon, Modal, Dialog, DialogTrigger, Text, useToast } from '@umami/react-zen'; import { Plus } from '@/components/icons'; import { LinkEditForm } from './LinkEditForm'; export function LinkAddButton({ teamId }: { teamId?: string }) { - const { formatMessage, labels } = useMessages(); + const { formatMessage, labels, messages } = useMessages(); + const { toast } = useToast(); + const { touch } = useModified(); + + const handleSave = async () => { + toast(formatMessage(messages.saved)); + touch('links'); + }; return ( @@ -16,7 +23,7 @@ export function LinkAddButton({ teamId }: { teamId?: string }) { - {({ close }) => } + {({ close }) => } diff --git a/src/app/(main)/links/LinkEditForm.tsx b/src/app/(main)/links/LinkEditForm.tsx index 23aaab1e6..16c65aecb 100644 --- a/src/app/(main)/links/LinkEditForm.tsx +++ b/src/app/(main)/links/LinkEditForm.tsx @@ -139,9 +139,7 @@ export function LinkEditForm({ {formatMessage(labels.cancel)} )} - - {formatMessage(labels.save)} - + {formatMessage(labels.save)} ); diff --git a/src/app/(main)/pixels/PixelAddButton.tsx b/src/app/(main)/pixels/PixelAddButton.tsx index 9c60db8c6..0958ff0e6 100644 --- a/src/app/(main)/pixels/PixelAddButton.tsx +++ b/src/app/(main)/pixels/PixelAddButton.tsx @@ -1,10 +1,17 @@ -import { useMessages } from '@/components/hooks'; -import { Button, Icon, Modal, Dialog, DialogTrigger, Text } from '@umami/react-zen'; +import { useMessages, useModified } from '@/components/hooks'; +import { Button, Icon, Modal, Dialog, DialogTrigger, Text, useToast } from '@umami/react-zen'; import { Plus } from '@/components/icons'; import { PixelEditForm } from './PixelEditForm'; export function PixelAddButton({ teamId }: { teamId?: string }) { - const { formatMessage, labels } = useMessages(); + const { formatMessage, labels, messages } = useMessages(); + const { toast } = useToast(); + const { touch } = useModified(); + + const handleSave = async () => { + toast(formatMessage(messages.saved)); + touch('pixels'); + }; return ( @@ -16,7 +23,7 @@ export function PixelAddButton({ teamId }: { teamId?: string }) { - {({ close }) => } + {({ close }) => } diff --git a/src/app/(main)/settings/layout.tsx b/src/app/(main)/settings/layout.tsx index fcc7392ad..6f409e483 100644 --- a/src/app/(main)/settings/layout.tsx +++ b/src/app/(main)/settings/layout.tsx @@ -2,7 +2,7 @@ import { Metadata } from 'next'; import { SettingsLayout } from './SettingsLayout'; export default function ({ children }) { - if (process.env.cloudMode) { + if (process.env.cloudUrl) { return null; } diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortEditForm.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortEditForm.tsx index c02d85c9a..cfe552300 100644 --- a/src/app/(main)/websites/[websiteId]/cohorts/CohortEditForm.tsx +++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortEditForm.tsx @@ -114,7 +114,10 @@ export function CohortEditForm({ - + diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts index 4e40caa4b..7fa9ce8e2 100644 --- a/src/app/api/config/route.ts +++ b/src/app/api/config/route.ts @@ -9,7 +9,8 @@ export async function GET(request: Request) { } return json({ - cloudMode: !!process.env.CLOUD_MODE, + cloudMode: !!process.env.CLOUD_URL, + cloudUrl: process.env.CLOUD_URL, faviconUrl: process.env.FAVICON_URL, linksUrl: process.env.LINKS_URL, pixelsUrl: process.env.PIXELS_URL, diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 8abf7a4e0..4c8d80446 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -2,7 +2,7 @@ import { Metadata } from 'next'; import { LoginPage } from './LoginPage'; export default async function () { - if (process.env.DISABLE_LOGIN || process.env.CLOUD_MODE) { + if (process.env.DISABLE_LOGIN) { return null; } diff --git a/src/app/logout/page.tsx b/src/app/logout/page.tsx index 0617c2e2d..7b56ea679 100644 --- a/src/app/logout/page.tsx +++ b/src/app/logout/page.tsx @@ -1,8 +1,8 @@ -import { Metadata } from 'next'; import { LogoutPage } from './LogoutPage'; +import { Metadata } from 'next'; export default function () { - if (process.env.DISABLE_LOGIN || process.env.CLOUD_MODE) { + if (process.env.DISABLE_LOGIN) { return null; } diff --git a/src/components/hooks/useConfig.ts b/src/components/hooks/useConfig.ts index 643b9cbc7..170136437 100644 --- a/src/components/hooks/useConfig.ts +++ b/src/components/hooks/useConfig.ts @@ -4,6 +4,7 @@ import { useApi } from '@/components/hooks/useApi'; export type Config = { cloudMode: boolean; + cloudUrl?: string; faviconUrl?: string; linksUrl?: string; pixelsUrl?: string; diff --git a/src/components/input/ProfileButton.tsx b/src/components/input/ProfileButton.tsx index 03f14d071..017ea8319 100644 --- a/src/components/input/ProfileButton.tsx +++ b/src/components/input/ProfileButton.tsx @@ -11,13 +11,14 @@ import { Text, Row, } from '@umami/react-zen'; -import { useMessages, useLoginQuery, useNavigation } from '@/components/hooks'; +import { useMessages, useLoginQuery, useNavigation, useConfig } from '@/components/hooks'; import { LogOut, UserCircle, LockKeyhole } from '@/components/icons'; export function ProfileButton() { const { formatMessage, labels } = useMessages(); const { user } = useLoginQuery(); const { renderUrl } = useNavigation(); + const { cloudUrl } = useConfig(); const items = [ { @@ -27,7 +28,7 @@ export function ProfileButton() { icon: , }, user.isAdmin && - !process.env.cloudMode && { + !cloudUrl && { id: 'admin', label: formatMessage(labels.admin), path: '/admin', diff --git a/src/components/input/SettingsButton.tsx b/src/components/input/SettingsButton.tsx index 68fd2cdc3..7e5ac4852 100644 --- a/src/components/input/SettingsButton.tsx +++ b/src/components/input/SettingsButton.tsx @@ -16,12 +16,12 @@ export function SettingsButton() { const { formatMessage, labels } = useMessages(); const { user } = useLoginQuery(); const { router, renderUrl } = useNavigation(); - const { cloudMode } = useConfig(); + const { cloudMode, cloudUrl } = useConfig(); const handleAction = (id: Key) => { if (id === 'settings') { if (cloudMode) { - window.location.href = `/settings`; + window.location.href = `${cloudUrl}/settings`; return; } } diff --git a/src/components/messages.ts b/src/components/messages.ts index 1abe98f28..ebd8c7651 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -359,7 +359,7 @@ export const labels = defineMessages({ invalidUrl: { id: 'label.invalid-url', defaultMessage: 'Invalid URL' }, environment: { id: 'label.environment', defaultMessage: 'Environment' }, criteria: { id: 'label.criteria', defaultMessage: 'Criteria' }, - share: { id: 'label.share', defaultMessage: 'Share' }, + share: { defaultMessage: 'label.share', id: 'Share' }, }); export const messages = defineMessages({ diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 890e535f4..46af18b8a 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -18,10 +18,10 @@ export function getBearerToken(request: Request) { export async function checkAuth(request: Request) { const token = getBearerToken(request); const payload = parseSecureToken(token, secret()); - const shareToken = await parseShareToken(request); + const shareToken = await parseShareToken(request.headers); let user = null; - const { userId, authKey } = payload || {}; + const { userId, authKey, grant } = payload || {}; if (userId) { user = await getUser(userId); @@ -33,7 +33,7 @@ export async function checkAuth(request: Request) { } } - log({ token, payload, authKey, shareToken, user }); + log({ token, shareToken, payload, user, grant }); if (!user?.id && !shareToken) { log('User not authorized'); @@ -45,10 +45,11 @@ export async function checkAuth(request: Request) { } return { - token, - authKey, - shareToken, user, + grant, + token, + shareToken, + authKey, }; } @@ -70,9 +71,9 @@ export async function hasPermission(role: string, permission: string | string[]) return ensureArray(permission).some(e => ROLE_PERMISSIONS[role]?.includes(e)); } -export function parseShareToken(request: Request) { +export function parseShareToken(headers: Headers) { try { - return parseToken(request.headers.get(SHARE_TOKEN_HEADER), secret()); + return parseToken(headers.get(SHARE_TOKEN_HEADER), secret()); } catch (e) { log(e); return null; diff --git a/src/lib/types.ts b/src/lib/types.ts index 1237f5199..c70490598 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,5 +1,5 @@ import { UseQueryOptions } from '@tanstack/react-query'; -import { DATA_TYPE, ROLES, OPERATORS } from './constants'; +import { DATA_TYPE, PERMISSIONS, ROLES, OPERATORS } from './constants'; import { TIME_UNIT } from './date'; export type ObjectValues = T[keyof T]; @@ -7,6 +7,7 @@ export type ObjectValues = T[keyof T]; export type ReactQueryOptions = Omit, 'queryKey' | 'queryFn'>; export type TimeUnit = ObjectValues; +export type Permission = ObjectValues; export type Role = ObjectValues; export type DynamicDataType = ObjectValues; export type Operator = (typeof OPERATORS)[keyof typeof OPERATORS]; @@ -18,6 +19,7 @@ export interface Auth { role: string; isAdmin: boolean; }; + grant?: Permission[]; shareToken?: { websiteId: string; }; diff --git a/src/permissions/team.ts b/src/permissions/team.ts index 77e42b1bd..3273c8192 100644 --- a/src/permissions/team.ts +++ b/src/permissions/team.ts @@ -3,6 +3,8 @@ import { PERMISSIONS } from '@/lib/constants'; import { getTeamUser } from '@/queries'; import { hasPermission } from '@/lib/auth'; +const cloudMode = !!process.env.CLOUD_URL; + export async function canViewTeam({ user }: Auth, teamId: string) { if (user.isAdmin) { return true; @@ -11,7 +13,11 @@ export async function canViewTeam({ user }: Auth, teamId: string) { return getTeamUser(teamId, user.id); } -export async function canCreateTeam({ user }: Auth) { +export async function canCreateTeam({ user, grant }: Auth) { + if (cloudMode) { + return !!grant?.find(a => a === PERMISSIONS.teamCreate); + } + if (user.isAdmin) { return true; } @@ -19,11 +25,15 @@ export async function canCreateTeam({ user }: Auth) { return !!user; } -export async function canUpdateTeam({ user }: Auth, teamId: string) { +export async function canUpdateTeam({ user, grant }: Auth, teamId: string) { if (user.isAdmin) { return true; } + if (cloudMode) { + return !!grant?.find(a => a === PERMISSIONS.teamUpdate); + } + const teamUser = await getTeamUser(teamId, user.id); return teamUser && hasPermission(teamUser.role, PERMISSIONS.teamUpdate); @@ -39,7 +49,11 @@ export async function canDeleteTeam({ user }: Auth, teamId: string) { return teamUser && hasPermission(teamUser.role, PERMISSIONS.teamDelete); } -export async function canAddUserToTeam({ user }: Auth) { +export async function canAddUserToTeam({ user, grant }: Auth) { + if (cloudMode) { + return !!grant?.find(a => a === PERMISSIONS.teamUpdate); + } + return user.isAdmin; } diff --git a/src/permissions/website.ts b/src/permissions/website.ts index 11e8dc650..63ae5c903 100644 --- a/src/permissions/website.ts +++ b/src/permissions/website.ts @@ -3,6 +3,8 @@ import { PERMISSIONS } from '@/lib/constants'; import { hasPermission } from '@/lib/auth'; import { getTeamUser, getWebsite } from '@/queries'; +const cloudMode = !!process.env.CLOUD_URL; + export async function canViewWebsite({ user, shareToken }: Auth, websiteId: string) { if (user?.isAdmin) { return true; @@ -31,7 +33,11 @@ export async function canViewAllWebsites({ user }: Auth) { return user.isAdmin; } -export async function canCreateWebsite({ user }: Auth) { +export async function canCreateWebsite({ user, grant }: Auth) { + if (cloudMode) { + return !!grant?.find(a => a === PERMISSIONS.websiteCreate); + } + if (user.isAdmin) { return true; }