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 1adb1ab073
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` export const CURRENT_USER_CLIENT = gql`
query { query {
currentUser @client { currentUser @client {

View file

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

View file

@ -1,5 +1,10 @@
<template> <template>
<section class="container"> <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 v-if="currentActor">
<div class="header"> <div class="header">
<figure v-if="currentActor.banner" class="image is-3by1"> <figure v-if="currentActor.banner" class="image is-3by1">
@ -10,6 +15,9 @@
<div class="columns"> <div class="columns">
<div class="identities column is-4"> <div class="identities column is-4">
<identities v-bind:currentIdentityName="currentIdentityName"></identities> <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>
<div class="column is-8"> <div class="column is-8">
<router-view></router-view> <router-view></router-view>
@ -27,6 +35,10 @@
.identities { .identities {
padding-right: 45px; padding-right: 45px;
margin-right: 45px; margin-right: 45px;
.buttons {
margin-top: 1.2rem;
}
} }
</style> </style>
@ -36,6 +48,8 @@ import { Component, Vue, Watch } from 'vue-property-decorator';
import EventCard from '@/components/Event/EventCard.vue'; import EventCard from '@/components/Event/EventCard.vue';
import { IPerson } from '@/types/actor'; import { IPerson } from '@/types/actor';
import Identities from '@/components/Account/Identities.vue'; import Identities from '@/components/Account/Identities.vue';
import { UserRouteName } from '@/router/user';
import { MyAccountRouteName } from '@/router/actor';
@Component({ @Component({
components: { components: {
@ -52,6 +66,9 @@ export default class MyAccount extends Vue {
currentActor!: IPerson; currentActor!: IPerson;
currentIdentityName: string | null = null; currentIdentityName: string | null = null;
UserRouteName = UserRouteName;
MyAccountRouteName = MyAccountRouteName;
@Watch('$route.params.identityName', { immediate: true }) @Watch('$route.params.identityName', { immediate: true })
async onIdentityParamChanged (val: string) { async onIdentityParamChanged (val: string) {
await this.redirectIfNoIdentitySelected(val); 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] @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 @confirmation_token_length 30
@ -107,8 +109,22 @@ defmodule Mobilizon.Users.User do
@doc false @doc false
@spec password_reset_changeset(t, map) :: Ecto.Changeset.t() @spec password_reset_changeset(t, map) :: Ecto.Changeset.t()
def password_reset_changeset(%__MODULE__{} = user, attrs) do 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 user
|> cast(attrs, @password_reset_required_attrs) |> cast(attrs, required_attrs)
|> validate_length(:password, |> validate_length(:password,
min: 6, min: 6,
max: 100, max: 100,

View file

@ -7,6 +7,7 @@ defmodule MobilizonWeb.Resolvers.User do
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Service.Users.{ResetPassword, Activation} alias Mobilizon.Service.Users.{ResetPassword, Activation}
alias Mobilizon.Users.User alias Mobilizon.Users.User
alias Mobilizon.Storage.Repo
import Mobilizon.Users.Guards import Mobilizon.Users.Guards
@ -238,4 +239,34 @@ defmodule MobilizonWeb.Resolvers.User do
{:ok, participations} {:ok, participations}
end end
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 end

View file

@ -159,5 +159,12 @@ defmodule MobilizonWeb.Schema.UserType do
arg(:preferred_username, non_null(:string)) arg(:preferred_username, non_null(:string))
resolve(&User.change_default_actor/3) resolve(&User.change_default_actor/3)
end 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
end end

View file

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

View file

@ -833,4 +833,193 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do
] == actor2.preferred_username ] == actor2.preferred_username
end end
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 end