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 }) {
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 }) {
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;
}