Ctrl K
ComunidadGuías…

app/stores/AuthStore.ts

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

Archivo original ↗Preguntar a KiBot
import * as Sentry from "@sentry/react";
import invariant from "invariant";
import { isNil } from "es-toolkit/compat";
import {
  action,
  autorun,
  computed,
  makeObservable,
  observable,
  runInAction,
} from "mobx";
import { getCookie, setCookie } from "tiny-cookie";
import { toError } from "@shared/utils/error";
import type { CustomTheme } from "@shared/types";
import Storage from "@shared/utils/Storage";
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
import {
  getRememberedSessions,
  getSessionsCookieExpiry,
  parseSessionsCookie,
} from "@shared/utils/sessions";
import type RootStore from "~/stores/RootStore";
import Team from "~/models/Team";
import env from "~/env";
import { setPostLoginPath } from "~/hooks/useLastVisitedPath";
import { client } from "~/utils/ApiClient";
import Desktop from "~/utils/Desktop";
import { deleteAllDatabases } from "~/utils/developer";
import Logger from "~/utils/Logger";
import isCloudHosted from "~/utils/isCloudHosted";
import Store from "./base/Store";

type PersistedData = Pick<
  AuthStore,
  "user" | "team" | "collaborationToken" | "availableTeams" | "policies"
>;

type Provider = {
  id: string;
  name: string;
  authUrl: string;
};

export type Config = {
  name?: string;
  logo?: string;
  customTheme?: Partial<CustomTheme>;
  hostname?: string;
  providers: Provider[];
};

export default class AuthStore extends Store<Team> {
  private name = "AUTH_STORE";

  /* The ID of the user that is currently signed in. */
  @observable
  public currentUserId?: string | null = undefined;
  /* The ID of the team that is currently signed in. */
  @observable
  public currentTeamId?: string | null = undefined;
  /* A short-lived token to be used to authenticate with the collaboration server. */
  @observable
  public collaborationToken?: string | null = undefined;
  /* When set, the user will be redirected to this URL after logging out. */
  @observable
  public logoutRedirectUri?: string = undefined;
  /* A list of teams that the current user has access to. */
  @observable
  public availableTeams?: {
    id: string;
    name: string;
    avatarUrl: string;
    url: string;
    isSignedIn: boolean;
  }[] = undefined;

  /* The authentication provider the user signed in with. */
  @observable
  public lastSignedIn?: string | null = undefined;
  /* Whether the user is currently suspended. */
  @observable
  public isSuspended = false;

  /* The email address to contact if the user is suspended. */
  @observable
  public suspendedContactEmail?: string | null = undefined;
  /* The auth configuration for the current domain. */
  @observable
  public config: Config | null | undefined = undefined;

  rootStore: RootStore;

  constructor(rootStore: RootStore) {
    super(rootStore, Team);
    makeObservable(this);

    this.rootStore = rootStore;

    // attempt to load the previous state of this store from localstorage
    const data: PersistedData = Storage.get(this.name) || {};

    this.rehydrate(data);

    client.setUnauthorizedHandler((reason) =>
      reason === "user_suspended"
        ? this.logout({ savePath: false, revokeToken: false, clearCache: true })
        : this.logout({ savePath: true, revokeToken: false, clearCache: false })
    );

    void this.fetchAuth();

    // persists this entire store to localstorage whenever any keys are changed
    autorun(() => {
      Storage.set(this.name, this.asJson);
    });

    // listen to the localstorage value changing in other tabs to react to
    // signin/signout events in other tabs and follow suit.
    window.addEventListener("storage", (event) => {
      if (event.key === this.name && event.newValue) {
        const newData: PersistedData | null = JSON.parse(event.newValue);

        // data may be null if key is deleted in localStorage
        if (!newData) {
          return;
        }

        // If we're not signed in then hydrate from the received data, otherwise if
        // we are signed in and the received data contains no user then sign out
        if (this.authenticated) {
          if (isNil(newData.user)) {
            void this.logout({
              savePath: false,
              clearCache: false,
              revokeToken: false,
            });
          }
        } else {
          this.rehydrate(newData);
        }
      }
    });
  }

  @action
  rehydrate(data: PersistedData) {
    if (data.policies) {
      this.addPolicies(data.policies);
    }
    if (data.team) {
      this.add(data.team);
    }
    if (data.user) {
      this.rootStore.users.add(data.user);
    }

    this.currentTeamId = data.team?.id;
    this.currentUserId = data.user?.id;
    this.collaborationToken = data.collaborationToken;
    this.lastSignedIn = getCookie("lastSignedIn");
  }

  /** The current user */
  @computed
  get user() {
    return this.currentUserId
      ? this.rootStore.users.get(this.currentUserId)
      : undefined;
  }

  /** The current team */
  @computed
  get team() {
    return this.currentTeamId ? this.get(this.currentTeamId) : undefined;
  }

  /** The current team's policies */
  @computed
  get policies() {
    const policy = this.currentTeamId
      ? this.rootStore.policies.get(this.currentTeamId)
      : undefined;
    return policy ? [policy] : [];
  }

  /** Whether the user is signed in */
  @computed
  get authenticated(): boolean {
    return !!this.user && !!this.team;
  }

  @computed
  get asJson(): PersistedData {
    return {
      user: this.user,
      team: this.team,
      collaborationToken: this.collaborationToken,
      availableTeams: this.availableTeams,
      policies: this.policies,
    };
  }

  fetchConfig = async () => {
    const res = await client.post("/auth.config");
    invariant(res?.data, "Config not available");
    runInAction(() => {
      this.config = res.data;
    });
  };

  @action
  fetchAuth = async () => {
    this.isFetching = true;

    try {
      const res = await client.post("/auth.info", undefined, {
        credentials: "same-origin",
      });
      invariant(res?.data, "Auth not available");

      runInAction(() => {
        const { data } = res;
        this.addPolicies(res.policies);
        this.add(data.team);
        this.rootStore.users.add(data.user);
        data.groups.map(this.rootStore.groups.add);
        data.groupUsers.map(this.rootStore.groupUsers.add);
        this.currentUserId = data.user.id;
        this.currentTeamId = data.team.id;

        this.availableTeams = res.data.availableTeams;
        this.collaborationToken = res.data.collaborationToken;

        // Covers the first session after login, where no team was available
        // in localStorage when the root store was constructed.
        this.rootStore.enablePersistence();

        if (env.SENTRY_DSN) {
          const scope = Sentry.getCurrentScope();
          scope.setUser({ id: this.currentUserId! });
          scope.setExtra("team", this.team?.name);
          scope.setExtra("teamId", this.currentTeamId);
        }

        // Redirect to the correct custom domain or team subdomain if needed
        // Occurs when the (sub)domain is changed in admin and the user hits an old url
        const { hostname, pathname } = window.location;

        if (data.team.domain) {
          if (data.team.domain !== hostname) {
            window.location.href = `${data.team.url}${pathname}`;
            return;
          }
        } else if (
          isCloudHosted &&
          parseDomain(hostname).teamSubdomain !== (data.team.subdomain ?? "")
        ) {
          window.location.href = `${data.team.url}${pathname}`;
          return;
        }

        // Update the user's timezone if it has changed
        const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
        if (data.user.timezone !== timezone) {
          const user = this.rootStore.users.get(data.user.id);
          void user?.save({ timezone });
        }
      });
    } catch (err) {
      if (
        err instanceof Error &&
        "error" in err &&
        err.error === "user_suspended"
      ) {
        this.isSuspended = true;
        if (
          "data" in err &&
          err.data instanceof Object &&
          "adminEmail" in err.data &&
          typeof err.data.adminEmail === "string"
        ) {
          this.suspendedContactEmail = err.data.adminEmail;
        }
        return;
      }
      throw err;
    } finally {
      this.isFetching = false;
    }
  };

  requestDeleteUser = () => client.post(`/users.requestDelete`);

  requestDeleteTeam = () => client.post(`/teams.requestDelete`);

  @action
  deleteUser = async (data: { code: string }) => {
    await client.post(`/users.delete`, data);
    runInAction(() => {
      this.currentUserId = null;
      this.currentTeamId = null;
      this.collaborationToken = null;
      this.availableTeams = this.availableTeams?.filter(
        (team) => team.id !== this.team?.id
      );
    });
  };

  @action
  deleteTeam = async (data: { code: string }) => {
    await client.post(`/teams.delete`, data);

    runInAction(() => {
      this.currentUserId = null;
      this.currentTeamId = null;
      this.availableTeams = this.availableTeams?.filter(
        (team) => team.id !== this.team?.id
      );
    });
  };

  @action
  createTeam = async (params: { name: string }) => {
    this.isSaving = true;

    try {
      const res = await client.post(`/teams.create`, params);
      invariant(res?.success, "Unable to create team");

      window.location.href = res.data.transferUrl;
    } finally {
      this.isSaving = false;
    }
  };

  /**
   * Logs the user out and optionally revokes the authentication token.
   *
   * @param clearCache Whether to clear the IndexedDB databases used for document caching.
   * @param revokeToken Whether the auth token should attempt to be revoked, this should be
   * @param savePath Whether the current path should be saved and returned to after login.
   * @param userInitiated Whether the logout was initiated by the user.
   * disabled with requests from ApiClient to prevent infinite loops.
   */
  @action
  logout = async ({
    clearCache = true,
    revokeToken = true,
    savePath = false,
    userInitiated = false,
  }: {
    clearCache?: boolean;
    revokeToken?: boolean;
    savePath?: boolean;
    userInitiated?: boolean;
  }) => {
    // if this logout was forced from an authenticated route then
    // save the current path so we can go back there once signed in
    if (savePath) {
      setPostLoginPath(window.location.pathname + window.location.search);
    }

    if (revokeToken) {
      try {
        // invalidate authentication token on server and unset auth cookie
        await client.post(`/auth.delete`);
      } catch (err) {
        Logger.error("Failed to delete authentication", toError(err));
      }
    }

    // Retain the workspace as logged out so it remains available for login.
    const team = this.team;

    if (
      team &&
      isCloudHosted &&
      !parseDomain(window.location.hostname).custom
    ) {
      const sessions = getRememberedSessions(
        parseSessionsCookie(getCookie("sessions") || "{}")
      );
      sessions[team.id] = {
        name: team.name,
        logoUrl: team.avatarUrl ?? null,
        url: team.url,
        expiresAt: new Date().toISOString(),
        lastUsedAt: new Date().toISOString(),
      };
      const expires = getSessionsCookieExpiry(sessions);
      const options = {
        domain: getCookieDomain(window.location.hostname, isCloudHosted),
        path: "/",
      };
      if (expires) {
        setCookie("sessions", JSON.stringify(sessions), {
          ...options,
          expires,
        });
      }
    }

    if (
      userInitiated &&
      (env.OIDC_LOGOUT_URI || this.lastSignedIn === "oidc")
    ) {
      // Route through the server so it can build a spec-compliant RP-initiated
      // logout URL (including the id_token_hint) for the OIDC provider.
      this.logoutRedirectUri = "/auth/oidc.logout";
    }

    if (clearCache) {
      // clear IndexedDB databases used for document caching
      await deleteAllDatabases();
    }

    // clear all credentials from cache (and local storage via autorun)
    this.currentUserId = null;
    this.currentTeamId = null;
    this.collaborationToken = null;
    this.rootStore.clear();

    // Tell the host application we logged out, if any – allows window cleanup.
    if (Desktop.isElectron()) {
      void Desktop.bridge?.onLogout?.();
    }
  };
}