mirror of
https://github.com/umami-software/umami.git
synced 2026-02-18 19:45:35 +01:00
Merge branch 'umami-software:master' into master
This commit is contained in:
commit
d007399e25
47 changed files with 788 additions and 512 deletions
|
|
@ -3,6 +3,7 @@
|
|||
"browser": true,
|
||||
"es2020": true,
|
||||
"node": true,
|
||||
"jquery": true,
|
||||
"jest": true
|
||||
},
|
||||
"parser": "@typescript-eslint/parser",
|
||||
|
|
@ -14,6 +15,7 @@
|
|||
"sourceType": "module"
|
||||
},
|
||||
"extends": [
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"eslint:recommended",
|
||||
"plugin:prettier/recommended",
|
||||
|
|
@ -33,12 +35,14 @@
|
|||
"react/prop-types": "off",
|
||||
"import/no-anonymous-default-export": "off",
|
||||
"import/no-named-as-default": "off",
|
||||
"css-modules/no-unused-class": "off",
|
||||
"@next/next/no-img-element": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-var-requires": "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": {
|
||||
"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', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/login');
|
||||
});
|
||||
|
||||
it(
|
||||
'logs user in with correct credentials and logs user out',
|
||||
{
|
||||
defaultCommandTimeout: 10000,
|
||||
},
|
||||
() => {
|
||||
cy.visit('/login');
|
||||
cy.getDataTest('input-username').find('input').click();
|
||||
cy.getDataTest('input-username').find('input').type(Cypress.env('umami_user'), { delay: 50 });
|
||||
cy.getDataTest('input-password').find('input').click();
|
||||
cy.getDataTest('input-username').find('input').as('inputUsername').click();
|
||||
cy.get('@inputUsername').type(Cypress.env('umami_user'), { delay: 0 });
|
||||
cy.get('@inputUsername').click();
|
||||
cy.getDataTest('input-password')
|
||||
.find('input')
|
||||
.type(Cypress.env('umami_password'), { delay: 50 });
|
||||
.type(Cypress.env('umami_password'), { delay: 0 });
|
||||
cy.getDataTest('button-submit').click();
|
||||
cy.url().should('eq', Cypress.config().baseUrl + '/dashboard');
|
||||
cy.getDataTest('button-profile').click();
|
||||
cy.getDataTest('item-logout').click();
|
||||
cy.url().should('eq', Cypress.config().baseUrl + '/login');
|
||||
cy.logout();
|
||||
},
|
||||
);
|
||||
|
||||
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.getDataTest('button-website-add').click();
|
||||
cy.contains(/Add website/i).should('be.visible');
|
||||
cy.getDataTest('input-name').find('input').click();
|
||||
cy.getDataTest('input-name').find('input').type('Add test', { delay: 50 });
|
||||
cy.getDataTest('input-name').find('input').as('inputUsername').click();
|
||||
cy.getDataTest('input-name').find('input').type('Add test', { delay: 0 });
|
||||
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.get('td[label="Name"]').should('contain.text', 'Add test');
|
||||
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.getDataTest('input-name').find('input').click();
|
||||
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').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('input-name').find('input').should('have.value', 'Updated website');
|
||||
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}]`);
|
||||
});
|
||||
|
||||
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) => {
|
||||
cy.session([username, password], () => {
|
||||
cy.request({
|
||||
|
|
|
|||
6
cypress/support/index.d.ts
vendored
6
cypress/support/index.d.ts
vendored
|
|
@ -1,4 +1,5 @@
|
|||
/// <reference types="cypress" />
|
||||
/* global JQuery */
|
||||
|
||||
declare namespace Cypress {
|
||||
interface Chainable {
|
||||
|
|
@ -7,6 +8,11 @@ declare namespace Cypress {
|
|||
* @example cy.getDataTest('greeting')
|
||||
*/
|
||||
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.
|
||||
* @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 = [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
headers: [
|
||||
{ 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' },
|
||||
],
|
||||
headers: apiHeaders
|
||||
},
|
||||
{
|
||||
source: '/:path*',
|
||||
|
|
@ -89,6 +103,11 @@ if (trackerScriptURL) {
|
|||
}
|
||||
|
||||
if (collectApiEndpoint) {
|
||||
headers.push({
|
||||
source: collectApiEndpoint,
|
||||
headers: apiHeaders,
|
||||
});
|
||||
|
||||
rewrites.push({
|
||||
source: collectApiEndpoint,
|
||||
destination: '/api/send',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "umami",
|
||||
"version": "2.16.0",
|
||||
"version": "2.17.0",
|
||||
"description": "A simple, fast, privacy-focused alternative to Google Analytics.",
|
||||
"author": "Umami Software, Inc. <hello@umami.is>",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -32,13 +32,13 @@
|
|||
"label.add-member": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Add member"
|
||||
"value": "Гишүүн нэмэх"
|
||||
}
|
||||
],
|
||||
"label.add-step": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Add step"
|
||||
"value": "Алхам нэмэх"
|
||||
}
|
||||
],
|
||||
"label.add-website": [
|
||||
|
|
@ -74,7 +74,7 @@
|
|||
"label.analytics": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Analytics"
|
||||
"value": "Аналитик"
|
||||
}
|
||||
],
|
||||
"label.average": [
|
||||
|
|
@ -152,7 +152,7 @@
|
|||
"label.compare": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Compare"
|
||||
"value": "Харьцуулах"
|
||||
}
|
||||
],
|
||||
"label.confirm": [
|
||||
|
|
@ -182,7 +182,7 @@
|
|||
"label.count": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Count"
|
||||
"value": "Тоо"
|
||||
}
|
||||
],
|
||||
"label.countries": [
|
||||
|
|
@ -230,13 +230,13 @@
|
|||
"label.created-by": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Created By"
|
||||
"value": "Үүсгэсэн"
|
||||
}
|
||||
],
|
||||
"label.current": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Current"
|
||||
"value": "Одоогийн"
|
||||
}
|
||||
],
|
||||
"label.current-password": [
|
||||
|
|
@ -296,7 +296,7 @@
|
|||
"label.delete-report": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Delete report"
|
||||
"value": "Тайлан устгах"
|
||||
}
|
||||
],
|
||||
"label.delete-team": [
|
||||
|
|
@ -386,7 +386,7 @@
|
|||
"label.edit-member": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Edit member"
|
||||
"value": "Гишүүн засах"
|
||||
}
|
||||
],
|
||||
"label.enable-share-url": [
|
||||
|
|
@ -398,13 +398,13 @@
|
|||
"label.end-step": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "End Step"
|
||||
"value": "Төгсгөлийн алхам"
|
||||
}
|
||||
],
|
||||
"label.entry": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Entry URL"
|
||||
"value": "Орох зам"
|
||||
}
|
||||
],
|
||||
"label.event": [
|
||||
|
|
@ -428,7 +428,7 @@
|
|||
"label.exit": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Exit URL"
|
||||
"value": "Гарах зам"
|
||||
}
|
||||
],
|
||||
"label.false": [
|
||||
|
|
@ -476,7 +476,7 @@
|
|||
"label.first-seen": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "First seen"
|
||||
"value": "Анх харсан"
|
||||
}
|
||||
],
|
||||
"label.funnel": [
|
||||
|
|
@ -494,19 +494,19 @@
|
|||
"label.goal": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Goal"
|
||||
"value": "Зорилго"
|
||||
}
|
||||
],
|
||||
"label.goals": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Goals"
|
||||
"value": "Зорилго"
|
||||
}
|
||||
],
|
||||
"label.goals-description": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Track your goals for pageviews and events."
|
||||
"value": "Хуудас үзсэн болон үйлдлийн зорилгыг мөрдөх."
|
||||
}
|
||||
],
|
||||
"label.greater-than": [
|
||||
|
|
@ -524,13 +524,13 @@
|
|||
"label.host": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Host"
|
||||
"value": "Хост"
|
||||
}
|
||||
],
|
||||
"label.hosts": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Hosts"
|
||||
"value": "Хост"
|
||||
}
|
||||
],
|
||||
"label.insights": [
|
||||
|
|
@ -584,13 +584,13 @@
|
|||
"label.journey": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Journey"
|
||||
"value": "Аялал"
|
||||
}
|
||||
],
|
||||
"label.journey-description": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Understand how users navigate through your website."
|
||||
"value": "Хэрэглэгчид таны цахим хуудсаар хэрхэн шилжиж явсныг шинжлэх."
|
||||
}
|
||||
],
|
||||
"label.language": [
|
||||
|
|
@ -642,7 +642,7 @@
|
|||
"label.last-months": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Last "
|
||||
"value": "Сүүлийн "
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
|
|
@ -650,13 +650,13 @@
|
|||
},
|
||||
{
|
||||
"type": 0,
|
||||
"value": " months"
|
||||
"value": " сар"
|
||||
}
|
||||
],
|
||||
"label.last-seen": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Last seen"
|
||||
"value": "Сүүлд харагдсан"
|
||||
}
|
||||
],
|
||||
"label.leave": [
|
||||
|
|
@ -698,13 +698,13 @@
|
|||
"label.manage": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Manage"
|
||||
"value": "Удирдах"
|
||||
}
|
||||
],
|
||||
"label.manager": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Manager"
|
||||
"value": "Удирдагч"
|
||||
}
|
||||
],
|
||||
"label.max": [
|
||||
|
|
@ -716,7 +716,7 @@
|
|||
"label.member": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Member"
|
||||
"value": "Гишүүн"
|
||||
}
|
||||
],
|
||||
"label.members": [
|
||||
|
|
@ -746,7 +746,7 @@
|
|||
"label.my-account": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "My account"
|
||||
"value": "Миний бүртгэл"
|
||||
}
|
||||
],
|
||||
"label.my-websites": [
|
||||
|
|
@ -789,7 +789,7 @@
|
|||
"value": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "record"
|
||||
"value": "бичлэг"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -797,7 +797,7 @@
|
|||
"value": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "records"
|
||||
"value": "бичлэг"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -810,7 +810,7 @@
|
|||
"label.ok": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "OK"
|
||||
"value": "ЗА"
|
||||
}
|
||||
],
|
||||
"label.os": [
|
||||
|
|
@ -876,13 +876,13 @@
|
|||
"label.path": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Path"
|
||||
"value": "Зам"
|
||||
}
|
||||
],
|
||||
"label.paths": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Paths"
|
||||
"value": "Зам"
|
||||
}
|
||||
],
|
||||
"label.powered-by": [
|
||||
|
|
@ -898,19 +898,19 @@
|
|||
"label.previous": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Previous"
|
||||
"value": "Өмнөх"
|
||||
}
|
||||
],
|
||||
"label.previous-period": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Previous period"
|
||||
"value": "Өмнөх үе"
|
||||
}
|
||||
],
|
||||
"label.previous-year": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Previous year"
|
||||
"value": "Өмнөх жил"
|
||||
}
|
||||
],
|
||||
"label.profile": [
|
||||
|
|
@ -922,13 +922,13 @@
|
|||
"label.properties": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Properties"
|
||||
"value": "Шинж чанар"
|
||||
}
|
||||
],
|
||||
"label.property": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Property"
|
||||
"value": "Шинж чанар"
|
||||
}
|
||||
],
|
||||
"label.queries": [
|
||||
|
|
@ -1000,7 +1000,7 @@
|
|||
"label.remove-member": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Remove member"
|
||||
"value": "Гишүүн хасах"
|
||||
}
|
||||
],
|
||||
"label.reports": [
|
||||
|
|
@ -1042,19 +1042,19 @@
|
|||
"label.revenue": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Revenue"
|
||||
"value": "Орлого"
|
||||
}
|
||||
],
|
||||
"label.revenue-description": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Look into your revenue across time."
|
||||
"value": "Цаг хугацааны туршид орлогын өөрчлөлтийг харах."
|
||||
}
|
||||
],
|
||||
"label.revenue-property": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Revenue Property"
|
||||
"value": "Орлогын шинж чанар"
|
||||
}
|
||||
],
|
||||
"label.role": [
|
||||
|
|
@ -1090,7 +1090,7 @@
|
|||
"label.select": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Select"
|
||||
"value": "Сонгох"
|
||||
}
|
||||
],
|
||||
"label.select-date": [
|
||||
|
|
@ -1144,13 +1144,13 @@
|
|||
"label.start-step": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Start Step"
|
||||
"value": "Эхлэх алхам"
|
||||
}
|
||||
],
|
||||
"label.steps": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Steps"
|
||||
"value": "Алхам"
|
||||
}
|
||||
],
|
||||
"label.sum": [
|
||||
|
|
@ -1180,7 +1180,7 @@
|
|||
"label.team-manager": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Team manager"
|
||||
"value": "Багийн удирдагч"
|
||||
}
|
||||
],
|
||||
"label.team-member": [
|
||||
|
|
@ -1294,13 +1294,13 @@
|
|||
"label.transfer": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Transfer"
|
||||
"value": "Шилжүүлэх"
|
||||
}
|
||||
],
|
||||
"label.transfer-website": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Transfer website"
|
||||
"value": "Вебийг шилжүүлэх"
|
||||
}
|
||||
],
|
||||
"label.true": [
|
||||
|
|
@ -1330,7 +1330,7 @@
|
|||
"label.uniqueCustomers": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Unique Customers"
|
||||
"value": "Давтагдаагүй зочин"
|
||||
}
|
||||
],
|
||||
"label.unknown": [
|
||||
|
|
@ -1348,7 +1348,7 @@
|
|||
"label.update": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Update"
|
||||
"value": "Шинэчлэх"
|
||||
}
|
||||
],
|
||||
"label.url": [
|
||||
|
|
@ -1360,7 +1360,7 @@
|
|||
"label.urls": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "URLs"
|
||||
"value": "URL-ууд"
|
||||
}
|
||||
],
|
||||
"label.user": [
|
||||
|
|
@ -1372,7 +1372,7 @@
|
|||
"label.user-property": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "User Property"
|
||||
"value": "Хэрэглэгчийн шинж"
|
||||
}
|
||||
],
|
||||
"label.username": [
|
||||
|
|
@ -1396,7 +1396,7 @@
|
|||
"label.utm-description": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Track your campaigns through UTM parameters."
|
||||
"value": "UTM параметраар кампанит ажлаа мөрдөх."
|
||||
}
|
||||
],
|
||||
"label.value": [
|
||||
|
|
@ -1432,7 +1432,7 @@
|
|||
"label.views-per-visit": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Views per visit"
|
||||
"value": "Зочдын хуудас үзсэн тоо"
|
||||
}
|
||||
],
|
||||
"label.visit-duration": [
|
||||
|
|
@ -1450,7 +1450,7 @@
|
|||
"label.visits": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Visits"
|
||||
"value": "Зочилсон"
|
||||
}
|
||||
],
|
||||
"label.website": [
|
||||
|
|
@ -1486,7 +1486,7 @@
|
|||
"message.action-confirmation": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Type "
|
||||
"value": "Доорх хэсэгт "
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
|
|
@ -1494,7 +1494,7 @@
|
|||
},
|
||||
{
|
||||
"type": 0,
|
||||
"value": " in the box below to confirm."
|
||||
"value": " гэж бичин баталгаажуулна уу."
|
||||
}
|
||||
],
|
||||
"message.active-users": [
|
||||
|
|
@ -1542,7 +1542,7 @@
|
|||
"message.collected-data": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Collected data"
|
||||
"value": "Цуглуулсан өгөгдөл"
|
||||
}
|
||||
],
|
||||
"message.confirm-delete": [
|
||||
|
|
@ -1576,7 +1576,7 @@
|
|||
"message.confirm-remove": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Are you sure you want to remove "
|
||||
"value": "Та "
|
||||
},
|
||||
{
|
||||
"type": 1,
|
||||
|
|
@ -1584,7 +1584,7 @@
|
|||
},
|
||||
{
|
||||
"type": 0,
|
||||
"value": "?"
|
||||
"value": "-г устгахдаа итгэлтэй байна уу?"
|
||||
}
|
||||
],
|
||||
"message.confirm-reset": [
|
||||
|
|
@ -1604,7 +1604,7 @@
|
|||
"message.delete-team-warning": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Deleting a team will also delete all team websites."
|
||||
"value": "Баг устгах нь мөн түүнд харъяалагдах вебүүдийг устгах болно."
|
||||
}
|
||||
],
|
||||
"message.delete-website-warning": [
|
||||
|
|
@ -1806,25 +1806,25 @@
|
|||
"message.transfer-team-website-to-user": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Transfer this website to your account?"
|
||||
"value": "Энэ вебийг өөрийн бүртгэл рүү шилжүүлэх үү?"
|
||||
}
|
||||
],
|
||||
"message.transfer-user-website-to-team": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Select the team to transfer this website to."
|
||||
"value": "Энэ вебийг шилжүүлж авах багийг сонгоно уу."
|
||||
}
|
||||
],
|
||||
"message.transfer-website": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Transfer website ownership to your account or another team."
|
||||
"value": "Энэ вебийг өөрийн бүртгэл рүү эсвэл багт шилжүүлж авах."
|
||||
}
|
||||
],
|
||||
"message.triggered-event": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Triggered event"
|
||||
"value": "Өдөөсөн үйлдэл"
|
||||
}
|
||||
],
|
||||
"message.user-deleted": [
|
||||
|
|
@ -1836,7 +1836,7 @@
|
|||
"message.viewed-page": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Viewed page"
|
||||
"value": "Үзсэн хуудас"
|
||||
}
|
||||
],
|
||||
"message.visitor-log": [
|
||||
|
|
@ -1876,7 +1876,7 @@
|
|||
"message.visitors-dropped-off": [
|
||||
{
|
||||
"type": 0,
|
||||
"value": "Visitors dropped off"
|
||||
"value": "Зочдын уналт"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,9 +82,11 @@ async function checkV1Tables() {
|
|||
}
|
||||
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,9 @@ export function ReportDeleteButton({
|
|||
<Modal title={formatMessage(labels.deleteReport)}>
|
||||
{(close: () => void) => (
|
||||
<ConfirmationForm
|
||||
message={formatMessage(messages.confirmDelete, { target: <b>{reportName}</b> })}
|
||||
message={formatMessage(messages.confirmDelete, {
|
||||
target: <b key={messages.confirmDelete.id}>{reportName}</b>,
|
||||
})}
|
||||
isLoading={isPending}
|
||||
error={error}
|
||||
onConfirm={handleConfirm.bind(null, close)}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ export function TeamLeaveForm({
|
|||
return (
|
||||
<ConfirmationForm
|
||||
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}
|
||||
onClose={onClose}
|
||||
isLoading={isPending}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export function UserAddButton({ onSave }: { onSave?: () => void }) {
|
|||
|
||||
return (
|
||||
<ModalTrigger>
|
||||
<Button variant="primary">
|
||||
<Button data-test="button-create-user" variant="primary">
|
||||
<Icon>
|
||||
<Icons.Plus />
|
||||
</Icon>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from 'react-basics';
|
||||
import { useApi, useMessages } from '@/components/hooks';
|
||||
import { ROLES } from '@/lib/constants';
|
||||
import { messages } from '@/components/messages';
|
||||
|
||||
export function UserAddForm({ onSave, onClose }) {
|
||||
const { post, useMutation } = useApi();
|
||||
|
|
@ -44,26 +45,43 @@ export function UserAddForm({ onSave, onClose }) {
|
|||
return (
|
||||
<Form onSubmit={handleSubmit} error={error}>
|
||||
<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" />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<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" />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormRow label={formatMessage(labels.role)}>
|
||||
<FormInput name="role" rules={{ required: formatMessage(labels.required) }}>
|
||||
<Dropdown renderValue={renderValue}>
|
||||
<Item key={ROLES.viewOnly}>{formatMessage(labels.viewOnly)}</Item>
|
||||
<Item key={ROLES.user}>{formatMessage(labels.user)}</Item>
|
||||
<Item key={ROLES.admin}>{formatMessage(labels.admin)}</Item>
|
||||
<Dropdown data-test="dropdown-role" renderValue={renderValue}>
|
||||
<Item data-test="dropdown-item-viewOnly" key={ROLES.viewOnly}>
|
||||
{formatMessage(labels.viewOnly)}
|
||||
</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>
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormButtons flex>
|
||||
<SubmitButton variant="primary" disabled={false}>
|
||||
<SubmitButton data-test="button-submit" variant="primary" disabled={false}>
|
||||
{formatMessage(labels.save)}
|
||||
</SubmitButton>
|
||||
<Button disabled={isPending} onClick={onClose}>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export function UserDeleteButton({
|
|||
|
||||
return (
|
||||
<ModalTrigger disabled={userId === user?.id}>
|
||||
<Button disabled={userId === user?.id} variant="quiet">
|
||||
<Button data-test="button-delete" disabled={userId === user?.id} variant="quiet">
|
||||
<Icon>
|
||||
<Icons.Trash />
|
||||
</Icon>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ export function UserDeleteForm({ userId, username, onSave, onClose }) {
|
|||
|
||||
return (
|
||||
<ConfirmationForm
|
||||
message={formatMessage(messages.confirmDelete, { target: <b>{username}</b> })}
|
||||
message={formatMessage(messages.confirmDelete, {
|
||||
target: <b key={messages.confirmDelete.id}>{username}</b>,
|
||||
})}
|
||||
onConfirm={handleConfirm}
|
||||
onClose={onClose}
|
||||
buttonLabel={formatMessage(labels.delete)}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export function UsersTable({
|
|||
<>
|
||||
<UserDeleteButton userId={id} username={username} />
|
||||
<LinkButton href={`/settings/users/${id}`}>
|
||||
<Icon>
|
||||
<Icon data-test="link-button-edit">
|
||||
<Icons.Edit />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.edit)}</Text>
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () =
|
|||
</FormRow>
|
||||
<FormRow label={formatMessage(labels.password)}>
|
||||
<FormInput
|
||||
data-test="input-password"
|
||||
name="password"
|
||||
rules={{
|
||||
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 && (
|
||||
<FormRow label={formatMessage(labels.role)}>
|
||||
<FormInput name="role" rules={{ required: formatMessage(labels.required) }}>
|
||||
<Dropdown renderValue={renderValue}>
|
||||
<Item key={ROLES.viewOnly}>{formatMessage(labels.viewOnly)}</Item>
|
||||
<Item key={ROLES.user}>{formatMessage(labels.user)}</Item>
|
||||
<Item key={ROLES.admin}>{formatMessage(labels.admin)}</Item>
|
||||
<Dropdown data-test="dropdown-role" renderValue={renderValue}>
|
||||
<Item data-test="dropdown-item-viewOnly" key={ROLES.viewOnly}>
|
||||
{formatMessage(labels.viewOnly)}
|
||||
</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>
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
)}
|
||||
<FormButtons>
|
||||
<SubmitButton variant="primary">{formatMessage(labels.save)}</SubmitButton>
|
||||
<SubmitButton data-test="button-submit" variant="primary">
|
||||
{formatMessage(labels.save)}
|
||||
</SubmitButton>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -43,7 +43,9 @@ export function TeamMemberRemoveButton({
|
|||
<Modal title={formatMessage(labels.removeMember)}>
|
||||
{(close: () => void) => (
|
||||
<ConfirmationForm
|
||||
message={formatMessage(messages.confirmRemove, { target: <b>{userName}</b> })}
|
||||
message={formatMessage(messages.confirmRemove, {
|
||||
target: <b key={messages.confirmRemove.id}>{userName}</b>,
|
||||
})}
|
||||
isLoading={isPending}
|
||||
error={error}
|
||||
onConfirm={handleConfirm.bind(null, close)}
|
||||
|
|
|
|||
|
|
@ -71,9 +71,10 @@ export function RealtimeLog({ data }: { data: RealtimeData }) {
|
|||
|
||||
if (__type === TYPE_EVENT) {
|
||||
return formatMessage(messages.eventLog, {
|
||||
event: <b>{eventName || formatMessage(labels.unknown)}</b>,
|
||||
event: <b key="b">{eventName || formatMessage(labels.unknown)}</b>,
|
||||
url: (
|
||||
<a
|
||||
key="a"
|
||||
href={`//${website?.domain}${url}`}
|
||||
className={styles.link}
|
||||
target="_blank"
|
||||
|
|
@ -100,10 +101,10 @@ export function RealtimeLog({ data }: { data: RealtimeData }) {
|
|||
|
||||
if (__type === TYPE_SESSION) {
|
||||
return formatMessage(messages.visitorLog, {
|
||||
country: <b>{countryNames[country] || formatMessage(labels.unknown)}</b>,
|
||||
browser: <b>{BROWSERS[browser]}</b>,
|
||||
os: <b>{OS_NAMES[os] || os}</b>,
|
||||
device: <b>{formatMessage(labels[device] || labels.unknown)}</b>,
|
||||
country: <b key="country">{countryNames[country] || formatMessage(labels.unknown)}</b>,
|
||||
browser: <b key="browser">{BROWSERS[browser]}</b>,
|
||||
os: <b key="os">{OS_NAMES[os] || os}</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}>
|
||||
{format(getDayOfWeekAsDate(index), 'EEE', { locale: dateLocale })}
|
||||
</div>
|
||||
{day?.map((hour: number) => {
|
||||
{day?.map((hour: number, j) => {
|
||||
const pct = hour / max;
|
||||
return (
|
||||
<div key={hour} className={classNames(styles.cell)}>
|
||||
<div key={j} className={classNames(styles.cell)}>
|
||||
{hour > 0 && (
|
||||
<TooltipPopup
|
||||
label={`${formatMessage(labels.visitors)}: ${hour}`}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export async function POST(request: Request) {
|
|||
const user = await getUserByUsername(username, { includePassword: true });
|
||||
|
||||
if (!user || !checkPassword(password, user.password)) {
|
||||
return unauthorized();
|
||||
return unauthorized('message.incorrect-username-password');
|
||||
}
|
||||
|
||||
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({
|
||||
...reportParms,
|
||||
steps: z.coerce.number().min(3).max(7),
|
||||
startStep: z.string(),
|
||||
endStep: z.string(),
|
||||
startStep: z.string().optional(),
|
||||
endStep: z.string().optional(),
|
||||
});
|
||||
|
||||
const { auth, body, error } = await parseRequest(request, schema);
|
||||
|
|
|
|||
|
|
@ -1,31 +1,34 @@
|
|||
import { z } from 'zod';
|
||||
import { isbot } from 'isbot';
|
||||
import { createToken, parseToken } from '@/lib/jwt';
|
||||
import { startOfHour, startOfMonth } from 'date-fns';
|
||||
import clickhouse from '@/lib/clickhouse';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { badRequest, json, forbidden, serverError } from '@/lib/response';
|
||||
import { fetchSession, fetchWebsite } from '@/lib/load';
|
||||
import { getClientInfo, hasBlockedIp } from '@/lib/detect';
|
||||
import { secret, uuid, visitSalt } from '@/lib/crypto';
|
||||
import { COLLECTION_TYPE, DOMAIN_REGEX } from '@/lib/constants';
|
||||
import { createToken, parseToken } from '@/lib/jwt';
|
||||
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 { urlOrPathParam } from '@/lib/schema';
|
||||
|
||||
const schema = z.object({
|
||||
type: z.enum(['event', 'identify']),
|
||||
payload: z.object({
|
||||
website: z.string().uuid(),
|
||||
data: z.object({}).passthrough().optional(),
|
||||
hostname: z.string().regex(DOMAIN_REGEX).max(100).optional(),
|
||||
data: anyObjectParam.optional(),
|
||||
hostname: z.string().max(100).optional(),
|
||||
language: z.string().max(35).optional(),
|
||||
referrer: urlOrPathParam.optional(),
|
||||
screen: z.string().max(11).optional(),
|
||||
title: z.string().optional(),
|
||||
url: urlOrPathParam,
|
||||
url: urlOrPathParam.optional(),
|
||||
name: z.string().max(50).optional(),
|
||||
tag: z.string().max(50).optional(),
|
||||
ip: z.string().ip().optional(),
|
||||
userAgent: z.string().optional(),
|
||||
timestamp: z.coerce.number().int().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -55,6 +58,7 @@ export async function POST(request: Request) {
|
|||
data,
|
||||
title,
|
||||
tag,
|
||||
timestamp,
|
||||
} = payload;
|
||||
|
||||
// Cache check
|
||||
|
|
@ -87,7 +91,13 @@ export async function POST(request: Request) {
|
|||
return forbidden();
|
||||
}
|
||||
|
||||
const sessionId = uuid(websiteId, hostname, 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
|
||||
if (!clickhouse.enabled && !cache?.sessionId) {
|
||||
|
|
@ -119,13 +129,12 @@ export async function POST(request: Request) {
|
|||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Expire visit after 30 minutes
|
||||
if (now - iat > 1800) {
|
||||
visitId = uuid(sessionId, visitSalt());
|
||||
if (!timestamp && now - iat > 1800) {
|
||||
visitId = uuid(sessionId, visitSalt);
|
||||
iat = now;
|
||||
}
|
||||
|
||||
|
|
@ -160,12 +169,12 @@ export async function POST(request: Request) {
|
|||
websiteId,
|
||||
sessionId,
|
||||
visitId,
|
||||
urlPath,
|
||||
urlPath: safeDecodeURI(urlPath),
|
||||
urlQuery,
|
||||
referrerPath,
|
||||
referrerPath: safeDecodeURI(referrerPath),
|
||||
referrerQuery,
|
||||
referrerDomain,
|
||||
pageTitle: title,
|
||||
pageTitle: safeDecodeURIComponent(title),
|
||||
eventName: name,
|
||||
eventData: data,
|
||||
hostname: hostname || urlDomain,
|
||||
|
|
@ -179,6 +188,7 @@ export async function POST(request: Request) {
|
|||
subdivision2,
|
||||
city,
|
||||
tag,
|
||||
createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -191,12 +201,13 @@ export async function POST(request: Request) {
|
|||
websiteId,
|
||||
sessionId,
|
||||
sessionData: data,
|
||||
createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
const token = createToken({ websiteId, sessionId, visitId, iat }, secret());
|
||||
|
||||
return json({ cache: token });
|
||||
return json({ cache: token, sessionId, visitId });
|
||||
} catch (e) {
|
||||
return serverError(e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { z } from 'zod';
|
||||
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 { canDeleteUser, canUpdateUser, canViewUser, hashPassword } from '@/lib/auth';
|
||||
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 }> }) {
|
||||
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 }> }) {
|
||||
const schema = z.object({
|
||||
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),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import { createUser, getUserByUsername } from '@/queries';
|
|||
|
||||
export async function POST(request: Request) {
|
||||
const schema = z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
username: z.string().max(255),
|
||||
password: z.string(),
|
||||
id: z.string().uuid(),
|
||||
role: z.string().regex(/admin|user|view-only/i),
|
||||
});
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ export async function POST(request: Request) {
|
|||
return unauthorized();
|
||||
}
|
||||
|
||||
const { username, password, role, id } = body;
|
||||
const { id, username, password, role } = body;
|
||||
|
||||
const existingUser = await getUserByUsername(username, { showDeleted: true });
|
||||
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ function getChannels(data: { domain: string; query: string; visitors: number }[]
|
|||
|
||||
const match = (value: string) => {
|
||||
return (str: string | RegExp) => {
|
||||
return typeof str === 'string' ? value.includes(str) : (str as RegExp).test(value);
|
||||
return typeof str === 'string' ? value?.includes(str) : (str as RegExp).test(value);
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export default function ({ children }) {
|
|||
<meta name="theme-color" content="#2f2f2f" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
</head>
|
||||
<body>
|
||||
<body suppressHydrationWarning>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import Logo from '@/assets/logo.svg';
|
|||
import styles from './LoginForm.module.css';
|
||||
|
||||
export function LoginForm() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { formatMessage, labels, getMessage } = useMessages();
|
||||
const router = useRouter();
|
||||
const { post, useMutation } = useApi();
|
||||
const { mutate, error, isPending } = useMutation({
|
||||
|
|
@ -40,7 +40,7 @@ export function LoginForm() {
|
|||
<Logo />
|
||||
</Icon>
|
||||
<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)}>
|
||||
<FormInput
|
||||
data-test="input-username"
|
||||
|
|
|
|||
|
|
@ -27,7 +27,12 @@ export function ConfirmationForm({
|
|||
<Form error={error}>
|
||||
<p>{message}</p>
|
||||
<FormButtons flex>
|
||||
<LoadingButton isLoading={isLoading} onClick={onConfirm} variant={buttonVariant}>
|
||||
<LoadingButton
|
||||
data-test="button-confirm"
|
||||
isLoading={isLoading}
|
||||
onClick={onConfirm}
|
||||
variant={buttonVariant}
|
||||
>
|
||||
{buttonLabel || formatMessage(labels.ok)}
|
||||
</LoadingButton>
|
||||
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@ export function TypeConfirmationForm({
|
|||
return (
|
||||
<Form onSubmit={onConfirm} error={error}>
|
||||
<p>
|
||||
{formatMessage(messages.actionConfirmation, { confirmation: <b>{confirmationValue}</b> })}
|
||||
{formatMessage(messages.actionConfirmation, {
|
||||
confirmation: <b key={messages.actionConfirmation.id}>{confirmationValue}</b>,
|
||||
})}
|
||||
</p>
|
||||
<FormRow label={formatMessage(labels.confirm)}>
|
||||
<FormInput name="confirm" rules={{ validate: value => value === confirmationValue }}>
|
||||
|
|
|
|||
|
|
@ -69,11 +69,10 @@ export function ReferrersTable({ allowFilter, ...props }: ReferrersTableProps) {
|
|||
if (!groups[domain]) {
|
||||
groups[domain] = 0;
|
||||
}
|
||||
groups[domain] += y;
|
||||
} else {
|
||||
groups._other += y;
|
||||
groups[domain] += +y;
|
||||
}
|
||||
}
|
||||
groups._other += +y;
|
||||
}
|
||||
|
||||
return Object.keys(groups)
|
||||
|
|
|
|||
|
|
@ -1,145 +1,145 @@
|
|||
{
|
||||
"label.access-code": "Access code",
|
||||
"label.access-code": "Kode akses",
|
||||
"label.actions": "Aksi",
|
||||
"label.activity": "Activity log",
|
||||
"label.add": "Add",
|
||||
"label.add-description": "Add description",
|
||||
"label.add-member": "Add member",
|
||||
"label.add-step": "Add step",
|
||||
"label.activity": "Catatan aktivitas",
|
||||
"label.add": "Tambah",
|
||||
"label.add-description": "Tambah deskripsi",
|
||||
"label.add-member": "Tambah anggota",
|
||||
"label.add-step": "Tambah langkah",
|
||||
"label.add-website": "Tambah situs web",
|
||||
"label.admin": "Pengelola",
|
||||
"label.after": "After",
|
||||
"label.after": "Setelah",
|
||||
"label.all": "Semua",
|
||||
"label.all-time": "Semua waktu",
|
||||
"label.analytics": "Analytics",
|
||||
"label.average": "Average",
|
||||
"label.analytics": "Analitik",
|
||||
"label.average": "Rata-rata",
|
||||
"label.back": "Kembali",
|
||||
"label.before": "Before",
|
||||
"label.before": "Sebelum",
|
||||
"label.bounce-rate": "Rasio pentalan",
|
||||
"label.breakdown": "Breakdown",
|
||||
"label.browser": "Browser",
|
||||
"label.breakdown": "Rincian",
|
||||
"label.browser": "Peramban",
|
||||
"label.browsers": "Peramban",
|
||||
"label.cancel": "Batal",
|
||||
"label.change-password": "Ganti kata sandi",
|
||||
"label.cities": "Cities",
|
||||
"label.city": "City",
|
||||
"label.clear-all": "Clear all",
|
||||
"label.compare": "Compare",
|
||||
"label.confirm": "Confirm",
|
||||
"label.cities": "Kota",
|
||||
"label.city": "Kota",
|
||||
"label.clear-all": "Hapus semua",
|
||||
"label.compare": "Bandingkan",
|
||||
"label.confirm": "Konfirmasi",
|
||||
"label.confirm-password": "Konfirmasi kata sandi",
|
||||
"label.contains": "Contains",
|
||||
"label.continue": "Continue",
|
||||
"label.count": "Count",
|
||||
"label.contains": "Mengandung",
|
||||
"label.continue": "Lanjutkan",
|
||||
"label.count": "Jumlah",
|
||||
"label.countries": "Negara",
|
||||
"label.country": "Country",
|
||||
"label.create": "Create",
|
||||
"label.create-report": "Create report",
|
||||
"label.create-team": "Create team",
|
||||
"label.create-user": "Create user",
|
||||
"label.created": "Created",
|
||||
"label.created-by": "Created By",
|
||||
"label.current": "Current",
|
||||
"label.country": "Negara",
|
||||
"label.create": "Buat",
|
||||
"label.create-report": "Buat laporan",
|
||||
"label.create-team": "Buat tim",
|
||||
"label.create-user": "Buat pengguna",
|
||||
"label.created": "Dibuat",
|
||||
"label.created-by": "Dibuat oleh",
|
||||
"label.current": "Saat ini",
|
||||
"label.current-password": "Kata sandi sekarang",
|
||||
"label.custom-range": "Rentang khusus",
|
||||
"label.dashboard": "Dasbor",
|
||||
"label.data": "Data",
|
||||
"label.date": "Date",
|
||||
"label.date": "Tanggal",
|
||||
"label.date-range": "Rentang tanggal",
|
||||
"label.day": "Day",
|
||||
"label.day": "Hari",
|
||||
"label.default-date-range": "Rentang tanggal bawaan",
|
||||
"label.delete": "Hapus",
|
||||
"label.delete-report": "Delete report",
|
||||
"label.delete-team": "Delete team",
|
||||
"label.delete-user": "Delete user",
|
||||
"label.delete-report": "Hapus laporan",
|
||||
"label.delete-team": "Hapus tim",
|
||||
"label.delete-user": "Hapus pengguna",
|
||||
"label.delete-website": "Hapus situs web",
|
||||
"label.description": "Description",
|
||||
"label.description": "Deskripsi",
|
||||
"label.desktop": "Desktop",
|
||||
"label.details": "Details",
|
||||
"label.device": "Device",
|
||||
"label.details": "Detail",
|
||||
"label.device": "Perangkat",
|
||||
"label.devices": "Perangkat",
|
||||
"label.dismiss": "Tutup",
|
||||
"label.does-not-contain": "Does not contain",
|
||||
"label.does-not-contain": "Tidak mengandung",
|
||||
"label.domain": "Domain",
|
||||
"label.dropoff": "Dropoff",
|
||||
"label.dropoff": "Penurunan",
|
||||
"label.edit": "Sunting",
|
||||
"label.edit-dashboard": "Edit dashboard",
|
||||
"label.edit-member": "Edit member",
|
||||
"label.edit-dashboard": "Sunting dasbor",
|
||||
"label.edit-member": "Sunting anggota",
|
||||
"label.enable-share-url": "Aktifkan URL berbagi",
|
||||
"label.end-step": "End Step",
|
||||
"label.entry": "Entry URL",
|
||||
"label.event": "Event",
|
||||
"label.event-data": "Event data",
|
||||
"label.events": "Perihal",
|
||||
"label.end-step": "Langkah akhir",
|
||||
"label.entry": "URL masuk",
|
||||
"label.event": "Peristiwa",
|
||||
"label.event-data": "Data peristiwa",
|
||||
"label.events": "Peristiwa",
|
||||
"label.exit": "Exit URL",
|
||||
"label.false": "False",
|
||||
"label.field": "Field",
|
||||
"label.fields": "Fields",
|
||||
"label.false": "Salah",
|
||||
"label.field": "Kolom",
|
||||
"label.fields": "Kolom",
|
||||
"label.filter": "Filter",
|
||||
"label.filter-combined": "Gabungan",
|
||||
"label.filter-raw": "Mentah",
|
||||
"label.filters": "Filters",
|
||||
"label.first-seen": "First seen",
|
||||
"label.first-seen": "Pertama kali dilihat",
|
||||
"label.funnel": "Funnel",
|
||||
"label.funnel-description": "Understand the conversion and drop-off rate of users.",
|
||||
"label.goal": "Goal",
|
||||
"label.goals": "Goals",
|
||||
"label.goals-description": "Track your goals for pageviews and events.",
|
||||
"label.greater-than": "Greater than",
|
||||
"label.greater-than-equals": "Greater than or equals",
|
||||
"label.funnel-description": "Pahami tingkat konversi dan penurunan pengguna.",
|
||||
"label.goal": "Tujuan",
|
||||
"label.goals": "Tujuan",
|
||||
"label.goals-description": "Lacak tujuan Anda untuk tampilan halaman dan peristiwa.",
|
||||
"label.greater-than": "Lebih dari",
|
||||
"label.greater-than-equals": "Lebih dari atau sama dengan",
|
||||
"label.host": "Host",
|
||||
"label.hosts": "Hosts",
|
||||
"label.insights": "Insights",
|
||||
"label.insights-description": "Dive deeper into your data by using segments and filters.",
|
||||
"label.is": "Is",
|
||||
"label.is-not": "Is not",
|
||||
"label.is-not-set": "Is not set",
|
||||
"label.is-set": "Is set",
|
||||
"label.join": "Join",
|
||||
"label.join-team": "Join team",
|
||||
"label.journey": "Journey",
|
||||
"label.journey-description": "Understand how users navigate through your website.",
|
||||
"label.insights": "Wawasan",
|
||||
"label.insights-description": "Jelajahi data Anda lebih dalam dengan menggunakan segmen dan filter.",
|
||||
"label.is": "Adalah",
|
||||
"label.is-not": "Bukan",
|
||||
"label.is-not-set": "Tidak diatur",
|
||||
"label.is-set": "Diatur",
|
||||
"label.join": "Gabung",
|
||||
"label.join-team": "Gabung tim",
|
||||
"label.journey": "Perjalanan",
|
||||
"label.journey-description": "Pahami bagaimana pengguna menavigasi situs web Anda.",
|
||||
"label.language": "Bahasa",
|
||||
"label.languages": "Bahasa",
|
||||
"label.laptop": "Laptop",
|
||||
"label.last-days": "{x} hari terakhir",
|
||||
"label.last-hours": "{x} jam terakhir",
|
||||
"label.last-months": "Last {x} months",
|
||||
"label.last-seen": "Last seen",
|
||||
"label.leave": "Leave",
|
||||
"label.leave-team": "Leave team",
|
||||
"label.less-than": "Less than",
|
||||
"label.less-than-equals": "Less than or equals",
|
||||
"label.last-months": "{x} bulan terakhir",
|
||||
"label.last-seen": "Terakhir kali dilihat",
|
||||
"label.leave": "Keluar",
|
||||
"label.leave-team": "Keluar dari tim",
|
||||
"label.less-than": "Kurang dari",
|
||||
"label.less-than-equals": "Kurang dari atau sama dengan",
|
||||
"label.login": "Masuk",
|
||||
"label.logout": "Keluar",
|
||||
"label.manage": "Manage",
|
||||
"label.manager": "Manager",
|
||||
"label.max": "Max",
|
||||
"label.member": "Member",
|
||||
"label.members": "Members",
|
||||
"label.manage": "Kelola",
|
||||
"label.manager": "Pengelola",
|
||||
"label.max": "Maks",
|
||||
"label.member": "Anggota",
|
||||
"label.members": "Anggota",
|
||||
"label.min": "Min",
|
||||
"label.mobile": "Ponsel",
|
||||
"label.more": "Lebih banyak",
|
||||
"label.my-account": "My account",
|
||||
"label.my-websites": "My websites",
|
||||
"label.my-account": "Akun saya",
|
||||
"label.my-websites": "Situs web saya",
|
||||
"label.name": "Nama",
|
||||
"label.new-password": "Kata sandi baru",
|
||||
"label.none": "None",
|
||||
"label.none": "Tidak ada",
|
||||
"label.number-of-records": "{x} {x, plural, one {record} other {records}}",
|
||||
"label.ok": "OK",
|
||||
"label.os": "OS",
|
||||
"label.overview": "Overview",
|
||||
"label.overview": "Tinjauan umum",
|
||||
"label.owner": "Pemilik",
|
||||
"label.page-of": "Page {current} of {total}",
|
||||
"label.page-of": "Halaman {current} dari {total}",
|
||||
"label.page-views": "Tampilan halaman",
|
||||
"label.pageTitle": "Page title",
|
||||
"label.pageTitle": "Judul halaman",
|
||||
"label.pages": "Halaman",
|
||||
"label.password": "Kata sandi",
|
||||
"label.path": "Path",
|
||||
"label.paths": "Paths",
|
||||
"label.powered-by": "Didukung oleh {name}",
|
||||
"label.previous": "Previous",
|
||||
"label.previous-period": "Previous period",
|
||||
"label.previous-year": "Previous year",
|
||||
"label.previous": "Sebelumnya",
|
||||
"label.previous-period": "Periode sebelumnya",
|
||||
"label.previous-year": "Tahun lalu",
|
||||
"label.profile": "Profil",
|
||||
"label.properties": "Properties",
|
||||
"label.property": "Property",
|
||||
|
|
@ -147,133 +147,133 @@
|
|||
"label.query": "Query",
|
||||
"label.query-parameters": "Query parameters",
|
||||
"label.realtime": "Waktu nyata",
|
||||
"label.referrer": "Referrer",
|
||||
"label.referrer": "Perujuk",
|
||||
"label.referrers": "Perujuk",
|
||||
"label.refresh": "Segarkan",
|
||||
"label.regenerate": "Regenerate",
|
||||
"label.region": "Region",
|
||||
"label.regions": "Regions",
|
||||
"label.remove": "Remove",
|
||||
"label.remove-member": "Remove member",
|
||||
"label.reports": "Reports",
|
||||
"label.regenerate": "Buat ulang",
|
||||
"label.region": "Wilayah",
|
||||
"label.regions": "Wilayah",
|
||||
"label.remove": "Hapus",
|
||||
"label.remove-member": "Hapus anggota",
|
||||
"label.reports": "Laporan",
|
||||
"label.required": "Wajib",
|
||||
"label.reset": "Atur ulang",
|
||||
"label.reset-website": "Atur ulang statistik",
|
||||
"label.retention": "Retention",
|
||||
"label.retention-description": "Measure your website stickiness by tracking how often users return.",
|
||||
"label.revenue": "Revenue",
|
||||
"label.revenue-description": "Look into your revenue across time.",
|
||||
"label.revenue-property": "Revenue Property",
|
||||
"label.retention": "Retensi",
|
||||
"label.retention-description": "Ukur daya tarik situs web Anda dengan melacak seberapa sering pengguna kembali.",
|
||||
"label.revenue": "Pendapatan",
|
||||
"label.revenue-description": "Lihat pendapatan Anda seiring waktu.",
|
||||
"label.revenue-property": "Properti pendapatan",
|
||||
"label.role": "Role",
|
||||
"label.run-query": "Run query",
|
||||
"label.save": "Simpan",
|
||||
"label.screens": "Layar",
|
||||
"label.search": "Search",
|
||||
"label.select": "Select",
|
||||
"label.select-date": "Select date",
|
||||
"label.select-role": "Select role",
|
||||
"label.select-website": "Select website",
|
||||
"label.session": "Session",
|
||||
"label.sessions": "Sessions",
|
||||
"label.search": "Cari",
|
||||
"label.select": "Pilih",
|
||||
"label.select-date": "Pilih tanggal",
|
||||
"label.select-role": "Pilih role",
|
||||
"label.select-website": "Pilih situs web",
|
||||
"label.session": "Sesi",
|
||||
"label.sessions": "Sesi",
|
||||
"label.settings": "Pengaturan",
|
||||
"label.share-url": "Bagikan URL",
|
||||
"label.single-day": "Sehari",
|
||||
"label.start-step": "Start Step",
|
||||
"label.steps": "Steps",
|
||||
"label.start-step": "Langkah awal",
|
||||
"label.steps": "Langkah",
|
||||
"label.sum": "Sum",
|
||||
"label.tablet": "Tablet",
|
||||
"label.team": "Team",
|
||||
"label.team-id": "Team ID",
|
||||
"label.team-manager": "Team manager",
|
||||
"label.team-member": "Team member",
|
||||
"label.team-name": "Team name",
|
||||
"label.team-owner": "Team owner",
|
||||
"label.team": "Tim",
|
||||
"label.team-id": "ID tim",
|
||||
"label.team-manager": "Pengelola tim",
|
||||
"label.team-member": "Anggota tim",
|
||||
"label.team-name": "Nama tim",
|
||||
"label.team-owner": "Pemilik tim",
|
||||
"label.team-view-only": "Team view only",
|
||||
"label.team-websites": "Team websites",
|
||||
"label.teams": "Teams",
|
||||
"label.team-websites": "Situs web tim",
|
||||
"label.teams": "Tim",
|
||||
"label.theme": "Tema",
|
||||
"label.this-month": "Bulan ini",
|
||||
"label.this-week": "Minggu ini",
|
||||
"label.this-year": "Tahun ini",
|
||||
"label.timezone": "Zona waktu",
|
||||
"label.title": "Title",
|
||||
"label.title": "Judul",
|
||||
"label.today": "Hari ini",
|
||||
"label.toggle-charts": "Buka grafik",
|
||||
"label.total": "Total",
|
||||
"label.total-records": "Total records",
|
||||
"label.total-records": "Total baris",
|
||||
"label.tracking-code": "Kode lacak",
|
||||
"label.transactions": "Transactions",
|
||||
"label.transactions": "Transaksi",
|
||||
"label.transfer": "Transfer",
|
||||
"label.transfer-website": "Transfer website",
|
||||
"label.true": "True",
|
||||
"label.type": "Type",
|
||||
"label.unique": "Unique",
|
||||
"label.transfer-website": "Transfer situs web",
|
||||
"label.true": "Benar",
|
||||
"label.type": "Tipe",
|
||||
"label.unique": "Unik",
|
||||
"label.unique-visitors": "Pengunjung unik",
|
||||
"label.uniqueCustomers": "Unique Customers",
|
||||
"label.uniqueCustomers": "Kustomer unik",
|
||||
"label.unknown": "Tidak diketahui",
|
||||
"label.untitled": "Untitled",
|
||||
"label.update": "Update",
|
||||
"label.untitled": "Tanpa judul",
|
||||
"label.update": "Perbarui",
|
||||
"label.url": "URL",
|
||||
"label.urls": "URLs",
|
||||
"label.user": "User",
|
||||
"label.user": "Pengguna",
|
||||
"label.user-property": "User Property",
|
||||
"label.username": "Nama pengguna",
|
||||
"label.users": "Users",
|
||||
"label.users": "Pengguna",
|
||||
"label.utm": "UTM",
|
||||
"label.utm-description": "Track your campaigns through UTM parameters.",
|
||||
"label.value": "Value",
|
||||
"label.view": "View",
|
||||
"label.utm-description": "Lacak kampanye Anda melalui parameter UTM.",
|
||||
"label.value": "Nilai",
|
||||
"label.view": "Lihat",
|
||||
"label.view-details": "Lihat Detil",
|
||||
"label.view-only": "View only",
|
||||
"label.view-only": "Hanya melihat",
|
||||
"label.views": "Tampilan",
|
||||
"label.views-per-visit": "Views per visit",
|
||||
"label.views-per-visit": "Tampilan per kunjungan",
|
||||
"label.visit-duration": "Waktu kunjungan rata-rata",
|
||||
"label.visitors": "Pengunjung",
|
||||
"label.visits": "Visits",
|
||||
"label.website": "Website",
|
||||
"label.website-id": "Website ID",
|
||||
"label.visits": "Kunjungan",
|
||||
"label.website": "Situs web",
|
||||
"label.website-id": "ID situs web",
|
||||
"label.websites": "Situs web",
|
||||
"label.window": "Window",
|
||||
"label.yesterday": "Yesterday",
|
||||
"message.action-confirmation": "Type {confirmation} in the box below to confirm.",
|
||||
"label.yesterday": "Kemarin",
|
||||
"message.action-confirmation": "Ketik {confirmation} pada kotak di bawah untuk mengonfirmasi.",
|
||||
"message.active-users": "{x} pengunjung saat ini",
|
||||
"message.collected-data": "Collected data",
|
||||
"message.collected-data": "Data dikumpulkan",
|
||||
"message.confirm-delete": "Apakah kamu yakin ingin menghapus {target}?",
|
||||
"message.confirm-leave": "Are you sure you want to leave {target}?",
|
||||
"message.confirm-remove": "Are you sure you want to remove {target}?",
|
||||
"message.confirm-leave": "Apakah Anda yakin ingin meninggalkan {target}?",
|
||||
"message.confirm-remove": "Apakah Anda yakin ingin menghapus {target}?",
|
||||
"message.confirm-reset": "Anda yakin ingin mengatur ulang statistik {target}?",
|
||||
"message.delete-team-warning": "Deleting a team will also delete all team websites.",
|
||||
"message.delete-team-warning": "Menghapus tim juga akan menghapus semua situs web yang terkait.",
|
||||
"message.delete-website-warning": "Semua data terkait juga akan dihapus.",
|
||||
"message.error": "Ada yang salah.",
|
||||
"message.event-log": "{event} on {url}",
|
||||
"message.go-to-settings": "Pergi ke pengaturan",
|
||||
"message.incorrect-username-password": "Nama pengguna/kata sandi salah.",
|
||||
"message.invalid-domain": "Domain tidak valid",
|
||||
"message.min-password-length": "Minimum length of {n} characters",
|
||||
"message.new-version-available": "A new version of Umami {version} is available!",
|
||||
"message.min-password-length": "Minimal {n} karakter",
|
||||
"message.new-version-available": "Versi baru dari Umami {version} telah tersedia!",
|
||||
"message.no-data-available": "Tidak ada data.",
|
||||
"message.no-event-data": "No event data is available.",
|
||||
"message.no-event-data": "Tidak ada data peristiwa",
|
||||
"message.no-match-password": "Kata sandi tidak cocok",
|
||||
"message.no-results-found": "No results were found.",
|
||||
"message.no-team-websites": "This team does not have any websites.",
|
||||
"message.no-teams": "You have not created any teams.",
|
||||
"message.no-users": "There are no users.",
|
||||
"message.no-results-found": "Tidak ada hasil yang ditemukan.",
|
||||
"message.no-team-websites": "Tim ini tidak memiliki situs web.",
|
||||
"message.no-teams": "Anda belum membuat tim.",
|
||||
"message.no-users": "Tidak ada pengguna.",
|
||||
"message.no-websites-configured": "Anda tidak memiliki situs web yang dikonfigurasi.",
|
||||
"message.page-not-found": "Halaman tidak ditemukan.",
|
||||
"message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.",
|
||||
"message.reset-website-warning": "Semua statistik pada website ini akan dihapus, tetapi kode lacak akan tetap terpasang",
|
||||
"message.reset-website": "Untuk mengatur ulang situs web ini, ketik {confirmation} pada kotak di bawah untuk mengonfirmasi.",
|
||||
"message.reset-website-warning": "Semua statistik pada situs web ini akan dihapus, tetapi kode lacak akan tetap terpasang",
|
||||
"message.saved": "Berhasil disimpan.",
|
||||
"message.share-url": "Ini adalah URL yang dibagikan secara publik untuk {target}.",
|
||||
"message.team-already-member": "You are already a member of the team.",
|
||||
"message.team-not-found": "Team not found.",
|
||||
"message.team-websites-info": "Websites can be viewed by anyone on the team.",
|
||||
"message.team-already-member": "Anda sudah menjadi anggota tim ini.",
|
||||
"message.team-not-found": "Tim tidak ditemukan.",
|
||||
"message.team-websites-info": "Situs web dapat dilihat oleh semua anggota tim.",
|
||||
"message.tracking-code": "Kode lacak",
|
||||
"message.transfer-team-website-to-user": "Transfer this website to your account?",
|
||||
"message.transfer-user-website-to-team": "Select the team to transfer this website to.",
|
||||
"message.transfer-website": "Transfer website ownership to your account or another team.",
|
||||
"message.triggered-event": "Triggered event",
|
||||
"message.user-deleted": "User deleted.",
|
||||
"message.viewed-page": "Viewed page",
|
||||
"message.transfer-team-website-to-user": "Transfer situs web ini ke akun Anda?",
|
||||
"message.transfer-user-website-to-team": "Pilih tim tujuan untuk mentransfer situs web ini.",
|
||||
"message.transfer-website": "Transfer kepemilikan situs web ke akun Anda atau tim lain",
|
||||
"message.triggered-event": "Peristiwa terjadi",
|
||||
"message.user-deleted": "Pengguna telah dihapus.",
|
||||
"message.viewed-page": "Halaman dilihat",
|
||||
"message.visitor-log": "Pengunjung dari {country} dengan {browser} di {device} {os}",
|
||||
"message.visitors-dropped-off": "Visitors dropped off"
|
||||
"message.visitors-dropped-off": "Pengunjung yang meninggalkan situs web"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -397,6 +397,14 @@ export const PAID_AD_PARAMS = [
|
|||
'epik=',
|
||||
'ttclid=',
|
||||
'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 = [
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import crypto from 'crypto';
|
||||
import { startOfHour, startOfMonth } from 'date-fns';
|
||||
import prand from 'pure-rand';
|
||||
import { v4, v5 } from 'uuid';
|
||||
|
||||
|
|
@ -77,20 +76,8 @@ export function secret() {
|
|||
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) {
|
||||
if (!args.length) return v4();
|
||||
|
||||
return v5(hash(...args, salt()), v5.DNS);
|
||||
return v5(hash(...args, secret()), v5.DNS);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,13 +86,13 @@ function decodeHeader(s: string | undefined | null): string | undefined | null {
|
|||
return Buffer.from(s, 'latin1').toString('utf-8');
|
||||
}
|
||||
|
||||
export async function getLocation(ip: string = '', headers: Headers) {
|
||||
export async function getLocation(ip: string = '', headers: Headers, hasPayloadIP: boolean) {
|
||||
// Ignore local ips
|
||||
if (await isLocalhost(ip)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!process.env.SKIP_LOCATION_HEADERS) {
|
||||
if (!hasPayloadIP && !process.env.SKIP_LOCATION_HEADERS) {
|
||||
// Cloudflare headers
|
||||
if (headers.get('cf-ipcountry')) {
|
||||
const country = decodeHeader(headers.get('cf-ipcountry'));
|
||||
|
|
@ -147,8 +147,8 @@ export async function getLocation(ip: string = '', headers: Headers) {
|
|||
export async function getClientInfo(request: Request, payload: Record<string, any>) {
|
||||
const userAgent = payload?.userAgent || request.headers.get('user-agent');
|
||||
const ip = payload?.ip || getIpAddress(request.headers);
|
||||
const location = await getLocation(ip, request.headers);
|
||||
const country = payload?.userAgent || location?.country;
|
||||
const location = await getLocation(ip, request.headers, !!payload?.ip);
|
||||
const country = location?.country;
|
||||
const subdivision1 = location?.subdivision1;
|
||||
const subdivision2 = location?.subdivision2;
|
||||
const city = location?.city;
|
||||
|
|
|
|||
|
|
@ -192,7 +192,9 @@ async function parseFilters(
|
|||
options: QueryOptions = {},
|
||||
) {
|
||||
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 {
|
||||
joinSession:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ZodObject } from 'zod';
|
||||
import { z, ZodSchema } from 'zod';
|
||||
import { FILTER_COLUMNS } from '@/lib/constants';
|
||||
import { badRequest, unauthorized } from '@/lib/response';
|
||||
import { getAllowedUnits, getMinimumUnit } from '@/lib/date';
|
||||
|
|
@ -15,7 +15,7 @@ export async function getJsonBody(request: Request) {
|
|||
|
||||
export async function parseRequest(
|
||||
request: Request,
|
||||
schema?: ZodObject<any>,
|
||||
schema?: ZodSchema,
|
||||
options?: { skipAuth: boolean },
|
||||
): Promise<any> {
|
||||
const url = new URL(request.url);
|
||||
|
|
@ -24,12 +24,21 @@ export async function parseRequest(
|
|||
let error: () => void | undefined;
|
||||
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) {
|
||||
const isGet = request.method === 'GET';
|
||||
const result = schema.safeParse(isGet ? query : body);
|
||||
|
||||
if (!result.success) {
|
||||
error = () => badRequest(result.error);
|
||||
error = () => badRequest(getErrorMessages(result.error));
|
||||
} else if (isGet) {
|
||||
query = result.data;
|
||||
} 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 anyObjectParam = z.object({}).passthrough();
|
||||
|
||||
export const urlOrPathParam = z.string().refine(
|
||||
value => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export async function saveEvent(args: {
|
|||
subdivision2?: string;
|
||||
city?: string;
|
||||
tag?: string;
|
||||
createdAt?: Date;
|
||||
}) {
|
||||
return runQuery({
|
||||
[PRISMA]: () => relationalQuery(args),
|
||||
|
|
@ -49,6 +50,7 @@ async function relationalQuery(data: {
|
|||
eventName?: string;
|
||||
eventData?: any;
|
||||
tag?: string;
|
||||
createdAt?: Date;
|
||||
}) {
|
||||
const {
|
||||
websiteId,
|
||||
|
|
@ -63,6 +65,7 @@ async function relationalQuery(data: {
|
|||
eventData,
|
||||
pageTitle,
|
||||
tag,
|
||||
createdAt,
|
||||
} = data;
|
||||
const websiteEventId = uuid();
|
||||
|
||||
|
|
@ -81,6 +84,7 @@ async function relationalQuery(data: {
|
|||
eventType: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView,
|
||||
eventName: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
|
||||
tag,
|
||||
createdAt,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -92,6 +96,7 @@ async function relationalQuery(data: {
|
|||
urlPath: urlPath?.substring(0, URL_LENGTH),
|
||||
eventName: eventName?.substring(0, EVENT_NAME_LENGTH),
|
||||
eventData,
|
||||
createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -121,6 +126,7 @@ async function clickhouseQuery(data: {
|
|||
subdivision2?: string;
|
||||
city?: string;
|
||||
tag?: string;
|
||||
createdAt?: Date;
|
||||
}) {
|
||||
const {
|
||||
websiteId,
|
||||
|
|
@ -139,12 +145,12 @@ async function clickhouseQuery(data: {
|
|||
subdivision2,
|
||||
city,
|
||||
tag,
|
||||
createdAt,
|
||||
...args
|
||||
} = data;
|
||||
const { insert, getUTCString } = clickhouse;
|
||||
const { sendMessage } = kafka;
|
||||
const eventId = uuid();
|
||||
const createdAt = getUTCString();
|
||||
|
||||
const message = {
|
||||
...args,
|
||||
|
|
@ -170,7 +176,7 @@ async function clickhouseQuery(data: {
|
|||
event_type: eventName ? EVENT_TYPE.customEvent : EVENT_TYPE.pageView,
|
||||
event_name: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null,
|
||||
tag: tag,
|
||||
created_at: createdAt,
|
||||
created_at: getUTCString(createdAt),
|
||||
};
|
||||
|
||||
if (kafka.enabled) {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export async function saveEventData(data: {
|
|||
urlPath?: string;
|
||||
eventName?: string;
|
||||
eventData: DynamicData;
|
||||
createdAt?: string;
|
||||
createdAt?: Date;
|
||||
}) {
|
||||
return runQuery({
|
||||
[PRISMA]: () => relationalQuery(data),
|
||||
|
|
@ -27,8 +27,9 @@ async function relationalQuery(data: {
|
|||
websiteId: string;
|
||||
eventId: string;
|
||||
eventData: DynamicData;
|
||||
createdAt?: Date;
|
||||
}): Promise<Prisma.BatchPayload> {
|
||||
const { websiteId, eventId, eventData } = data;
|
||||
const { websiteId, eventId, eventData, createdAt } = data;
|
||||
|
||||
const jsonKeys = flattenJSON(eventData);
|
||||
|
||||
|
|
@ -42,6 +43,7 @@ async function relationalQuery(data: {
|
|||
numberValue: a.dataType === DATA_TYPE.number ? a.value : null,
|
||||
dateValue: a.dataType === DATA_TYPE.date ? new Date(a.value) : null,
|
||||
dataType: a.dataType,
|
||||
createdAt,
|
||||
}));
|
||||
|
||||
return prisma.client.eventData.createMany({
|
||||
|
|
@ -56,7 +58,7 @@ async function clickhouseQuery(data: {
|
|||
urlPath?: string;
|
||||
eventName?: string;
|
||||
eventData: DynamicData;
|
||||
createdAt?: string;
|
||||
createdAt?: Date;
|
||||
}) {
|
||||
const { websiteId, sessionId, eventId, urlPath, eventName, eventData, createdAt } = data;
|
||||
|
||||
|
|
@ -77,7 +79,7 @@ async function clickhouseQuery(data: {
|
|||
string_value: getStringValue(value, dataType),
|
||||
number_value: dataType === DATA_TYPE.number ? 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;
|
||||
sessionId: string;
|
||||
sessionData: DynamicData;
|
||||
createdAt?: Date;
|
||||
}) {
|
||||
return runQuery({
|
||||
[PRISMA]: () => relationalQuery(data),
|
||||
|
|
@ -22,9 +23,10 @@ export async function relationalQuery(data: {
|
|||
websiteId: string;
|
||||
sessionId: string;
|
||||
sessionData: DynamicData;
|
||||
createdAt?: Date;
|
||||
}) {
|
||||
const { client } = prisma;
|
||||
const { websiteId, sessionId, sessionData } = data;
|
||||
const { websiteId, sessionId, sessionData, createdAt } = data;
|
||||
|
||||
const jsonKeys = flattenJSON(sessionData);
|
||||
|
||||
|
|
@ -37,6 +39,7 @@ export async function relationalQuery(data: {
|
|||
numberValue: a.dataType === DATA_TYPE.number ? a.value : null,
|
||||
dateValue: a.dataType === DATA_TYPE.date ? new Date(a.value) : null,
|
||||
dataType: a.dataType,
|
||||
createdAt,
|
||||
}));
|
||||
|
||||
const existing = await client.sessionData.findMany({
|
||||
|
|
@ -77,12 +80,12 @@ async function clickhouseQuery(data: {
|
|||
websiteId: string;
|
||||
sessionId: string;
|
||||
sessionData: DynamicData;
|
||||
createdAt?: Date;
|
||||
}) {
|
||||
const { websiteId, sessionId, sessionData } = data;
|
||||
const { websiteId, sessionId, sessionData, createdAt } = data;
|
||||
|
||||
const { insert, getUTCString } = clickhouse;
|
||||
const { sendMessage } = kafka;
|
||||
const createdAt = getUTCString();
|
||||
|
||||
const jsonKeys = flattenJSON(sessionData);
|
||||
|
||||
|
|
@ -95,7 +98,7 @@ async function clickhouseQuery(data: {
|
|||
string_value: getStringValue(value, dataType),
|
||||
number_value: dataType === DATA_TYPE.number ? value : null,
|
||||
date_value: dataType === DATA_TYPE.date ? getUTCString(value) : null,
|
||||
created_at: createdAt,
|
||||
created_at: getUTCString(createdAt),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
(window => {
|
||||
const {
|
||||
screen: { width, height },
|
||||
navigator: { language },
|
||||
navigator: { language, doNotTrack: ndnt, msDoNotTrack: msdnt },
|
||||
location,
|
||||
document,
|
||||
history,
|
||||
top,
|
||||
doNotTrack,
|
||||
} = window;
|
||||
const { hostname, href, origin } = location;
|
||||
const { currentScript, referrer } = document;
|
||||
|
|
@ -21,6 +22,7 @@
|
|||
const hostUrl = attr(_data + 'host-url');
|
||||
const tag = attr(_data + 'tag');
|
||||
const autoTrack = attr(_data + 'auto-track') !== _false;
|
||||
const dnt = attr(_data + 'do-not-track') === _true;
|
||||
const excludeSearch = attr(_data + 'exclude-search') === _true;
|
||||
const excludeHash = attr(_data + 'exclude-hash') === _true;
|
||||
const domain = attr(_data + 'domains') || '';
|
||||
|
|
@ -46,6 +48,11 @@
|
|||
tag: tag ? tag : undefined,
|
||||
});
|
||||
|
||||
const hasDoNotTrack = () => {
|
||||
const dnt = doNotTrack || ndnt || msdnt;
|
||||
return dnt === 1 || dnt === '1' || dnt === 'yes';
|
||||
};
|
||||
|
||||
/* Event handlers */
|
||||
|
||||
const handlePush = (state, title, url) => {
|
||||
|
|
@ -182,7 +189,8 @@
|
|||
disabled ||
|
||||
!website ||
|
||||
(localStorage && localStorage.getItem('umami.disabled')) ||
|
||||
(domain && !domains.includes(hostname));
|
||||
(domain && !domains.includes(hostname)) ||
|
||||
(dnt && hasDoNotTrack());
|
||||
|
||||
const send = async (payload, type = 'event') => {
|
||||
if (trackingDisabled()) return;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue