Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 39 additions & 28 deletions src/components/NcPasswordField/NcPasswordField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -93,40 +93,17 @@ export default {
```
</docs>

<template>
<NcInputField v-bind="propsToForward"
ref="inputField"
v-model="modelValue"
:error="error || isValid === false"
:helper-text="helperText || internalHelpMessage"
:input-class="[inputClass, { 'password-field__input--secure-text': isPasswordHidden && asText }]"
:minlength="minlength ?? passwordPolicy?.minLength ?? 0"
:success="success || isValid === true"
:trailing-button-label="isPasswordHidden ? t('Show password') : t('Hide password')"
:type="isPasswordHidden && !asText ? 'password' : 'text'"
@trailing-button-click="isPasswordHidden = !isPasswordHidden">
<template v-if="!!$slots.icon" #icon>
<!-- @slot Leading icon -->
<slot name="icon" />
</template>
<template #trailing-button-icon>
<Eye v-if="isPasswordHidden" :size="18" />
<EyeOff v-else :size="18" />
</template>
</NcInputField>
</template>

<script setup lang="ts">
import type { NcInputFieldProps } from '../NcInputField/NcInputField.vue'
import type { Writable } from '../../utils/VueTypes.ts'

import debounce from 'debounce'
import { mdiEye, mdiEyeOff } from '@mdi/js'
import axios from '@nextcloud/axios'
import { getCapabilities } from '@nextcloud/capabilities'
import { generateOcsUrl } from '@nextcloud/router'
import debounce from 'debounce'
import { computed, ref, useTemplateRef, watch } from 'vue'
import Eye from 'vue-material-design-icons/Eye.vue'
import EyeOff from 'vue-material-design-icons/EyeOff.vue'
import NcIconSvgWrapper from '../NcIconSvgWrapper/NcIconSvgWrapper.vue'
import NcInputField from '../NcInputField/NcInputField.vue'
import { t } from '../../l10n.js'
import logger from '../../utils/logger.ts'
Expand Down Expand Up @@ -162,9 +139,15 @@ const props = withDefaults(defineProps<Omit<NcInputFieldProps, 'trailingButtonLa
showTrailingButton: true,
})

const modelValue = defineModel<string>('modelValue', { default: '' })
const modelValue = defineModel<string>({ default: '' })
watch(modelValue, debounce(checkPassword, 500))

/**
* The visibility of the password.
* If this is set to true then the password will not be obfuscated by the browser.
*/
const visible = defineModel<boolean>('visible', { default: false })

const emit = defineEmits<{
valid: []
invalid: []
Expand Down Expand Up @@ -224,7 +207,6 @@ const { password_policy: passwordPolicy } = getCapabilities() as { password_poli
// internal state
const inputField = useTemplateRef('inputField')

const isPasswordHidden = ref(true)
const internalHelpMessage = ref('')
const isValid = ref<boolean>()

Expand Down Expand Up @@ -276,6 +258,13 @@ async function checkPassword() {
}
}

/**
* Toggle the visibility of the password
*/
function toggleVisibility() {
visible.value = !visible.value
}

/**
* Focus the input element
*
Expand All @@ -296,6 +285,28 @@ function select() {
}
</script>

<template>
<NcInputField v-bind="propsToForward"
ref="inputField"
v-model="modelValue"
:error="error || isValid === false"
:helper-text="helperText || internalHelpMessage"
:input-class="[inputClass, { 'password-field__input--secure-text': !visible && asText }]"
:minlength="minlength ?? passwordPolicy?.minLength ?? 0"
:success="success || isValid === true"
:trailing-button-label="visible ? t('Hide password') : t('Show password')"
:type="visible || asText ? 'text' : 'password'"
@trailing-button-click="toggleVisibility">
<template v-if="!!$slots.icon" #icon>
<!-- @slot Leading icon -->
<slot name="icon" />
</template>
<template #trailing-button-icon>
<NcIconSvgWrapper :path="visible ? mdiEyeOff : mdiEye" />
</template>
</NcInputField>
</template>

<style lang="scss" scoped>
:deep(.password-field__input--secure-text) {
// Emulate password field look
Expand Down
33 changes: 33 additions & 0 deletions tests/component/components/Nc*Field/NcPassword.story.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<template>
<div class="story-wrapper">
<NcPasswordField :as-text
label="Password"
model-value="password"
:visible />
</div>
</template>

<script setup lang="ts">
import NcPasswordField from '../../../../src/components/NcPasswordField/NcPasswordField.vue'

withDefaults(defineProps<{
asText?: boolean
visible?: boolean
}>(), {
asText: undefined,
visible: undefined,
})
</script>

<style scoped>
.story-wrapper {
margin-inline: auto;
max-width: 300px;
padding-top: 8px;
}
</style>
78 changes: 78 additions & 0 deletions tests/component/components/Nc*Field/NcPasswordField.visual.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { expect, test } from '@playwright/experimental-ct-vue'
import NcPasswordField from './NcPassword.story.vue'

test.skip(({ browserName }) => browserName !== 'chromium')

test.describe('NcPasswordField', () => {
test('password is hidden', { tag: '@visual' }, async ({ mount, page }) => {
await mount(NcPasswordField)

await expect(page.getByRole('textbox', { name: 'Password' })).toBeVisible()
await expect(page.locator('.story-wrapper')).toHaveScreenshot()
})

test('password can be shown', { tag: '@visual' }, async ({ mount, page }) => {
await mount(NcPasswordField, {
props: {
visible: true,
},
})

await expect(page.getByRole('textbox', { name: 'Password' })).toBeVisible()
await expect(page.locator('.story-wrapper')).toHaveScreenshot()
})

test('password can be shown by trailing button click', { tag: '@visual' }, async ({ mount, page }) => {
await mount(NcPasswordField)

const el = page.getByRole('textbox', { name: 'Password' })
await expect(el).toBeVisible()
await expect(el).toHaveAttribute('type', 'password')

await page.getByRole('button', { name: 'Show password' }).click()
await expect(el).toHaveAttribute('type', 'text')
await expect(page.locator('.story-wrapper')).toHaveScreenshot()
})

test('password as-text is hidden', { tag: '@visual' }, async ({ mount, page }) => {
await mount(NcPasswordField, {
props: {
asText: true,
},
})

await expect(page.getByRole('textbox', { name: 'Password' })).toBeVisible()
await expect(page.locator('.story-wrapper')).toHaveScreenshot()
})

test('password as-text can be shown', { tag: '@visual' }, async ({ mount, page }) => {
await mount(NcPasswordField, {
props: {
asText: true,
visible: true,
},
})

await expect(page.getByRole('textbox', { name: 'Password' })).toBeVisible()
await expect(page.locator('.story-wrapper')).toHaveScreenshot()
})

test('password as-text can be shown by trailing button click', { tag: '@visual' }, async ({ mount, page }) => {
await mount(NcPasswordField, {
props: {
asText: true,
},
})

const el = page.getByRole('textbox', { name: 'Password' })
await expect(el).toBeVisible()

await page.getByRole('button', { name: 'Show password' }).click()
await expect(page.locator('.story-wrapper')).toHaveScreenshot()
})
})
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading