mirror of
https://github.com/outline/outline.git
synced 2025-12-20 10:09:43 -06:00
Conversion of User to event system (#9741)
* Conversion of User to event system * fix * warning * fixes * Skip lastActiveAt in changeset * fix: Skip count in view changeset * refactor: Remove userDestroyer * refactor: Remove userSuspender * refactor: Remove userUnsuspender * tests
This commit is contained in:
@@ -4,7 +4,6 @@ import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import Group from "~/models/Group";
|
||||
import User from "~/models/User";
|
||||
import Invite from "~/scenes/Invite";
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "@server/utils/passport";
|
||||
import config from "../../plugin.json";
|
||||
import env from "../env";
|
||||
import { createContext } from "@server/context";
|
||||
|
||||
const router = new Router();
|
||||
const scopes: string[] = [];
|
||||
@@ -38,7 +39,7 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
|
||||
scope: scopes,
|
||||
},
|
||||
async function (
|
||||
ctx: Context,
|
||||
context: Context,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: { expires_in: number; id_token: string },
|
||||
@@ -94,15 +95,15 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
|
||||
);
|
||||
}
|
||||
|
||||
const team = await getTeamFromContext(ctx);
|
||||
const client = getClientFromContext(ctx);
|
||||
const team = await getTeamFromContext(context);
|
||||
const client = getClientFromContext(context);
|
||||
|
||||
const domain = parseEmail(email).domain;
|
||||
const subdomain = slugifyDomain(domain);
|
||||
|
||||
const teamName = organization.displayName;
|
||||
const result = await accountProvisioner({
|
||||
ip: ctx.ip,
|
||||
const ctx = createContext({ ip: context.ip });
|
||||
const result = await accountProvisioner(ctx, {
|
||||
team: {
|
||||
teamId: team?.id,
|
||||
name: teamName,
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import config from "../../plugin.json";
|
||||
import env from "../env";
|
||||
import { DiscordGuildError, DiscordGuildRoleError } from "../errors";
|
||||
import { createContext } from "@server/context";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
@@ -54,7 +55,7 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
|
||||
pkce: false,
|
||||
},
|
||||
async function (
|
||||
ctx: Context,
|
||||
context: Context,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: { expires_in: number },
|
||||
@@ -66,8 +67,8 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
|
||||
) => void
|
||||
) {
|
||||
try {
|
||||
const team = await getTeamFromContext(ctx);
|
||||
const client = getClientFromContext(ctx);
|
||||
const team = await getTeamFromContext(context);
|
||||
const client = getClientFromContext(context);
|
||||
/** Fetch the user's profile */
|
||||
const profile: RESTGetAPICurrentUserResult = await request(
|
||||
"GET",
|
||||
@@ -180,8 +181,8 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
|
||||
// if a team can be inferred, we assume the user is only interested in signing into
|
||||
// that team in particular; otherwise, we will do a best effort at finding their account
|
||||
// or provisioning a new one (within AccountProvisioner)
|
||||
const result = await accountProvisioner({
|
||||
ip: ctx.ip,
|
||||
const ctx = createContext({ ip: context.ip });
|
||||
const result = await accountProvisioner(ctx, {
|
||||
team: {
|
||||
teamId: team?.id,
|
||||
name: teamName,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "@server/utils/passport";
|
||||
import config from "../../plugin.json";
|
||||
import env from "../env";
|
||||
import { createContext } from "@server/context";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
@@ -51,7 +52,7 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
scope: scopes,
|
||||
},
|
||||
async function (
|
||||
ctx: Context,
|
||||
context: Context,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: { expires_in: number },
|
||||
@@ -65,8 +66,8 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
try {
|
||||
// "domain" is the Google Workspaces domain
|
||||
const domain = profile._json.hd;
|
||||
const team = await getTeamFromContext(ctx);
|
||||
const client = getClientFromContext(ctx);
|
||||
const team = await getTeamFromContext(context);
|
||||
const client = getClientFromContext(context);
|
||||
|
||||
// No profile domain means personal gmail account
|
||||
// No team implies the request came from the apex domain
|
||||
@@ -108,8 +109,8 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
// if a team can be inferred, we assume the user is only interested in signing into
|
||||
// that team in particular; otherwise, we will do a best effort at finding their account
|
||||
// or provisioning a new one (within AccountProvisioner)
|
||||
const result = await accountProvisioner({
|
||||
ip: ctx.ip,
|
||||
const ctx = createContext({ ip: context.ip });
|
||||
const result = await accountProvisioner(ctx, {
|
||||
team: {
|
||||
teamId: team?.id,
|
||||
name: teamName,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import config from "../../plugin.json";
|
||||
import env from "../env";
|
||||
import { OIDCStrategy } from "./OIDCStrategy";
|
||||
import { createContext } from "@server/context";
|
||||
|
||||
export interface OIDCEndpoints {
|
||||
authorizationURL: string;
|
||||
@@ -65,7 +66,7 @@ export function createOIDCRouter(
|
||||
// Any claim supplied in response to the userinfo request will be
|
||||
// available on the `profile` parameter
|
||||
async function (
|
||||
ctx: Context,
|
||||
context: Context,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: { expires_in: number; id_token: string },
|
||||
@@ -118,8 +119,8 @@ export function createOIDCRouter(
|
||||
);
|
||||
}
|
||||
|
||||
const team = await getTeamFromContext(ctx);
|
||||
const client = getClientFromContext(ctx);
|
||||
const team = await getTeamFromContext(context);
|
||||
const client = getClientFromContext(context);
|
||||
const { domain } = parseEmail(email);
|
||||
|
||||
// Only a single OIDC provider is supported – find the existing, if any.
|
||||
@@ -185,8 +186,8 @@ export function createOIDCRouter(
|
||||
avatarUrl = null;
|
||||
}
|
||||
|
||||
const result = await accountProvisioner({
|
||||
ip: ctx.ip,
|
||||
const ctx = createContext({ ip: context.ip });
|
||||
const result = await accountProvisioner(ctx, {
|
||||
team: {
|
||||
teamId: team?.id,
|
||||
name: env.APP_NAME,
|
||||
|
||||
@@ -29,6 +29,7 @@ import env from "../env";
|
||||
import * as Slack from "../slack";
|
||||
import * as T from "./schema";
|
||||
import { SlackUtils } from "plugins/slack/shared/SlackUtils";
|
||||
import { createContext } from "@server/context";
|
||||
|
||||
type SlackProfile = Profile & {
|
||||
team: {
|
||||
@@ -68,7 +69,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
||||
scope: scopes,
|
||||
},
|
||||
async function (
|
||||
ctx: Context,
|
||||
context: Context,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: { expires_in: number },
|
||||
@@ -80,14 +81,13 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
||||
) => void
|
||||
) {
|
||||
try {
|
||||
const team = await getTeamFromContext(ctx);
|
||||
const client = getClientFromContext(ctx);
|
||||
|
||||
const team = await getTeamFromContext(context);
|
||||
const client = getClientFromContext(context);
|
||||
|
||||
const { domain } = parseEmail(profile.user.email);
|
||||
|
||||
const result = await accountProvisioner({
|
||||
ip: ctx.ip,
|
||||
const ctx = createContext({ ip: context.ip });
|
||||
const result = await accountProvisioner(ctx, {
|
||||
team: {
|
||||
teamId: team?.id,
|
||||
name: profile.team.name,
|
||||
|
||||
@@ -7,24 +7,27 @@ import UserAuthentication from "@server/models/UserAuthentication";
|
||||
import { buildUser, buildTeam, buildAdmin } from "@server/test/factories";
|
||||
import { setSelfHosted } from "@server/test/support";
|
||||
import accountProvisioner from "./accountProvisioner";
|
||||
import { createContext } from "@server/context";
|
||||
|
||||
describe("accountProvisioner", () => {
|
||||
const ip = "127.0.0.1";
|
||||
const ip = faker.internet.ip();
|
||||
const ctx = createContext({ ip });
|
||||
|
||||
describe("hosted", () => {
|
||||
it("should create a new user and team", async () => {
|
||||
const spy = jest.spyOn(WelcomeEmail.prototype, "schedule");
|
||||
const email = faker.internet.email().toLowerCase();
|
||||
const { user, team, isNewTeam, isNewUser } = await accountProvisioner({
|
||||
ip,
|
||||
const { user, team, isNewTeam, isNewUser } = await accountProvisioner(
|
||||
ctx,
|
||||
{
|
||||
user: {
|
||||
name: "Jenny Tester",
|
||||
email,
|
||||
avatarUrl: faker.internet.avatar(),
|
||||
avatarUrl: faker.image.avatar(),
|
||||
},
|
||||
team: {
|
||||
name: "New workspace",
|
||||
avatarUrl: faker.internet.avatar(),
|
||||
avatarUrl: faker.image.avatar(),
|
||||
subdomain: faker.internet.domainWord(),
|
||||
},
|
||||
authenticationProvider: {
|
||||
@@ -36,7 +39,8 @@ describe("accountProvisioner", () => {
|
||||
accessToken: "123",
|
||||
scopes: ["read"],
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
const authentications = await user.$get("authentications");
|
||||
const auth = authentications[0];
|
||||
expect(auth.accessToken).toEqual("123");
|
||||
@@ -68,8 +72,7 @@ describe("accountProvisioner", () => {
|
||||
const authentications = await existing.$get("authentications");
|
||||
const authentication = authentications[0];
|
||||
const newEmail = faker.internet.email().toLowerCase();
|
||||
const { user, isNewUser, isNewTeam } = await accountProvisioner({
|
||||
ip,
|
||||
const { user, isNewUser, isNewTeam } = await accountProvisioner(ctx, {
|
||||
user: {
|
||||
name: existing.name,
|
||||
email: newEmail,
|
||||
@@ -117,8 +120,7 @@ describe("accountProvisioner", () => {
|
||||
authentications: [],
|
||||
});
|
||||
|
||||
const { user, isNewUser, isNewTeam } = await accountProvisioner({
|
||||
ip,
|
||||
const { user, isNewUser, isNewTeam } = await accountProvisioner(ctx, {
|
||||
user: {
|
||||
name: userWithoutAuth.name,
|
||||
email,
|
||||
@@ -161,8 +163,7 @@ describe("accountProvisioner", () => {
|
||||
let error;
|
||||
|
||||
try {
|
||||
await accountProvisioner({
|
||||
ip,
|
||||
await accountProvisioner(ctx, {
|
||||
user: {
|
||||
name: existing.name,
|
||||
email: existing.email!,
|
||||
@@ -210,8 +211,7 @@ describe("accountProvisioner", () => {
|
||||
});
|
||||
const authentications = await existing.$get("authentications");
|
||||
const authentication = authentications[0];
|
||||
const { isNewUser, isNewTeam } = await accountProvisioner({
|
||||
ip,
|
||||
const { isNewUser, isNewTeam } = await accountProvisioner(ctx, {
|
||||
user: {
|
||||
name: existing.name,
|
||||
email: existing.email!,
|
||||
@@ -256,12 +256,11 @@ describe("accountProvisioner", () => {
|
||||
let error;
|
||||
|
||||
try {
|
||||
await accountProvisioner({
|
||||
ip,
|
||||
await accountProvisioner(ctx, {
|
||||
user: {
|
||||
name: "Jenny Tester",
|
||||
email,
|
||||
avatarUrl: faker.internet.avatar(),
|
||||
avatarUrl: faker.image.avatar(),
|
||||
},
|
||||
team: {
|
||||
avatarUrl: existingTeam.avatarUrl,
|
||||
@@ -299,12 +298,11 @@ describe("accountProvisioner", () => {
|
||||
createdById: admin.id,
|
||||
});
|
||||
const email = faker.internet.email({ provider: domain });
|
||||
const { user, isNewUser } = await accountProvisioner({
|
||||
ip,
|
||||
const { user, isNewUser } = await accountProvisioner(ctx, {
|
||||
user: {
|
||||
name: "Jenny Tester",
|
||||
email,
|
||||
avatarUrl: faker.internet.avatar(),
|
||||
avatarUrl: faker.image.avatar(),
|
||||
},
|
||||
team: {
|
||||
avatarUrl: team.avatarUrl,
|
||||
@@ -347,12 +345,11 @@ describe("accountProvisioner", () => {
|
||||
);
|
||||
const authenticationProvider = authenticationProviders[0];
|
||||
const email = faker.internet.email().toLowerCase();
|
||||
const { user, isNewUser } = await accountProvisioner({
|
||||
ip,
|
||||
const { user, isNewUser } = await accountProvisioner(ctx, {
|
||||
user: {
|
||||
name: "Jenny Tester",
|
||||
email,
|
||||
avatarUrl: faker.internet.avatar(),
|
||||
avatarUrl: faker.image.avatar(),
|
||||
},
|
||||
team: {
|
||||
name: team.name,
|
||||
@@ -397,12 +394,11 @@ describe("accountProvisioner", () => {
|
||||
const team = await buildTeam();
|
||||
|
||||
try {
|
||||
await accountProvisioner({
|
||||
ip,
|
||||
await accountProvisioner(ctx, {
|
||||
user: {
|
||||
name: "Jenny Tester",
|
||||
email: faker.internet.email(),
|
||||
avatarUrl: faker.internet.avatar(),
|
||||
avatarUrl: faker.image.avatar(),
|
||||
},
|
||||
team: {
|
||||
teamId: team.id,
|
||||
@@ -430,12 +426,11 @@ describe("accountProvisioner", () => {
|
||||
it("should always use existing team if self-hosted", async () => {
|
||||
const team = await buildTeam();
|
||||
const domain = faker.internet.domainName();
|
||||
const { user, isNewUser } = await accountProvisioner({
|
||||
ip,
|
||||
const { user, isNewUser } = await accountProvisioner(ctx, {
|
||||
user: {
|
||||
name: "Jenny Tester",
|
||||
email: faker.internet.email(),
|
||||
avatarUrl: faker.internet.avatar(),
|
||||
avatarUrl: faker.image.avatar(),
|
||||
},
|
||||
team: {
|
||||
teamId: team.id,
|
||||
|
||||
@@ -20,9 +20,9 @@ import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import teamProvisioner from "./teamProvisioner";
|
||||
import userProvisioner from "./userProvisioner";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
type Props = {
|
||||
ip: string;
|
||||
/** Details of the user logging in from SSO provider */
|
||||
user: {
|
||||
/** The displayed name of the user */
|
||||
@@ -79,19 +79,20 @@ export type AccountProvisionerResult = {
|
||||
isNewUser: boolean;
|
||||
};
|
||||
|
||||
async function accountProvisioner({
|
||||
ip,
|
||||
async function accountProvisioner(
|
||||
ctx: APIContext,
|
||||
{
|
||||
user: userParams,
|
||||
team: teamParams,
|
||||
authenticationProvider: authenticationProviderParams,
|
||||
authentication: authenticationParams,
|
||||
}: Props): Promise<AccountProvisionerResult> {
|
||||
}: Props
|
||||
): Promise<AccountProvisionerResult> {
|
||||
let result;
|
||||
let emailMatchOnly;
|
||||
|
||||
try {
|
||||
result = await teamProvisioner({
|
||||
ip,
|
||||
result = await teamProvisioner(ctx, {
|
||||
name: "Wiki",
|
||||
...teamParams,
|
||||
authenticationProvider: authenticationProviderParams,
|
||||
@@ -141,14 +142,13 @@ async function accountProvisioner({
|
||||
throw AuthenticationProviderDisabledError();
|
||||
}
|
||||
|
||||
result = await userProvisioner({
|
||||
result = await userProvisioner(ctx, {
|
||||
name: userParams.name,
|
||||
email: userParams.email,
|
||||
language: userParams.language,
|
||||
role: isNewTeam ? UserRole.Admin : undefined,
|
||||
avatarUrl: userParams.avatarUrl,
|
||||
teamId: team.id,
|
||||
ip,
|
||||
authentication: emailMatchOnly
|
||||
? undefined
|
||||
: {
|
||||
|
||||
@@ -30,7 +30,7 @@ export default async function subscriptionCreator({
|
||||
event,
|
||||
resubscribe = true,
|
||||
}: Props): Promise<Subscription> {
|
||||
const { user } = ctx.context.auth;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const where: WhereOptions<Subscription> = {
|
||||
userId: user.id,
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Team } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
type Props = {
|
||||
ctx: APIContext;
|
||||
/** The displayed name of the team */
|
||||
name: string;
|
||||
/** The domain name from the email of the user logging in */
|
||||
@@ -24,13 +23,10 @@ type Props = {
|
||||
}[];
|
||||
};
|
||||
|
||||
async function teamCreator({
|
||||
ctx,
|
||||
name,
|
||||
subdomain,
|
||||
avatarUrl,
|
||||
authenticationProviders,
|
||||
}: Props): Promise<Team> {
|
||||
async function teamCreator(
|
||||
ctx: APIContext,
|
||||
{ name, subdomain, avatarUrl, authenticationProviders }: Props
|
||||
): Promise<Team> {
|
||||
if (!avatarUrl?.startsWith("http")) {
|
||||
avatarUrl = null;
|
||||
}
|
||||
|
||||
@@ -3,22 +3,23 @@ import TeamDomain from "@server/models/TeamDomain";
|
||||
import { buildTeam, buildUser } from "@server/test/factories";
|
||||
import { setSelfHosted } from "@server/test/support";
|
||||
import teamProvisioner from "./teamProvisioner";
|
||||
import { createContext } from "@server/context";
|
||||
|
||||
describe("teamProvisioner", () => {
|
||||
const ip = "127.0.0.1";
|
||||
const ip = faker.internet.ip();
|
||||
const ctx = createContext({ ip });
|
||||
|
||||
describe("hosted", () => {
|
||||
it("should create team and authentication provider", async () => {
|
||||
const subdomain = faker.internet.domainWord();
|
||||
const result = await teamProvisioner({
|
||||
const result = await teamProvisioner(ctx, {
|
||||
name: "Test team",
|
||||
subdomain,
|
||||
avatarUrl: faker.internet.avatar(),
|
||||
avatarUrl: faker.image.avatar(),
|
||||
authenticationProvider: {
|
||||
name: "google",
|
||||
providerId: `${subdomain}.com`,
|
||||
},
|
||||
ip,
|
||||
});
|
||||
const { team, authenticationProvider, isNewTeam } = result;
|
||||
expect(authenticationProvider.name).toEqual("google");
|
||||
@@ -35,15 +36,14 @@ describe("teamProvisioner", () => {
|
||||
subdomain,
|
||||
});
|
||||
|
||||
const result = await teamProvisioner({
|
||||
const result = await teamProvisioner(ctx, {
|
||||
name: "Test team",
|
||||
subdomain,
|
||||
avatarUrl: faker.internet.avatar(),
|
||||
avatarUrl: faker.image.avatar(),
|
||||
authenticationProvider: {
|
||||
name: "google",
|
||||
providerId: `${subdomain}.com`,
|
||||
},
|
||||
ip,
|
||||
});
|
||||
|
||||
expect(result.isNewTeam).toEqual(true);
|
||||
@@ -58,15 +58,14 @@ describe("teamProvisioner", () => {
|
||||
await buildTeam({
|
||||
subdomain: `${subdomain}1`,
|
||||
});
|
||||
const result = await teamProvisioner({
|
||||
const result = await teamProvisioner(ctx, {
|
||||
name: "Test team",
|
||||
subdomain,
|
||||
avatarUrl: faker.internet.avatar(),
|
||||
avatarUrl: faker.image.avatar(),
|
||||
authenticationProvider: {
|
||||
name: "google",
|
||||
providerId: `${subdomain}.com`,
|
||||
},
|
||||
ip,
|
||||
});
|
||||
|
||||
expect(result.team.subdomain).toEqual(`${subdomain}2`);
|
||||
@@ -82,11 +81,10 @@ describe("teamProvisioner", () => {
|
||||
subdomain,
|
||||
authenticationProviders: [authenticationProvider],
|
||||
});
|
||||
const result = await teamProvisioner({
|
||||
const result = await teamProvisioner(ctx, {
|
||||
name: faker.company.name(),
|
||||
subdomain,
|
||||
authenticationProvider,
|
||||
ip,
|
||||
});
|
||||
const { team, isNewTeam } = result;
|
||||
expect(team.id).toEqual(existing.id);
|
||||
@@ -111,7 +109,7 @@ describe("teamProvisioner", () => {
|
||||
let error;
|
||||
try {
|
||||
const testSubdomain = faker.internet.domainWord();
|
||||
await teamProvisioner({
|
||||
await teamProvisioner(ctx, {
|
||||
teamId: exampleTeam.id,
|
||||
name: "name",
|
||||
subdomain: testSubdomain,
|
||||
@@ -119,7 +117,6 @@ describe("teamProvisioner", () => {
|
||||
name: "google",
|
||||
providerId: `${testSubdomain}.com`,
|
||||
},
|
||||
ip,
|
||||
});
|
||||
} catch (e) {
|
||||
error = e;
|
||||
@@ -133,15 +130,14 @@ describe("teamProvisioner", () => {
|
||||
|
||||
it("should allow creating first team", async () => {
|
||||
const subdomain = faker.internet.domainWord();
|
||||
const { team, isNewTeam } = await teamProvisioner({
|
||||
const { team, isNewTeam } = await teamProvisioner(ctx, {
|
||||
name: "Test team",
|
||||
subdomain,
|
||||
avatarUrl: faker.internet.avatar(),
|
||||
avatarUrl: faker.image.avatar(),
|
||||
authenticationProvider: {
|
||||
name: "google",
|
||||
providerId: `${subdomain}.com`,
|
||||
},
|
||||
ip,
|
||||
});
|
||||
|
||||
expect(isNewTeam).toBeTruthy();
|
||||
@@ -154,16 +150,15 @@ describe("teamProvisioner", () => {
|
||||
let error;
|
||||
|
||||
try {
|
||||
await teamProvisioner({
|
||||
await teamProvisioner(ctx, {
|
||||
name: "Test team",
|
||||
subdomain,
|
||||
avatarUrl: faker.internet.avatar(),
|
||||
avatarUrl: faker.image.avatar(),
|
||||
teamId: team.id,
|
||||
authenticationProvider: {
|
||||
name: "google",
|
||||
providerId: `${subdomain}.com`,
|
||||
},
|
||||
ip,
|
||||
});
|
||||
} catch (err) {
|
||||
error = err;
|
||||
@@ -183,7 +178,7 @@ describe("teamProvisioner", () => {
|
||||
name: domain,
|
||||
createdById: user.id,
|
||||
});
|
||||
const result = await teamProvisioner({
|
||||
const result = await teamProvisioner(ctx, {
|
||||
name: "Updated name",
|
||||
subdomain: faker.internet.domainWord(),
|
||||
domain,
|
||||
@@ -192,7 +187,6 @@ describe("teamProvisioner", () => {
|
||||
name: "google",
|
||||
providerId: domain,
|
||||
},
|
||||
ip,
|
||||
});
|
||||
const { team, authenticationProvider, isNewTeam } = result;
|
||||
expect(team.id).toEqual(existing.id);
|
||||
@@ -219,7 +213,7 @@ describe("teamProvisioner", () => {
|
||||
|
||||
let error;
|
||||
try {
|
||||
await teamProvisioner({
|
||||
await teamProvisioner(ctx, {
|
||||
name: "Updated name",
|
||||
subdomain: faker.internet.domainWord(),
|
||||
domain: otherDomain,
|
||||
@@ -228,7 +222,6 @@ describe("teamProvisioner", () => {
|
||||
name: "google",
|
||||
providerId: otherDomain,
|
||||
},
|
||||
ip,
|
||||
});
|
||||
} catch (err) {
|
||||
error = err;
|
||||
@@ -247,11 +240,10 @@ describe("teamProvisioner", () => {
|
||||
subdomain,
|
||||
authenticationProviders: [authenticationProvider],
|
||||
});
|
||||
const result = await teamProvisioner({
|
||||
const result = await teamProvisioner(ctx, {
|
||||
name: "Updated name",
|
||||
subdomain,
|
||||
authenticationProvider,
|
||||
ip,
|
||||
});
|
||||
const { team, isNewTeam } = result;
|
||||
expect(team.id).toEqual(existing.id);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
import { Team, AuthenticationProvider } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
type TeamProvisionerResult = {
|
||||
team: Team;
|
||||
@@ -37,18 +38,12 @@ type Props = {
|
||||
/** External identifier of the authentication provider */
|
||||
providerId: string;
|
||||
};
|
||||
ip?: string;
|
||||
};
|
||||
|
||||
async function teamProvisioner({
|
||||
teamId,
|
||||
name,
|
||||
domain,
|
||||
subdomain,
|
||||
avatarUrl,
|
||||
authenticationProvider,
|
||||
ip,
|
||||
}: Props): Promise<TeamProvisionerResult> {
|
||||
async function teamProvisioner(
|
||||
ctx: APIContext,
|
||||
{ teamId, name, domain, subdomain, avatarUrl, authenticationProvider }: Props
|
||||
): Promise<TeamProvisionerResult> {
|
||||
let authP = await AuthenticationProvider.findOne({
|
||||
where: teamId
|
||||
? { ...authenticationProvider, teamId }
|
||||
@@ -109,8 +104,7 @@ async function teamProvisioner({
|
||||
|
||||
// We cannot find an existing team, so we create a new one
|
||||
const team = await sequelize.transaction((transaction) =>
|
||||
teamCreator({
|
||||
ctx: createContext({ ip, transaction }),
|
||||
teamCreator(createContext({ transaction }), {
|
||||
name,
|
||||
domain,
|
||||
subdomain,
|
||||
|
||||
@@ -5,13 +5,12 @@ import { Team, TeamDomain, User } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
type Props = {
|
||||
ctx: APIContext;
|
||||
params: Partial<Omit<Team, "allowedDomains">> & { allowedDomains?: string[] };
|
||||
user: User;
|
||||
team: Team;
|
||||
};
|
||||
|
||||
const teamUpdater = async ({ ctx, params, user, team }: Props) => {
|
||||
const teamUpdater = async (ctx: APIContext, { params, user, team }: Props) => {
|
||||
const { allowedDomains, preferences, subdomain, ...attributes } = params;
|
||||
team.setAttributes(attributes);
|
||||
|
||||
@@ -22,7 +21,7 @@ const teamUpdater = async ({ ctx, params, user, team }: Props) => {
|
||||
if (allowedDomains !== undefined) {
|
||||
const existingAllowedDomains = await TeamDomain.findAll({
|
||||
where: { teamId: team.id },
|
||||
transaction: ctx.context.transaction,
|
||||
transaction: ctx.state.transaction,
|
||||
});
|
||||
|
||||
// Only keep existing domains if they are still in the list of allowed domains
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { buildUser, buildAdmin } from "@server/test/factories";
|
||||
import { withAPIContext } from "@server/test/support";
|
||||
import userDestroyer from "./userDestroyer";
|
||||
|
||||
describe("userDestroyer", () => {
|
||||
it("should prevent last user from deleting account", async () => {
|
||||
const user = await buildUser();
|
||||
let error;
|
||||
|
||||
try {
|
||||
await withAPIContext(user, async (ctx) => {
|
||||
await userDestroyer(ctx, {
|
||||
user,
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
expect(error && error.message).toContain("Cannot delete last user");
|
||||
});
|
||||
|
||||
it("should prevent last admin from deleting account", async () => {
|
||||
const user = await buildAdmin();
|
||||
await buildUser({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
let error;
|
||||
|
||||
try {
|
||||
await withAPIContext(user, async (ctx) => {
|
||||
await userDestroyer(ctx, {
|
||||
user,
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
expect(error && error.message).toContain("Cannot delete account");
|
||||
});
|
||||
|
||||
it("should not prevent multiple admin from deleting account", async () => {
|
||||
const actor = await buildAdmin();
|
||||
const user = await buildAdmin({
|
||||
teamId: actor.teamId,
|
||||
});
|
||||
let error;
|
||||
|
||||
try {
|
||||
await withAPIContext(actor, async (ctx) => {
|
||||
await userDestroyer(ctx, {
|
||||
user,
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
expect(error).toBeFalsy();
|
||||
expect(user.deletedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not prevent last non-admin from deleting account", async () => {
|
||||
const user = await buildUser();
|
||||
await buildUser({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
let error;
|
||||
|
||||
try {
|
||||
await withAPIContext(user, async (ctx) => {
|
||||
await userDestroyer(ctx, {
|
||||
user,
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
expect(error).toBeFalsy();
|
||||
expect(user.deletedAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Op } from "sequelize";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { Event, User } from "@server/models";
|
||||
import { APIContext } from "@server/types";
|
||||
import { ValidationError } from "../errors";
|
||||
|
||||
export default async function userDestroyer(
|
||||
ctx: APIContext,
|
||||
{
|
||||
user,
|
||||
}: {
|
||||
user: User;
|
||||
}
|
||||
) {
|
||||
const { transaction } = ctx.state;
|
||||
const { teamId } = user;
|
||||
const usersCount = await User.count({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (usersCount === 1) {
|
||||
throw ValidationError(
|
||||
"Cannot delete last user on the team, delete the workspace instead."
|
||||
);
|
||||
}
|
||||
|
||||
if (user.isAdmin) {
|
||||
const otherAdminsCount = await User.count({
|
||||
where: {
|
||||
role: UserRole.Admin,
|
||||
teamId,
|
||||
id: {
|
||||
[Op.ne]: user.id,
|
||||
},
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (otherAdminsCount === 0) {
|
||||
throw ValidationError(
|
||||
"Cannot delete account as only admin. Please make another user admin and try again."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "users.delete",
|
||||
userId: user.id,
|
||||
data: {
|
||||
name: user.name,
|
||||
},
|
||||
});
|
||||
|
||||
return user.destroy({
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
@@ -2,13 +2,13 @@ import { faker } from "@faker-js/faker";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { buildUser } from "@server/test/factories";
|
||||
import userInviter from "./userInviter";
|
||||
import { withAPIContext } from "@server/test/support";
|
||||
|
||||
describe("userInviter", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should return sent invites", async () => {
|
||||
const user = await buildUser();
|
||||
const response = await userInviter({
|
||||
const response = await withAPIContext(user, (ctx) =>
|
||||
userInviter(ctx, {
|
||||
invites: [
|
||||
{
|
||||
role: UserRole.Member,
|
||||
@@ -16,15 +16,15 @@ describe("userInviter", () => {
|
||||
name: "Test",
|
||||
},
|
||||
],
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
})
|
||||
);
|
||||
expect(response.sent.length).toEqual(1);
|
||||
});
|
||||
|
||||
it("should filter empty invites", async () => {
|
||||
const user = await buildUser();
|
||||
const response = await userInviter({
|
||||
const response = await withAPIContext(user, (ctx) =>
|
||||
userInviter(ctx, {
|
||||
invites: [
|
||||
{
|
||||
role: UserRole.Member,
|
||||
@@ -32,15 +32,15 @@ describe("userInviter", () => {
|
||||
name: "Test",
|
||||
},
|
||||
],
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
})
|
||||
);
|
||||
expect(response.sent.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should filter obviously bunk emails", async () => {
|
||||
const user = await buildUser();
|
||||
const response = await userInviter({
|
||||
const response = await withAPIContext(user, (ctx) =>
|
||||
userInviter(ctx, {
|
||||
invites: [
|
||||
{
|
||||
role: UserRole.Member,
|
||||
@@ -48,15 +48,15 @@ describe("userInviter", () => {
|
||||
name: "Test",
|
||||
},
|
||||
],
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
})
|
||||
);
|
||||
expect(response.sent.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should not send duplicates", async () => {
|
||||
const user = await buildUser();
|
||||
const response = await userInviter({
|
||||
const response = await withAPIContext(user, (ctx) =>
|
||||
userInviter(ctx, {
|
||||
invites: [
|
||||
{
|
||||
role: UserRole.Member,
|
||||
@@ -69,16 +69,16 @@ describe("userInviter", () => {
|
||||
name: "Test",
|
||||
},
|
||||
],
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
})
|
||||
);
|
||||
expect(response.sent.length).toEqual(1);
|
||||
});
|
||||
|
||||
it("should not send invites to existing team members", async () => {
|
||||
const email = faker.internet.email().toLowerCase();
|
||||
const user = await buildUser({ email });
|
||||
const response = await userInviter({
|
||||
const response = await withAPIContext(user, (ctx) =>
|
||||
userInviter(ctx, {
|
||||
invites: [
|
||||
{
|
||||
role: UserRole.Member,
|
||||
@@ -86,9 +86,8 @@ describe("userInviter", () => {
|
||||
name: user.name,
|
||||
},
|
||||
],
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
})
|
||||
);
|
||||
expect(response.sent.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,9 @@ import { UserRole } from "@shared/types";
|
||||
import InviteEmail from "@server/emails/templates/InviteEmail";
|
||||
import env from "@server/env";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { User, Event, Team } from "@server/models";
|
||||
import { User, Team } from "@server/models";
|
||||
import { UserFlag } from "@server/models/User";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
export type Invite = {
|
||||
name: string;
|
||||
@@ -12,18 +13,18 @@ export type Invite = {
|
||||
role: UserRole;
|
||||
};
|
||||
|
||||
export default async function userInviter({
|
||||
user,
|
||||
invites,
|
||||
ip,
|
||||
}: {
|
||||
user: User;
|
||||
type Props = {
|
||||
invites: Invite[];
|
||||
ip: string;
|
||||
}): Promise<{
|
||||
};
|
||||
|
||||
export default async function userInviter(
|
||||
ctx: APIContext,
|
||||
{ invites }: Props
|
||||
): Promise<{
|
||||
sent: Invite[];
|
||||
users: User[];
|
||||
}> {
|
||||
const { user } = ctx.state.auth;
|
||||
const team = await Team.findByPk(user.teamId, { rejectOnEmpty: true });
|
||||
|
||||
// filter out empties and obvious non-emails
|
||||
@@ -56,7 +57,9 @@ export default async function userInviter({
|
||||
|
||||
// send and record remaining invites
|
||||
for (const invite of filteredInvites) {
|
||||
const newUser = await User.create({
|
||||
const newUser = await User.createWithCtx(
|
||||
ctx,
|
||||
{
|
||||
teamId: user.teamId,
|
||||
name: invite.name,
|
||||
email: invite.email,
|
||||
@@ -70,20 +73,12 @@ export default async function userInviter({
|
||||
flags: {
|
||||
[UserFlag.InviteSent]: 1,
|
||||
},
|
||||
});
|
||||
users.push(newUser);
|
||||
await Event.create({
|
||||
name: "users.invite",
|
||||
actorId: user.id,
|
||||
teamId: user.teamId,
|
||||
userId: newUser.id,
|
||||
data: {
|
||||
email: invite.email,
|
||||
name: invite.name,
|
||||
role: invite.role,
|
||||
},
|
||||
ip,
|
||||
});
|
||||
{
|
||||
name: "invite",
|
||||
}
|
||||
);
|
||||
users.push(newUser);
|
||||
|
||||
await new InviteEmail({
|
||||
to: invite.email,
|
||||
|
||||
@@ -9,21 +9,22 @@ import {
|
||||
buildAdmin,
|
||||
} from "@server/test/factories";
|
||||
import userProvisioner from "./userProvisioner";
|
||||
import { createContext } from "@server/context";
|
||||
|
||||
describe("userProvisioner", () => {
|
||||
const ip = "127.0.0.1";
|
||||
const ip = faker.internet.ip();
|
||||
const ctx = createContext({ ip });
|
||||
|
||||
it("should update existing user and authentication", async () => {
|
||||
const existing = await buildUser();
|
||||
const authentications = await existing.$get("authentications");
|
||||
const existingAuth = authentications[0];
|
||||
const newEmail = "test@example.com";
|
||||
const result = await userProvisioner({
|
||||
const result = await userProvisioner(ctx, {
|
||||
name: existing.name,
|
||||
email: newEmail,
|
||||
avatarUrl: existing.avatarUrl,
|
||||
teamId: existing.teamId,
|
||||
ip,
|
||||
authentication: {
|
||||
authenticationProviderId: existingAuth.authenticationProviderId,
|
||||
providerId: existingAuth.providerId,
|
||||
@@ -52,12 +53,11 @@ describe("userProvisioner", () => {
|
||||
authentications: [],
|
||||
});
|
||||
|
||||
const result = await userProvisioner({
|
||||
const result = await userProvisioner(ctx, {
|
||||
name: existing.name,
|
||||
email,
|
||||
avatarUrl: existing.avatarUrl,
|
||||
teamId: existing.teamId,
|
||||
ip,
|
||||
authentication: {
|
||||
authenticationProviderId: authenticationProvider.id,
|
||||
providerId: uuidv4(),
|
||||
@@ -87,12 +87,11 @@ describe("userProvisioner", () => {
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const result = await userProvisioner({
|
||||
const result = await userProvisioner(ctx, {
|
||||
name: existing.name,
|
||||
email,
|
||||
avatarUrl: existing.avatarUrl,
|
||||
teamId: existing.teamId,
|
||||
ip,
|
||||
authentication: {
|
||||
authenticationProviderId: authenticationProvider.id,
|
||||
providerId: uuidv4(),
|
||||
@@ -116,12 +115,11 @@ describe("userProvisioner", () => {
|
||||
const authentications = await existing.$get("authentications");
|
||||
const existingAuth = authentications[0];
|
||||
const newEmail = "test@example.com";
|
||||
await existing.destroy();
|
||||
const result = await userProvisioner({
|
||||
await existing.destroy({ hooks: false });
|
||||
const result = await userProvisioner(ctx, {
|
||||
name: "Test Name",
|
||||
email: "test@example.com",
|
||||
teamId: existing.teamId,
|
||||
ip,
|
||||
authentication: {
|
||||
authenticationProviderId: existingAuth.authenticationProviderId,
|
||||
providerId: existingAuth.providerId,
|
||||
@@ -145,11 +143,10 @@ describe("userProvisioner", () => {
|
||||
let error;
|
||||
|
||||
try {
|
||||
await userProvisioner({
|
||||
await userProvisioner(ctx, {
|
||||
name: "Test Name",
|
||||
email: "test@example.com",
|
||||
teamId: existing.teamId,
|
||||
ip,
|
||||
authentication: {
|
||||
authenticationProviderId: uuidv4(),
|
||||
providerId: existingAuth.providerId,
|
||||
@@ -168,11 +165,10 @@ describe("userProvisioner", () => {
|
||||
const team = await buildTeam();
|
||||
const authenticationProviders = await team.$get("authenticationProviders");
|
||||
const authenticationProvider = authenticationProviders[0];
|
||||
const result = await userProvisioner({
|
||||
const result = await userProvisioner(ctx, {
|
||||
name: "Test Name",
|
||||
email: "test@example.com",
|
||||
teamId: team.id,
|
||||
ip,
|
||||
authentication: {
|
||||
authenticationProviderId: authenticationProvider.id,
|
||||
providerId: "fake-service-id",
|
||||
@@ -196,12 +192,11 @@ describe("userProvisioner", () => {
|
||||
});
|
||||
const authenticationProviders = await team.$get("authenticationProviders");
|
||||
const authenticationProvider = authenticationProviders[0];
|
||||
const result = await userProvisioner({
|
||||
const result = await userProvisioner(ctx, {
|
||||
name: "Test Name",
|
||||
email: "test@example.com",
|
||||
teamId: team.id,
|
||||
role: UserRole.Admin,
|
||||
ip,
|
||||
authentication: {
|
||||
authenticationProviderId: authenticationProvider.id,
|
||||
providerId: "fake-service-id",
|
||||
@@ -219,11 +214,10 @@ describe("userProvisioner", () => {
|
||||
});
|
||||
const authenticationProviders = await team.$get("authenticationProviders");
|
||||
const authenticationProvider = authenticationProviders[0];
|
||||
const result = await userProvisioner({
|
||||
const result = await userProvisioner(ctx, {
|
||||
name: "Test Name",
|
||||
email: "test@example.com",
|
||||
teamId: team.id,
|
||||
ip,
|
||||
authentication: {
|
||||
authenticationProviderId: authenticationProvider.id,
|
||||
providerId: "fake-service-id",
|
||||
@@ -233,11 +227,10 @@ describe("userProvisioner", () => {
|
||||
});
|
||||
const { user: tname } = result;
|
||||
expect(tname.role).toEqual(UserRole.Viewer);
|
||||
const tname2Result = await userProvisioner({
|
||||
const tname2Result = await userProvisioner(ctx, {
|
||||
name: "Test2 Name",
|
||||
email: "tes2@example.com",
|
||||
teamId: team.id,
|
||||
ip,
|
||||
authentication: {
|
||||
authenticationProviderId: authenticationProvider.id,
|
||||
providerId: "fake-service-id",
|
||||
@@ -257,11 +250,10 @@ describe("userProvisioner", () => {
|
||||
});
|
||||
const authenticationProviders = await team.$get("authenticationProviders");
|
||||
const authenticationProvider = authenticationProviders[0];
|
||||
const result = await userProvisioner({
|
||||
const result = await userProvisioner(ctx, {
|
||||
name: invite.name,
|
||||
email: "invite@ExamPle.com",
|
||||
teamId: invite.teamId,
|
||||
ip,
|
||||
authentication: {
|
||||
authenticationProviderId: authenticationProvider.id,
|
||||
providerId: "fake-service-id",
|
||||
@@ -289,11 +281,10 @@ describe("userProvisioner", () => {
|
||||
email: externalUser.email,
|
||||
});
|
||||
|
||||
const result = await userProvisioner({
|
||||
const result = await userProvisioner(ctx, {
|
||||
name: invite.name,
|
||||
email: "external@ExamPle.com", // ensure that email is case insensistive
|
||||
teamId: invite.teamId,
|
||||
ip,
|
||||
});
|
||||
const { user, authentication, isNewUser } = result;
|
||||
expect(authentication).toEqual(null);
|
||||
@@ -309,11 +300,10 @@ describe("userProvisioner", () => {
|
||||
let error;
|
||||
|
||||
try {
|
||||
await userProvisioner({
|
||||
await userProvisioner(ctx, {
|
||||
name: "Uninvited User",
|
||||
email: "invite@ExamPle.com",
|
||||
teamId: team.id,
|
||||
ip,
|
||||
authentication: {
|
||||
authenticationProviderId: authenticationProvider.id,
|
||||
providerId: "fake-service-id",
|
||||
@@ -343,11 +333,10 @@ describe("userProvisioner", () => {
|
||||
const authenticationProviders = await team.$get("authenticationProviders");
|
||||
const authenticationProvider = authenticationProviders[0];
|
||||
const email = faker.internet.email({ provider: domain });
|
||||
const result = await userProvisioner({
|
||||
const result = await userProvisioner(ctx, {
|
||||
name: faker.person.fullName(),
|
||||
email,
|
||||
teamId: team.id,
|
||||
ip,
|
||||
authentication: {
|
||||
authenticationProviderId: authenticationProvider.id,
|
||||
providerId: "fake-service-id",
|
||||
@@ -376,11 +365,10 @@ describe("userProvisioner", () => {
|
||||
createdById: admin.id,
|
||||
});
|
||||
|
||||
const result = await userProvisioner({
|
||||
const result = await userProvisioner(ctx, {
|
||||
name: "Test Name",
|
||||
email,
|
||||
teamId: team.id,
|
||||
ip,
|
||||
});
|
||||
const { user, authentication, isNewUser } = result;
|
||||
expect(authentication).toBeUndefined();
|
||||
@@ -393,11 +381,10 @@ describe("userProvisioner", () => {
|
||||
let error;
|
||||
|
||||
try {
|
||||
await userProvisioner({
|
||||
await userProvisioner(ctx, {
|
||||
name: "Test Name",
|
||||
email: faker.internet.email(),
|
||||
teamId: team.id,
|
||||
ip,
|
||||
});
|
||||
} catch (err) {
|
||||
error = err;
|
||||
@@ -420,11 +407,10 @@ describe("userProvisioner", () => {
|
||||
let error;
|
||||
|
||||
try {
|
||||
await userProvisioner({
|
||||
await userProvisioner(ctx, {
|
||||
name: "Bad Domain User",
|
||||
email: faker.internet.email(),
|
||||
teamId: team.id,
|
||||
ip,
|
||||
authentication: {
|
||||
authenticationProviderId: authenticationProvider.id,
|
||||
providerId: "fake-service-id",
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
InviteRequiredError,
|
||||
} from "@server/errors";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Event, Team, User, UserAuthentication } from "@server/models";
|
||||
import { Team, User, UserAuthentication } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { APIContext } from "@server/types";
|
||||
|
||||
type UserProvisionerResult = {
|
||||
user: User;
|
||||
@@ -32,8 +33,6 @@ type Props = {
|
||||
* subdomain that the request came from, if any.
|
||||
*/
|
||||
teamId: string;
|
||||
/** The IP address of the incoming request */
|
||||
ip: string;
|
||||
/** Bundle of props related to the current external provider authentication */
|
||||
authentication?: {
|
||||
authenticationProviderId: string;
|
||||
@@ -50,16 +49,10 @@ type Props = {
|
||||
};
|
||||
};
|
||||
|
||||
export default async function userProvisioner({
|
||||
name,
|
||||
email,
|
||||
role,
|
||||
language,
|
||||
avatarUrl,
|
||||
teamId,
|
||||
authentication,
|
||||
ip,
|
||||
}: Props): Promise<UserProvisionerResult> {
|
||||
export default async function userProvisioner(
|
||||
ctx: APIContext,
|
||||
{ name, email, role, language, avatarUrl, teamId, authentication }: Props
|
||||
): Promise<UserProvisionerResult> {
|
||||
const auth = authentication
|
||||
? await UserAuthentication.findOne({
|
||||
where: {
|
||||
@@ -137,24 +130,6 @@ export default async function userProvisioner({
|
||||
const isInvite = existingUser.isInvited;
|
||||
|
||||
const userAuth = await sequelize.transaction(async (transaction) => {
|
||||
if (isInvite) {
|
||||
await Event.create(
|
||||
{
|
||||
name: "users.create",
|
||||
actorId: existingUser.id,
|
||||
userId: existingUser.id,
|
||||
teamId: existingUser.teamId,
|
||||
data: {
|
||||
name,
|
||||
},
|
||||
ip,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Regardless, create a new authentication record
|
||||
// against the existing user (user can auth with multiple SSO providers)
|
||||
// Update user's name and avatar based on the most recently added provider
|
||||
@@ -163,7 +138,7 @@ export default async function userProvisioner({
|
||||
name,
|
||||
avatarUrl,
|
||||
lastActiveAt: new Date(),
|
||||
lastActiveIp: ip,
|
||||
lastActiveIp: ctx.ip,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
@@ -230,7 +205,8 @@ export default async function userProvisioner({
|
||||
throw DomainNotAllowedError();
|
||||
}
|
||||
|
||||
const user = await User.create(
|
||||
const user = await User.createWithCtx(
|
||||
ctx,
|
||||
{
|
||||
name,
|
||||
email,
|
||||
@@ -239,25 +215,12 @@ export default async function userProvisioner({
|
||||
teamId,
|
||||
avatarUrl,
|
||||
authentications: authentication ? [authentication] : [],
|
||||
lastActiveAt: new Date(),
|
||||
lastActiveIp: ctx.ip,
|
||||
} as Partial<InferCreationAttributes<User>>,
|
||||
undefined,
|
||||
{
|
||||
include: "authentications",
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
await Event.create(
|
||||
{
|
||||
name: "users.create",
|
||||
actorId: user.id,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
data: {
|
||||
name: user.name,
|
||||
},
|
||||
ip,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
await transaction.commit();
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import GroupUser from "@server/models/GroupUser";
|
||||
import { buildGroup, buildAdmin, buildUser } from "@server/test/factories";
|
||||
import userSuspender from "./userSuspender";
|
||||
|
||||
describe("userSuspender", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should not suspend self", async () => {
|
||||
const user = await buildUser();
|
||||
let error;
|
||||
|
||||
try {
|
||||
await userSuspender({
|
||||
actorId: user.id,
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
expect(error.message).toEqual("Unable to suspend the current user");
|
||||
});
|
||||
|
||||
it("should suspend the user", async () => {
|
||||
const admin = await buildAdmin();
|
||||
const user = await buildUser({
|
||||
teamId: admin.teamId,
|
||||
});
|
||||
await userSuspender({
|
||||
actorId: admin.id,
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
expect(user.suspendedAt).toBeTruthy();
|
||||
expect(user.suspendedById).toEqual(admin.id);
|
||||
});
|
||||
|
||||
it("should remove group memberships", async () => {
|
||||
const admin = await buildAdmin();
|
||||
const user = await buildUser({
|
||||
teamId: admin.teamId,
|
||||
});
|
||||
const group = await buildGroup({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
await group.$add("user", user, {
|
||||
through: {
|
||||
createdById: user.id,
|
||||
},
|
||||
});
|
||||
await userSuspender({
|
||||
actorId: admin.id,
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
expect(user.suspendedAt).toBeTruthy();
|
||||
expect(user.suspendedById).toEqual(admin.id);
|
||||
expect(
|
||||
await GroupUser.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
).toEqual(0);
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { User, Event, GroupUser } from "@server/models";
|
||||
import CleanupDemotedUserTask from "@server/queues/tasks/CleanupDemotedUserTask";
|
||||
import { ValidationError } from "../errors";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
actorId: string;
|
||||
transaction?: Transaction;
|
||||
ip: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* This command suspends an active user, this will cause them to lose access to
|
||||
* the team.
|
||||
*/
|
||||
export default async function userSuspender({
|
||||
user,
|
||||
actorId,
|
||||
transaction,
|
||||
ip,
|
||||
}: Props): Promise<void> {
|
||||
if (user.id === actorId) {
|
||||
throw ValidationError("Unable to suspend the current user");
|
||||
}
|
||||
|
||||
await user.update(
|
||||
{
|
||||
suspendedById: actorId,
|
||||
suspendedAt: new Date(),
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
await GroupUser.destroy({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
transaction,
|
||||
individualHooks: true,
|
||||
});
|
||||
await Event.create(
|
||||
{
|
||||
name: "users.suspend",
|
||||
actorId,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
data: {
|
||||
name: user.name,
|
||||
},
|
||||
ip,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
|
||||
await new CleanupDemotedUserTask().schedule({ userId: user.id });
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { buildAdmin, buildUser } from "@server/test/factories";
|
||||
import userUnsuspender from "./userUnsuspender";
|
||||
|
||||
describe("userUnsuspender", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should not allow unsuspending self", async () => {
|
||||
const user = await buildUser();
|
||||
let error;
|
||||
|
||||
try {
|
||||
await userUnsuspender({
|
||||
actorId: user.id,
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
expect(error.message).toEqual("Unable to unsuspend the current user");
|
||||
});
|
||||
|
||||
it("should unsuspend the user", async () => {
|
||||
const admin = await buildAdmin();
|
||||
const user = await buildUser({
|
||||
teamId: admin.teamId,
|
||||
suspendedAt: new Date(),
|
||||
suspendedById: admin.id,
|
||||
});
|
||||
await userUnsuspender({
|
||||
actorId: admin.id,
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
expect(user.suspendedAt).toEqual(null);
|
||||
expect(user.suspendedById).toEqual(null);
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { User, Event } from "@server/models";
|
||||
import { ValidationError } from "../errors";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
actorId: string;
|
||||
transaction?: Transaction;
|
||||
ip: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* This command unsuspends a previously suspended user, allowing access to the
|
||||
* team again.
|
||||
*/
|
||||
export default async function userUnsuspender({
|
||||
user,
|
||||
actorId,
|
||||
transaction,
|
||||
ip,
|
||||
}: Props): Promise<void> {
|
||||
if (user.id === actorId) {
|
||||
throw ValidationError("Unable to unsuspend the current user");
|
||||
}
|
||||
|
||||
await user.update(
|
||||
{
|
||||
suspendedById: null,
|
||||
suspendedAt: null,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await Event.create(
|
||||
{
|
||||
name: "users.activate",
|
||||
actorId,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
data: {
|
||||
name: user.name,
|
||||
},
|
||||
ip,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -13,9 +13,14 @@ export function createContext({
|
||||
ip?: string | null;
|
||||
transaction?: Transaction;
|
||||
}) {
|
||||
const auth = { user, type: authType };
|
||||
return {
|
||||
state: {
|
||||
auth,
|
||||
transaction,
|
||||
},
|
||||
context: {
|
||||
auth: { user, type: authType },
|
||||
auth,
|
||||
ip: ip ?? user?.lastActiveIp,
|
||||
transaction,
|
||||
},
|
||||
|
||||
@@ -194,7 +194,7 @@ class Share extends IdModel<
|
||||
documentId: string;
|
||||
|
||||
revoke(ctx: APIContext) {
|
||||
const { user } = ctx.context.auth;
|
||||
const { user } = ctx.state.auth;
|
||||
this.revokedAt = new Date();
|
||||
this.revokedById = user.id;
|
||||
return this.saveWithCtx(ctx, undefined, { name: "revoke" });
|
||||
|
||||
@@ -50,6 +50,7 @@ import IsFQDN from "./validators/IsFQDN";
|
||||
import IsUrlOrRelativePath from "./validators/IsUrlOrRelativePath";
|
||||
import Length from "./validators/Length";
|
||||
import NotContainsUrl from "./validators/NotContainsUrl";
|
||||
import { SkipChangeset } from "./decorators/Changeset";
|
||||
|
||||
@Scopes(() => ({
|
||||
withDomains: {
|
||||
@@ -174,6 +175,7 @@ class Team extends ParanoidModel<
|
||||
/** Approximate size in bytes of all attachments in the team. */
|
||||
@IsNumeric
|
||||
@Column(DataType.BIGINT)
|
||||
@SkipChangeset
|
||||
approximateTotalAttachmentsSize: number;
|
||||
|
||||
@AllowNull
|
||||
@@ -186,9 +188,11 @@ class Team extends ParanoidModel<
|
||||
|
||||
@IsDate
|
||||
@Column
|
||||
@SkipChangeset
|
||||
lastActiveAt: Date | null;
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
@SkipChangeset
|
||||
previousSubdomains: string[] | null;
|
||||
|
||||
// getters
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { buildUser, buildTeam, buildCollection } from "@server/test/factories";
|
||||
import {
|
||||
buildUser,
|
||||
buildTeam,
|
||||
buildCollection,
|
||||
buildAdmin,
|
||||
} from "@server/test/factories";
|
||||
import { withAPIContext } from "@server/test/support";
|
||||
import UserMembership from "./UserMembership";
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -37,10 +43,78 @@ describe("user model", () => {
|
||||
describe("destroy", () => {
|
||||
it("should clear PII", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
await buildUser({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
await user.destroy();
|
||||
expect(user.email).toBe(null);
|
||||
expect(user.name).toBe("Unknown");
|
||||
});
|
||||
|
||||
it("should prevent last user from deleting account", async () => {
|
||||
const user = await buildUser();
|
||||
let error;
|
||||
|
||||
try {
|
||||
await user.destroy();
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
expect(error && error.message).toContain("Cannot delete last user");
|
||||
});
|
||||
|
||||
it("should prevent last admin from deleting account", async () => {
|
||||
const user = await buildAdmin();
|
||||
await buildUser({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
let error;
|
||||
|
||||
try {
|
||||
await user.destroy();
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
expect(error && error.message).toContain("Cannot delete account");
|
||||
});
|
||||
|
||||
it("should not prevent multiple admin from deleting account", async () => {
|
||||
const actor = await buildAdmin();
|
||||
const user = await buildAdmin({
|
||||
teamId: actor.teamId,
|
||||
});
|
||||
let error;
|
||||
|
||||
try {
|
||||
await user.destroy();
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
expect(error).toBeFalsy();
|
||||
expect(user.deletedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not prevent last non-admin from deleting account", async () => {
|
||||
const user = await buildUser();
|
||||
await buildUser({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
let error;
|
||||
|
||||
try {
|
||||
await user.destroy();
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
expect(error).toBeFalsy();
|
||||
expect(user.deletedAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getJwtToken", () => {
|
||||
|
||||
@@ -66,6 +66,7 @@ import Fix from "./decorators/Fix";
|
||||
import IsUrlOrRelativePath from "./validators/IsUrlOrRelativePath";
|
||||
import Length from "./validators/Length";
|
||||
import NotContainsUrl from "./validators/NotContainsUrl";
|
||||
import { SkipChangeset } from "./decorators/Changeset";
|
||||
|
||||
/**
|
||||
* Flags that are available for setting on the user.
|
||||
@@ -157,22 +158,27 @@ class User extends ParanoidModel<
|
||||
|
||||
@IsDate
|
||||
@Column
|
||||
@SkipChangeset
|
||||
lastActiveAt: Date | null;
|
||||
|
||||
@IsIP
|
||||
@Column
|
||||
@SkipChangeset
|
||||
lastActiveIp: string | null;
|
||||
|
||||
@IsDate
|
||||
@Column
|
||||
@SkipChangeset
|
||||
lastSignedInAt: Date | null;
|
||||
|
||||
@IsIP
|
||||
@Column
|
||||
@SkipChangeset
|
||||
lastSignedInIp: string | null;
|
||||
|
||||
@IsDate
|
||||
@Column
|
||||
@SkipChangeset
|
||||
lastSigninEmailSentAt: Date | null;
|
||||
|
||||
@IsDate
|
||||
@@ -645,6 +651,52 @@ class User extends ParanoidModel<
|
||||
|
||||
// hooks
|
||||
|
||||
@BeforeDestroy
|
||||
static async checkLastUser(
|
||||
model: User,
|
||||
{ transaction }: { transaction: Transaction }
|
||||
) {
|
||||
const usersCount = await this.count({
|
||||
where: {
|
||||
teamId: model.teamId,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (usersCount === 1) {
|
||||
throw ValidationError(
|
||||
"Cannot delete last user on the team, delete the workspace instead."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeDestroy
|
||||
static async checkLastAdmin(
|
||||
model: User,
|
||||
{ transaction }: { transaction: Transaction }
|
||||
) {
|
||||
if (model.role !== UserRole.Admin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const otherAdminsCount = await this.count({
|
||||
where: {
|
||||
teamId: model.teamId,
|
||||
role: UserRole.Admin,
|
||||
id: {
|
||||
[Op.ne]: model.id,
|
||||
},
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (otherAdminsCount === 0) {
|
||||
throw ValidationError(
|
||||
"Cannot delete account as only admin. Please make another user admin and try again."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeDestroy
|
||||
static removeIdentifyingInfo = async (
|
||||
model: User,
|
||||
@@ -754,7 +806,7 @@ class User extends ParanoidModel<
|
||||
static findByEmail = async function (ctx: APIContext, email: string) {
|
||||
return this.findOne({
|
||||
where: {
|
||||
teamId: ctx.context.auth.user.teamId,
|
||||
teamId: ctx.state.auth.user.teamId,
|
||||
email: email.trim().toLowerCase(),
|
||||
},
|
||||
...ctx.context,
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("View", () => {
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
await user.destroy();
|
||||
await user.destroy({ hooks: false });
|
||||
|
||||
const views = await View.findByDocument(document.id, {
|
||||
includeSuspended: false,
|
||||
|
||||
@@ -18,6 +18,7 @@ import Document from "./Document";
|
||||
import User from "./User";
|
||||
import IdModel from "./base/IdModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
import { SkipChangeset } from "./decorators/Changeset";
|
||||
|
||||
@Scopes(() => ({
|
||||
withUser: () => ({
|
||||
@@ -41,6 +42,7 @@ class View extends IdModel<
|
||||
|
||||
@Default(1)
|
||||
@Column(DataType.INTEGER)
|
||||
@SkipChangeset
|
||||
count: number;
|
||||
|
||||
// associations
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Replace, APIContext } from "@server/types";
|
||||
import { getChangsetSkipped } from "../decorators/Changeset";
|
||||
import { InternalError } from "@server/errors";
|
||||
|
||||
type EventOverrideOptions = {
|
||||
/** Override the default event name. */
|
||||
@@ -241,6 +242,12 @@ class Model<
|
||||
});
|
||||
}
|
||||
|
||||
if (context.event.name?.includes(".")) {
|
||||
throw InternalError(
|
||||
`Event name (${context.event.name}) should not include a period, the namespace is automatically prefixed`
|
||||
);
|
||||
}
|
||||
|
||||
const attrs = {
|
||||
name: `${namespace}.${context.event.name ?? name}`,
|
||||
modelId: "modelId" in model ? model.modelId : model.id,
|
||||
|
||||
@@ -30,10 +30,23 @@ export default function Fix(target: any): void {
|
||||
|
||||
Object.defineProperty(this, propertyKey, {
|
||||
get() {
|
||||
// Safety check for Jest serialization - getDataValue may not be available
|
||||
// during serialization for inter-process communication
|
||||
if (typeof this.getDataValue === "function") {
|
||||
return this.getDataValue(propertyKey);
|
||||
}
|
||||
// Fallback to direct dataValues access
|
||||
return this.dataValues?.[propertyKey];
|
||||
},
|
||||
set(value) {
|
||||
// Safety check for Jest serialization - setDataValue may not be available
|
||||
// during serialization for inter-process communication
|
||||
if (typeof this.setDataValue === "function") {
|
||||
this.setDataValue(propertyKey, value);
|
||||
} else if (this.dataValues) {
|
||||
// Fallback to direct dataValues assignment
|
||||
this.dataValues[propertyKey] = value;
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -301,7 +301,7 @@ describe("NotificationHelper", () => {
|
||||
userId: deletedUser.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
await deletedUser.destroy();
|
||||
await deletedUser.destroy({ hooks: false });
|
||||
|
||||
const recipients =
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("ProsemirrorHelper", () => {
|
||||
modelId: user.id,
|
||||
};
|
||||
|
||||
await user.destroy();
|
||||
await user.destroy({ hooks: false });
|
||||
|
||||
const mentionedParagraph: DeepPartial<ProsemirrorData> = {
|
||||
type: "paragraph",
|
||||
|
||||
@@ -57,14 +57,17 @@ allow(User, "delete", User, (actor, user) =>
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, ["activate", "suspend"], User, isTeamAdmin);
|
||||
allow(User, ["activate", "suspend"], User, (actor, user) =>
|
||||
and(isTeamAdmin(actor, user), user?.id !== actor.id)
|
||||
);
|
||||
|
||||
allow(User, "promote", User, (actor, user) =>
|
||||
and(
|
||||
//
|
||||
isTeamAdmin(actor, user),
|
||||
!user?.isAdmin,
|
||||
!user?.isSuspended
|
||||
!user?.isSuspended,
|
||||
user?.id !== actor.id
|
||||
)
|
||||
);
|
||||
|
||||
@@ -72,7 +75,8 @@ allow(User, "demote", User, (actor, user) =>
|
||||
and(
|
||||
//
|
||||
isTeamAdmin(actor, user),
|
||||
!user?.isSuspended
|
||||
!user?.isSuspended,
|
||||
user?.id !== actor.id
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -39,8 +39,7 @@ export default class CollectionsProcessor extends BaseProcessor {
|
||||
ip: event.ip,
|
||||
});
|
||||
|
||||
await teamUpdater({
|
||||
ctx,
|
||||
await teamUpdater(ctx, {
|
||||
params: { defaultCollectionId: null },
|
||||
user,
|
||||
team,
|
||||
|
||||
@@ -3,7 +3,7 @@ import CleanupDemotedUserTask from "../tasks/CleanupDemotedUserTask";
|
||||
import BaseProcessor from "./BaseProcessor";
|
||||
|
||||
export default class UserDemotedProcessor extends BaseProcessor {
|
||||
static applicableEvents: TEvent["name"][] = ["users.demote"];
|
||||
static applicableEvents: TEvent["name"][] = ["users.demote", "users.suspend"];
|
||||
|
||||
async perform(event: UserEvent) {
|
||||
await new CleanupDemotedUserTask().schedule({ userId: event.userId });
|
||||
|
||||
@@ -659,8 +659,7 @@ router.post(
|
||||
collection.permission === null &&
|
||||
team?.defaultCollectionId === collection.id
|
||||
) {
|
||||
await teamUpdater({
|
||||
ctx,
|
||||
await teamUpdater(ctx, {
|
||||
params: { defaultCollectionId: null },
|
||||
user,
|
||||
team,
|
||||
|
||||
@@ -29,7 +29,6 @@ router.post(
|
||||
validate(T.CreateTestUsersSchema),
|
||||
async (ctx: APIContext<T.CreateTestUsersReq>) => {
|
||||
const { count = 10 } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const invites = Array(Math.min(count, 100))
|
||||
.fill(0)
|
||||
.map(() => {
|
||||
@@ -45,11 +44,7 @@ router.post(
|
||||
Logger.info("utils", `Creating ${count} test users`, invites);
|
||||
|
||||
// Generate a bunch of invites
|
||||
const response = await userInviter({
|
||||
user,
|
||||
invites,
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
const response = await userInviter(ctx, { invites });
|
||||
|
||||
// Convert from invites to active users by marking as active
|
||||
await Promise.all(
|
||||
|
||||
@@ -287,7 +287,7 @@ describe("#events.list", () => {
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
await user.destroy();
|
||||
await user.destroy({ hooks: false });
|
||||
const res = await server.post("/api/events.list", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
|
||||
@@ -183,7 +183,7 @@ describe("#groups.list", () => {
|
||||
createdById: me.id,
|
||||
},
|
||||
});
|
||||
await user.destroy();
|
||||
await user.destroy({ hooks: false });
|
||||
const res = await server.post("/api/groups.list", {
|
||||
body: {
|
||||
token: me.getJwtToken(),
|
||||
|
||||
@@ -29,24 +29,18 @@ router.post(
|
||||
throw ValidationError("Installation already has existing teams");
|
||||
}
|
||||
|
||||
const team = await teamCreator({
|
||||
ctx,
|
||||
const team = await teamCreator(ctx, {
|
||||
name: teamName,
|
||||
subdomain: slugify(teamName),
|
||||
authenticationProviders: [],
|
||||
});
|
||||
|
||||
const user = await User.create(
|
||||
{
|
||||
const user = await User.createWithCtx(ctx, {
|
||||
name: userName,
|
||||
email: userEmail,
|
||||
teamId: team.id,
|
||||
role: UserRole.Admin,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
await signIn(ctx, "email", {
|
||||
user,
|
||||
|
||||
@@ -9,7 +9,7 @@ import auth from "@server/middlewares/authentication";
|
||||
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Event, Team, TeamDomain, User } from "@server/models";
|
||||
import { Team, TeamDomain, User } from "@server/models";
|
||||
import { authorize } from "@server/policies";
|
||||
import { presentTeam, presentPolicies } from "@server/presenters";
|
||||
import { APIContext } from "@server/types";
|
||||
@@ -29,8 +29,7 @@ const handleTeamUpdate = async (ctx: APIContext<T.TeamsUpdateSchemaReq>) => {
|
||||
});
|
||||
authorize(user, "update", team);
|
||||
|
||||
const updatedTeam = await teamUpdater({
|
||||
ctx,
|
||||
const updatedTeam = await teamUpdater(ctx, {
|
||||
params: ctx.input.body,
|
||||
user,
|
||||
team,
|
||||
@@ -140,36 +139,18 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
const team = await teamCreator({
|
||||
ctx,
|
||||
const team = await teamCreator(ctx, {
|
||||
name,
|
||||
subdomain: name,
|
||||
authenticationProviders,
|
||||
});
|
||||
|
||||
const newUser = await User.create(
|
||||
{
|
||||
const newUser = await User.createWithCtx(ctx, {
|
||||
teamId: team.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: UserRole.Admin,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "users.create",
|
||||
actorId: user.id,
|
||||
userId: newUser.id,
|
||||
teamId: newUser.teamId,
|
||||
data: {
|
||||
name: newUser.name,
|
||||
},
|
||||
ip: ctx.ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
|
||||
@@ -45,12 +45,12 @@ exports[`#users.promote should require admin 1`] = `
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.suspend should not allow suspending the user themselves 1`] = `
|
||||
exports[`#users.suspend should not allow suspending self 1`] = `
|
||||
{
|
||||
"error": "validation_error",
|
||||
"message": "Unable to suspend the current user",
|
||||
"error": "authorization_error",
|
||||
"message": "Authorization error",
|
||||
"ok": false,
|
||||
"status": 400,
|
||||
"status": 403,
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -750,7 +750,7 @@ describe("#users.updateEmail", () => {
|
||||
|
||||
await TeamDomain.create({
|
||||
teamId: user.teamId,
|
||||
name: "example.com",
|
||||
name: "getoutline.com",
|
||||
createdById: user.id,
|
||||
});
|
||||
|
||||
@@ -971,7 +971,7 @@ describe("#users.suspend", () => {
|
||||
expect(res.status).toEqual(200);
|
||||
});
|
||||
|
||||
it("should not allow suspending the user themselves", async () => {
|
||||
it("should not allow suspending self", async () => {
|
||||
const admin = await buildAdmin();
|
||||
const res = await server.post("/api/users.suspend", {
|
||||
body: {
|
||||
@@ -980,7 +980,7 @@ describe("#users.suspend", () => {
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(400);
|
||||
expect(res.status).toEqual(403);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
|
||||
@@ -4,10 +4,7 @@ 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";
|
||||
@@ -18,7 +15,7 @@ import auth from "@server/middlewares/authentication";
|
||||
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Event, User, Team } from "@server/models";
|
||||
import { User, Team } from "@server/models";
|
||||
import { UserFlag } from "@server/models/User";
|
||||
import { can, authorize } from "@server/policies";
|
||||
import { presentUser, presentPolicies } from "@server/presenters";
|
||||
@@ -291,13 +288,7 @@ router.get(
|
||||
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 });
|
||||
await user.updateWithCtx(ctx, { email });
|
||||
|
||||
ctx.redirect(settingsPath());
|
||||
}
|
||||
@@ -343,12 +334,7 @@ router.post(
|
||||
user.timezone = timezone;
|
||||
}
|
||||
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "users.update",
|
||||
userId: user.id,
|
||||
changes: user.changeset,
|
||||
});
|
||||
await user.save({ transaction });
|
||||
await user.saveWithCtx(ctx);
|
||||
|
||||
ctx.body = {
|
||||
data: presentUser(user, {
|
||||
@@ -440,25 +426,15 @@ async function updateRole(ctx: APIContext<T.UsersChangeRoleReq>) {
|
||||
}
|
||||
|
||||
if (UserRoleHelper.canDemote(user, role)) {
|
||||
name = "users.demote";
|
||||
name = "demote";
|
||||
authorize(actor, "demote", user);
|
||||
}
|
||||
if (UserRoleHelper.canPromote(user, role)) {
|
||||
name = "users.promote";
|
||||
name = "promote";
|
||||
authorize(actor, "promote", user);
|
||||
}
|
||||
|
||||
await user.update({ role }, { transaction });
|
||||
|
||||
await Event.createFromContext(ctx, {
|
||||
name,
|
||||
userId,
|
||||
data: {
|
||||
name: user.name,
|
||||
role,
|
||||
},
|
||||
});
|
||||
|
||||
await user.updateWithCtx(ctx, { role }, { name });
|
||||
const includeDetails = !!can(actor, "readDetails", user);
|
||||
|
||||
ctx.body = {
|
||||
@@ -485,12 +461,16 @@ router.post(
|
||||
});
|
||||
authorize(actor, "suspend", user);
|
||||
|
||||
await userSuspender({
|
||||
user,
|
||||
actorId: actor.id,
|
||||
ip: ctx.request.ip,
|
||||
transaction,
|
||||
});
|
||||
await user.updateWithCtx(
|
||||
ctx,
|
||||
{
|
||||
suspendedById: actor.id,
|
||||
suspendedAt: new Date(),
|
||||
},
|
||||
{
|
||||
name: "suspend",
|
||||
}
|
||||
);
|
||||
const includeDetails = !!can(actor, "readDetails", user);
|
||||
|
||||
ctx.body = {
|
||||
@@ -518,12 +498,16 @@ router.post(
|
||||
});
|
||||
authorize(actor, "activate", user);
|
||||
|
||||
await userUnsuspender({
|
||||
user,
|
||||
actorId: actor.id,
|
||||
transaction,
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
await user.updateWithCtx(
|
||||
ctx,
|
||||
{
|
||||
suspendedById: null,
|
||||
suspendedAt: null,
|
||||
},
|
||||
{
|
||||
name: "activate",
|
||||
}
|
||||
);
|
||||
const includeDetails = !!can(actor, "readDetails", user);
|
||||
|
||||
ctx.body = {
|
||||
@@ -542,29 +526,22 @@ router.post(
|
||||
validate(T.UsersInviteSchema),
|
||||
async (ctx: APIContext<T.UsersInviteReq>) => {
|
||||
const { invites } = ctx.input.body;
|
||||
const actor = ctx.state.auth.user;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
if (invites.length > UserValidation.maxInvitesPerRequest) {
|
||||
throw ValidationError(
|
||||
`You can only invite up to ${UserValidation.maxInvitesPerRequest} users at a time`
|
||||
);
|
||||
}
|
||||
authorize(user, "inviteUser", user.team);
|
||||
|
||||
const { user } = ctx.state.auth;
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
authorize(user, "inviteUser", team);
|
||||
|
||||
const response = await userInviter({
|
||||
user,
|
||||
invites,
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
const response = await userInviter(ctx, { invites });
|
||||
|
||||
ctx.body = {
|
||||
data: {
|
||||
sent: response.sent,
|
||||
users: response.users.map((user) =>
|
||||
presentUser(user, { includeEmail: !!can(actor, "readEmail", user) })
|
||||
presentUser(user, { includeEmail: !!can(user, "readEmail", user) })
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -676,9 +653,7 @@ router.post(
|
||||
}
|
||||
}
|
||||
|
||||
await userDestroyer(ctx, {
|
||||
user,
|
||||
});
|
||||
await user.destroyWithCtx(ctx);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
@@ -693,16 +668,10 @@ router.post(
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.UsersNotificationsSubscribeReq>) => {
|
||||
const { eventType } = ctx.input.body;
|
||||
const { transaction } = ctx.state;
|
||||
const { user } = ctx.state.auth;
|
||||
user.setNotificationEventType(eventType, true);
|
||||
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "users.update",
|
||||
userId: user.id,
|
||||
changes: user.changeset,
|
||||
});
|
||||
await user.save({ transaction });
|
||||
await user.saveWithCtx(ctx);
|
||||
|
||||
ctx.body = {
|
||||
data: presentUser(user, { includeDetails: true }),
|
||||
@@ -717,16 +686,10 @@ router.post(
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.UsersNotificationsUnsubscribeReq>) => {
|
||||
const { eventType } = ctx.input.body;
|
||||
const { transaction } = ctx.state;
|
||||
const { user } = ctx.state.auth;
|
||||
user.setNotificationEventType(eventType, false);
|
||||
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "users.update",
|
||||
userId: user.id,
|
||||
changes: user.changeset,
|
||||
});
|
||||
await user.save({ transaction });
|
||||
await user.saveWithCtx(ctx);
|
||||
|
||||
ctx.body = {
|
||||
data: presentUser(user, { includeDetails: true }),
|
||||
|
||||
Reference in New Issue
Block a user