Merge branch 'feature/edit-password' into 'master'

Feature/edit password

Closes #165

See merge request framasoft/mobilizon!197
This commit is contained in:
Thomas Citharel 2019-09-24 19:01:11 +02:00
commit 954e98ed8a
9 changed files with 380 additions and 7 deletions

View File

@ -25,6 +25,14 @@ mutation ValidateUser($token: String!) {
}
`;
export const CHANGE_PASSWORD = gql`
mutation ChangePassword($oldPassword: String!, $newPassword: String!) {
changePassword(oldPassword: $oldPassword, newPassword: $newPassword) {
id
}
}
`;
export const CURRENT_USER_CLIENT = gql`
query {
currentUser @client {

View File

@ -7,6 +7,7 @@ import SendPasswordReset from '@/views/User/SendPasswordReset.vue';
import PasswordReset from '@/views/User/PasswordReset.vue';
import { beforeRegisterGuard } from '@/router/guards/register-guard';
import { RouteConfig } from 'vue-router';
import PasswordChange from '@/views/User/PasswordChange.vue';
export enum UserRouteName {
REGISTER = 'Register',
@ -16,6 +17,7 @@ export enum UserRouteName {
PASSWORD_RESET = 'PasswordReset',
VALIDATE = 'Validate',
LOGIN = 'Login',
PASSWORD_CHANGE = 'PasswordChange',
}
export const userRoutes: RouteConfig[] = [
@ -70,4 +72,10 @@ export const userRoutes: RouteConfig[] = [
props: true,
meta: { requiredAuth: false },
},
{
path: '/my-account/password',
name: UserRouteName.PASSWORD_CHANGE,
component: PasswordChange,
meta: { requiredAuth: true },
},
];

View File

@ -1,5 +1,10 @@
<template>
<section class="container">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li class="is-active"><router-link :to="{ name: MyAccountRouteName.UPDATE_IDENTITY }" aria-current="page">{{ $t('My account') }}</router-link></li>
</ul>
</nav>
<div v-if="currentActor">
<div class="header">
<figure v-if="currentActor.banner" class="image is-3by1">
@ -10,6 +15,9 @@
<div class="columns">
<div class="identities column is-4">
<identities v-bind:currentIdentityName="currentIdentityName"></identities>
<div class="buttons">
<b-button tag="router-link" type="is-secondary" :to="{ name: UserRouteName.PASSWORD_CHANGE }">{{ $t('Change password') }}</b-button>
</div>
</div>
<div class="column is-8">
<router-view></router-view>
@ -27,6 +35,10 @@
.identities {
padding-right: 45px;
margin-right: 45px;
.buttons {
margin-top: 1.2rem;
}
}
</style>
@ -36,6 +48,8 @@ import { Component, Vue, Watch } from 'vue-property-decorator';
import EventCard from '@/components/Event/EventCard.vue';
import { IPerson } from '@/types/actor';
import Identities from '@/components/Account/Identities.vue';
import { UserRouteName } from '@/router/user';
import { MyAccountRouteName } from '@/router/actor';
@Component({
components: {
@ -52,6 +66,9 @@ export default class MyAccount extends Vue {
currentActor!: IPerson;
currentIdentityName: string | null = null;
UserRouteName = UserRouteName;
MyAccountRouteName = MyAccountRouteName;
@Watch('$route.params.identityName', { immediate: true })
async onIdentityParamChanged (val: string) {
await this.redirectIfNoIdentitySelected(val);

View File

@ -0,0 +1,94 @@
<template>
<section class="section">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><router-link :to="{ name: MyAccountRouteName.UPDATE_IDENTITY }">{{ $t('My account') }}</router-link></li>
<li class="is-active"><router-link :to="{ name: UserRouteName.PASSWORD_CHANGE }" aria-current="page">{{ $t('Password change') }}</router-link></li>
</ul>
</nav>
<h1 class="title">{{ $t('Password') }}</h1>
<b-notification
type="is-danger"
has-icon
aria-close-label="Close notification"
role="alert"
:key="error"
v-for="error in errors"
>
{{ error }}
</b-notification>
<form @submit="resetAction" class="form">
<b-field :label="$t('Old password')">
<b-input
aria-required="true"
required
type="password"
password-reveal
minlength="6"
v-model="oldPassword"
/>
</b-field>
<b-field :label="$t('New password')">
<b-input
aria-required="true"
required
type="password"
password-reveal
minlength="6"
v-model="newPassword"
/>
</b-field>
<button class="button is-primary">
{{ $t('Change my password') }}
</button>
</form>
</section>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { CHANGE_PASSWORD } from '@/graphql/user';
import { UserRouteName } from '@/router/user';
import { MyAccountRouteName } from '@/router/actor';
@Component
export default class PasswordChange extends Vue {
oldPassword: string = '';
newPassword: string = '';
errors: string[] = [];
MyAccountRouteName = MyAccountRouteName;
UserRouteName = UserRouteName;
async resetAction(e) {
e.preventDefault();
this.errors = [];
try {
await this.$apollo.mutate({
mutation: CHANGE_PASSWORD,
variables: {
oldPassword: this.oldPassword,
newPassword: this.newPassword,
},
});
this.$notifier.success(this.$t('The password was successfully changed') as string);
} catch (err) {
this.handleError(err);
}
}
private handleError(err: any) {
console.error(err);
if (err.graphQLErrors !== undefined) {
err.graphQLErrors.forEach(({ message }) => {
this.errors.push(message);
});
}
}
}
</script>
<style lang="scss">
</style>

View File

@ -43,7 +43,9 @@ defmodule Mobilizon.Users.User do
@registration_required_attrs [:email, :password]
@password_reset_required_attrs [:password, :reset_password_token, :reset_password_sent_at]
@password_change_required_attrs [:password]
@password_reset_required_attrs @password_change_required_attrs ++
[:reset_password_token, :reset_password_sent_at]
@confirmation_token_length 30
@ -107,8 +109,22 @@ defmodule Mobilizon.Users.User do
@doc false
@spec password_reset_changeset(t, map) :: Ecto.Changeset.t()
def password_reset_changeset(%__MODULE__{} = user, attrs) do
password_change_changeset(user, attrs, @password_reset_required_attrs)
end
@doc """
Changeset to change a password
It checks the minimum requirements for a password and hashes it.
"""
@spec password_change_changeset(t, map) :: Ecto.Changeset.t()
def password_change_changeset(
%__MODULE__{} = user,
attrs,
required_attrs \\ @password_change_required_attrs
) do
user
|> cast(attrs, @password_reset_required_attrs)
|> cast(attrs, required_attrs)
|> validate_length(:password,
min: 6,
max: 100,

View File

@ -7,6 +7,7 @@ defmodule MobilizonWeb.Resolvers.User do
alias Mobilizon.Actors.Actor
alias Mobilizon.Service.Users.{ResetPassword, Activation}
alias Mobilizon.Users.User
alias Mobilizon.Storage.Repo
import Mobilizon.Users.Guards
@ -238,4 +239,34 @@ defmodule MobilizonWeb.Resolvers.User do
{:ok, participations}
end
end
def change_password(_parent, %{old_password: old_password, new_password: new_password}, %{
context: %{current_user: %User{password_hash: old_password_hash} = user}
}) do
with {:current_password, true} <-
{:current_password, Argon2.verify_pass(old_password, old_password_hash)},
{:same_password, false} <- {:same_password, old_password == new_password},
{:ok, %User{} = user} <-
user
|> User.password_change_changeset(%{
"password" => new_password
})
|> Repo.update() do
{:ok, user}
else
{:current_password, false} ->
{:error, "The current password is invalid"}
{:same_password, true} ->
{:error, "The new password must be different"}
{:error, %Ecto.Changeset{errors: [password: {"registration.error.password_too_short", _}]}} ->
{:error,
"The password you have chosen is too short. Please make sure your password contains at least 6 characters."}
end
end
def change_password(_parent, _args, _resolution) do
{:error, "You need to be logged-in to change your password"}
end
end

View File

@ -159,5 +159,12 @@ defmodule MobilizonWeb.Schema.UserType do
arg(:preferred_username, non_null(:string))
resolve(&User.change_default_actor/3)
end
@desc "Change an user password"
field :change_password, :user do
arg(:old_password, non_null(:string))
arg(:new_password, non_null(:string))
resolve(&User.change_password/3)
end
end
end

View File

@ -1,5 +1,5 @@
# source: http://localhost:4000/api
# timestamp: Fri Sep 20 2019 16:55:10 GMT+0200 (GMT+02:00)
# timestamp: Tue Sep 24 2019 18:20:05 GMT+0200 (GMT+02:00)
schema {
query: RootQueryType
@ -184,7 +184,7 @@ enum CommentVisibility {
"""Visible only to people members of the group or followers of the person"""
PRIVATE
"""Publically listed and federated. Can be shared."""
"""Publicly listed and federated. Can be shared."""
PUBLIC
"""Visible only to people with the link - or invited"""
@ -885,6 +885,9 @@ type RootMutationType {
"""Change default actor for user"""
changeDefaultActor(preferredUsername: String!): User
"""Change an user password"""
changePassword(newPassword: String!, oldPassword: String!): User
"""Create a comment"""
createComment(actorUsername: String!, text: String!): Comment
@ -911,7 +914,7 @@ type RootMutationType {
"""The list of tags associated to the event"""
tags: [String] = [""]
title: String!
visibility: EventVisibility = PRIVATE
visibility: EventVisibility = PUBLIC
): Event
"""Create a Feed Token"""
@ -1044,7 +1047,7 @@ type RootMutationType {
description: String
endsOn: DateTime
eventId: ID!
joinOptions: EventJoinOptions
joinOptions: EventJoinOptions = FREE
onlineAddress: String
options: EventOptionsInput
phoneAddress: String
@ -1059,7 +1062,7 @@ type RootMutationType {
"""The list of tags associated to the event"""
tags: [String]
title: String
visibility: EventVisibility
visibility: EventVisibility = PUBLIC
): Event
"""Update an identity"""

View File

@ -833,4 +833,193 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do
] == actor2.preferred_username
end
end
describe "Resolver: Change password for an user" do
@email "toto@tata.tld"
@old_password "p4ssw0rd"
@new_password "upd4t3d"
test "change_password/3 with valid password", %{conn: conn} do
{:ok, %User{} = user} = Users.register(%{email: @email, password: @old_password})
# Hammer time !
{:ok, %User{} = _user} =
Users.update_user(user, %{
"confirmed_at" => Timex.shift(user.confirmation_sent_at, hours: -3),
"confirmation_sent_at" => nil,
"confirmation_token" => nil
})
mutation = """
mutation {
login(
email: "#{@email}",
password: "#{@old_password}",
) {
accessToken,
refreshToken,
user {
id
}
}
}
"""
res =
conn
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert login = json_response(res, 200)["data"]["login"]
assert Map.has_key?(login, "accessToken") && not is_nil(login["accessToken"])
mutation = """
mutation {
changePassword(old_password: "#{@old_password}", new_password: "#{@new_password}") {
id
}
}
"""
res =
conn
|> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["changePassword"]["id"] == to_string(user.id)
mutation = """
mutation {
login(
email: "#{@email}",
password: "#{@new_password}",
) {
accessToken,
refreshToken,
user {
id
}
}
}
"""
res =
conn
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert login = json_response(res, 200)["data"]["login"]
assert Map.has_key?(login, "accessToken") && not is_nil(login["accessToken"])
end
test "change_password/3 with invalid password", %{conn: conn} do
{:ok, %User{} = user} = Users.register(%{email: @email, password: @old_password})
# Hammer time !
{:ok, %User{} = _user} =
Users.update_user(user, %{
"confirmed_at" => Timex.shift(user.confirmation_sent_at, hours: -3),
"confirmation_sent_at" => nil,
"confirmation_token" => nil
})
mutation = """
mutation {
changePassword(old_password: "invalid password", new_password: "#{@new_password}") {
id
}
}
"""
res =
conn
|> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert hd(json_response(res, 200)["errors"])["message"] == "The current password is invalid"
end
test "change_password/3 with same password", %{conn: conn} do
{:ok, %User{} = user} = Users.register(%{email: @email, password: @old_password})
# Hammer time !
{:ok, %User{} = _user} =
Users.update_user(user, %{
"confirmed_at" => Timex.shift(user.confirmation_sent_at, hours: -3),
"confirmation_sent_at" => nil,
"confirmation_token" => nil
})
mutation = """
mutation {
changePassword(old_password: "#{@old_password}", new_password: "#{@old_password}") {
id
}
}
"""
res =
conn
|> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert hd(json_response(res, 200)["errors"])["message"] ==
"The new password must be different"
end
test "change_password/3 with new password too short", %{conn: conn} do
{:ok, %User{} = user} = Users.register(%{email: @email, password: @old_password})
# Hammer time !
{:ok, %User{} = _user} =
Users.update_user(user, %{
"confirmed_at" => Timex.shift(user.confirmation_sent_at, hours: -3),
"confirmation_sent_at" => nil,
"confirmation_token" => nil
})
mutation = """
mutation {
changePassword(old_password: "#{@old_password}", new_password: "new") {
id
}
}
"""
res =
conn
|> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert hd(json_response(res, 200)["errors"])["message"] ==
"The password you have chosen is too short. Please make sure your password contains at least 6 characters."
end
test "change_password/3 without being authenticated", %{conn: conn} do
{:ok, %User{} = user} = Users.register(%{email: @email, password: @old_password})
# Hammer time !
{:ok, %User{} = _user} =
Users.update_user(user, %{
"confirmed_at" => Timex.shift(user.confirmation_sent_at, hours: -3),
"confirmation_sent_at" => nil,
"confirmation_token" => nil
})
mutation = """
mutation {
changePassword(old_password: "#{@old_password}", new_password: "#{@new_password}") {
id
}
}
"""
res =
conn
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert hd(json_response(res, 200)["errors"])["message"] ==
"You need to be logged-in to change your password"
end
end
end