diff --git a/packages/design-system/package.json b/packages/design-system/package.json index ca746502bfa..731d4e74c1e 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -138,7 +138,8 @@ "vue-inline-svg": "^3.1.0", "vue-select": "^3.12.0", "web-client": "workspace:@ownclouders/web-client@*", - "webfontloader": "^1.6.28" + "webfontloader": "^1.6.28", + "portal-vue": "*" }, "engines": { "node": ">= 14.0.0", diff --git a/packages/design-system/src/components/OcModal/OcModal.vue b/packages/design-system/src/components/OcModal/OcModal.vue index 7e4f980f775..3b703949e4f 100644 --- a/packages/design-system/src/components/OcModal/OcModal.vue +++ b/packages/design-system/src/components/OcModal/OcModal.vue @@ -40,12 +40,16 @@ v-model="userInputValue" class="oc-modal-body-input" :error-message="inputError" + :placeholder="inputPlaceholder" :label="inputLabel" :type="inputType" + :password-policy="inputPasswordPolicy" :description-message="inputDescription" :disabled="inputDisabled" :fix-message-line="true" :selection-range="inputSelectionRange" + @password-challenge-completed="$emit('passwordChallengeCompleted')" + @password-challenge-failed="$emit('passwordChallengeFailed')" @update:model-value="inputOnInput" @keydown.enter.prevent="confirm" /> @@ -99,6 +103,7 @@ import OcIcon from '../OcIcon/OcIcon.vue' import OcTextInput from '../OcTextInput/OcTextInput.vue' import { FocusTrap } from 'focus-trap-vue' import { FocusTargetOrFalse, FocusTargetValueOrFalse } from 'focus-trap' +import { PasswordPolicy } from '../_OcTextInputPassword/_OcTextInputPassword.vue' /** * Modals are generally used to force the user to focus on confirming or completing a single action. @@ -323,6 +328,14 @@ export default defineComponent({ required: false, default: null }, + /** + * Placeholder of the text input field + */ + inputPlaceholder: { + type: String, + required: false, + default: null + }, /** * Additional description message for the input field */ @@ -347,6 +360,14 @@ export default defineComponent({ required: false, default: false }, + /** + * Password policy for the input + */ + inputPasswordPolicy: { + type: Object as PropType, + required: false, + default: () => ({}) + }, /** * Overwrite default focused element * Can be `#id, .class`. @@ -357,7 +378,15 @@ export default defineComponent({ default: null } }, - emits: ['cancel', 'confirm', 'confirm-secondary', 'input', 'checkbox-changed'], + emits: [ + 'cancel', + 'confirm', + 'confirm-secondary', + 'input', + 'checkbox-changed', + 'passwordChallengeCompleted', + 'passwordChallengeFailed' + ], data() { return { userInputValue: null, @@ -560,6 +589,12 @@ export default defineComponent({ } } } + + .oc-text-input-password-wrapper { + button { + background-color: var(--oc-color-background-highlight) !important; + } + } } diff --git a/packages/design-system/src/components/OcModal/__snapshots__/OcModal.spec.ts.snap b/packages/design-system/src/components/OcModal/__snapshots__/OcModal.spec.ts.snap index 81347339c5e..b992ac294d8 100644 --- a/packages/design-system/src/components/OcModal/__snapshots__/OcModal.spec.ts.snap +++ b/packages/design-system/src/components/OcModal/__snapshots__/OcModal.spec.ts.snap @@ -11,7 +11,7 @@ exports[`OcModal displays input 1`] = `
- +
diff --git a/packages/design-system/src/components/OcTextInput/OcTextInput.spec.ts b/packages/design-system/src/components/OcTextInput/OcTextInput.spec.ts index 401f6c551d3..f0979f62863 100644 --- a/packages/design-system/src/components/OcTextInput/OcTextInput.spec.ts +++ b/packages/design-system/src/components/OcTextInput/OcTextInput.spec.ts @@ -133,7 +133,7 @@ describe('OcTextInput', () => { it.each(['text', 'number', 'email', 'password'])( 'should set the provided type for the input', (type) => { - const wrapper = getShallowWrapper({ type: type }) + const wrapper = getMountedWrapper({ props: { type: type } }) expect(wrapper.find('input').attributes('type')).toBe(type) } ) diff --git a/packages/design-system/src/components/OcTextInput/OcTextInput.vue b/packages/design-system/src/components/OcTextInput/OcTextInput.vue index 65efb422a74..2b74175a769 100644 --- a/packages/design-system/src/components/OcTextInput/OcTextInput.vue +++ b/packages/design-system/src/components/OcTextInput/OcTextInput.vue @@ -8,7 +8,8 @@ size="small" class="oc-mt-s oc-ml-s oc-position-absolute" /> -
+ @@ -73,6 +77,9 @@ import { defineComponent, HTMLAttributes, PropType } from 'vue' import uniqueId from '../../utils/uniqueId' import OcButton from '../OcButton/OcButton.vue' import OcIcon from '../OcIcon/OcIcon.vue' +import OcTextInputPassword, { + PasswordPolicy +} from '../_OcTextInputPassword/_OcTextInputPassword.vue' /** * Form Inputs are used to allow users to provide text input when the expected @@ -89,7 +96,7 @@ import OcIcon from '../OcIcon/OcIcon.vue' */ export default defineComponent({ name: 'OcTextInput', - components: { OcIcon, OcButton }, + components: { OcIcon, OcButton, OcTextInputPassword }, status: 'ready', release: '1.0.0', inheritAttrs: false, @@ -214,9 +221,27 @@ export default defineComponent({ readOnly: { type: Boolean, default: false + }, + /** + * Array of password policy rules, if type is password and password policy is given, + * the entered value will be checked against these rules. + * + * Password policy rules must be compliant with auth0/password-sheriff + * https://github.com/auth0/password-sheriff + * + */ + passwordPolicy: { + type: Object as PropType, + default: () => ({}) } }, - emits: ['change', 'update:modelValue', 'focus'], + emits: [ + 'change', + 'update:modelValue', + 'focus', + 'passwordChallengeCompleted', + 'passwordChallengeFailed' + ], computed: { showMessageLine() { return ( @@ -238,6 +263,9 @@ export default defineComponent({ if (this.defaultValue) { additionalAttrs['placeholder'] = this.defaultValue } + if (this.type === 'password') { + additionalAttrs['password-policy'] = this.passwordPolicy + } // Exclude listeners for events which are handled via methods in this component // eslint-disable-next-line no-unused-vars const { change, input, focus, class: classes, ...attrs } = this.$attrs @@ -266,6 +294,9 @@ export default defineComponent({ }, displayValue() { return this.modelValue || '' + }, + inputComponent() { + return this.type === 'password' ? 'oc-text-input-password' : 'input' } }, methods: { @@ -331,6 +362,12 @@ export default defineComponent({ color: var(--oc-color-text-muted); } + &-success, + &-success:focus { + border-color: var(--oc-color-swatch-success-default) !important; + color: var(--oc-color-swatch-success-default) !important; + } + &-warning, &-warning:focus { border-color: var(--oc-color-swatch-warning-default) !important; diff --git a/packages/design-system/src/components/_OcTextInputPassword/_OcTextInputPassword.vue b/packages/design-system/src/components/_OcTextInputPassword/_OcTextInputPassword.vue new file mode 100644 index 00000000000..16298720369 --- /dev/null +++ b/packages/design-system/src/components/_OcTextInputPassword/_OcTextInputPassword.vue @@ -0,0 +1,161 @@ + + + + diff --git a/packages/web-app-admin-settings/tests/unit/components/Groups/SideBar/__snapshots__/MembersPanel.spec.ts.snap b/packages/web-app-admin-settings/tests/unit/components/Groups/SideBar/__snapshots__/MembersPanel.spec.ts.snap index 13190e9a05c..52dfc481fa7 100644 --- a/packages/web-app-admin-settings/tests/unit/components/Groups/SideBar/__snapshots__/MembersPanel.spec.ts.snap +++ b/packages/web-app-admin-settings/tests/unit/components/Groups/SideBar/__snapshots__/MembersPanel.spec.ts.snap @@ -2,7 +2,7 @@ exports[`MembersPanel should display an empty result if no matching members found 1`] = `
- +

No members found

@@ -14,7 +14,7 @@ exports[`MembersPanel should display an empty result if no matching members foun exports[`MembersPanel should render all members accordingly to their role assignments 1`] = `
- +
diff --git a/packages/web-app-admin-settings/tests/unit/components/Spaces/SideBar/__snapshots__/MembersPanel.spec.ts.snap b/packages/web-app-admin-settings/tests/unit/components/Spaces/SideBar/__snapshots__/MembersPanel.spec.ts.snap index a696bd97ca1..b9d43d71037 100644 --- a/packages/web-app-admin-settings/tests/unit/components/Spaces/SideBar/__snapshots__/MembersPanel.spec.ts.snap +++ b/packages/web-app-admin-settings/tests/unit/components/Spaces/SideBar/__snapshots__/MembersPanel.spec.ts.snap @@ -2,7 +2,7 @@ exports[`MembersPanel should display an empty result if no matching members found 1`] = `
- +

No members found

@@ -16,7 +16,7 @@ exports[`MembersPanel should display an empty result if no matching members foun exports[`MembersPanel should render all members accordingly to their role assignments 1`] = `
- +
diff --git a/packages/web-app-admin-settings/tests/unit/components/Spaces/__snapshots__/SpacesList.spec.ts.snap b/packages/web-app-admin-settings/tests/unit/components/Spaces/__snapshots__/SpacesList.spec.ts.snap index d24bd14b6e3..cb78a7186aa 100644 --- a/packages/web-app-admin-settings/tests/unit/components/Spaces/__snapshots__/SpacesList.spec.ts.snap +++ b/packages/web-app-admin-settings/tests/unit/components/Spaces/__snapshots__/SpacesList.spec.ts.snap @@ -10,6 +10,7 @@ exports[`SpacesList should render all spaces in a table 1`] = `
+
diff --git a/packages/web-app-admin-settings/tests/unit/components/Users/SideBar/__snapshots__/EditPanel.spec.ts.snap b/packages/web-app-admin-settings/tests/unit/components/Users/SideBar/__snapshots__/EditPanel.spec.ts.snap index b0804cc4c86..0f1cffd340c 100644 --- a/packages/web-app-admin-settings/tests/unit/components/Users/SideBar/__snapshots__/EditPanel.spec.ts.snap +++ b/packages/web-app-admin-settings/tests/unit/components/Users/SideBar/__snapshots__/EditPanel.spec.ts.snap @@ -5,10 +5,10 @@ exports[`EditPanel renders all available inputs 1`] = `
- - - - + + + +
diff --git a/packages/web-app-files/src/components/SideBar/Shares/Links/DetailsAndEdit.vue b/packages/web-app-files/src/components/SideBar/Shares/Links/DetailsAndEdit.vue index ef61a516703..8186d7c0d3d 100644 --- a/packages/web-app-files/src/components/SideBar/Shares/Links/DetailsAndEdit.vue +++ b/packages/web-app-files/src/components/SideBar/Shares/Links/DetailsAndEdit.vue @@ -188,6 +188,7 @@ import { formatDateFromDateTime, formatRelativeDateFromDateTime } from 'web-pkg/ import { Resource, SpaceResource } from 'web-client/src/helpers' import { createFileRouteOptions } from 'web-pkg/src/helpers/router' import { OcDrop } from 'design-system/src/components' +import { usePasswordPolicyService } from 'web-pkg/src/composables/passwordPolicyService' export default defineComponent({ name: 'DetailsAndEdit', @@ -224,9 +225,12 @@ export default defineComponent({ }, emits: ['removePublicLink', 'updateLink'], setup() { + const passwordPolicyService = usePasswordPolicyService() + return { space: inject>('space'), - resource: inject>('resource') + resource: inject>('resource'), + passwordPolicy: passwordPolicyService.getPolicy() } }, data() { @@ -461,19 +465,6 @@ export default defineComponent({ this.createModal(modal) }, - checkPassword(password) { - if (password === '') { - this.setModalConfirmButtonDisabled(true) - return this.setModalInputErrorMessage(this.$gettext("Password can't be empty")) - } - if (password.length > 72) { - this.setModalConfirmButtonDisabled(true) - return this.setModalInputErrorMessage(this.$gettext("Password can't exceed 72 characters")) - } - this.setModalConfirmButtonDisabled(false) - return this.setModalInputErrorMessage(null) - }, - showPasswordModal() { const modal = { variation: 'passive', @@ -483,9 +474,12 @@ export default defineComponent({ hasInput: true, confirmDisabled: true, inputLabel: this.$gettext('Password'), + inputPasswordPolicy: this.passwordPolicy, + inputPlaceholder: this.link.password ? '●●●●●●●●' : null, inputType: 'password', onCancel: this.hideModal, - onInput: (password) => this.checkPassword(password), + onPasswordChallengeCompleted: () => this.setModalConfirmButtonDisabled(false), + onPasswordChallengeFailed: () => this.setModalConfirmButtonDisabled(true), onConfirm: (password) => { this.updateLink({ link: { diff --git a/packages/web-app-files/tests/unit/components/SideBar/Shares/Links/DetailsAndEdit.spec.ts b/packages/web-app-files/tests/unit/components/SideBar/Shares/Links/DetailsAndEdit.spec.ts index 288305b2525..b6c6a1688bf 100644 --- a/packages/web-app-files/tests/unit/components/SideBar/Shares/Links/DetailsAndEdit.spec.ts +++ b/packages/web-app-files/tests/unit/components/SideBar/Shares/Links/DetailsAndEdit.spec.ts @@ -4,7 +4,8 @@ import { createStore, defaultPlugins, shallowMount, - defaultStoreMockOptions + defaultStoreMockOptions, + defaultComponentMocks } from 'web-test-helpers' import { mockDeep } from 'jest-mock-extended' import { Resource } from 'web-client' @@ -48,25 +49,11 @@ describe('DetailsAndEdit component', () => { expect(setModalInputErrorMessageStub).toHaveBeenCalledWith(expect.anything()) }) }) - - describe('method "checkPassword"', () => { - it('should not show an error if value is valid', () => { - const { wrapper } = getShallowMountedWrapper(exampleLink, false, true) - const setModalInputErrorMessageStub = jest.spyOn(wrapper.vm, 'setModalInputErrorMessage') - wrapper.vm.checkPassword('Password1234') - expect(setModalInputErrorMessageStub).toHaveBeenCalledWith(null) - }) - it('should show an error if value is longer than 72 characters', () => { - const { wrapper } = getShallowMountedWrapper(exampleLink, false, true) - const setModalInputErrorMessageStub = jest.spyOn(wrapper.vm, 'setModalInputErrorMessage') - wrapper.vm.checkPassword('n'.repeat(73)) - expect(setModalInputErrorMessageStub).toHaveBeenCalledWith(expect.anything()) - }) - }) }) function getShallowMountedWrapper(link, expireDateEnforced = false, isModifiable = false) { const storeOptions = defaultStoreMockOptions + const mocks = defaultComponentMocks() const store = createStore(storeOptions) return { wrapper: shallowMount(DetailsAndEdit, { @@ -85,9 +72,11 @@ function getShallowMountedWrapper(link, expireDateEnforced = false, isModifiable file: mockDeep() }, global: { + mocks, renderStubDefaultSlot: true, stubs: { OcDatepicker: false, 'date-picker': true }, - plugins: [...defaultPlugins(), store] + plugins: [...defaultPlugins(), store], + provide: mocks } }) } diff --git a/packages/web-app-files/tests/unit/components/SideBar/Shares/__snapshots__/SpaceMembers.spec.ts.snap b/packages/web-app-files/tests/unit/components/SideBar/Shares/__snapshots__/SpaceMembers.spec.ts.snap index 4686bb93aba..7bd1fc0e8b5 100644 --- a/packages/web-app-files/tests/unit/components/SideBar/Shares/__snapshots__/SpaceMembers.spec.ts.snap +++ b/packages/web-app-files/tests/unit/components/SideBar/Shares/__snapshots__/SpaceMembers.spec.ts.snap @@ -46,6 +46,7 @@ exports[`SpaceMembers filter toggles the filter on click 1`] = `
+
diff --git a/packages/web-client/src/ocs/capabilities.ts b/packages/web-client/src/ocs/capabilities.ts index b5ccaad69f5..410ac89202e 100644 --- a/packages/web-client/src/ocs/capabilities.ts +++ b/packages/web-client/src/ocs/capabilities.ts @@ -9,8 +9,20 @@ export interface AppProviderCapability { open_url: string version: string } + +export interface PasswordPolicyCapability { + min_characters?: number + max_characters?: number + min_lower_case_characters?: number + min_upper_case_characters?: number + min_digits?: number + min_special_characters?: number + special_characters?: string +} + export interface Capabilities { capabilities: { + password_policy?: PasswordPolicyCapability notifications: { ocs_endpoints: string[] } diff --git a/packages/web-pkg/package.json b/packages/web-pkg/package.json index 1d61acb65eb..3f4acd38bcc 100644 --- a/packages/web-pkg/package.json +++ b/packages/web-pkg/package.json @@ -34,6 +34,7 @@ "vue3-gettext": "2.5.0-alpha.1", "vuex": "4.1.0", "web-client": "workspace:@ownclouders/web-client@*", - "web-pkg": "workspace:@ownclouders/web-pkg@*" + "web-pkg": "workspace:@ownclouders/web-pkg@*", + "password-sheriff": "^1.1.1" } } diff --git a/packages/web-pkg/src/composables/capability/useCapability.ts b/packages/web-pkg/src/composables/capability/useCapability.ts index 8df471becdf..f1957ce36fc 100644 --- a/packages/web-pkg/src/composables/capability/useCapability.ts +++ b/packages/web-pkg/src/composables/capability/useCapability.ts @@ -2,7 +2,7 @@ import { Store } from 'vuex' import get from 'lodash-es/get' import { computed, ComputedRef } from 'vue' import { useStore } from '../store' -import { AppProviderCapability } from 'web-client/src/ocs/capabilities' +import { AppProviderCapability, PasswordPolicyCapability } from 'web-client/src/ocs/capabilities' export const useCapability = ( store: Store, @@ -135,3 +135,7 @@ export const useCapabilityNotifications = createCapabilityComposable( 'notifications.ocs-endpoints', [] ) +export const useCapabilityPasswordPolicy = createCapabilityComposable( + 'password_policy', + {} +) diff --git a/packages/web-pkg/src/composables/index.ts b/packages/web-pkg/src/composables/index.ts index 10f6ddd97e9..0173787fbb7 100644 --- a/packages/web-pkg/src/composables/index.ts +++ b/packages/web-pkg/src/composables/index.ts @@ -18,3 +18,4 @@ export * from './fileListHeaderPosition' export * from './viewMode' export * from './search' export * from './sse' +export * from './passwordPolicyService' diff --git a/packages/web-pkg/src/composables/passwordPolicyService/index.ts b/packages/web-pkg/src/composables/passwordPolicyService/index.ts new file mode 100644 index 00000000000..829912b5ad9 --- /dev/null +++ b/packages/web-pkg/src/composables/passwordPolicyService/index.ts @@ -0,0 +1 @@ +export * from './usePasswordPolicyService' diff --git a/packages/web-pkg/src/composables/passwordPolicyService/usePasswordPolicyService.ts b/packages/web-pkg/src/composables/passwordPolicyService/usePasswordPolicyService.ts new file mode 100644 index 00000000000..bedaa293e05 --- /dev/null +++ b/packages/web-pkg/src/composables/passwordPolicyService/usePasswordPolicyService.ts @@ -0,0 +1,6 @@ +import { useService } from 'web-pkg/src/composables/service' +import { PasswordPolicyService } from 'web-pkg/src/services/passwordPolicy' + +export const usePasswordPolicyService = (): PasswordPolicyService => { + return useService('$passwordPolicyService') +} diff --git a/packages/web-pkg/src/services/index.ts b/packages/web-pkg/src/services/index.ts index bb74e593598..f41c44791d0 100644 --- a/packages/web-pkg/src/services/index.ts +++ b/packages/web-pkg/src/services/index.ts @@ -4,3 +4,4 @@ export * from './client' export * from './eventBus' export * from './loadingService' export * from './preview' +export * from './passwordPolicy' diff --git a/packages/web-pkg/src/services/passwordPolicy/index.ts b/packages/web-pkg/src/services/passwordPolicy/index.ts new file mode 100644 index 00000000000..600504b2e4b --- /dev/null +++ b/packages/web-pkg/src/services/passwordPolicy/index.ts @@ -0,0 +1 @@ +export * from './passwordPolicy' diff --git a/packages/web-pkg/src/services/passwordPolicy/passwordPolicy.ts b/packages/web-pkg/src/services/passwordPolicy/passwordPolicy.ts new file mode 100644 index 00000000000..dac86de30c6 --- /dev/null +++ b/packages/web-pkg/src/services/passwordPolicy/passwordPolicy.ts @@ -0,0 +1,78 @@ +import { Language } from 'vue3-gettext' +import { + AtLeastCharactersRule, + AtLeastDigitsRule, + AtLeastLowercaseCharactersRule, + AtLeastUppercaseCharactersRule, + AtMostCharactersRule, + MustContainRule, + MustNotBeEmptyRule +} from './rules' +import { PasswordPolicyCapability } from 'web-client/src/ocs/capabilities' +import { PasswordPolicy } from 'password-sheriff' +import get from 'lodash-es/get' +import { Store } from 'vuex' + +export class PasswordPolicyService { + private readonly capability: PasswordPolicyCapability + private readonly language: Language + private policy: PasswordPolicy + + constructor({ store, language }: { store: Store; language: Language }) { + this.capability = get(store, 'getters.capabilities.password_policy', {}) + this.language = language + this.buildPolicy() + } + + private buildPolicy() { + const ruleset = { + atLeastCharacters: new AtLeastCharactersRule({ ...this.language }), + mustNotBeEmpty: new MustNotBeEmptyRule({ ...this.language }), + atLeastUppercaseCharacters: new AtLeastUppercaseCharactersRule({ ...this.language }), + atLeastLowercaseCharacters: new AtLeastLowercaseCharactersRule({ ...this.language }), + atLeastDigits: new AtLeastDigitsRule({ ...this.language }), + mustContain: new MustContainRule({ ...this.language }), + atMostCharacters: new AtMostCharactersRule({ ...this.language }) + } + const rules = {} as any + + if (this.capability.min_characters) { + rules.atLeastCharacters = { minLength: this.capability.min_characters } + } else { + rules.mustNotBeEmpty = {} + } + + if (this.capability.min_upper_case_characters) { + rules.atLeastUppercaseCharacters = { + minLength: this.capability.min_upper_case_characters + } + } + + if (this.capability.min_lower_case_characters) { + rules.atLeastLowercaseCharacters = { + minLength: this.capability.min_lower_case_characters + } + } + + if (this.capability.min_digits) { + rules.atLeastDigits = { minLength: this.capability.min_digits } + } + + if (this.capability.min_special_characters) { + rules.mustContain = { + minLength: this.capability.min_special_characters, + characters: this.capability.special_characters + } + } + + if (this.capability.max_characters) { + rules.atMostCharacters = { maxLength: this.capability.max_characters } + } + + this.policy = new PasswordPolicy(rules, ruleset) + } + + public getPolicy(): PasswordPolicy { + return this.policy + } +} diff --git a/packages/web-pkg/src/services/passwordPolicy/rules.ts b/packages/web-pkg/src/services/passwordPolicy/rules.ts new file mode 100644 index 00000000000..0a5d02f0c04 --- /dev/null +++ b/packages/web-pkg/src/services/passwordPolicy/rules.ts @@ -0,0 +1,275 @@ +import { isNaN, isNumber, isObject, isString, isBoolean } from 'lodash-es' +import { Language } from 'vue3-gettext' + +export interface PasswordPolicyRuleOptions { + minLength?: number + maxLength?: number + characters?: string +} + +export interface PasswordPolicyRuleExplained { + code: string + message: string + format: (number | string)[] + verified?: boolean +} + +export interface PasswordPolicyRule { + assert(options: PasswordPolicyRuleOptions, password: string): boolean + explain(options: PasswordPolicyRuleOptions, verified?: boolean): PasswordPolicyRuleExplained + missing(options: PasswordPolicyRuleOptions, password: string): PasswordPolicyRuleExplained + validate(options?: PasswordPolicyRuleOptions): boolean +} + +export class MustNotBeEmptyRule implements PasswordPolicyRule { + protected $gettext + + constructor({ $gettext }: Language) { + this.$gettext = $gettext + } + + explain(options: PasswordPolicyRuleOptions, verified: boolean): PasswordPolicyRuleExplained { + return { + code: 'mustNotBeEmpty', + message: this.$gettext('Must not be empty'), + format: [], + ...(isBoolean(verified) && { verified }) + } + } + + assert(options: PasswordPolicyRuleOptions, password: string): boolean { + return password.length > 0 + } + + validate(): boolean { + return true + } + + missing(options: PasswordPolicyRuleOptions, password: string): PasswordPolicyRuleExplained { + return this.explain(options, this.assert(options, password)) + } +} +export class MustContainRule implements PasswordPolicyRule { + protected $gettext + + constructor({ $gettext }: Language) { + this.$gettext = $gettext + } + + explain(options: PasswordPolicyRuleOptions, verified: boolean): PasswordPolicyRuleExplained { + return { + code: 'mustContain', + message: this.$gettext('At least %{param1} of the special characters: %{param2}'), + format: [options.minLength, options.characters], + ...(isBoolean(verified) && { verified }) + } + } + + assert(options: PasswordPolicyRuleOptions, password: string) { + const charsCount = Array.from(password).filter((char) => + options.characters.includes(char) + ).length + + return charsCount >= options.minLength + } + + validate(options: PasswordPolicyRuleOptions): boolean { + if (!isObject(options)) { + throw new Error('options should be an object') + } + + if (!isNumber(options.minLength) || isNaN(options.minLength)) { + throw new Error('minLength should be a non-zero number') + } + + if (!isString(options.characters)) { + throw new Error('characters should be a character sequence') + } + + return true + } + missing(options, password) { + return this.explain(options, this.assert(options, password)) + } +} +export class AtMostBaseRule implements PasswordPolicyRule { + protected $ngettext + + constructor({ $ngettext }: Language) { + this.$ngettext = $ngettext + } + + assert(options: PasswordPolicyRuleOptions, password: string): boolean { + throw new Error('Method not implemented.') + } + + explain(options: PasswordPolicyRuleOptions, verified?: boolean): PasswordPolicyRuleExplained { + throw new Error('Method not implemented.') + } + + validate(options: PasswordPolicyRuleOptions): boolean { + if (!isObject(options)) { + throw new Error('options should be an object') + } + + if (!isNumber(options.maxLength) || isNaN(options.maxLength)) { + throw new Error('maxLength should be a non-zero number') + } + + return true + } + + missing(options: PasswordPolicyRuleOptions, password: string): PasswordPolicyRuleExplained { + return this.explain(options, this.assert(options, password)) + } +} + +export class AtMostCharactersRule extends AtMostBaseRule { + constructor(args: Language) { + super(args) + } + + explain(options: PasswordPolicyRuleOptions, verified: boolean): PasswordPolicyRuleExplained { + return { + code: 'atMostCharacters', + message: this.$ngettext( + 'At most %{param1} character long', + 'At most %{param1} characters long', + options.maxLength + ), + format: [options.maxLength], + ...(isBoolean(verified) && { verified }) + } + } + + assert(options: PasswordPolicyRuleOptions, password: string): boolean { + return password.length <= options.maxLength + } +} + +export class AtLeastBaseRule implements PasswordPolicyRule { + protected $ngettext + + constructor({ $ngettext }: Language) { + this.$ngettext = $ngettext + } + + assert(options: PasswordPolicyRuleOptions, password: string): boolean { + throw new Error('Method not implemented.') + } + + explain(options: PasswordPolicyRuleOptions, verified?: boolean): PasswordPolicyRuleExplained { + throw new Error('Method not implemented.') + } + + validate(options: PasswordPolicyRuleOptions): boolean { + if (!isObject(options)) { + throw new Error('options should be an object') + } + + if (!isNumber(options.minLength) || isNaN(options.minLength)) { + throw new Error('minLength should be a non-zero number') + } + + return true + } + + missing(options: PasswordPolicyRuleOptions, password: string): PasswordPolicyRuleExplained { + return this.explain(options, this.assert(options, password)) + } +} + +export class AtLeastCharactersRule extends AtLeastBaseRule implements PasswordPolicyRule { + constructor(args: Language) { + super(args) + } + + explain(options: PasswordPolicyRuleOptions, verified: boolean): PasswordPolicyRuleExplained { + return { + code: 'atLeastCharacters', + message: this.$ngettext( + 'At least %{param1} character long', + 'At least %{param1} characters long', + options.minLength + ), + format: [options.minLength], + ...(isBoolean(verified) && { verified }) + } + } + + assert(options: PasswordPolicyRuleOptions, password: string): boolean { + return password.length >= options.minLength + } +} + +export class AtLeastUppercaseCharactersRule extends AtLeastBaseRule { + constructor(args: Language) { + super(args) + } + + explain(options: PasswordPolicyRuleOptions, verified: boolean): PasswordPolicyRuleExplained { + return { + code: 'atLeastUppercaseCharacters', + message: this.$ngettext( + 'At least %{param1} uppercase character', + 'At least %{param1} uppercase characters', + options.minLength + ), + format: [options.minLength], + ...(isBoolean(verified) && { verified }) + } + } + + assert(options: PasswordPolicyRuleOptions, password: string): boolean { + const uppercaseCount = (password || '').match(/[A-Z\xC0-\xD6\xD8-\xDE]/g)?.length + return uppercaseCount >= options.minLength + } +} + +export class AtLeastLowercaseCharactersRule extends AtLeastBaseRule { + constructor(args: Language) { + super(args) + } + + explain(options: PasswordPolicyRuleOptions, verified: boolean): PasswordPolicyRuleExplained { + return { + code: 'atLeastLowercaseCharacters', + message: this.$ngettext( + 'At least %{param1} lowercase character', + 'At least %{param1} lowercase characters', + options.minLength + ), + format: [options.minLength], + ...(isBoolean(verified) && { verified }) + } + } + + assert(options: PasswordPolicyRuleOptions, password: string): boolean { + const lowercaseCount = (password || '').match(/[a-z\xDF-\xF6\xF8-\xFF]/g)?.length + return lowercaseCount >= options.minLength + } +} + +export class AtLeastDigitsRule extends AtLeastBaseRule { + constructor(args: Language) { + super(args) + } + + explain(options: PasswordPolicyRuleOptions, verified: boolean): PasswordPolicyRuleExplained { + return { + code: 'atLeastDigits', + message: this.$ngettext( + 'At least %{param1} number', + 'At least %{param1} numbers', + options.minLength + ), + format: [options.minLength], + ...(isBoolean(verified) && { verified }) + } + } + + assert(options: PasswordPolicyRuleOptions, password: string): boolean { + const digitCount = (password || '').match(/\d/g)?.length + return digitCount >= options.minLength + } +} diff --git a/packages/web-pkg/types.d.ts b/packages/web-pkg/types.d.ts index 522d5a6ee45..8f4cca31ee1 100644 --- a/packages/web-pkg/types.d.ts +++ b/packages/web-pkg/types.d.ts @@ -1,5 +1,11 @@ import { Ability } from 'web-client/src/helpers/resource/types' -import { ArchiverService, ClientService, LoadingService, PreviewService } from './src/services' +import { + ArchiverService, + ClientService, + LoadingService, + PreviewService, + PasswordPolicyService +} from './src/services' export * from './src' @@ -10,5 +16,6 @@ declare module 'vue' { $clientService: ClientService $loadingService: LoadingService $previewService: PreviewService + $passwordPolicyService: PasswordPolicyService } } diff --git a/packages/web-runtime/src/App.vue b/packages/web-runtime/src/App.vue index 510647eadb6..2543ce66e98 100644 --- a/packages/web-runtime/src/App.vue +++ b/packages/web-runtime/src/App.vue @@ -14,6 +14,8 @@ :message="modal.message" :has-input="modal.hasInput" :input-description="modal.inputDescription" + :input-placeholder="modal.inputPlaceholder" + :input-password-policy="modal.inputPasswordPolicy" :input-disabled="modal.inputDisabled" :input-error="modal.inputError" :input-label="modal.inputLabel" @@ -33,6 +35,8 @@ @input="modal.onInput" @checkbox-changed="modal.onCheckboxValueChanged" @confirm-secondary="modal.onConfirmSecondary" + @passwordChallengeCompleted="modal.onPasswordChallengeCompleted" + @passwordChallengeFailed="modal.onPasswordChallengeFailed" @mounted="focusModal" @before-unmount="focusModal" > diff --git a/packages/web-runtime/src/container/bootstrap.ts b/packages/web-runtime/src/container/bootstrap.ts index 95dfe4d77eb..4292d3c0e7a 100644 --- a/packages/web-runtime/src/container/bootstrap.ts +++ b/packages/web-runtime/src/container/bootstrap.ts @@ -11,7 +11,12 @@ import { getBackendVersion, getWebVersion } from './versions' import { useLocalStorage } from 'web-pkg/src/composables' import { useDefaultThemeName } from '../composables' import { authService } from '../services/auth' -import { ClientService, LoadingService, PreviewService } from 'web-pkg/src/services' +import { + ClientService, + LoadingService, + PasswordPolicyService, + PreviewService +} from 'web-pkg/src/services' import { UppyService } from '../services/uppyService' import { default as storeOptions } from '../store' import { init as sentryInit } from '@sentry/vue' @@ -413,6 +418,22 @@ export const announceAuthService = ({ app.provide('$authService', authService) } +/** + * @param vue + */ +export const announcePasswordPolicyService = ({ + app, + store +}: { + app: App + store: Store +}): void => { + const language = app.config.globalProperties.$language + const passwordPolicyService = new PasswordPolicyService({ store, language }) + app.config.globalProperties.passwordPolicyService = passwordPolicyService + app.provide('$passwordPolicyService', passwordPolicyService) +} + /** * announce runtime defaults, this is usual the last needed announcement before rendering the actual ui * diff --git a/packages/web-runtime/src/index.ts b/packages/web-runtime/src/index.ts index 636d1f74d90..e11c51ff731 100644 --- a/packages/web-runtime/src/index.ts +++ b/packages/web-runtime/src/index.ts @@ -24,6 +24,7 @@ import { announceCustomScripts, announceLoadingService, announcePreviewService, + announcePasswordPolicyService, announceAdditionalTranslations } from './container/bootstrap' import { applicationStore } from './container/store' @@ -87,6 +88,7 @@ export const bootstrapApp = async (configurationPath: string): Promise => app.provide('$archiverService', app.config.globalProperties.$archiverService) announceLoadingService({ app }) announcePreviewService({ app, store, configurationManager }) + announcePasswordPolicyService({ app, store }) await announceClient(runtimeConfiguration) const applicationsPromise = initializeApplications({ diff --git a/packages/web-runtime/src/store/modal.ts b/packages/web-runtime/src/store/modal.ts index abc0fb67812..d1d505278c6 100644 --- a/packages/web-runtime/src/store/modal.ts +++ b/packages/web-runtime/src/store/modal.ts @@ -71,8 +71,10 @@ const mutations = { state.onConfirm = modal.onConfirm state.hasInput = modal.hasInput || false state.inputValue = modal.inputValue || null + state.inputPasswordPolicy = modal.inputPasswordPolicy || {} state.inputSelectionRange = modal.inputSelectionRange state.inputDescription = modal.inputDescription || null + state.inputPlaceholder = modal.inputPlaceholder || null state.inputLabel = modal.inputLabel || null state.inputError = modal.inputError || null state.inputDisabled = modal.inputDisabled || false @@ -83,6 +85,8 @@ const mutations = { state.contextualHelperLabel = modal.contextualHelperLabel state.contextualHelperData = modal.contextualHelperData state.customContent = modal.customContent || '' + state.onPasswordChallengeCompleted = modal.onPasswordChallengeCompleted + state.onPasswordChallengeFailed = modal.onPasswordChallengeFailed }, HIDE_MODAL(state) { diff --git a/packages/web-runtime/tests/unit/pages/__snapshots__/resolvePublicLink.spec.ts.snap b/packages/web-runtime/tests/unit/pages/__snapshots__/resolvePublicLink.spec.ts.snap index bebeafec806..0d3e74fff56 100644 --- a/packages/web-runtime/tests/unit/pages/__snapshots__/resolvePublicLink.spec.ts.snap +++ b/packages/web-runtime/tests/unit/pages/__snapshots__/resolvePublicLink.spec.ts.snap @@ -8,7 +8,7 @@ exports[`resolvePublicLink password required form should display if password is
- +
diff --git a/packages/web-test-helpers/src/mocks/defaultComponentMocks.ts b/packages/web-test-helpers/src/mocks/defaultComponentMocks.ts index e506d8eac6a..8ff1b4a649b 100644 --- a/packages/web-test-helpers/src/mocks/defaultComponentMocks.ts +++ b/packages/web-test-helpers/src/mocks/defaultComponentMocks.ts @@ -3,6 +3,7 @@ import { ClientService, LoadingService, LoadingTaskCallbackArguments, + PasswordPolicyService, PreviewService } from 'web-pkg/src/services' import { Router, RouteLocationNormalizedLoaded, RouteLocationRaw } from 'vue-router' @@ -33,6 +34,7 @@ export const defaultComponentMocks = ({ currentRoute = undefined }: ComponentMoc addTask: (callback) => { return callback(mock()) } - }) + }), + $passwordPolicyService: mockDeep() } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f32b11c10a5..ef6a57ec779 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.1' +lockfileVersion: '6.0' settings: autoInstallPeers: true @@ -294,6 +294,9 @@ importers: packages/design-system: dependencies: + portal-vue: + specifier: '*' + version: 3.0.0(vue@3.3.4) web-client: specifier: workspace:@ownclouders/web-client@* version: link:../web-client @@ -934,6 +937,9 @@ importers: mark.js: specifier: ^8.11.1 version: 8.11.1 + password-sheriff: + specifier: ^1.1.1 + version: 1.1.1 pinia: specifier: ^2.1.3 version: 2.1.3(typescript@5.0.3)(vue@3.3.4) @@ -16222,6 +16228,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /password-sheriff@1.1.1: + resolution: {integrity: sha512-bt0ptyUs97Fb2ZXUcdQP0RYrBFjzO6KhGTjq4RkmR388c6wcT3khG0U7Bvvqwq3DyShEZ9IACed9JMVyAxdaCA==} + dev: false + /path-browserify@0.0.1: resolution: {integrity: sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==} dev: true