diff --git a/platform/api/src/api/v1/gql/mutations/auth.rs b/platform/api/src/api/v1/gql/mutations/auth.rs index e5e4c5e3c..50c8fc159 100644 --- a/platform/api/src/api/v1/gql/mutations/auth.rs +++ b/platform/api/src/api/v1/gql/mutations/auth.rs @@ -83,12 +83,12 @@ impl AuthMutation { .bind(user.id) .bind(!user.totp_enabled) .bind(expires_at) - .fetch_one(&mut *tx) + .fetch_one(tx.as_mut()) .await?; sqlx::query("UPDATE users SET last_login_at = NOW() WHERE id = $1") .bind(user.id) - .execute(&mut *tx) + .execute(tx.as_mut()) .await?; tx.commit().await?; diff --git a/platform/api/src/api/v1/gql/mutations/user.rs b/platform/api/src/api/v1/gql/mutations/user.rs index 3c030f157..39e53498b 100644 --- a/platform/api/src/api/v1/gql/mutations/user.rs +++ b/platform/api/src/api/v1/gql/mutations/user.rs @@ -3,6 +3,7 @@ use bytes::Bytes; use prost::Message; use crate::api::middleware::auth::AuthError; +use crate::api::v1::gql::validators::PasswordValidator; use crate::{ api::v1::gql::{ error::{GqlError, Result, ResultExt}, @@ -148,6 +149,59 @@ impl UserMutation { Ok(user.into()) } + async fn password<'ctx>( + &self, + ctx: &Context<'_>, + #[graphql(desc = "Current password")] current_password: String, + #[graphql(desc = "New password", validator(custom = "PasswordValidator"))] + new_password: String, + ) -> Result { + let global = ctx.get_global(); + let request_context = ctx.get_req_context(); + + let auth = request_context + .auth() + .await? + .ok_or(GqlError::Auth(AuthError::NotLoggedIn))?; + + let user = global + .user_by_id_loader + .load(auth.session.user_id.0) + .await + .map_err_gql("failed to fetch user")? + .map_err_gql(GqlError::NotFound("user"))?; + + if !user.verify_password(¤t_password) { + return Err(GqlError::InvalidInput { + fields: vec!["password"], + message: "wrong password", + } + .into()); + } + + let mut tx = global.db.begin().await?; + + let user: database::User = + sqlx::query_as("UPDATE users SET password_hash = $1 WHERE id = $2 RETURNING *") + .bind(database::User::hash_password(&new_password)) + .bind(user.id) + .fetch_one(tx.as_mut()) + .await?; + + // Delete all sessions except current + sqlx::query("DELETE FROM user_sessions WHERE user_id = $1 AND id != $2") + .bind(user.id) + .bind(auth.session.id) + .execute(tx.as_mut()) + .await?; + + // TODO: Logout active connections + + tx.commit().await?; + + Ok(user.into()) + } + /// Follow or unfollow a user. async fn follow<'ctx>( &self, diff --git a/platform/website/src/assets/styles/global.scss b/platform/website/src/assets/styles/global.scss index 2131ecc4d..8b3d03793 100644 --- a/platform/website/src/assets/styles/global.scss +++ b/platform/website/src/assets/styles/global.scss @@ -84,11 +84,8 @@ input[type="password"] { border-color: $primaryColor; } - &:not(:focus) { - &.invalid, - &:invalid { - border-color: $errorColor; - } + &:not(:focus).invalid { + border-color: $errorColor; } } diff --git a/platform/website/src/assets/styles/settings.scss b/platform/website/src/assets/styles/settings.scss index b70867bb8..324172f4e 100644 --- a/platform/website/src/assets/styles/settings.scss +++ b/platform/website/src/assets/styles/settings.scss @@ -1,18 +1,5 @@ @import "./variables.scss"; -.message { - font-size: 0.9rem; - font-weight: 500; - - &.error { - color: $errorColor; - } - - &.success { - color: $successColor; - } -} - .input { margin-top: 0.5rem; background-color: $bgColor2; @@ -52,7 +39,3 @@ gap: 0.5rem; } } - -input.input.invalid:not(:focus) { - border-color: $errorColor; -} diff --git a/platform/website/src/components/auth/auth-dialog.svelte b/platform/website/src/components/auth/auth-dialog.svelte index ae213bb47..a455a9b95 100644 --- a/platform/website/src/components/auth/auth-dialog.svelte +++ b/platform/website/src/components/auth/auth-dialog.svelte @@ -1,14 +1,16 @@ - - - -
- - -
- - -
- - {#if field.type === "password"} - - {/if} - - - {#if field.touched} - {#if field.status === "loading"} -
- -
- {:else if field.status === "success"} -
- -
- {:else if field.status === "error"} -
- -
- {:else if field.status === "warning"} -
- -
- {/if} - {/if} -
-
- - {field.touched && (field.message || "hidden")} -
- - diff --git a/platform/website/src/components/auth/solve-two-fa-dialog.svelte b/platform/website/src/components/auth/solve-two-fa-dialog.svelte index 7eb4b9646..fec0e3a6a 100644 --- a/platform/website/src/components/auth/solve-two-fa-dialog.svelte +++ b/platform/website/src/components/auth/solve-two-fa-dialog.svelte @@ -1,46 +1,31 @@ + + + +
+ {#if label} + + {/if} +
+ + +
+ + {#if status.type === FieldStatusType.Loading} +
+ +
+ {:else if status.type === FieldStatusType.Success} +
+ +
+ {:else if status.type === FieldStatusType.Error} +
+ +
+ {:else if status.type === FieldStatusType.Warning} +
+ +
+ {/if} +
+
+ + {status.message} +
+ + diff --git a/platform/website/src/components/form/password-field.svelte b/platform/website/src/components/form/password-field.svelte new file mode 100644 index 000000000..6a295f127 --- /dev/null +++ b/platform/website/src/components/form/password-field.svelte @@ -0,0 +1,38 @@ + + + + + + + diff --git a/platform/website/src/components/settings/account/change-password.svelte b/platform/website/src/components/settings/account/change-password.svelte new file mode 100644 index 000000000..2da249edb --- /dev/null +++ b/platform/website/src/components/settings/account/change-password.svelte @@ -0,0 +1,176 @@ + + + +

+ + Change Password +

+ +

+ Please confirm your current password before changing it. Forgot your password? +

+
+ + + + +
+ + +
+
+ + diff --git a/platform/website/src/components/settings/account/disable-2fa.svelte b/platform/website/src/components/settings/account/disable-2fa.svelte index c58171fd9..2628e08a1 100644 --- a/platform/website/src/components/settings/account/disable-2fa.svelte +++ b/platform/website/src/components/settings/account/disable-2fa.svelte @@ -6,12 +6,17 @@ import ShieldX from "$/components/icons/settings/shield-x.svelte"; import Dialog from "$/components/dialog.svelte"; import Spinner from "$/components/spinner.svelte"; + import { FieldStatusType, type FieldStatus } from "$/components/form/field.svelte"; + import PasswordField from "$/components/form/password-field.svelte"; + import { fieldsValid } from "$/lib/utils"; const dispatch = createEventDispatcher(); const client = getContextClient(); + let passwordStatus: FieldStatus; let password: string; - let wrongPassword = false; + + $: formValid = fieldsValid([passwordStatus]); let loading = false; @@ -23,7 +28,7 @@ } async function disableTotp() { - if (password) { + if (formValid) { loading = true; const res = await client .mutation( @@ -51,7 +56,7 @@ $user.totpEnabled = res.data.user.twoFa.resp.totpEnabled; close(); } else if (res.error && isWrongPassword(res.error)) { - wrongPassword = true; + passwordStatus = { type: FieldStatusType.Error, message: "Wrong password" }; } } } @@ -70,19 +75,13 @@

Please confirm your password before disabling 2-Factor-Authentication.

-

- - {#if wrongPassword} - Wrong password - {/if} -

+
@@ -90,7 +89,7 @@ class="button primary submit" type="submit" form="disable-2fa-form" - disabled={loading || !password} + disabled={loading || !formValid} > {#if loading} @@ -120,19 +119,6 @@ color: $textColorLight; } - .input { - width: 100%; - } - - .message { - font-size: 0.9rem; - font-weight: 500; - - &.error { - color: $errorColor; - } - } - .buttons { display: flex; align-items: center; diff --git a/platform/website/src/components/settings/account/enable-2fa.svelte b/platform/website/src/components/settings/account/enable-2fa.svelte index 00380f675..4a843f457 100644 --- a/platform/website/src/components/settings/account/enable-2fa.svelte +++ b/platform/website/src/components/settings/account/enable-2fa.svelte @@ -1,5 +1,6 @@ @@ -138,21 +153,15 @@ Please scan the QR code with your authenticator app and submit the code. - - {#if invalidCode} - Invalid code - {/if}
{:else if state.step === 3} @@ -210,27 +219,16 @@ color: $textColorLight; } - .input { - width: 100%; - } - - .message { - font-size: 0.9rem; - font-weight: 500; - - &.error { - color: $errorColor; - } - } - .step-2 { margin: 1rem 0; display: flex; gap: 2rem; - .input { - margin-top: 1rem; + & > div { + display: flex; + flex-direction: column; + gap: 1rem; } } diff --git a/platform/website/src/lib/utils.ts b/platform/website/src/lib/utils.ts index 1026f5367..6e8357c15 100644 --- a/platform/website/src/lib/utils.ts +++ b/platform/website/src/lib/utils.ts @@ -1,3 +1,33 @@ +import { z } from "zod"; +import { FieldStatusType, type FieldStatus } from "$/components/form/field.svelte"; + +export function fieldsValid(status: (FieldStatus | undefined)[]) { + for (let s of status) { + if (!s || s.type !== FieldStatusType.Success) { + return false; + } + } + return true; +} + +export async function passwordValidate(v: string): Promise { + const valid = z + .string() + .min(8, "At least 8 characters") + .max(100, "Maximum of 100 characters") + .regex(/.*[A-Z].*/, "At least one uppercase character") + .regex(/.*[a-z].*/, "At least one lowercase character") + .regex(/.*\d.*/, "At least one number") + .regex(/.*[`~<>?,./!@#$%^&*()\-_+="'|{}[\];:].*/, "At least one special character") + .safeParse(v); + + if (!valid.success) { + return { type: FieldStatusType.Error, message: valid.error.issues[0].message }; + } + + return { type: FieldStatusType.Success }; +} + export function viewersToString(viewers: number, labelled: boolean = false) { const count = formatBigNumber(viewers); diff --git a/platform/website/src/routes/settings/account/+page.svelte b/platform/website/src/routes/settings/account/+page.svelte index d966c8b3d..de4b38d92 100644 --- a/platform/website/src/routes/settings/account/+page.svelte +++ b/platform/website/src/routes/settings/account/+page.svelte @@ -12,6 +12,8 @@ import { z } from "zod"; import Enable2fa from "$/components/settings/account/enable-2fa.svelte"; import Disable2fa from "$/components/settings/account/disable-2fa.svelte"; + import ChangePassword from "$/components/settings/account/change-password.svelte"; + import Field from "$/components/form/field.svelte"; //TODO: Improve detail texts @@ -72,6 +74,7 @@ enum Dialog { None, + ChangePassword, Enable2Fa, Disable2Fa, } @@ -91,20 +94,20 @@ on:reset={() => (email = $user?.email)} showReset={emailChanged} > -
- +
{#if !emailValid} Invalid email address @@ -115,7 +118,10 @@ {/if}
- @@ -148,7 +154,9 @@
- {#if showDialog === Dialog.Enable2Fa} + {#if showDialog === Dialog.ChangePassword} + + {:else if showDialog === Dialog.Enable2Fa} {:else if showDialog === Dialog.Disable2Fa} @@ -158,20 +166,29 @@ diff --git a/schema.graphql b/schema.graphql index 5842b2e6c..97aba37fa 100644 --- a/schema.graphql +++ b/schema.graphql @@ -351,6 +351,16 @@ type UserMutation { """ follow: Boolean! ): Boolean! + password( + """ + Current password + """ + currentPassword: String! + """ + New password + """ + newPassword: String! + ): User! twoFa: TwoFaMutation! }