aboutsummaryrefslogtreecommitdiffstats
path: root/cypress/e2e/settings
diff options
context:
space:
mode:
Diffstat (limited to 'cypress/e2e/settings')
-rw-r--r--cypress/e2e/settings/access-levels.cy.ts65
-rw-r--r--cypress/e2e/settings/apps.cy.ts156
-rw-r--r--cypress/e2e/settings/personal-info.cy.ts448
-rw-r--r--cypress/e2e/settings/users-group-admin.cy.ts186
-rw-r--r--cypress/e2e/settings/users.cy.ts129
-rw-r--r--cypress/e2e/settings/usersUtils.ts90
-rw-r--r--cypress/e2e/settings/users_columns.cy.ts94
-rw-r--r--cypress/e2e/settings/users_disable.cy.ts79
-rw-r--r--cypress/e2e/settings/users_groups.cy.ts291
-rw-r--r--cypress/e2e/settings/users_manager.cy.ts121
-rw-r--r--cypress/e2e/settings/users_modify.cy.ts225
11 files changed, 1884 insertions, 0 deletions
diff --git a/cypress/e2e/settings/access-levels.cy.ts b/cypress/e2e/settings/access-levels.cy.ts
new file mode 100644
index 00000000000..4bf0cbc1832
--- /dev/null
+++ b/cypress/e2e/settings/access-levels.cy.ts
@@ -0,0 +1,65 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { User } from '@nextcloud/cypress'
+import { clearState, getNextcloudUserMenu, getNextcloudUserMenuToggle } from '../../support/commonUtils'
+
+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.get('#app-navigation').should('be.visible').within(() => {
+ // I see the personal section is NOT shown
+ cy.get('#app-navigation-caption-personal').should('not.exist')
+ // I see the admin section is NOT shown
+ cy.get('#app-navigation-caption-administration').should('not.exist')
+
+ // I see that the "Personal info" entry in the settings panel is shown
+ cy.get('[data-section-id="personal-info"]').should('exist').and('be.visible')
+ })
+ })
+
+ 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.get('#app-navigation').should('be.visible').within(() => {
+ // I see the personal section is shown
+ cy.get('#app-navigation-caption-personal').should('be.visible')
+ // I see the admin section is shown
+ cy.get('#app-navigation-caption-administration').should('be.visible')
+
+ // I see that the "Personal info" entry in the settings panel is shown
+ cy.get('[data-section-id="personal-info"]').should('exist').and('be.visible')
+ })
+ })
+})
diff --git a/cypress/e2e/settings/apps.cy.ts b/cypress/e2e/settings/apps.cy.ts
new file mode 100644
index 00000000000..0df073271ef
--- /dev/null
+++ b/cypress/e2e/settings/apps.cy.ts
@@ -0,0 +1,156 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { User } from '@nextcloud/cypress'
+import { handlePasswordConfirmation } from './usersUtils'
+
+const admin = new User('admin', 'admin')
+
+describe('Settings: App management', { testIsolation: true }, () => {
+ beforeEach(() => {
+ // disable QA if already enabled
+ cy.runOccCommand('app:disable -n testing')
+ // enable notification if already disabled
+ cy.runOccCommand('app:enable -n updatenotification')
+
+ // I am logged in as the admin
+ cy.login(admin)
+
+ // Intercept the apps list request
+ cy.intercept('GET', '*/settings/apps/list').as('fetchAppsList')
+
+ // I open the Apps management
+ cy.visit('/settings/apps/installed')
+
+ // Wait for the apps list to load
+ cy.wait('@fetchAppsList')
+ })
+
+ it('Can enable an installed app', () => {
+ cy.get('#apps-list').should('exist')
+ // Wait for the app list to load
+ .contains('tr', 'QA testing', { timeout: 10000 })
+ .should('exist')
+ // I enable the "QA testing" app
+ .contains('button', 'Enable')
+ .click({ force: true })
+
+ handlePasswordConfirmation(admin.password)
+
+ // Wait until we see the disable button for the app
+ cy.get('#apps-list').should('exist')
+ .contains('tr', 'QA testing')
+ .should('exist')
+ // I see the disable button for the app
+ .contains('button', 'Disable', { timeout: 10000 })
+
+ // Change to enabled apps view
+ cy.get('#app-category-enabled a').click({ force: true })
+ cy.url().should('match', /settings\/apps\/enabled$/)
+ // I see that the "QA testing" app has been enabled
+ cy.get('#apps-list').contains('tr', 'QA testing')
+ })
+
+ it('Can disable an installed app', () => {
+ cy.get('#apps-list')
+ .should('exist')
+ // Wait for the app list to load
+ .contains('tr', 'Update notification', { timeout: 10000 })
+ .should('exist')
+ // I disable the "Update notification" app
+ .contains('button', 'Disable')
+ .click({ force: true })
+
+ handlePasswordConfirmation(admin.password)
+
+ // Wait until we see the disable button for the app
+ cy.get('#apps-list').should('exist')
+ .contains('tr', 'Update notification')
+ .should('exist')
+ // I see the enable button for the app
+ .contains('button', 'Enable', { timeout: 10000 })
+
+ // Change to disabled apps view
+ cy.get('#app-category-disabled a').click({ force: true })
+ cy.url().should('match', /settings\/apps\/disabled$/)
+ // I see that the "Update notification" app has been disabled
+ cy.get('#apps-list').contains('tr', 'Update notification')
+ })
+
+ it('Browse enabled apps', () => {
+ // When I open the "Active apps" section
+ cy.get('#app-category-enabled a')
+ .should('contain', 'Active apps')
+ .click({ force: true })
+ // Then I see that the current section is "Active apps"
+ cy.url().should('match', /settings\/apps\/enabled$/)
+ cy.get('#app-category-enabled').find('.active').should('exist')
+ // I see that there are only enabled apps
+ cy.get('#apps-list')
+ .should('exist')
+ .find('tr button')
+ .each(($action) => {
+ cy.wrap($action).should('not.contain', 'Enable')
+ })
+ })
+
+ it('Browse disabled apps', () => {
+ // When I open the "Active apps" section
+ cy.get('#app-category-disabled a')
+ .should('contain', 'Disabled apps')
+ .click({ force: true })
+ // Then I see that the current section is "Active apps"
+ cy.url().should('match', /settings\/apps\/disabled$/)
+ cy.get('#app-category-disabled').find('.active').should('exist')
+ // I see that there are only disabled apps
+ cy.get('#apps-list')
+ .should('exist')
+ .find('tr button')
+ .each(($action) => {
+ cy.wrap($action).should('not.contain', 'Disable')
+ })
+ })
+
+ it('Browse app bundles', () => {
+ // When I open the "App bundles" section
+ cy.get('#app-category-your-bundles a')
+ .should('contain', 'App bundles')
+ .click({ force: true })
+ // Then I see that the current section is "App bundles"
+ cy.url().should('match', /settings\/apps\/app-bundles$/)
+ cy.get('#app-category-your-bundles').find('.active').should('exist')
+ // I see the app bundles
+ cy.get('#apps-list').contains('tr', 'Enterprise bundle')
+ cy.get('#apps-list').contains('tr', 'Education bundle')
+ // I see that the "Enterprise bundle" is disabled
+ cy.get('#apps-list').contains('tr', 'Enterprise bundle').contains('button', 'Download and enable all')
+ })
+
+ it('View app details', () => {
+ // When I click on the "QA testing" app
+ cy.get('#apps-list').contains('a', 'QA testing').click({ force: true })
+ // I see that the app details are shown
+ cy.get('#app-sidebar-vue')
+ .should('be.visible')
+ .find('.app-sidebar-header__info')
+ .should('contain', 'QA testing')
+ cy.get('#app-sidebar-vue').contains('a', 'View in store').should('exist')
+ cy.get('#app-sidebar-vue').find('input[type="button"][value="Enable"]').should('be.visible')
+ cy.get('#app-sidebar-vue').find('input[type="button"][value="Remove"]').should('be.visible')
+ cy.get('#app-sidebar-vue').contains(/Version \d+\.\d+\.\d+/).should('be.visible')
+ })
+
+ /*
+ * TODO: Improve testing with app store as external API
+ * The following scenarios require the files_antivirus and calendar app
+ * being present in the app store with support for the current server version
+ * Ideally we would have either a dummy app store endpoint with some test apps
+ * or even an app store instance running somewhere to properly test this.
+ * This is also a requirement to properly test updates of apps
+ */
+ // TODO: View app details for app store apps
+ // TODO: Install an app from the app store
+ // TODO: Show section from app store
+})
diff --git a/cypress/e2e/settings/personal-info.cy.ts b/cypress/e2e/settings/personal-info.cy.ts
new file mode 100644
index 00000000000..8d4b4bb606a
--- /dev/null
+++ b/cypress/e2e/settings/personal-info.cy.ts
@@ -0,0 +1,448 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+import { handlePasswordConfirmation } from './usersUtils.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
+ */
+const 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
+ */
+const 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
+ */
+const 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
new file mode 100644
index 00000000000..5b5dcfd33a8
--- /dev/null
+++ b/cypress/e2e/settings/users-group-admin.cy.ts
@@ -0,0 +1,186 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+/// <reference types="cypress-if" />
+import { User } from '@nextcloud/cypress'
+import { getUserListRow, handlePasswordConfirmation } from './usersUtils'
+// eslint-disable-next-line n/no-extraneous-import
+import randomString from 'crypto-random-string'
+
+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')
+ })
+
+ it('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('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
new file mode 100644
index 00000000000..5b8726e92ca
--- /dev/null
+++ b/cypress/e2e/settings/users.cy.ts
@@ -0,0 +1,129 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+/// <reference types="cypress-if" />
+import { User } from '@nextcloud/cypress'
+import { getUserListRow, handlePasswordConfirmation } from './usersUtils'
+
+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/usersUtils.ts b/cypress/e2e/settings/usersUtils.ts
new file mode 100644
index 00000000000..7d8ea55d35b
--- /dev/null
+++ b/cypress/e2e/settings/usersUtils.ts
@@ -0,0 +1,90 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+
+/**
+ * Assert that `element` does not exist or is not visible
+ * Useful in cases such as when NcModal is opened/closed rapidly
+ * @param element Element that is inspected
+ */
+export function assertNotExistOrNotVisible(element: JQuery<HTMLElement>) {
+ const doesNotExist = element.length === 0
+ const isNotVisible = !element.is(':visible')
+
+ // eslint-disable-next-line no-unused-expressions
+ expect(doesNotExist || isNotVisible, 'does not exist or is not visible').to.be.true
+}
+
+/**
+ * Get the settings users list
+ * @return Cypress chainable object
+ */
+export function getUserList() {
+ return cy.get('[data-cy-user-list]')
+}
+
+/**
+ * Get the row entry for given userId within the settings users list
+ *
+ * @param userId the user to query
+ * @return Cypress chainable object
+ */
+export function getUserListRow(userId: string) {
+ return getUserList().find(`[data-cy-user-row="${userId}"]`)
+}
+
+export function waitLoading(selector: string) {
+ // We need to make sure the element is loading, otherwise the "done loading" will succeed even if we did not start loading.
+ // But Cypress might also be simply too slow to catch the loading phase. Thats why we need to wait in this case.
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.get(`${selector}[data-loading]`).if().should('exist').else().wait(1000)
+ // https://github.com/NoriSte/cypress-wait-until/issues/75#issuecomment-572685623
+ cy.waitUntil(() => Cypress.$(selector).length > 0 && !Cypress.$(selector).attr('data-loading')?.length, { timeout: 10000 })
+}
+
+/**
+ * Toggle the edit button of the user row
+ * @param user The user row to edit
+ * @param toEdit True if it should be switch to edit mode, false to switch to read-only
+ */
+export function toggleEditButton(user: User, toEdit = true) {
+ // see that the list of users contains the user
+ getUserListRow(user.userId).should('exist')
+ // toggle the edit mode for the user
+ .find('[data-cy-user-list-cell-actions]')
+ .find(`[data-cy-user-list-action-toggle-edit="${!toEdit}"]`)
+ .if()
+ .click({ force: true })
+ .else()
+ // otherwise ensure the button is already in edit mode
+ .then(() => getUserListRow(user.userId)
+ .find(`[data-cy-user-list-action-toggle-edit="${toEdit}"]`)
+ .should('exist'),
+ )
+}
+
+/**
+ * Handle the confirm password dialog (if needed)
+ * @param adminPassword The admin password for the dialog
+ */
+export function handlePasswordConfirmation(adminPassword = 'admin') {
+ const handleModal = (context: Cypress.Chainable) => {
+ return context.contains('.modal-container', 'Confirm your password')
+ .if()
+ .within(() => {
+ cy.get('input[type="password"]').type(adminPassword)
+ cy.get('button').contains('Confirm').click()
+ })
+ }
+
+ return cy.get('body')
+ .if()
+ .then(() => handleModal(cy.get('body')))
+ .else()
+ // Handle if inside a cy.within
+ .root().closest('body')
+ .then(($body) => handleModal(cy.wrap($body)))
+}
diff --git a/cypress/e2e/settings/users_columns.cy.ts b/cypress/e2e/settings/users_columns.cy.ts
new file mode 100644
index 00000000000..0afbf14e773
--- /dev/null
+++ b/cypress/e2e/settings/users_columns.cy.ts
@@ -0,0 +1,94 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { User } from '@nextcloud/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')
+ })
+ })
+
+ 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
new file mode 100644
index 00000000000..6195d43e211
--- /dev/null
+++ b/cypress/e2e/settings/users_disable.cy.ts
@@ -0,0 +1,79 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { User } from '@nextcloud/cypress'
+import { getUserListRow } from './usersUtils'
+import { clearState } from '../../support/commonUtils'
+
+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
new file mode 100644
index 00000000000..8d84ddc6bb4
--- /dev/null
+++ b/cypress/e2e/settings/users_groups.cy.ts
@@ -0,0 +1,291 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { User } from '@nextcloud/cypress'
+import { assertNotExistOrNotVisible, getUserListRow, handlePasswordConfirmation, toggleEditButton } from './usersUtils'
+
+// eslint-disable-next-line n/no-extraneous-import
+import randomString from 'crypto-random-string'
+
+const admin = new User('admin', 'admin')
+
+describe('Settings: Create groups', () => {
+ before(() => {
+ cy.login(admin)
+ cy.visit('/settings/users')
+ })
+
+ it('Can create a group', () => {
+ const 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)
+
+ // 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))
+ before(() => {
+ 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('switch into user edit mode', () => {
+ toggleEditButton(testUser)
+ getUserListRow(testUser.userId)
+ .find('[data-cy-user-list-input-groups]')
+ .should('exist')
+ })
+
+ it('assign the group', () => {
+ // focus inside the input
+ getUserListRow(testUser.userId)
+ .find('[data-cy-user-list-input-groups] input')
+ .click({ force: true })
+ // enter the group name
+ getUserListRow(testUser.userId)
+ .find('[data-cy-user-list-input-groups] input')
+ .type(`${groupName.slice(0, 5)}`) // only type part as otherwise we would create a new one with the same name
+ cy.contains('li.vs__dropdown-option', groupName)
+ .click({ force: true })
+
+ handlePasswordConfirmation(admin.password)
+ })
+
+ it('leave the user edit mode', () => {
+ toggleEditButton(testUser, false)
+ })
+
+ it('see the group was successfully assigned', () => {
+ // see a new memeber
+ 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.code).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)
+
+ 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').contains(groupName)
+ .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)
+
+ 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').contains(groupName)
+ .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
+ cy.runOccCommand('group:list --output json').then((output) => {
+ const groups = Object.keys(JSON.parse(output.stdout)).filter((group) => group !== 'admin')
+ groups.forEach((group) => {
+ cy.runOccCommand(`group:delete '${group}'`)
+ })
+ })
+
+ // 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').eq(0).should('contain', 'B') // 1 member
+ cy.get('li').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').eq(0).should('contain', 'B') // 1 member
+ cy.get('li').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').eq(0).should('contain', 'A')
+ cy.get('li').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').eq(0).should('contain', 'A')
+ cy.get('li').eq(1).should('contain', 'B')
+ })
+ })
+})
diff --git a/cypress/e2e/settings/users_manager.cy.ts b/cypress/e2e/settings/users_manager.cy.ts
new file mode 100644
index 00000000000..b7596ddf0ce
--- /dev/null
+++ b/cypress/e2e/settings/users_manager.cy.ts
@@ -0,0 +1,121 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { User } from '@nextcloud/cypress'
+import { getUserListRow, handlePasswordConfirmation, toggleEditButton, waitLoading } from './usersUtils'
+import { clearState } from '../../support/commonUtils'
+
+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)
+ cy.intercept('PUT', `/ocs/v2.php/cloud/users/${user.userId}*`).as('updateUser')
+ })
+ })
+
+ it('Can assign and remove a manager through the UI', function() {
+ cy.visit('/settings/users')
+
+ toggleEditButton(user, true)
+
+ // Scroll to manager cell and wait for it to be visible
+ getUserListRow(user.userId)
+ .find('[data-cy-user-list-cell-manager]')
+ .scrollIntoView()
+ .should('be.visible')
+
+ // Assign a manager
+ getUserListRow(user.userId).find('[data-cy-user-list-cell-manager]').within(() => {
+ // Verify no manager is set initially
+ cy.get('.vs__selected').should('not.exist')
+
+ // Open the dropdown menu
+ cy.get('[role="combobox"]').click({ force: true })
+
+ // Wait for the dropdown to be visible and initialized
+ waitLoading('[data-cy-user-list-input-manager]')
+
+ // Type the manager's username to search
+ cy.get('input[type="search"]').type(manager.userId, { force: true })
+
+ // Wait for the search results to load
+ waitLoading('[data-cy-user-list-input-manager]')
+ })
+
+ // Now select the manager from the filtered results
+ // Since the dropdown is floating, we need to search globally
+ cy.get('.vs__dropdown-menu').find('li').contains('span', manager.userId).should('be.visible').click({ force: true })
+
+ // Handle password confirmation if needed
+ handlePasswordConfirmation(admin.password)
+
+ // Verify the manager is selected in the UI
+ cy.get('.vs__selected').should('exist').and('contain.text', manager.userId)
+
+ // Verify the PUT request was made to set the manager
+ cy.wait('@updateUser').then((interception) => {
+ // Verify the request URL and body
+ expect(interception.request.url).to.match(/\/cloud\/users\/.+/)
+ expect(interception.request.body).to.deep.equal({
+ key: 'manager',
+ value: manager.userId
+ })
+ expect(interception.response?.statusCode).to.equal(200)
+ })
+
+ // Wait for the save to complete
+ waitLoading('[data-cy-user-list-input-manager]')
+
+ // Verify the manager is set in the backend
+ cy.getUserData(user).then(($result) => {
+ expect($result.body).to.contain(`<manager>${manager.userId}</manager>`)
+ })
+
+ // Now remove the manager
+ getUserListRow(user.userId).find('[data-cy-user-list-cell-manager]').within(() => {
+ // Clear the manager selection
+ cy.get('.vs__clear').click({ force: true })
+
+ // Verify the manager is cleared in the UI
+ cy.get('.vs__selected').should('not.exist')
+
+ // Handle password confirmation if needed
+ handlePasswordConfirmation(admin.password)
+ })
+
+ // Verify the PUT request was made to clear the manager
+ cy.wait('@updateUser').then((interception) => {
+ // Verify the request URL and body
+ expect(interception.request.url).to.match(/\/cloud\/users\/.+/)
+ expect(interception.request.body).to.deep.equal({
+ key: 'manager',
+ value: '',
+ })
+ expect(interception.response?.statusCode).to.equal(200)
+ })
+
+ // Wait for the save to complete
+ waitLoading('[data-cy-user-list-input-manager]')
+
+ // Verify the manager is cleared in the backend
+ cy.getUserData(user).then(($result) => {
+ expect($result.body).to.not.contain(`<manager>${manager.userId}</manager>`)
+ expect($result.body).to.contain('<manager></manager>')
+ })
+
+ // Finish editing the user
+ toggleEditButton(user, false)
+ })
+})
diff --git a/cypress/e2e/settings/users_modify.cy.ts b/cypress/e2e/settings/users_modify.cy.ts
new file mode 100644
index 00000000000..749bded2e94
--- /dev/null
+++ b/cypress/e2e/settings/users_modify.cy.ts
@@ -0,0 +1,225 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { User } from '@nextcloud/cypress'
+import { getUserListRow, handlePasswordConfirmation, toggleEditButton, waitLoading } from './usersUtils'
+import { clearState } from '../../support/commonUtils'
+
+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() {
+ // open the User settings as admin
+ cy.visit('/settings/users')
+
+ // toggle edit button into edit mode
+ toggleEditButton(user, true)
+
+ getUserListRow(user.userId).within(() => {
+ // set the display name
+ cy.get('[data-cy-user-list-input-displayname]').should('exist').and('have.value', user.userId)
+ cy.get('[data-cy-user-list-input-displayname]').clear()
+ cy.get('[data-cy-user-list-input-displayname]').type('John Doe')
+ cy.get('[data-cy-user-list-input-displayname]').should('have.value', 'John Doe')
+ cy.get('[data-cy-user-list-input-displayname] ~ button').click()
+
+ // Make sure no confirmation modal is shown
+ handlePasswordConfirmation(admin.password)
+
+ // see that the display name cell is done loading
+ waitLoading('[data-cy-user-list-input-displayname]')
+ })
+
+ // Success message is shown
+ cy.get('.toastify.toast-success').contains(/Display.+name.+was.+successfully.+changed/i).should('exist')
+ })
+
+ it('Can change the password', function() {
+ // open the User settings as admin
+ cy.visit('/settings/users')
+
+ // toggle edit button into edit mode
+ toggleEditButton(user, true)
+
+ getUserListRow(user.userId).within(() => {
+ // see that the password of user is ""
+ cy.get('[data-cy-user-list-input-password]').should('exist').and('have.value', '')
+ // set the password for user to 123456
+ cy.get('[data-cy-user-list-input-password]').type('123456')
+ // When I set the password for user to 123456
+ cy.get('[data-cy-user-list-input-password]').should('have.value', '123456')
+ cy.get('[data-cy-user-list-input-password] ~ button').click()
+
+ // Make sure no confirmation modal is shown
+ handlePasswordConfirmation(admin.password)
+
+ // see that the password cell for user is done loading
+ waitLoading('[data-cy-user-list-input-password]')
+ // password input is emptied on change
+ cy.get('[data-cy-user-list-input-password]').should('have.value', '')
+ })
+
+ // Success message is shown
+ cy.get('.toastify.toast-success').contains(/Password.+successfully.+changed/i).should('exist')
+ })
+
+ it('Can change the email address', function() {
+ // open the User settings as admin
+ cy.visit('/settings/users')
+
+ // toggle edit button into edit mode
+ toggleEditButton(user, true)
+
+ getUserListRow(user.userId).find('[data-cy-user-list-cell-email]').within(() => {
+ // see that the email of user is ""
+ cy.get('input').should('exist').and('have.value', '')
+ // set the email for user to mymail@example.com
+ cy.get('input').type('mymail@example.com')
+ // When I set the password for user to mymail@example.com
+ cy.get('input').should('have.value', 'mymail@example.com')
+ cy.get('input ~ button').click()
+
+ // Make sure no confirmation modal is shown
+ handlePasswordConfirmation(admin.password)
+
+ // see that the password cell for user is done loading
+ waitLoading('[data-cy-user-list-input-email]')
+ })
+
+ // Success message is shown
+ cy.get('.toastify.toast-success').contains(/Email.+successfully.+changed/i).should('exist')
+ })
+
+ it('Can change the user quota to a predefined one', function() {
+ // open the User settings as admin
+ cy.visit('/settings/users')
+
+ // toggle edit button into edit mode
+ toggleEditButton(user, true)
+
+ getUserListRow(user.userId).find('[data-cy-user-list-cell-quota]').scrollIntoView()
+ getUserListRow(user.userId).find('[data-cy-user-list-cell-quota] [data-cy-user-list-input-quota]').within(() => {
+ // see that the quota of user is unlimited
+ cy.get('.vs__selected').should('exist').and('contain.text', 'Unlimited')
+ // Open the quota selector
+ cy.get('[role="combobox"]').click({ force: true })
+ // see that there are default options for the quota
+ cy.get('li').then(($options) => {
+ expect($options).to.have.length(5)
+ cy.wrap($options).contains('Default quota')
+ cy.wrap($options).contains('Unlimited')
+ cy.wrap($options).contains('1 GB')
+ cy.wrap($options).contains('10 GB')
+ // select 5 GB
+ cy.wrap($options).contains('5 GB').click({ force: true })
+
+ // Make sure no confirmation modal is shown
+ handlePasswordConfirmation(admin.password)
+ })
+ // see that the quota of user is 5 GB
+ cy.get('.vs__selected').should('exist').and('contain.text', '5 GB')
+ })
+
+ // see that the changes are loading
+ waitLoading('[data-cy-user-list-input-quota]')
+
+ // finish editing the user
+ toggleEditButton(user, false)
+
+ // I see that the quota was set on the backend
+ cy.runOccCommand(`user:info --output=json '${user.userId}'`).then(($result) => {
+ expect($result.code).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() {
+ // open the User settings as admin
+ cy.visit('/settings/users')
+
+ // toggle edit button into edit mode
+ toggleEditButton(user, true)
+
+ getUserListRow(user.userId).find('[data-cy-user-list-cell-quota]').scrollIntoView()
+ getUserListRow(user.userId).find('[data-cy-user-list-cell-quota]').within(() => {
+ // see that the quota of user is unlimited
+ cy.get('.vs__selected').should('exist').and('contain.text', 'Unlimited')
+ // set the quota to 4 MB
+ cy.get('[data-cy-user-list-input-quota] input').type('4 MB{enter}')
+
+ // Make sure no confirmation modal is shown
+ handlePasswordConfirmation(admin.password)
+
+ // see that the quota of user is 4 MB
+ // TODO: Enable this after the file size handling is fixed
+ // cy.get('.vs__selected').should('exist').and('contain.text', '4 MB')
+
+ // see that the changes are loading
+ waitLoading('[data-cy-user-list-input-quota]')
+ })
+
+ // finish editing the user
+ toggleEditButton(user, false)
+
+ // I see that the quota was set on the backend
+ cy.runOccCommand(`user:info --output=json '${user.userId}'`).then(($result) => {
+ expect($result.code).to.equal(0)
+ // TODO: Enable this after the file size handling is fixed!!!!!!
+ // const info = JSON.parse($result.stdout)
+ // expect(info?.quota).to.equal('4 MB')
+ })
+ })
+
+ it('Can make user a subadmin of a group', function() {
+ // create a group
+ const groupName = 'userstestgroup'
+ cy.runOccCommand(`group:add '${groupName}'`)
+
+ // open the User settings as admin
+ cy.visit('/settings/users')
+
+ // toggle edit button into edit mode
+ toggleEditButton(user, true)
+
+ getUserListRow(user.userId).find('[data-cy-user-list-cell-subadmins]').scrollIntoView()
+ getUserListRow(user.userId).find('[data-cy-user-list-cell-subadmins]').within(() => {
+ // see that the user is no subadmin
+ cy.get('.vs__selected').should('not.exist')
+ // Open the dropdown menu
+ cy.get('[role="combobox"]').click({ force: true })
+ // Search for the group
+ cy.get('[role="combobox"]').type('userstestgroup')
+ // select the group
+ cy.contains('li', groupName).click({ force: true })
+
+ // handle password confirmation on time out
+ handlePasswordConfirmation(admin.password)
+
+ // see that the user is subadmin of the group
+ cy.get('.vs__selected').should('exist').and('contain.text', groupName)
+ })
+
+ waitLoading('[data-cy-user-list-input-subadmins]')
+
+ // finish editing the user
+ toggleEditButton(user, false)
+
+ // I see that the quota was set on the 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)
+ })
+ })
+})