Add user email changing, and refactor activation token system (#831)

This commit is contained in:
InfiniteStash
2024-11-18 23:36:48 +01:00
committed by GitHub
parent 3ad64db41e
commit 96c1f77c42
40 changed files with 2297 additions and 465 deletions

View File

@@ -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";

View File

@@ -0,0 +1,3 @@
mutation ConfirmChangeEmail($token: ID!) {
confirmChangeEmail(token: $token)
}

View File

@@ -0,0 +1,3 @@
mutation RequestChangeEmail {
requestChangeEmail
}

View File

@@ -0,0 +1,3 @@
mutation ValidateChangeEmail($token: ID!, $email: String!) {
validateChangeEmail(token: $token, email: $email)
}

View File

@@ -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);

View File

@@ -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: [

View File

@@ -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;
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View 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;

View 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;

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -4,7 +4,7 @@ import (
"github.com/jmoiron/sqlx"
)
var appSchemaVersion uint = 36
var appSchemaVersion uint = 37
var databaseProviders map[string]databaseProvider

View 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
);

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -18,7 +18,7 @@ type Repo interface {
Joins() JoinsRepo
PendingActivation() PendingActivationRepo
UserToken() UserTokenRepo
Invite() InviteKeyRepo
User() UserRepo
Site() SiteRepo

View File

@@ -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)

View File

@@ -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 (

View File

@@ -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))
}

View 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
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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)
}

View 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)
}

View File

@@ -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
View 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)
}

View 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 youre 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>

View File

@@ -0,0 +1,9 @@
************
{{ .Greeting }}
************
{{ .Content }}
{{ .ActionURL }}
- {{ .SiteName }}

View File

@@ -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
View 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)
}