mirror of
https://github.com/BillyOutlast/stash-box.git
synced 2026-02-04 02:51:17 +01:00
Add user email changing, and refactor activation token system (#831)
This commit is contained in:
@@ -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";
|
||||
|
||||
3
frontend/src/graphql/mutations/ConfirmChangeEmail.gql
Normal file
3
frontend/src/graphql/mutations/ConfirmChangeEmail.gql
Normal file
@@ -0,0 +1,3 @@
|
||||
mutation ConfirmChangeEmail($token: ID!) {
|
||||
confirmChangeEmail(token: $token)
|
||||
}
|
||||
3
frontend/src/graphql/mutations/RequestChangeEmail.gql
Normal file
3
frontend/src/graphql/mutations/RequestChangeEmail.gql
Normal file
@@ -0,0 +1,3 @@
|
||||
mutation RequestChangeEmail {
|
||||
requestChangeEmail
|
||||
}
|
||||
3
frontend/src/graphql/mutations/ValidateChangeEmail.gql
Normal file
3
frontend/src/graphql/mutations/ValidateChangeEmail.gql
Normal file
@@ -0,0 +1,3 @@
|
||||
mutation ValidateChangeEmail($token: ID!, $email: String!) {
|
||||
validateChangeEmail(token: $token, email: $email)
|
||||
}
|
||||
@@ -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<RequestChangeEmailMutation>
|
||||
) => useMutation(RequestChangeEmailGQL, options);
|
||||
|
||||
@@ -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<Image>;
|
||||
imageDestroy: Scalars["Boolean"];
|
||||
/** User interface for registering */
|
||||
newUser?: Maybe<Scalars["String"]>;
|
||||
newUser?: Maybe<Scalars["ID"]>;
|
||||
performerCreate?: Maybe<Performer>;
|
||||
performerDestroy: Scalars["Boolean"];
|
||||
/** Propose a new performer or modification to a performer */
|
||||
@@ -472,6 +472,8 @@ export type Mutation = {
|
||||
performerUpdate?: Maybe<Performer>;
|
||||
/** 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<User>;
|
||||
userDestroy: Scalars["Boolean"];
|
||||
userUpdate?: Maybe<User>;
|
||||
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<Scalars["String"]>;
|
||||
invite_key?: InputMaybe<Scalars["ID"]>;
|
||||
};
|
||||
|
||||
export enum OperationEnum {
|
||||
@@ -1779,11 +1791,26 @@ export type User = {
|
||||
vote_count: UserVoteCount;
|
||||
};
|
||||
|
||||
export type UserChangeEmailInput = {
|
||||
existing_email_token?: InputMaybe<Scalars["ID"]>;
|
||||
new_email?: InputMaybe<Scalars["String"]>;
|
||||
new_email_token?: InputMaybe<Scalars["ID"]>;
|
||||
};
|
||||
|
||||
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<Scalars["String"]>;
|
||||
new_password: Scalars["String"];
|
||||
reset_key?: InputMaybe<Scalars["String"]>;
|
||||
reset_key?: InputMaybe<Scalars["ID"]>;
|
||||
};
|
||||
|
||||
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<UpdateUserMutation, UpdateUserMutationVariables>;
|
||||
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: [
|
||||
|
||||
@@ -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<string, ParamConfig>;
|
||||
|
||||
export type QueryParams<T extends QueryParamConfig> = {
|
||||
@@ -38,6 +43,8 @@ export type QueryParams<T extends QueryParamConfig> = {
|
||||
? 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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
<Form.Control
|
||||
type="hidden"
|
||||
value={query.get("email") ?? ""}
|
||||
{...register("email")}
|
||||
/>
|
||||
<Form.Control
|
||||
type="hidden"
|
||||
value={query.get("key") ?? ""}
|
||||
@@ -96,6 +80,8 @@ const ActivateNewUserPage: FC = () => {
|
||||
/>
|
||||
|
||||
<Form.Group controlId="name">
|
||||
<h3>Register account</h3>
|
||||
<hr className="my-4" />
|
||||
<Row>
|
||||
<Col xs={4}>
|
||||
<Form.Label>Username:</Form.Label>
|
||||
|
||||
@@ -94,6 +94,8 @@ const Register: FC<Props> = ({ config }) => {
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<Form.Group controlId="email">
|
||||
<h3>Register account</h3>
|
||||
<hr className="my-4" />
|
||||
<Row>
|
||||
<Col xs={4}>
|
||||
<Form.Label>Email:</Form.Label>
|
||||
|
||||
@@ -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<typeof schema>;
|
||||
|
||||
@@ -41,10 +61,14 @@ const ResetPassword: FC = () => {
|
||||
|
||||
if (Auth.authenticated) navigate(ROUTE_HOME);
|
||||
|
||||
const key = query.get("key");
|
||||
|
||||
if (!key) return <ErrorMessage error="Invalid request" />;
|
||||
|
||||
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)}
|
||||
>
|
||||
<Form.Control
|
||||
type="hidden"
|
||||
value={query.get("email") ?? ""}
|
||||
{...register("email")}
|
||||
/>
|
||||
|
||||
<Form.Control
|
||||
type="hidden"
|
||||
value={query.get("key") ?? ""}
|
||||
{...register("resetKey")}
|
||||
/>
|
||||
<Form.Control type="hidden" value={key} {...register("resetKey")} />
|
||||
|
||||
<Form.Group controlId="password" className="mt-2">
|
||||
<h3>Reset Password</h3>
|
||||
<hr className="my-4" />
|
||||
<Row>
|
||||
<Col xs={4}>
|
||||
<Form.Label>New Password:</Form.Label>
|
||||
</Col>
|
||||
<Col xs={8}>
|
||||
<Form.Control
|
||||
className={cx({ "is-invalid": errors?.password })}
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
{...register("password")}
|
||||
/>
|
||||
<Col>
|
||||
<Form.Group controlId="newPassword" className="mb-3">
|
||||
<Form.Control
|
||||
className={cx({ "is-invalid": errors.newPassword })}
|
||||
type="password"
|
||||
placeholder="New Password"
|
||||
{...register("newPassword")}
|
||||
/>
|
||||
<div className="invalid-feedback">
|
||||
{errors?.newPassword?.message}
|
||||
</div>
|
||||
</Form.Group>
|
||||
<Form.Group controlId="confirmNewPassword" className="mb-3">
|
||||
<Form.Control
|
||||
className={cx({ "is-invalid": errors.confirmNewPassword })}
|
||||
type="password"
|
||||
placeholder="Confirm New Password"
|
||||
{...register("confirmNewPassword")}
|
||||
/>
|
||||
<div className="invalid-feedback">
|
||||
{errors?.confirmNewPassword?.message}
|
||||
</div>
|
||||
</Form.Group>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form.Group>
|
||||
|
||||
@@ -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<Props> = ({ user, refetch }) => {
|
||||
const [showRegenerateAPIKey, setShowRegenerateAPIKey] = useState(false);
|
||||
const [showRescindCode, setShowRescindCode] = useState<string | undefined>();
|
||||
const [showGenerateInviteKey, setShowGenerateInviteKey] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const [deleteUser, { loading: deleting }] = useDeleteUser();
|
||||
const [regenerateAPIKey] = useRegenerateAPIKey();
|
||||
@@ -146,6 +150,7 @@ const UserComponent: FC<Props> = ({ 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<Props> = ({ user, refetch }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleChangeEmail = () => {
|
||||
requestChangeEmail()
|
||||
.then(() => {
|
||||
toast({
|
||||
variant: "success",
|
||||
content: (
|
||||
<>
|
||||
<h5>Change email</h5>
|
||||
<div>Please check your existing email to continue.</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
let message: React.ReactNode | string | undefined =
|
||||
error instanceof Error && isApolloError(error) && error.message;
|
||||
if (message === "pending-email-change")
|
||||
message = (
|
||||
<>
|
||||
<h5>Pending email change</h5>
|
||||
<div>Email change already requested. Please try again later.</div>
|
||||
</>
|
||||
);
|
||||
toast({ variant: "danger", content: message });
|
||||
});
|
||||
};
|
||||
|
||||
const editCount = filterEdits(user.edit_count);
|
||||
const voteCount = filterVotes(user.vote_count);
|
||||
|
||||
@@ -276,6 +308,11 @@ const UserComponent: FC<Props> = ({ user, refetch }) => {
|
||||
<Button>Change Password</Button>
|
||||
</Link>
|
||||
)}
|
||||
{isOwner && (
|
||||
<Button onClick={() => handleChangeEmail()} className="ms-2">
|
||||
Change Email
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin(Auth.user) && (
|
||||
<>
|
||||
<Link to={createHref(ROUTE_USER_EDIT, user)} className="ms-2">
|
||||
|
||||
74
frontend/src/pages/users/UserConfirmChangeEmail.tsx
Normal file
74
frontend/src/pages/users/UserConfirmChangeEmail.tsx
Normal file
@@ -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<string | undefined>();
|
||||
const [{ token }] = useQueryParams({
|
||||
token: { name: "key", type: "string" },
|
||||
});
|
||||
const toast = useToast();
|
||||
|
||||
const [confirmChangeEmail, { loading }] = useConfirmChangeEmail();
|
||||
|
||||
if (!token) return <ErrorMessage error="Missing key" />;
|
||||
if (submitError) return <ErrorMessage error={submitError} />;
|
||||
|
||||
const onSubmit = () => {
|
||||
setSubmitError(undefined);
|
||||
confirmChangeEmail({ variables: { token } })
|
||||
.then((res) => {
|
||||
const status = res.data?.confirmChangeEmail;
|
||||
if (status === "SUCCESS") {
|
||||
toast({
|
||||
variant: "success",
|
||||
content: (
|
||||
<>
|
||||
<h5>Email successfully changed</h5>
|
||||
</>
|
||||
),
|
||||
});
|
||||
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 (
|
||||
<div className="LoginPrompt">
|
||||
<Title page="Confirm Email change" />
|
||||
<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;
|
||||
121
frontend/src/pages/users/UserValidateChangeEmail.tsx
Normal file
121
frontend/src/pages/users/UserValidateChangeEmail.tsx
Normal file
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
17
go.mod
17
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
|
||||
|
||||
54
go.sum
54
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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
input UserChangeEmailInput {
|
||||
existing_email_token: ID
|
||||
new_email_token: ID
|
||||
new_email: String
|
||||
}
|
||||
|
||||
enum UserChangeEmailStatus {
|
||||
CONFIRM_OLD
|
||||
CONFIRM_NEW
|
||||
EXPIRED
|
||||
INVALID_TOKEN
|
||||
SUCCESS
|
||||
ERROR
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
var appSchemaVersion uint = 36
|
||||
var appSchemaVersion uint = 37
|
||||
|
||||
var databaseProviders map[string]databaseProvider
|
||||
|
||||
|
||||
9
pkg/database/migrations/postgres/37_tokens.up.sql
Normal file
9
pkg/database/migrations/postgres/37_tokens.up.sql
Normal file
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ type Repo interface {
|
||||
|
||||
Joins() JoinsRepo
|
||||
|
||||
PendingActivation() PendingActivationRepo
|
||||
UserToken() UserTokenRepo
|
||||
Invite() InviteKeyRepo
|
||||
User() UserRepo
|
||||
Site() SiteRepo
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
81
pkg/models/model_user_tokens.go
Normal file
81
pkg/models/model_user_tokens.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
73
pkg/sqlx/querybuilder_user_token.go
Normal file
73
pkg/sqlx/querybuilder_user_token.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
obj, err := aqb.Create(activation)
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return obj.ID.String(), nil
|
||||
}
|
||||
|
||||
func ClearExpiredActivations(fac models.Repo) error {
|
||||
expireTime := config.GetActivationExpireTime()
|
||||
|
||||
aqb := fac.PendingActivation()
|
||||
return aqb.DestroyExpired(expireTime)
|
||||
}
|
||||
|
||||
func ClearExpiredInviteKeys(fac models.Repo) error {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id, _ := uuid.FromString(activationKey)
|
||||
token, err := tqb.Create(activation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &token.ID, nil
|
||||
}
|
||||
|
||||
func ActivateNewUser(fac models.Repo, name string, id uuid.UUID, password string) (*models.User, error) {
|
||||
uqb := fac.User()
|
||||
aqb := fac.PendingActivation()
|
||||
tqb := fac.UserToken()
|
||||
iqb := fac.Invite()
|
||||
|
||||
a, err := aqb.Find(id)
|
||||
t, err := tqb.Find(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if a == nil || a.Email != email || a.Type != models.PendingActivationTypeNewUser {
|
||||
data, err := t.GetNewUserTokenData()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
err = activation.SetData(models.UserTokenData{
|
||||
UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
obj, err := aqb.Create(activation)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return obj.ID.String(), nil
|
||||
return &obj.ID, 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
|
||||
}
|
||||
|
||||
id, _ := uuid.FromString(activationKey)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
205
pkg/user/email.go
Normal file
205
pkg/user/email.go
Normal file
@@ -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)
|
||||
}
|
||||
514
pkg/user/templates/email.html
Normal file
514
pkg/user/templates/email.html
Normal file
@@ -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></title>
|
||||
<style type="text/css" rel="stylesheet" media="all">
|
||||
/* Base ------------------------------ */
|
||||
|
||||
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap");
|
||||
body {
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
-webkit-text-size-adjust: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #3869D4;
|
||||
}
|
||||
|
||||
a img {
|
||||
border: none;
|
||||
}
|
||||
|
||||
td {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.preheader {
|
||||
display: none !important;
|
||||
visibility: hidden;
|
||||
mso-hide: all;
|
||||
font-size: 1px;
|
||||
line-height: 1px;
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Type ------------------------------ */
|
||||
|
||||
body,
|
||||
td,
|
||||
th {
|
||||
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
color: #333333;
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
color: #333333;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
color: #333333;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
blockquote {
|
||||
margin: .4em 0 1.1875em;
|
||||
font-size: 16px;
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
||||
p.sub {
|
||||
font-size: 13px;
|
||||
}
|
||||
/* Utilities ------------------------------ */
|
||||
|
||||
.align-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.align-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.align-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.u-margin-bottom-none {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
/* Buttons ------------------------------ */
|
||||
|
||||
.button {
|
||||
background-color: #3869D4;
|
||||
border-top: 10px solid #3869D4;
|
||||
border-right: 18px solid #3869D4;
|
||||
border-bottom: 10px solid #3869D4;
|
||||
border-left: 18px solid #3869D4;
|
||||
display: inline-block;
|
||||
color: #FFF !important;
|
||||
text-decoration: none;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
|
||||
-webkit-text-size-adjust: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.button--green {
|
||||
background-color: #22BC66;
|
||||
border-top: 10px solid #22BC66;
|
||||
border-right: 18px solid #22BC66;
|
||||
border-bottom: 10px solid #22BC66;
|
||||
border-left: 18px solid #22BC66;
|
||||
}
|
||||
|
||||
.button--red {
|
||||
background-color: #FF6136;
|
||||
border-top: 10px solid #FF6136;
|
||||
border-right: 18px solid #FF6136;
|
||||
border-bottom: 10px solid #FF6136;
|
||||
border-left: 18px solid #FF6136;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 500px) {
|
||||
.button {
|
||||
width: 100% !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
}
|
||||
/* Attribute list ------------------------------ */
|
||||
|
||||
.attributes {
|
||||
margin: 0 0 21px;
|
||||
}
|
||||
|
||||
.attributes_content {
|
||||
background-color: #F4F4F7;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.attributes_item {
|
||||
padding: 0;
|
||||
}
|
||||
/* Related Items ------------------------------ */
|
||||
|
||||
.related {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 25px 0 0 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
|
||||
.related_item {
|
||||
padding: 10px 0;
|
||||
color: #CBCCCF;
|
||||
font-size: 15px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.related_item-title {
|
||||
display: block;
|
||||
margin: .5em 0 0;
|
||||
}
|
||||
|
||||
.related_item-thumb {
|
||||
display: block;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.related_heading {
|
||||
border-top: 1px solid #CBCCCF;
|
||||
text-align: center;
|
||||
padding: 25px 0 10px;
|
||||
}
|
||||
/* Discount Code ------------------------------ */
|
||||
|
||||
.discount {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
background-color: #F4F4F7;
|
||||
border: 2px dashed #CBCCCF;
|
||||
}
|
||||
|
||||
.discount_heading {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.discount_body {
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
}
|
||||
/* Social Icons ------------------------------ */
|
||||
|
||||
.social {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.social td {
|
||||
padding: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.social_icon {
|
||||
height: 20px;
|
||||
margin: 0 8px 10px 8px;
|
||||
padding: 0;
|
||||
}
|
||||
/* Data table ------------------------------ */
|
||||
|
||||
.purchase {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 35px 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
|
||||
.purchase_content {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 25px 0 0 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
|
||||
.purchase_item {
|
||||
padding: 10px 0;
|
||||
color: #51545E;
|
||||
font-size: 15px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.purchase_heading {
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #EAEAEC;
|
||||
}
|
||||
|
||||
.purchase_heading p {
|
||||
margin: 0;
|
||||
color: #85878E;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.purchase_footer {
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #EAEAEC;
|
||||
}
|
||||
|
||||
.purchase_total {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.purchase_total--label {
|
||||
padding: 0 15px 0 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #F2F4F6;
|
||||
color: #51545E;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #51545E;
|
||||
}
|
||||
|
||||
.email-wrapper {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
background-color: #F2F4F6;
|
||||
}
|
||||
|
||||
.email-content {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
/* Masthead ----------------------- */
|
||||
|
||||
.email-masthead {
|
||||
padding: 25px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.email-masthead_logo {
|
||||
width: 94px;
|
||||
}
|
||||
|
||||
.email-masthead_name {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #A8AAAF !important;
|
||||
text-decoration: none;
|
||||
text-shadow: 0 1px 0 white;
|
||||
}
|
||||
/* Body ------------------------------ */
|
||||
|
||||
.email-body {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
|
||||
.email-body_inner {
|
||||
width: 570px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
-premailer-width: 570px;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.email-footer {
|
||||
width: 570px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
-premailer-width: 570px;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.email-footer p {
|
||||
color: #A8AAAF;
|
||||
}
|
||||
|
||||
.body-action {
|
||||
width: 100%;
|
||||
margin: 30px auto;
|
||||
padding: 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.body-sub {
|
||||
margin-top: 25px;
|
||||
padding-top: 25px;
|
||||
border-top: 1px solid #EAEAEC;
|
||||
}
|
||||
|
||||
.content-cell {
|
||||
padding: 45px;
|
||||
}
|
||||
/*Media Queries ------------------------------ */
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.email-body_inner,
|
||||
.email-footer {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body,
|
||||
.email-body,
|
||||
.email-body_inner,
|
||||
.email-content,
|
||||
.email-wrapper,
|
||||
.email-masthead,
|
||||
.email-footer {
|
||||
background-color: #333333 !important;
|
||||
color: #FFF !important;
|
||||
}
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
blockquote,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
span,
|
||||
.purchase_item {
|
||||
color: #FFF !important;
|
||||
}
|
||||
.attributes_content,
|
||||
.discount {
|
||||
background-color: #222 !important;
|
||||
}
|
||||
.email-masthead_name {
|
||||
text-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
supported-color-schemes: light dark;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
.f-fallback {
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body>
|
||||
<span class="preheader">{{ .PreHeader }}</span>
|
||||
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td class="email-masthead">
|
||||
<!--
|
||||
<a href="{{ .SiteURL }}" class="f-fallback email-masthead_name">
|
||||
{{ .SiteName }}
|
||||
</a>
|
||||
-->
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Email Body -->
|
||||
<tr>
|
||||
<td class="email-body" width="570" cellpadding="0" cellspacing="0">
|
||||
<table class="email-body_inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<!-- Body content -->
|
||||
<tr>
|
||||
<td class="content-cell">
|
||||
<div class="f-fallback">
|
||||
<h1>{{ .Greeting }}</h1>
|
||||
<p>{{ .Content }}</p>
|
||||
<!-- Action -->
|
||||
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<!-- Border based button
|
||||
https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="{{ .ActionURL }}" class="f-fallback button button--green" target="_blank">{{ .ActionText }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- Sub copy -->
|
||||
<table class="body-sub" role="presentation">
|
||||
<tr>
|
||||
<td>
|
||||
<p class="f-fallback sub">If you’re having trouble with the button above, copy and paste the URL below into your web browser.</p>
|
||||
<p class="f-fallback sub">{{ .ActionURL }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td class="content-cell" align="center">
|
||||
<p class="f-fallback sub align-center">
|
||||
This is an automatically generated message. Replies are not monitored or answered.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
9
pkg/user/templates/email.txt
Normal file
9
pkg/user/templates/email.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
************
|
||||
{{ .Greeting }}
|
||||
************
|
||||
|
||||
{{ .Content }}
|
||||
|
||||
{{ .ActionURL }}
|
||||
|
||||
- {{ .SiteName }}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
23
pkg/utils/json.go
Normal file
23
pkg/utils/json.go
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user