Skip to content

Commit

Permalink
Desktop view: Add user registration and password reset to the login s…
Browse files Browse the repository at this point in the history
…creen.

Co-authored-by: Tobias Schäfer <ts@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Vladimir Sheremet <vs@zammad.com>
Co-authored-by: Dominik Klein <dk@zammad.com>
  • Loading branch information
5 people committed Dec 28, 2023
1 parent abdf4a7 commit 015a19a
Show file tree
Hide file tree
Showing 175 changed files with 4,494 additions and 502 deletions.
2 changes: 1 addition & 1 deletion .prettierignore
@@ -1,4 +1,4 @@
**/graphql/**/*.ts
**/graphql/**/*.api.ts
!**/graphql/**/mocks/*.ts
!**/graphql/**/*.spec.ts
!**/tests/graphql/**
Expand Down
181 changes: 70 additions & 111 deletions app/controllers/users_controller.rb
Expand Up @@ -364,12 +364,19 @@ def unlock
def email_verify
raise Exceptions::UnprocessableEntity, __('No token!') if !params[:token]

user = User.signup_verify_via_token(params[:token], current_user)
raise Exceptions::UnprocessableEntity, __('The provided token is invalid.') if !user
verify = Service::User::SignupVerify.new(token: params[:token], current_user: current_user)

current_user_set(user)
begin
user = verify.execute
rescue Service::CheckFeatureEnabled::FeatureDisabledError, Service::User::SignupVerify::InvalidTokenError => e
raise Exceptions::UnprocessableEntity, e.message
end

current_user_set(user) if user

render json: { message: 'ok', user_email: user.email }, status: :ok
msg = user ? { message: 'ok', user_email: user.email } : { message: 'failed' }

render json: msg, status: :ok
end

=begin
Expand All @@ -396,31 +403,18 @@ def email_verify_send

raise Exceptions::UnprocessableEntity, __('No email!') if !params[:email]

user = User.find_by(email: params[:email].downcase)
if !user || user.verified == true
# result is always positive to avoid leaking of existing user accounts
render json: { message: 'ok' }, status: :ok
return
end
signup = Service::User::Deprecated::Signup.new(user_data: { email: params[:email] }, resend: true)

Token.create(action: 'Signup', user_id: user.id)

result = User.signup_new_token(user)
if result && result[:token]
user = result[:user]
NotificationFactory::Mailer.notification(
template: 'signup',
user: user,
objects: result
)

# token sent to user, send ok to browser
render json: { message: 'ok' }, status: :ok
return
begin
signup.execute
rescue Service::CheckFeatureEnabled::FeatureDisabledError => e
raise Exceptions::UnprocessableEntity, e.message
rescue Service::User::Signup::TokenGenerationError
render json: { message: 'failed' }, status: :ok
end

# unable to generate token
render json: { message: 'failed' }, status: :ok
# Result is always positive to avoid leaking of existing user accounts.
render json: { message: 'ok' }, status: :ok
end

=begin
Expand All @@ -447,7 +441,14 @@ def admin_password_auth_send
raise Exceptions::UnprocessableEntity, 'username param needed!' if params[:username].blank?

send = Service::Auth::Deprecated::SendAdminToken.new(login: params[:username])
send.execute
begin
send.execute
rescue Service::CheckFeatureEnabled::FeatureDisabledError => e
raise Exceptions::UnprocessableEntity, e.message
rescue Service::Auth::Deprecated::SendAdminToken::TokenError, Service::Auth::Deprecated::SendAdminToken::EmailError
render json: { message: 'failed' }, status: :ok
return
end

render json: { message: 'ok' }, status: :ok
end
Expand All @@ -456,7 +457,12 @@ def admin_password_auth_verify
raise Exceptions::UnprocessableEntity, 'token param needed!' if params[:token].blank?

verify = Service::Auth::VerifyAdminToken.new(token: params[:token])
user = verify.execute

user = begin
verify.execute
rescue => e
raise Exceptions::UnprocessableEntity, e.message
end

msg = user ? { message: 'ok', user_login: user.login } : { message: 'failed' }

Expand Down Expand Up @@ -484,28 +490,20 @@ def admin_password_auth_verify
=end

def password_reset_send
raise Exceptions::UnprocessableEntity, 'username param needed!' if params[:username].blank?

# check if feature is enabled
raise Exceptions::UnprocessableEntity, __('Feature not enabled!') if !Setting.get('user_lost_password')

result = User.password_reset_new_token(params[:username])
if result && result[:token]

# unable to send email
if !result[:user] || result[:user].email.blank?
render json: { message: 'failed' }, status: :ok
return
end
send = Service::User::PasswordReset::Deprecated::Send.new(username: params[:username])

# send password reset emails
NotificationFactory::Mailer.notification(
template: 'password_reset',
user: result[:user],
objects: result
)
begin
send.execute
rescue Service::CheckFeatureEnabled::FeatureDisabledError => e
raise Exceptions::UnprocessableEntity, e.message
rescue Service::User::PasswordReset::Send::EmailError
render json: { message: 'failed' }, status: :ok
return
end

# result is always positive to avoid leaking of existing user accounts
# Result is always positive to avoid leaking of existing user accounts.
render json: { message: 'ok' }, status: :ok
end

Expand All @@ -531,46 +529,39 @@ def password_reset_send
=end

def password_reset_verify

# check if feature is enabled
raise Exceptions::UnprocessableEntity, __('Feature not enabled!') if !Setting.get('user_lost_password')
raise Exceptions::UnprocessableEntity, 'token param needed!' if params[:token].blank?

# if no password is given, verify token only
# If no password is given, verify token only.
if params[:password].blank?
user = User.by_reset_token(params[:token])
if user
render json: { message: 'ok', user_login: user.login }, status: :ok
verify = Service::User::PasswordReset::Verify.new(token: params[:token])

begin
user = verify.execute
rescue Service::CheckFeatureEnabled::FeatureDisabledError => e
raise Exceptions::UnprocessableEntity, e.message
rescue Service::User::PasswordReset::Verify::InvalidTokenError
render json: { message: 'failed' }, status: :ok
return
end
render json: { message: 'failed' }, status: :ok
return
end

result = PasswordPolicy.new(params[:password])
if !result.valid?
render json: { message: 'failed', notice: result.error }, status: :ok
render json: { message: 'ok', user_login: user.login }, status: :ok
return
end

# set new password with token
user = User.password_reset_via_token(params[:token], params[:password])
update = Service::User::PasswordReset::Update.new(token: params[:token], password: params[:password])

# send mail
if !user || user.email.blank?
begin
user = update.execute
rescue Service::CheckFeatureEnabled::FeatureDisabledError => e
raise Exceptions::UnprocessableEntity, e.message
rescue Service::User::PasswordReset::Update::InvalidTokenError, Service::User::PasswordReset::Update::EmailError
render json: { message: 'failed' }, status: :ok
return
rescue PasswordPolicy::Error => e
render json: { message: 'failed', notice: [e] }, status: :ok
return
end

NotificationFactory::Mailer.notification(
template: 'password_change',
user: user,
objects: {
user: user,
current_user: current_user,
}
)

render json: { message: 'ok', user_login: user.login }, status: :ok
end

Expand Down Expand Up @@ -1013,11 +1004,6 @@ def create_internal
# @response_message 200 [User] Created User record.
# @response_message 403 Forbidden / Invalid session.
def create_signup
# check if feature is enabled
if !Setting.get('user_create_account')
raise Exceptions::UnprocessableEntity, __('Feature not enabled!')
end

# check signup option only after admin account is created
if !params[:signup]
raise Exceptions::UnprocessableEntity, __("The required parameter 'signup' is missing.")
Expand All @@ -1032,44 +1018,17 @@ def create_signup
raise Exceptions::UnprocessableEntity, __('Attribute \'email\' required!')
end

email_taken_by = User.find_by email: new_params[:email].downcase.strip

result = PasswordPolicy.new(new_params[:password])
if !result.valid?
render json: { error: result.error }, status: :unprocessable_entity
return
end

user = User.new(new_params)
user.role_ids = Role.signup_role_ids
user.source = 'signup'

if email_taken_by # show fake OK response to avoid leaking that email is already in use
user.skip_ensure_uniq_email = true
user.validate!

result = User.password_reset_new_token(email_taken_by.email)
NotificationFactory::Mailer.notification(
template: 'signup_taken_reset',
user: email_taken_by,
objects: result,
)
signup = Service::User::Deprecated::Signup.new(user_data: new_params)

render json: { message: 'ok' }, status: :created
begin
signup.execute
rescue PasswordPolicy::Error => e
render json: { error: e.message }, status: :unprocessable_entity
return
rescue Service::CheckFeatureEnabled::FeatureDisabledError => e
raise Exceptions::UnprocessableEntity, e.message
end

UserInfo.ensure_current_user_id do
user.save!
end

result = User.signup_new_token(user)
NotificationFactory::Mailer.notification(
template: 'signup',
user: user,
objects: result,
)

render json: { message: 'ok' }, status: :created
end

Expand Down
Expand Up @@ -42,6 +42,10 @@ const hoverPoweredByLogo = ref(false)
{{ $t(title) }}
</h1>
<slot />

<div v-if="$slots.boxActions" class="flex justify-end items-end gap-2">
<slot name="boxActions" />
</div>
</main>

<section
Expand Down
Expand Up @@ -8,12 +8,15 @@ import type {
} from '#shared/types/form.ts'

const textInputClasses = (classes: Classes = {}) => {
const innerInvalidClasses =
'formkit-invalid:outline formkit-invalid:outline-1 formkit-invalid:outline-offset-1 formkit-invalid:outline-red-500 dark:hover:formkit-invalid:outline-red-500'
const innerErrorsClasses = innerInvalidClasses.replace(/invalid/g, 'errors')

return extendClasses(classes, {
wrapper: 'flex flex-col items-start justify-start mb-1.5 last:mb-0',
input: 'grow bg-transparent',
input: 'grow bg-transparent py-2 px-2.5',
label: 'block mb-1 text-sm text-gray-100 dark:text-neutral-400',
inner:
'flex items-center w-full h-10 py-2 px-2.5 bg-blue-200 dark:bg-gray-700 text-black dark:text-white hover:outline hover:outline-1 hover:outline-offset-1 hover:outline-blue-600 dark:hover:outline-blue-900 focus-within:outline focus-within:outline-1 focus-within:outline-offset-1 focus-within:outline-blue-800 hover:focus-within:outline-blue-800 dark:hover:focus-within:outline-blue-800 formkit-invalid:outline formkit-invalid:outline-1 formkit-invalid:outline-offset-1 formkit-invalid:outline-red-500 dark:hover:formkit-invalid:outline-red-500',
inner: `flex items-center w-full h-10 bg-blue-200 dark:bg-gray-700 text-black dark:text-white hover:outline hover:outline-1 hover:outline-offset-1 hover:outline-blue-600 dark:hover:outline-blue-900 focus-within:outline focus-within:outline-1 focus-within:outline-offset-1 focus-within:outline-blue-800 hover:focus-within:outline-blue-800 dark:hover:focus-within:outline-blue-800 ${innerInvalidClasses} ${innerErrorsClasses}`,
})
}

Expand All @@ -24,14 +27,15 @@ export const getCoreDesktopClasses: FormThemeExtension = (
global: extendClasses(classes.global, {
wrapper: 'flex-grow',
block: 'flex items-end',
label: 'formkit-required:required formkit-invalid:text-red-500',
label:
'formkit-required:required formkit-invalid:text-red-500 formkit-errors:text-red-500',
inner: 'rounded-lg text-sm',
messages: 'mt-1 formkit-invalid:text-red-500',
messages: 'mt-1 formkit-invalid:text-red-500 formkit-errors:text-red-500',
help: 'text-stone-200 dark:text-neutral-500 mt-1',
prefixIcon:
'relative h-5 w-5 flex justify-center items-center fill-current text-stone-200 dark:text-neutral-500',
'relative h-5 w-5 flex justify-center items-center fill-current text-stone-200 dark:text-neutral-500 ltr:ml-2.5 rtl:mr-2.5',
suffixIcon:
'relative h-5 w-5 flex justify-center items-center fill-current text-stone-200 dark:text-neutral-500',
'relative h-5 w-5 flex justify-center items-center fill-current text-stone-200 dark:text-neutral-500 ltr:mr-2.5 rtl:ml-2.5',
}),
text: textInputClasses(classes.text),
password: textInputClasses(classes.password),
Expand Down
@@ -0,0 +1,21 @@
// Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/

import { axe } from 'vitest-axe'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import { visitView } from '#tests/support/components/visitView.ts'
import '#tests/graphql/builders/mocks.ts'

describe('testing admin password request a11y', () => {
beforeEach(() => {
mockApplicationConfig({
user_show_password_login: false,
auth_github: true,
})
})

it('has no accessibility violations', async () => {
const view = await visitView('/admin-password-auth')
const results = await axe(view.html())
expect(results).toHaveNoViolations()
})
})
Expand Up @@ -8,6 +8,8 @@ import '#tests/graphql/builders/mocks.ts'
describe('testing login a11y', () => {
beforeEach(() => {
mockApplicationConfig({
user_create_account: true,
user_show_password_login: true,
product_name: 'Zammad Test System',
})
})
Expand Down

0 comments on commit 015a19a

Please sign in to comment.