mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
feat: Allow users to change email in-app (#8119)
This commit is contained in:
@@ -8,8 +8,8 @@ import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
/** Callback when the dialog is submitted */
|
||||
onSubmit: () => Promise<void> | void;
|
||||
/** Callback when the dialog is submitted. Return false to prevent closing. */
|
||||
onSubmit: () => Promise<void | boolean> | void;
|
||||
/** Text to display on the submit button */
|
||||
submitText?: string;
|
||||
/** Text to display while the form is saving */
|
||||
@@ -38,7 +38,10 @@ const ConfirmationDialog: React.FC<Props> = ({
|
||||
ev.preventDefault();
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSubmit();
|
||||
const res = await onSubmit();
|
||||
if (res === false) {
|
||||
return;
|
||||
}
|
||||
dialogs.closeAllModals();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { UserRole } from "@shared/types";
|
||||
import User from "~/models/User";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Input from "~/components/Input";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Text from "./Text";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
@@ -85,7 +89,11 @@ export function UserSuspendDialog({ user, onSubmit }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog onSubmit={handleSubmit} savingText={`${t("Saving")}…`}>
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
savingText={`${t("Saving")}…`}
|
||||
danger
|
||||
>
|
||||
{t(
|
||||
"Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.",
|
||||
{
|
||||
@@ -123,6 +131,68 @@ export function UserChangeNameDialog({ user, onSubmit }: Props) {
|
||||
onChange={handleChange}
|
||||
error={!name ? t("Name can't be empty") : undefined}
|
||||
value={name}
|
||||
autoSelect
|
||||
required
|
||||
flex
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserChangeEmailDialog({ user, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const actor = useCurrentUser();
|
||||
const [email, setEmail] = React.useState<string>(user.email);
|
||||
const [error, setError] = React.useState<string | undefined>();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await client.post(`/users.updateEmail`, { id: user.id, email });
|
||||
onSubmit();
|
||||
toast.info(
|
||||
actor.id === user.id
|
||||
? t("Check your email to verify the new address.")
|
||||
: t("The email will be changed once verified.")
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(ev.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("Save")}
|
||||
savingText={`${t("Saving")}…`}
|
||||
disabled={!email || email === user.email}
|
||||
>
|
||||
<Text as="p">
|
||||
{actor.id === user.id ? (
|
||||
<Trans>
|
||||
You will receive an email to verify your new address. It must be
|
||||
unique in the workspace.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
A confirmation email will be sent to the new address before it is
|
||||
changed.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
label={t("New email")}
|
||||
onChange={handleChange}
|
||||
error={!email ? t("Email can't be empty") : error}
|
||||
value={email}
|
||||
autoSelect
|
||||
required
|
||||
flex
|
||||
/>
|
||||
|
||||
+25
-1
@@ -11,6 +11,7 @@ import Template from "~/components/ContextMenu/Template";
|
||||
import {
|
||||
UserSuspendDialog,
|
||||
UserChangeNameDialog,
|
||||
UserChangeEmailDialog,
|
||||
} from "~/components/UserDialogs";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import {
|
||||
@@ -49,6 +50,22 @@ function UserMenu({ user }: Props) {
|
||||
[dialogs, t, user]
|
||||
);
|
||||
|
||||
const handleChangeEmail = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
dialogs.openModal({
|
||||
title: t("Change email"),
|
||||
content: (
|
||||
<UserChangeEmailDialog
|
||||
user={user}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
[dialogs, t, user]
|
||||
);
|
||||
|
||||
const handleSuspend = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
@@ -117,7 +134,13 @@ function UserMenu({ user }: Props) {
|
||||
type: "button",
|
||||
title: `${t("Change name")}…`,
|
||||
onClick: handleChangeName,
|
||||
visible: can.update && user.role !== "admin",
|
||||
visible: can.update,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Change email")}…`,
|
||||
onClick: handleChangeEmail,
|
||||
visible: can.update,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
@@ -144,6 +167,7 @@ function UserMenu({ user }: Props) {
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Suspend user")}…`,
|
||||
dangerous: true,
|
||||
onClick: handleSuspend,
|
||||
visible: !user.isInvited && !user.isSuspended,
|
||||
},
|
||||
|
||||
@@ -19,19 +19,22 @@ import { toast } from "sonner";
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import Flex from "~/components/Flex";
|
||||
import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import Notice from "~/components/Notice";
|
||||
import Scene from "~/components/Scene";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
function Notifications() {
|
||||
const user = useCurrentUser();
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(team.id);
|
||||
|
||||
const options = [
|
||||
{
|
||||
@@ -161,17 +164,7 @@ function Notifications() {
|
||||
<Trans>Manage when and where you receive email notifications.</Trans>
|
||||
</Text>
|
||||
|
||||
{env.EMAIL_ENABLED ? (
|
||||
<SettingRow
|
||||
label={t("Email address")}
|
||||
name="email"
|
||||
description={t(
|
||||
"Your email address should be updated in your SSO provider."
|
||||
)}
|
||||
>
|
||||
<Input type="email" value={user.email} readOnly />
|
||||
</SettingRow>
|
||||
) : (
|
||||
{env.EMAIL_ENABLED && can.manage && (
|
||||
<Notice>
|
||||
<Trans>
|
||||
The email integration is currently disabled. Please set the
|
||||
|
||||
@@ -8,14 +8,18 @@ import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import { UserChangeEmailDialog } from "~/components/UserDialogs";
|
||||
import env from "~/env";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import ImageInput from "./components/ImageInput";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
const Profile = () => {
|
||||
const user = useCurrentUser();
|
||||
const { dialogs } = useStores();
|
||||
const form = React.useRef<HTMLFormElement>(null);
|
||||
const [name, setName] = React.useState<string>(user.name || "");
|
||||
const [name, setName] = React.useState<string>(user.name);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async (ev: React.SyntheticEvent) => {
|
||||
@@ -29,6 +33,15 @@ const Profile = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeEmail = () => {
|
||||
dialogs.openModal({
|
||||
title: t("Change email"),
|
||||
content: (
|
||||
<UserChangeEmailDialog user={user} onSubmit={dialogs.closeAllModals} />
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const handleNameChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(ev.target.value);
|
||||
};
|
||||
@@ -81,6 +94,17 @@ const Profile = () => {
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{env.EMAIL_ENABLED && (
|
||||
<SettingRow label={t("Email address")} name="email">
|
||||
<Input
|
||||
type="email"
|
||||
value={user.email}
|
||||
readOnly
|
||||
onClick={handleChangeEmail}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={isSaving || !isValid}>
|
||||
{isSaving ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
|
||||
@@ -437,7 +437,7 @@ describe("userProvisioner", () => {
|
||||
}
|
||||
|
||||
expect(error && error.toString()).toContain(
|
||||
"The domain is not allowed for this team"
|
||||
"The domain is not allowed for this workspace"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { InferCreationAttributes } from "sequelize";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { parseEmail } from "@shared/utils/email";
|
||||
import InviteAcceptedEmail from "@server/emails/templates/InviteAcceptedEmail";
|
||||
import {
|
||||
DomainNotAllowedError,
|
||||
@@ -227,8 +226,7 @@ export default async function userProvisioner({
|
||||
|
||||
// If the team settings do not allow this domain,
|
||||
// throw an error and fail user creation.
|
||||
const { domain } = parseEmail(email);
|
||||
if (team && !(await team.isDomainAllowed(domain))) {
|
||||
if (team && !(await team.isDomainAllowed(email))) {
|
||||
throw DomainNotAllowedError();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as React from "react";
|
||||
import env from "@server/env";
|
||||
import BaseEmail, { EmailMessageCategory, EmailProps } from "./BaseEmail";
|
||||
import Body from "./components/Body";
|
||||
import Button from "./components/Button";
|
||||
import EmailTemplate from "./components/EmailLayout";
|
||||
import EmptySpace from "./components/EmptySpace";
|
||||
import Footer from "./components/Footer";
|
||||
import Header from "./components/Header";
|
||||
import Heading from "./components/Heading";
|
||||
|
||||
type Props = EmailProps & {
|
||||
code: string;
|
||||
previous: string | null;
|
||||
teamUrl: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Email sent to a user when they request to change their email.
|
||||
*/
|
||||
export default class ConfirmUpdateEmail extends BaseEmail<Props> {
|
||||
protected get category() {
|
||||
return EmailMessageCategory.Authentication;
|
||||
}
|
||||
|
||||
protected subject() {
|
||||
return `Your email update request`;
|
||||
}
|
||||
|
||||
protected preview() {
|
||||
return `Here’s your email change confirmation.`;
|
||||
}
|
||||
|
||||
protected renderAsText({ teamUrl, code, previous, to }: Props): string {
|
||||
return `
|
||||
You requested to update your ${env.APP_NAME} account email. Please
|
||||
follow the link below to confirm the change ${
|
||||
previous ? `from ${previous} ` : ""
|
||||
}to ${to}.
|
||||
|
||||
${this.updateLink(teamUrl, code)}
|
||||
`;
|
||||
}
|
||||
|
||||
protected render({ teamUrl, code, previous, to }: Props) {
|
||||
return (
|
||||
<EmailTemplate previewText={this.preview()}>
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>Your email update request</Heading>
|
||||
<p>
|
||||
You requested to update your {env.APP_NAME} account email. Please
|
||||
click below to confirm the change{" "}
|
||||
{previous ? `from ${previous} ` : ""}to <strong>{to}</strong>.
|
||||
</p>
|
||||
<EmptySpace height={5} />
|
||||
<p>
|
||||
<Button href={this.updateLink(teamUrl, code)}>
|
||||
Confirm Change
|
||||
</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
<Footer />
|
||||
</EmailTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
private updateLink(teamUrl: string, code: string): string {
|
||||
return `${teamUrl}/api/users.updateEmail?code=${code}`;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -49,7 +49,7 @@ export function InviteRequiredError(
|
||||
}
|
||||
|
||||
export function DomainNotAllowedError(
|
||||
message = "The domain is not allowed for this team"
|
||||
message = "The domain is not allowed for this workspace"
|
||||
) {
|
||||
return httpErrors(403, message, {
|
||||
id: "domain_not_allowed",
|
||||
|
||||
+10
-2
@@ -27,9 +27,11 @@ import {
|
||||
BeforeCreate,
|
||||
IsNumeric,
|
||||
} from "sequelize-typescript";
|
||||
import { isEmail } from "validator";
|
||||
import { TeamPreferenceDefaults } from "@shared/constants";
|
||||
import { TeamPreference, TeamPreferences, UserRole } from "@shared/types";
|
||||
import { getBaseDomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains";
|
||||
import { parseEmail } from "@shared/utils/email";
|
||||
import env from "@server/env";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask";
|
||||
@@ -293,15 +295,21 @@ class Team extends ParanoidModel<
|
||||
* Find whether the passed domain can be used to sign-in to this team. Note
|
||||
* that this method always returns true if no domain restrictions are set.
|
||||
*
|
||||
* @param domain The domain to check
|
||||
* @param domainOrEmail The domain or email to check
|
||||
* @returns True if the domain is allowed to sign-in to this team
|
||||
*/
|
||||
public isDomainAllowed = async function (
|
||||
this: Team,
|
||||
domain: string
|
||||
domainOrEmail: string
|
||||
): Promise<boolean> {
|
||||
const allowedDomains = (await this.$get("allowedDomains")) || [];
|
||||
|
||||
let domain = domainOrEmail;
|
||||
if (isEmail(domainOrEmail)) {
|
||||
const parsed = parseEmail(domainOrEmail);
|
||||
domain = parsed.domain;
|
||||
}
|
||||
|
||||
return (
|
||||
allowedDomains.length === 0 ||
|
||||
allowedDomains.map((d: TeamDomain) => d.name).includes(domain)
|
||||
|
||||
@@ -48,6 +48,7 @@ import { stringToColor } from "@shared/utils/color";
|
||||
import { locales } from "@shared/utils/date";
|
||||
import env from "@server/env";
|
||||
import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask";
|
||||
import { APIContext } from "@server/types";
|
||||
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
|
||||
import { ValidationError } from "../errors";
|
||||
import Attachment from "./Attachment";
|
||||
@@ -581,6 +582,24 @@ class User extends ParanoidModel<
|
||||
this.jwtSecret
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns a temporary token that can be used to update the users
|
||||
* email address.
|
||||
*
|
||||
* @param email The new email address
|
||||
* @returns The token
|
||||
*/
|
||||
getEmailUpdateToken = (email: string) =>
|
||||
JWT.sign(
|
||||
{
|
||||
id: this.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
email,
|
||||
type: "email-update",
|
||||
},
|
||||
this.jwtSecret
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns a list of teams that have a user matching this user's email.
|
||||
*
|
||||
@@ -705,6 +724,16 @@ class User extends ParanoidModel<
|
||||
}
|
||||
};
|
||||
|
||||
static findByEmail = async function (ctx: APIContext, email: string) {
|
||||
return this.findOne({
|
||||
where: {
|
||||
teamId: ctx.context.auth.user.teamId,
|
||||
email: email.trim().toLowerCase(),
|
||||
},
|
||||
...ctx.context,
|
||||
});
|
||||
};
|
||||
|
||||
static getCounts = async function (teamId: string) {
|
||||
const countSql = `
|
||||
SELECT
|
||||
|
||||
@@ -68,16 +68,18 @@ router.post(
|
||||
rateLimiter(RateLimiterStrategy.FivePerHour),
|
||||
auth(),
|
||||
async (ctx: APIContext) => {
|
||||
if (!emailEnabled) {
|
||||
throw ValidationError("Email support is not setup for this instance");
|
||||
}
|
||||
|
||||
const { user } = ctx.state.auth;
|
||||
const { team } = user;
|
||||
authorize(user, "delete", team);
|
||||
|
||||
if (emailEnabled) {
|
||||
await new ConfirmTeamDeleteEmail({
|
||||
to: user.email,
|
||||
deleteConfirmationCode: team.getDeleteConfirmationCode(user),
|
||||
}).schedule();
|
||||
}
|
||||
await new ConfirmTeamDeleteEmail({
|
||||
to: user.email,
|
||||
deleteConfirmationCode: team.getDeleteConfirmationCode(user),
|
||||
}).schedule();
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
|
||||
@@ -71,3 +71,30 @@ exports[`#users.update should require authentication 1`] = `
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.updateEmail post should fail if email not in allowed domains 1`] = `
|
||||
{
|
||||
"error": "validation_error",
|
||||
"message": "The domain is not allowed for this workspace",
|
||||
"ok": false,
|
||||
"status": 400,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.updateEmail post should fail if email not unique in workspace 1`] = `
|
||||
{
|
||||
"error": "validation_error",
|
||||
"message": "User with email already exists",
|
||||
"ok": false,
|
||||
"status": 400,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.updateEmail post should require authentication 1`] = `
|
||||
{
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -99,6 +99,26 @@ export const UsersDeleteSchema = BaseSchema.extend({
|
||||
|
||||
export type UsersDeleteSchemaReq = z.infer<typeof UsersDeleteSchema>;
|
||||
|
||||
export const UsersUpdateEmailSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
email: z.string().email(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type UsersUpdateEmailReq = z.infer<typeof UsersUpdateEmailSchema>;
|
||||
|
||||
export const UsersUpdateEmailConfirmSchema = BaseSchema.extend({
|
||||
query: z.object({
|
||||
code: z.string(),
|
||||
follow: z.string().default(""),
|
||||
}),
|
||||
});
|
||||
|
||||
export type UsersUpdateEmailConfirmReq = z.infer<
|
||||
typeof UsersUpdateEmailConfirmSchema
|
||||
>;
|
||||
|
||||
export const UsersInfoSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { TeamPreference, UserRole } from "@shared/types";
|
||||
import ConfirmUpdateEmail from "@server/emails/templates/ConfirmUpdateEmail";
|
||||
import { TeamDomain } from "@server/models";
|
||||
import {
|
||||
buildTeam,
|
||||
buildAdmin,
|
||||
@@ -723,6 +726,85 @@ describe("#users.update", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("#users.updateEmail", () => {
|
||||
describe("post", () => {
|
||||
it("should trigger verification email", async () => {
|
||||
const spy = jest.spyOn(ConfirmUpdateEmail.prototype, "schedule");
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/users.updateEmail", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
email: faker.internet.email(),
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.success).toEqual(true);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("should fail if email not in allowed domains", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
await TeamDomain.create({
|
||||
teamId: user.teamId,
|
||||
name: "example.com",
|
||||
createdById: user.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/users.updateEmail", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
email: faker.internet.email(),
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should fail if email not unique in workspace", async () => {
|
||||
const user = await buildUser();
|
||||
const email = faker.internet.email().toLowerCase();
|
||||
await buildUser({ teamId: user.teamId, email });
|
||||
|
||||
const res = await server.post("/api/users.updateEmail", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
email,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/users.updateEmail");
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("get", () => {
|
||||
it("should update email", async () => {
|
||||
const user = await buildUser();
|
||||
const email = faker.internet.email();
|
||||
await server.get(
|
||||
`/api/users.updateEmail?token=${user.getJwtToken()}&code=${user.getEmailUpdateToken(
|
||||
email
|
||||
)}&follow=true`
|
||||
);
|
||||
|
||||
await user.reload();
|
||||
expect(user.email).toEqual(email);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("#users.update_role", () => {
|
||||
it("should promote", async () => {
|
||||
const team = await buildTeam();
|
||||
|
||||
@@ -2,11 +2,13 @@ import Router from "koa-router";
|
||||
import { Op, Sequelize, WhereOptions } from "sequelize";
|
||||
import { UserPreference, UserRole } from "@shared/types";
|
||||
import { UserRoleHelper } from "@shared/utils/UserRoleHelper";
|
||||
import { settingsPath } from "@shared/utils/routeHelpers";
|
||||
import { UserValidation } from "@shared/validations";
|
||||
import userDestroyer from "@server/commands/userDestroyer";
|
||||
import userInviter from "@server/commands/userInviter";
|
||||
import userSuspender from "@server/commands/userSuspender";
|
||||
import userUnsuspender from "@server/commands/userUnsuspender";
|
||||
import ConfirmUpdateEmail from "@server/emails/templates/ConfirmUpdateEmail";
|
||||
import ConfirmUserDeleteEmail from "@server/emails/templates/ConfirmUserDeleteEmail";
|
||||
import InviteEmail from "@server/emails/templates/InviteEmail";
|
||||
import env from "@server/env";
|
||||
@@ -23,6 +25,7 @@ import { presentUser, presentPolicies } from "@server/presenters";
|
||||
import { APIContext } from "@server/types";
|
||||
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
|
||||
import { safeEqual } from "@server/utils/crypto";
|
||||
import { getDetailsForEmailUpdateToken } from "@server/utils/jwt";
|
||||
import pagination from "../middlewares/pagination";
|
||||
import * as T from "./schema";
|
||||
|
||||
@@ -201,6 +204,108 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"users.updateEmail",
|
||||
rateLimiter(RateLimiterStrategy.TenPerHour),
|
||||
auth(),
|
||||
validate(T.UsersUpdateEmailSchema),
|
||||
async (ctx: APIContext<T.UsersUpdateEmailReq>) => {
|
||||
if (!emailEnabled) {
|
||||
throw ValidationError("Email support is not setup for this instance");
|
||||
}
|
||||
|
||||
const { user: actor } = ctx.state.auth;
|
||||
const { id } = ctx.input.body;
|
||||
const { team } = actor;
|
||||
const user = id ? await User.findByPk(id) : actor;
|
||||
const email = ctx.input.body.email.trim().toLowerCase();
|
||||
|
||||
authorize(actor, "update", user);
|
||||
|
||||
// Check if email domain is allowed
|
||||
if (!(await team.isDomainAllowed(email))) {
|
||||
throw ValidationError("The domain is not allowed for this workspace");
|
||||
}
|
||||
|
||||
// Check if email already exists in workspace
|
||||
if (await User.findByEmail(ctx, email)) {
|
||||
throw ValidationError("User with email already exists");
|
||||
}
|
||||
|
||||
await new ConfirmUpdateEmail({
|
||||
to: email,
|
||||
previous: user.email,
|
||||
code: user.getEmailUpdateToken(email),
|
||||
teamUrl: team.url,
|
||||
}).schedule();
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
"users.updateEmail",
|
||||
rateLimiter(RateLimiterStrategy.TenPerHour),
|
||||
auth(),
|
||||
transaction(),
|
||||
validate(T.UsersUpdateEmailConfirmSchema),
|
||||
async (ctx: APIContext<T.UsersUpdateEmailConfirmReq>) => {
|
||||
if (!emailEnabled) {
|
||||
throw ValidationError("Email support is not setup for this instance");
|
||||
}
|
||||
|
||||
const { transaction } = ctx.state;
|
||||
const { code, follow } = ctx.input.query;
|
||||
|
||||
// The link in the email does not include the follow query param, this
|
||||
// is to help prevent anti-virus, and email clients from pre-fetching the link
|
||||
// and spending the token before the user clicks on it. Instead we redirect
|
||||
// to the same URL with the follow query param added from the client side.
|
||||
if (!follow) {
|
||||
return ctx.redirectOnClient(ctx.request.href + "&follow=true");
|
||||
}
|
||||
let user: User;
|
||||
let email: string;
|
||||
|
||||
try {
|
||||
const res = await getDetailsForEmailUpdateToken(code as string, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
user = res.user;
|
||||
email = res.email;
|
||||
} catch (err) {
|
||||
ctx.redirect(`/?notice=expired-token`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { user: actor } = ctx.state.auth;
|
||||
authorize(actor, "update", user);
|
||||
|
||||
// Check if email domain is allowed
|
||||
if (!(await actor.team.isDomainAllowed(email))) {
|
||||
throw ValidationError("The domain is not allowed for this workspace");
|
||||
}
|
||||
|
||||
// Check if email already exists in workspace
|
||||
if (await User.findByEmail(ctx, email)) {
|
||||
throw ValidationError("User with email already exists");
|
||||
}
|
||||
|
||||
user.email = email;
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "users.update",
|
||||
userId: user.id,
|
||||
changes: user.changeset,
|
||||
});
|
||||
await user.save({ transaction });
|
||||
|
||||
ctx.redirect(settingsPath());
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"users.update",
|
||||
auth(),
|
||||
@@ -518,15 +623,17 @@ router.post(
|
||||
rateLimiter(RateLimiterStrategy.FivePerHour),
|
||||
auth(),
|
||||
async (ctx: APIContext) => {
|
||||
if (!emailEnabled) {
|
||||
throw ValidationError("Email support is not setup for this instance");
|
||||
}
|
||||
|
||||
const { user } = ctx.state.auth;
|
||||
authorize(user, "delete", user);
|
||||
|
||||
if (emailEnabled) {
|
||||
await new ConfirmUserDeleteEmail({
|
||||
to: user.email,
|
||||
deleteConfirmationCode: user.deleteConfirmationCode,
|
||||
}).schedule();
|
||||
}
|
||||
await new ConfirmUserDeleteEmail({
|
||||
to: user.email,
|
||||
deleteConfirmationCode: user.deleteConfirmationCode,
|
||||
}).schedule();
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { subMinutes } from "date-fns";
|
||||
import JWT from "jsonwebtoken";
|
||||
import { FindOptions } from "sequelize";
|
||||
import { Team, User } from "@server/models";
|
||||
import { AuthenticationError } from "../errors";
|
||||
|
||||
@@ -105,3 +106,35 @@ export async function getUserForEmailSigninToken(token: string): Promise<User> {
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getDetailsForEmailUpdateToken(
|
||||
token: string,
|
||||
options: FindOptions<User> = {}
|
||||
): Promise<{ user: User; email: string }> {
|
||||
const payload = getJWTPayload(token);
|
||||
|
||||
if (payload.type !== "email-update") {
|
||||
throw AuthenticationError("Invalid token");
|
||||
}
|
||||
|
||||
// check the token is within it's expiration time
|
||||
if (payload.createdAt) {
|
||||
if (new Date(payload.createdAt) < subMinutes(new Date(), 10)) {
|
||||
throw AuthenticationError("Expired token");
|
||||
}
|
||||
}
|
||||
|
||||
const email = payload.email;
|
||||
const user = await User.findByPk(payload.id, {
|
||||
rejectOnEmpty: true,
|
||||
...options,
|
||||
});
|
||||
|
||||
try {
|
||||
JWT.verify(token, user.jwtSecret);
|
||||
} catch (err) {
|
||||
throw AuthenticationError("Invalid token");
|
||||
}
|
||||
|
||||
return { user, email };
|
||||
}
|
||||
|
||||
@@ -402,6 +402,12 @@
|
||||
"Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.": "Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.",
|
||||
"New name": "New name",
|
||||
"Name can't be empty": "Name can't be empty",
|
||||
"Check your email to verify the new address.": "Check your email to verify the new address.",
|
||||
"The email will be changed once verified.": "The email will be changed once verified.",
|
||||
"You will receive an email to verify your new address. It must be unique in the workspace.": "You will receive an email to verify your new address. It must be unique in the workspace.",
|
||||
"A confirmation email will be sent to the new address before it is changed.": "A confirmation email will be sent to the new address before it is changed.",
|
||||
"New email": "New email",
|
||||
"Email can't be empty": "Email can't be empty",
|
||||
"Your import completed": "Your import completed",
|
||||
"Previous match": "Previous match",
|
||||
"Next match": "Next match",
|
||||
@@ -543,6 +549,7 @@
|
||||
"Headings you add to the document will appear here": "Headings you add to the document will appear here",
|
||||
"Table of contents": "Table of contents",
|
||||
"Change name": "Change name",
|
||||
"Change email": "Change email",
|
||||
"Suspend user": "Suspend user",
|
||||
"An error occurred while sending the invite": "An error occurred while sending the invite",
|
||||
"User options": "User options",
|
||||
@@ -958,8 +965,6 @@
|
||||
"Notifications saved": "Notifications saved",
|
||||
"Unsubscription successful. Your notification settings were updated": "Unsubscription successful. Your notification settings were updated",
|
||||
"Manage when and where you receive email notifications.": "Manage when and where you receive email notifications.",
|
||||
"Email address": "Email address",
|
||||
"Your email address should be updated in your SSO provider.": "Your email address should be updated in your SSO provider.",
|
||||
"The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.": "The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.",
|
||||
"Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.": "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the <em>developer documentation</em>.",
|
||||
"Personal keys": "Personal keys",
|
||||
@@ -986,6 +991,7 @@
|
||||
"Photo": "Photo",
|
||||
"Choose a photo or image to represent yourself.": "Choose a photo or image to represent yourself.",
|
||||
"This could be your real name, or a nickname — however you’d like people to refer to you.": "This could be your real name, or a nickname — however you’d like people to refer to you.",
|
||||
"Email address": "Email address",
|
||||
"Are you sure you want to require invites?": "Are you sure you want to require invites?",
|
||||
"New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> will no longer apply.": "New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> will no longer apply.",
|
||||
"Settings that impact the access, security, and content of your workspace.": "Settings that impact the access, security, and content of your workspace.",
|
||||
|
||||
@@ -2,6 +2,10 @@ export function signin(service = "slack"): string {
|
||||
return `/auth/${service}`;
|
||||
}
|
||||
|
||||
export function settingsPath(section?: string): string {
|
||||
return "/settings" + (section ? `/${section}` : "");
|
||||
}
|
||||
|
||||
export function integrationSettingsPath(id: string): string {
|
||||
return `/settings/integrations/${id}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user