mirror of
https://github.com/outline/outline.git
synced 2026-01-06 02:59:54 -06:00
373 lines
10 KiB
TypeScript
373 lines
10 KiB
TypeScript
import * as Sentry from "@sentry/react";
|
||
import invariant from "invariant";
|
||
import { observable, action, computed, autorun, runInAction } from "mobx";
|
||
import { getCookie, setCookie, removeCookie } from "tiny-cookie";
|
||
import { CustomTheme, TeamPreferences, UserPreferences } from "@shared/types";
|
||
import Storage from "@shared/utils/Storage";
|
||
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
|
||
import { Hour } from "@shared/utils/time";
|
||
import RootStore from "~/stores/RootStore";
|
||
import Policy from "~/models/Policy";
|
||
import Team from "~/models/Team";
|
||
import User from "~/models/User";
|
||
import env from "~/env";
|
||
import { client } from "~/utils/ApiClient";
|
||
import Desktop from "~/utils/Desktop";
|
||
import Logger from "~/utils/Logger";
|
||
import history from "~/utils/history";
|
||
|
||
const AUTH_STORE = "AUTH_STORE";
|
||
const NO_REDIRECT_PATHS = ["/", "/create", "/home", "/logout"];
|
||
|
||
type PersistedData = {
|
||
user?: User;
|
||
team?: Team;
|
||
collaborationToken?: string;
|
||
availableTeams?: {
|
||
id: string;
|
||
name: string;
|
||
avatarUrl: string;
|
||
url: string;
|
||
isSignedIn: boolean;
|
||
}[];
|
||
policies?: Policy[];
|
||
};
|
||
|
||
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 {
|
||
/* The user that is currently signed in. */
|
||
@observable
|
||
user?: User | null;
|
||
|
||
/* The team that the current user is signed into. */
|
||
@observable
|
||
team?: Team | null;
|
||
|
||
/* A short-lived token to be used to authenticate with the collaboration server. */
|
||
@observable
|
||
collaborationToken?: string | null;
|
||
|
||
/* A list of teams that the current user has access to. */
|
||
@observable
|
||
availableTeams?: {
|
||
id: string;
|
||
name: string;
|
||
avatarUrl: string;
|
||
url: string;
|
||
isSignedIn: boolean;
|
||
}[];
|
||
|
||
/* A list of cancan policies for the current user. */
|
||
@observable
|
||
policies: Policy[] = [];
|
||
|
||
/* The authentication provider the user signed in with. */
|
||
@observable
|
||
lastSignedIn?: string | null;
|
||
|
||
/* Whether the user is currently saving their profile or team settings. */
|
||
@observable
|
||
isSaving = false;
|
||
|
||
/* Whether the user is currently suspended. */
|
||
@observable
|
||
isSuspended = false;
|
||
|
||
/* The email address to contact if the user is suspended. */
|
||
@observable
|
||
suspendedContactEmail?: string | null;
|
||
|
||
/* The auth configuration for the current domain. */
|
||
@observable
|
||
config: Config | null | undefined;
|
||
|
||
rootStore: RootStore;
|
||
|
||
constructor(rootStore: RootStore) {
|
||
this.rootStore = rootStore;
|
||
// attempt to load the previous state of this store from localstorage
|
||
const data: PersistedData = Storage.get(AUTH_STORE) || {};
|
||
|
||
this.rehydrate(data);
|
||
|
||
// Refresh the auth store every 12 hours that the window is open
|
||
setInterval(this.fetch, 12 * Hour);
|
||
|
||
// persists this entire store to localstorage whenever any keys are changed
|
||
autorun(() => {
|
||
Storage.set(AUTH_STORE, this.asJson);
|
||
});
|
||
|
||
// listen to the localstorage value changing in other tabs to react to
|
||
// signin/signout events in other tabs and follow suite.
|
||
window.addEventListener("storage", (event) => {
|
||
if (event.key === AUTH_STORE && event.newValue) {
|
||
const data: PersistedData | null | undefined = JSON.parse(
|
||
event.newValue
|
||
);
|
||
// data may be null if key is deleted in localStorage
|
||
if (!data) {
|
||
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 (data.user === null) {
|
||
void this.logout();
|
||
}
|
||
} else {
|
||
this.rehydrate(data);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
@action
|
||
rehydrate(data: PersistedData) {
|
||
this.user = data.user ? new User(data.user, this) : undefined;
|
||
this.team = data.team ? new Team(data.team, this) : undefined;
|
||
this.collaborationToken = data.collaborationToken;
|
||
this.lastSignedIn = getCookie("lastSignedIn");
|
||
this.addPolicies(data.policies);
|
||
void this.fetch();
|
||
}
|
||
|
||
addPolicies(policies?: Policy[]) {
|
||
if (policies) {
|
||
// cache policies in this store so that they are persisted between sessions
|
||
this.policies = policies;
|
||
policies.forEach((policy) => this.rootStore.policies.add(policy));
|
||
}
|
||
}
|
||
|
||
@computed
|
||
get authenticated(): boolean {
|
||
return !!this.user && !!this.team;
|
||
}
|
||
|
||
@computed
|
||
get asJson() {
|
||
return {
|
||
user: this.user,
|
||
team: this.team,
|
||
collaborationToken: this.collaborationToken,
|
||
availableTeams: this.availableTeams,
|
||
policies: this.policies,
|
||
};
|
||
}
|
||
|
||
@action
|
||
fetchConfig = async () => {
|
||
const res = await client.post("/auth.config");
|
||
invariant(res?.data, "Config not available");
|
||
this.config = res.data;
|
||
};
|
||
|
||
@action
|
||
fetch = async () => {
|
||
try {
|
||
const res = await client.post("/auth.info", undefined, {
|
||
credentials: "same-origin",
|
||
});
|
||
invariant(res?.data, "Auth not available");
|
||
runInAction("AuthStore#fetch", () => {
|
||
this.addPolicies(res.policies);
|
||
const { user, team } = res.data;
|
||
this.user = new User(user, this);
|
||
this.team = new Team(team, this);
|
||
this.availableTeams = res.data.availableTeams;
|
||
this.collaborationToken = res.data.collaborationToken;
|
||
|
||
if (env.SENTRY_DSN) {
|
||
Sentry.configureScope(function (scope) {
|
||
scope.setUser({
|
||
id: user.id,
|
||
});
|
||
scope.setExtra("team", team.name);
|
||
scope.setExtra("teamId", team.id);
|
||
});
|
||
}
|
||
|
||
// 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 (this.team.domain) {
|
||
if (this.team.domain !== hostname) {
|
||
window.location.href = `${team.url}${pathname}`;
|
||
return;
|
||
}
|
||
} else if (
|
||
env.SUBDOMAINS_ENABLED &&
|
||
parseDomain(hostname).teamSubdomain !== (team.subdomain ?? "")
|
||
) {
|
||
window.location.href = `${team.url}${pathname}`;
|
||
return;
|
||
}
|
||
|
||
// If we came from a redirect then send the user immediately there
|
||
const postLoginRedirectPath = getCookie("postLoginRedirectPath");
|
||
|
||
if (postLoginRedirectPath) {
|
||
removeCookie("postLoginRedirectPath");
|
||
|
||
if (!NO_REDIRECT_PATHS.includes(postLoginRedirectPath)) {
|
||
window.location.href = postLoginRedirectPath;
|
||
}
|
||
}
|
||
});
|
||
} catch (err) {
|
||
if (err.error === "user_suspended") {
|
||
this.isSuspended = true;
|
||
this.suspendedContactEmail = err.data.adminEmail;
|
||
}
|
||
}
|
||
};
|
||
|
||
@action
|
||
requestDelete = () => client.post(`/users.requestDelete`);
|
||
|
||
@action
|
||
deleteUser = async (data: { code: string }) => {
|
||
await client.post(`/users.delete`, data);
|
||
runInAction("AuthStore#updateUser", () => {
|
||
this.user = null;
|
||
this.team = null;
|
||
this.collaborationToken = null;
|
||
this.availableTeams = this.availableTeams?.filter(
|
||
(team) => team.id !== this.team?.id
|
||
);
|
||
this.policies = [];
|
||
});
|
||
};
|
||
|
||
@action
|
||
updateUser = async (params: {
|
||
name?: string;
|
||
avatarUrl?: string | null;
|
||
language?: string;
|
||
preferences?: UserPreferences;
|
||
}) => {
|
||
this.isSaving = true;
|
||
const previousData = this.user?.toAPI();
|
||
|
||
try {
|
||
this.user?.updateFromJson(params);
|
||
const res = await client.post(`/users.update`, params);
|
||
invariant(res?.data, "User response not available");
|
||
this.user?.updateFromJson(res.data);
|
||
this.addPolicies(res.policies);
|
||
} catch (err) {
|
||
this.user?.updateFromJson(previousData);
|
||
throw err;
|
||
} finally {
|
||
this.isSaving = false;
|
||
}
|
||
};
|
||
|
||
@action
|
||
updateTeam = async (params: {
|
||
name?: string;
|
||
avatarUrl?: string | null | undefined;
|
||
sharing?: boolean;
|
||
defaultCollectionId?: string | null;
|
||
subdomain?: string | null | undefined;
|
||
allowedDomains?: string[] | null | undefined;
|
||
preferences?: TeamPreferences;
|
||
}) => {
|
||
this.isSaving = true;
|
||
const previousData = this.team?.toAPI();
|
||
|
||
try {
|
||
this.team?.updateFromJson(params);
|
||
const res = await client.post(`/team.update`, params);
|
||
invariant(res?.data, "Team response not available");
|
||
this.team?.updateFromJson(res.data);
|
||
this.addPolicies(res.policies);
|
||
} catch (err) {
|
||
this.team?.updateFromJson(previousData);
|
||
throw err;
|
||
} finally {
|
||
this.isSaving = false;
|
||
}
|
||
};
|
||
|
||
@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;
|
||
}
|
||
};
|
||
|
||
@action
|
||
logout = async (
|
||
/** Whether the current path should be saved and returned to after login */
|
||
savePath = false,
|
||
/** Whether the auth token is already expired. */
|
||
tokenIsExpired = false
|
||
) => {
|
||
// 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) {
|
||
const pathName = window.location.pathname;
|
||
|
||
if (!NO_REDIRECT_PATHS.includes(pathName)) {
|
||
setCookie("postLoginRedirectPath", pathName);
|
||
}
|
||
}
|
||
|
||
// invalidate authentication token on server and unset auth cookie
|
||
if (!tokenIsExpired) {
|
||
try {
|
||
await client.post(`/auth.delete`);
|
||
} catch (err) {
|
||
Logger.error("Failed to delete authentication", err);
|
||
}
|
||
}
|
||
|
||
// remove session record on apex cookie
|
||
const team = this.team;
|
||
|
||
if (team) {
|
||
const sessions = JSON.parse(getCookie("sessions") || "{}");
|
||
delete sessions[team.id];
|
||
setCookie("sessions", JSON.stringify(sessions), {
|
||
domain: getCookieDomain(window.location.hostname),
|
||
});
|
||
}
|
||
|
||
// clear all credentials from cache (and local storage via autorun)
|
||
this.user = null;
|
||
this.team = null;
|
||
this.collaborationToken = null;
|
||
this.policies = [];
|
||
|
||
// Tell the host application we logged out, if any – allows window cleanup.
|
||
void Desktop.bridge?.onLogout?.();
|
||
this.rootStore.logout();
|
||
|
||
history.replace("/");
|
||
};
|
||
}
|