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/package.json b/package.json index 8c7d91fa..274444a8 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", @@ -120,6 +120,7 @@ "react-simple-maps": "^2.3.0", "react-use-measure": "^2.1.7", "react-window": "^1.8.11", + "react-zen": "link:C:/Users/mike/AppData/Local/pnpm/global/5/node_modules/@umami/react-zen", "request-ip": "^3.3.0", "semver": "^7.7.1", "serialize-error": "^12.0.0", @@ -196,7 +197,7 @@ "sharp" ], "overrides": { - "@umami/react-zen": "link:C:/Users/mike/AppData/Local/pnpm/global/5/node_modules/@umami/react-zen" + "react-zen": "link:C:/Users/mike/AppData/Local/pnpm/global/5/node_modules/@umami/react-zen" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4028ca7..72ccf05c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - '@umami/react-zen': link:C:/Users/mike/AppData/Local/pnpm/global/5/node_modules/@umami/react-zen + react-zen: link:C:/Users/mike/AppData/Local/pnpm/global/5/node_modules/@umami/react-zen importers: @@ -176,6 +176,9 @@ importers: react-window: specifier: ^1.8.11 version: 1.8.11(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react-zen: + specifier: link:C:/Users/mike/AppData/Local/pnpm/global/5/node_modules/@umami/react-zen + version: link:C:/Users/mike/AppData/Local/pnpm/global/5/node_modules/@umami/react-zen request-ip: specifier: ^3.3.0 version: 3.3.0 diff --git a/src/app/(main)/reports/ReportDeleteButton.tsx b/src/app/(main)/reports/ReportDeleteButton.tsx index 1f4286b8..5a6c7597 100644 --- a/src/app/(main)/reports/ReportDeleteButton.tsx +++ b/src/app/(main)/reports/ReportDeleteButton.tsx @@ -41,7 +41,7 @@ export function ReportDeleteButton({ {({ close }) => ( {reportName}, + target: {reportName}, })} isLoading={isPending} error={error} diff --git a/src/app/(main)/settings/teams/TeamLeaveForm.tsx b/src/app/(main)/settings/teams/TeamLeaveForm.tsx index 0d555c4d..1923777f 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/UserAddButton.tsx b/src/app/(main)/settings/users/UserAddButton.tsx index 3dc312a7..ed9649b3 100644 --- a/src/app/(main)/settings/users/UserAddButton.tsx +++ b/src/app/(main)/settings/users/UserAddButton.tsx @@ -24,7 +24,7 @@ export function UserAddButton({ onSave }: { onSave?: () => void }) { return ( - - + {buttonLabel || formatMessage(labels.ok)} diff --git a/src/components/common/TypeConfirmationForm.tsx b/src/components/common/TypeConfirmationForm.tsx index d15e1192..1c125a99 100644 --- a/src/components/common/TypeConfirmationForm.tsx +++ b/src/components/common/TypeConfirmationForm.tsx @@ -35,7 +35,7 @@ export function TypeConfirmationForm({

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

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 {