diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml
index fd57e3a056125..cf40b1c390335 100644
--- a/.github/workflows/cypress.yml
+++ b/.github/workflows/cypress.yml
@@ -141,10 +141,10 @@ jobs:
matrix:
# Run multiple copies of the current job in parallel
# Please increase the number or runners as your tests suite grows (0 based index for e2e tests)
- containers: ['setup', '0', '1', '2', '3', '4', '5']
+ containers: ['setup', '0', '1', '2', '3', '4']
# Hack as strategy.job-total includes the "setup" and GitHub does not allow math expressions
# Always align this number with the total of e2e runners (max. index + 1)
- total-containers: [6]
+ total-containers: [5]
services:
mysql:
diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
index dfb673edadb3f..84bd2ba12cfbc 100644
--- a/.github/workflows/playwright.yml
+++ b/.github/workflows/playwright.yml
@@ -82,8 +82,8 @@ jobs:
strategy:
fail-fast: false
matrix:
- shardIndex: [1, 2, 3, 4]
- shardTotal: [4]
+ shardIndex: [1, 2, 3, 4, 5]
+ shardTotal: [5]
outputs:
node-version: ${{ steps.versions.outputs.node-version }}
package-manager-version: ${{ steps.versions.outputs.package-manager-version }}
diff --git a/cypress/e2e/settings/access-levels.cy.ts b/cypress/e2e/settings/access-levels.cy.ts
deleted file mode 100644
index 2a3699e985431..0000000000000
--- a/cypress/e2e/settings/access-levels.cy.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import { User } from '@nextcloud/e2e-test-server/cypress'
-import { clearState, getNextcloudUserMenu, getNextcloudUserMenuToggle } from '../../support/commonUtils.ts'
-
-const admin = new User('admin', 'admin')
-
-describe('Settings: Ensure only administrator can see the administration settings section', { testIsolation: true }, () => {
- beforeEach(() => {
- clearState()
- })
-
- it('Regular users cannot see admin-level items on the Settings page', () => {
- // Given I am logged in
- cy.createRandomUser().then(($user) => {
- cy.login($user)
- cy.visit('/')
- })
-
- // I open the settings menu
- getNextcloudUserMenuToggle().click()
- // I navigate to the settings panel
- getNextcloudUserMenu()
- .findByRole('link', { name: /settings/i })
- .click()
- cy.url().should('match', /\/settings\/user$/)
-
- cy.findAllByRole('navigation')
- .filter('#app-navigation-vue')
- .as('appNavigation')
- .findByRole('list', { name: 'Personal' })
- .should('be.visible')
- .findByRole('link', { name: /Personal info/i })
- .should('be.visible')
- .and('have.attr', 'aria-current', 'page')
-
- cy.get('@appNavigation')
- .findByRole('list', { name: 'Administration' })
- .should('not.exist')
- })
-
- it('Admin users can see admin-level items on the Settings page', () => {
- // Given I am logged in
- cy.login(admin)
- cy.visit('/')
-
- // I open the settings menu
- getNextcloudUserMenuToggle().click()
- // I navigate to the settings panel
- getNextcloudUserMenu()
- .findByRole('link', { name: /Personal settings/i })
- .click()
- cy.url().should('match', /\/settings\/user$/)
-
- cy.findAllByRole('navigation')
- .filter('#app-navigation-vue')
- .as('appNavigation')
- .findByRole('list', { name: 'Personal' })
- .should('be.visible')
- .findByRole('link', { name: /Personal info/i })
- .should('be.visible')
- .and('have.attr', 'aria-current', 'page')
-
- cy.get('@appNavigation')
- .findByRole('list', { name: 'Administration' })
- .should('be.visible')
- .findByRole('link', { name: /Overview/i })
- .should('be.visible')
- })
-})
diff --git a/cypress/e2e/settings/personal-info.cy.ts b/cypress/e2e/settings/personal-info.cy.ts
deleted file mode 100644
index 068af94aec9cb..0000000000000
--- a/cypress/e2e/settings/personal-info.cy.ts
+++ /dev/null
@@ -1,449 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import type { User } from '@nextcloud/e2e-test-server/cypress'
-
-import { handlePasswordConfirmation } from '../core-utils.ts'
-
-let user: User
-
-enum Visibility {
- Private = 'Private',
- Local = 'Local',
- Federated = 'Federated',
- Public = 'Published',
-}
-
-const ALL_VISIBILITIES = [Visibility.Public, Visibility.Private, Visibility.Local, Visibility.Federated]
-
-/**
- * Get the input connected to a specific label
- * @param label The content of the label
- */
-const inputForLabel = (label: string) => cy.contains('label', label).then((el) => cy.get(`#${el.attr('for')}`))
-
-/**
- * Get the property visibility button
- * @param property The property to which to look for the button
- */
-const getVisibilityButton = (property: string) => cy.get(`button[aria-label*="Change scope level of ${property.toLowerCase()}"`)
-
-/**
- * Validate a specifiy visibility is set for a property
- * @param property The property
- * @param active The active visibility
- */
-function validateActiveVisibility(property: string, active: Visibility) {
- getVisibilityButton(property)
- .should('have.attr', 'aria-label')
- .and('match', new RegExp(`current scope is ${active}`, 'i'))
- getVisibilityButton(property)
- .click()
- cy.get('ul[role="menu"]')
- .contains('button', active)
- .should('have.attr', 'aria-checked', 'true')
-
- // close menu
- getVisibilityButton(property)
- .click()
-}
-
-/**
- * Set a specific visibility for a property
- * @param property The property
- * @param active The visibility to set
- */
-function setActiveVisibility(property: string, active: Visibility) {
- getVisibilityButton(property)
- .click()
- cy.get('ul[role="menu"]')
- .contains('button', active)
- .click({ force: true })
- handlePasswordConfirmation(user.password)
-
- cy.wait('@submitSetting')
-}
-
-/**
- * Helper to check that setting all visibilities on a property is possible
- * @param property The property to test
- * @param defaultVisibility The default visibility of that property
- * @param allowedVisibility Visibility that is allowed and need to be checked
- */
-function checkSettingsVisibility(property: string, defaultVisibility: Visibility = Visibility.Local, allowedVisibility: Visibility[] = ALL_VISIBILITIES) {
- getVisibilityButton(property)
- .scrollIntoView()
-
- validateActiveVisibility(property, defaultVisibility)
-
- allowedVisibility.forEach((active) => {
- setActiveVisibility(property, active)
-
- cy.reload()
- getVisibilityButton(property).scrollIntoView()
-
- validateActiveVisibility(property, active)
- })
-
- // TODO: Fix this in vue library then enable this test again
- /* // Test that not allowed options are disabled
- ALL_VISIBILITIES.filter((v) => !allowedVisibility.includes(v)).forEach((disabled) => {
- getVisibilityButton(property)
- .click()
- cy.get('ul[role="dialog"')
- .contains('button', disabled)
- .should('exist')
- .and('have.attr', 'disabled', 'true')
- }) */
-}
-
-const genericProperties = [
- ['Location', 'Berlin'],
- ['X (formerly Twitter)', 'nextclouders'],
- ['Fediverse', 'nextcloud@mastodon.xyz'],
-]
-const nonfederatedProperties = ['Organisation', 'Role', 'Headline', 'About']
-
-describe('Settings: Change personal information', { testIsolation: true }, () => {
- let snapshot: string = ''
-
- before(() => {
- // make sure the fediverse check does not do http requests
- cy.runOccCommand('config:system:set has_internet_connection --type bool --value false')
- // ensure we can set locale and language
- cy.runOccCommand('config:system:delete force_language')
- cy.runOccCommand('config:system:delete force_locale')
- cy.createRandomUser().then(($user) => {
- user = $user
- cy.modifyUser(user, 'language', 'en')
- cy.modifyUser(user, 'locale', 'en_US')
-
- // Make sure the user is logged in at least once
- // before the snapshot is taken to speed up the tests
- cy.login(user)
- cy.visit('/settings/user')
-
- cy.saveState().then(($snapshot) => {
- snapshot = $snapshot
- })
- })
- })
-
- after(() => {
- cy.runOccCommand('config:system:delete has_internet_connection')
-
- cy.runOccCommand('config:system:set force_language --value en')
- cy.runOccCommand('config:system:set force_locale --value en_US')
- })
-
- beforeEach(() => {
- cy.login(user)
- cy.visit('/settings/user')
- cy.intercept('PUT', /ocs\/v2.php\/cloud\/users\//).as('submitSetting')
- })
-
- afterEach(() => {
- cy.restoreState(snapshot)
- })
-
- it('Can dis- and enable the profile', () => {
- cy.visit(`/u/${user.userId}`)
- cy.contains('h2', user.userId).should('be.visible')
-
- cy.visit('/settings/user')
- cy.contains('Enable profile').click()
- handlePasswordConfirmation(user.password)
- cy.wait('@submitSetting')
-
- cy.visit(`/u/${user.userId}`, { failOnStatusCode: false })
- cy.contains('h2', 'Profile not found').should('be.visible')
-
- cy.visit('/settings/user')
- cy.contains('Enable profile').click()
- handlePasswordConfirmation(user.password)
- cy.wait('@submitSetting')
-
- cy.visit(`/u/${user.userId}`, { failOnStatusCode: false })
- cy.contains('h2', user.userId).should('be.visible')
- })
-
- it('Can change language', () => {
- cy.intercept('GET', /settings\/user/).as('reload')
- inputForLabel('Language').scrollIntoView()
- inputForLabel('Language').type('Ned')
- cy.contains('li[role="option"]', 'Nederlands')
- .click()
- cy.wait('@reload')
-
- // expect language changed
- inputForLabel('Taal').scrollIntoView()
- cy.contains('section', 'Help met vertalen')
- })
-
- it('Can change locale', () => {
- cy.intercept('GET', /settings\/user/).as('reload')
- cy.clock(new Date(2024, 0, 10))
-
- // Default is US
- cy.contains('section', '01/10/2024')
-
- inputForLabel('Locale').scrollIntoView()
- inputForLabel('Locale').type('German')
- cy.contains('li[role="option"]', 'German (Germany')
- .click()
- cy.wait('@reload')
-
- // expect locale changed
- inputForLabel('Locale').scrollIntoView()
- cy.contains('section', '10.01.2024')
- })
-
- it('Can set primary email and change its visibility', () => {
- cy.contains('label', 'Email').scrollIntoView()
- // Check invalid input
- inputForLabel('Email').type('foo bar')
- inputForLabel('Email').then(($el) => expect(($el.get(0) as HTMLInputElement).checkValidity()).to.be.false)
- // handle valid input
- inputForLabel('Email').type('{selectAll}hello@example.com')
- handlePasswordConfirmation(user.password)
-
- cy.wait('@submitSetting')
- cy.reload()
- inputForLabel('Email').should('have.value', 'hello@example.com')
-
- checkSettingsVisibility(
- 'Email',
- Visibility.Federated,
- // It is not possible to set it as private
- ALL_VISIBILITIES.filter((v) => v !== Visibility.Private),
- )
-
- // check it is visible on the profile
- cy.visit(`/u/${user.userId}`)
- cy.contains('a', 'hello@example.com').should('be.visible').and('have.attr', 'href', 'mailto:hello@example.com')
- })
-
- it('Can delete primary email', () => {
- cy.contains('label', 'Email').scrollIntoView()
- inputForLabel('Email').type('{selectAll}hello@example.com')
- handlePasswordConfirmation(user.password)
- cy.wait('@submitSetting')
-
- // check after reload
- cy.reload()
- inputForLabel('Email').should('have.value', 'hello@example.com')
-
- // delete email
- cy.get('button[aria-label="Remove primary email"]').click({ force: true })
- cy.wait('@submitSetting')
-
- // check after reload
- cy.reload()
- inputForLabel('Email').should('have.value', '')
- })
-
- it('Can set and delete additional emails', () => {
- cy.get('button[aria-label="Add additional email"]').should('be.disabled')
- // we need a primary email first
- cy.contains('label', 'Email').scrollIntoView()
- inputForLabel('Email').type('{selectAll}primary@example.com')
- handlePasswordConfirmation(user.password)
- cy.wait('@submitSetting')
-
- // add new email
- cy.get('button[aria-label="Add additional email"]')
- .click()
-
- // without any value we should not be able to add a second additional
- cy.get('button[aria-label="Add additional email"]').should('be.disabled')
-
- // fill the first additional
- inputForLabel('Additional email address 1')
- .type('1@example.com')
- handlePasswordConfirmation(user.password)
- cy.wait('@submitSetting')
-
- // add second additional email
- cy.get('button[aria-label="Add additional email"]')
- .click()
-
- // fill the second additional
- inputForLabel('Additional email address 2')
- .type('2@example.com')
- handlePasswordConfirmation(user.password)
- cy.wait('@submitSetting')
-
- // check the content is saved
- cy.reload()
- inputForLabel('Additional email address 1')
- .should('have.value', '1@example.com')
- inputForLabel('Additional email address 2')
- .should('have.value', '2@example.com')
-
- // delete the first
- cy.get('button[aria-label="Options for additional email address 1"]')
- .click({ force: true })
- cy.contains('button[role="menuitem"]', 'Delete email')
- .click({ force: true })
- handlePasswordConfirmation(user.password)
-
- cy.reload()
- inputForLabel('Additional email address 1')
- .should('have.value', '2@example.com')
- })
-
- it('Can set Full name and change its visibility', () => {
- cy.contains('label', 'Full name').scrollIntoView()
- // handle valid input
- inputForLabel('Full name').type('{selectAll}Jane Doe')
- handlePasswordConfirmation(user.password)
-
- cy.wait('@submitSetting')
- cy.reload()
- inputForLabel('Full name').should('have.value', 'Jane Doe')
-
- checkSettingsVisibility(
- 'Full name',
- Visibility.Federated,
- // It is not possible to set it as private
- ALL_VISIBILITIES.filter((v) => v !== Visibility.Private),
- )
-
- // check it is visible on the profile
- cy.visit(`/u/${user.userId}`)
- cy.contains('h2', 'Jane Doe').should('be.visible')
- })
-
- it('Can set Phone number and its visibility', () => {
- cy.contains('label', 'Phone number').scrollIntoView()
- // Check invalid input
- inputForLabel('Phone number').type('foo bar')
- inputForLabel('Phone number').should('have.attr', 'class').and('contain', '--error')
- // handle valid input
- inputForLabel('Phone number').type('{selectAll}+49 89 721010 99701')
- inputForLabel('Phone number').should('have.attr', 'class').and('not.contain', '--error')
- handlePasswordConfirmation(user.password)
-
- cy.wait('@submitSetting')
- cy.reload()
- inputForLabel('Phone number').should('have.value', '+498972101099701')
-
- checkSettingsVisibility('Phone number')
-
- // check it is visible on the profile
- cy.visit(`/u/${user.userId}`)
- cy.get('a[href="tel:+498972101099701"]').should('be.visible')
- })
-
- it('Can set phone number with phone region', () => {
- cy.contains('label', 'Phone number').scrollIntoView()
- inputForLabel('Phone number').type('{selectAll}0 40 428990')
- inputForLabel('Phone number').should('have.attr', 'class').and('contain', '--error')
-
- cy.runOccCommand('config:system:set default_phone_region --value DE')
- cy.reload()
-
- cy.contains('label', 'Phone number').scrollIntoView()
- inputForLabel('Phone number').type('{selectAll}0 40 428990')
- handlePasswordConfirmation(user.password)
-
- cy.wait('@submitSetting')
- cy.reload()
- inputForLabel('Phone number').should('have.value', '+4940428990')
- })
-
- it('Can reset phone number', () => {
- cy.contains('label', 'Phone number').scrollIntoView()
- inputForLabel('Phone number').type('{selectAll}+49 40 428990')
- handlePasswordConfirmation(user.password)
-
- cy.wait('@submitSetting')
- cy.reload()
- inputForLabel('Phone number').should('have.value', '+4940428990')
-
- inputForLabel('Phone number').clear()
- handlePasswordConfirmation(user.password)
-
- cy.wait('@submitSetting')
- cy.reload()
- inputForLabel('Phone number').should('have.value', '')
- })
-
- it('Can reset social media property', () => {
- cy.contains('label', 'Fediverse').scrollIntoView()
- inputForLabel('Fediverse').type('{selectAll}@nextcloud@mastodon.social')
- handlePasswordConfirmation(user.password)
-
- cy.wait('@submitSetting')
- cy.reload()
- inputForLabel('Fediverse').should('have.value', 'nextcloud@mastodon.social')
-
- inputForLabel('Fediverse').clear()
- handlePasswordConfirmation(user.password)
-
- cy.wait('@submitSetting')
- cy.reload()
- inputForLabel('Fediverse').should('have.value', '')
- })
-
- it('Can set Website and change its visibility', () => {
- cy.contains('label', 'Website').scrollIntoView()
- // Check invalid input
- inputForLabel('Website').type('foo bar')
- inputForLabel('Website').then(($el) => expect(($el.get(0) as HTMLInputElement).checkValidity()).to.be.false)
- // handle valid input
- inputForLabel('Website').type('{selectAll}http://example.com')
- handlePasswordConfirmation(user.password)
-
- cy.wait('@submitSetting')
- cy.reload()
- inputForLabel('Website').should('have.value', 'http://example.com')
-
- checkSettingsVisibility('Website')
-
- // check it is visible on the profile
- cy.visit(`/u/${user.userId}`)
- cy.contains('http://example.com').should('be.visible')
- })
-
- // Check generic properties that allow any visibility and any value
- genericProperties.forEach(([property, value]) => {
- it(`Can set ${property} and change its visibility`, () => {
- cy.contains('label', property).scrollIntoView()
- inputForLabel(property).type(value)
- handlePasswordConfirmation(user.password)
-
- cy.wait('@submitSetting')
- cy.reload()
- inputForLabel(property).should('have.value', value)
-
- checkSettingsVisibility(property)
-
- // check it is visible on the profile
- cy.visit(`/u/${user.userId}`)
- cy.contains(value).should('be.visible')
- })
- })
-
- // Check non federated properties - those where we need special configuration and only support local visibility
- nonfederatedProperties.forEach((property) => {
- it(`Can set ${property} and change its visibility`, () => {
- const uniqueValue = `${property.toUpperCase()} ${property.toLowerCase()}`
- cy.contains('label', property).scrollIntoView()
- inputForLabel(property).type(uniqueValue)
- handlePasswordConfirmation(user.password)
-
- cy.wait('@submitSetting')
- cy.reload()
- inputForLabel(property).should('have.value', uniqueValue)
-
- checkSettingsVisibility(property, Visibility.Local, [Visibility.Private, Visibility.Local])
-
- // check it is visible on the profile
- cy.visit(`/u/${user.userId}`)
- cy.contains(uniqueValue).should('be.visible')
- })
- })
-})
diff --git a/cypress/e2e/settings/users-group-admin.cy.ts b/cypress/e2e/settings/users-group-admin.cy.ts
deleted file mode 100644
index 68dddac2e053f..0000000000000
--- a/cypress/e2e/settings/users-group-admin.cy.ts
+++ /dev/null
@@ -1,186 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import { User } from '@nextcloud/e2e-test-server/cypress'
-import { randomString } from '../../support/utils/randomString.ts'
-import { handlePasswordConfirmation } from '../core-utils.ts'
-import { getUserListRow } from './usersUtils.ts'
-
-const admin = new User('admin', 'admin')
-const john = new User('john', '123456')
-
-/**
- * Make a user subadmin of a group.
- *
- * @param user - The user to make subadmin
- * @param group - The group the user should be subadmin of
- */
-function makeSubAdmin(user: User, group: string): void {
- cy.request({
- url: `${Cypress.config('baseUrl')!.replace('/index.php', '')}/ocs/v2.php/cloud/users/${user.userId}/subadmins`,
- method: 'POST',
- auth: {
- user: admin.userId,
- password: admin.userId,
- },
- headers: {
- 'OCS-ApiRequest': 'true',
- },
- body: {
- groupid: group,
- },
- })
-}
-
-describe('Settings: Create accounts as a group admin', function() {
- let subadmin: User
- let group: string
-
- beforeEach(() => {
- group = randomString(7)
- cy.deleteUser(john)
- cy.createRandomUser().then((user) => {
- subadmin = user
- cy.runOccCommand(`group:add '${group}'`)
- cy.runOccCommand(`group:adduser '${group}' '${subadmin.userId}'`)
- makeSubAdmin(subadmin, group)
- })
- })
-
- it('Can create a user with prefilled single group', () => {
- cy.login(subadmin)
- // open the User settings
- cy.visit('/settings/users')
-
- // open the New user modal
- cy.get('button#new-user-button').click()
-
- cy.get('form[data-test="form"]').within(() => {
- // see that the correct group is preselected
- cy.contains('[data-test="groups"] .vs__selected', group).should('be.visible')
- // see that the username is ""
- cy.get('input[data-test="username"]').should('exist').and('have.value', '')
- // set the username to john
- cy.get('input[data-test="username"]').type(john.userId)
- // see that the username is john
- cy.get('input[data-test="username"]').should('have.value', john.userId)
- // see that the password is ""
- cy.get('input[type="password"]').should('exist').and('have.value', '')
- // set the password to 123456
- cy.get('input[type="password"]').type(john.password)
- // see that the password is 123456
- cy.get('input[type="password"]').should('have.value', john.password)
- })
-
- cy.get('form[data-test="form"]').parents('[role="dialog"]').within(() => {
- // submit the new user form
- cy.get('button[type="submit"]').click({ force: true })
- })
-
- // Make sure no confirmation modal is shown
- handlePasswordConfirmation(admin.password)
-
- // see that the created user is in the list
- getUserListRow(john.userId)
- // see that the list of users contains the user john
- .contains(john.userId).should('exist')
- })
-
- // Skiping as this crash the webengine in the CI
- it.skip('Can create a new user when member of multiple groups', () => {
- const group2 = randomString(7)
- cy.runOccCommand(`group:add '${group2}'`)
- cy.runOccCommand(`group:adduser '${group2}' '${subadmin.userId}'`)
- makeSubAdmin(subadmin, group2)
-
- cy.login(subadmin)
- // open the User settings
- cy.visit('/settings/users')
-
- // open the New user modal
- cy.get('button#new-user-button').click()
-
- cy.get('form[data-test="form"]').within(() => {
- // see that no group is pre-selected
- cy.get('[data-test="groups"] .vs__selected').should('not.exist')
- // see both groups are available
- cy.findByRole('combobox', { name: /member of the following groups/i })
- .should('be.visible')
- .click()
- // can select both groups
- cy.document().its('body')
- .findByRole('listbox', { name: 'Options' })
- .should('be.visible')
- .as('options')
- .findAllByRole('option')
- .should('have.length', 2)
- .get('@options')
- .findByRole('option', { name: group })
- .should('be.visible')
- .get('@options')
- .findByRole('option', { name: group2 })
- .should('be.visible')
- .click()
- // see group is selected
- cy.contains('[data-test="groups"] .vs__selected', group2).should('be.visible')
-
- // see that the username is ""
- cy.get('input[data-test="username"]').should('exist').and('have.value', '')
- // set the username to john
- cy.get('input[data-test="username"]').type(john.userId)
- // see that the username is john
- cy.get('input[data-test="username"]').should('have.value', john.userId)
- // see that the password is ""
- cy.get('input[type="password"]').should('exist').and('have.value', '')
- // set the password to 123456
- cy.get('input[type="password"]').type(john.password)
- // see that the password is 123456
- cy.get('input[type="password"]').should('have.value', john.password)
- })
-
- cy.get('form[data-test="form"]').parents('[role="dialog"]').within(() => {
- // submit the new user form
- cy.get('button[type="submit"]').click({ force: true })
- })
-
- // Make sure no confirmation modal is shown
- handlePasswordConfirmation(admin.password)
-
- // see that the created user is in the list
- getUserListRow(john.userId)
- // see that the list of users contains the user john
- .contains(john.userId).should('exist')
- })
-
- it.skip('Only sees groups they are subadmin of', () => {
- const group2 = randomString(7)
- cy.runOccCommand(`group:add '${group2}'`)
- cy.runOccCommand(`group:adduser '${group2}' '${subadmin.userId}'`)
- // not a subadmin!
-
- cy.login(subadmin)
- // open the User settings
- cy.visit('/settings/users')
-
- // open the New user modal
- cy.get('button#new-user-button').click()
-
- cy.get('form[data-test="form"]').within(() => {
- // see that the subadmin group is pre-selected
- cy.contains('[data-test="groups"] .vs__selected', group).should('be.visible')
- // see only the subadmin group is available
- cy.findByRole('combobox', { name: /member of the following groups/i })
- .should('be.visible')
- .click()
- // can select both groups
- cy.document().its('body')
- .findByRole('listbox', { name: 'Options' })
- .should('be.visible')
- .as('options')
- .findAllByRole('option')
- .should('have.length', 1)
- })
- })
-})
diff --git a/cypress/e2e/settings/users.cy.ts b/cypress/e2e/settings/users.cy.ts
deleted file mode 100644
index 615455bc30282..0000000000000
--- a/cypress/e2e/settings/users.cy.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-///
-
-import { User } from '@nextcloud/e2e-test-server/cypress'
-import { handlePasswordConfirmation } from '../core-utils.ts'
-import { getUserListRow } from './usersUtils.ts'
-
-const admin = new User('admin', 'admin')
-const john = new User('john', '123456')
-
-describe('Settings: Create and delete accounts', function() {
- beforeEach(function() {
- cy.listUsers().then((users) => {
- if ((users as string[]).includes(john.userId)) {
- // ensure created user is deleted
- cy.deleteUser(john)
- }
- })
- cy.login(admin)
- // open the User settings
- cy.visit('/settings/users')
- })
-
- it('Can create a user', function() {
- // open the New user modal
- cy.get('button#new-user-button').click()
-
- cy.get('form[data-test="form"]').within(() => {
- // see that the username is ""
- cy.get('input[data-test="username"]').should('exist').and('have.value', '')
- // set the username to john
- cy.get('input[data-test="username"]').type(john.userId)
- // see that the username is john
- cy.get('input[data-test="username"]').should('have.value', john.userId)
- // see that the password is ""
- cy.get('input[type="password"]').should('exist').and('have.value', '')
- // set the password to 123456
- cy.get('input[type="password"]').type(john.password)
- // see that the password is 123456
- cy.get('input[type="password"]').should('have.value', john.password)
- })
-
- cy.get('form[data-test="form"]').parents('[role="dialog"]').within(() => {
- // submit the new user form
- cy.get('button[type="submit"]').click({ force: true })
- })
-
- // Make sure no confirmation modal is shown
- handlePasswordConfirmation(admin.password)
-
- // see that the created user is in the list
- getUserListRow(john.userId)
- // see that the list of users contains the user john
- .contains(john.userId).should('exist')
- })
-
- it('Can create a user with additional field data', function() {
- // open the New user modal
- cy.get('button#new-user-button').click()
-
- cy.get('form[data-test="form"]').within(() => {
- // set the username
- cy.get('input[data-test="username"]').should('exist').and('have.value', '')
- cy.get('input[data-test="username"]').type(john.userId)
- cy.get('input[data-test="username"]').should('have.value', john.userId)
- // set the display name
- cy.get('input[data-test="displayName"]').should('exist').and('have.value', '')
- cy.get('input[data-test="displayName"]').type('John Smith')
- cy.get('input[data-test="displayName"]').should('have.value', 'John Smith')
- // set the email
- cy.get('input[data-test="email"]').should('exist').and('have.value', '')
- cy.get('input[data-test="email"]').type('john@example.org')
- cy.get('input[data-test="email"]').should('have.value', 'john@example.org')
- // set the password
- cy.get('input[type="password"]').should('exist').and('have.value', '')
- cy.get('input[type="password"]').type(john.password)
- cy.get('input[type="password"]').should('have.value', john.password)
- })
-
- cy.get('form[data-test="form"]').parents('[role="dialog"]').within(() => {
- // submit the new user form
- cy.get('button[type="submit"]').click({ force: true })
- })
-
- // Make sure no confirmation modal is shown
- handlePasswordConfirmation(admin.password)
-
- // see that the created user is in the list
- getUserListRow(john.userId)
- // see that the list of users contains the user john
- .contains(john.userId)
- .should('exist')
- })
-
- it('Can delete a user', function() {
- let testUser
- // create user
- cy.createRandomUser()
- .then(($user) => {
- testUser = $user
- })
- cy.login(admin)
- // ensure created user is present
- cy.reload().then(() => {
- // see that the user is in the list
- getUserListRow(testUser.userId).within(() => {
- // see that the list of users contains the user testUser
- cy.contains(testUser.userId).should('exist')
- // open the actions menu for the user
- cy.get('[data-cy-user-list-cell-actions]')
- .find('button.action-item__menutoggle')
- .click({ force: true })
- })
-
- // The "Delete account" action in the actions menu is shown and clicked
- cy.get('.action-item__popper .action').contains('Delete account').should('exist').click({ force: true })
-
- // Make sure no confirmation modal is shown
- handlePasswordConfirmation(admin.password)
-
- // And confirmation dialog accepted
- cy.get('.nc-generic-dialog button').contains(`Delete ${testUser.userId}`).click({ force: true })
-
- // deleted clicked the user is not shown anymore
- getUserListRow(testUser.userId).should('not.exist')
- })
- })
-})
diff --git a/cypress/e2e/settings/users_columns.cy.ts b/cypress/e2e/settings/users_columns.cy.ts
deleted file mode 100644
index ad7db65c4f8c1..0000000000000
--- a/cypress/e2e/settings/users_columns.cy.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import { User } from '@nextcloud/e2e-test-server/cypress'
-import { assertNotExistOrNotVisible, getUserList } from './usersUtils.js'
-
-const admin = new User('admin', 'admin')
-
-describe('Settings: Show and hide columns', function() {
- before(function() {
- cy.login(admin)
- // open the User settings
- cy.visit('/settings/users')
- })
-
- beforeEach(function() {
- // open the settings dialog
- cy.contains('button', 'Account management settings').click()
- // reset all visibility toggles
- cy.get('.modal-container #settings-section_visibility-settings input[type="checkbox"]').uncheck({ force: true })
-
- cy.contains('.modal-container', 'Account management settings').within(() => {
- // enable the last login toggle
- cy.get('[data-test="showLastLogin"] input[type="checkbox"]').check({ force: true })
- // close the settings dialog
- cy.get('button.modal-container__close').click()
- })
- cy.waitUntil(() => cy.get('.modal-container').should((el) => assertNotExistOrNotVisible(el)))
- })
-
- it('Can show a column', function() {
- // see that the language column is not in the header
- cy.get('[data-cy-user-list-header-languages]').should('not.exist')
-
- // see that the language column is not in all user rows
- cy.get('tbody.user-list__body tr').each(($row) => {
- cy.wrap($row).get('[data-test="language"]').should('not.exist')
- })
-
- // open the settings dialog
- cy.contains('button', 'Account management settings').click()
-
- cy.contains('.modal-container', 'Account management settings').within(() => {
- // enable the language toggle
- cy.get('[data-test="showLanguages"] input[type="checkbox"]').should('not.be.checked')
- cy.get('[data-test="showLanguages"] input[type="checkbox"]').check({ force: true })
- cy.get('[data-test="showLanguages"] input[type="checkbox"]').should('be.checked')
- // close the settings dialog
- cy.get('button.modal-container__close').click()
- })
- cy.waitUntil(() => cy.get('.modal-container').should((el) => assertNotExistOrNotVisible(el)))
-
- // see that the language column is in the header
- cy.get('[data-cy-user-list-header-languages]').should('exist')
-
- // see that the language column is in all user rows
- getUserList().find('tbody tr').each(($row) => {
- cy.wrap($row).get('[data-cy-user-list-cell-language]').should('exist')
- })
-
- // Clear local storage and reload to verify user settings DB persistence
- cy.clearLocalStorage()
- cy.reload()
- cy.get('[data-cy-user-list-header-languages]').should('exist')
- })
-
- it('Can hide a column', function() {
- // see that the last login column is in the header
- cy.get('[data-cy-user-list-header-last-login]').should('exist')
-
- // see that the last login column is in all user rows
- getUserList().find('tbody tr').each(($row) => {
- cy.wrap($row).get('[data-cy-user-list-cell-last-login]').should('exist')
- })
-
- // open the settings dialog
- cy.contains('button', 'Account management settings').click()
-
- cy.contains('.modal-container', 'Account management settings').within(() => {
- // disable the last login toggle
- cy.get('[data-test="showLastLogin"] input[type="checkbox"]').should('be.checked')
- cy.get('[data-test="showLastLogin"] input[type="checkbox"]').uncheck({ force: true })
- cy.get('[data-test="showLastLogin"] input[type="checkbox"]').should('not.be.checked')
- // close the settings dialog
- cy.get('button.modal-container__close').click()
- })
- cy.waitUntil(() => cy.contains('.modal-container', 'Account management settings').should((el) => assertNotExistOrNotVisible(el)))
-
- // see that the last login column is not in the header
- cy.get('[data-cy-user-list-header-last-login]').should('not.exist')
-
- // see that the last login column is not in all user rows
- getUserList().find('tbody tr').each(($row) => {
- cy.wrap($row).get('[data-cy-user-list-cell-last-login]').should('not.exist')
- })
- })
-})
diff --git a/cypress/e2e/settings/users_disable.cy.ts b/cypress/e2e/settings/users_disable.cy.ts
deleted file mode 100644
index 23b0397aa9908..0000000000000
--- a/cypress/e2e/settings/users_disable.cy.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import { User } from '@nextcloud/e2e-test-server/cypress'
-import { clearState } from '../../support/commonUtils.ts'
-import { getUserListRow } from './usersUtils.ts'
-
-const admin = new User('admin', 'admin')
-
-describe('Settings: Disable and enable users', function() {
- let testUser: User
-
- beforeEach(function() {
- clearState()
- cy.createRandomUser().then(($user) => {
- testUser = $user
- })
- cy.login(admin)
- // open the User settings
- cy.visit('/settings/users')
- })
-
- // Not guranteed to run but would be nice to cleanup
- after(() => {
- cy.deleteUser(testUser)
- })
-
- it('Can disable the user', function() {
- // ensure user is enabled
- cy.enableUser(testUser)
-
- // see that the user is in the list of active users
- getUserListRow(testUser.userId).within(() => {
- // see that the list of users contains the user testUser
- cy.contains(testUser.userId).should('exist')
- // open the actions menu for the user
- cy.get('[data-cy-user-list-cell-actions] button.action-item__menutoggle').click({ scrollBehavior: 'center' })
- })
-
- // The "Disable account" action in the actions menu is shown and clicked
- cy.get('.action-item__popper .action').contains('Disable account').should('exist').click()
- // When clicked the section is not shown anymore
- getUserListRow(testUser.userId).should('not.exist')
- // But the disabled user section now exists
- cy.get('#disabled').should('exist')
- // Open disabled users section
- cy.get('#disabled a').click()
- cy.url().should('match', /\/disabled/)
- // The list of disabled users should now contain the user
- getUserListRow(testUser.userId).should('exist')
- })
-
- it('Can enable the user', function() {
- // ensure user is disabled
- cy.enableUser(testUser, false).reload()
-
- // Open disabled users section
- cy.get('#disabled a').click()
- cy.url().should('match', /\/disabled/)
-
- // see that the user is in the list of active users
- getUserListRow(testUser.userId).within(() => {
- // see that the list of disabled users contains the user testUser
- cy.contains(testUser.userId).should('exist')
- // open the actions menu for the user
- cy.get('[data-cy-user-list-cell-actions] button.action-item__menutoggle').click({ scrollBehavior: 'center' })
- })
-
- // The "Enable account" action in the actions menu is shown and clicked
- cy.get('.action-item__popper .action').contains('Enable account').should('exist').click()
- // When clicked the section is not shown anymore
- cy.get('#disabled').should('not.exist')
- // Make sure it is still gone after the reload reload
- cy.reload().login(admin)
- cy.get('#disabled').should('not.exist')
- })
-})
diff --git a/cypress/e2e/settings/users_groups.cy.ts b/cypress/e2e/settings/users_groups.cy.ts
deleted file mode 100644
index b8738115a0e07..0000000000000
--- a/cypress/e2e/settings/users_groups.cy.ts
+++ /dev/null
@@ -1,305 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import { User } from '@nextcloud/e2e-test-server/cypress'
-import { clearState } from '../../support/commonUtils.ts'
-import { randomString } from '../../support/utils/randomString.ts'
-import { handlePasswordConfirmation } from '../core-utils.ts'
-import { assertNotExistOrNotVisible, getUserListRow, openEditDialog, saveEditDialog } from './usersUtils.ts'
-
-const admin = new User('admin', 'admin')
-
-describe('Settings: Create groups', () => {
- let groupName: string
-
- after(() => {
- cy.runOccCommand(`group:delete '${groupName!}'`)
- })
-
- before(() => {
- cy.login(admin)
- cy.visit('/settings/users')
- })
-
- it('Can create a group', () => {
- cy.intercept('POST', '**/ocs/v2.php/cloud/groups').as('createGroups')
-
- groupName = randomString(7)
- // open the Create group menu
- cy.get('button[aria-label="Create group"]').click()
-
- cy.get('li[data-cy-users-settings-new-group-name]').within(() => {
- // see that the group name is ""
- cy.get('input').should('exist').and('have.value', '')
- // set the group name to foo
- cy.get('input').type(groupName)
- // see that the group name is foo
- cy.get('input').should('have.value', groupName)
- // submit the group name
- cy.get('input ~ button').click()
- })
-
- // Make sure no confirmation modal is shown
- handlePasswordConfirmation(admin.password)
- cy.wait('@createGroups').its('response.statusCode').should('eq', 200)
-
- // see that the created group is in the list
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
- // see that the list of groups contains the group foo
- cy.contains(groupName).should('exist')
- })
- })
-})
-
-describe('Settings: Assign user to a group', { testIsolation: false }, () => {
- const groupName = randomString(7)
- let testUser: User
-
- after(() => {
- cy.deleteUser(testUser)
- cy.runOccCommand(`group:delete '${groupName}'`)
- })
-
- before(() => {
- clearState()
-
- cy.createRandomUser().then((user) => {
- testUser = user
- })
- cy.runOccCommand(`group:add '${groupName}'`)
- cy.login(admin)
- cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=&offset=*&limit=*').as('loadGroups')
- cy.visit('/settings/users')
- cy.wait('@loadGroups')
- })
-
- it('see that the group is in the list', () => {
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
- .should('exist')
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
- .find('.counter-bubble__counter')
- .should('not.exist') // is hidden when 0
- })
-
- it('see that the user is in the list', () => {
- getUserListRow(testUser.userId)
- .contains(testUser.userId)
- .should('exist')
- .scrollIntoView()
- })
-
- it('assign the group via the edit dialog', () => {
- openEditDialog(testUser)
-
- // Type part of the group name in the groups NcSelect
- cy.get('.edit-dialog [data-test="form"]').within(() => {
- cy.get('[data-test="groups"] input[type="search"]').click({ force: true })
- cy.get('[data-test="groups"] input[type="search"]').type(groupName.slice(0, 5))
- })
-
- // Select the group from the floating dropdown
- cy.get('.vs__dropdown-menu').should('be.visible')
- .contains('li', groupName).click({ force: true })
-
- handlePasswordConfirmation(admin.password)
- saveEditDialog()
-
- cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
- })
-
- it('see the group was successfully assigned', () => {
- // see a new member
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
- .find('.counter-bubble__counter')
- .should('contain', '1')
- })
-
- it('validate the user was added on backend', () => {
- cy.runOccCommand(`user:info --output=json '${testUser.userId}'`).then((output) => {
- cy.wrap(output.exitCode).should('eq', 0)
- cy.wrap(JSON.parse(output.stdout)?.groups).should('include', groupName)
- })
- })
-})
-
-describe('Settings: Delete an empty group', { testIsolation: false }, () => {
- const groupName = randomString(7)
-
- after(() => {
- cy.runOccCommand(`group:delete '${groupName}'`, { failOnNonZeroExit: false })
- })
- before(() => {
- cy.runOccCommand(`group:add '${groupName}'`)
- cy.login(admin)
- cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=&offset=*&limit=*').as('loadGroups')
- cy.visit('/settings/users')
- cy.wait('@loadGroups')
- })
-
- it('see that the group is in the list', () => {
- // see that the list of groups contains the group foo
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
- .should('exist')
- .scrollIntoView()
- // open the actions menu for the group
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
- .find('button.action-item__menutoggle')
- .click({ force: true })
- })
-
- it('can delete the group', () => {
- // The "Delete group" action in the actions menu is shown and clicked
- cy.get('.action-item__popper button').contains('Delete group').should('exist').click({ force: true })
- // And confirmation dialog accepted
- cy.get('.modal-container button').contains('Confirm').click({ force: true })
-
- // Make sure no confirmation modal is shown
- handlePasswordConfirmation(admin.password)
- })
-
- it('deleted group is not shown anymore', () => {
- // see that the list of groups does not contain the group
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]')
- .find('li')
- .not('.app-navigation-caption')
- .should('not.exist')
- // and also not in database
- cy.runOccCommand('group:list --output=json').then(($response) => {
- const groups: string[] = Object.keys(JSON.parse($response.stdout))
- expect(groups).to.not.include(groupName)
- })
- })
-})
-
-describe('Settings: Delete a non empty group', () => {
- let testUser: User
- const groupName = randomString(7)
-
- after(() => {
- cy.runOccCommand(`group:delete '${groupName}'`, { failOnNonZeroExit: false })
- })
-
- before(() => {
- cy.runOccCommand(`group:add '${groupName}'`)
- cy.createRandomUser().then(($user) => {
- testUser = $user
- cy.runOccCommand(`group:addUser '${groupName}' '${$user.userId}'`)
- })
- cy.login(admin)
- cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=&offset=*&limit=*').as('loadGroups')
- cy.visit('/settings/users')
- cy.wait('@loadGroups')
- })
- after(() => cy.deleteUser(testUser))
-
- it('see that the group is in the list', () => {
- // see that the list of groups contains the group
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
- .should('exist')
- .scrollIntoView()
- })
-
- it('can delete the group', () => {
- // open the menu
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').find('li').contains(groupName)
- .find('button.action-item__menutoggle')
- .click({ force: true })
-
- // The "Delete group" action in the actions menu is shown and clicked
- cy.get('.action-item__popper button').contains('Delete group').should('exist').click({ force: true })
- // And confirmation dialog accepted
- cy.get('.modal-container button').contains('Confirm').click({ force: true })
-
- // Make sure no confirmation modal is shown
- handlePasswordConfirmation(admin.password)
- })
-
- it('deleted group is not shown anymore', () => {
- // see that the list of groups does not contain the group foo
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]')
- .find('li')
- .not('.app-navigation-caption')
- .should('not.exist')
- // and also not in database
- cy.runOccCommand('group:list --output=json').then(($response) => {
- const groups: string[] = Object.keys(JSON.parse($response.stdout))
- expect(groups).to.not.include(groupName)
- })
- })
-})
-
-describe('Settings: Sort groups in the UI', () => {
- before(() => {
- // Clear state
- clearState()
-
- // Add two groups and add one user to group B
- cy.runOccCommand('group:add A')
- cy.runOccCommand('group:add B')
- cy.createRandomUser().then((user) => {
- cy.runOccCommand(`group:adduser B '${user.userId}'`)
- })
-
- // Visit the settings as admin
- cy.login(admin)
- cy.visit('/settings/users')
- })
-
- it('Can set sort by member count', () => {
- // open the settings dialog
- cy.contains('button', 'Account management settings').click()
-
- cy.contains('.modal-container', 'Account management settings').within(() => {
- cy.get('[data-test="sortGroupsByMemberCount"] input[type="radio"]').scrollIntoView()
- cy.get('[data-test="sortGroupsByMemberCount"] input[type="radio"]').check({ force: true })
- // close the settings dialog
- cy.get('button.modal-container__close').click()
- })
- cy.waitUntil(() => cy.get('.modal-container').should((el) => assertNotExistOrNotVisible(el)))
- })
-
- it('See that the groups are sorted by the member count', () => {
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
- cy.get('li').not('.app-navigation-caption').eq(0).should('contain', 'B') // 1 member
- cy.get('li').not('.app-navigation-caption').eq(1).should('contain', 'A') // 0 members
- })
- })
-
- it('See that the order is preserved after a reload', () => {
- cy.reload()
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
- cy.get('li').not('.app-navigation-caption').eq(0).should('contain', 'B') // 1 member
- cy.get('li').not('.app-navigation-caption').eq(1).should('contain', 'A') // 0 members
- })
- })
-
- it('Can set sort by group name', () => {
- // open the settings dialog
- cy.contains('button', 'Account management settings').click()
-
- cy.contains('.modal-container', 'Account management settings').within(() => {
- cy.get('[data-test="sortGroupsByName"] input[type="radio"]').scrollIntoView()
- cy.get('[data-test="sortGroupsByName"] input[type="radio"]').check({ force: true })
- // close the settings dialog
- cy.get('button.modal-container__close').click()
- })
- cy.waitUntil(() => cy.get('.modal-container').should((el) => assertNotExistOrNotVisible(el)))
- })
-
- it('See that the groups are sorted by the user count', () => {
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
- cy.get('li').not('.app-navigation-caption').eq(0).should('contain', 'A')
- cy.get('li').not('.app-navigation-caption').eq(1).should('contain', 'B')
- })
- })
-
- it('See that the order is preserved after a reload', () => {
- cy.reload()
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
- cy.get('li').not('.app-navigation-caption').eq(0).should('contain', 'A')
- cy.get('li').not('.app-navigation-caption').eq(1).should('contain', 'B')
- })
- })
-})
diff --git a/cypress/e2e/settings/users_manager.cy.ts b/cypress/e2e/settings/users_manager.cy.ts
deleted file mode 100644
index 68b453b1c9b9d..0000000000000
--- a/cypress/e2e/settings/users_manager.cy.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import { User } from '@nextcloud/e2e-test-server/cypress'
-import { clearState } from '../../support/commonUtils.ts'
-import { handlePasswordConfirmation } from '../core-utils.ts'
-import { openEditDialog, saveEditDialog } from './usersUtils.ts'
-
-const admin = new User('admin', 'admin')
-
-describe('Settings: User Manager Management', function() {
- let user: User
- let manager: User
-
- beforeEach(function() {
- clearState()
- cy.createRandomUser().then(($user) => {
- manager = $user
- return cy.createRandomUser()
- }).then(($user) => {
- user = $user
- cy.login(admin)
- })
- })
-
- it('Can assign a manager through the edit dialog', function() {
- cy.visit('/settings/users')
-
- openEditDialog(user)
-
- // Open the Manager NcSelect and type manager name
- cy.get('.edit-dialog [data-test="form"]').within(() => {
- cy.findByRole('combobox', { name: /Manager/i }).click({ force: true })
- cy.findByRole('combobox', { name: /Manager/i }).type(manager.userId)
- })
-
- // Select the manager from the floating dropdown
- cy.get('.vs__dropdown-menu').should('be.visible')
- .contains('li', manager.userId).click({ force: true })
-
- handlePasswordConfirmation(admin.password)
- saveEditDialog()
-
- cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
-
- // Verify backend
- cy.getUserData(user).then(($result) => {
- expect($result.body).to.contain(`${manager.userId}`)
- })
- })
-
- it('Can remove a manager through the edit dialog', function() {
- // Set manager via backend first.
- // User::getManagerUids() decodes this with JSON_THROW_ON_ERROR, so we
- // must store a JSON array, matching what setManagerUids() writes.
- // Double-quotes are escaped because runOccCommand passes the command
- // through `bash -c "..."`, which would otherwise eat them.
- cy.runOccCommand(`user:setting '${user.userId}' settings manager '[\\"${manager.userId}\\"]'`)
-
- cy.visit('/settings/users')
-
- openEditDialog(user)
-
- // Clear the manager selection inside the dialog
- cy.get('.edit-dialog [data-test="form"]').within(() => {
- cy.get('.user-form__managers .vs__clear').click({ force: true })
- })
-
- handlePasswordConfirmation(admin.password)
- saveEditDialog()
-
- cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
-
- // Verify backend
- cy.getUserData(user).then(($result) => {
- expect($result.body).to.not.contain(`${manager.userId}`)
- expect($result.body).to.contain('')
- })
- })
-})
diff --git a/cypress/e2e/settings/users_modify.cy.ts b/cypress/e2e/settings/users_modify.cy.ts
deleted file mode 100644
index 7a3147124d5b0..0000000000000
--- a/cypress/e2e/settings/users_modify.cy.ts
+++ /dev/null
@@ -1,176 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import { User } from '@nextcloud/e2e-test-server/cypress'
-import { clearState } from '../../support/commonUtils.ts'
-import { handlePasswordConfirmation } from '../core-utils.ts'
-import { openEditDialog, saveEditDialog } from './usersUtils.ts'
-
-const admin = new User('admin', 'admin')
-
-describe('Settings: Change user properties', function() {
- let user: User
-
- beforeEach(function() {
- clearState()
- cy.createRandomUser().then(($user) => {
- user = $user
- })
- cy.login(admin)
- })
-
- it('Can change the display name', function() {
- cy.visit('/settings/users')
-
- openEditDialog(user)
-
- cy.get('.edit-dialog [data-test="form"]').within(() => {
- cy.get('input[data-test="displayName"]').should('have.value', user.userId)
- cy.get('input[data-test="displayName"]').clear()
- cy.get('input[data-test="displayName"]').type('John Doe')
- cy.get('input[data-test="displayName"]').should('have.value', 'John Doe')
- })
-
- handlePasswordConfirmation(admin.password)
- saveEditDialog()
-
- cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
-
- // Verify backend
- cy.runOccCommand(`user:info --output=json '${user.userId}'`).then(($result) => {
- expect($result.exitCode).to.equal(0)
- const info = JSON.parse($result.stdout)
- expect(info?.display_name).to.equal('John Doe')
- })
- })
-
- it('Can change the password', function() {
- cy.visit('/settings/users')
-
- openEditDialog(user)
-
- cy.get('.edit-dialog [data-test="form"]').within(() => {
- cy.get('input[data-test="password"]').should('have.value', '')
- cy.get('input[data-test="password"]').type('newpassword123')
- })
-
- handlePasswordConfirmation(admin.password)
- saveEditDialog()
-
- cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
-
- // Verify by logging in with the new password
- cy.login(new User(user.userId, 'newpassword123'))
- cy.visit('/apps/dashboard')
- cy.url().should('include', '/apps/dashboard')
- })
-
- it('Can change the email address', function() {
- cy.visit('/settings/users')
-
- openEditDialog(user)
-
- cy.get('.edit-dialog [data-test="form"]').within(() => {
- cy.get('input[data-test="email"]').should('have.value', '')
- cy.get('input[data-test="email"]').type('mymail@example.com')
- cy.get('input[data-test="email"]').should('have.value', 'mymail@example.com')
- })
-
- handlePasswordConfirmation(admin.password)
- saveEditDialog()
-
- cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
-
- // Verify backend
- cy.runOccCommand(`user:info --output=json '${user.userId}'`).then(($result) => {
- expect($result.exitCode).to.equal(0)
- const info = JSON.parse($result.stdout)
- expect(info?.email).to.equal('mymail@example.com')
- })
- })
-
- it('Can change the user quota to a predefined one', function() {
- cy.visit('/settings/users')
-
- openEditDialog(user)
-
- cy.get('.edit-dialog [data-test="form"]').within(() => {
- // Open the quota selector
- cy.get('.vs__selected').contains('Unlimited').should('exist')
- cy.findByRole('combobox', { name: /Quota/i }).click({ force: true })
- })
-
- // Dropdown is floating outside the form — select 5 GB
- cy.get('.vs__dropdown-menu').should('be.visible')
- .contains('li', '5 GB').click({ force: true })
-
- handlePasswordConfirmation(admin.password)
- saveEditDialog()
-
- cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
-
- // Verify backend
- cy.runOccCommand(`user:info --output=json '${user.userId}'`).then(($result) => {
- expect($result.exitCode).to.equal(0)
- const info = JSON.parse($result.stdout)
- expect(info?.quota).to.equal('5 GB')
- })
- })
-
- it('Can change the user quota to a custom value', function() {
- cy.visit('/settings/users')
-
- openEditDialog(user)
-
- cy.get('.edit-dialog [data-test="form"]').within(() => {
- // Type a custom quota value
- cy.findByRole('combobox', { name: /Quota/i }).type('4 MB{enter}')
- })
-
- handlePasswordConfirmation(admin.password)
- saveEditDialog()
-
- cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
-
- // Verify backend
- cy.runOccCommand(`user:info --output=json '${user.userId}'`).then(($result) => {
- expect($result.exitCode).to.equal(0)
- // Quota value is stored as bytes, verify it was set
- const info = JSON.parse($result.stdout)
- expect(info?.quota).to.not.equal('none')
- })
- })
-
- it('Can make user a subadmin of a group', function() {
- const groupName = 'userstestgroup'
- cy.runOccCommand(`group:add '${groupName}'`)
-
- cy.visit('/settings/users')
-
- openEditDialog(user)
-
- cy.get('.edit-dialog [data-test="form"]').within(() => {
- // Find the subadmin NcSelect by its label and open the dropdown
- cy.findByRole('combobox', { name: /Admin of the following groups/i }).click({ force: true })
- cy.findByRole('combobox', { name: /Admin of the following groups/i }).type('userstestgroup')
- })
-
- // Select the group from the floating dropdown
- cy.get('.vs__dropdown-menu').should('be.visible')
- .contains('li', groupName).click({ force: true })
-
- handlePasswordConfirmation(admin.password)
- saveEditDialog()
-
- cy.get('.toastify.toast-success').contains(/Account updated/i).should('exist')
-
- // Verify backend
- cy.getUserData(user).then(($response) => {
- expect($response.status).to.equal(200)
- const dom = (new DOMParser()).parseFromString($response.body, 'text/xml')
- expect(dom.querySelector('subadmin element')?.textContent).to.contain(groupName)
- })
- })
-})
diff --git a/cypress/e2e/settings/users_search.cy.ts b/cypress/e2e/settings/users_search.cy.ts
deleted file mode 100644
index 3c2bbc5f57a92..0000000000000
--- a/cypress/e2e/settings/users_search.cy.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-///
-
-import { User } from '@nextcloud/e2e-test-server/cypress'
-import { clearState } from '../../support/commonUtils.ts'
-import { randomString } from '../../support/utils/randomString.ts'
-import { getUserList, getUserListRow } from './usersUtils.ts'
-
-const admin = new User('admin', 'admin')
-
-/** Scope role queries to the account management sidebar so they don't match
- * unrelated elements (e.g. the global unified search bar at the top of the page).
- */
-function accountNav() {
- return cy.findByRole('navigation', { name: /account management/i })
-}
-
-function waitForSearchRequest(alias: string, expectedSearch: string) {
- return cy.wait(alias).then(({ request }) => {
- expect(new URL(request.url).searchParams.get('search')).to.equal(expectedSearch)
- })
-}
-
-describe('Settings: Unified search for accounts and groups', { testIsolation: false }, () => {
- // Use a stable, searchable prefix in the group name so we can match
- // it independently from the random user id below.
- const matchingGroup = `zzz-match-${randomString(5)}`
- const otherGroup = `aaa-other-${randomString(5)}`
- let alice: User
- let bob: User
-
- after(() => {
- cy.deleteUser(alice)
- cy.deleteUser(bob)
- cy.runOccCommand(`group:delete '${matchingGroup}'`, { failOnNonZeroExit: false })
- cy.runOccCommand(`group:delete '${otherGroup}'`, { failOnNonZeroExit: false })
- })
-
- before(() => {
- clearState()
-
- cy.createRandomUser().then((user) => {
- alice = user
- })
- cy.createRandomUser().then((user) => {
- bob = user
- })
-
- cy.runOccCommand(`group:add '${matchingGroup}'`)
- cy.runOccCommand(`group:add '${otherGroup}'`)
-
- cy.login(admin)
- cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=*').as('initialLoadGroups')
- cy.intercept('GET', '**/ocs/v2.php/cloud/users/details?*').as('initialLoadUsers')
- cy.visit('/settings/users')
- cy.wait('@initialLoadGroups')
- cy.wait('@initialLoadUsers')
- })
-
- beforeEach(() => {
- // Intercept aliases reset between tests even with testIsolation: false,
- // so re-register them here to capture requests triggered inside each test.
- cy.intercept('GET', '**/ocs/v2.php/cloud/groups/details?search=*').as('loadGroups')
- cy.intercept('GET', '**/ocs/v2.php/cloud/users/details?*').as('loadUsers')
- })
-
- it('shows the search input in the navigation sidebar', () => {
- accountNav().findByRole('searchbox', { name: /search accounts and groups/i })
- .should('be.visible')
- .and('have.value', '')
- })
-
- it('dispatches the query to both the users and groups API', () => {
- accountNav().findByRole('searchbox', { name: /search accounts and groups/i })
- .type(alice.userId)
-
- // A single keystroke sequence debounces once (300ms), then fans out
- // to both APIs — both requests must carry the same search term.
- cy.wait('@loadUsers').its('request.url').should('include', `search=${alice.userId}`)
- cy.wait('@loadGroups').its('request.url').should('include', `search=${alice.userId}`)
-
- // The user list reflects what the backend returned for this query.
- getUserListRow(alice.userId).should('exist')
- getUserList().should('not.contain', bob.userId)
- })
-
- it('filters the group list when the query matches a group name', () => {
- accountNav().findByRole('searchbox', { name: /search accounts and groups/i })
- .clear()
- .type(matchingGroup)
-
- cy.wait('@loadGroups').its('request.url').should('include', `search=${matchingGroup}`)
-
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]')
- .should('contain', matchingGroup)
- .and('not.contain', otherGroup)
- })
-
- it('resets both lists when the clear button is clicked', () => {
- accountNav().findByRole('button', { name: /clear search/i }).click()
-
- accountNav().findByRole('searchbox', { name: /search accounts and groups/i })
- .should('have.value', '')
-
- waitForSearchRequest('@loadUsers', '')
- waitForSearchRequest('@loadGroups', '')
-
- getUserListRow(alice.userId).should('exist')
- getUserListRow(bob.userId).should('exist')
- cy.get('ul[data-cy-users-settings-navigation-groups="custom"]')
- .should('contain', matchingGroup)
- .and('contain', otherGroup)
- })
-})
diff --git a/tests/playwright/e2e/core/header-contacts-menu.spec.ts b/tests/playwright/e2e/core/header-contacts-menu.spec.ts
index 04eedcb9492eb..f11f3bc2d259e 100644
--- a/tests/playwright/e2e/core/header-contacts-menu.spec.ts
+++ b/tests/playwright/e2e/core/header-contacts-menu.spec.ts
@@ -94,7 +94,6 @@ test.describe('Header: Contacts menu', () => {
test('users from other groups are not seen when user enumeration is restricted to the same group', async ({ page, contactUser }) => {
// Enable restriction first, then open the menu.
await runOcc(['config:app:set', '--value', 'yes', 'core', 'shareapi_restrict_user_enumeration_to_group'])
- await new Promise((resolve) => globalThis.setTimeout(resolve, 3000)) // wait for app config cache to expire
try {
await page.goto('/')
const contactsMenu = new ContactsMenuPage(page)
@@ -106,9 +105,7 @@ test.describe('Header: Contacts menu', () => {
// Close, lift the restriction, reopen — the contact should reappear.
await runOcc(['config:app:set', '--value', 'no', 'core', 'shareapi_restrict_user_enumeration_to_group'])
- const waitForAppConfigCacheTTL = new Promise((resolve) => globalThis.setTimeout(resolve, 3000)) // wait for app config cache to expire
await contactsMenu.close()
- await waitForAppConfigCacheTTL
await page.reload()
await contactsMenu.open()
diff --git a/tests/playwright/e2e/settings/access-levels.spec.ts b/tests/playwright/e2e/settings/access-levels.spec.ts
new file mode 100644
index 0000000000000..528f675e3a8ba
--- /dev/null
+++ b/tests/playwright/e2e/settings/access-levels.spec.ts
@@ -0,0 +1,42 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { expect } from '@playwright/test'
+import { test as userTest } from '../../support/fixtures/random-user-session.ts'
+import { test as adminTest } from '../../support/fixtures/admin-session.ts'
+import { AccountMenuPage } from '../../support/sections/AccountMenuPage.ts'
+
+userTest.describe('Settings: Access levels – regular user', () => {
+ userTest('cannot see the Administration section in the settings navigation', async ({ page }) => {
+ await page.goto('/')
+ const accountMenu = new AccountMenuPage(page)
+ await accountMenu.open()
+ await accountMenu.entry('Settings').getByRole('link').click()
+ await expect(page).toHaveURL(/\/settings\/user$/)
+
+ const appNavigation = page.locator('#app-navigation-vue')
+ await expect(appNavigation.getByRole('list', { name: 'Personal' })).toBeVisible()
+ await expect(appNavigation.getByRole('link', { name: /Personal info/i })).toBeVisible()
+ // Regular users must not see the Administration section
+ await expect(appNavigation.getByRole('list', { name: 'Administration' })).toHaveCount(0)
+ })
+})
+
+adminTest.describe('Settings: Access levels – admin user', () => {
+ adminTest('can see the Administration section in the settings navigation', async ({ page }) => {
+ await page.goto('/')
+ const accountMenu = new AccountMenuPage(page)
+ await accountMenu.open()
+ await accountMenu.entry('Personal settings').getByRole('link').click()
+ await expect(page).toHaveURL(/\/settings\/user$/)
+
+ const appNavigation = page.locator('#app-navigation-vue')
+ await expect(appNavigation.getByRole('list', { name: 'Personal' })).toBeVisible()
+ await expect(appNavigation.getByRole('link', { name: /Personal info/i })).toBeVisible()
+ // Admins must see the Administration section
+ await expect(appNavigation.getByRole('list', { name: 'Administration' })).toBeVisible()
+ await expect(appNavigation.getByRole('link', { name: /Overview/i })).toBeVisible()
+ })
+})
diff --git a/tests/playwright/e2e/settings/personal-info.spec.ts b/tests/playwright/e2e/settings/personal-info.spec.ts
new file mode 100644
index 0000000000000..71aac5d3856bc
--- /dev/null
+++ b/tests/playwright/e2e/settings/personal-info.spec.ts
@@ -0,0 +1,460 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Page, Response } from '@playwright/test'
+import { expect, test as baseTest } from '@playwright/test'
+import type { User } from '@nextcloud/e2e-test-server'
+import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright'
+import { runOcc } from '@nextcloud/e2e-test-server/docker'
+import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts'
+
+// ── Visibility scope labels exactly as rendered in the UI ─────────────────────
+const Visibility = {
+ Private: 'Private',
+ Local: 'Local',
+ Federated: 'Federated',
+ Published: 'Published',
+} as const
+type Visibility = typeof Visibility[keyof typeof Visibility]
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+/**
+ * Register a listener for the next personal-info PUT. Call BEFORE triggering
+ * the save action; await the result after the action and any password dialog.
+ */
+function waitForSave(page: Page): Promise {
+ return page.waitForResponse(
+ (r) => r.request().method() === 'PUT' && r.url().includes('/ocs/v2.php/cloud/users/'),
+ )
+}
+
+/**
+ * Click the scope (visibility) control for `property` and select `scope`.
+ * `property` is the lowercase readable name as it appears in the button's
+ * aria-label (e.g. "email", "full name", "phone number").
+ */
+async function changeVisibility(page: Page, property: string, scope: Visibility, password: string): Promise {
+ const saved = waitForSave(page)
+ await page.getByRole('button', { name: new RegExp(`change scope level of ${property}`, 'i') }).click()
+ await page.getByRole('menuitemradio', { name: scope }).click()
+ await handlePasswordConfirmation(page, password)
+ await saved
+}
+
+// ── Fixture ───────────────────────────────────────────────────────────────────
+
+const test = baseTest.extend<{ user: User }>({
+ user: async ({ context }, use) => {
+ const user = await createRandomUser()
+ // Ensure English UI language and locale so string assertions are stable
+ await runOcc(['user:setting', user.userId, 'core', 'lang', 'en'])
+ await runOcc(['user:setting', user.userId, 'core', 'locale', 'en_US'])
+ await login(context.request, user)
+ await use(user)
+ await runOcc(['user:delete', user.userId])
+ },
+})
+
+// ── Spec ──────────────────────────────────────────────────────────────────────
+
+test.describe('Settings: Change personal information', () => {
+ test.beforeAll(async () => {
+ // Prevent the Fediverse section from making outbound HTTP requests
+ await runOcc(['config:system:set', 'has_internet_connection', '--type', 'bool', '--value', 'false'])
+ // Let each user choose their own language and locale
+ await runOcc(['config:system:delete', 'force_language'])
+ await runOcc(['config:system:delete', 'force_locale'])
+ })
+
+ test.afterAll(async () => {
+ await runOcc(['config:system:delete', 'has_internet_connection'])
+ // Restore English defaults so other test suites are unaffected
+ await runOcc(['config:system:set', 'force_language', '--value', 'en'])
+ await runOcc(['config:system:set', 'force_locale', '--value', 'en_US'])
+ })
+
+ // ── Profile ───────────────────────────────────────────────────────────────
+
+ test('can enable and disable the profile', async ({ page, user }) => {
+ // Profile is enabled by default: the public profile page shows the user id
+ await page.goto(`/u/${user.userId}`)
+ await expect(page.getByRole('heading', { name: user.userId })).toBeVisible()
+
+ await page.goto('/settings/user')
+ const saved1 = waitForSave(page)
+ await page.getByRole('checkbox', { name: 'Enable profile' }).uncheck({ force: true })
+ await handlePasswordConfirmation(page, user.password)
+ await saved1
+
+ // Profile is disabled: the public profile page shows a "not found" heading
+ await page.goto(`/u/${user.userId}`, { waitUntil: 'networkidle' })
+ await expect(page.getByRole('heading', { name: /Profile not found/i })).toBeVisible()
+
+ // Re-enable the profile
+ await page.goto('/settings/user')
+ const saved2 = waitForSave(page)
+ await page.getByRole('checkbox', { name: 'Enable profile' }).check({ force: true } )
+ await handlePasswordConfirmation(page, user.password)
+ await saved2
+
+ await page.goto(`/u/${user.userId}`)
+ await expect(page.getByRole('heading', { name: user.userId })).toBeVisible()
+ })
+
+ // ── Language ──────────────────────────────────────────────────────────────
+
+ test('can change language', async ({ page, user: _ }) => {
+ await page.goto('/settings/user')
+
+ // NcSelect: type to filter, click the option (teleported to )
+ await page.getByRole('combobox', { name: 'Language' }).scrollIntoViewIfNeeded()
+ await page.getByRole('combobox', { name: 'Language' }).fill('Ned')
+ await page.getByRole('option', { name: /Neder\s?lands/ }).click()
+
+ // Language change triggers a full page reload; wait for Dutch UI
+ await expect(page.getByRole('combobox', { name: 'Taal' })).toBeVisible({ timeout: 15_000 })
+ await expect(page.getByText('Help met vertalen')).toBeVisible()
+ })
+
+ // ── Locale ────────────────────────────────────────────────────────────────
+
+ test('can change locale', async ({ page, user: _ }) => {
+ await page.goto('/settings/user')
+
+ await page.getByRole('combobox', { name: 'Locale' }).fill('German')
+ await page.getByRole('option', { name: /^German/ }).filter({ hasText: /\(Germany\)/ }).click()
+
+ // Locale change triggers a full page reload
+ await page.waitForLoadState('networkidle')
+ // After reload the German locale option is reflected in the combobox
+ await expect(page.getByRole('combobox', { name: 'Locale' })).toBeVisible()
+ await expect(page.getByText(/German \(Germany\)/)).toBeVisible()
+ })
+
+ // ── Primary email ─────────────────────────────────────────────────────────
+
+ test('can set primary email and change its visibility', async ({ page, user }) => {
+ await page.goto('/settings/user')
+
+ const emailInput = page.getByRole('textbox', { name: 'Email' })
+ // HTML5 email validation: 'foo bar' is not a valid address
+ await emailInput.fill('foo bar')
+ await expect(emailInput.and(page.locator(':invalid'))).toHaveCount(1)
+
+ // Set a valid email
+ const saved = waitForSave(page)
+ await emailInput.fill('hello@example.com')
+ await handlePasswordConfirmation(page, user.password)
+ await saved
+
+ await page.reload()
+ await expect(emailInput).toHaveValue('hello@example.com')
+
+ // Change visibility and verify it persists across a reload
+ await changeVisibility(page, 'email', Visibility.Local, user.password)
+ await page.reload()
+ await expect(page.getByRole('button', { name: /change scope level of email.*local/i })).toBeVisible()
+
+ // With Local visibility the address is visible on the public profile
+ await page.goto(`/u/${user.userId}`)
+ await expect(page.getByRole('link', { name: 'hello@example.com' })).toBeVisible()
+ })
+
+ test('can delete primary email', async ({ page, user }) => {
+ await page.goto('/settings/user')
+
+ const saved1 = waitForSave(page)
+ const emailInput = page.getByRole('textbox', { name: 'Email' })
+ await emailInput.fill('hello@example.com')
+ await handlePasswordConfirmation(page, user.password)
+ await saved1
+
+ await page.reload()
+ await expect(emailInput).toHaveValue('hello@example.com')
+
+ const saved2 = waitForSave(page)
+ // The "Remove primary email" button is visually inside the input row
+ await page.getByRole('button', { name: 'Remove primary email' }).click({ force: true })
+ await handlePasswordConfirmation(page, user.password)
+ await saved2
+
+ await page.reload()
+ await expect(emailInput).toHaveValue('')
+ })
+
+ // ── Additional emails ─────────────────────────────────────────────────────
+
+ test('can set and delete additional emails', async ({ page, user }) => {
+ await page.goto('/settings/user')
+
+ // "Add additional email" is disabled until a primary email exists
+ await expect(page.getByRole('button', { name: 'Add additional email' })).toBeDisabled()
+
+ // Set a primary email first
+ const emailInput = page.getByRole('textbox', { name: 'Email' })
+ const saved1 = waitForSave(page)
+ await emailInput.fill('primary@example.com')
+ await handlePasswordConfirmation(page, user.password)
+ await saved1
+
+ // Add first additional email
+ await page.getByRole('button', { name: 'Add additional email' }).click()
+ // Disabled again until the new field has a value
+ await expect(page.getByRole('button', { name: 'Add additional email' })).toBeDisabled()
+
+ const saved2 = waitForSave(page)
+ await page.getByRole('textbox', { name: 'Additional email address 1' }).fill('1@example.com')
+ await handlePasswordConfirmation(page, user.password)
+ await saved2
+
+ // Add second additional email
+ await page.getByRole('button', { name: 'Add additional email' }).click()
+
+ const saved3 = waitForSave(page)
+ await page.getByRole('textbox', { name: 'Additional email address 2' }).fill('2@example.com')
+ await handlePasswordConfirmation(page, user.password)
+ await saved3
+
+ // Both additional addresses persist across a reload
+ await page.reload()
+ await expect(page.getByRole('textbox', { name: 'Additional email address 1' })).toHaveValue('1@example.com')
+ await expect(page.getByRole('textbox', { name: 'Additional email address 2' })).toHaveValue('2@example.com')
+
+ // Delete the first additional email via its options menu
+ await page.getByRole('button', { name: 'Options for additional email address 1' }).click({ force: true })
+ const saved4 = waitForSave(page)
+ await page.getByRole('menuitem', { name: 'Delete email' }).click({ force: true })
+ await handlePasswordConfirmation(page, user.password)
+ await saved4
+
+ // After deletion the second address shifts into position 1
+ await page.reload()
+ await expect(page.getByRole('textbox', { name: 'Additional email address' })).toHaveValue('2@example.com')
+ })
+
+ // ── Full name ─────────────────────────────────────────────────────────────
+
+ test('can set full name and change its visibility', async ({ page, user }) => {
+ await page.goto('/settings/user')
+
+ const saved = waitForSave(page)
+ await page.getByRole('textbox', { name: 'Full name' }).fill('Jane Doe')
+ await handlePasswordConfirmation(page, user.password)
+ await saved
+
+ await page.reload()
+ await expect(page.getByRole('textbox', { name: 'Full name' })).toHaveValue('Jane Doe')
+
+ await changeVisibility(page, 'full name', Visibility.Local, user.password)
+ await page.reload()
+ await expect(page.getByRole('button', { name: /change scope level of full name.*local/i })).toBeVisible()
+
+ // With Local visibility the display name appears on the public profile
+ await page.goto(`/u/${user.userId}`)
+ await expect(page.getByRole('heading', { name: 'Jane Doe' })).toBeVisible()
+ })
+
+ // ── Phone number ──────────────────────────────────────────────────────────
+
+ test('can set phone number and its visibility', async ({ page, user }) => {
+ await page.goto('/settings/user')
+
+ const saved = waitForSave(page)
+ const phoneInput = page.getByRole('textbox', { name: 'Phone number' })
+ await phoneInput.fill('+49 89 721010 99701')
+ await handlePasswordConfirmation(page, user.password)
+ await saved
+
+ // Server normalises to E.164 format
+ await page.reload()
+ await expect(phoneInput).toHaveValue('+498972101099701')
+
+ await changeVisibility(page, 'phone number', Visibility.Private, user.password)
+ await page.reload()
+ await expect(page.getByRole('button', { name: /change scope level of phone number.*private/i })).toBeVisible()
+ })
+
+ test('can set phone number with phone region', async ({ page, user }) => {
+ await page.goto('/settings/user')
+ const phoneInput = page.getByRole('textbox', { name: 'Phone number' })
+
+ // Without a phone region, a local-format number is rejected
+ await phoneInput.fill('0 40 428990')
+ // NcTextField marks the field with an error class but we verify via the saved value
+ // being empty after reload (the server rejects the malformed number)
+
+ // Set the default region and reload
+ await runOcc(['config:system:set', 'default_phone_region', '--value', 'DE'])
+ await page.reload()
+
+ const saved = waitForSave(page)
+ await phoneInput.fill('0 40 428990')
+ await handlePasswordConfirmation(page, user.password)
+ await saved
+
+ await page.reload()
+ await expect(phoneInput).toHaveValue('+4940428990')
+
+ await runOcc(['config:system:delete', 'default_phone_region'])
+ })
+
+ test('can reset phone number', async ({ page, user }) => {
+ await page.goto('/settings/user')
+ const phoneInput = page.getByRole('textbox', { name: 'Phone number' })
+
+ const saved1 = waitForSave(page)
+ await phoneInput.fill('+49 40 428990')
+ await handlePasswordConfirmation(page, user.password)
+ await saved1
+
+ await page.reload()
+ await expect(phoneInput).toHaveValue('+4940428990')
+
+ const saved2 = waitForSave(page)
+ await phoneInput.clear()
+ await handlePasswordConfirmation(page, user.password)
+ await saved2
+
+ await page.reload()
+ await expect(phoneInput).toHaveValue('')
+ })
+
+ // ── Social media ──────────────────────────────────────────────────────────
+
+ test('can reset a social media property', async ({ page, user }) => {
+ await page.goto('/settings/user')
+ const fediverseInput = page.getByRole('textbox', { name: 'Fediverse (e.g. Mastodon)' })
+
+ const saved1 = waitForSave(page)
+ await fediverseInput.fill('@nextcloud@mastodon.social')
+ await handlePasswordConfirmation(page, user.password)
+ await saved1
+
+ // The server strips the leading '@'
+ await page.reload()
+ await expect(fediverseInput).toHaveValue('nextcloud@mastodon.social')
+
+ const saved2 = waitForSave(page)
+ await fediverseInput.clear()
+ await handlePasswordConfirmation(page, user.password)
+ await saved2
+
+ await page.reload()
+ await expect(fediverseInput).toHaveValue('')
+ })
+
+ // ── Website ───────────────────────────────────────────────────────────────
+
+ test('can set website and change its visibility', async ({ page, user }) => {
+ await page.goto('/settings/user')
+
+ const websiteInput = page.getByRole('textbox', { name: 'Website' })
+ // HTML5 URL validation: 'foo bar' is not a valid URL
+ await websiteInput.fill('foo bar')
+ await expect(websiteInput.and(page.locator(':invalid'))).toHaveCount(1)
+
+ const saved = waitForSave(page)
+ await websiteInput.fill('http://example.com')
+ await handlePasswordConfirmation(page, user.password)
+ await saved
+
+ await page.reload()
+ await expect(websiteInput).toHaveValue('http://example.com')
+
+ await changeVisibility(page, 'website', Visibility.Private, user.password)
+ await page.reload()
+ await expect(page.getByRole('button', { name: /change scope level of website.*private/i })).toBeVisible()
+
+ // Change to Local so the URL appears on the public profile
+ await changeVisibility(page, 'website', Visibility.Local, user.password)
+ await page.goto(`/u/${user.userId}`)
+ await expect(page.getByText('http://example.com')).toBeVisible()
+ })
+
+ // ── Generic properties (any value, all visibility levels) ─────────────────
+ // Each property is tested in its own test so failures are isolated.
+
+ const genericProperties = [
+ { label: 'Location', scopeProperty: 'location', value: 'Berlin' },
+ { label: 'Fediverse (e.g. Mastodon)', scopeProperty: 'fediverse', value: 'nextcloud@mastodon.xyz' },
+ ] as const
+
+ for (const { label, scopeProperty, value } of genericProperties) {
+ test(`can set ${label} and change its visibility`, async ({ page, user }) => {
+ await page.goto('/settings/user')
+
+ const saved = waitForSave(page)
+ await page.getByRole('textbox', { name: label }).fill(value)
+ await handlePasswordConfirmation(page, user.password)
+ await saved
+
+ await expect(page.getByRole('textbox', { name: label })).toHaveValue(value)
+ await expect(
+ page.getByRole('button', { name: new RegExp(`change scope level of ${scopeProperty}.*local`, 'i') }),
+ ).toHaveCount(1)
+
+ // Cycle Private → Local and verify the final state persists
+ await changeVisibility(page, scopeProperty, Visibility.Federated, user.password)
+ await expect(
+ page.getByRole('button', { name: new RegExp(`change scope level of ${scopeProperty}.*federated`, 'i') }),
+ ).toBeVisible()
+
+ await page.reload()
+ await expect(
+ page.getByRole('button', { name: new RegExp(`change scope level of ${scopeProperty}.*federated`, 'i') }),
+ ).toBeVisible()
+
+ await changeVisibility(page, scopeProperty, Visibility.Private, user.password)
+ await expect(
+ page.getByRole('button', { name: new RegExp(`change scope level of ${scopeProperty}.*private`, 'i') }),
+ ).toBeVisible()
+
+ // With Local visibility the value appears on the public profile
+ await page.goto(`/u/${user.userId}`)
+ await expect(page.getByText(value)).toBeVisible()
+ })
+ }
+
+ // ── Non-federated properties (Local and Private only) ─────────────────────
+
+ const nonfederatedProperties = [
+ { label: 'Organisation', scopeProperty: 'organisation' },
+ { label: 'Role', scopeProperty: 'role' },
+ { label: 'Headline', scopeProperty: 'headline' },
+ { label: 'About', scopeProperty: 'about' },
+ ] as const
+
+ for (const { label, scopeProperty } of nonfederatedProperties) {
+ test(`can set ${label} and change its visibility`, async ({ page, user }) => {
+ // Use a value unique to this property to identify it on the profile page
+ const uniqueValue = `${label.toUpperCase()} ${label.toLowerCase()}`
+ await page.goto('/settings/user')
+
+ const input = page.getByRole('textbox', { name: label })
+
+ const saved = waitForSave(page)
+ await input.fill(uniqueValue)
+ await handlePasswordConfirmation(page, user.password)
+ await saved
+
+ await page.reload()
+ await expect(input).toHaveValue(uniqueValue)
+
+ // Toggle Private → Local (the two supported scopes for these properties)
+ await changeVisibility(page, scopeProperty, Visibility.Private, user.password)
+ await page.reload()
+ await expect(
+ page.getByRole('button', { name: new RegExp(`change scope level of ${scopeProperty}.*private`, 'i') }),
+ ).toBeVisible()
+
+ await changeVisibility(page, scopeProperty, Visibility.Local, user.password)
+
+ // With Local visibility the value appears on the public profile
+ await page.goto(`/u/${user.userId}`)
+ await expect(page.getByText(uniqueValue)).toBeVisible()
+ })
+ }
+})
diff --git a/tests/playwright/e2e/users/users-columns.spec.ts b/tests/playwright/e2e/users/users-columns.spec.ts
new file mode 100644
index 0000000000000..8df06216d2a95
--- /dev/null
+++ b/tests/playwright/e2e/users/users-columns.spec.ts
@@ -0,0 +1,77 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { expect } from '@playwright/test'
+import { test } from '../../support/fixtures/admin-session.ts'
+import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts'
+
+test.describe('Settings: Show and hide columns', () => {
+ test.beforeEach(async ({ page }) => {
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ // Reset: open settings, uncheck all optional columns, re-enable last-login
+ await settingsPage.openSettingsDialog()
+ const dialog = settingsPage.settingsDialog()
+
+ // Uncheck both optional columns
+ for (const name of ['Show language', 'Show last login']) {
+ const checkbox = dialog.getByRole('checkbox', { name })
+ if (await checkbox.isChecked()) {
+ await checkbox.uncheck({ force: true })
+ }
+ }
+
+ // Re-enable last-login so each test starts from a known baseline
+ await dialog.getByRole('checkbox', { name: 'Show last login' }).check({ force: true })
+ await settingsPage.closeSettingsDialog()
+ })
+
+ test('can show the Language column', async ({ page }) => {
+ const settingsPage = new SettingsUsersPage(page)
+
+ // Language column must not be visible before the toggle
+ await expect(page.getByRole('columnheader', { name: /Language/i })).toHaveCount(0)
+ await expect(page.locator('[data-cy-user-list-cell-language]').first()).toHaveCount(0)
+
+ await settingsPage.openSettingsDialog()
+ const dialog = settingsPage.settingsDialog()
+ const checkbox = dialog.getByRole('checkbox', { name: 'Show language' })
+ await expect(checkbox).not.toBeChecked()
+ await checkbox.check({ force: true })
+ await expect(checkbox).toBeChecked()
+ await settingsPage.closeSettingsDialog()
+
+ // Language column header must now be visible
+ await expect(page.getByRole('columnheader', { name: /Language/i })).toBeVisible()
+ // Every row must have a language cell
+ await expect(page.locator('[data-cy-user-list-cell-language]').first()).toBeVisible()
+
+ // Reload to verify the preference is persisted (stored in DB, not just localStorage)
+ await page.evaluate(() => localStorage.clear())
+ await page.reload()
+ await expect(page.getByRole('columnheader', { name: /Language/i })).toBeVisible()
+ })
+
+ test('can hide the Last login column', async ({ page }) => {
+ const settingsPage = new SettingsUsersPage(page)
+
+ // Last login column must be visible (enabled in beforeEach)
+ await expect(page.getByRole('columnheader', { name: /Last login/i })).toBeVisible()
+ await expect(page.locator('[data-cy-user-list-cell-last-login]').first()).toBeVisible()
+
+ await settingsPage.openSettingsDialog()
+ const dialog = settingsPage.settingsDialog()
+ const checkbox = dialog.getByRole('checkbox', { name: 'Show last login' })
+ await expect(checkbox).toBeChecked()
+ await checkbox.uncheck({ force: true })
+ await expect(checkbox).not.toBeChecked()
+ await settingsPage.closeSettingsDialog()
+
+ // Column header must now be gone
+ await expect(page.getByRole('columnheader', { name: /Last login/i })).toHaveCount(0)
+ await expect(page.locator('[data-cy-user-list-cell-last-login]').first()).toHaveCount(0)
+ })
+})
diff --git a/tests/playwright/e2e/users/users-disable.spec.ts b/tests/playwright/e2e/users/users-disable.spec.ts
new file mode 100644
index 0000000000000..aa50a701b4372
--- /dev/null
+++ b/tests/playwright/e2e/users/users-disable.spec.ts
@@ -0,0 +1,76 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { expect, test as baseTest } from '@playwright/test'
+import { type User } from '@nextcloud/e2e-test-server'
+import { createRandomUser } from '@nextcloud/e2e-test-server/playwright'
+import { runOcc } from '@nextcloud/e2e-test-server/docker'
+import { test as adminTest } from '../../support/fixtures/admin-session.ts'
+import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts'
+
+const test = adminTest.extend<{ testUser: User }>({
+ testUser: async ({}, use) => {
+ const user = await createRandomUser()
+ await use(user)
+ await runOcc(['user:delete', user.userId])
+ },
+})
+
+test.describe('Settings: Disable and enable users', () => {
+ test('can disable a user', async ({ page, testUser }) => {
+ // Ensure user is enabled
+ await runOcc(['user:enable', testUser.userId])
+
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ await expect(settingsPage.userRow(testUser.userId)).toBeVisible()
+
+ await settingsPage.openActionsMenu(testUser.userId)
+ await page.getByRole('menuitem', { name: 'Disable account' }).click()
+
+ // User should no longer be in the main list
+ await expect(settingsPage.userRow(testUser.userId)).toHaveCount(0)
+
+ // Disabled accounts nav link should now appear
+ const disabledLink = settingsPage.navigation().getByRole('link', { name: /Disabled accounts/i })
+ await expect(disabledLink).toBeVisible()
+
+ // Navigate to disabled users
+ await disabledLink.click()
+ await expect(page).toHaveURL(/\/disabled/)
+
+ // The disabled user should be in the list
+ await settingsPage.userList().waitFor({ state: 'visible' })
+ await expect(settingsPage.userRow(testUser.userId)).toBeVisible()
+ })
+
+ test('can enable a user', async ({ page, testUser }) => {
+ // Ensure user is disabled
+ await runOcc(['user:disable', testUser.userId])
+
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ // Navigate to disabled users
+ const disabledLink = settingsPage.navigation().getByRole('link', { name: /Disabled accounts/i })
+ await expect(disabledLink).toBeVisible()
+ await disabledLink.click()
+ await expect(page).toHaveURL(/\/disabled/)
+ await settingsPage.userList().waitFor({ state: 'visible' })
+
+ const waitForEnableRequest = page.waitForResponse((r) => r.request().url().match(/\/ocs\/v2\.php\/cloud\/users\/[^/]+\/enable/) !== null)
+ await settingsPage.openActionsMenu(testUser.userId)
+ await page.getByRole('menuitem', { name: 'Enable account' }).click()
+ await waitForEnableRequest
+
+ // Disabled accounts section should disappear (no more disabled users)
+ await expect(settingsPage.navigation().getByRole('link', { name: /Disabled accounts/i })).toHaveCount(0)
+
+ // After reload, still no disabled accounts section
+ await page.reload()
+ await expect(settingsPage.navigation().getByRole('link', { name: /Disabled accounts/i })).toHaveCount(0)
+ })
+})
diff --git a/tests/playwright/e2e/users/users-group-admin.spec.ts b/tests/playwright/e2e/users/users-group-admin.spec.ts
new file mode 100644
index 0000000000000..fd4e38e703d1a
--- /dev/null
+++ b/tests/playwright/e2e/users/users-group-admin.spec.ts
@@ -0,0 +1,65 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/e2e-test-server'
+
+import { expect, test as baseTest } from '@playwright/test'
+import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright'
+import { runOcc } from '@nextcloud/e2e-test-server/docker'
+import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts'
+import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts'
+
+const test = baseTest.extend<{ subadmin: User; group: string }>({
+ group: async ({}, use) => {
+ const groupName = crypto.randomUUID()
+ await runOcc(['group:add', groupName])
+ await use(groupName)
+ await runOcc(['group:delete', groupName]).catch(() => {})
+ },
+ subadmin: async ({ group, request }, use) => {
+ const user = await createRandomUser()
+ await runOcc(['group:adduser', group, user.userId])
+ // Grant subadmin rights via OCS API authenticated as admin
+ await request.post(`/ocs/v2.php/cloud/users/${user.userId}/subadmins`, {
+ headers: {
+ 'OCS-APIRequest': 'true',
+ Authorization: 'Basic ' + Buffer.from('admin:admin').toString('base64'),
+ },
+ form: { groupid: group },
+ })
+ await use(user)
+ await runOcc(['user:delete', user.userId])
+ },
+})
+
+test.describe('Settings: Create accounts as a group admin', () => {
+ test('can create a user with the group pre-filled', async ({ page, context, subadmin, group }) => {
+ // Log in as the subadmin (not as admin)
+ await login(context.request, subadmin)
+
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ await settingsPage.openNewUserDialog()
+ const dialog = settingsPage.newUserDialog()
+
+ // The subadmin's single group must be pre-selected in the groups field.
+ // NcSelect renders selected values as .vs__selected (no accessible role).
+ await expect(dialog.locator('.vs__selected').filter({ hasText: group })).toBeVisible()
+
+ // Fill in the new user details and submit
+ const newUserId = crypto.randomUUID()
+ await dialog.getByLabel(/Account name/).fill(newUserId)
+ await dialog.getByLabel(/Password/).and(page.locator('input')).fill('password123')
+
+ await dialog.getByRole('button', { name: 'Add new account' }).click()
+ await handlePasswordConfirmation(page, subadmin.password)
+ await dialog.waitFor({ state: 'hidden' })
+
+ await expect(settingsPage.userRow(newUserId)).toContainText(newUserId)
+
+ await runOcc(['user:delete', newUserId])
+ })
+})
diff --git a/tests/playwright/e2e/users/users-groups.spec.ts b/tests/playwright/e2e/users/users-groups.spec.ts
new file mode 100644
index 0000000000000..6340022a98def
--- /dev/null
+++ b/tests/playwright/e2e/users/users-groups.spec.ts
@@ -0,0 +1,235 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/e2e-test-server'
+
+import { expect } from '@playwright/test'
+import { createRandomUser } from '@nextcloud/e2e-test-server/playwright'
+import { runOcc } from '@nextcloud/e2e-test-server/docker'
+import { test } from '../../support/fixtures/admin-session.ts'
+import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts'
+import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts'
+
+// ── Create group ──────────────────────────────────────────────────────────────
+
+test('Account Management: Can create a group', async ({ page }) => {
+ const groupName = crypto.randomUUID()
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ try {
+ const createGroupsResponsePromise = page.waitForResponse(/ocs\/v2\.php\/cloud\/groups($|\?)/)
+
+ await page.getByRole('button', { name: 'Create group' }).click()
+ await page.getByLabel('Group name').fill(groupName)
+ await page.getByLabel('Group name').press('Enter')
+
+ await handlePasswordConfirmation(page)
+ await createGroupsResponsePromise
+
+ await expect(settingsPage.customGroupsList()).toContainText(groupName)
+ } finally {
+ await runOcc(['group:delete', groupName]).catch(() => {})
+ }
+})
+
+// ── Assign user to group ──────────────────────────────────────────────────────
+
+const userGroupTest = test.extend<{ testUser: User, testGroup: string }>({
+ async testUser({}, use) {
+ const testUser = await createRandomUser()
+ await use(testUser)
+ await runOcc(['user:delete', testUser.userId])
+ },
+ async testGroup({}, use) {
+ const testGroup = crypto.randomUUID()
+ await runOcc(['group:add', testGroup])
+ await use(testGroup)
+ await runOcc(['group:delete', testGroup])
+ },
+})
+
+userGroupTest('Account Management: Assign user to a group', async ({ page, testGroup, testUser }) => {
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ // group is in the list with no members
+ await expect(settingsPage.groupListItem(testGroup)).toBeVisible()
+ // Counter bubble is absent when member count is 0
+ await expect(settingsPage.groupListItem(testGroup).locator('.counter-bubble__counter')).toHaveCount(0)
+ // user is in the list
+ await expect(settingsPage.userRow(testUser.userId)).toBeVisible()
+
+ // can assign the group via the edit dialog
+ await settingsPage.openEditDialog(testUser.userId)
+ const dialog = settingsPage.editUserDialog()
+ const groupsCombobox = dialog.getByRole('combobox', { name: /Member of the following groups/i })
+ const searchRequest = page.waitForResponse((r) => r.request().url().match(new RegExp('/ocs/v2\\.php/cloud/groups/details\\?(.+&|)search=' + testGroup.slice(0, 5))) !== null)
+ await groupsCombobox.fill(testGroup.slice(0, 5))
+ await searchRequest
+
+ await page.getByRole('option', { name: new RegExp(testGroup.slice(0, 8)) }).click()
+
+ await handlePasswordConfirmation(page)
+ await settingsPage.saveEditDialog()
+ await expect(page.getByText(/Account updated/i)).toBeVisible()
+
+ // user is now group now shows 1 member
+ await expect(settingsPage.groupListItem(testGroup).locator('.counter-bubble__counter')).toHaveText('1')
+ // backend confirms the user is in the group
+ const info = JSON.parse(await runOcc(['user:info', '--output=json', testUser.userId]))
+ expect(info?.groups).toContain(testGroup)
+})
+
+// ── Delete an empty group ─────────────────────────────────────────────────────
+
+test.describe('Settings: Delete an empty group', () => {
+ const groupName = crypto.randomUUID()
+
+ test.beforeAll(async () => {
+ await runOcc(['group:add', groupName])
+ })
+
+ test.afterAll(async () => {
+ await runOcc(['group:delete', groupName]).catch(() => {})
+ })
+
+ test('can delete an empty group', async ({ page }) => {
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ const groupItem = settingsPage.groupListItem(groupName)
+ await expect(groupItem).toBeVisible()
+
+ // Open the group's actions menu
+ await groupItem.hover()
+ await expect(groupItem.getByRole('button', { name: /Actions/i })).toBeVisible()
+ await groupItem.getByRole('button', { name: /Actions/i }).click()
+
+ // and delete the group
+ await page.getByRole('button', { name: 'Delete group' }).click()
+ await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click()
+ await handlePasswordConfirmation(page)
+
+ // Group must be gone from the UI
+ await expect(settingsPage.groupListItem(groupName)).toHaveCount(0)
+
+ // Verify backend
+ const groups: Record = JSON.parse(await runOcc(['group:list', '--output=json']))
+ expect(Object.keys(groups)).not.toContain(groupName)
+ })
+})
+
+// ── Delete a non-empty group ──────────────────────────────────────────────────
+
+test.describe('Settings: Delete a non-empty group', () => {
+ const groupName = crypto.randomUUID()
+ let testUser: User
+
+ test.beforeAll(async () => {
+ testUser = await createRandomUser()
+ await runOcc(['group:add', groupName])
+ await runOcc(['group:adduser', groupName, testUser.userId])
+ })
+
+ test.afterAll(async () => {
+ await runOcc(['user:delete', testUser.userId])
+ await runOcc(['group:delete', groupName]).catch(() => {})
+ })
+
+ test('can delete a non-empty group', async ({ page }) => {
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ const groupItem = settingsPage.groupListItem(groupName)
+ await expect(groupItem).toBeVisible()
+
+ // Open the group's actions menu
+ await groupItem.hover()
+ expect(groupItem.getByRole('button', { name: /Actions/i })).toBeVisible()
+ await groupItem.getByRole('button', { name: /Actions/i }).click()
+
+ // and delete the group
+ await page.getByRole('button', { name: 'Delete group' }).click()
+ await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click()
+ await handlePasswordConfirmation(page)
+
+ await expect(settingsPage.groupListItem(groupName)).toHaveCount(0)
+
+ const groups: Record = JSON.parse(await runOcc(['group:list', '--output=json']))
+ expect(Object.keys(groups)).not.toContain(groupName)
+ })
+})
+
+// ── Sort groups ───────────────────────────────────────────────────────────────
+const sortGroupsTest = test.extend<{ testUser: User, testGroups: [string, string] }>({
+ async testGroups({ testUser }, use) {
+ const suffix = crypto.randomUUID().slice(0, 8)
+ const groupA = `A-${suffix}`
+ const groupB = `B-${suffix}`
+
+ await runOcc(['group:add', groupA])
+ await runOcc(['group:add', groupB])
+ await runOcc(['group:adduser', groupB, testUser.userId])
+ await use([groupA, groupB])
+ await runOcc(['group:delete', groupA]).catch(() => {})
+ await runOcc(['group:delete', groupB]).catch(() => {})
+ },
+ testUser: async ({}, use) => {
+ const testUser = await createRandomUser()
+ await use(testUser)
+ await runOcc(['user:delete', testUser.userId])
+ },
+})
+
+sortGroupsTest('Settings: Sort groups by member count and then by name', async ({ page, testGroups }) => {
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ // ── sort by member count ──
+ await settingsPage.openSettingsDialog()
+ await settingsPage.settingsDialog()
+ .getByRole('radio', { name: 'By member count' })
+ .check({ force: true })
+ await settingsPage.closeSettingsDialog()
+
+ // B (1 member) must come before A (0 members)
+ await checkGroupOrder([testGroups[1], testGroups[0]], settingsPage)
+
+ // Reload to confirm persistence
+ await page.reload()
+ await checkGroupOrder([testGroups[1], testGroups[0]], settingsPage)
+
+ // ── sort by name ──
+ await settingsPage.openSettingsDialog()
+ await settingsPage.settingsDialog().getByRole('radio', { name: 'By name' }).check({ force: true })
+ await settingsPage.closeSettingsDialog()
+
+ // A comes before B alphabetically
+ await checkGroupOrder([testGroups[0], testGroups[1]], settingsPage)
+
+ // Reload to confirm persistence
+ await page.reload()
+ await checkGroupOrder([testGroups[0], testGroups[1]], settingsPage)
+})
+
+/**
+ * Check that the groups are in the expected order in the UI.
+ *
+ * @param order - The expected group order
+ * @param settingsPage - The settings page
+ */
+async function checkGroupOrder(order: string[], settingsPage: SettingsUsersPage) {
+ // B (1 member) must come before A (0 members)
+ const listItems = settingsPage.customGroupsList().getByRole('listitem')
+ for (const group of order) {
+ await expect(listItems.filter({ hasText: group })).toHaveCount(1)
+ }
+
+ const contents = (await listItems.allTextContents())
+ .map((text) => text.trim().replaceAll(/\s+.*/g, '')) // trim and remove member count
+ .filter((text) => order.includes(text)) // filter out other groups that might be in the list
+ expect(contents).toEqual(order)
+}
diff --git a/tests/playwright/e2e/users/users-manager.spec.ts b/tests/playwright/e2e/users/users-manager.spec.ts
new file mode 100644
index 0000000000000..87835f640a307
--- /dev/null
+++ b/tests/playwright/e2e/users/users-manager.spec.ts
@@ -0,0 +1,82 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { expect } from '@playwright/test'
+import { type User } from '@nextcloud/e2e-test-server'
+import { createRandomUser } from '@nextcloud/e2e-test-server/playwright'
+import { runOcc } from '@nextcloud/e2e-test-server/docker'
+import { test as adminTest } from '../../support/fixtures/admin-session.ts'
+import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts'
+import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts'
+
+const test = adminTest.extend<{ user: User; manager: User }>({
+ user: async ({}, use) => {
+ const u = await createRandomUser()
+ await use(u)
+ await runOcc(['user:delete', u.userId])
+ },
+ manager: async ({}, use) => {
+ const u = await createRandomUser()
+ await use(u)
+ await runOcc(['user:delete', u.userId])
+ },
+})
+
+test.describe('Settings: User Manager Management', () => {
+ test('can assign a manager through the edit dialog', async ({ page, user, manager }) => {
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ await settingsPage.openEditDialog(user.userId)
+ const dialog = settingsPage.editUserDialog()
+
+ const managerCombobox = dialog.getByRole('combobox', { name: /Manager/i })
+ await managerCombobox.fill(manager.userId)
+ await page.getByRole('option', { name: manager.userId }).click()
+
+ await handlePasswordConfirmation(page)
+ await settingsPage.saveEditDialog()
+
+ await expect(page.getByText(/Account updated/i)).toBeVisible()
+
+ // Verify via OCS API (page shares admin auth cookies)
+ const response = await page.request.get(
+ `/ocs/v2.php/cloud/users/${user.userId}`,
+ { headers: { 'OCS-APIRequest': 'true', Accept: 'application/json' } },
+ )
+ const data = await response.json()
+ expect(data?.ocs?.data?.manager).toBe(manager.userId)
+ })
+
+ test('can remove a manager through the edit dialog', async ({ page, user, manager }) => {
+ // Set manager via OCC first
+ await runOcc([
+ 'user:setting', user.userId, 'settings', 'manager',
+ `["${manager.userId}"]`,
+ ])
+
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ await settingsPage.openEditDialog(user.userId)
+ const dialog = settingsPage.editUserDialog()
+
+ // Clear the currently-set manager using the NcSelect's clear button
+ await dialog.getByRole('button', { name: /Clear Selected/i }).click()
+
+ await handlePasswordConfirmation(page)
+ await settingsPage.saveEditDialog()
+
+ await expect(page.getByText(/Account updated/i)).toBeVisible()
+
+ // Verify backend: manager must be empty
+ const response = await page.request.get(
+ `/ocs/v2.php/cloud/users/${user.userId}`,
+ { headers: { 'OCS-APIRequest': 'true', Accept: 'application/json' } },
+ )
+ const data = await response.json()
+ expect(data?.ocs?.data?.manager).toBeFalsy()
+ })
+})
diff --git a/tests/playwright/e2e/users/users-modify.spec.ts b/tests/playwright/e2e/users/users-modify.spec.ts
new file mode 100644
index 0000000000000..f82ede2d31bba
--- /dev/null
+++ b/tests/playwright/e2e/users/users-modify.spec.ts
@@ -0,0 +1,165 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { expect } from '@playwright/test'
+import { type User } from '@nextcloud/e2e-test-server'
+import { createRandomUser, login } from '@nextcloud/e2e-test-server/playwright'
+import { runOcc } from '@nextcloud/e2e-test-server/docker'
+import { test as adminTest } from '../../support/fixtures/admin-session.ts'
+import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts'
+import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts'
+
+const test = adminTest.extend<{ user: User }>({
+ user: async ({}, use) => {
+ const user = await createRandomUser()
+ await use(user)
+ await runOcc(['user:delete', user.userId])
+ },
+})
+
+test.describe('Settings: Change user properties', () => {
+ test('can change the display name', async ({ page, user }) => {
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ await settingsPage.openEditDialog(user.userId)
+ const dialog = settingsPage.editUserDialog()
+ const displayNameInput = dialog.getByLabel('Display name')
+ await expect(displayNameInput).toHaveValue(user.userId)
+ await displayNameInput.fill('John Doe')
+
+ await handlePasswordConfirmation(page)
+ await settingsPage.saveEditDialog()
+
+ await expect(page.getByText(/Account updated/i)).toBeVisible()
+
+ // Verify backend
+ const info = JSON.parse(await runOcc(['user:info', '--output=json', user.userId]))
+ expect(info?.display_name).toBe('John Doe')
+ })
+
+ test('can change the password', async ({ page, user, context }) => {
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ await settingsPage.openEditDialog(user.userId)
+ const dialog = settingsPage.editUserDialog()
+ const passwordInput = dialog.getByLabel(/New password/i).and(page.locator('input')) // hack because there is no accessible role for input fields with type=password
+ await expect(passwordInput).toHaveValue('')
+ await passwordInput.fill('newpassword123')
+
+ await handlePasswordConfirmation(page)
+ await settingsPage.saveEditDialog()
+
+ await expect(page.getByText(/Account updated/i)).toBeVisible()
+
+ // Verify by logging in with the new password
+ await login(context.request, { ...user, password: 'newpassword123' })
+ await page.goto('/apps/dashboard')
+ await expect(page).toHaveURL(/\/apps\/dashboard/)
+ })
+
+ test('can change the email address', async ({ page, user }) => {
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ await settingsPage.openEditDialog(user.userId)
+ const dialog = settingsPage.editUserDialog()
+ const emailInput = dialog.getByLabel(/Email/)
+ await expect(emailInput).toHaveValue('')
+ await emailInput.fill('mymail@example.com')
+
+ await handlePasswordConfirmation(page)
+ await settingsPage.saveEditDialog()
+
+ await expect(page.getByText(/Account updated/i)).toBeVisible()
+
+ // Verify backend
+ const info = JSON.parse(await runOcc(['user:info', '--output=json', user.userId]))
+ expect(info?.email).toBe('mymail@example.com')
+ })
+
+ test('can change the user quota to a predefined value', async ({ page, user }) => {
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ await settingsPage.openEditDialog(user.userId)
+ const dialog = settingsPage.editUserDialog()
+
+ // Open the Quota NcSelect and choose 5 GB
+ const quotaCombobox = dialog.getByRole('combobox', { name: /Quota/i })
+ await quotaCombobox.click()
+ await page.getByRole('option', { name: '5 GB' }).click()
+
+ await handlePasswordConfirmation(page)
+ await settingsPage.saveEditDialog()
+
+ await expect(page.getByText(/Account updated/i)).toBeVisible()
+
+ // Verify backend
+ const info = JSON.parse(await runOcc(['user:info', '--output=json', user.userId]))
+ expect(info?.quota).toBe('5 GB')
+ })
+
+ test('can change the user quota to a custom value', async ({ page, user }) => {
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ await settingsPage.openEditDialog(user.userId)
+ const dialog = settingsPage.editUserDialog()
+
+ // Type a custom value directly into the combobox
+ const quotaCombobox = dialog.getByRole('combobox', { name: /Quota/i })
+ await quotaCombobox.fill('4 MB')
+ await quotaCombobox.press('Enter')
+
+ await handlePasswordConfirmation(page)
+ await settingsPage.saveEditDialog()
+
+ await expect(page.getByText(/Account updated/i)).toBeVisible()
+
+ // Verify backend (stored as bytes)
+ const info = JSON.parse(await runOcc(['user:info', '--output=json', user.userId]))
+ expect(info?.quota).not.toBe('none')
+ })
+
+ test('can make user a subadmin of a group', async ({ page, user }) => {
+ const groupName = crypto.randomUUID().slice(0, 6)
+ const shortName = groupName.slice(0, 4)
+ await runOcc(['group:add', groupName])
+
+ try {
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ await settingsPage.openEditDialog(user.userId)
+ const dialog = settingsPage.editUserDialog()
+
+ // Open the subadmin NcSelect and pick the group
+ const subadminCombobox = dialog.getByRole('combobox', { name: /Admin of the following groups/i })
+ await subadminCombobox.click()
+
+ const waitForSearch = page
+ .waitForResponse((r) => r.request().url().includes(`ocs/v2.php/cloud/groups/details?search=${shortName}`))
+ await subadminCombobox.fill(shortName)
+ await waitForSearch
+ await page.getByRole('option', { name: new RegExp(groupName) }).click()
+
+ await settingsPage.saveEditDialog()
+
+ await expect(page.getByText(/Account updated/i)).toBeVisible()
+
+ // Verify backend via OCS API (page shares admin auth state)
+ const response = await page.request.get(
+ `/ocs/v2.php/cloud/users/${user.userId}/subadmins`,
+ { headers: { 'OCS-APIRequest': 'true', Accept: 'application/json' } },
+ )
+ const data = await response.json()
+ expect(data?.ocs?.data).toContain(groupName)
+ } finally {
+ await runOcc(['group:delete', groupName])
+ }
+ })
+})
diff --git a/tests/playwright/e2e/users/users-search.spec.ts b/tests/playwright/e2e/users/users-search.spec.ts
new file mode 100644
index 0000000000000..a2640ba006f8c
--- /dev/null
+++ b/tests/playwright/e2e/users/users-search.spec.ts
@@ -0,0 +1,108 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { expect } from '@playwright/test'
+import { type User } from '@nextcloud/e2e-test-server'
+import { createRandomUser } from '@nextcloud/e2e-test-server/playwright'
+import { runOcc } from '@nextcloud/e2e-test-server/docker'
+import { test as adminTest } from '../../support/fixtures/admin-session.ts'
+import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts'
+
+adminTest.describe.configure({ mode: 'serial' })
+
+adminTest.describe('Settings: Unified search for accounts and groups', () => {
+ // Stable, searchable prefix so we can match the group independently of the random suffix
+ const matchingGroup = `zzz-match-${crypto.randomUUID().slice(0, 5)}`
+ const otherGroup = `aaa-other-${crypto.randomUUID().slice(0, 5)}`
+ let alice: User
+ let bob: User
+
+ adminTest.beforeAll(async () => {
+ alice = await createRandomUser()
+ bob = await createRandomUser()
+ await runOcc(['group:add', matchingGroup])
+ await runOcc(['group:add', otherGroup])
+ })
+
+ adminTest.afterAll(async () => {
+ await runOcc(['user:delete', alice.userId])
+ await runOcc(['user:delete', bob.userId])
+ await runOcc(['group:delete', matchingGroup])
+ await runOcc(['group:delete', otherGroup])
+ })
+
+ adminTest('shows the search input in the navigation sidebar', async ({ page }) => {
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ const searchbox = settingsPage.navigation().getByRole('searchbox', { name: /search accounts and groups/i })
+ await expect(searchbox).toBeVisible()
+ await expect(searchbox).toHaveValue('')
+ })
+
+ adminTest('dispatches the query to both the users and groups API', async ({ page }) => {
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ const searchbox = settingsPage.navigation().getByRole('searchbox', { name: /search accounts and groups/i })
+
+ const usersRespPromise = page.waitForResponse(/ocs\/v2\.php\/cloud\/users\/details/)
+ const groupsRespPromise = page.waitForResponse(/ocs\/v2\.php\/cloud\/groups\/details/)
+ await searchbox.fill(alice.userId)
+ const usersResp = await usersRespPromise
+ const groupsResp = await groupsRespPromise
+
+ expect(new URL(usersResp.url()).searchParams.get('search')).toBe(alice.userId)
+ expect(new URL(groupsResp.url()).searchParams.get('search')).toBe(alice.userId)
+
+ // User list reflects the filtered result
+ await expect(settingsPage.userRow(alice.userId)).toBeVisible()
+ await expect(settingsPage.userList()).not.toContainText(bob.userId)
+ })
+
+ adminTest('filters the group list when the query matches a group name', async ({ page }) => {
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ const searchbox = settingsPage.navigation().getByRole('searchbox', { name: /search accounts and groups/i })
+
+ const groupsRespPromise = page.waitForResponse(/ocs\/v2\.php\/cloud\/groups\/details/)
+ await searchbox.fill(matchingGroup)
+ const groupsResp = await groupsRespPromise
+
+ expect(new URL(groupsResp.url()).searchParams.get('search')).toBe(matchingGroup)
+
+ await expect(settingsPage.customGroupsList()).toContainText(matchingGroup)
+ await expect(settingsPage.customGroupsList()).not.toContainText(otherGroup)
+ })
+
+ adminTest('resets both lists when the clear button is clicked', async ({ page }) => {
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ const searchbox = settingsPage.navigation().getByRole('searchbox', { name: /search accounts and groups/i })
+
+ // Prime the search box with a term first
+ const primeUsersPromise = page.waitForResponse(/ocs\/v2\.php\/cloud\/users\/details/)
+ const primeGroupsPromise = page.waitForResponse(/ocs\/v2\.php\/cloud\/groups\/details/)
+ await searchbox.fill(alice.userId)
+ await primeUsersPromise
+ await primeGroupsPromise
+
+ // Now clear
+ const usersRespPromise = page.waitForResponse(/ocs\/v2\.php\/cloud\/users\/details/)
+ const groupsRespPromise = page.waitForResponse(/ocs\/v2\.php\/cloud\/groups\/details/)
+ await settingsPage.navigation().getByRole('button', { name: /clear search/i }).click()
+ await usersRespPromise
+ await groupsRespPromise
+
+ await expect(searchbox).toHaveValue('')
+ // Both users and both groups must be visible again
+ await expect(settingsPage.userRow(alice.userId)).toBeVisible()
+ await expect(settingsPage.userRow(bob.userId)).toBeVisible()
+ await expect(settingsPage.customGroupsList()).toContainText(matchingGroup)
+ await expect(settingsPage.customGroupsList()).toContainText(otherGroup)
+ })
+})
diff --git a/tests/playwright/e2e/users/users.spec.ts b/tests/playwright/e2e/users/users.spec.ts
new file mode 100644
index 0000000000000..73f8a8d0c8804
--- /dev/null
+++ b/tests/playwright/e2e/users/users.spec.ts
@@ -0,0 +1,77 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { expect } from '@playwright/test'
+import { createRandomUser } from '@nextcloud/e2e-test-server/playwright'
+import { runOcc } from '@nextcloud/e2e-test-server/docker'
+import { test } from '../../support/fixtures/admin-session.ts'
+import { handlePasswordConfirmation } from '../../support/utils/password-confirmation.ts'
+import { SettingsUsersPage } from '../../support/sections/SettingsUsersPage.ts'
+
+test.describe('Settings: Create and delete accounts', () => {
+ test('can create a user with username and password', async ({ page }) => {
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ await settingsPage.openNewUserDialog()
+
+ const dialog = settingsPage.newUserDialog()
+ await dialog.getByLabel(/Account name/).fill('newuser-basic')
+ await dialog.getByLabel(/Password/).and(page.locator('input')).fill('password123')
+
+ await dialog.getByRole('button', { name: 'Add new account' }).click()
+ await handlePasswordConfirmation(page)
+ await dialog.waitFor({ state: 'hidden' })
+
+ await expect(settingsPage.userRow('newuser-basic')).toContainText('newuser-basic')
+
+ await runOcc(['user:delete', 'newuser-basic'])
+ })
+
+ test('can create a user with display name and email', async ({ page }) => {
+ const newUserId = crypto.randomUUID()
+ try {
+ const settingsPage = new SettingsUsersPage(page)
+ await settingsPage.open()
+
+ await settingsPage.openNewUserDialog()
+
+ const dialog = settingsPage.newUserDialog()
+ await dialog.getByLabel(/Account name/).fill(newUserId)
+ await dialog.getByLabel('Display name').fill('John Smith')
+ await dialog.getByLabel(/Email/).fill('john@example.org')
+ await dialog.getByLabel(/Password/).and(page.locator('input')).fill('password123')
+
+ await dialog.getByRole('button', { name: 'Add new account' }).click()
+ await handlePasswordConfirmation(page)
+ await dialog.waitFor({ state: 'hidden' })
+
+ await expect(settingsPage.userRow(newUserId)).toContainText(newUserId)
+ } finally {
+ await runOcc(['user:delete', newUserId])
+ }
+ })
+
+ test('can delete a user', async ({ page }) => {
+ const testUser = await createRandomUser()
+ const settingsPage = new SettingsUsersPage(page)
+
+ try {
+ await settingsPage.open()
+ await expect(settingsPage.userRow(testUser.userId)).toBeVisible()
+
+ await settingsPage.openActionsMenu(testUser.userId)
+ await page.getByRole('menuitem', { name: 'Delete account' }).click()
+ await handlePasswordConfirmation(page)
+
+ // Confirm the deletion in the confirmation dialog
+ await page.getByRole('dialog').getByRole('button', { name: `Delete ${testUser.userId}` }).click()
+
+ await expect(settingsPage.userRow(testUser.userId)).toHaveCount(0)
+ } finally {
+ await runOcc(['user:delete', testUser.userId]).catch(() => {})
+ }
+ })
+})
diff --git a/tests/playwright/start-nextcloud-server.js b/tests/playwright/start-nextcloud-server.js
index ac81788f692b8..bf4c9d54ea3e8 100644
--- a/tests/playwright/start-nextcloud-server.js
+++ b/tests/playwright/start-nextcloud-server.js
@@ -49,13 +49,19 @@ async function start() {
await configureNextcloud()
process.stdout.write('\nApply custom configuration for Playwright tests\n')
+ await runExec(['php', '-r', '$db = new SQLite3("data/owncloud.db");$db->busyTimeout(5000);$db->exec("PRAGMA journal_mode = wal;");'])
+ process.stdout.write('├─ Enabled SQLite WAL mode for better performance\n')
+
+ await runOcc(['config:system:set', 'cache_app_config', '--value', 'false', '--type', 'boolean'])
+ process.stdout.write('├─ Disabled caching AppConfig\n') // otherwise test setup using OCC will need to wait 3s so that web cache TTL expires
+
await runOcc(['config:system:set', 'appstoreenabled', '--value', 'false', '--type', 'boolean'])
process.stdout.write('├─ Disabled app store\n')
+
// createRandomUser() generates short passwords that the policy would reject
await runOcc(['app:disable', 'password_policy'])
process.stdout.write('├─ Disabled password policy for random test users\n')
- await runExec(['php', '-r', '$db = new SQLite3("data/owncloud.db");$db->busyTimeout(5000);$db->exec("PRAGMA journal_mode = wal;");'])
- process.stdout.write('├─ Enabled SQLite WAL mode for better performance\n')
+
process.stdout.write('├─ Initialize cron job...\n')
await runExec(['php', 'cron.php'])
process.stdout.write('│ └─ OK !\n')
diff --git a/tests/playwright/support/sections/SettingsUsersPage.ts b/tests/playwright/support/sections/SettingsUsersPage.ts
new file mode 100644
index 0000000000000..ab2a67f9015a4
--- /dev/null
+++ b/tests/playwright/support/sections/SettingsUsersPage.ts
@@ -0,0 +1,115 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { expect, type Locator, type Page } from '@playwright/test'
+import { handlePasswordConfirmation } from '../utils/password-confirmation'
+
+/**
+ * Page object for the Admin Users Management page (/settings/users).
+ *
+ * Selector strategy:
+ * - Prefer role / label / text selectors.
+ * - `data-cy-user-row` and `data-cy-user-list` are the only data-attribute
+ * selectors used — the virtual-scroll list and individual rows have no
+ * semantic ARIA alternative.
+ * - `data-cy-users-settings-navigation-groups` is used for the custom groups
+ * list because the list has no distinct accessible name.
+ */
+export class SettingsUsersPage {
+ constructor(private readonly page: Page) {}
+
+ async open(): Promise {
+ await this.page.goto('/settings/users')
+ await this.userList().waitFor({ state: 'visible' })
+ }
+
+ // ── Sidebar navigation ──────────────────────────────────────────────────
+
+ navigation(): Locator {
+ return this.page.getByRole('navigation', { name: 'Account management' })
+ }
+
+ /** Click a named link in the account management sidebar. */
+ async navigateTo(name: string | RegExp): Promise {
+ await this.navigation().getByRole('link', { name }).click()
+ }
+
+ /** The custom groups section in the sidebar navigation. */
+ customGroupsList(): Locator {
+ return this.page.locator('[data-cy-users-settings-navigation-groups="custom"]')
+ }
+
+ groupListItem(groupName: string): Locator {
+ return this.customGroupsList().getByRole('listitem').filter({ hasText: groupName })
+ }
+
+ // ── User list ────────────────────────────────────────────────────────────
+
+ userList(): Locator {
+ return this.page.locator('[data-cy-user-list]')
+ }
+
+ userRow(userId: string): Locator {
+ return this.page.locator(`[data-cy-user-row="${userId}"]`)
+ }
+
+ // ── Dialogs ──────────────────────────────────────────────────────────────
+
+ /** Open the "New account" dialog and wait for it to appear. */
+ async openNewUserDialog(): Promise {
+ await this.page.getByRole('navigation')
+ .getByRole('button', { name: 'New account' })
+ .click()
+ await this.newUserDialog().waitFor({ state: 'visible' })
+ }
+
+ newUserDialog(): Locator {
+ return this.page.getByRole('dialog', { name: 'New account' })
+ }
+
+ /** Open the edit dialog for `userId` by clicking its inline Edit button. */
+ async openEditDialog(userId: string): Promise {
+ await this.userRow(userId).getByRole('button', { name: 'Edit' }).click()
+ await this.editUserDialog().waitFor({ state: 'visible' })
+ }
+
+ editUserDialog(): Locator {
+ return this.page.getByRole('dialog', { name: 'Edit account' })
+ }
+
+ /** Save and close the currently open edit dialog. */
+ async saveEditDialog(): Promise {
+ const dialog = this.editUserDialog()
+ const button = dialog.getByRole('button', { name: 'Save' })
+ await button.focus()
+ await button.click({ force: true })
+ await handlePasswordConfirmation(this.page)
+ await dialog.waitFor({ state: 'hidden' })
+ }
+
+ /** Open the actions dropdown for `userId`. */
+ async openActionsMenu(userId: string): Promise {
+ const button = this.userRow(userId).getByRole('button', { name: 'Toggle account actions menu' })
+ await button.click()
+ await expect(button).toHaveAttribute('aria-controls')
+ await expect(this.page.getByRole('menu').and(this.page.locator('#' + await button.getAttribute('aria-controls')))).toBeVisible()
+ }
+
+ /** Open the "Account management settings" dialog. */
+ async openSettingsDialog(): Promise {
+ await this.page.getByRole('button', { name: 'Account management settings' }).click()
+ await this.settingsDialog().waitFor({ state: 'visible' })
+ }
+
+ settingsDialog(): Locator {
+ return this.page.getByRole('dialog', { name: 'Account management settings' })
+ }
+
+ /** Close the "Account management settings" dialog. */
+ async closeSettingsDialog(): Promise {
+ await this.settingsDialog().getByRole('button', { name: 'Close' }).click()
+ await this.settingsDialog().waitFor({ state: 'hidden' })
+ }
+}