From 96c1f77c42326e8e3d3fba35562cf0d84a8eff7c Mon Sep 17 00:00:00 2001 From: InfiniteStash <117855276+InfiniteStash@users.noreply.github.com> Date: Mon, 18 Nov 2024 23:36:48 +0100 Subject: [PATCH] Add user email changing, and refactor activation token system (#831) --- frontend/src/constants/route.ts | 6 +- .../graphql/mutations/ConfirmChangeEmail.gql | 3 + .../graphql/mutations/RequestChangeEmail.gql | 3 + .../graphql/mutations/ValidateChangeEmail.gql | 3 + frontend/src/graphql/mutations/index.ts | 26 + frontend/src/graphql/types.ts | 199 ++++++- frontend/src/hooks/useQueryParams.ts | 10 +- .../src/pages/activateUser/ActivateUser.tsx | 20 +- frontend/src/pages/registerUser/Register.tsx | 2 + .../src/pages/resetPassword/ResetPassword.tsx | 81 ++- frontend/src/pages/users/User.tsx | 37 ++ .../pages/users/UserConfirmChangeEmail.tsx | 74 +++ .../pages/users/UserValidateChangeEmail.tsx | 121 +++++ frontend/src/pages/users/index.tsx | 10 + go.mod | 17 +- go.sum | 54 +- graphql/schema/schema.graphql | 7 +- graphql/schema/types/user.graphql | 24 +- pkg/api/resolver_mutation_user.go | 91 +++- pkg/database/database.go | 2 +- .../migrations/postgres/37_tokens.up.sql | 9 + pkg/email/email.go | 41 +- pkg/manager/cron/cron.go | 32 ++ pkg/models/activation.go | 21 +- pkg/models/factory.go | 2 +- pkg/models/generated_exec.go | 430 ++++++++++++++- pkg/models/generated_models.go | 72 ++- pkg/models/model_pending_activation.go | 36 -- pkg/models/model_user_tokens.go | 81 +++ pkg/models/translate.go | 90 +-- pkg/sqlx/factory.go | 4 +- pkg/sqlx/querybuilder_invite_key.go | 7 +- pkg/sqlx/querybuilder_pending_activation.go | 92 ---- pkg/sqlx/querybuilder_user_token.go | 73 +++ pkg/user/activation.go | 212 +++----- pkg/user/email.go | 205 +++++++ pkg/user/templates/email.html | 514 ++++++++++++++++++ pkg/user/templates/email.txt | 9 + pkg/user/user.go | 11 +- pkg/utils/json.go | 23 + 40 files changed, 2293 insertions(+), 461 deletions(-) create mode 100644 frontend/src/graphql/mutations/ConfirmChangeEmail.gql create mode 100644 frontend/src/graphql/mutations/RequestChangeEmail.gql create mode 100644 frontend/src/graphql/mutations/ValidateChangeEmail.gql create mode 100644 frontend/src/pages/users/UserConfirmChangeEmail.tsx create mode 100644 frontend/src/pages/users/UserValidateChangeEmail.tsx create mode 100644 pkg/database/migrations/postgres/37_tokens.up.sql delete mode 100644 pkg/models/model_pending_activation.go create mode 100644 pkg/models/model_user_tokens.go delete mode 100644 pkg/sqlx/querybuilder_pending_activation.go create mode 100644 pkg/sqlx/querybuilder_user_token.go create mode 100644 pkg/user/email.go create mode 100644 pkg/user/templates/email.html create mode 100644 pkg/user/templates/email.txt create mode 100644 pkg/utils/json.go diff --git a/frontend/src/constants/route.ts b/frontend/src/constants/route.ts index cd36ebf23..52b37cc53 100644 --- a/frontend/src/constants/route.ts +++ b/frontend/src/constants/route.ts @@ -38,8 +38,10 @@ export const ROUTE_EDIT = "/edits/:id"; export const ROUTE_EDIT_UPDATE = "/edits/:id/update"; export const ROUTE_REGISTER = "/register"; export const ROUTE_ACTIVATE = "/activate"; -export const ROUTE_FORGOT_PASSWORD = "/forgotPassword"; -export const ROUTE_RESET_PASSWORD = "/resetPassword"; +export const ROUTE_FORGOT_PASSWORD = "/forgot-password"; +export const ROUTE_RESET_PASSWORD = "/reset-password"; +export const ROUTE_CONFIRM_EMAIL = "/users/confirm-email"; +export const ROUTE_CHANGE_EMAIL = "/users/change-email"; export const ROUTE_SEARCH = "/search/:term"; export const ROUTE_SEARCH_INDEX = "/search/"; export const ROUTE_VERSION = "/version"; diff --git a/frontend/src/graphql/mutations/ConfirmChangeEmail.gql b/frontend/src/graphql/mutations/ConfirmChangeEmail.gql new file mode 100644 index 000000000..ab697a224 --- /dev/null +++ b/frontend/src/graphql/mutations/ConfirmChangeEmail.gql @@ -0,0 +1,3 @@ +mutation ConfirmChangeEmail($token: ID!) { + confirmChangeEmail(token: $token) +} diff --git a/frontend/src/graphql/mutations/RequestChangeEmail.gql b/frontend/src/graphql/mutations/RequestChangeEmail.gql new file mode 100644 index 000000000..30c30272e --- /dev/null +++ b/frontend/src/graphql/mutations/RequestChangeEmail.gql @@ -0,0 +1,3 @@ +mutation RequestChangeEmail { + requestChangeEmail +} diff --git a/frontend/src/graphql/mutations/ValidateChangeEmail.gql b/frontend/src/graphql/mutations/ValidateChangeEmail.gql new file mode 100644 index 000000000..b61226581 --- /dev/null +++ b/frontend/src/graphql/mutations/ValidateChangeEmail.gql @@ -0,0 +1,3 @@ +mutation ValidateChangeEmail($token: ID!, $email: String!) { + validateChangeEmail(token: $token, email: $email) +} diff --git a/frontend/src/graphql/mutations/index.ts b/frontend/src/graphql/mutations/index.ts index bd4f77833..862e5e38b 100644 --- a/frontend/src/graphql/mutations/index.ts +++ b/frontend/src/graphql/mutations/index.ts @@ -82,6 +82,11 @@ import { DeleteDraftMutationVariables, UnmatchFingerprintMutation, UnmatchFingerprintMutationVariables, + ValidateChangeEmailMutation, + ValidateChangeEmailMutationVariables, + ConfirmChangeEmailMutation, + ConfirmChangeEmailMutationVariables, + RequestChangeEmailMutation, } from "../types"; import ActivateUserGQL from "./ActivateNewUser.gql"; @@ -125,6 +130,9 @@ import FavoriteStudioGQL from "./FavoriteStudio.gql"; import FavoritePerformerGQL from "./FavoritePerformer.gql"; import DeleteDraftGQL from "./DeleteDraft.gql"; import UnmatchFingerprintGQL from "./UnmatchFingerprint.gql"; +import ValidateChangeEmailGQL from "./ValidateChangeEmail.gql"; +import ConfirmChangeEmailGQL from "./ConfirmChangeEmail.gql"; +import RequestChangeEmailGQL from "./RequestChangeEmail.gql"; export const useActivateUser = ( options?: MutationHookOptions< @@ -383,3 +391,21 @@ export const useUnmatchFingerprint = ( }, ...options, }); + +export const useValidateChangeEmail = ( + options?: MutationHookOptions< + ValidateChangeEmailMutation, + ValidateChangeEmailMutationVariables + > +) => useMutation(ValidateChangeEmailGQL, options); + +export const useConfirmChangeEmail = ( + options?: MutationHookOptions< + ConfirmChangeEmailMutation, + ConfirmChangeEmailMutationVariables + > +) => useMutation(ConfirmChangeEmailGQL, options); + +export const useRequestChangeEmail = ( + options?: MutationHookOptions +) => useMutation(RequestChangeEmailGQL, options); diff --git a/frontend/src/graphql/types.ts b/frontend/src/graphql/types.ts index f3b57789b..518300c7d 100644 --- a/frontend/src/graphql/types.ts +++ b/frontend/src/graphql/types.ts @@ -24,8 +24,7 @@ export type Scalars = { }; export type ActivateNewUserInput = { - activation_key: Scalars["String"]; - email: Scalars["String"]; + activation_key: Scalars["ID"]; name: Scalars["String"]; password: Scalars["String"]; }; @@ -444,6 +443,7 @@ export type Mutation = { cancelEdit: Edit; /** Changes the password for the current user */ changePassword: Scalars["Boolean"]; + confirmChangeEmail: UserChangeEmailStatus; destroyDraft: Scalars["Boolean"]; /** Comment on an edit */ editComment: Edit; @@ -462,7 +462,7 @@ export type Mutation = { imageCreate?: Maybe; imageDestroy: Scalars["Boolean"]; /** User interface for registering */ - newUser?: Maybe; + newUser?: Maybe; performerCreate?: Maybe; performerDestroy: Scalars["Boolean"]; /** Propose a new performer or modification to a performer */ @@ -472,6 +472,8 @@ export type Mutation = { performerUpdate?: Maybe; /** Regenerates the api key for the given user, or the current user if id not provided */ regenerateAPIKey: Scalars["String"]; + /** Request an email change for the current user */ + requestChangeEmail: UserChangeEmailStatus; /** Removes a pending invite code - refunding the token */ rescindInviteCode: Scalars["Boolean"]; /** Generates an email to reset a user password */ @@ -513,6 +515,7 @@ export type Mutation = { userCreate?: Maybe; userDestroy: Scalars["Boolean"]; userUpdate?: Maybe; + validateChangeEmail: UserChangeEmailStatus; }; export type MutationActivateNewUserArgs = { @@ -531,6 +534,10 @@ export type MutationChangePasswordArgs = { input: UserChangePasswordInput; }; +export type MutationConfirmChangeEmailArgs = { + token: Scalars["ID"]; +}; + export type MutationDestroyDraftArgs = { id: Scalars["ID"]; }; @@ -721,9 +728,14 @@ export type MutationUserUpdateArgs = { input: UserUpdateInput; }; +export type MutationValidateChangeEmailArgs = { + email: Scalars["String"]; + token: Scalars["ID"]; +}; + export type NewUserInput = { email: Scalars["String"]; - invite_key?: InputMaybe; + invite_key?: InputMaybe; }; export enum OperationEnum { @@ -1779,11 +1791,26 @@ export type User = { vote_count: UserVoteCount; }; +export type UserChangeEmailInput = { + existing_email_token?: InputMaybe; + new_email?: InputMaybe; + new_email_token?: InputMaybe; +}; + +export enum UserChangeEmailStatus { + CONFIRM_NEW = "CONFIRM_NEW", + CONFIRM_OLD = "CONFIRM_OLD", + ERROR = "ERROR", + EXPIRED = "EXPIRED", + INVALID_TOKEN = "INVALID_TOKEN", + SUCCESS = "SUCCESS", +} + export type UserChangePasswordInput = { /** Password in plain text */ existing_password?: InputMaybe; new_password: Scalars["String"]; - reset_key?: InputMaybe; + reset_key?: InputMaybe; }; export type UserCreateInput = { @@ -4309,6 +4336,15 @@ export type ChangePasswordMutation = { changePassword: boolean; }; +export type ConfirmChangeEmailMutationVariables = Exact<{ + token: Scalars["ID"]; +}>; + +export type ConfirmChangeEmailMutation = { + __typename: "Mutation"; + confirmChangeEmail: UserChangeEmailStatus; +}; + export type DeleteDraftMutationVariables = Exact<{ id: Scalars["ID"]; }>; @@ -6495,6 +6531,15 @@ export type RegenerateApiKeyMutation = { regenerateAPIKey: string; }; +export type RequestChangeEmailMutationVariables = Exact<{ + [key: string]: never; +}>; + +export type RequestChangeEmailMutation = { + __typename: "Mutation"; + requestChangeEmail: UserChangeEmailStatus; +}; + export type RescindInviteCodeMutationVariables = Exact<{ code: Scalars["ID"]; }>; @@ -12827,6 +12872,16 @@ export type UpdateUserMutation = { } | null; }; +export type ValidateChangeEmailMutationVariables = Exact<{ + token: Scalars["ID"]; + email: Scalars["String"]; +}>; + +export type ValidateChangeEmailMutation = { + __typename: "Mutation"; + validateChangeEmail: UserChangeEmailStatus; +}; + export type VoteMutationVariables = Exact<{ input: EditVoteInput; }>; @@ -23279,6 +23334,51 @@ export const ChangePasswordDocument = { ChangePasswordMutation, ChangePasswordMutationVariables >; +export const ConfirmChangeEmailDocument = { + kind: "Document", + definitions: [ + { + kind: "OperationDefinition", + operation: "mutation", + name: { kind: "Name", value: "ConfirmChangeEmail" }, + variableDefinitions: [ + { + kind: "VariableDefinition", + variable: { + kind: "Variable", + name: { kind: "Name", value: "token" }, + }, + type: { + kind: "NonNullType", + type: { kind: "NamedType", name: { kind: "Name", value: "ID" } }, + }, + }, + ], + selectionSet: { + kind: "SelectionSet", + selections: [ + { + kind: "Field", + name: { kind: "Name", value: "confirmChangeEmail" }, + arguments: [ + { + kind: "Argument", + name: { kind: "Name", value: "token" }, + value: { + kind: "Variable", + name: { kind: "Name", value: "token" }, + }, + }, + ], + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + ConfirmChangeEmailMutation, + ConfirmChangeEmailMutationVariables +>; export const DeleteDraftDocument = { kind: "Document", definitions: [ @@ -27145,6 +27245,28 @@ export const RegenerateApiKeyDocument = { RegenerateApiKeyMutation, RegenerateApiKeyMutationVariables >; +export const RequestChangeEmailDocument = { + kind: "Document", + definitions: [ + { + kind: "OperationDefinition", + operation: "mutation", + name: { kind: "Name", value: "RequestChangeEmail" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { + kind: "Field", + name: { kind: "Name", value: "requestChangeEmail" }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + RequestChangeEmailMutation, + RequestChangeEmailMutationVariables +>; export const RescindInviteCodeDocument = { kind: "Document", definitions: [ @@ -37483,6 +37605,73 @@ export const UpdateUserDocument = { }, ], } as unknown as DocumentNode; +export const ValidateChangeEmailDocument = { + kind: "Document", + definitions: [ + { + kind: "OperationDefinition", + operation: "mutation", + name: { kind: "Name", value: "ValidateChangeEmail" }, + variableDefinitions: [ + { + kind: "VariableDefinition", + variable: { + kind: "Variable", + name: { kind: "Name", value: "token" }, + }, + type: { + kind: "NonNullType", + type: { kind: "NamedType", name: { kind: "Name", value: "ID" } }, + }, + }, + { + kind: "VariableDefinition", + variable: { + kind: "Variable", + name: { kind: "Name", value: "email" }, + }, + type: { + kind: "NonNullType", + type: { + kind: "NamedType", + name: { kind: "Name", value: "String" }, + }, + }, + }, + ], + selectionSet: { + kind: "SelectionSet", + selections: [ + { + kind: "Field", + name: { kind: "Name", value: "validateChangeEmail" }, + arguments: [ + { + kind: "Argument", + name: { kind: "Name", value: "token" }, + value: { + kind: "Variable", + name: { kind: "Name", value: "token" }, + }, + }, + { + kind: "Argument", + name: { kind: "Name", value: "email" }, + value: { + kind: "Variable", + name: { kind: "Name", value: "email" }, + }, + }, + ], + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + ValidateChangeEmailMutation, + ValidateChangeEmailMutationVariables +>; export const VoteDocument = { kind: "Document", definitions: [ diff --git a/frontend/src/hooks/useQueryParams.ts b/frontend/src/hooks/useQueryParams.ts index 711c6ebfa..ef197b809 100644 --- a/frontend/src/hooks/useQueryParams.ts +++ b/frontend/src/hooks/useQueryParams.ts @@ -22,11 +22,16 @@ interface NumberArrayParamConfig extends ParamBase { type: "number[]"; default?: number[]; } +interface BooleanParamConfig extends ParamBase { + type: "boolean"; + default?: boolean; +} type ParamConfig = | StringParamConfig | StringArrayParamConfig | NumberParamConfig - | NumberArrayParamConfig; + | NumberArrayParamConfig + | BooleanParamConfig; type QueryParamConfig = Record; export type QueryParams = { @@ -38,6 +43,8 @@ export type QueryParams = { ? number : T[Property] extends NumberArrayParamConfig ? number[] + : T[Property] extends BooleanParamConfig + ? boolean : never; }; @@ -71,6 +78,7 @@ const getParamValue = ( if (config.type === "number[]") return ensureNumberArray(value); if (config.type === "string[]") return ensureArray(value); if (config.type === "number") return parseInt(value.toString(), 10); + if (config.type === "boolean") return value.toString() === "true"; return value; }; diff --git a/frontend/src/pages/activateUser/ActivateUser.tsx b/frontend/src/pages/activateUser/ActivateUser.tsx index f84c46d24..8f9c65f35 100644 --- a/frontend/src/pages/activateUser/ActivateUser.tsx +++ b/frontend/src/pages/activateUser/ActivateUser.tsx @@ -13,16 +13,7 @@ import { ROUTE_HOME, ROUTE_LOGIN } from "src/constants/route"; import Title from "src/components/title"; const schema = yup.object({ - name: yup - .string() - .required("Username is required") - .test( - "excludeEmail", - "The username is public and should not be the same as your email", - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - (value, { parent }) => value?.trim() !== parent?.email - ), - email: yup.string().email().required("Email is required"), + name: yup.string().required("Username is required"), activationKey: yup.string().required("Activation Key is required"), password: yup.string().required("Password is required"), }); @@ -53,7 +44,6 @@ const ActivateNewUserPage: FC = () => { const onSubmit = (formData: ActivateNewUserFormData) => { const userData = { name: formData.name, - email: formData.email, activation_key: formData.activationKey, password: formData.password, }; @@ -71,7 +61,6 @@ const ActivateNewUserPage: FC = () => { const errorList = [ errors.activationKey?.message, - errors.email?.message, errors.name?.message, errors.password?.message, submitError, @@ -84,11 +73,6 @@ const ActivateNewUserPage: FC = () => { className="align-self-center col-8 mx-auto" onSubmit={handleSubmit(onSubmit)} > - { /> +

Register account

+
Username: diff --git a/frontend/src/pages/registerUser/Register.tsx b/frontend/src/pages/registerUser/Register.tsx index e11667c72..1d3e64be7 100644 --- a/frontend/src/pages/registerUser/Register.tsx +++ b/frontend/src/pages/registerUser/Register.tsx @@ -94,6 +94,8 @@ const Register: FC = ({ config }) => { onSubmit={handleSubmit(onSubmit)} > +

Register account

+
Email: diff --git a/frontend/src/pages/resetPassword/ResetPassword.tsx b/frontend/src/pages/resetPassword/ResetPassword.tsx index 131cb6ee1..db0ebccaa 100644 --- a/frontend/src/pages/resetPassword/ResetPassword.tsx +++ b/frontend/src/pages/resetPassword/ResetPassword.tsx @@ -8,14 +8,34 @@ import * as yup from "yup"; import cx from "classnames"; import { Button, Form, Row, Col } from "react-bootstrap"; +import { ErrorMessage } from "src/components/fragments"; import Title from "src/components/title"; import { useChangePassword } from "src/graphql"; import { ROUTE_HOME, ROUTE_LOGIN } from "src/constants/route"; const schema = yup.object({ - email: yup.string().email().required("Email is required"), resetKey: yup.string().required("Reset Key is required"), - password: yup.string().required("Password is required"), + newPassword: yup + .string() + .min(8, "Password must be at least 8 characters") + .test( + "uniqueness", + "Password must have at least 5 unique characters", + (value) => + value !== undefined && + value + .split("") + .filter( + (item: string, i: number, ar: string[]) => ar.indexOf(item) === i + ) + .join("").length >= 5 + ) + .required("Password is required"), + confirmNewPassword: yup + .string() + .nullable() + .oneOf([yup.ref("newPassword"), null], "Passwords don't match") + .required("Password is required"), }); type ResetPasswordFormData = yup.Asserts; @@ -41,10 +61,14 @@ const ResetPassword: FC = () => { if (Auth.authenticated) navigate(ROUTE_HOME); + const key = query.get("key"); + + if (!key) return ; + const onSubmit = (formData: ResetPasswordFormData) => { const userData = { reset_key: formData.resetKey, - new_password: formData.password, + new_password: formData.newPassword, }; setSubmitError(undefined); changePassword({ variables: { userData } }) @@ -61,8 +85,8 @@ const ResetPassword: FC = () => { const errorList = [ errors.resetKey?.message, - errors.email?.message, - errors.password?.message, + errors.newPassword?.message, + errors.confirmNewPassword?.message, submitError, ].filter((err): err is string => err !== undefined); @@ -73,30 +97,35 @@ const ResetPassword: FC = () => { className="align-self-center col-8 mx-auto" onSubmit={handleSubmit(onSubmit)} > - - - + +

Reset Password

+
- - New Password: - - - + + + +
+ {errors?.newPassword?.message} +
+
+ + +
+ {errors?.confirmNewPassword?.message} +
+
diff --git a/frontend/src/pages/users/User.tsx b/frontend/src/pages/users/User.tsx index 7537a2a15..a35b2c3c2 100644 --- a/frontend/src/pages/users/User.tsx +++ b/frontend/src/pages/users/User.tsx @@ -22,7 +22,9 @@ import { PublicUserQuery, useGenerateInviteCodes, GenerateInviteCodeInput, + useRequestChangeEmail, } from "src/graphql"; +import { useToast } from "src/hooks"; import AuthContext from "src/AuthContext"; import { ROUTE_USER_EDIT, @@ -35,6 +37,7 @@ import { Icon, Tooltip } from "src/components/fragments"; import { isAdmin, isPrivateUser, createHref, formatDateTime } from "src/utils"; import { EditStatusTypes, VoteTypes } from "src/constants"; import { GenerateInviteKeyModal } from "./GenerateInviteKeyModal"; +import { isApolloError } from "@apollo/client"; interface IInviteKeys { id: string; @@ -139,6 +142,7 @@ const UserComponent: FC = ({ user, refetch }) => { const [showRegenerateAPIKey, setShowRegenerateAPIKey] = useState(false); const [showRescindCode, setShowRescindCode] = useState(); const [showGenerateInviteKey, setShowGenerateInviteKey] = useState(false); + const toast = useToast(); const [deleteUser, { loading: deleting }] = useDeleteUser(); const [regenerateAPIKey] = useRegenerateAPIKey(); @@ -146,6 +150,7 @@ const UserComponent: FC = ({ user, refetch }) => { const [generateInviteCode] = useGenerateInviteCodes(); const [grantInvite] = useGrantInvite(); const [revokeInvite] = useRevokeInvite(); + const [requestChangeEmail] = useRequestChangeEmail(); const showPrivate = isPrivateUser(user); const isOwner = showPrivate && user.id === Auth.user?.id; @@ -255,6 +260,33 @@ const UserComponent: FC = ({ user, refetch }) => { }); }; + const handleChangeEmail = () => { + requestChangeEmail() + .then(() => { + toast({ + variant: "success", + content: ( + <> +
Change email
+
Please check your existing email to continue.
+ + ), + }); + }) + .catch((error: unknown) => { + let message: React.ReactNode | string | undefined = + error instanceof Error && isApolloError(error) && error.message; + if (message === "pending-email-change") + message = ( + <> +
Pending email change
+
Email change already requested. Please try again later.
+ + ); + toast({ variant: "danger", content: message }); + }); + }; + const editCount = filterEdits(user.edit_count); const voteCount = filterVotes(user.vote_count); @@ -276,6 +308,11 @@ const UserComponent: FC = ({ user, refetch }) => { )} + {isOwner && ( + + )} {isAdmin(Auth.user) && ( <> diff --git a/frontend/src/pages/users/UserConfirmChangeEmail.tsx b/frontend/src/pages/users/UserConfirmChangeEmail.tsx new file mode 100644 index 000000000..5423ddd02 --- /dev/null +++ b/frontend/src/pages/users/UserConfirmChangeEmail.tsx @@ -0,0 +1,74 @@ +import { FC, useState } from "react"; +import { isApolloError } from "@apollo/client"; +import { useNavigate } from "react-router-dom"; +import { Button, Form } from "react-bootstrap"; + +import type { User } from "src/AuthContext"; +import { useQueryParams, useToast } from "src/hooks"; +import { userHref } from "src/utils"; +import { ErrorMessage } from "src/components/fragments"; +import Title from "src/components/title"; +import { useConfirmChangeEmail } from "src/graphql"; + +const ConfirmChangeEmail: FC<{ user: User }> = ({ user }) => { + const navigate = useNavigate(); + const [submitError, setSubmitError] = useState(); + const [{ token }] = useQueryParams({ + token: { name: "key", type: "string" }, + }); + const toast = useToast(); + + const [confirmChangeEmail, { loading }] = useConfirmChangeEmail(); + + if (!token) return ; + if (submitError) return ; + + const onSubmit = () => { + setSubmitError(undefined); + confirmChangeEmail({ variables: { token } }) + .then((res) => { + const status = res.data?.confirmChangeEmail; + if (status === "SUCCESS") { + toast({ + variant: "success", + content: ( + <> +
Email successfully changed
+ + ), + }); + navigate(userHref(user)); + } else if (status === "INVALID_TOKEN") + setSubmitError( + "Invalid or expired token, please restart the process." + ); + else if (status === "EXPIRED") + setSubmitError( + "Email change token expired, please restart the process." + ); + else setSubmitError("An unknown error occurred"); + }) + .catch( + (error: unknown) => + error instanceof Error && + isApolloError(error) && + setSubmitError(error.message) + ); + return false; + }; + + return ( +
+ + <Form className="align-self-center col-8 mx-auto"> + <h5>Confirm change email</h5> + <p>Click the button to confirm email change.</p> + <Button type="submit" disabled={loading} onClick={onSubmit}> + Complete email change + </Button> + </Form> + </div> + ); +}; + +export default ConfirmChangeEmail; diff --git a/frontend/src/pages/users/UserValidateChangeEmail.tsx b/frontend/src/pages/users/UserValidateChangeEmail.tsx new file mode 100644 index 000000000..f3bbc0da1 --- /dev/null +++ b/frontend/src/pages/users/UserValidateChangeEmail.tsx @@ -0,0 +1,121 @@ +import { FC, useState } from "react"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { useForm } from "react-hook-form"; +import { isApolloError } from "@apollo/client"; +import * as yup from "yup"; +import cx from "classnames"; +import { Button, Form, Row, Col } from "react-bootstrap"; + +import type { User } from "src/AuthContext"; +import { useQueryParams } from "src/hooks"; +import { ErrorMessage } from "src/components/fragments"; +import Title from "src/components/title"; +import { useValidateChangeEmail } from "src/graphql"; + +const schema = yup.object({ + token: yup.string().required(), + email: yup.string().required("Email is required"), +}); +type ValidateChangeEmailFormData = yup.Asserts<typeof schema>; + +const ValidateChangeEmail: FC<{ user: User }> = () => { + const [submitError, setSubmitError] = useState<string | undefined>(); + const [{ token, submitted }, setQueryParam] = useQueryParams({ + token: { name: "key", type: "string" }, + submitted: { name: "submitted", type: "boolean" }, + }); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm<ValidateChangeEmailFormData>({ + resolver: yupResolver(schema), + }); + + const [validateChangeEmail, { loading }] = useValidateChangeEmail(); + + if (submitted) + return ( + <div className="LoginPrompt"> + <div className="align-self-center col-8 mx-auto"> + <h5>Confirmation email sent</h5> + <p>Please check your email to complete the email change.</p> + </div> + </div> + ); + + if (!token) return <ErrorMessage error="Missing token" />; + + const onSubmit = (formData: ValidateChangeEmailFormData) => { + setSubmitError(undefined); + validateChangeEmail({ variables: { ...formData } }) + .then((res) => { + const status = res.data?.validateChangeEmail; + if (status === "CONFIRM_NEW") setQueryParam("submitted", true); + else if (status === "INVALID_TOKEN") + setSubmitError( + "Invalid or expired token, please restart the process." + ); + else if (status === "EXPIRED") + setSubmitError( + "Email change token expired, please restart the process." + ); + else setSubmitError("An unknown error occurred"); + }) + .catch( + (error: unknown) => + error instanceof Error && + isApolloError(error) && + setSubmitError(error.message) + ); + }; + + const errorList = [ + errors.token?.message, + errors.email?.message, + submitError, + ].filter((err): err is string => err !== undefined); + + return ( + <div className="LoginPrompt"> + <Title page="Confirm Email" /> + <Form + className="align-self-center col-8 mx-auto" + onSubmit={handleSubmit(onSubmit)} + > + <h5>Change email</h5> + <p>Enter a new email address to complete email change.</p> + <Form.Control type="hidden" value={token} {...register("token")} /> + + <Form.Group controlId="email" className="mt-2"> + <Form.Control + className={cx({ "is-invalid": errors?.email })} + type="email" + placeholder="New email" + {...register("email")} + /> + </Form.Group> + + {errorList.map((error) => ( + <Row key={error} className="text-end text-danger"> + <div>{error}</div> + </Row> + ))} + + <Row> + <Col + xs={{ span: 3, offset: 9 }} + className="justify-content-end mt-2 d-flex" + > + <Button type="submit" disabled={loading}> + Change Email + </Button> + </Col> + </Row> + </Form> + </div> + ); +}; + +export default ValidateChangeEmail; diff --git a/frontend/src/pages/users/index.tsx b/frontend/src/pages/users/index.tsx index 8b16da85c..c4d62a95b 100644 --- a/frontend/src/pages/users/index.tsx +++ b/frontend/src/pages/users/index.tsx @@ -12,6 +12,8 @@ import UserAdd from "./UserAdd"; import UserEdit from "./UserEdit"; import UserPassword from "./UserPassword"; import UserEdits from "./UserEdits"; +import UserConfirmChangeEmail from "./UserConfirmChangeEmail"; +import UserValidateChangeEmail from "./UserValidateChangeEmail"; const UserLoader: FC = () => { const { name } = useParams<{ name: string }>(); @@ -53,6 +55,14 @@ const UserLoader: FC = () => { </> } /> + <Route + path="/confirm-email" + element={<UserConfirmChangeEmail user={user} />} + /> + <Route + path="/change-email" + element={<UserValidateChangeEmail user={user} />} + /> </Routes> ); }; diff --git a/go.mod b/go.mod index 61d3f4b53..17fac240d 100644 --- a/go.mod +++ b/go.mod @@ -23,11 +23,12 @@ require ( github.com/spf13/viper v1.18.2 github.com/vektah/dataloaden v0.3.0 github.com/vektah/gqlparser/v2 v2.5.11 + github.com/wneessen/go-mail v0.5.2 go.deanishe.net/favicon v0.1.0 - golang.org/x/crypto v0.19.0 + golang.org/x/crypto v0.28.0 golang.org/x/image v0.15.0 - golang.org/x/net v0.21.0 - golang.org/x/sync v0.6.0 + golang.org/x/net v0.25.0 + golang.org/x/sync v0.8.0 gopkg.in/guregu/null.v4 v4.0.0 gotest.tools/v3 v3.5.1 ) @@ -40,7 +41,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/friendsofgo/errors v0.9.2 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/google/go-cmp v0.5.9 // indirect + github.com/google/go-cmp v0.6.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/websocket v1.5.0 // indirect @@ -71,10 +72,10 @@ require ( go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/mod v0.14.0 // indirect - golang.org/x/sys v0.17.0 // indirect - golang.org/x/text v0.14.0 // indirect - golang.org/x/tools v0.17.0 // indirect + golang.org/x/mod v0.17.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index b3766c43e..fb2faf5a4 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/golang-migrate/migrate/v4 v4.17.0 h1:rd40H3QXU0AA4IoLllFcEAEo9dYKRHYN github.com/golang-migrate/migrate/v4 v4.17.0/go.mod h1:+Cp2mtLP4/aXDTKb9wmXYitdrNx2HGs45rbWAo6OsKM= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -245,6 +245,8 @@ github.com/vektah/dataloaden v0.3.0 h1:ZfVN2QD6swgvp+tDqdH/OIT/wu3Dhu0cus0k5gIZS github.com/vektah/dataloaden v0.3.0/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U= github.com/vektah/gqlparser/v2 v2.5.11 h1:JJxLtXIoN7+3x6MBdtIP59TP1RANnY7pXOaDnADQSf8= github.com/vektah/gqlparser/v2 v2.5.11/go.mod h1:1rCcfwB2ekJofmluGWXMSEnPMZgbxzwj6FaZ/4OT8Cc= +github.com/wneessen/go-mail v0.5.2 h1:MZKwgHJoRboLJ+EHMLuHpZc95wo+u1xViL/4XSswDT8= +github.com/wneessen/go-mail v0.5.2/go.mod h1:kRroJvEq2hOSEPFRiKjN7Csrz0G1w+RpiGR3b6yo+Ck= github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= github.com/xdg/stringprep v0.0.0-20180714160509-73f8eece6fdc/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= @@ -262,16 +264,22 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= -golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -280,16 +288,24 @@ golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -304,18 +320,32 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190329151228-23e29df326fe/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190416151739-9c9e1878f421/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= @@ -324,8 +354,10 @@ golang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd/go.mod h1:RgjU9mgBXZiqYHBn golang.org/x/tools v0.0.0-20190531172133-b3315ee88b7d/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= -golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 88df6f4f6..db8e79259 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -102,7 +102,7 @@ type Mutation { imageDestroy(input: ImageDestroyInput!): Boolean! @hasRole(role: MODIFY) """User interface for registering""" - newUser(input: NewUserInput!): String + newUser(input: NewUserInput!): ID activateNewUser(input: ActivateNewUserInput!): User generateInviteCode: ID @deprecated(reason: "Use generateInviteCodes") @@ -132,6 +132,11 @@ type Mutation { """Changes the password for the current user""" changePassword(input: UserChangePasswordInput!): Boolean! + """Request an email change for the current user""" + requestChangeEmail: UserChangeEmailStatus! @hasRole(role: READ) + validateChangeEmail(token: ID!, email: String!): UserChangeEmailStatus! @hasRole(role: READ) + confirmChangeEmail(token: ID!): UserChangeEmailStatus! @hasRole(role: READ) + # Edit interfaces """Propose a new scene or modification to a scene""" sceneEdit(input: SceneEditInput!): Edit! @hasRole(role: EDIT) diff --git a/graphql/schema/types/user.graphql b/graphql/schema/types/user.graphql index 063f1bde8..fb12ca9a8 100644 --- a/graphql/schema/types/user.graphql +++ b/graphql/schema/types/user.graphql @@ -63,13 +63,12 @@ input UserUpdateInput { input NewUserInput { email: String! - invite_key: String + invite_key: ID } input ActivateNewUserInput { name: String! - email: String! - activation_key: String! + activation_key: ID! password: String! } @@ -81,7 +80,7 @@ input UserChangePasswordInput { """Password in plain text""" existing_password: String new_password: String! - reset_key: String + reset_key: ID } input UserDestroyInput { @@ -160,4 +159,19 @@ input GenerateInviteCodeInput { uses: Int # the number of seconds until the invite code expires. If not set, the invite code will never expire ttl: Int -} \ No newline at end of file +} + +input UserChangeEmailInput { + existing_email_token: ID + new_email_token: ID + new_email: String +} + +enum UserChangeEmailStatus { + CONFIRM_OLD + CONFIRM_NEW + EXPIRED + INVALID_TOKEN + SUCCESS + ERROR +} diff --git a/pkg/api/resolver_mutation_user.go b/pkg/api/resolver_mutation_user.go index 03fa8aa42..88a841490 100644 --- a/pkg/api/resolver_mutation_user.go +++ b/pkg/api/resolver_mutation_user.go @@ -210,17 +210,12 @@ func (r *mutationResolver) ChangePassword(ctx context.Context, input models.User return err == nil, err } -func (r *mutationResolver) NewUser(ctx context.Context, input models.NewUserInput) (*string, error) { - inviteKey := "" - if input.InviteKey != nil { - inviteKey = *input.InviteKey - } - +func (r *mutationResolver) NewUser(ctx context.Context, input models.NewUserInput) (*uuid.UUID, error) { fac := r.getRepoFactory(ctx) - var ret *string + var ret *uuid.UUID err := fac.WithTxn(func() error { var txnErr error - ret, txnErr = user.NewUser(fac, manager.GetInstance().EmailManager, input.Email, inviteKey) + ret, txnErr = user.NewUser(fac, manager.GetInstance().EmailManager, input.Email, input.InviteKey) return txnErr }) @@ -232,7 +227,7 @@ func (r *mutationResolver) ActivateNewUser(ctx context.Context, input models.Act fac := r.getRepoFactory(ctx) err := fac.WithTxn(func() error { var txnErr error - ret, txnErr = user.ActivateNewUser(fac, input.Name, input.Email, input.ActivationKey, input.Password) + ret, txnErr = user.ActivateNewUser(fac, input.Name, input.ActivationKey, input.Password) return txnErr }) @@ -412,3 +407,81 @@ func (r *mutationResolver) RevokeInvite(ctx context.Context, input models.Revoke return ret, err } + +func (r *mutationResolver) RequestChangeEmail(ctx context.Context) (models.UserChangeEmailStatus, error) { + currentUser := getCurrentUser(ctx) + fac := r.getRepoFactory(ctx) + + err := fac.WithTxn(func() error { + return user.ConfirmOldEmail(fac, manager.GetInstance().EmailManager, *currentUser) + }) + + if err != nil { + return models.UserChangeEmailStatusError, err + } + return models.UserChangeEmailStatusConfirmOld, nil +} + +func (r *mutationResolver) ValidateChangeEmail(ctx context.Context, tokenID uuid.UUID, email string) (models.UserChangeEmailStatus, error) { + fac := r.getRepoFactory(ctx) + tqb := fac.UserToken() + + token, err := tqb.Find(tokenID) + if err != nil { + return models.UserChangeEmailStatusError, err + } + if token == nil { + return models.UserChangeEmailStatusInvalidToken, err + } + + data, err := token.GetUserTokenData() + if err != nil { + return models.UserChangeEmailStatusError, err + } + + currentUser := getCurrentUser(ctx) + if data.UserID != currentUser.ID { + return models.UserChangeEmailStatusInvalidToken, nil + } + + err = fac.WithTxn(func() error { + return user.ConfirmNewEmail(fac, manager.GetInstance().EmailManager, *currentUser, email) + }) + + if err != nil { + return models.UserChangeEmailStatusError, err + } + return models.UserChangeEmailStatusConfirmNew, nil +} + +func (r *mutationResolver) ConfirmChangeEmail(ctx context.Context, tokenID uuid.UUID) (models.UserChangeEmailStatus, error) { + fac := r.getRepoFactory(ctx) + tqb := fac.UserToken() + + token, err := tqb.Find(tokenID) + if err != nil { + return models.UserChangeEmailStatusError, err + } + if token == nil { + return models.UserChangeEmailStatusInvalidToken, err + } + + data, err := token.GetChangeEmailTokenData() + if err != nil || data == nil { + return models.UserChangeEmailStatusError, err + } + + currentUser := getCurrentUser(ctx) + if data.UserID != currentUser.ID { + return models.UserChangeEmailStatusInvalidToken, nil + } + + err = fac.WithTxn(func() error { + return user.ChangeEmail(fac, *data) + }) + + if err != nil { + return models.UserChangeEmailStatusError, err + } + return models.UserChangeEmailStatusSuccess, nil +} diff --git a/pkg/database/database.go b/pkg/database/database.go index b8144804d..824f76784 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -4,7 +4,7 @@ import ( "github.com/jmoiron/sqlx" ) -var appSchemaVersion uint = 36 +var appSchemaVersion uint = 37 var databaseProviders map[string]databaseProvider diff --git a/pkg/database/migrations/postgres/37_tokens.up.sql b/pkg/database/migrations/postgres/37_tokens.up.sql new file mode 100644 index 000000000..2b37fe3ce --- /dev/null +++ b/pkg/database/migrations/postgres/37_tokens.up.sql @@ -0,0 +1,9 @@ +DROP TABLE "pending_activations"; + +CREATE TABLE "user_tokens" ( + "id" UUID NOT NULL, + "data" JSONB, + "type" TEXT NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "expires_at" TIMESTAMP NOT NULL +); diff --git a/pkg/email/email.go b/pkg/email/email.go index 5e4c51671..8f0038667 100644 --- a/pkg/email/email.go +++ b/pkg/email/email.go @@ -2,10 +2,11 @@ package email import ( "errors" - "net/smtp" - "strconv" + "fmt" "time" + "github.com/wneessen/go-mail" + "github.com/stashapp/stash-box/pkg/manager/config" ) @@ -23,7 +24,7 @@ func (m *Manager) validateEmailCooldown(email string) error { m.clearExpired() if _, found := m.lastEmailed[email]; found { - return errors.New("try again later") + return errors.New("pending-email-change") } return nil @@ -41,15 +42,7 @@ func (m *Manager) clearExpired() { } } -func (m *Manager) makeAuth() smtp.Auth { - if config.GetEmailUser() != "" { - return smtp.PlainAuth("", config.GetEmailUser(), config.GetEmailPassword(), config.GetEmailHost()) - } - - return nil -} - -func (m *Manager) Send(email, subject, body string) error { +func (m *Manager) Send(email, subject, text, html string) error { err := m.validateEmailCooldown(email) if err != nil { return err @@ -59,17 +52,27 @@ func (m *Manager) Send(email, subject, body string) error { return errors.New("email settings not configured") } - const endLine = "\r\n" - from := "From: " + config.GetEmailFrom() - to := "To: " + email - port := strconv.Itoa(config.GetEmailPort()) + message := mail.NewMsg() + if err := message.FromFormat(config.GetTitle(), config.GetEmailFrom()); err != nil { + return fmt.Errorf("failed to set From address: %w", err) + } - msg := []byte(from + endLine + to + endLine + subject + endLine + endLine + body + endLine) + if err := message.To(email); err != nil { + return fmt.Errorf("failed to set To address: %w", err) + } - err = smtp.SendMail(config.GetEmailHost()+":"+port, m.makeAuth(), config.GetEmailFrom(), []string{email}, msg) + message.Subject(subject) + message.SetBodyString(mail.TypeTextPlain, text) + message.AddAlternativeString(mail.TypeTextHTML, html) + client, err := mail.NewClient(config.GetEmailHost(), mail.WithPort(config.GetEmailPort()), mail.WithSMTPAuth(mail.SMTPAuthPlain), + mail.WithUsername(config.GetEmailUser()), mail.WithPassword(config.GetEmailPassword())) if err != nil { - return err + return fmt.Errorf("failed to create mail client: %w", err) + } + + if err := client.DialAndSend(message); err != nil { + return fmt.Errorf("failed to send mail: %w", err) } // add to email map diff --git a/pkg/manager/cron/cron.go b/pkg/manager/cron/cron.go index c75f16aa7..86c110e76 100644 --- a/pkg/manager/cron/cron.go +++ b/pkg/manager/cron/cron.go @@ -74,6 +74,28 @@ func (c Cron) cleanDrafts() { } } +func (c Cron) cleanTokens() { + fac := c.rfp.Repo() + err := fac.WithTxn(func() error { + return fac.UserToken().DestroyExpired() + }) + + if err != nil { + logger.Errorf("Error cleaning user tokens: %s", err) + } +} + +func (c Cron) cleanInvites() { + fac := c.rfp.Repo() + err := fac.WithTxn(func() error { + return fac.Invite().DestroyExpired() + }) + + if err != nil { + logger.Errorf("Error cleaning invites: %s", err) + } +} + func Init(rfp api.RepoProvider) { c := cron.New() cronJobs := Cron{rfp} @@ -83,6 +105,16 @@ func Init(rfp api.RepoProvider) { panic(err.Error()) } + _, err = c.AddFunc("@every 1m", cronJobs.cleanTokens) + if err != nil { + panic(err.Error()) + } + + _, err = c.AddFunc("@every 60m", cronJobs.cleanInvites) + if err != nil { + panic(err.Error()) + } + interval := config.GetVoteCronInterval() if interval != "" { _, err := c.AddFunc("@every "+config.GetVoteCronInterval(), cronJobs.processEdits) diff --git a/pkg/models/activation.go b/pkg/models/activation.go index 032b4f400..ed9247242 100644 --- a/pkg/models/activation.go +++ b/pkg/models/activation.go @@ -1,26 +1,23 @@ package models import ( - "time" - "github.com/gofrs/uuid" ) -type PendingActivationRepo interface { - PendingActivationFinder - PendingActivationCreator +type UserTokenRepo interface { + UserTokenFinder + UserTokenCreator Destroy(id uuid.UUID) error - DestroyExpired(expireTime time.Time) error + DestroyExpired() error Count() (int, error) } -type PendingActivationFinder interface { - Find(id uuid.UUID) (*PendingActivation, error) - FindByEmail(email string, activationType string) (*PendingActivation, error) - FindByInviteKey(key string, activationType string) ([]*PendingActivation, error) +type UserTokenFinder interface { + Find(id uuid.UUID) (*UserToken, error) + FindByInviteKey(key uuid.UUID) ([]*UserToken, error) } -type PendingActivationCreator interface { - Create(newActivation PendingActivation) (*PendingActivation, error) +type UserTokenCreator interface { + Create(newActivation UserToken) (*UserToken, error) } diff --git a/pkg/models/factory.go b/pkg/models/factory.go index 67075f6a5..8dc9129b9 100644 --- a/pkg/models/factory.go +++ b/pkg/models/factory.go @@ -18,7 +18,7 @@ type Repo interface { Joins() JoinsRepo - PendingActivation() PendingActivationRepo + UserToken() UserTokenRepo Invite() InviteKeyRepo User() UserRepo Site() SiteRepo diff --git a/pkg/models/generated_exec.go b/pkg/models/generated_exec.go index ddabfae1e..e6ee8481a 100644 --- a/pkg/models/generated_exec.go +++ b/pkg/models/generated_exec.go @@ -175,6 +175,7 @@ type ComplexityRoot struct { ApplyEdit func(childComplexity int, input ApplyEditInput) int CancelEdit func(childComplexity int, input CancelEditInput) int ChangePassword func(childComplexity int, input UserChangePasswordInput) int + ConfirmChangeEmail func(childComplexity int, token uuid.UUID) int DestroyDraft func(childComplexity int, id uuid.UUID) int EditComment func(childComplexity int, input EditCommentInput) int EditVote func(childComplexity int, input EditVoteInput) int @@ -192,6 +193,7 @@ type ComplexityRoot struct { PerformerEditUpdate func(childComplexity int, id uuid.UUID, input PerformerEditInput) int PerformerUpdate func(childComplexity int, input PerformerUpdateInput) int RegenerateAPIKey func(childComplexity int, userID *uuid.UUID) int + RequestChangeEmail func(childComplexity int) int RescindInviteCode func(childComplexity int, code uuid.UUID) int ResetPassword func(childComplexity int, input ResetPasswordInput) int RevokeInvite func(childComplexity int, input RevokeInviteInput) int @@ -222,6 +224,7 @@ type ComplexityRoot struct { UserCreate func(childComplexity int, input UserCreateInput) int UserDestroy func(childComplexity int, input UserDestroyInput) int UserUpdate func(childComplexity int, input UserUpdateInput) int + ValidateChangeEmail func(childComplexity int, token uuid.UUID, email string) int } Performer struct { @@ -653,7 +656,7 @@ type MutationResolver interface { UserDestroy(ctx context.Context, input UserDestroyInput) (bool, error) ImageCreate(ctx context.Context, input ImageCreateInput) (*Image, error) ImageDestroy(ctx context.Context, input ImageDestroyInput) (bool, error) - NewUser(ctx context.Context, input NewUserInput) (*string, error) + NewUser(ctx context.Context, input NewUserInput) (*uuid.UUID, error) ActivateNewUser(ctx context.Context, input ActivateNewUserInput) (*User, error) GenerateInviteCode(ctx context.Context) (*uuid.UUID, error) GenerateInviteCodes(ctx context.Context, input *GenerateInviteCodeInput) ([]uuid.UUID, error) @@ -669,6 +672,9 @@ type MutationResolver interface { RegenerateAPIKey(ctx context.Context, userID *uuid.UUID) (string, error) ResetPassword(ctx context.Context, input ResetPasswordInput) (bool, error) ChangePassword(ctx context.Context, input UserChangePasswordInput) (bool, error) + RequestChangeEmail(ctx context.Context) (UserChangeEmailStatus, error) + ValidateChangeEmail(ctx context.Context, token uuid.UUID, email string) (UserChangeEmailStatus, error) + ConfirmChangeEmail(ctx context.Context, token uuid.UUID) (UserChangeEmailStatus, error) SceneEdit(ctx context.Context, input SceneEditInput) (*Edit, error) PerformerEdit(ctx context.Context, input PerformerEditInput) (*Edit, error) StudioEdit(ctx context.Context, input StudioEditInput) (*Edit, error) @@ -1377,6 +1383,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.ChangePassword(childComplexity, args["input"].(UserChangePasswordInput)), true + case "Mutation.confirmChangeEmail": + if e.complexity.Mutation.ConfirmChangeEmail == nil { + break + } + + args, err := ec.field_Mutation_confirmChangeEmail_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.ConfirmChangeEmail(childComplexity, args["token"].(uuid.UUID)), true + case "Mutation.destroyDraft": if e.complexity.Mutation.DestroyDraft == nil { break @@ -1576,6 +1594,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.RegenerateAPIKey(childComplexity, args["userID"].(*uuid.UUID)), true + case "Mutation.requestChangeEmail": + if e.complexity.Mutation.RequestChangeEmail == nil { + break + } + + return e.complexity.Mutation.RequestChangeEmail(childComplexity), true + case "Mutation.rescindInviteCode": if e.complexity.Mutation.RescindInviteCode == nil { break @@ -1936,6 +1961,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.UserUpdate(childComplexity, args["input"].(UserUpdateInput)), true + case "Mutation.validateChangeEmail": + if e.complexity.Mutation.ValidateChangeEmail == nil { + break + } + + args, err := ec.field_Mutation_validateChangeEmail_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.ValidateChangeEmail(childComplexity, args["token"].(uuid.UUID), args["email"].(string)), true + case "Performer.age": if e.complexity.Performer.Age == nil { break @@ -4089,6 +4126,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputTagQueryInput, ec.unmarshalInputTagUpdateInput, ec.unmarshalInputURLInput, + ec.unmarshalInputUserChangeEmailInput, ec.unmarshalInputUserChangePasswordInput, ec.unmarshalInputUserCreateInput, ec.unmarshalInputUserDestroyInput, @@ -5474,13 +5512,12 @@ input UserUpdateInput { input NewUserInput { email: String! - invite_key: String + invite_key: ID } input ActivateNewUserInput { name: String! - email: String! - activation_key: String! + activation_key: ID! password: String! } @@ -5492,7 +5529,7 @@ input UserChangePasswordInput { """Password in plain text""" existing_password: String new_password: String! - reset_key: String + reset_key: ID } input UserDestroyInput { @@ -5571,7 +5608,23 @@ input GenerateInviteCodeInput { uses: Int # the number of seconds until the invite code expires. If not set, the invite code will never expire ttl: Int -}`, BuiltIn: false}, +} + +input UserChangeEmailInput { + existing_email_token: ID + new_email_token: ID + new_email: String +} + +enum UserChangeEmailStatus { + CONFIRM_OLD + CONFIRM_NEW + EXPIRED + INVALID_TOKEN + SUCCESS + ERROR +} +`, BuiltIn: false}, {Name: "../../graphql/schema/types/version.graphql", Input: `type Version { hash: String! build_time: String! @@ -5683,7 +5736,7 @@ type Mutation { imageDestroy(input: ImageDestroyInput!): Boolean! @hasRole(role: MODIFY) """User interface for registering""" - newUser(input: NewUserInput!): String + newUser(input: NewUserInput!): ID activateNewUser(input: ActivateNewUserInput!): User generateInviteCode: ID @deprecated(reason: "Use generateInviteCodes") @@ -5713,6 +5766,11 @@ type Mutation { """Changes the password for the current user""" changePassword(input: UserChangePasswordInput!): Boolean! + """Request an email change for the current user""" + requestChangeEmail: UserChangeEmailStatus! @hasRole(role: READ) + validateChangeEmail(token: ID!, email: String!): UserChangeEmailStatus! @hasRole(role: READ) + confirmChangeEmail(token: ID!): UserChangeEmailStatus! @hasRole(role: READ) + # Edit interfaces """Propose a new scene or modification to a scene""" sceneEdit(input: SceneEditInput!): Edit! @hasRole(role: EDIT) @@ -5842,6 +5900,21 @@ func (ec *executionContext) field_Mutation_changePassword_args(ctx context.Conte return args, nil } +func (ec *executionContext) field_Mutation_confirmChangeEmail_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 uuid.UUID + if tmp, ok := rawArgs["token"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("token")) + arg0, err = ec.unmarshalNID2githubᚗcomᚋgofrsᚋuuidᚐUUID(ctx, tmp) + if err != nil { + return nil, err + } + } + args["token"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_destroyDraft_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -6586,6 +6659,30 @@ func (ec *executionContext) field_Mutation_userUpdate_args(ctx context.Context, return args, nil } +func (ec *executionContext) field_Mutation_validateChangeEmail_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 uuid.UUID + if tmp, ok := rawArgs["token"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("token")) + arg0, err = ec.unmarshalNID2githubᚗcomᚋgofrsᚋuuidᚐUUID(ctx, tmp) + if err != nil { + return nil, err + } + } + args["token"] = arg0 + var arg1 string + if tmp, ok := rawArgs["email"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("email")) + arg1, err = ec.unmarshalNString2string(ctx, tmp) + if err != nil { + return nil, err + } + } + args["email"] = arg1 + return args, nil +} + func (ec *executionContext) field_Performer_scenes_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -11423,9 +11520,9 @@ func (ec *executionContext) _Mutation_newUser(ctx context.Context, field graphql if resTmp == nil { return graphql.Null } - res := resTmp.(*string) + res := resTmp.(*uuid.UUID) fc.Result = res - return ec.marshalOString2ᚖstring(ctx, field.Selections, res) + return ec.marshalOID2ᚖgithubᚗcomᚋgofrsᚋuuidᚐUUID(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_Mutation_newUser(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -11435,7 +11532,7 @@ func (ec *executionContext) fieldContext_Mutation_newUser(ctx context.Context, f IsMethod: true, IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type String does not have child fields") + return nil, errors.New("field of type ID does not have child fields") }, } defer func() { @@ -12478,6 +12575,232 @@ func (ec *executionContext) fieldContext_Mutation_changePassword(ctx context.Con return fc, nil } +func (ec *executionContext) _Mutation_requestChangeEmail(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_requestChangeEmail(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + directive0 := func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().RequestChangeEmail(rctx) + } + directive1 := func(ctx context.Context) (interface{}, error) { + role, err := ec.unmarshalNRoleEnum2githubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐRoleEnum(ctx, "READ") + if err != nil { + return nil, err + } + if ec.directives.HasRole == nil { + return nil, errors.New("directive hasRole is not implemented") + } + return ec.directives.HasRole(ctx, nil, directive0, role) + } + + tmp, err := directive1(rctx) + if err != nil { + return nil, graphql.ErrorOnPath(ctx, err) + } + if tmp == nil { + return nil, nil + } + if data, ok := tmp.(UserChangeEmailStatus); ok { + return data, nil + } + return nil, fmt.Errorf(`unexpected type %T from directive, should be github.com/stashapp/stash-box/pkg/models.UserChangeEmailStatus`, tmp) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(UserChangeEmailStatus) + fc.Result = res + return ec.marshalNUserChangeEmailStatus2githubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐUserChangeEmailStatus(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_requestChangeEmail(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type UserChangeEmailStatus does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Mutation_validateChangeEmail(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_validateChangeEmail(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + directive0 := func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().ValidateChangeEmail(rctx, fc.Args["token"].(uuid.UUID), fc.Args["email"].(string)) + } + directive1 := func(ctx context.Context) (interface{}, error) { + role, err := ec.unmarshalNRoleEnum2githubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐRoleEnum(ctx, "READ") + if err != nil { + return nil, err + } + if ec.directives.HasRole == nil { + return nil, errors.New("directive hasRole is not implemented") + } + return ec.directives.HasRole(ctx, nil, directive0, role) + } + + tmp, err := directive1(rctx) + if err != nil { + return nil, graphql.ErrorOnPath(ctx, err) + } + if tmp == nil { + return nil, nil + } + if data, ok := tmp.(UserChangeEmailStatus); ok { + return data, nil + } + return nil, fmt.Errorf(`unexpected type %T from directive, should be github.com/stashapp/stash-box/pkg/models.UserChangeEmailStatus`, tmp) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(UserChangeEmailStatus) + fc.Result = res + return ec.marshalNUserChangeEmailStatus2githubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐUserChangeEmailStatus(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_validateChangeEmail(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type UserChangeEmailStatus does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_validateChangeEmail_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_confirmChangeEmail(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_confirmChangeEmail(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + directive0 := func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().ConfirmChangeEmail(rctx, fc.Args["token"].(uuid.UUID)) + } + directive1 := func(ctx context.Context) (interface{}, error) { + role, err := ec.unmarshalNRoleEnum2githubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐRoleEnum(ctx, "READ") + if err != nil { + return nil, err + } + if ec.directives.HasRole == nil { + return nil, errors.New("directive hasRole is not implemented") + } + return ec.directives.HasRole(ctx, nil, directive0, role) + } + + tmp, err := directive1(rctx) + if err != nil { + return nil, graphql.ErrorOnPath(ctx, err) + } + if tmp == nil { + return nil, nil + } + if data, ok := tmp.(UserChangeEmailStatus); ok { + return data, nil + } + return nil, fmt.Errorf(`unexpected type %T from directive, should be github.com/stashapp/stash-box/pkg/models.UserChangeEmailStatus`, tmp) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(UserChangeEmailStatus) + fc.Result = res + return ec.marshalNUserChangeEmailStatus2githubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐUserChangeEmailStatus(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_confirmChangeEmail(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type UserChangeEmailStatus does not have child fields") + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_confirmChangeEmail_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_sceneEdit(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_sceneEdit(ctx, field) if err != nil { @@ -31202,7 +31525,7 @@ func (ec *executionContext) unmarshalInputActivateNewUserInput(ctx context.Conte asMap[k] = v } - fieldsInOrder := [...]string{"name", "email", "activation_key", "password"} + fieldsInOrder := [...]string{"name", "activation_key", "password"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -31216,16 +31539,9 @@ func (ec *executionContext) unmarshalInputActivateNewUserInput(ctx context.Conte return it, err } it.Name = data - case "email": - ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("email")) - data, err := ec.unmarshalNString2string(ctx, v) - if err != nil { - return it, err - } - it.Email = data case "activation_key": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("activation_key")) - data, err := ec.unmarshalNString2string(ctx, v) + data, err := ec.unmarshalNID2githubᚗcomᚋgofrsᚋuuidᚐUUID(ctx, v) if err != nil { return it, err } @@ -32324,7 +32640,7 @@ func (ec *executionContext) unmarshalInputNewUserInput(ctx context.Context, obj it.Email = data case "invite_key": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("invite_key")) - data, err := ec.unmarshalOString2ᚖstring(ctx, v) + data, err := ec.unmarshalOID2ᚖgithubᚗcomᚋgofrsᚋuuidᚐUUID(ctx, v) if err != nil { return it, err } @@ -35156,6 +35472,47 @@ func (ec *executionContext) unmarshalInputURLInput(ctx context.Context, obj inte return it, nil } +func (ec *executionContext) unmarshalInputUserChangeEmailInput(ctx context.Context, obj interface{}) (UserChangeEmailInput, error) { + var it UserChangeEmailInput + asMap := map[string]interface{}{} + for k, v := range obj.(map[string]interface{}) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"existing_email_token", "new_email_token", "new_email"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "existing_email_token": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("existing_email_token")) + data, err := ec.unmarshalOID2ᚖgithubᚗcomᚋgofrsᚋuuidᚐUUID(ctx, v) + if err != nil { + return it, err + } + it.ExistingEmailToken = data + case "new_email_token": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("new_email_token")) + data, err := ec.unmarshalOID2ᚖgithubᚗcomᚋgofrsᚋuuidᚐUUID(ctx, v) + if err != nil { + return it, err + } + it.NewEmailToken = data + case "new_email": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("new_email")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.NewEmail = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputUserChangePasswordInput(ctx context.Context, obj interface{}) (UserChangePasswordInput, error) { var it UserChangePasswordInput asMap := map[string]interface{}{} @@ -35186,7 +35543,7 @@ func (ec *executionContext) unmarshalInputUserChangePasswordInput(ctx context.Co it.NewPassword = data case "reset_key": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("reset_key")) - data, err := ec.unmarshalOString2ᚖstring(ctx, v) + data, err := ec.unmarshalOID2ᚖgithubᚗcomᚋgofrsᚋuuidᚐUUID(ctx, v) if err != nil { return it, err } @@ -37293,6 +37650,27 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "requestChangeEmail": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_requestChangeEmail(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "validateChangeEmail": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_validateChangeEmail(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "confirmChangeEmail": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_confirmChangeEmail(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "sceneEdit": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_sceneEdit(ctx, field) @@ -46296,6 +46674,16 @@ func (ec *executionContext) marshalNUser2ᚖgithubᚗcomᚋstashappᚋstashᚑbo return ec._User(ctx, sel, v) } +func (ec *executionContext) unmarshalNUserChangeEmailStatus2githubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐUserChangeEmailStatus(ctx context.Context, v interface{}) (UserChangeEmailStatus, error) { + var res UserChangeEmailStatus + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNUserChangeEmailStatus2githubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐUserChangeEmailStatus(ctx context.Context, sel ast.SelectionSet, v UserChangeEmailStatus) graphql.Marshaler { + return v +} + func (ec *executionContext) unmarshalNUserChangePasswordInput2githubᚗcomᚋstashappᚋstashᚑboxᚋpkgᚋmodelsᚐUserChangePasswordInput(ctx context.Context, v interface{}) (UserChangePasswordInput, error) { res, err := ec.unmarshalInputUserChangePasswordInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) diff --git a/pkg/models/generated_models.go b/pkg/models/generated_models.go index 9735a9c89..9c046f266 100644 --- a/pkg/models/generated_models.go +++ b/pkg/models/generated_models.go @@ -37,10 +37,9 @@ type SceneDraftTag interface { } type ActivateNewUserInput struct { - Name string `json:"name"` - Email string `json:"email"` - ActivationKey string `json:"activation_key"` - Password string `json:"password"` + Name string `json:"name"` + ActivationKey uuid.UUID `json:"activation_key"` + Password string `json:"password"` } type ApplyEditInput struct { @@ -242,8 +241,8 @@ type Mutation struct { } type NewUserInput struct { - Email string `json:"email"` - InviteKey *string `json:"invite_key,omitempty"` + Email string `json:"email"` + InviteKey *uuid.UUID `json:"invite_key,omitempty"` } type PerformerAppearance struct { @@ -733,11 +732,17 @@ type TagUpdateInput struct { CategoryID *uuid.UUID `json:"category_id,omitempty"` } +type UserChangeEmailInput struct { + ExistingEmailToken *uuid.UUID `json:"existing_email_token,omitempty"` + NewEmailToken *uuid.UUID `json:"new_email_token,omitempty"` + NewEmail *string `json:"new_email,omitempty"` +} + type UserChangePasswordInput struct { // Password in plain text - ExistingPassword *string `json:"existing_password,omitempty"` - NewPassword string `json:"new_password"` - ResetKey *string `json:"reset_key,omitempty"` + ExistingPassword *string `json:"existing_password,omitempty"` + NewPassword string `json:"new_password"` + ResetKey *uuid.UUID `json:"reset_key,omitempty"` } type UserCreateInput struct { @@ -1816,6 +1821,55 @@ func (e TargetTypeEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } +type UserChangeEmailStatus string + +const ( + UserChangeEmailStatusConfirmOld UserChangeEmailStatus = "CONFIRM_OLD" + UserChangeEmailStatusConfirmNew UserChangeEmailStatus = "CONFIRM_NEW" + UserChangeEmailStatusExpired UserChangeEmailStatus = "EXPIRED" + UserChangeEmailStatusInvalidToken UserChangeEmailStatus = "INVALID_TOKEN" + UserChangeEmailStatusSuccess UserChangeEmailStatus = "SUCCESS" + UserChangeEmailStatusError UserChangeEmailStatus = "ERROR" +) + +var AllUserChangeEmailStatus = []UserChangeEmailStatus{ + UserChangeEmailStatusConfirmOld, + UserChangeEmailStatusConfirmNew, + UserChangeEmailStatusExpired, + UserChangeEmailStatusInvalidToken, + UserChangeEmailStatusSuccess, + UserChangeEmailStatusError, +} + +func (e UserChangeEmailStatus) IsValid() bool { + switch e { + case UserChangeEmailStatusConfirmOld, UserChangeEmailStatusConfirmNew, UserChangeEmailStatusExpired, UserChangeEmailStatusInvalidToken, UserChangeEmailStatusSuccess, UserChangeEmailStatusError: + return true + } + return false +} + +func (e UserChangeEmailStatus) String() string { + return string(e) +} + +func (e *UserChangeEmailStatus) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = UserChangeEmailStatus(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid UserChangeEmailStatus", str) + } + return nil +} + +func (e UserChangeEmailStatus) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + type UserVotedFilterEnum string const ( diff --git a/pkg/models/model_pending_activation.go b/pkg/models/model_pending_activation.go deleted file mode 100644 index f8a10b0d3..000000000 --- a/pkg/models/model_pending_activation.go +++ /dev/null @@ -1,36 +0,0 @@ -package models - -import ( - "time" - - "github.com/gofrs/uuid" -) - -const ( - PendingActivationTypeNewUser = "newUser" - PendingActivationTypeResetPassword = "resetPassword" -) - -type PendingActivation struct { - ID uuid.UUID `db:"id" json:"id"` - Email string `db:"email" json:"email"` - InviteKey uuid.NullUUID `db:"invite_key" json:"invite_key"` - Type string `db:"type" json:"type"` - Time time.Time `db:"time" json:"time"` -} - -func (p PendingActivation) GetID() uuid.UUID { - return p.ID -} - -type PendingActivations []*PendingActivation - -func (p PendingActivations) Each(fn func(interface{})) { - for _, v := range p { - fn(*v) - } -} - -func (p *PendingActivations) Add(o interface{}) { - *p = append(*p, o.(*PendingActivation)) -} diff --git a/pkg/models/model_user_tokens.go b/pkg/models/model_user_tokens.go new file mode 100644 index 000000000..971f7fa61 --- /dev/null +++ b/pkg/models/model_user_tokens.go @@ -0,0 +1,81 @@ +package models + +import ( + "time" + + "github.com/gofrs/uuid" + "github.com/jmoiron/sqlx/types" + "github.com/stashapp/stash-box/pkg/utils" +) + +const ( + UserTokenTypeNewUser = "NEW_USER" + UserTokenTypeResetPassword = "RESET_PASSWORD" + UserTokenTypeConfirmOldEmail = "CONFIRM_OLD_EMAIL" + UserTokenTypeConfirmNewEmail = "CONFIRM_NEW_EMAIL" +) + +type UserToken struct { + ID uuid.UUID `db:"id" json:"id"` + Data types.JSONText `db:"data" json:"data"` + Type string `db:"type" json:"type"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + ExpiresAt time.Time `db:"expires_at" json:"expires_at"` +} + +func (t UserToken) GetID() uuid.UUID { + return t.ID +} + +type UserTokens []*UserToken + +func (t UserTokens) Each(fn func(interface{})) { + for _, v := range t { + fn(*v) + } +} + +func (t *UserTokens) Add(o interface{}) { + *t = append(*t, o.(*UserToken)) +} + +func (t *UserToken) SetData(data interface{}) error { + jsonData, err := utils.ToJSON(data) + if err != nil { + return err + } + t.Data = jsonData + return nil +} + +type NewUserTokenData struct { + Email string `json:"email"` + InviteKey *uuid.UUID `json:"invite_key,omitempty"` +} + +func (t *UserToken) GetNewUserTokenData() (*NewUserTokenData, error) { + var obj NewUserTokenData + err := utils.FromJSON(t.Data, &obj) + return &obj, err +} + +type UserTokenData struct { + UserID uuid.UUID `json:"user_id"` +} + +func (t *UserToken) GetUserTokenData() (*UserTokenData, error) { + var obj UserTokenData + err := utils.FromJSON(t.Data, &obj) + return &obj, err +} + +type ChangeEmailTokenData struct { + UserID uuid.UUID `json:"user_id"` + Email string `json:"email"` +} + +func (t *UserToken) GetChangeEmailTokenData() (*ChangeEmailTokenData, error) { + var obj ChangeEmailTokenData + err := utils.FromJSON(t.Data, &obj) + return &obj, err +} diff --git a/pkg/models/translate.go b/pkg/models/translate.go index cba9f3c20..420e351a5 100644 --- a/pkg/models/translate.go +++ b/pkg/models/translate.go @@ -83,84 +83,84 @@ type stringEnum interface { String() string } -func (d *editDiff) string(old *string, new *string) (oldOut *string, newOut *string) { - if old != nil && (new == nil || *new != *old) { - oldVal := *old - oldOut = &oldVal +func (d *editDiff) string(oldVal *string, newVal *string) (oldOut *string, newOut *string) { + if oldVal != nil && (newVal == nil || *newVal != *oldVal) { + value := *oldVal + oldOut = &value } - if new != nil && (old == nil || *new != *old) { - newVal := *new - newOut = &newVal + if newVal != nil && (oldVal == nil || *newVal != *oldVal) { + value := *newVal + newOut = &value } return } -func (d *editDiff) nullString(old sql.NullString, new *string) (oldOut *string, newOut *string) { - if old.Valid && (new == nil || *new != old.String) { - oldVal := old.String - oldOut = &oldVal +func (d *editDiff) nullString(oldVal sql.NullString, newVal *string) (oldOut *string, newOut *string) { + if oldVal.Valid && (newVal == nil || *newVal != oldVal.String) { + value := oldVal.String + oldOut = &value } - if new != nil && *new != "" && (!old.Valid || *new != old.String) { - newVal := *new - newOut = &newVal + if newVal != nil && *newVal != "" && (!oldVal.Valid || *newVal != oldVal.String) { + value := *newVal + newOut = &value } return } -func (d *editDiff) nullInt64(old sql.NullInt64, new *int) (oldOut *int64, newOut *int64) { - if old.Valid && (new == nil || int64(*new) != old.Int64) { - oldVal := old.Int64 - oldOut = &oldVal +func (d *editDiff) nullInt64(oldVal sql.NullInt64, newVal *int) (oldOut *int64, newOut *int64) { + if oldVal.Valid && (newVal == nil || int64(*newVal) != oldVal.Int64) { + value := oldVal.Int64 + oldOut = &value } - if new != nil && (!old.Valid || int64(*new) != old.Int64) { - newVal := int64(*new) - newOut = &newVal + if newVal != nil && (!oldVal.Valid || int64(*newVal) != oldVal.Int64) { + value := int64(*newVal) + newOut = &value } return } -func (d *editDiff) nullUUID(old uuid.NullUUID, new *uuid.UUID) (oldOut *uuid.UUID, newOut *uuid.UUID) { - if old.Valid && (new == nil || *new != old.UUID) { - oldOut = &old.UUID +func (d *editDiff) nullUUID(oldVal uuid.NullUUID, newVal *uuid.UUID) (oldOut *uuid.UUID, newOut *uuid.UUID) { + if oldVal.Valid && (newVal == nil || *newVal != oldVal.UUID) { + oldOut = &oldVal.UUID } - if new != nil && (!old.Valid || *new != old.UUID) { - newOut = new + if newVal != nil && (!oldVal.Valid || *newVal != oldVal.UUID) { + newOut = newVal } return } -func (d *editDiff) nullStringEnum(old sql.NullString, new stringEnum) (oldOut *string, newOut *string) { - newNil := reflect.ValueOf(new).IsNil() +func (d *editDiff) nullStringEnum(oldVal sql.NullString, newVal stringEnum) (oldOut *string, newOut *string) { + newNil := reflect.ValueOf(newVal).IsNil() - if old.Valid && (newNil || !new.IsValid() || new.String() != old.String) { - oldVal := old.String - oldOut = &oldVal + if oldVal.Valid && (newNil || !newVal.IsValid() || newVal.String() != oldVal.String) { + value := oldVal.String + oldOut = &value } - if !newNil && new.IsValid() && (!old.Valid || new.String() != old.String) { - newVal := new.String() - newOut = &newVal + if !newNil && newVal.IsValid() && (!oldVal.Valid || newVal.String() != oldVal.String) { + value := newVal.String() + newOut = &value } return } -func (d *editDiff) fuzzyDate(oldDate SQLDate, oldAcc sql.NullString, new *string) (outOldDate, outOldAcc, outNewDate, outNewAcc *string) { - if new == nil && oldDate.Valid { +func (d *editDiff) fuzzyDate(oldDate SQLDate, oldAcc sql.NullString, newVal *string) (outOldDate, outOldAcc, outNewDate, outNewAcc *string) { + if newVal == nil && oldDate.Valid { outOldDate = &oldDate.String if oldAcc.Valid { outOldAcc = &oldAcc.String } - } else if new != nil { - newDate, newAccuracy, _ := ParseFuzzyString(new) + } else if newVal != nil { + newDate, newAccuracy, _ := ParseFuzzyString(newVal) if !oldDate.Valid || newDate.String != oldDate.String || newAccuracy.String != oldAcc.String { outNewDate = &newDate.String newAccuracy := newAccuracy.String @@ -178,15 +178,15 @@ func (d *editDiff) fuzzyDate(oldDate SQLDate, oldAcc sql.NullString, new *string } //nolint:unused -func (d *editDiff) sqlDate(old SQLDate, new *string) (oldOut *string, newOut *string) { - if old.Valid && (new == nil || *new != old.String) { - oldVal := old.String - oldOut = &oldVal +func (d *editDiff) sqlDate(old SQLDate, newVal *string) (oldOut *string, newOut *string) { + if old.Valid && (newVal == nil || *newVal != old.String) { + value := old.String + oldOut = &value } - if new != nil && (!old.Valid || *new != old.String) { - newVal := *new - newOut = &newVal + if newVal != nil && (!old.Valid || *newVal != old.String) { + value := *newVal + newOut = &value } return diff --git a/pkg/sqlx/factory.go b/pkg/sqlx/factory.go index 62e0548f1..73a6afdaa 100644 --- a/pkg/sqlx/factory.go +++ b/pkg/sqlx/factory.go @@ -40,8 +40,8 @@ func (f *repo) Joins() models.JoinsRepo { return newJoinsQueryBuilder(f.txnState) } -func (f *repo) PendingActivation() models.PendingActivationRepo { - return newPendingActivationQueryBuilder(f.txnState) +func (f *repo) UserToken() models.UserTokenRepo { + return newUserTokenQueryBuilder(f.txnState) } func (f *repo) Invite() models.InviteKeyRepo { diff --git a/pkg/sqlx/querybuilder_invite_key.go b/pkg/sqlx/querybuilder_invite_key.go index 7e3ef347d..a3af3901e 100644 --- a/pkg/sqlx/querybuilder_invite_key.go +++ b/pkg/sqlx/querybuilder_invite_key.go @@ -110,14 +110,13 @@ func (qb *inviteKeyQueryBuilder) Find(id uuid.UUID) (*models.InviteKey, error) { func (qb *inviteKeyQueryBuilder) FindActiveKeysForUser(userID uuid.UUID, expireTime time.Time) (models.InviteKeys, error) { query := `SELECT i.* FROM ` + inviteKeyTable + ` i LEFT JOIN ( - SELECT invite_key, COUNT(*) as count - FROM pending_activations - WHERE time > ? + SELECT uuid(data->>'invite_key') as invite_key, COUNT(*) as count + FROM user_tokens + WHERE expires_at > now() GROUP BY invite_key ) a ON a.invite_key = i.id WHERE i.generated_by = ? AND (a.invite_key IS NULL OR i.uses IS NULL OR a.count < i.uses)` var args []interface{} - args = append(args, expireTime) args = append(args, userID) output := inviteKeyRows{} err := qb.dbi.RawQuery(inviteKeyDBTable, query, args, &output) diff --git a/pkg/sqlx/querybuilder_pending_activation.go b/pkg/sqlx/querybuilder_pending_activation.go deleted file mode 100644 index 46f9147cb..000000000 --- a/pkg/sqlx/querybuilder_pending_activation.go +++ /dev/null @@ -1,92 +0,0 @@ -package sqlx - -import ( - "time" - - "github.com/gofrs/uuid" - "github.com/stashapp/stash-box/pkg/models" -) - -const ( - pendingActivationTable = "pending_activations" -) - -var ( - pendingActivationDBTable = newTable(pendingActivationTable, func() interface{} { - return &models.PendingActivation{} - }) -) - -type pendingActivationQueryBuilder struct { - dbi *dbi -} - -func newPendingActivationQueryBuilder(txn *txnState) models.PendingActivationRepo { - return &pendingActivationQueryBuilder{ - dbi: newDBI(txn), - } -} - -func (qb *pendingActivationQueryBuilder) toModel(ro interface{}) *models.PendingActivation { - if ro != nil { - return ro.(*models.PendingActivation) - } - - return nil -} - -func (qb *pendingActivationQueryBuilder) Create(newActivation models.PendingActivation) (*models.PendingActivation, error) { - ret, err := qb.dbi.Insert(pendingActivationDBTable, newActivation) - return qb.toModel(ret), err -} - -func (qb *pendingActivationQueryBuilder) Destroy(id uuid.UUID) error { - return qb.dbi.Delete(id, pendingActivationDBTable) -} - -func (qb *pendingActivationQueryBuilder) DestroyExpired(expireTime time.Time) error { - q := newDeleteQueryBuilder(pendingActivationDBTable) - q.AddWhere("time <= ?") - q.AddArg(expireTime) - return qb.dbi.DeleteQuery(*q) -} - -func (qb *pendingActivationQueryBuilder) Find(id uuid.UUID) (*models.PendingActivation, error) { - ret, err := qb.dbi.Find(id, pendingActivationDBTable) - return qb.toModel(ret), err -} - -func (qb *pendingActivationQueryBuilder) FindByEmail(email string, activationType string) (*models.PendingActivation, error) { - query := `SELECT * FROM ` + pendingActivationTable + ` WHERE email = ? AND type = ?` - var args []interface{} - args = append(args, email) - args = append(args, activationType) - output := models.PendingActivations{} - err := qb.dbi.RawQuery(pendingActivationDBTable, query, args, &output) - if err != nil { - return nil, err - } - - if len(output) > 0 { - return output[0], nil - } - return nil, nil -} - -func (qb *pendingActivationQueryBuilder) FindByInviteKey(key string, activationType string) ([]*models.PendingActivation, error) { - query := `SELECT * FROM ` + pendingActivationTable + ` WHERE invite_key = ? AND type = ?` - var args []interface{} - args = append(args, key) - args = append(args, activationType) - output := models.PendingActivations{} - err := qb.dbi.RawQuery(pendingActivationDBTable, query, args, &output) - if err != nil { - return nil, err - } - - return output, nil -} - -func (qb *pendingActivationQueryBuilder) Count() (int, error) { - return runCountQuery(qb.dbi.db(), buildCountQuery("SELECT "+pendingActivationTable+".id FROM "+pendingActivationTable), nil) -} diff --git a/pkg/sqlx/querybuilder_user_token.go b/pkg/sqlx/querybuilder_user_token.go new file mode 100644 index 000000000..5e3973be2 --- /dev/null +++ b/pkg/sqlx/querybuilder_user_token.go @@ -0,0 +1,73 @@ +package sqlx + +import ( + "fmt" + + "github.com/gofrs/uuid" + "github.com/stashapp/stash-box/pkg/models" +) + +const ( + userTokenTable = "user_tokens" +) + +var ( + userTokenDBTable = newTable(userTokenTable, func() interface{} { + return &models.UserToken{} + }) +) + +type userTokenQueryBuilder struct { + dbi *dbi +} + +func newUserTokenQueryBuilder(txn *txnState) models.UserTokenRepo { + return &userTokenQueryBuilder{ + dbi: newDBI(txn), + } +} + +func (qb *userTokenQueryBuilder) toModel(ro interface{}) *models.UserToken { + if ro != nil { + return ro.(*models.UserToken) + } + + return nil +} + +func (qb *userTokenQueryBuilder) Create(newActivation models.UserToken) (*models.UserToken, error) { + ret, err := qb.dbi.Insert(userTokenDBTable, newActivation) + return qb.toModel(ret), err +} + +func (qb *userTokenQueryBuilder) Destroy(id uuid.UUID) error { + return qb.dbi.Delete(id, userTokenDBTable) +} + +func (qb *userTokenQueryBuilder) DestroyExpired() error { + q := newDeleteQueryBuilder(userTokenDBTable) + q.AddWhere("expires_at <= now()") + return qb.dbi.DeleteQuery(*q) +} + +func (qb *userTokenQueryBuilder) Find(id uuid.UUID) (*models.UserToken, error) { + ret, err := qb.dbi.Find(id, userTokenDBTable) + return qb.toModel(ret), err +} + +func (qb *userTokenQueryBuilder) FindByInviteKey(key uuid.UUID) ([]*models.UserToken, error) { + query := fmt.Sprintf("SELECT * FROM %s WHERE data->>'invite_key' = ?", userTokenTable) + var args []interface{} + args = append(args, key) + output := models.UserTokens{} + err := qb.dbi.RawQuery(userTokenDBTable, query, args, &output) + if err != nil { + return nil, err + } + + return output, nil +} + +func (qb *userTokenQueryBuilder) Count() (int, error) { + return runCountQuery(qb.dbi.db(), buildCountQuery("SELECT "+userTokenTable+".id FROM "+userTokenTable), nil) +} diff --git a/pkg/user/activation.go b/pkg/user/activation.go index f0ad41895..4ea7604b3 100644 --- a/pkg/user/activation.go +++ b/pkg/user/activation.go @@ -3,7 +3,6 @@ package user import ( "errors" "math/rand" - "net/url" "time" "github.com/gofrs/uuid" @@ -14,19 +13,14 @@ import ( var ErrInvalidActivationKey = errors.New("invalid activation key") +var tokenLifetime = time.Minute * 15 + // NewUser registers a new user. It returns the activation key only if // email verification is not required, otherwise it returns nil. -func NewUser(fac models.Repo, em *email.Manager, email, inviteKey string) (*string, error) { - if err := ClearExpiredActivations(fac); err != nil { - return nil, err - } - if err := ClearExpiredInviteKeys(fac); err != nil { - return nil, err - } - +func NewUser(fac models.Repo, em *email.Manager, email string, inviteKey *uuid.UUID) (*uuid.UUID, error) { // ensure user or pending activation with email does not already exist uqb := fac.User() - aqb := fac.PendingActivation() + tqb := fac.UserToken() iqb := fac.Invite() if err := validateUserEmail(email); err != nil { @@ -37,35 +31,22 @@ func NewUser(fac models.Repo, em *email.Manager, email, inviteKey string) (*stri return nil, err } - // if existing activation exists with the same email, then re-create it - a, err := aqb.FindByEmail(email, models.PendingActivationTypeNewUser) - if err != nil { - return nil, err - } - - if a != nil { - if err := aqb.Destroy(a.ID); err != nil { - return nil, err - } - } - - inviteID, err := validateInviteKey(iqb, aqb, inviteKey) - if err != nil { + if err := validateInviteKey(iqb, tqb, inviteKey); err != nil { return nil, err } // generate an activation key and email - key, err := generateActivationKey(aqb, email, inviteID) + key, err := generateActivationKey(tqb, email, inviteKey) if err != nil { return nil, err } // if activation is not required, then return the activation key if !config.GetRequireActivation() { - return &key, nil + return key, nil } - if err := sendNewUserEmail(em, email, key); err != nil { + if err := sendNewUserEmail(em, email, *key); err != nil { return nil, err } @@ -85,111 +66,95 @@ func validateExistingEmail(f models.UserFinder, email string) error { return nil } -func validateInviteKey(iqb models.InviteKeyFinder, aqb models.PendingActivationFinder, inviteKey string) (uuid.NullUUID, error) { - var ret uuid.NullUUID +func validateInviteKey(iqb models.InviteKeyFinder, tqb models.UserTokenFinder, inviteKey *uuid.UUID) error { if config.GetRequireInvite() { - if inviteKey == "" { - return ret, errors.New("invite key required") + if inviteKey == nil { + return errors.New("invite key required") } - var err error - ret.UUID, _ = uuid.FromString(inviteKey) - ret.Valid = true - - key, err := iqb.Find(ret.UUID) + key, err := iqb.Find(*inviteKey) if err != nil { - return ret, err + return err } if key == nil { - return ret, errors.New("invalid invite key") + return errors.New("invalid invite key") } // ensure invite key is not expired if key.Expires != nil && key.Expires.Before(time.Now()) { - return ret, errors.New("invite key expired") + return errors.New("invite key expired") } // ensure key isn't already used - a, err := aqb.FindByInviteKey(inviteKey, models.PendingActivationTypeNewUser) + t, err := tqb.FindByInviteKey(*inviteKey) if err != nil { - return ret, err + return err } - if key.Uses != nil && len(a) >= *key.Uses { - return ret, errors.New("key already used") + if key.Uses != nil && len(t) >= *key.Uses { + return errors.New("key already used") } } - return ret, nil + return nil } -func generateActivationKey(aqb models.PendingActivationCreator, email string, inviteKey uuid.NullUUID) (string, error) { +func generateActivationKey(tqb models.UserTokenCreator, email string, inviteKey *uuid.UUID) (*uuid.UUID, error) { UUID, err := uuid.NewV4() if err != nil { - return "", err + return nil, err } - activation := models.PendingActivation{ + activation := models.UserToken{ ID: UUID, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(config.GetActivationExpiry()), + Type: models.UserTokenTypeNewUser, + } + + err = activation.SetData(models.NewUserTokenData{ Email: email, InviteKey: inviteKey, - Time: time.Now(), - Type: models.PendingActivationTypeNewUser, + }) + if err != nil { + return nil, err } - obj, err := aqb.Create(activation) + token, err := tqb.Create(activation) if err != nil { - return "", err + return nil, err } - return obj.ID.String(), nil + return &token.ID, nil } -func ClearExpiredActivations(fac models.Repo) error { - expireTime := config.GetActivationExpireTime() - - aqb := fac.PendingActivation() - return aqb.DestroyExpired(expireTime) -} - -func ClearExpiredInviteKeys(fac models.Repo) error { +func ActivateNewUser(fac models.Repo, name string, id uuid.UUID, password string) (*models.User, error) { + uqb := fac.User() + tqb := fac.UserToken() iqb := fac.Invite() - return iqb.DestroyExpired() -} - -func sendNewUserEmail(em *email.Manager, email, activationKey string) error { - subject := "Subject: Activate stash-box account" - - link := config.GetHostURL() + "/activate?email=" + url.QueryEscape(email) + "&key=" + activationKey - body := "Please click the following link to activate your account: " + link - - return em.Send(email, subject, body) -} -func ActivateNewUser(fac models.Repo, name, email, activationKey, password string) (*models.User, error) { - if err := ClearExpiredActivations(fac); err != nil { + t, err := tqb.Find(id) + if err != nil { return nil, err } - id, _ := uuid.FromString(activationKey) - - uqb := fac.User() - aqb := fac.PendingActivation() - iqb := fac.Invite() - - a, err := aqb.Find(id) + data, err := t.GetNewUserTokenData() if err != nil { return nil, err } - if a == nil || a.Email != email || a.Type != models.PendingActivationTypeNewUser { + if t == nil || t.Type != models.UserTokenTypeNewUser { return nil, ErrInvalidActivationKey } var invitedBy *uuid.UUID if config.GetRequireInvite() { - i, err := iqb.Find(a.InviteKey.UUID) + if data.InviteKey == nil { + return nil, errors.New("cannot find invite key") + } + + i, err := iqb.Find(*data.InviteKey) if err != nil { return nil, err } @@ -203,7 +168,7 @@ func ActivateNewUser(fac models.Repo, name, email, activationKey, password strin createInput := models.UserCreateInput{ Name: name, - Email: email, + Email: data.Email, Password: password, InvitedByID: invitedBy, Roles: getDefaultUserRoles(), @@ -229,13 +194,13 @@ func ActivateNewUser(fac models.Repo, name, email, activationKey, password strin } // delete the activation - if err := aqb.Destroy(id); err != nil { + if err := tqb.Destroy(id); err != nil { return nil, err } if config.GetRequireInvite() { // decrement the invite key uses - usesLeft, err := iqb.KeyUsed(a.InviteKey.UUID) + usesLeft, err := iqb.KeyUsed(*data.InviteKey) if err != nil { return nil, err } @@ -243,7 +208,7 @@ func ActivateNewUser(fac models.Repo, name, email, activationKey, password strin // if all used up, then delete the invite key if usesLeft != nil && *usesLeft <= 0 { // delete the invite key - if err := iqb.Destroy(a.InviteKey.UUID); err != nil { + if err := iqb.Destroy(*data.InviteKey); err != nil { return nil, err } } @@ -255,7 +220,7 @@ func ActivateNewUser(fac models.Repo, name, email, activationKey, password strin // ResetPassword generates an email to reset a users password. func ResetPassword(fac models.Repo, em *email.Manager, email string) error { uqb := fac.User() - aqb := fac.PendingActivation() + tqb := fac.UserToken() // ensure user exists u, err := uqb.FindByEmail(email) @@ -272,77 +237,62 @@ func ResetPassword(fac models.Repo, em *email.Manager, email string) error { return nil } - // if existing activation exists with the same email, then re-create it - a, err := aqb.FindByEmail(email, models.PendingActivationTypeResetPassword) - if err != nil { - return err - } - - if a != nil { - if err := aqb.Destroy(a.ID); err != nil { - return err - } - } - // generate an activation key and email - key, err := generateResetPasswordActivationKey(aqb, email) + key, err := generateResetPasswordActivationKey(tqb, u.ID) if err != nil { return err } - return sendResetPasswordEmail(em, email, key) + return sendResetPasswordEmail(em, u, *key) } -func generateResetPasswordActivationKey(aqb models.PendingActivationCreator, email string) (string, error) { +func generateResetPasswordActivationKey(aqb models.UserTokenCreator, userID uuid.UUID) (*uuid.UUID, error) { UUID, err := uuid.NewV4() if err != nil { - return "", err + return nil, err } - activation := models.PendingActivation{ - ID: UUID, - Email: email, - Time: time.Now(), - Type: models.PendingActivationTypeResetPassword, + activation := models.UserToken{ + ID: UUID, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(tokenLifetime), + Type: models.UserTokenTypeResetPassword, } - obj, err := aqb.Create(activation) + err = activation.SetData(models.UserTokenData{ + UserID: userID, + }) if err != nil { - return "", err + return nil, err } - return obj.ID.String(), nil -} - -func sendResetPasswordEmail(em *email.Manager, email, activationKey string) error { - subject := "Subject: Reset stash-box password" - - link := config.GetHostURL() + "/resetPassword?email=" + email + "&key=" + activationKey - body := "Please click the following link to set your account password: " + link - - return em.Send(email, subject, body) -} - -func ActivateResetPassword(fac models.Repo, activationKey string, newPassword string) error { - if err := ClearExpiredActivations(fac); err != nil { - return err + obj, err := aqb.Create(activation) + if err != nil { + return nil, err } - id, _ := uuid.FromString(activationKey) + return &obj.ID, nil +} +func ActivateResetPassword(fac models.Repo, id uuid.UUID, newPassword string) error { uqb := fac.User() - aqb := fac.PendingActivation() + tqb := fac.UserToken() - a, err := aqb.Find(id) + t, err := tqb.Find(id) if err != nil { return err } - if a == nil || a.Type != models.PendingActivationTypeResetPassword { + if t == nil || t.Type != models.UserTokenTypeResetPassword { return ErrInvalidActivationKey } - user, err := uqb.FindByEmail(a.Email) + data, err := t.GetUserTokenData() + if err != nil { + return err + } + + user, err := uqb.Find(data.UserID) if err != nil { return err } @@ -368,5 +318,5 @@ func ActivateResetPassword(fac models.Repo, activationKey string, newPassword st } // delete the activation - return aqb.Destroy(id) + return tqb.Destroy(id) } diff --git a/pkg/user/email.go b/pkg/user/email.go new file mode 100644 index 000000000..20841b922 --- /dev/null +++ b/pkg/user/email.go @@ -0,0 +1,205 @@ +package user + +import ( + "bytes" + "embed" + "fmt" + "text/template" + "time" + + "github.com/gofrs/uuid" + "github.com/stashapp/stash-box/pkg/email" + "github.com/stashapp/stash-box/pkg/manager/config" + "github.com/stashapp/stash-box/pkg/models" +) + +//go:embed templates/*.html +//go:embed templates/*.txt +var templateFS embed.FS + +var emailChangeTokenLifetime = time.Minute * 15 + +func ConfirmOldEmail(fac models.Repo, em *email.Manager, user models.User) error { + tqb := fac.UserToken() + + // generate an activation key and email + key, err := generateConfirmOldEmailKey(tqb, user.ID) + if err != nil { + return err + } + + return sendConfirmOldEmail(em, user, *key) +} + +func generateConfirmOldEmailKey(aqb models.UserTokenCreator, userID uuid.UUID) (*uuid.UUID, error) { + UUID, err := uuid.NewV4() + if err != nil { + return nil, err + } + + activation := models.UserToken{ + ID: UUID, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(emailChangeTokenLifetime), + Type: models.UserTokenTypeConfirmOldEmail, + } + + if err := activation.SetData(models.UserTokenData{ + UserID: userID, + }); err != nil { + return nil, err + } + + obj, err := aqb.Create(activation) + if err != nil { + return nil, err + } + + return &obj.ID, nil +} + +func ConfirmNewEmail(fac models.Repo, em *email.Manager, user models.User, email string) error { + tqb := fac.UserToken() + + // generate an activation key and email + key, err := generateConfirmNewEmailKey(tqb, user.ID, email) + if err != nil { + return err + } + + return sendConfirmNewEmail(em, &user, email, *key) +} + +func generateConfirmNewEmailKey(aqb models.UserTokenCreator, userID uuid.UUID, email string) (*uuid.UUID, error) { + UUID, err := uuid.NewV4() + if err != nil { + return nil, err + } + + activation := models.UserToken{ + ID: UUID, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(emailChangeTokenLifetime), + Type: models.UserTokenTypeConfirmNewEmail, + } + + err = activation.SetData(models.ChangeEmailTokenData{ + UserID: userID, + Email: email, + }) + if err != nil { + return nil, err + } + + obj, err := aqb.Create(activation) + if err != nil { + return nil, err + } + + return &obj.ID, nil +} + +func ChangeEmail(fac models.Repo, token models.ChangeEmailTokenData) error { + uqb := fac.User() + + user, err := uqb.Find(token.UserID) + if err != nil { + return err + } + + user.Email = token.Email + user.UpdatedAt = time.Now() + + _, err = uqb.Update(*user) + return err +} + +func sendTemplatedEmail(em *email.Manager, email, subject, preHeader, greeting, content, link, cta string) error { + htmlTemplates, err := template.ParseFS(templateFS, + "templates/email.html", + ) + if err != nil { + return err + } + + data := struct { + SiteName string + SiteURL string + Content string + ActionURL string + ActionText string + Greeting string + PreHeader string + }{ + SiteURL: config.GetHostURL(), + SiteName: config.GetTitle(), + Content: content, + ActionURL: link, + ActionText: cta, + Greeting: greeting, + PreHeader: preHeader, + } + + var html bytes.Buffer + if err := htmlTemplates.Execute(&html, data); err != nil { + return err + } + + textTemplate, err := template.ParseFS(templateFS, + "templates/email.txt", + ) + if err != nil { + return err + } + + var text bytes.Buffer + if err := textTemplate.Execute(&text, data); err != nil { + return err + } + + return em.Send(email, subject, text.String(), html.String()) +} + +func sendConfirmOldEmail(em *email.Manager, user models.User, activationKey uuid.UUID) error { + subject := "Email change requested" + link := fmt.Sprintf("%s/users/%s/change-email?key=%s", config.GetHostURL(), user.Name, activationKey) + preHeader := "Confirm you want to change your email." + greeting := fmt.Sprintf("Hi %s,", user.Name) + content := "An email change was requested for your account. Click the button below to confirm you want to continue. <strong>The link is only valid for 15 minutes.</strong>" + cta := "Confirm email change" + + return sendTemplatedEmail(em, user.Email, subject, preHeader, greeting, content, link, cta) +} + +func sendNewUserEmail(em *email.Manager, email string, activationKey uuid.UUID) error { + subject := "Activate your account" + link := fmt.Sprintf("%s/activate?key=%s", config.GetHostURL(), activationKey) + preHeader := fmt.Sprintf("Welcome, to activate your %s account, click the button below.", config.GetTitle()) + greeting := "Welcome!" + content := fmt.Sprintf("To activate your %s account, click the button below. <strong>The activation link is valid for %s.</strong>", config.GetTitle(), config.GetActivationExpiry()) + cta := "Activate account" + + return sendTemplatedEmail(em, email, subject, preHeader, greeting, content, link, cta) +} + +func sendResetPasswordEmail(em *email.Manager, user *models.User, activationKey uuid.UUID) error { + subject := fmt.Sprintf("Confirm %s password reset", config.GetTitle()) + link := fmt.Sprintf("%s/reset-password?key=%s", config.GetHostURL(), activationKey) + preHeader := fmt.Sprintf("A password reset was requested for your %s account. Click the button to continue.", config.GetTitle()) + greeting := fmt.Sprintf("Hi %s,", user.Name) + content := fmt.Sprintf("A password reset was requested for your %s account. Click the button below to continue. <strong>The link is only valid for 15 minutes.</strong>", config.GetTitle()) + cta := "Reset password" + + return sendTemplatedEmail(em, user.Email, subject, preHeader, greeting, content, link, cta) +} + +func sendConfirmNewEmail(em *email.Manager, user *models.User, email string, activationKey uuid.UUID) error { + subject := fmt.Sprintf("Confirm %s email change", config.GetTitle()) + link := fmt.Sprintf("%s/users/%s/confirm-email?key=%s", config.GetHostURL(), user.Name, activationKey) + preHeader := fmt.Sprintf("To confirm you want to change your %s account email, click the button to continue.", config.GetTitle()) + greeting := fmt.Sprintf("Hi %s,", user.Name) + content := fmt.Sprintf("To confirm you want to change your %s account email, click the button to continue. <strong>The link is only valid for 15 minutes.</strong>", config.GetTitle()) + cta := "Confirm email change" + + return sendTemplatedEmail(em, email, subject, preHeader, greeting, content, link, cta) +} diff --git a/pkg/user/templates/email.html b/pkg/user/templates/email.html new file mode 100644 index 000000000..4e671967f --- /dev/null +++ b/pkg/user/templates/email.html @@ -0,0 +1,514 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <meta name="x-apple-disable-message-reformatting" /> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> + <meta name="color-scheme" content="light dark" /> + <meta name="supported-color-schemes" content="light dark" /> + <title> + + + + + {{ .PreHeader }} + + + + + + + diff --git a/pkg/user/templates/email.txt b/pkg/user/templates/email.txt new file mode 100644 index 000000000..807ad0c08 --- /dev/null +++ b/pkg/user/templates/email.txt @@ -0,0 +1,9 @@ +************ +{{ .Greeting }} +************ + +{{ .Content }} + +{{ .ActionURL }} + +- {{ .SiteName }} diff --git a/pkg/user/user.go b/pkg/user/user.go index 02edbed8c..a0097f8ad 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -28,6 +28,7 @@ var ( ErrUserNotExist = errors.New("user not found") ErrEmptyUsername = errors.New("empty username") ErrUsernameHasWhitespace = errors.New("username has leading or trailing whitespace") + ErrUsernameMatchesEmail = errors.New("username is the same as email") ErrEmptyEmail = errors.New("empty email") ErrEmailHasWhitespace = errors.New("email has leading or trailing whitespace") ErrInvalidEmail = errors.New("not a valid email address") @@ -57,7 +58,7 @@ var modUserRoles []models.RoleEnum = []models.RoleEnum{ func ValidateCreate(input models.UserCreateInput) error { // username must be set - err := validateUserName(input.Name) + err := validateUserName(input.Name, &input.Email) if err != nil { return err } @@ -99,7 +100,7 @@ func ValidateUpdate(input models.UserUpdateInput, current models.User) error { if input.Name != nil { currentName = *input.Name - err := validateUserName(*input.Name) + err := validateUserName(*input.Name, input.Email) if err != nil { return err } @@ -135,7 +136,7 @@ func ValidateDestroy(user *models.User) error { return nil } -func validateUserName(username string) error { +func validateUserName(username string, email *string) error { if username == "" { return ErrEmptyUsername } @@ -147,6 +148,10 @@ func validateUserName(username string) error { return ErrUsernameHasWhitespace } + if email != nil && *email == trimmed { + return ErrUsernameMatchesEmail + } + return nil } diff --git a/pkg/utils/json.go b/pkg/utils/json.go new file mode 100644 index 000000000..7d5af5745 --- /dev/null +++ b/pkg/utils/json.go @@ -0,0 +1,23 @@ +package utils + +import ( + "bytes" + "encoding/json" + + "github.com/jmoiron/sqlx/types" +) + +func ToJSON(data interface{}) (types.JSONText, error) { + buffer := &bytes.Buffer{} + encoder := json.NewEncoder(buffer) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + if err := encoder.Encode(data); err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func FromJSON(data types.JSONText, obj interface{}) error { + return json.Unmarshal(data, obj) +}