mirror of
https://github.com/umami-software/umami.git
synced 2025-12-06 01:18:00 +01:00
commit
36c4645e5b
42 changed files with 395 additions and 114 deletions
|
|
@ -3,6 +3,7 @@
|
||||||
"browser": true,
|
"browser": true,
|
||||||
"es2020": true,
|
"es2020": true,
|
||||||
"node": true,
|
"node": true,
|
||||||
|
"jquery": true,
|
||||||
"jest": true
|
"jest": true
|
||||||
},
|
},
|
||||||
"parser": "@typescript-eslint/parser",
|
"parser": "@typescript-eslint/parser",
|
||||||
|
|
@ -14,6 +15,7 @@
|
||||||
"sourceType": "module"
|
"sourceType": "module"
|
||||||
},
|
},
|
||||||
"extends": [
|
"extends": [
|
||||||
|
"plugin:@typescript-eslint/eslint-recommended",
|
||||||
"plugin:@typescript-eslint/recommended",
|
"plugin:@typescript-eslint/recommended",
|
||||||
"eslint:recommended",
|
"eslint:recommended",
|
||||||
"plugin:prettier/recommended",
|
"plugin:prettier/recommended",
|
||||||
|
|
@ -33,12 +35,14 @@
|
||||||
"react/prop-types": "off",
|
"react/prop-types": "off",
|
||||||
"import/no-anonymous-default-export": "off",
|
"import/no-anonymous-default-export": "off",
|
||||||
"import/no-named-as-default": "off",
|
"import/no-named-as-default": "off",
|
||||||
|
"css-modules/no-unused-class": "off",
|
||||||
"@next/next/no-img-element": "off",
|
"@next/next/no-img-element": "off",
|
||||||
"@typescript-eslint/no-empty-function": "off",
|
"@typescript-eslint/no-empty-function": "off",
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
"@typescript-eslint/no-var-requires": "off",
|
"@typescript-eslint/no-var-requires": "off",
|
||||||
"@typescript-eslint/no-empty-interface": "off",
|
"@typescript-eslint/no-empty-interface": "off",
|
||||||
"@typescript-eslint/no-unused-vars": ["error", { "ignoreRestSiblings": true }]
|
"@typescript-eslint/no-unused-vars": ["error", { "ignoreRestSiblings": true }],
|
||||||
|
"@typescript-eslint/no-namespace": ["error", { "allowDeclarations": true }]
|
||||||
},
|
},
|
||||||
"globals": {
|
"globals": {
|
||||||
"React": "writable"
|
"React": "writable"
|
||||||
|
|
|
||||||
29
cypress/e2e/api.cy.ts
Normal file
29
cypress/e2e/api.cy.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
describe('Website tests', () => {
|
||||||
|
Cypress.session.clearAllSavedSessions();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.login(Cypress.env('umami_user'), Cypress.env('umami_password'));
|
||||||
|
});
|
||||||
|
|
||||||
|
//let userId;
|
||||||
|
|
||||||
|
it('creates a user.', () => {
|
||||||
|
cy.fixture('users').then(data => {
|
||||||
|
const userPost = data.userPost;
|
||||||
|
cy.request({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/users',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: Cypress.env('authorization'),
|
||||||
|
},
|
||||||
|
body: userPost,
|
||||||
|
}).then(response => {
|
||||||
|
//userId = response.body.id;
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
expect(response.body).to.have.property('username', 'cypress1');
|
||||||
|
expect(response.body).to.have.property('role', 'User');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,22 +1,36 @@
|
||||||
describe('Login tests', () => {
|
describe('Login tests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.visit('/login');
|
||||||
|
});
|
||||||
|
|
||||||
it(
|
it(
|
||||||
'logs user in with correct credentials and logs user out',
|
'logs user in with correct credentials and logs user out',
|
||||||
{
|
{
|
||||||
defaultCommandTimeout: 10000,
|
defaultCommandTimeout: 10000,
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
cy.visit('/login');
|
cy.getDataTest('input-username').find('input').as('inputUsername').click();
|
||||||
cy.getDataTest('input-username').find('input').click();
|
cy.get('@inputUsername').type(Cypress.env('umami_user'), { delay: 0 });
|
||||||
cy.getDataTest('input-username').find('input').type(Cypress.env('umami_user'), { delay: 50 });
|
cy.get('@inputUsername').click();
|
||||||
cy.getDataTest('input-password').find('input').click();
|
|
||||||
cy.getDataTest('input-password')
|
cy.getDataTest('input-password')
|
||||||
.find('input')
|
.find('input')
|
||||||
.type(Cypress.env('umami_password'), { delay: 50 });
|
.type(Cypress.env('umami_password'), { delay: 0 });
|
||||||
cy.getDataTest('button-submit').click();
|
cy.getDataTest('button-submit').click();
|
||||||
cy.url().should('eq', Cypress.config().baseUrl + '/dashboard');
|
cy.url().should('eq', Cypress.config().baseUrl + '/dashboard');
|
||||||
cy.getDataTest('button-profile').click();
|
cy.logout();
|
||||||
cy.getDataTest('item-logout').click();
|
|
||||||
cy.url().should('eq', Cypress.config().baseUrl + '/login');
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it('login with blank inputs or incorrect credentials', () => {
|
||||||
|
cy.getDataTest('button-submit').click();
|
||||||
|
cy.contains(/Required/i).should('be.visible');
|
||||||
|
|
||||||
|
cy.getDataTest('input-username').find('input').as('inputUsername');
|
||||||
|
cy.get('@inputUsername').click();
|
||||||
|
cy.get('@inputUsername').type(Cypress.env('umami_user'), { delay: 0 });
|
||||||
|
cy.get('@inputUsername').click();
|
||||||
|
cy.getDataTest('input-password').find('input').type('wrongpassword', { delay: 0 });
|
||||||
|
cy.getDataTest('button-submit').click();
|
||||||
|
cy.contains(/Incorrect username and\/or password./i).should('be.visible');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
65
cypress/e2e/user.cy.ts
Normal file
65
cypress/e2e/user.cy.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
describe('Website tests', () => {
|
||||||
|
Cypress.session.clearAllSavedSessions();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.login(Cypress.env('umami_user'), Cypress.env('umami_password'));
|
||||||
|
cy.visit('/settings/users');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Add a User', () => {
|
||||||
|
// add user
|
||||||
|
cy.contains(/Create user/i).should('be.visible');
|
||||||
|
cy.getDataTest('button-create-user').click();
|
||||||
|
cy.getDataTest('input-username').find('input').as('inputName').click();
|
||||||
|
cy.get('@inputName').type('Test-user', { delay: 0 });
|
||||||
|
cy.getDataTest('input-password').find('input').as('inputPassword').click();
|
||||||
|
cy.get('@inputPassword').type('testPasswordCypress', { delay: 0 });
|
||||||
|
cy.getDataTest('dropdown-role').click();
|
||||||
|
cy.getDataTest('dropdown-item-user').click();
|
||||||
|
cy.getDataTest('button-submit').click();
|
||||||
|
cy.get('td[label="Username"]').should('contain.text', 'Test-user');
|
||||||
|
cy.get('td[label="Role"]').should('contain.text', 'User');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Edit a User role and password', () => {
|
||||||
|
// edit user
|
||||||
|
cy.get('table tbody tr')
|
||||||
|
.contains('td', /Test-user/i)
|
||||||
|
.parent()
|
||||||
|
.within(() => {
|
||||||
|
cy.getDataTest('link-button-edit').click(); // Clicks the button inside the row
|
||||||
|
});
|
||||||
|
cy.getDataTest('input-password').find('input').as('inputPassword').click();
|
||||||
|
cy.get('@inputPassword').type('newPassword', { delay: 0 });
|
||||||
|
cy.getDataTest('dropdown-role').click();
|
||||||
|
cy.getDataTest('dropdown-item-viewOnly').click();
|
||||||
|
cy.getDataTest('button-submit').click();
|
||||||
|
|
||||||
|
cy.visit('/settings/users');
|
||||||
|
cy.get('table tbody tr')
|
||||||
|
.contains('td', /Test-user/i)
|
||||||
|
.parent()
|
||||||
|
.should('contain.text', 'View only');
|
||||||
|
|
||||||
|
cy.logout();
|
||||||
|
cy.url().should('eq', Cypress.config().baseUrl + '/login');
|
||||||
|
cy.getDataTest('input-username').find('input').as('inputUsername').click();
|
||||||
|
cy.get('@inputUsername').type('Test-user', { delay: 0 });
|
||||||
|
cy.get('@inputUsername').click();
|
||||||
|
cy.getDataTest('input-password').find('input').type('newPassword', { delay: 0 });
|
||||||
|
cy.getDataTest('button-submit').click();
|
||||||
|
cy.url().should('eq', Cypress.config().baseUrl + '/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Delete a website', () => {
|
||||||
|
// delete user
|
||||||
|
cy.get('table tbody tr')
|
||||||
|
.contains('td', /Test-user/i)
|
||||||
|
.parent()
|
||||||
|
.within(() => {
|
||||||
|
cy.getDataTest('button-delete').click(); // Clicks the button inside the row
|
||||||
|
});
|
||||||
|
cy.contains(/Are you sure you want to delete Test-user?/i).should('be.visible');
|
||||||
|
cy.getDataTest('button-confirm').click();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -10,10 +10,10 @@ describe('Website tests', () => {
|
||||||
cy.visit('/settings/websites');
|
cy.visit('/settings/websites');
|
||||||
cy.getDataTest('button-website-add').click();
|
cy.getDataTest('button-website-add').click();
|
||||||
cy.contains(/Add website/i).should('be.visible');
|
cy.contains(/Add website/i).should('be.visible');
|
||||||
cy.getDataTest('input-name').find('input').click();
|
cy.getDataTest('input-name').find('input').as('inputUsername').click();
|
||||||
cy.getDataTest('input-name').find('input').type('Add test', { delay: 50 });
|
cy.getDataTest('input-name').find('input').type('Add test', { delay: 0 });
|
||||||
cy.getDataTest('input-domain').find('input').click();
|
cy.getDataTest('input-domain').find('input').click();
|
||||||
cy.getDataTest('input-domain').find('input').type('addtest.com', { delay: 50 });
|
cy.getDataTest('input-domain').find('input').type('addtest.com', { delay: 0 });
|
||||||
cy.getDataTest('button-submit').click();
|
cy.getDataTest('button-submit').click();
|
||||||
cy.get('td[label="Name"]').should('contain.text', 'Add test');
|
cy.get('td[label="Name"]').should('contain.text', 'Add test');
|
||||||
cy.get('td[label="Domain"]').should('contain.text', 'addtest.com');
|
cy.get('td[label="Domain"]').should('contain.text', 'addtest.com');
|
||||||
|
|
@ -41,10 +41,10 @@ describe('Website tests', () => {
|
||||||
cy.contains(/Details/i).should('be.visible');
|
cy.contains(/Details/i).should('be.visible');
|
||||||
cy.getDataTest('input-name').find('input').click();
|
cy.getDataTest('input-name').find('input').click();
|
||||||
cy.getDataTest('input-name').find('input').clear();
|
cy.getDataTest('input-name').find('input').clear();
|
||||||
cy.getDataTest('input-name').find('input').type('Updated website', { delay: 50 });
|
cy.getDataTest('input-name').find('input').type('Updated website', { delay: 0 });
|
||||||
cy.getDataTest('input-domain').find('input').click();
|
cy.getDataTest('input-domain').find('input').click();
|
||||||
cy.getDataTest('input-domain').find('input').clear();
|
cy.getDataTest('input-domain').find('input').clear();
|
||||||
cy.getDataTest('input-domain').find('input').type('updatedwebsite.com', { delay: 50 });
|
cy.getDataTest('input-domain').find('input').type('updatedwebsite.com', { delay: 0 });
|
||||||
cy.getDataTest('button-submit').click({ force: true });
|
cy.getDataTest('button-submit').click({ force: true });
|
||||||
cy.getDataTest('input-name').find('input').should('have.value', 'Updated website');
|
cy.getDataTest('input-name').find('input').should('have.value', 'Updated website');
|
||||||
cy.getDataTest('input-domain').find('input').should('have.value', 'updatedwebsite.com');
|
cy.getDataTest('input-domain').find('input').should('have.value', 'updatedwebsite.com');
|
||||||
|
|
|
||||||
17
cypress/fixtures/users.json
Normal file
17
cypress/fixtures/users.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"userGet": {
|
||||||
|
"name": "cypress",
|
||||||
|
"email": "password",
|
||||||
|
"role": "User"
|
||||||
|
},
|
||||||
|
"userPost": {
|
||||||
|
"username": "cypress1",
|
||||||
|
"password": "password",
|
||||||
|
"role": "User"
|
||||||
|
},
|
||||||
|
"userDelete": {
|
||||||
|
"name": "Charlie",
|
||||||
|
"email": "charlie@example.com",
|
||||||
|
"age": 35
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,12 @@ Cypress.Commands.add('getDataTest', (value: string) => {
|
||||||
return cy.get(`[data-test=${value}]`);
|
return cy.get(`[data-test=${value}]`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add('logout', () => {
|
||||||
|
cy.getDataTest('button-profile').click();
|
||||||
|
cy.getDataTest('item-logout').click();
|
||||||
|
cy.url().should('eq', Cypress.config().baseUrl + '/login');
|
||||||
|
});
|
||||||
|
|
||||||
Cypress.Commands.add('login', (username: string, password: string) => {
|
Cypress.Commands.add('login', (username: string, password: string) => {
|
||||||
cy.session([username, password], () => {
|
cy.session([username, password], () => {
|
||||||
cy.request({
|
cy.request({
|
||||||
|
|
|
||||||
6
cypress/support/index.d.ts
vendored
6
cypress/support/index.d.ts
vendored
|
|
@ -1,4 +1,5 @@
|
||||||
/// <reference types="cypress" />
|
/// <reference types="cypress" />
|
||||||
|
/* global JQuery */
|
||||||
|
|
||||||
declare namespace Cypress {
|
declare namespace Cypress {
|
||||||
interface Chainable {
|
interface Chainable {
|
||||||
|
|
@ -7,6 +8,11 @@ declare namespace Cypress {
|
||||||
* @example cy.getDataTest('greeting')
|
* @example cy.getDataTest('greeting')
|
||||||
*/
|
*/
|
||||||
getDataTest(value: string): Chainable<JQuery<HTMLElement>>;
|
getDataTest(value: string): Chainable<JQuery<HTMLElement>>;
|
||||||
|
/**
|
||||||
|
* Custom command to logout through UI.
|
||||||
|
* @example cy.logout()
|
||||||
|
*/
|
||||||
|
logout(): Chainable<JQuery<HTMLElement>>;
|
||||||
/**
|
/**
|
||||||
* Custom command to login user into the app.
|
* Custom command to login user into the app.
|
||||||
* @example cy.login('admin', 'password)
|
* @example cy.login('admin', 'password)
|
||||||
|
|
|
||||||
|
|
@ -59,15 +59,29 @@ const trackerHeaders = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const apiHeaders = [
|
||||||
|
{
|
||||||
|
key: 'Access-Control-Allow-Origin',
|
||||||
|
value: '*'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Access-Control-Allow-Headers',
|
||||||
|
value: '*'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Access-Control-Allow-Methods',
|
||||||
|
value: 'GET, DELETE, POST, PUT'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Access-Control-Max-Age',
|
||||||
|
value: corsMaxAge || '86400'
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const headers = [
|
const headers = [
|
||||||
{
|
{
|
||||||
source: '/api/:path*',
|
source: '/api/:path*',
|
||||||
headers: [
|
headers: apiHeaders
|
||||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
|
||||||
{ key: 'Access-Control-Allow-Headers', value: '*' },
|
|
||||||
{ key: 'Access-Control-Allow-Methods', value: 'GET, DELETE, POST, PUT' },
|
|
||||||
{ key: 'Access-Control-Max-Age', value: corsMaxAge || '86400' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: '/:path*',
|
source: '/:path*',
|
||||||
|
|
@ -89,6 +103,11 @@ if (trackerScriptURL) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (collectApiEndpoint) {
|
if (collectApiEndpoint) {
|
||||||
|
headers.push({
|
||||||
|
source: collectApiEndpoint,
|
||||||
|
headers: apiHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
rewrites.push({
|
rewrites.push({
|
||||||
source: collectApiEndpoint,
|
source: collectApiEndpoint,
|
||||||
destination: '/api/send',
|
destination: '/api/send',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "umami",
|
"name": "umami",
|
||||||
"version": "2.16.1",
|
"version": "2.17.0",
|
||||||
"description": "A simple, fast, privacy-focused alternative to Google Analytics.",
|
"description": "A simple, fast, privacy-focused alternative to Google Analytics.",
|
||||||
"author": "Umami Software, Inc. <hello@umami.is>",
|
"author": "Umami Software, Inc. <hello@umami.is>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
|
||||||
|
|
@ -82,9 +82,11 @@ async function checkV1Tables() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyMigration() {
|
async function applyMigration() {
|
||||||
console.log(execSync('prisma migrate deploy').toString());
|
if (!process.env.SKIP_DB_MIGRATION) {
|
||||||
|
console.log(execSync('prisma migrate deploy').toString());
|
||||||
|
|
||||||
success('Database is up to date.');
|
success('Database is up to date.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,9 @@ export function ReportDeleteButton({
|
||||||
<Modal title={formatMessage(labels.deleteReport)}>
|
<Modal title={formatMessage(labels.deleteReport)}>
|
||||||
{(close: () => void) => (
|
{(close: () => void) => (
|
||||||
<ConfirmationForm
|
<ConfirmationForm
|
||||||
message={formatMessage(messages.confirmDelete, { target: <b>{reportName}</b> })}
|
message={formatMessage(messages.confirmDelete, {
|
||||||
|
target: <b key={messages.confirmDelete.id}>{reportName}</b>,
|
||||||
|
})}
|
||||||
isLoading={isPending}
|
isLoading={isPending}
|
||||||
error={error}
|
error={error}
|
||||||
onConfirm={handleConfirm.bind(null, close)}
|
onConfirm={handleConfirm.bind(null, close)}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,9 @@ export function TeamLeaveForm({
|
||||||
return (
|
return (
|
||||||
<ConfirmationForm
|
<ConfirmationForm
|
||||||
buttonLabel={formatMessage(labels.leave)}
|
buttonLabel={formatMessage(labels.leave)}
|
||||||
message={formatMessage(messages.confirmLeave, { target: <b>{teamName}</b> })}
|
message={formatMessage(messages.confirmLeave, {
|
||||||
|
target: <b key={messages.confirmLeave.id}>{teamName}</b>,
|
||||||
|
})}
|
||||||
onConfirm={handleConfirm}
|
onConfirm={handleConfirm}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
isLoading={isPending}
|
isLoading={isPending}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export function UserAddButton({ onSave }: { onSave?: () => void }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalTrigger>
|
<ModalTrigger>
|
||||||
<Button variant="primary">
|
<Button data-test="button-create-user" variant="primary">
|
||||||
<Icon>
|
<Icon>
|
||||||
<Icons.Plus />
|
<Icons.Plus />
|
||||||
</Icon>
|
</Icon>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
} from 'react-basics';
|
} from 'react-basics';
|
||||||
import { useApi, useMessages } from '@/components/hooks';
|
import { useApi, useMessages } from '@/components/hooks';
|
||||||
import { ROLES } from '@/lib/constants';
|
import { ROLES } from '@/lib/constants';
|
||||||
|
import { messages } from '@/components/messages';
|
||||||
|
|
||||||
export function UserAddForm({ onSave, onClose }) {
|
export function UserAddForm({ onSave, onClose }) {
|
||||||
const { post, useMutation } = useApi();
|
const { post, useMutation } = useApi();
|
||||||
|
|
@ -44,26 +45,43 @@ export function UserAddForm({ onSave, onClose }) {
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={handleSubmit} error={error}>
|
<Form onSubmit={handleSubmit} error={error}>
|
||||||
<FormRow label={formatMessage(labels.username)}>
|
<FormRow label={formatMessage(labels.username)}>
|
||||||
<FormInput name="username" rules={{ required: formatMessage(labels.required) }}>
|
<FormInput
|
||||||
|
data-test="input-username"
|
||||||
|
name="username"
|
||||||
|
rules={{ required: formatMessage(labels.required) }}
|
||||||
|
>
|
||||||
<TextField autoComplete="new-username" />
|
<TextField autoComplete="new-username" />
|
||||||
</FormInput>
|
</FormInput>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<FormRow label={formatMessage(labels.password)}>
|
<FormRow label={formatMessage(labels.password)}>
|
||||||
<FormInput name="password" rules={{ required: formatMessage(labels.required) }}>
|
<FormInput
|
||||||
|
data-test="input-password"
|
||||||
|
name="password"
|
||||||
|
rules={{
|
||||||
|
required: formatMessage(labels.required),
|
||||||
|
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: 8 }) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
<PasswordField autoComplete="new-password" />
|
<PasswordField autoComplete="new-password" />
|
||||||
</FormInput>
|
</FormInput>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<FormRow label={formatMessage(labels.role)}>
|
<FormRow label={formatMessage(labels.role)}>
|
||||||
<FormInput name="role" rules={{ required: formatMessage(labels.required) }}>
|
<FormInput name="role" rules={{ required: formatMessage(labels.required) }}>
|
||||||
<Dropdown renderValue={renderValue}>
|
<Dropdown data-test="dropdown-role" renderValue={renderValue}>
|
||||||
<Item key={ROLES.viewOnly}>{formatMessage(labels.viewOnly)}</Item>
|
<Item data-test="dropdown-item-viewOnly" key={ROLES.viewOnly}>
|
||||||
<Item key={ROLES.user}>{formatMessage(labels.user)}</Item>
|
{formatMessage(labels.viewOnly)}
|
||||||
<Item key={ROLES.admin}>{formatMessage(labels.admin)}</Item>
|
</Item>
|
||||||
|
<Item data-test="dropdown-item-user" key={ROLES.user}>
|
||||||
|
{formatMessage(labels.user)}
|
||||||
|
</Item>
|
||||||
|
<Item data-test="dropdown-item-admin" key={ROLES.admin}>
|
||||||
|
{formatMessage(labels.admin)}
|
||||||
|
</Item>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</FormInput>
|
</FormInput>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<FormButtons flex>
|
<FormButtons flex>
|
||||||
<SubmitButton variant="primary" disabled={false}>
|
<SubmitButton data-test="button-submit" variant="primary" disabled={false}>
|
||||||
{formatMessage(labels.save)}
|
{formatMessage(labels.save)}
|
||||||
</SubmitButton>
|
</SubmitButton>
|
||||||
<Button disabled={isPending} onClick={onClose}>
|
<Button disabled={isPending} onClick={onClose}>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export function UserDeleteButton({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalTrigger disabled={userId === user?.id}>
|
<ModalTrigger disabled={userId === user?.id}>
|
||||||
<Button disabled={userId === user?.id} variant="quiet">
|
<Button data-test="button-delete" disabled={userId === user?.id} variant="quiet">
|
||||||
<Icon>
|
<Icon>
|
||||||
<Icons.Trash />
|
<Icons.Trash />
|
||||||
</Icon>
|
</Icon>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,9 @@ export function UserDeleteForm({ userId, username, onSave, onClose }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmationForm
|
<ConfirmationForm
|
||||||
message={formatMessage(messages.confirmDelete, { target: <b>{username}</b> })}
|
message={formatMessage(messages.confirmDelete, {
|
||||||
|
target: <b key={messages.confirmDelete.id}>{username}</b>,
|
||||||
|
})}
|
||||||
onConfirm={handleConfirm}
|
onConfirm={handleConfirm}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
buttonLabel={formatMessage(labels.delete)}
|
buttonLabel={formatMessage(labels.delete)}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ export function UsersTable({
|
||||||
<>
|
<>
|
||||||
<UserDeleteButton userId={id} username={username} />
|
<UserDeleteButton userId={id} username={username} />
|
||||||
<LinkButton href={`/settings/users/${id}`}>
|
<LinkButton href={`/settings/users/${id}`}>
|
||||||
<Icon>
|
<Icon data-test="link-button-edit">
|
||||||
<Icons.Edit />
|
<Icons.Edit />
|
||||||
</Icon>
|
</Icon>
|
||||||
<Text>{formatMessage(labels.edit)}</Text>
|
<Text>{formatMessage(labels.edit)}</Text>
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<FormRow label={formatMessage(labels.password)}>
|
<FormRow label={formatMessage(labels.password)}>
|
||||||
<FormInput
|
<FormInput
|
||||||
|
data-test="input-password"
|
||||||
name="password"
|
name="password"
|
||||||
rules={{
|
rules={{
|
||||||
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: 8 }) },
|
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: 8 }) },
|
||||||
|
|
@ -73,16 +74,24 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
|
||||||
{user.id !== login.id && (
|
{user.id !== login.id && (
|
||||||
<FormRow label={formatMessage(labels.role)}>
|
<FormRow label={formatMessage(labels.role)}>
|
||||||
<FormInput name="role" rules={{ required: formatMessage(labels.required) }}>
|
<FormInput name="role" rules={{ required: formatMessage(labels.required) }}>
|
||||||
<Dropdown renderValue={renderValue}>
|
<Dropdown data-test="dropdown-role" renderValue={renderValue}>
|
||||||
<Item key={ROLES.viewOnly}>{formatMessage(labels.viewOnly)}</Item>
|
<Item data-test="dropdown-item-viewOnly" key={ROLES.viewOnly}>
|
||||||
<Item key={ROLES.user}>{formatMessage(labels.user)}</Item>
|
{formatMessage(labels.viewOnly)}
|
||||||
<Item key={ROLES.admin}>{formatMessage(labels.admin)}</Item>
|
</Item>
|
||||||
|
<Item data-test="dropdown-item-user" key={ROLES.user}>
|
||||||
|
{formatMessage(labels.user)}
|
||||||
|
</Item>
|
||||||
|
<Item data-test="dropdown-item-admin" key={ROLES.admin}>
|
||||||
|
{formatMessage(labels.admin)}
|
||||||
|
</Item>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</FormInput>
|
</FormInput>
|
||||||
</FormRow>
|
</FormRow>
|
||||||
)}
|
)}
|
||||||
<FormButtons>
|
<FormButtons>
|
||||||
<SubmitButton variant="primary">{formatMessage(labels.save)}</SubmitButton>
|
<SubmitButton data-test="button-submit" variant="primary">
|
||||||
|
{formatMessage(labels.save)}
|
||||||
|
</SubmitButton>
|
||||||
</FormButtons>
|
</FormButtons>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,9 @@ export function TeamMemberRemoveButton({
|
||||||
<Modal title={formatMessage(labels.removeMember)}>
|
<Modal title={formatMessage(labels.removeMember)}>
|
||||||
{(close: () => void) => (
|
{(close: () => void) => (
|
||||||
<ConfirmationForm
|
<ConfirmationForm
|
||||||
message={formatMessage(messages.confirmRemove, { target: <b>{userName}</b> })}
|
message={formatMessage(messages.confirmRemove, {
|
||||||
|
target: <b key={messages.confirmRemove.id}>{userName}</b>,
|
||||||
|
})}
|
||||||
isLoading={isPending}
|
isLoading={isPending}
|
||||||
error={error}
|
error={error}
|
||||||
onConfirm={handleConfirm.bind(null, close)}
|
onConfirm={handleConfirm.bind(null, close)}
|
||||||
|
|
|
||||||
|
|
@ -71,9 +71,10 @@ export function RealtimeLog({ data }: { data: RealtimeData }) {
|
||||||
|
|
||||||
if (__type === TYPE_EVENT) {
|
if (__type === TYPE_EVENT) {
|
||||||
return formatMessage(messages.eventLog, {
|
return formatMessage(messages.eventLog, {
|
||||||
event: <b>{eventName || formatMessage(labels.unknown)}</b>,
|
event: <b key="b">{eventName || formatMessage(labels.unknown)}</b>,
|
||||||
url: (
|
url: (
|
||||||
<a
|
<a
|
||||||
|
key="a"
|
||||||
href={`//${website?.domain}${url}`}
|
href={`//${website?.domain}${url}`}
|
||||||
className={styles.link}
|
className={styles.link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
@ -100,10 +101,10 @@ export function RealtimeLog({ data }: { data: RealtimeData }) {
|
||||||
|
|
||||||
if (__type === TYPE_SESSION) {
|
if (__type === TYPE_SESSION) {
|
||||||
return formatMessage(messages.visitorLog, {
|
return formatMessage(messages.visitorLog, {
|
||||||
country: <b>{countryNames[country] || formatMessage(labels.unknown)}</b>,
|
country: <b key="country">{countryNames[country] || formatMessage(labels.unknown)}</b>,
|
||||||
browser: <b>{BROWSERS[browser]}</b>,
|
browser: <b key="browser">{BROWSERS[browser]}</b>,
|
||||||
os: <b>{OS_NAMES[os] || os}</b>,
|
os: <b key="os">{OS_NAMES[os] || os}</b>,
|
||||||
device: <b>{formatMessage(labels[device] || labels.unknown)}</b>,
|
device: <b key="device">{formatMessage(labels[device] || labels.unknown)}</b>,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -62,10 +62,10 @@ export function SessionsWeekly({ websiteId }: { websiteId: string }) {
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
{format(getDayOfWeekAsDate(index), 'EEE', { locale: dateLocale })}
|
{format(getDayOfWeekAsDate(index), 'EEE', { locale: dateLocale })}
|
||||||
</div>
|
</div>
|
||||||
{day?.map((hour: number) => {
|
{day?.map((hour: number, j) => {
|
||||||
const pct = hour / max;
|
const pct = hour / max;
|
||||||
return (
|
return (
|
||||||
<div key={hour} className={classNames(styles.cell)}>
|
<div key={j} className={classNames(styles.cell)}>
|
||||||
{hour > 0 && (
|
{hour > 0 && (
|
||||||
<TooltipPopup
|
<TooltipPopup
|
||||||
label={`${formatMessage(labels.visitors)}: ${hour}`}
|
label={`${formatMessage(labels.visitors)}: ${hour}`}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export async function POST(request: Request) {
|
||||||
const user = await getUserByUsername(username, { includePassword: true });
|
const user = await getUserByUsername(username, { includePassword: true });
|
||||||
|
|
||||||
if (!user || !checkPassword(password, user.password)) {
|
if (!user || !checkPassword(password, user.password)) {
|
||||||
return unauthorized();
|
return unauthorized('message.incorrect-username-password');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id, role, createdAt } = user;
|
const { id, role, createdAt } = user;
|
||||||
|
|
|
||||||
39
src/app/api/batch/route.ts
Normal file
39
src/app/api/batch/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import * as send from '@/app/api/send/route';
|
||||||
|
import { parseRequest } from '@/lib/request';
|
||||||
|
import { json, serverError } from '@/lib/response';
|
||||||
|
|
||||||
|
const schema = z.array(z.object({}).passthrough());
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { body, error } = await parseRequest(request, schema, { skipAuth: true });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error();
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
for (const data of body) {
|
||||||
|
const newRequest = new Request(request, { body: JSON.stringify(data) });
|
||||||
|
const response = await send.POST(newRequest);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
errors.push({ index, response: await response.json() });
|
||||||
|
}
|
||||||
|
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
size: body.length,
|
||||||
|
processed: body.length - errors.length,
|
||||||
|
errors: errors.length,
|
||||||
|
details: errors,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return serverError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,8 +9,8 @@ export async function POST(request: Request) {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
...reportParms,
|
...reportParms,
|
||||||
steps: z.coerce.number().min(3).max(7),
|
steps: z.coerce.number().min(3).max(7),
|
||||||
startStep: z.string(),
|
startStep: z.string().optional(),
|
||||||
endStep: z.string(),
|
endStep: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { auth, body, error } = await parseRequest(request, schema);
|
const { auth, body, error } = await parseRequest(request, schema);
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,34 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { isbot } from 'isbot';
|
import { isbot } from 'isbot';
|
||||||
import { createToken, parseToken } from '@/lib/jwt';
|
import { startOfHour, startOfMonth } from 'date-fns';
|
||||||
import clickhouse from '@/lib/clickhouse';
|
import clickhouse from '@/lib/clickhouse';
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { badRequest, json, forbidden, serverError } from '@/lib/response';
|
import { badRequest, json, forbidden, serverError } from '@/lib/response';
|
||||||
import { fetchSession, fetchWebsite } from '@/lib/load';
|
import { fetchSession, fetchWebsite } from '@/lib/load';
|
||||||
import { getClientInfo, hasBlockedIp } from '@/lib/detect';
|
import { getClientInfo, hasBlockedIp } from '@/lib/detect';
|
||||||
import { secret, uuid, visitSalt } from '@/lib/crypto';
|
import { createToken, parseToken } from '@/lib/jwt';
|
||||||
import { COLLECTION_TYPE, DOMAIN_REGEX } from '@/lib/constants';
|
import { secret, uuid, hash } from '@/lib/crypto';
|
||||||
|
import { COLLECTION_TYPE } from '@/lib/constants';
|
||||||
|
import { anyObjectParam, urlOrPathParam } from '@/lib/schema';
|
||||||
|
import { safeDecodeURI, safeDecodeURIComponent } from '@/lib/url';
|
||||||
import { createSession, saveEvent, saveSessionData } from '@/queries';
|
import { createSession, saveEvent, saveSessionData } from '@/queries';
|
||||||
import { urlOrPathParam } from '@/lib/schema';
|
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
type: z.enum(['event', 'identify']),
|
type: z.enum(['event', 'identify']),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
website: z.string().uuid(),
|
website: z.string().uuid(),
|
||||||
data: z.object({}).passthrough().optional(),
|
data: anyObjectParam.optional(),
|
||||||
hostname: z.string().regex(DOMAIN_REGEX).max(100).optional(),
|
hostname: z.string().max(100).optional(),
|
||||||
language: z.string().max(35).optional(),
|
language: z.string().max(35).optional(),
|
||||||
referrer: urlOrPathParam.optional(),
|
referrer: urlOrPathParam.optional(),
|
||||||
screen: z.string().max(11).optional(),
|
screen: z.string().max(11).optional(),
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
url: urlOrPathParam,
|
url: urlOrPathParam.optional(),
|
||||||
name: z.string().max(50).optional(),
|
name: z.string().max(50).optional(),
|
||||||
tag: z.string().max(50).optional(),
|
tag: z.string().max(50).optional(),
|
||||||
ip: z.string().ip().optional(),
|
ip: z.string().ip().optional(),
|
||||||
userAgent: z.string().optional(),
|
userAgent: z.string().optional(),
|
||||||
|
timestamp: z.coerce.number().int().optional(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -55,6 +58,7 @@ export async function POST(request: Request) {
|
||||||
data,
|
data,
|
||||||
title,
|
title,
|
||||||
tag,
|
tag,
|
||||||
|
timestamp,
|
||||||
} = payload;
|
} = payload;
|
||||||
|
|
||||||
// Cache check
|
// Cache check
|
||||||
|
|
@ -87,7 +91,13 @@ export async function POST(request: Request) {
|
||||||
return forbidden();
|
return forbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = uuid(websiteId, ip, userAgent);
|
const createdAt = timestamp ? new Date(timestamp * 1000) : new Date();
|
||||||
|
const now = Math.floor(new Date().getTime() / 1000);
|
||||||
|
|
||||||
|
const sessionSalt = hash(startOfMonth(createdAt).toUTCString());
|
||||||
|
const visitSalt = hash(startOfHour(createdAt).toUTCString());
|
||||||
|
|
||||||
|
const sessionId = uuid(websiteId, ip, userAgent, sessionSalt);
|
||||||
|
|
||||||
// Find session
|
// Find session
|
||||||
if (!clickhouse.enabled && !cache?.sessionId) {
|
if (!clickhouse.enabled && !cache?.sessionId) {
|
||||||
|
|
@ -119,13 +129,12 @@ export async function POST(request: Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Visit info
|
// Visit info
|
||||||
const now = Math.floor(new Date().getTime() / 1000);
|
let visitId = cache?.visitId || uuid(sessionId, visitSalt);
|
||||||
let visitId = cache?.visitId || uuid(sessionId, visitSalt());
|
|
||||||
let iat = cache?.iat || now;
|
let iat = cache?.iat || now;
|
||||||
|
|
||||||
// Expire visit after 30 minutes
|
// Expire visit after 30 minutes
|
||||||
if (now - iat > 1800) {
|
if (!timestamp && now - iat > 1800) {
|
||||||
visitId = uuid(sessionId, visitSalt());
|
visitId = uuid(sessionId, visitSalt);
|
||||||
iat = now;
|
iat = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,12 +169,12 @@ export async function POST(request: Request) {
|
||||||
websiteId,
|
websiteId,
|
||||||
sessionId,
|
sessionId,
|
||||||
visitId,
|
visitId,
|
||||||
urlPath,
|
urlPath: safeDecodeURI(urlPath),
|
||||||
urlQuery,
|
urlQuery,
|
||||||
referrerPath,
|
referrerPath: safeDecodeURI(referrerPath),
|
||||||
referrerQuery,
|
referrerQuery,
|
||||||
referrerDomain,
|
referrerDomain,
|
||||||
pageTitle: title,
|
pageTitle: safeDecodeURIComponent(title),
|
||||||
eventName: name,
|
eventName: name,
|
||||||
eventData: data,
|
eventData: data,
|
||||||
hostname: hostname || urlDomain,
|
hostname: hostname || urlDomain,
|
||||||
|
|
@ -179,6 +188,7 @@ export async function POST(request: Request) {
|
||||||
subdivision2,
|
subdivision2,
|
||||||
city,
|
city,
|
||||||
tag,
|
tag,
|
||||||
|
createdAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -191,12 +201,13 @@ export async function POST(request: Request) {
|
||||||
websiteId,
|
websiteId,
|
||||||
sessionId,
|
sessionId,
|
||||||
sessionData: data,
|
sessionData: data,
|
||||||
|
createdAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = createToken({ websiteId, sessionId, visitId, iat }, secret());
|
const token = createToken({ websiteId, sessionId, visitId, iat }, secret());
|
||||||
|
|
||||||
return json({ cache: token });
|
return json({ cache: token, sessionId, visitId });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return serverError(e);
|
return serverError(e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { z } from 'zod';
|
import { canDeleteUser, canUpdateUser, canViewUser, hashPassword } from '@/lib/auth';
|
||||||
import { canUpdateUser, canViewUser, canDeleteUser } from '@/lib/auth';
|
|
||||||
import { getUser, getUserByUsername, updateUser, deleteUser } from '@/queries';
|
|
||||||
import { json, unauthorized, badRequest, ok } from '@/lib/response';
|
|
||||||
import { hashPassword } from '@/lib/auth';
|
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
|
import { badRequest, json, ok, unauthorized } from '@/lib/response';
|
||||||
|
import { deleteUser, getUser, getUserByUsername, updateUser } from '@/queries';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
|
export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
|
||||||
const { auth, error } = await parseRequest(request);
|
const { auth, error } = await parseRequest(request);
|
||||||
|
|
@ -26,7 +25,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ user
|
||||||
export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) {
|
export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
username: z.string().max(255),
|
username: z.string().max(255),
|
||||||
password: z.string().max(255),
|
password: z.string().max(255).optional(),
|
||||||
role: z.string().regex(/admin|user|view-only/i),
|
role: z.string().regex(/admin|user|view-only/i),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { createUser, getUserByUsername } from '@/queries';
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
username: z.string().max(255),
|
username: z.string().max(255),
|
||||||
password: z.string(),
|
password: z.string(),
|
||||||
role: z.string().regex(/admin|user|view-only/i),
|
role: z.string().regex(/admin|user|view-only/i),
|
||||||
|
|
@ -23,7 +24,7 @@ export async function POST(request: Request) {
|
||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { username, password, role } = body;
|
const { id, username, password, role } = body;
|
||||||
|
|
||||||
const existingUser = await getUserByUsername(username, { showDeleted: true });
|
const existingUser = await getUserByUsername(username, { showDeleted: true });
|
||||||
|
|
||||||
|
|
@ -32,7 +33,7 @@ export async function POST(request: Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await createUser({
|
const user = await createUser({
|
||||||
id: uuid(),
|
id: id || uuid(),
|
||||||
username,
|
username,
|
||||||
password: hashPassword(password),
|
password: hashPassword(password),
|
||||||
role: role ?? ROLES.user,
|
role: role ?? ROLES.user,
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export default function ({ children }) {
|
||||||
<meta name="theme-color" content="#2f2f2f" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#2f2f2f" media="(prefers-color-scheme: dark)" />
|
||||||
<meta name="robots" content="noindex,nofollow" />
|
<meta name="robots" content="noindex,nofollow" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body suppressHydrationWarning>
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import Logo from '@/assets/logo.svg';
|
||||||
import styles from './LoginForm.module.css';
|
import styles from './LoginForm.module.css';
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels, getMessage } = useMessages();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { post, useMutation } = useApi();
|
const { post, useMutation } = useApi();
|
||||||
const { mutate, error, isPending } = useMutation({
|
const { mutate, error, isPending } = useMutation({
|
||||||
|
|
@ -40,7 +40,7 @@ export function LoginForm() {
|
||||||
<Logo />
|
<Logo />
|
||||||
</Icon>
|
</Icon>
|
||||||
<div className={styles.title}>umami</div>
|
<div className={styles.title}>umami</div>
|
||||||
<Form className={styles.form} onSubmit={handleSubmit} error={error}>
|
<Form className={styles.form} onSubmit={handleSubmit} error={getMessage(error)}>
|
||||||
<FormRow label={formatMessage(labels.username)}>
|
<FormRow label={formatMessage(labels.username)}>
|
||||||
<FormInput
|
<FormInput
|
||||||
data-test="input-username"
|
data-test="input-username"
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,12 @@ export function ConfirmationForm({
|
||||||
<Form error={error}>
|
<Form error={error}>
|
||||||
<p>{message}</p>
|
<p>{message}</p>
|
||||||
<FormButtons flex>
|
<FormButtons flex>
|
||||||
<LoadingButton isLoading={isLoading} onClick={onConfirm} variant={buttonVariant}>
|
<LoadingButton
|
||||||
|
data-test="button-confirm"
|
||||||
|
isLoading={isLoading}
|
||||||
|
onClick={onConfirm}
|
||||||
|
variant={buttonVariant}
|
||||||
|
>
|
||||||
{buttonLabel || formatMessage(labels.ok)}
|
{buttonLabel || formatMessage(labels.ok)}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
|
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,9 @@ export function TypeConfirmationForm({
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={onConfirm} error={error}>
|
<Form onSubmit={onConfirm} error={error}>
|
||||||
<p>
|
<p>
|
||||||
{formatMessage(messages.actionConfirmation, { confirmation: <b>{confirmationValue}</b> })}
|
{formatMessage(messages.actionConfirmation, {
|
||||||
|
confirmation: <b key={messages.actionConfirmation.id}>{confirmationValue}</b>,
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
<FormRow label={formatMessage(labels.confirm)}>
|
<FormRow label={formatMessage(labels.confirm)}>
|
||||||
<FormInput name="confirm" rules={{ validate: value => value === confirmationValue }}>
|
<FormInput name="confirm" rules={{ validate: value => value === confirmationValue }}>
|
||||||
|
|
|
||||||
|
|
@ -69,11 +69,10 @@ export function ReferrersTable({ allowFilter, ...props }: ReferrersTableProps) {
|
||||||
if (!groups[domain]) {
|
if (!groups[domain]) {
|
||||||
groups[domain] = 0;
|
groups[domain] = 0;
|
||||||
}
|
}
|
||||||
groups[domain] += y;
|
groups[domain] += +y;
|
||||||
} else {
|
|
||||||
groups._other += y;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
groups._other += +y;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.keys(groups)
|
return Object.keys(groups)
|
||||||
|
|
|
||||||
|
|
@ -397,6 +397,14 @@ export const PAID_AD_PARAMS = [
|
||||||
'epik=',
|
'epik=',
|
||||||
'ttclid=',
|
'ttclid=',
|
||||||
'scid=',
|
'scid=',
|
||||||
|
'aid=',
|
||||||
|
'pc_id=',
|
||||||
|
'ad_id=',
|
||||||
|
'rdt_cid=',
|
||||||
|
'ob_click_id=',
|
||||||
|
'utm_medium=cpc',
|
||||||
|
'utm_medium=paid',
|
||||||
|
'utm_medium=paid_social',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const GROUPED_DOMAINS = [
|
export const GROUPED_DOMAINS = [
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { startOfHour, startOfMonth } from 'date-fns';
|
|
||||||
import prand from 'pure-rand';
|
import prand from 'pure-rand';
|
||||||
import { v4, v5 } from 'uuid';
|
import { v4, v5 } from 'uuid';
|
||||||
|
|
||||||
|
|
@ -77,20 +76,8 @@ export function secret() {
|
||||||
return hash(process.env.APP_SECRET || process.env.DATABASE_URL);
|
return hash(process.env.APP_SECRET || process.env.DATABASE_URL);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function salt() {
|
|
||||||
const ROTATING_SALT = hash(startOfMonth(new Date()).toUTCString());
|
|
||||||
|
|
||||||
return hash(secret(), ROTATING_SALT);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function visitSalt() {
|
|
||||||
const ROTATING_SALT = hash(startOfHour(new Date()).toUTCString());
|
|
||||||
|
|
||||||
return hash(secret(), ROTATING_SALT);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function uuid(...args: any) {
|
export function uuid(...args: any) {
|
||||||
if (!args.length) return v4();
|
if (!args.length) return v4();
|
||||||
|
|
||||||
return v5(hash(...args, salt()), v5.DNS);
|
return v5(hash(...args, secret()), v5.DNS);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -192,7 +192,9 @@ async function parseFilters(
|
||||||
options: QueryOptions = {},
|
options: QueryOptions = {},
|
||||||
) {
|
) {
|
||||||
const website = await fetchWebsite(websiteId);
|
const website = await fetchWebsite(websiteId);
|
||||||
const joinSession = Object.keys(filters).find(key => SESSION_COLUMNS.includes(key));
|
const joinSession = Object.keys(filters).find(key =>
|
||||||
|
['referrer', ...SESSION_COLUMNS].includes(key),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
joinSession:
|
joinSession:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ZodObject } from 'zod';
|
import { z, ZodSchema } from 'zod';
|
||||||
import { FILTER_COLUMNS } from '@/lib/constants';
|
import { FILTER_COLUMNS } from '@/lib/constants';
|
||||||
import { badRequest, unauthorized } from '@/lib/response';
|
import { badRequest, unauthorized } from '@/lib/response';
|
||||||
import { getAllowedUnits, getMinimumUnit } from '@/lib/date';
|
import { getAllowedUnits, getMinimumUnit } from '@/lib/date';
|
||||||
|
|
@ -15,7 +15,7 @@ export async function getJsonBody(request: Request) {
|
||||||
|
|
||||||
export async function parseRequest(
|
export async function parseRequest(
|
||||||
request: Request,
|
request: Request,
|
||||||
schema?: ZodObject<any>,
|
schema?: ZodSchema,
|
||||||
options?: { skipAuth: boolean },
|
options?: { skipAuth: boolean },
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
@ -24,12 +24,21 @@ export async function parseRequest(
|
||||||
let error: () => void | undefined;
|
let error: () => void | undefined;
|
||||||
let auth = null;
|
let auth = null;
|
||||||
|
|
||||||
|
const getErrorMessages = (error: z.ZodError) => {
|
||||||
|
return Object.entries(error.format())
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const messages = (value as any)._errors;
|
||||||
|
return messages ? `${key}: ${messages.join(', ')}` : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
if (schema) {
|
if (schema) {
|
||||||
const isGet = request.method === 'GET';
|
const isGet = request.method === 'GET';
|
||||||
const result = schema.safeParse(isGet ? query : body);
|
const result = schema.safeParse(isGet ? query : body);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
error = () => badRequest(result.error);
|
error = () => badRequest(getErrorMessages(result.error));
|
||||||
} else if (isGet) {
|
} else if (isGet) {
|
||||||
query = result.data;
|
query = result.data;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ export const unitParam = z.string().refine(value => UNIT_TYPES.includes(value),
|
||||||
|
|
||||||
export const roleParam = z.enum(['team-member', 'team-view-only', 'team-manager']);
|
export const roleParam = z.enum(['team-member', 'team-view-only', 'team-manager']);
|
||||||
|
|
||||||
|
export const anyObjectParam = z.object({}).passthrough();
|
||||||
|
|
||||||
export const urlOrPathParam = z.string().refine(
|
export const urlOrPathParam = z.string().refine(
|
||||||
value => {
|
value => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export async function saveEvent(args: {
|
||||||
subdivision2?: string;
|
subdivision2?: string;
|
||||||
city?: string;
|
city?: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
|
createdAt?: Date;
|
||||||
}) {
|
}) {
|
||||||
return runQuery({
|
return runQuery({
|
||||||
[PRISMA]: () => relationalQuery(args),
|
[PRISMA]: () => relationalQuery(args),
|
||||||
|
|
@ -49,6 +50,7 @@ async function relationalQuery(data: {
|
||||||
eventName?: string;
|
eventName?: string;
|
||||||
eventData?: any;
|
eventData?: any;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
|
createdAt?: Date;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
websiteId,
|
websiteId,
|
||||||
|
|
@ -63,6 +65,7 @@ async function relationalQuery(data: {
|
||||||
eventData,
|
eventData,
|
||||||
pageTitle,
|
pageTitle,
|
||||||
tag,
|
tag,
|
||||||
|
createdAt,
|
||||||
} = data;
|
} = data;
|
||||||
const websiteEventId = uuid();
|
const websiteEventId = uuid();
|
||||||
|
|
||||||
|
|
@ -81,6 +84,7 @@ async function relationalQuery(data: {
|
||||||
eventType: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView,
|
eventType: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView,
|
||||||
eventName: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
|
eventName: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
|
||||||
tag,
|
tag,
|
||||||
|
createdAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -92,6 +96,7 @@ async function relationalQuery(data: {
|
||||||
urlPath: urlPath?.substring(0, URL_LENGTH),
|
urlPath: urlPath?.substring(0, URL_LENGTH),
|
||||||
eventName: eventName?.substring(0, EVENT_NAME_LENGTH),
|
eventName: eventName?.substring(0, EVENT_NAME_LENGTH),
|
||||||
eventData,
|
eventData,
|
||||||
|
createdAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,6 +126,7 @@ async function clickhouseQuery(data: {
|
||||||
subdivision2?: string;
|
subdivision2?: string;
|
||||||
city?: string;
|
city?: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
|
createdAt?: Date;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
websiteId,
|
websiteId,
|
||||||
|
|
@ -139,12 +145,12 @@ async function clickhouseQuery(data: {
|
||||||
subdivision2,
|
subdivision2,
|
||||||
city,
|
city,
|
||||||
tag,
|
tag,
|
||||||
|
createdAt,
|
||||||
...args
|
...args
|
||||||
} = data;
|
} = data;
|
||||||
const { insert, getUTCString } = clickhouse;
|
const { insert, getUTCString } = clickhouse;
|
||||||
const { sendMessage } = kafka;
|
const { sendMessage } = kafka;
|
||||||
const eventId = uuid();
|
const eventId = uuid();
|
||||||
const createdAt = getUTCString();
|
|
||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
...args,
|
...args,
|
||||||
|
|
@ -170,7 +176,7 @@ async function clickhouseQuery(data: {
|
||||||
event_type: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView,
|
event_type: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView,
|
||||||
event_name: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
|
event_name: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
|
||||||
tag: tag,
|
tag: tag,
|
||||||
created_at: createdAt,
|
created_at: getUTCString(createdAt),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (kafka.enabled) {
|
if (kafka.enabled) {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export async function saveEventData(data: {
|
||||||
urlPath?: string;
|
urlPath?: string;
|
||||||
eventName?: string;
|
eventName?: string;
|
||||||
eventData: DynamicData;
|
eventData: DynamicData;
|
||||||
createdAt?: string;
|
createdAt?: Date;
|
||||||
}) {
|
}) {
|
||||||
return runQuery({
|
return runQuery({
|
||||||
[PRISMA]: () => relationalQuery(data),
|
[PRISMA]: () => relationalQuery(data),
|
||||||
|
|
@ -27,8 +27,9 @@ async function relationalQuery(data: {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
eventData: DynamicData;
|
eventData: DynamicData;
|
||||||
|
createdAt?: Date;
|
||||||
}): Promise<Prisma.BatchPayload> {
|
}): Promise<Prisma.BatchPayload> {
|
||||||
const { websiteId, eventId, eventData } = data;
|
const { websiteId, eventId, eventData, createdAt } = data;
|
||||||
|
|
||||||
const jsonKeys = flattenJSON(eventData);
|
const jsonKeys = flattenJSON(eventData);
|
||||||
|
|
||||||
|
|
@ -42,6 +43,7 @@ async function relationalQuery(data: {
|
||||||
numberValue: a.dataType === DATA_TYPE.number ? a.value : null,
|
numberValue: a.dataType === DATA_TYPE.number ? a.value : null,
|
||||||
dateValue: a.dataType === DATA_TYPE.date ? new Date(a.value) : null,
|
dateValue: a.dataType === DATA_TYPE.date ? new Date(a.value) : null,
|
||||||
dataType: a.dataType,
|
dataType: a.dataType,
|
||||||
|
createdAt,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return prisma.client.eventData.createMany({
|
return prisma.client.eventData.createMany({
|
||||||
|
|
@ -56,7 +58,7 @@ async function clickhouseQuery(data: {
|
||||||
urlPath?: string;
|
urlPath?: string;
|
||||||
eventName?: string;
|
eventName?: string;
|
||||||
eventData: DynamicData;
|
eventData: DynamicData;
|
||||||
createdAt?: string;
|
createdAt?: Date;
|
||||||
}) {
|
}) {
|
||||||
const { websiteId, sessionId, eventId, urlPath, eventName, eventData, createdAt } = data;
|
const { websiteId, sessionId, eventId, urlPath, eventName, eventData, createdAt } = data;
|
||||||
|
|
||||||
|
|
@ -77,7 +79,7 @@ async function clickhouseQuery(data: {
|
||||||
string_value: getStringValue(value, dataType),
|
string_value: getStringValue(value, dataType),
|
||||||
number_value: dataType === DATA_TYPE.number ? value : null,
|
number_value: dataType === DATA_TYPE.number ? value : null,
|
||||||
date_value: dataType === DATA_TYPE.date ? getUTCString(value) : null,
|
date_value: dataType === DATA_TYPE.date ? getUTCString(value) : null,
|
||||||
created_at: createdAt,
|
created_at: getUTCString(createdAt),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export async function saveSessionData(data: {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
sessionData: DynamicData;
|
sessionData: DynamicData;
|
||||||
|
createdAt?: Date;
|
||||||
}) {
|
}) {
|
||||||
return runQuery({
|
return runQuery({
|
||||||
[PRISMA]: () => relationalQuery(data),
|
[PRISMA]: () => relationalQuery(data),
|
||||||
|
|
@ -22,9 +23,10 @@ export async function relationalQuery(data: {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
sessionData: DynamicData;
|
sessionData: DynamicData;
|
||||||
|
createdAt?: Date;
|
||||||
}) {
|
}) {
|
||||||
const { client } = prisma;
|
const { client } = prisma;
|
||||||
const { websiteId, sessionId, sessionData } = data;
|
const { websiteId, sessionId, sessionData, createdAt } = data;
|
||||||
|
|
||||||
const jsonKeys = flattenJSON(sessionData);
|
const jsonKeys = flattenJSON(sessionData);
|
||||||
|
|
||||||
|
|
@ -37,6 +39,7 @@ export async function relationalQuery(data: {
|
||||||
numberValue: a.dataType === DATA_TYPE.number ? a.value : null,
|
numberValue: a.dataType === DATA_TYPE.number ? a.value : null,
|
||||||
dateValue: a.dataType === DATA_TYPE.date ? new Date(a.value) : null,
|
dateValue: a.dataType === DATA_TYPE.date ? new Date(a.value) : null,
|
||||||
dataType: a.dataType,
|
dataType: a.dataType,
|
||||||
|
createdAt,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const existing = await client.sessionData.findMany({
|
const existing = await client.sessionData.findMany({
|
||||||
|
|
@ -77,12 +80,12 @@ async function clickhouseQuery(data: {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
sessionData: DynamicData;
|
sessionData: DynamicData;
|
||||||
|
createdAt?: Date;
|
||||||
}) {
|
}) {
|
||||||
const { websiteId, sessionId, sessionData } = data;
|
const { websiteId, sessionId, sessionData, createdAt } = data;
|
||||||
|
|
||||||
const { insert, getUTCString } = clickhouse;
|
const { insert, getUTCString } = clickhouse;
|
||||||
const { sendMessage } = kafka;
|
const { sendMessage } = kafka;
|
||||||
const createdAt = getUTCString();
|
|
||||||
|
|
||||||
const jsonKeys = flattenJSON(sessionData);
|
const jsonKeys = flattenJSON(sessionData);
|
||||||
|
|
||||||
|
|
@ -95,7 +98,7 @@ async function clickhouseQuery(data: {
|
||||||
string_value: getStringValue(value, dataType),
|
string_value: getStringValue(value, dataType),
|
||||||
number_value: dataType === DATA_TYPE.number ? value : null,
|
number_value: dataType === DATA_TYPE.number ? value : null,
|
||||||
date_value: dataType === DATA_TYPE.date ? getUTCString(value) : null,
|
date_value: dataType === DATA_TYPE.date ? getUTCString(value) : null,
|
||||||
created_at: createdAt,
|
created_at: getUTCString(createdAt),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
(window => {
|
(window => {
|
||||||
const {
|
const {
|
||||||
screen: { width, height },
|
screen: { width, height },
|
||||||
navigator: { language },
|
navigator: { language, doNotTrack: ndnt, msDoNotTrack: msdnt },
|
||||||
location,
|
location,
|
||||||
document,
|
document,
|
||||||
history,
|
history,
|
||||||
top,
|
top,
|
||||||
|
doNotTrack,
|
||||||
} = window;
|
} = window;
|
||||||
const { hostname, href, origin } = location;
|
const { hostname, href, origin } = location;
|
||||||
const { currentScript, referrer } = document;
|
const { currentScript, referrer } = document;
|
||||||
|
|
@ -21,6 +22,7 @@
|
||||||
const hostUrl = attr(_data + 'host-url');
|
const hostUrl = attr(_data + 'host-url');
|
||||||
const tag = attr(_data + 'tag');
|
const tag = attr(_data + 'tag');
|
||||||
const autoTrack = attr(_data + 'auto-track') !== _false;
|
const autoTrack = attr(_data + 'auto-track') !== _false;
|
||||||
|
const dnt = attr(_data + 'do-not-track') === _true;
|
||||||
const excludeSearch = attr(_data + 'exclude-search') === _true;
|
const excludeSearch = attr(_data + 'exclude-search') === _true;
|
||||||
const excludeHash = attr(_data + 'exclude-hash') === _true;
|
const excludeHash = attr(_data + 'exclude-hash') === _true;
|
||||||
const domain = attr(_data + 'domains') || '';
|
const domain = attr(_data + 'domains') || '';
|
||||||
|
|
@ -46,6 +48,11 @@
|
||||||
tag: tag ? tag : undefined,
|
tag: tag ? tag : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasDoNotTrack = () => {
|
||||||
|
const dnt = doNotTrack || ndnt || msdnt;
|
||||||
|
return dnt === 1 || dnt === '1' || dnt === 'yes';
|
||||||
|
};
|
||||||
|
|
||||||
/* Event handlers */
|
/* Event handlers */
|
||||||
|
|
||||||
const handlePush = (state, title, url) => {
|
const handlePush = (state, title, url) => {
|
||||||
|
|
@ -182,7 +189,8 @@
|
||||||
disabled ||
|
disabled ||
|
||||||
!website ||
|
!website ||
|
||||||
(localStorage && localStorage.getItem('umami.disabled')) ||
|
(localStorage && localStorage.getItem('umami.disabled')) ||
|
||||||
(domain && !domains.includes(hostname));
|
(domain && !domains.includes(hostname)) ||
|
||||||
|
(dnt && hasDoNotTrack());
|
||||||
|
|
||||||
const send = async (payload, type = 'event') => {
|
const send = async (payload, type = 'event') => {
|
||||||
if (trackingDisabled()) return;
|
if (trackingDisabled()) return;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue