Ctrl K
ComunidadGuías…

server/commands/accountProvisioner.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import path from "node:path";
import { readFile } from "fs-extra";
import invariant from "invariant";
import { errToString, errToId } from "@shared/utils/error";
import { CollectionPermission, UserRole } from "@shared/types";
import env from "@server/env";
import {
  InvalidAuthenticationError,
  AuthenticationProviderDisabledError,
} from "@server/errors";
import { traceFunction } from "@server/logging/tracing";
import type { User } from "@server/models";
import {
  AuthenticationProvider,
  Collection,
  Document,
  Event,
  Team,
} from "@server/models";
import AuthenticationHelper from "@server/models/helpers/AuthenticationHelper";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { sequelize } from "@server/storage/database";
import SyncUserGroupsTask from "@server/queues/tasks/SyncUserGroupsTask";
import teamProvisioner from "./teamProvisioner";
import userProvisioner from "./userProvisioner";
import type { APIContext } from "@server/types";
import { addSeconds } from "date-fns";
import { createContext } from "@server/context";

type Props = {
  /** Details of the user logging in from SSO provider */
  user: {
    /** The displayed name of the user */
    name: string;
    /** The email address of the user */
    email: string;
    /** Whether the provider has verified the user owns the email address */
    emailVerified?: boolean;
    /** The public url of an image representing the user */
    avatarUrl?: string | null;
    /** The language of the user, if known */
    language?: string;
  };
  /** Details of the team the user is logging into */
  team: {
    /**
     * The internal ID of the team that is being logged into based on the
     * subdomain that the request came from, if any.
     */
    teamId?: string;
    /** The displayed name of the team */
    name?: string;
    /** The domain name from the email of the user logging in */
    domain?: string;
    /** The preferred subdomain to provision for the team if not yet created */
    subdomain: string;
    /** The public url of an image representing the team */
    avatarUrl?: string | null;
  };
  /** Details of the authentication provider being used */
  authenticationProvider: {
    /** The name of the authentication provider, eg "google" */
    name: string;
    /** External identifier of the authentication provider */
    providerId: string;
  };
  /** Details of the authentication from SSO provider */
  authentication: {
    /** External identifier of the user in the authentication provider  */
    providerId: string;
    /** The scopes granted by the access token */
    scopes: string[];
    /** The token provided by the authentication provider */
    accessToken?: string;
    /** The refresh token provided by the authentication provider */
    refreshToken?: string;
    /** A number of seconds that the given access token expires in */
    expiresIn?: number;
  };
};

export type AccountProvisionerResult = {
  user: User;
  team: Team;
  isNewTeam: boolean;
  isNewUser: boolean;
};

async function accountProvisioner(
  ctx: APIContext,
  {
    user: userParams,
    team: teamParams,
    authenticationProvider: authenticationProviderParams,
    authentication: authenticationParams,
  }: Props
): Promise<AccountProvisionerResult> {
  let result;
  let emailMatchOnly;

  const actor = ctx.state.auth?.user;

  // If the user is already logged in and is an admin of the team then we
  // allow them to connect a new authentication provider.
  if (actor && actor.teamId === teamParams.teamId && actor.isAdmin) {
    const team = actor.team;
    const authenticationProvider = await AuthenticationProvider.findOne({
      where: {
        ...authenticationProviderParams,
        teamId: team.id,
      },
    });

    if (!authenticationProvider) {
      await team.$create<AuthenticationProvider>(
        "authenticationProvider",
        authenticationProviderParams
      );
    }

    return {
      user: actor,
      team,
      isNewUser: false,
      isNewTeam: false,
    };
  }

  try {
    result = await teamProvisioner(ctx, {
      ...teamParams,
      name: teamParams.name || "Wiki",
      authenticationProvider: authenticationProviderParams,
    });
  } catch (err) {
    // The account could not be provisioned for the provided teamId
    // check to see if we can try authentication using email matching only
    if (
      errToId(err) === "invalid_authentication" &&
      AuthenticationHelper.allowsEmailMatch(authenticationProviderParams.name)
    ) {
      const authProvider = await AuthenticationProvider.findOne({
        where: {
          name: authenticationProviderParams.name,
          teamId: teamParams.teamId,
        },
        include: [
          {
            model: Team,
            as: "team",
            required: true,
          },
        ],
        order: [["enabled", "DESC"]],
      });

      if (authProvider) {
        emailMatchOnly = true;
        result = {
          authenticationProvider: authProvider,
          team: authProvider.team,
          isNewTeam: false,
        };
      }
    }

    if (!result) {
      if (errToId(err)) {
        throw err;
      } else {
        throw InvalidAuthenticationError(errToString(err));
      }
    }
  }

  invariant(result, "Team creator result must exist");
  const { authenticationProvider, team, isNewTeam } = result;

  if (!authenticationProvider.enabled) {
    throw AuthenticationProviderDisabledError();
  }

  result = await userProvisioner(ctx, {
    name: userParams.name,
    email: userParams.email,
    emailVerified: userParams.emailVerified,
    authenticationProviderName: AuthenticationHelper.getProviderName(
      authenticationProviderParams.name
    ),
    language: userParams.language,
    role: isNewTeam ? UserRole.Admin : undefined,
    avatarUrl: userParams.avatarUrl,
    teamId: team.id,
    authentication: emailMatchOnly
      ? undefined
      : {
          authenticationProviderId: authenticationProvider.id,
          ...authenticationParams,
          expiresAt: authenticationParams.expiresIn
            ? addSeconds(Date.now(), authenticationParams.expiresIn)
            : undefined,
        },
  });
  const { isNewUser, isInvite, user } = result;

  if (isInvite) {
    // The browser may have a session for another user, which must not be used.
    await Event.create(
      {
        name: "users.invite_accepted",
        userId: user.id,
        actorId: user.id,
        teamId: user.teamId,
        ip: ctx.context.ip,
      },
      { transaction: ctx.state.transaction }
    );
  }

  if (isNewUser || isNewTeam) {
    let provision = isNewTeam;

    // accounts for the case where a team is provisioned, but the user creation
    // failed. In this case we have a valid previously created team but no
    // onboarding collection.
    if (!isNewTeam) {
      const count = await Collection.count({
        where: {
          teamId: team.id,
          ownerId: null,
        },
      });
      provision = count === 0;
    }

    if (provision) {
      await provisionFirstCollection(ctx, team, user);
    }
  }

  // Sync group memberships from the authentication provider if enabled
  if (
    result.authentication &&
    authenticationParams.accessToken &&
    authenticationProvider.settings?.groupSyncEnabled
  ) {
    await new SyncUserGroupsTask().schedule({
      userAuthenticationId: result.authentication.id,
    });
  }

  return {
    user,
    team,
    isNewUser,
    isNewTeam,
  };
}

/**
 * Creates the default "Welcome" collection and onboarding documents for a
 * newly created team.
 *
 * @param ctx the request context.
 * @param team the team to provision the collection for.
 * @param user the user that will be recorded as the creator.
 * @returns a promise that resolves when the collection has been created.
 */
export async function provisionFirstCollection(
  ctx: APIContext,
  team: Team,
  user: User
) {
  await sequelize.transaction(async (transaction) => {
    const context = createContext({
      user,
      authType: ctx.context.auth?.type,
      ip: ctx.context.ip,
      transaction,
    });

    const collection = await Collection.createWithCtx(context, {
      name: "Welcome",
      description: `This collection is a quick guide to what ${env.APP_NAME} is all about. Feel free to delete this collection once your team is up to speed with the basics!`,
      teamId: team.id,
      createdById: user.id,
      sort: Collection.DEFAULT_SORT,
      permission: CollectionPermission.ReadWrite,
    });

    // For the first collection we go ahead and create some initial documents to get
    // the team started. You can edit these in /server/onboarding/x.md
    const onboardingDocs = [
      "Integrations & API",
      "Our Editor",
      "Getting Started",
      "What is Outline",
    ];

    for (const title of onboardingDocs) {
      const text = await readFile(
        path.join(process.cwd(), "server", "onboarding", `${title}.md`),
        "utf8"
      );
      const document = await Document.createWithCtx(context, {
        version: 2,
        isWelcome: true,
        parentDocumentId: null,
        collectionId: collection.id,
        teamId: collection.teamId,
        lastModifiedById: collection.createdById,
        createdById: collection.createdById,
        title,
        text,
      });

      document.content = await DocumentHelper.toJSON(document);

      await document.publish(context, {
        collectionId: collection.id,
        silent: true,
      });
    }
  });
}

export default traceFunction({
  spanName: "accountProvisioner",
})(accountProvisioner);