feat: Allow users to change email in-app (#8119)

This commit is contained in:
Tom Moor
2024-12-25 19:58:26 +09:00
committed by GitHub
parent 85b62d3146
commit 86cfd62afa
19 changed files with 543 additions and 40 deletions
+6 -3
View File
@@ -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);
+72 -2
View File
@@ -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
View File
@@ -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,
},
+5 -12
View File
@@ -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
+25 -1
View File
@@ -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>
+1 -1
View File
@@ -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 -3
View File
@@ -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 `Heres 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
View File
@@ -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
View File
@@ -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)
+29
View File
@@ -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
+8 -6
View File
@@ -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,
}
`;
+20
View File
@@ -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(),
+82
View File
@@ -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();
+113 -6
View File
@@ -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,
+33
View File
@@ -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 };
}
+8 -2
View File
@@ -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 youd like people to refer to you.": "This could be your real name, or a nickname — however youd 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.",
+4
View File
@@ -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}`;
}