From c52774c787cbee06cdd2e00f880067f1d11f1297 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Sat, 1 Mar 2025 20:27:31 -0800 Subject: [PATCH 1/8] Bump version 2.17.0. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 87076936..db146e9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "umami", - "version": "2.16.1", + "version": "2.17.0", "description": "A simple, fast, privacy-focused alternative to Google Analytics.", "author": "Umami Software, Inc. ", "license": "MIT", From 72ac97c5d94edafa7c936ad1ef9ed66b46ba3eeb Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Mon, 3 Mar 2025 12:24:54 -0800 Subject: [PATCH 2/8] fix unique key prop error on forms --- src/app/(main)/reports/ReportDeleteButton.tsx | 4 +++- src/app/(main)/settings/teams/TeamLeaveForm.tsx | 4 +++- src/app/(main)/settings/users/UserDeleteForm.tsx | 4 +++- .../[teamId]/settings/members/TeamMemberRemoveButton.tsx | 4 +++- src/components/common/TypeConfirmationForm.tsx | 4 +++- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/app/(main)/reports/ReportDeleteButton.tsx b/src/app/(main)/reports/ReportDeleteButton.tsx index efd1da3c..ca096675 100644 --- a/src/app/(main)/reports/ReportDeleteButton.tsx +++ b/src/app/(main)/reports/ReportDeleteButton.tsx @@ -39,7 +39,9 @@ export function ReportDeleteButton({ {(close: () => void) => ( {reportName} })} + message={formatMessage(messages.confirmDelete, { + target: {reportName}, + })} isLoading={isPending} error={error} onConfirm={handleConfirm.bind(null, close)} diff --git a/src/app/(main)/settings/teams/TeamLeaveForm.tsx b/src/app/(main)/settings/teams/TeamLeaveForm.tsx index daf46434..389ba4ea 100644 --- a/src/app/(main)/settings/teams/TeamLeaveForm.tsx +++ b/src/app/(main)/settings/teams/TeamLeaveForm.tsx @@ -34,7 +34,9 @@ export function TeamLeaveForm({ return ( {teamName} })} + message={formatMessage(messages.confirmLeave, { + target: {teamName}, + })} onConfirm={handleConfirm} onClose={onClose} isLoading={isPending} diff --git a/src/app/(main)/settings/users/UserDeleteForm.tsx b/src/app/(main)/settings/users/UserDeleteForm.tsx index 3ac7c118..5c307cdc 100644 --- a/src/app/(main)/settings/users/UserDeleteForm.tsx +++ b/src/app/(main)/settings/users/UserDeleteForm.tsx @@ -19,7 +19,9 @@ export function UserDeleteForm({ userId, username, onSave, onClose }) { return ( {username} })} + message={formatMessage(messages.confirmDelete, { + target: {username}, + })} onConfirm={handleConfirm} onClose={onClose} buttonLabel={formatMessage(labels.delete)} diff --git a/src/app/(main)/teams/[teamId]/settings/members/TeamMemberRemoveButton.tsx b/src/app/(main)/teams/[teamId]/settings/members/TeamMemberRemoveButton.tsx index 931390c7..0dfe758b 100644 --- a/src/app/(main)/teams/[teamId]/settings/members/TeamMemberRemoveButton.tsx +++ b/src/app/(main)/teams/[teamId]/settings/members/TeamMemberRemoveButton.tsx @@ -43,7 +43,9 @@ export function TeamMemberRemoveButton({ {(close: () => void) => ( {userName} })} + message={formatMessage(messages.confirmRemove, { + target: {userName}, + })} isLoading={isPending} error={error} onConfirm={handleConfirm.bind(null, close)} diff --git a/src/components/common/TypeConfirmationForm.tsx b/src/components/common/TypeConfirmationForm.tsx index baf5949f..9ef5b30a 100644 --- a/src/components/common/TypeConfirmationForm.tsx +++ b/src/components/common/TypeConfirmationForm.tsx @@ -35,7 +35,9 @@ export function TypeConfirmationForm({ return (

- {formatMessage(messages.actionConfirmation, { confirmation: {confirmationValue} })} + {formatMessage(messages.actionConfirmation, { + confirmation: {confirmationValue}, + })}

value === confirmationValue }}> From 51f2a1c43165b1b78e4fdae7642142e604b5395c Mon Sep 17 00:00:00 2001 From: Maxime-J Date: Thu, 6 Mar 2025 15:41:27 +0100 Subject: [PATCH 3/8] Make journey report steps optional --- src/app/api/reports/journey/route.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/api/reports/journey/route.ts b/src/app/api/reports/journey/route.ts index a1bc6290..19ad98fa 100644 --- a/src/app/api/reports/journey/route.ts +++ b/src/app/api/reports/journey/route.ts @@ -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); From b1901c7278c99c90167d7303e333fd1d94c30258 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Fri, 7 Mar 2025 13:06:38 -0800 Subject: [PATCH 4/8] update cypress tests, update zod validation error messaging to UI --- .eslintrc.json | 5 +- cypress/e2e/api.cy.ts | 29 +++++++++ cypress/e2e/login.cy.ts | 30 ++++++--- cypress/e2e/user.cy.ts | 65 +++++++++++++++++++ cypress/e2e/website.cy.ts | 10 +-- cypress/fixtures/users.json | 17 +++++ cypress/support/e2e.ts | 6 ++ cypress/support/index.d.ts | 6 ++ .../(main)/settings/users/UserAddButton.tsx | 2 +- src/app/(main)/settings/users/UserAddForm.tsx | 32 +++++++-- .../settings/users/UserDeleteButton.tsx | 2 +- src/app/(main)/settings/users/UsersTable.tsx | 2 +- .../settings/users/[userId]/UserEditForm.tsx | 19 ++++-- src/app/api/auth/login/route.ts | 2 +- src/app/api/users/[userId]/route.ts | 11 ++-- src/app/login/LoginForm.tsx | 4 +- src/components/common/ConfirmationForm.tsx | 7 +- src/lib/request.ts | 13 +++- 18 files changed, 221 insertions(+), 41 deletions(-) create mode 100644 cypress/e2e/api.cy.ts create mode 100644 cypress/e2e/user.cy.ts create mode 100644 cypress/fixtures/users.json diff --git a/.eslintrc.json b/.eslintrc.json index 324e291c..9cbbd586 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -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", @@ -39,7 +41,8 @@ "@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" diff --git a/cypress/e2e/api.cy.ts b/cypress/e2e/api.cy.ts new file mode 100644 index 00000000..e69b5dff --- /dev/null +++ b/cypress/e2e/api.cy.ts @@ -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'); + }); + }); + }); +}); diff --git a/cypress/e2e/login.cy.ts b/cypress/e2e/login.cy.ts index 5831c81d..507b1b58 100644 --- a/cypress/e2e/login.cy.ts +++ b/cypress/e2e/login.cy.ts @@ -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'); + }); }); diff --git a/cypress/e2e/user.cy.ts b/cypress/e2e/user.cy.ts new file mode 100644 index 00000000..9f432f16 --- /dev/null +++ b/cypress/e2e/user.cy.ts @@ -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(); + }); +}); diff --git a/cypress/e2e/website.cy.ts b/cypress/e2e/website.cy.ts index b60d8e7a..2dcd6027 100644 --- a/cypress/e2e/website.cy.ts +++ b/cypress/e2e/website.cy.ts @@ -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'); diff --git a/cypress/fixtures/users.json b/cypress/fixtures/users.json new file mode 100644 index 00000000..420a71c3 --- /dev/null +++ b/cypress/fixtures/users.json @@ -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 + } +} diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 2c45142b..a300b969 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -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({ diff --git a/cypress/support/index.d.ts b/cypress/support/index.d.ts index 90cca19b..e89b24dd 100644 --- a/cypress/support/index.d.ts +++ b/cypress/support/index.d.ts @@ -1,4 +1,5 @@ /// +/* global JQuery */ declare namespace Cypress { interface Chainable { @@ -7,6 +8,11 @@ declare namespace Cypress { * @example cy.getDataTest('greeting') */ getDataTest(value: string): Chainable>; + /** + * Custom command to logout through UI. + * @example cy.logout() + */ + logout(): Chainable>; /** * Custom command to login user into the app. * @example cy.login('admin', 'password) diff --git a/src/app/(main)/settings/users/UserAddButton.tsx b/src/app/(main)/settings/users/UserAddButton.tsx index e1b04842..674771b6 100644 --- a/src/app/(main)/settings/users/UserAddButton.tsx +++ b/src/app/(main)/settings/users/UserAddButton.tsx @@ -15,7 +15,7 @@ export function UserAddButton({ onSave }: { onSave?: () => void }) { return ( - diff --git a/src/lib/request.ts b/src/lib/request.ts index 0c71537a..374f1cbc 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -1,4 +1,4 @@ -import { ZodSchema } 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'; @@ -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 { From 1b21f264b093885ed5b80f8507b208c63224dd45 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Fri, 7 Mar 2025 13:37:19 -0800 Subject: [PATCH 5/8] Added more paid ad params. Closes #3270 --- src/lib/constants.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index a64210ec..3eddefdc 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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 = [ From 833de1a1af2ef9efce23741f995043bf651d6a1e Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Fri, 7 Mar 2025 18:42:15 -0800 Subject: [PATCH 6/8] Added decoding to URL elements. --- src/app/api/send/route.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts index 8519a73e..bd255eaf 100644 --- a/src/app/api/send/route.ts +++ b/src/app/api/send/route.ts @@ -10,6 +10,7 @@ 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'; const schema = z.object({ @@ -168,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, From 97c687ff05f19be3d3abd75c4d737c4ec6d18a09 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Fri, 7 Mar 2025 20:29:31 -0800 Subject: [PATCH 7/8] Fixed group referrers count. Closes #3257 --- src/app/layout.tsx | 2 +- src/components/metrics/ReferrersTable.tsx | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f88d8169..ebe313e6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -23,7 +23,7 @@ export default function ({ children }) { - + {children} diff --git a/src/components/metrics/ReferrersTable.tsx b/src/components/metrics/ReferrersTable.tsx index db40a617..4d5a87c3 100644 --- a/src/components/metrics/ReferrersTable.tsx +++ b/src/components/metrics/ReferrersTable.tsx @@ -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) From abde966647af38828095da1145e20caa2eab7542 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Fri, 7 Mar 2025 21:41:02 -0800 Subject: [PATCH 8/8] Fixed wrong country lookup. --- src/lib/detect.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/detect.ts b/src/lib/detect.ts index 9d9fd7db..da2ca8a1 100644 --- a/src/lib/detect.ts +++ b/src/lib/detect.ts @@ -148,7 +148,7 @@ export async function getClientInfo(request: Request, payload: Record