Compare commits

..

No commits in common. "d8b3c8d13cab12eb62e8ac52ab65bf3bd0e3f428" and "26bd498a05b094edf24ac24b56ae04de67a31844" have entirely different histories.

23 changed files with 91 additions and 44 deletions

View file

@ -5,7 +5,7 @@ const TRACKER_SCRIPT = '/script.js';
const basePath = process.env.BASE_PATH; const basePath = process.env.BASE_PATH;
const collectApiEndpoint = process.env.COLLECT_API_ENDPOINT; 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 corsMaxAge = process.env.CORS_MAX_AGE;
const defaultLocale = process.env.DEFAULT_LOCALE; const defaultLocale = process.env.DEFAULT_LOCALE;
const forceSSL = process.env.FORCE_SSL; 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} */ /** @type {import('next').NextConfig} */
export default { export default {
reactStrictMode: false, reactStrictMode: false,
env: { env: {
basePath, basePath,
cloudMode, cloudUrl,
currentVersion: pkg.version, currentVersion: pkg.version,
defaultLocale, defaultLocale,
}, },

2
pnpm-lock.yaml generated
View file

@ -364,8 +364,6 @@ importers:
specifier: ^5.9.2 specifier: ^5.9.2
version: 5.9.2 version: 5.9.2
dist: {}
packages: packages:
'@ampproject/remapping@2.3.0': '@ampproject/remapping@2.3.0':

View file

@ -29,7 +29,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug
const req = new Request(request.url, { const req = new Request(request.url, {
method: 'POST', method: 'POST',
headers: request.headers, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });

View file

@ -27,7 +27,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug
const req = new Request(request.url, { const req = new Request(request.url, {
method: 'POST', method: 'POST',
headers: request.headers, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });

View file

@ -18,7 +18,7 @@ export function UpdateNotice({ user, config }) {
!config?.updatesDisabled && !config?.updatesDisabled &&
!config?.privateMode && !config?.privateMode &&
!pathname.includes('/share/') && !pathname.includes('/share/') &&
!process.env.cloudMode && !process.env.cloudUrl &&
!dismissed; !dismissed;
const updateCheck = useCallback(() => { const updateCheck = useCallback(() => {

View file

@ -11,7 +11,7 @@ export function AdminLayout({ children }: { children: ReactNode }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation(); const { pathname } = useNavigation();
if (!user.isAdmin || process.env.cloudMode) { if (!user.isAdmin || process.env.cloudUrl) {
return null; return null;
} }

View file

@ -2,7 +2,7 @@ import { Metadata } from 'next';
import { AdminLayout } from './AdminLayout'; import { AdminLayout } from './AdminLayout';
export default function ({ children }) { export default function ({ children }) {
if (process.env.cloudMode) { if (process.env.cloudUrl) {
return null; return null;
} }

View file

@ -1,10 +1,17 @@
import { useMessages } from '@/components/hooks'; import { useMessages, useModified } from '@/components/hooks';
import { Button, Icon, Modal, Dialog, DialogTrigger, Text } from '@umami/react-zen'; import { Button, Icon, Modal, Dialog, DialogTrigger, Text, useToast } from '@umami/react-zen';
import { Plus } from '@/components/icons'; import { Plus } from '@/components/icons';
import { LinkEditForm } from './LinkEditForm'; import { LinkEditForm } from './LinkEditForm';
export function LinkAddButton({ teamId }: { teamId?: string }) { 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 ( return (
<DialogTrigger> <DialogTrigger>
@ -16,7 +23,7 @@ export function LinkAddButton({ teamId }: { teamId?: string }) {
</Button> </Button>
<Modal> <Modal>
<Dialog title={formatMessage(labels.addLink)} style={{ width: 600 }}> <Dialog title={formatMessage(labels.addLink)} style={{ width: 600 }}>
{({ close }) => <LinkEditForm teamId={teamId} onClose={close} />} {({ close }) => <LinkEditForm teamId={teamId} onSave={handleSave} onClose={close} />}
</Dialog> </Dialog>
</Modal> </Modal>
</DialogTrigger> </DialogTrigger>

View file

@ -139,9 +139,7 @@ export function LinkEditForm({
{formatMessage(labels.cancel)} {formatMessage(labels.cancel)}
</Button> </Button>
)} )}
<FormSubmitButton isDisabled={false} isLoading={isPending}> <FormSubmitButton isDisabled={false}>{formatMessage(labels.save)}</FormSubmitButton>
{formatMessage(labels.save)}
</FormSubmitButton>
</Row> </Row>
</> </>
); );

View file

@ -1,10 +1,17 @@
import { useMessages } from '@/components/hooks'; import { useMessages, useModified } from '@/components/hooks';
import { Button, Icon, Modal, Dialog, DialogTrigger, Text } from '@umami/react-zen'; import { Button, Icon, Modal, Dialog, DialogTrigger, Text, useToast } from '@umami/react-zen';
import { Plus } from '@/components/icons'; import { Plus } from '@/components/icons';
import { PixelEditForm } from './PixelEditForm'; import { PixelEditForm } from './PixelEditForm';
export function PixelAddButton({ teamId }: { teamId?: string }) { 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 ( return (
<DialogTrigger> <DialogTrigger>
@ -16,7 +23,7 @@ export function PixelAddButton({ teamId }: { teamId?: string }) {
</Button> </Button>
<Modal> <Modal>
<Dialog title={formatMessage(labels.addPixel)} style={{ width: 600 }}> <Dialog title={formatMessage(labels.addPixel)} style={{ width: 600 }}>
{({ close }) => <PixelEditForm teamId={teamId} onClose={close} />} {({ close }) => <PixelEditForm teamId={teamId} onSave={handleSave} onClose={close} />}
</Dialog> </Dialog>
</Modal> </Modal>
</DialogTrigger> </DialogTrigger>

View file

@ -2,7 +2,7 @@ import { Metadata } from 'next';
import { SettingsLayout } from './SettingsLayout'; import { SettingsLayout } from './SettingsLayout';
export default function ({ children }) { export default function ({ children }) {
if (process.env.cloudMode) { if (process.env.cloudUrl) {
return null; return null;
} }

View file

@ -114,7 +114,10 @@ export function CohortEditForm({
<Column> <Column>
<Label>{formatMessage(labels.filters)}</Label> <Label>{formatMessage(labels.filters)}</Label>
<FormField name="parameters.filters"> <FormField
name="parameters.filters"
rules={{ required: formatMessage(labels.required) }}
>
<FieldFilters websiteId={websiteId} exclude={['path', 'event']} /> <FieldFilters websiteId={websiteId} exclude={['path', 'event']} />
</FormField> </FormField>
</Column> </Column>

View file

@ -9,7 +9,8 @@ export async function GET(request: Request) {
} }
return json({ return json({
cloudMode: !!process.env.CLOUD_MODE, cloudMode: !!process.env.CLOUD_URL,
cloudUrl: process.env.CLOUD_URL,
faviconUrl: process.env.FAVICON_URL, faviconUrl: process.env.FAVICON_URL,
linksUrl: process.env.LINKS_URL, linksUrl: process.env.LINKS_URL,
pixelsUrl: process.env.PIXELS_URL, pixelsUrl: process.env.PIXELS_URL,

View file

@ -2,7 +2,7 @@ import { Metadata } from 'next';
import { LoginPage } from './LoginPage'; import { LoginPage } from './LoginPage';
export default async function () { export default async function () {
if (process.env.DISABLE_LOGIN || process.env.CLOUD_MODE) { if (process.env.DISABLE_LOGIN) {
return null; return null;
} }

View file

@ -1,8 +1,8 @@
import { Metadata } from 'next';
import { LogoutPage } from './LogoutPage'; import { LogoutPage } from './LogoutPage';
import { Metadata } from 'next';
export default function () { export default function () {
if (process.env.DISABLE_LOGIN || process.env.CLOUD_MODE) { if (process.env.DISABLE_LOGIN) {
return null; return null;
} }

View file

@ -4,6 +4,7 @@ import { useApi } from '@/components/hooks/useApi';
export type Config = { export type Config = {
cloudMode: boolean; cloudMode: boolean;
cloudUrl?: string;
faviconUrl?: string; faviconUrl?: string;
linksUrl?: string; linksUrl?: string;
pixelsUrl?: string; pixelsUrl?: string;

View file

@ -11,13 +11,14 @@ import {
Text, Text,
Row, Row,
} from '@umami/react-zen'; } 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'; import { LogOut, UserCircle, LockKeyhole } from '@/components/icons';
export function ProfileButton() { export function ProfileButton() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { user } = useLoginQuery(); const { user } = useLoginQuery();
const { renderUrl } = useNavigation(); const { renderUrl } = useNavigation();
const { cloudUrl } = useConfig();
const items = [ const items = [
{ {
@ -27,7 +28,7 @@ export function ProfileButton() {
icon: <UserCircle />, icon: <UserCircle />,
}, },
user.isAdmin && user.isAdmin &&
!process.env.cloudMode && { !cloudUrl && {
id: 'admin', id: 'admin',
label: formatMessage(labels.admin), label: formatMessage(labels.admin),
path: '/admin', path: '/admin',

View file

@ -16,12 +16,12 @@ export function SettingsButton() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { user } = useLoginQuery(); const { user } = useLoginQuery();
const { router, renderUrl } = useNavigation(); const { router, renderUrl } = useNavigation();
const { cloudMode } = useConfig(); const { cloudMode, cloudUrl } = useConfig();
const handleAction = (id: Key) => { const handleAction = (id: Key) => {
if (id === 'settings') { if (id === 'settings') {
if (cloudMode) { if (cloudMode) {
window.location.href = `/settings`; window.location.href = `${cloudUrl}/settings`;
return; return;
} }
} }

View file

@ -359,7 +359,7 @@ export const labels = defineMessages({
invalidUrl: { id: 'label.invalid-url', defaultMessage: 'Invalid URL' }, invalidUrl: { id: 'label.invalid-url', defaultMessage: 'Invalid URL' },
environment: { id: 'label.environment', defaultMessage: 'Environment' }, environment: { id: 'label.environment', defaultMessage: 'Environment' },
criteria: { id: 'label.criteria', defaultMessage: 'Criteria' }, criteria: { id: 'label.criteria', defaultMessage: 'Criteria' },
share: { id: 'label.share', defaultMessage: 'Share' }, share: { defaultMessage: 'label.share', id: 'Share' },
}); });
export const messages = defineMessages({ export const messages = defineMessages({

View file

@ -18,10 +18,10 @@ export function getBearerToken(request: Request) {
export async function checkAuth(request: Request) { export async function checkAuth(request: Request) {
const token = getBearerToken(request); const token = getBearerToken(request);
const payload = parseSecureToken(token, secret()); const payload = parseSecureToken(token, secret());
const shareToken = await parseShareToken(request); const shareToken = await parseShareToken(request.headers);
let user = null; let user = null;
const { userId, authKey } = payload || {}; const { userId, authKey, grant } = payload || {};
if (userId) { if (userId) {
user = await getUser(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) { if (!user?.id && !shareToken) {
log('User not authorized'); log('User not authorized');
@ -45,10 +45,11 @@ export async function checkAuth(request: Request) {
} }
return { return {
token,
authKey,
shareToken,
user, 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)); return ensureArray(permission).some(e => ROLE_PERMISSIONS[role]?.includes(e));
} }
export function parseShareToken(request: Request) { export function parseShareToken(headers: Headers) {
try { try {
return parseToken(request.headers.get(SHARE_TOKEN_HEADER), secret()); return parseToken(headers.get(SHARE_TOKEN_HEADER), secret());
} catch (e) { } catch (e) {
log(e); log(e);
return null; return null;

View file

@ -1,5 +1,5 @@
import { UseQueryOptions } from '@tanstack/react-query'; 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'; import { TIME_UNIT } from './date';
export type ObjectValues<T> = T[keyof T]; export type ObjectValues<T> = T[keyof T];
@ -7,6 +7,7 @@ export type ObjectValues<T> = T[keyof T];
export type ReactQueryOptions<T = any> = Omit<UseQueryOptions<T, Error, T>, 'queryKey' | 'queryFn'>; export type ReactQueryOptions<T = any> = Omit<UseQueryOptions<T, Error, T>, 'queryKey' | 'queryFn'>;
export type TimeUnit = ObjectValues<typeof TIME_UNIT>; export type TimeUnit = ObjectValues<typeof TIME_UNIT>;
export type Permission = ObjectValues<typeof PERMISSIONS>;
export type Role = ObjectValues<typeof ROLES>; export type Role = ObjectValues<typeof ROLES>;
export type DynamicDataType = ObjectValues<typeof DATA_TYPE>; export type DynamicDataType = ObjectValues<typeof DATA_TYPE>;
export type Operator = (typeof OPERATORS)[keyof typeof OPERATORS]; export type Operator = (typeof OPERATORS)[keyof typeof OPERATORS];
@ -18,6 +19,7 @@ export interface Auth {
role: string; role: string;
isAdmin: boolean; isAdmin: boolean;
}; };
grant?: Permission[];
shareToken?: { shareToken?: {
websiteId: string; websiteId: string;
}; };

View file

@ -3,6 +3,8 @@ import { PERMISSIONS } from '@/lib/constants';
import { getTeamUser } from '@/queries'; import { getTeamUser } from '@/queries';
import { hasPermission } from '@/lib/auth'; import { hasPermission } from '@/lib/auth';
const cloudMode = !!process.env.CLOUD_URL;
export async function canViewTeam({ user }: Auth, teamId: string) { export async function canViewTeam({ user }: Auth, teamId: string) {
if (user.isAdmin) { if (user.isAdmin) {
return true; return true;
@ -11,7 +13,11 @@ export async function canViewTeam({ user }: Auth, teamId: string) {
return getTeamUser(teamId, user.id); 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) { if (user.isAdmin) {
return true; return true;
} }
@ -19,11 +25,15 @@ export async function canCreateTeam({ user }: Auth) {
return !!user; return !!user;
} }
export async function canUpdateTeam({ user }: Auth, teamId: string) { export async function canUpdateTeam({ user, grant }: Auth, teamId: string) {
if (user.isAdmin) { if (user.isAdmin) {
return true; return true;
} }
if (cloudMode) {
return !!grant?.find(a => a === PERMISSIONS.teamUpdate);
}
const teamUser = await getTeamUser(teamId, user.id); const teamUser = await getTeamUser(teamId, user.id);
return teamUser && hasPermission(teamUser.role, PERMISSIONS.teamUpdate); 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); 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; return user.isAdmin;
} }

View file

@ -3,6 +3,8 @@ import { PERMISSIONS } from '@/lib/constants';
import { hasPermission } from '@/lib/auth'; import { hasPermission } from '@/lib/auth';
import { getTeamUser, getWebsite } from '@/queries'; import { getTeamUser, getWebsite } from '@/queries';
const cloudMode = !!process.env.CLOUD_URL;
export async function canViewWebsite({ user, shareToken }: Auth, websiteId: string) { export async function canViewWebsite({ user, shareToken }: Auth, websiteId: string) {
if (user?.isAdmin) { if (user?.isAdmin) {
return true; return true;
@ -31,7 +33,11 @@ export async function canViewAllWebsites({ user }: Auth) {
return user.isAdmin; 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) { if (user.isAdmin) {
return true; return true;
} }