From 1e847dc1cf8ee6ffadce8666eec98751f0a07848 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 28 Oct 2023 12:43:50 -0400 Subject: [PATCH] Cleanup and refactor `AuthStore` (#6086) --- app/components/LanguagePrompt.tsx | 6 +- .../Document/components/MultiplayerEditor.tsx | 2 +- app/scenes/Settings/Details.tsx | 23 +-- app/scenes/Settings/Features.tsx | 13 +- app/scenes/Settings/Preferences.tsx | 15 +- app/scenes/Settings/Profile.tsx | 12 +- app/scenes/Settings/Security.tsx | 6 +- .../Settings/components/DomainManagement.tsx | 10 +- app/stores/AuthStore.ts | 172 +++++++----------- app/stores/RootStore.ts | 7 +- server/routes/api/teams/teams.ts | 54 +++--- 11 files changed, 136 insertions(+), 184 deletions(-) diff --git a/app/components/LanguagePrompt.tsx b/app/components/LanguagePrompt.tsx index 73425f4ed..c84c6b6ec 100644 --- a/app/components/LanguagePrompt.tsx +++ b/app/components/LanguagePrompt.tsx @@ -42,7 +42,7 @@ function Icon({ className }: { className?: string }) { } export default function LanguagePrompt() { - const { auth, ui } = useStores(); + const { ui } = useStores(); const { t } = useTranslation(); const user = useCurrentUser(); const language = detectLanguage(); @@ -75,9 +75,7 @@ export default function LanguagePrompt() { { ui.setLanguagePromptDismissed(); - await auth.updateUser({ - language, - }); + await user.save({ language }); }} > {t("Change Language")} diff --git a/app/scenes/Document/components/MultiplayerEditor.tsx b/app/scenes/Document/components/MultiplayerEditor.tsx index 614fb3f46..d4a7a2ddb 100644 --- a/app/scenes/Document/components/MultiplayerEditor.tsx +++ b/app/scenes/Document/components/MultiplayerEditor.tsx @@ -93,7 +93,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) { ); provider.on("authenticationFailed", () => { - void auth.fetch().catch(() => { + void auth.fetchAuth().catch(() => { history.replace(homePath()); }); }); diff --git a/app/scenes/Settings/Details.tsx b/app/scenes/Settings/Details.tsx index 79a6a8fae..c092583ee 100644 --- a/app/scenes/Settings/Details.tsx +++ b/app/scenes/Settings/Details.tsx @@ -28,7 +28,7 @@ import ImageInput from "./components/ImageInput"; import SettingRow from "./components/SettingRow"; function Details() { - const { auth, dialogs, ui } = useStores(); + const { dialogs, ui } = useStores(); const { t } = useTranslation(); const team = useCurrentTeam(); const theme = useTheme(); @@ -65,7 +65,7 @@ function Details() { } try { - await auth.updateTeam({ + await team.save({ name, subdomain, defaultCollectionId, @@ -80,16 +80,7 @@ function Details() { toast.error(err.message); } }, - [ - auth, - name, - subdomain, - defaultCollectionId, - team.preferences, - publicBranding, - customTheme, - t, - ] + [team, name, subdomain, defaultCollectionId, publicBranding, customTheme, t] ); const handleNameChange = React.useCallback( @@ -107,9 +98,7 @@ function Details() { ); const handleAvatarUpload = async (avatarUrl: string) => { - await auth.updateTeam({ - avatarUrl, - }); + await team.save({ avatarUrl }); toast.success(t("Logo updated")); }; @@ -288,8 +277,8 @@ function Details() { /> - {can.delete && ( diff --git a/app/scenes/Settings/Features.tsx b/app/scenes/Settings/Features.tsx index 9c0f1a84f..8e17aaafd 100644 --- a/app/scenes/Settings/Features.tsx +++ b/app/scenes/Settings/Features.tsx @@ -9,23 +9,20 @@ import Scene from "~/components/Scene"; import Switch from "~/components/Switch"; import Text from "~/components/Text"; import useCurrentTeam from "~/hooks/useCurrentTeam"; -import useStores from "~/hooks/useStores"; import SettingRow from "./components/SettingRow"; function Features() { - const { auth } = useStores(); const team = useCurrentTeam(); const { t } = useTranslation(); const handlePreferenceChange = (inverted = false) => async (ev: React.ChangeEvent) => { - const preferences = { - ...team.preferences, - [ev.target.name]: inverted ? !ev.target.checked : ev.target.checked, - }; - - await auth.updateTeam({ preferences }); + team.setPreference( + ev.target.name as TeamPreference, + inverted ? !ev.target.checked : ev.target.checked + ); + await team.save(); toast.success(t("Settings saved")); }; diff --git a/app/scenes/Settings/Preferences.tsx b/app/scenes/Settings/Preferences.tsx index ce9875f95..da1f3850f 100644 --- a/app/scenes/Settings/Preferences.tsx +++ b/app/scenes/Settings/Preferences.tsx @@ -19,24 +19,23 @@ import SettingRow from "./components/SettingRow"; function Preferences() { const { t } = useTranslation(); - const { dialogs, auth } = useStores(); + const { dialogs } = useStores(); const user = useCurrentUser(); const team = useCurrentTeam(); const handlePreferenceChange = (inverted = false) => async (ev: React.ChangeEvent) => { - const preferences = { - ...user.preferences, - [ev.target.name]: inverted ? !ev.target.checked : ev.target.checked, - }; - - await auth.updateUser({ preferences }); + user.setPreference( + ev.target.name as UserPreference, + inverted ? !ev.target.checked : ev.target.checked + ); + await user.save(); toast.success(t("Preferences saved")); }; const handleLanguageChange = async (language: string) => { - await auth.updateUser({ language }); + await user.save({ language }); toast.success(t("Preferences saved")); }; diff --git a/app/scenes/Settings/Profile.tsx b/app/scenes/Settings/Profile.tsx index a88db05db..f4a3d8db5 100644 --- a/app/scenes/Settings/Profile.tsx +++ b/app/scenes/Settings/Profile.tsx @@ -9,12 +9,10 @@ import Input from "~/components/Input"; import Scene from "~/components/Scene"; import Text from "~/components/Text"; import useCurrentUser from "~/hooks/useCurrentUser"; -import useStores from "~/hooks/useStores"; import ImageInput from "./components/ImageInput"; import SettingRow from "./components/SettingRow"; const Profile = () => { - const { auth } = useStores(); const user = useCurrentUser(); const form = React.useRef(null); const [name, setName] = React.useState(user.name || ""); @@ -24,9 +22,7 @@ const Profile = () => { ev.preventDefault(); try { - await auth.updateUser({ - name, - }); + await user.save({ name }); toast.success(t("Profile saved")); } catch (err) { toast.error(err.message); @@ -38,9 +34,7 @@ const Profile = () => { }; const handleAvatarUpload = async (avatarUrl: string) => { - await auth.updateUser({ - avatarUrl, - }); + await user.save({ avatarUrl }); toast.success(t("Profile picture updated")); }; @@ -49,7 +43,7 @@ const Profile = () => { }; const isValid = form.current?.checkValidity(); - const { isSaving } = auth; + const { isSaving } = user; return ( }> diff --git a/app/scenes/Settings/Security.tsx b/app/scenes/Settings/Security.tsx index 2cbbb683d..c8f185546 100644 --- a/app/scenes/Settings/Security.tsx +++ b/app/scenes/Settings/Security.tsx @@ -24,7 +24,7 @@ import DomainManagement from "./components/DomainManagement"; import SettingRow from "./components/SettingRow"; function Security() { - const { auth, authenticationProviders, dialogs } = useStores(); + const { authenticationProviders, dialogs } = useStores(); const team = useCurrentTeam(); const { t } = useTranslation(); const theme = useTheme(); @@ -61,13 +61,13 @@ function Security() { async (newData) => { try { setData(newData); - await auth.updateTeam(newData); + await team.save(newData); showSuccessMessage(); } catch (err) { toast.error(err.message); } }, - [auth, showSuccessMessage] + [team, showSuccessMessage] ); const handleChange = React.useCallback( diff --git a/app/scenes/Settings/components/DomainManagement.tsx b/app/scenes/Settings/components/DomainManagement.tsx index 906c10b29..5f535fae2 100644 --- a/app/scenes/Settings/components/DomainManagement.tsx +++ b/app/scenes/Settings/components/DomainManagement.tsx @@ -11,7 +11,6 @@ import Input from "~/components/Input"; import NudeButton from "~/components/NudeButton"; import Tooltip from "~/components/Tooltip"; import useCurrentTeam from "~/hooks/useCurrentTeam"; -import useStores from "~/hooks/useStores"; import SettingRow from "./SettingRow"; type Props = { @@ -19,7 +18,6 @@ type Props = { }; function DomainManagement({ onSuccess }: Props) { - const { auth } = useStores(); const team = useCurrentTeam(); const { t } = useTranslation(); @@ -35,16 +33,14 @@ function DomainManagement({ onSuccess }: Props) { const handleSaveDomains = React.useCallback(async () => { try { - await auth.updateTeam({ - allowedDomains, - }); + await team.save({ allowedDomains }); onSuccess(); setExistingDomainsTouched(false); updateLastKnownDomainCount(allowedDomains.length); } catch (err) { toast.error(err.message); } - }, [auth, allowedDomains, onSuccess]); + }, [team, allowedDomains, onSuccess]); const handleRemoveDomain = async (index: number) => { const newDomains = allowedDomains.filter((_, i) => index !== i); @@ -132,7 +128,7 @@ function DomainManagement({ onSuccess }: Props) { diff --git a/app/stores/AuthStore.ts b/app/stores/AuthStore.ts index eb767e2c9..e7995819f 100644 --- a/app/stores/AuthStore.ts +++ b/app/stores/AuthStore.ts @@ -2,7 +2,7 @@ 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 { CustomTheme } from "@shared/types"; import Storage from "@shared/utils/Storage"; import { getCookieDomain, parseDomain } from "@shared/utils/domains"; import RootStore from "~/stores/RootStore"; @@ -10,17 +10,19 @@ import Policy from "~/models/Policy"; import Team from "~/models/Team"; import User from "~/models/User"; import env from "~/env"; +import { PartialWithId } from "~/types"; import { client } from "~/utils/ApiClient"; import Desktop from "~/utils/Desktop"; import Logger from "~/utils/Logger"; import isCloudHosted from "~/utils/isCloudHosted"; +import Store from "./base/Store"; const AUTH_STORE = "AUTH_STORE"; const NO_REDIRECT_PATHS = ["/", "/create", "/home", "/logout"]; type PersistedData = { - user?: User; - team?: Team; + user?: PartialWithId; + team?: PartialWithId; collaborationToken?: string; availableTeams?: { id: string; @@ -46,14 +48,14 @@ export type Config = { providers: Provider[]; }; -export default class AuthStore { - /* The user that is currently signed in. */ +export default class AuthStore extends Store { + /* The ID of the user that is currently signed in. */ @observable - user?: User | null; + currentUserId?: string | null; - /* The team that the current user is signed into. */ + /* The ID of the team that is currently signed in. */ @observable - team?: Team | null; + currentTeamId?: string | null; /* A short-lived token to be used to authenticate with the collaboration server. */ @observable @@ -69,21 +71,10 @@ export default class AuthStore { 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; - - @observable - isFetching = true; - /* Whether the user is currently suspended. */ @observable isSuspended = false; @@ -99,12 +90,14 @@ export default class AuthStore { rootStore: RootStore; constructor(rootStore: RootStore) { + super(rootStore, Team); + this.rootStore = rootStore; // attempt to load the previous state of this store from localstorage const data: PersistedData = Storage.get(AUTH_STORE) || {}; this.rehydrate(data); - void this.fetch(); + void this.fetchAuth(); // persists this entire store to localstorage whenever any keys are changed autorun(() => { @@ -138,21 +131,44 @@ export default class AuthStore { @action rehydrate(data: PersistedData) { - this.user = data.user ? new User(data.user, this as any) : undefined; - this.team = data.team ? new Team(data.team, this as any) : undefined; + if (data.policies) { + this.addPolicies(data.policies); + } + if (data.team) { + this.add(data.team); + } + if (data.user) { + this.rootStore.users.add(data.user); + } + + this.currentUserId = data.user?.id; this.collaborationToken = data.collaborationToken; this.lastSignedIn = getCookie("lastSignedIn"); - this.addPolicies(data.policies); } - 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)); - } + /** The current user */ + @computed + get user() { + return this.currentUserId + ? this.rootStore.users.get(this.currentUserId) + : undefined; } + /** The current team */ + @computed + get team() { + return this.orderedData.at(0); + } + + /** The current team's policies */ + @computed + get policies() { + return this.currentTeamId + ? [this.rootStore.policies.get(this.currentTeamId)] + : []; + } + + /** Whether the user is signed in */ @computed get authenticated(): boolean { return !!this.user && !!this.team; @@ -177,7 +193,7 @@ export default class AuthStore { }; @action - fetch = async () => { + fetchAuth = async () => { this.isFetching = true; try { @@ -185,21 +201,23 @@ export default class AuthStore { credentials: "same-origin", }); invariant(res?.data, "Auth not available"); - runInAction("AuthStore#fetch", () => { + + runInAction("AuthStore#refresh", () => { + const { data } = res; this.addPolicies(res.policies); - const { user, team } = res.data; - this.user = new User(user, this as any); - this.team = new Team(team, this as any); + this.add(data.team); + this.rootStore.users.add(data.user); + this.currentUserId = data.user.id; + this.currentTeamId = data.team.id; + 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); + scope.setUser({ id: this.currentUserId }); + scope.setExtra("team", this.team.name); + scope.setExtra("teamId", this.team.id); }); } @@ -207,16 +225,16 @@ export default class AuthStore { // 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}`; + if (data.team.domain) { + if (data.team.domain !== hostname) { + window.location.href = `${data.team.url}${pathname}`; return; } } else if ( isCloudHosted && - parseDomain(hostname).teamSubdomain !== (team.subdomain ?? "") + parseDomain(hostname).teamSubdomain !== (data.team.subdomain ?? "") ) { - window.location.href = `${team.url}${pathname}`; + window.location.href = `${data.team.url}${pathname}`; return; } @@ -250,79 +268,28 @@ export default class AuthStore { deleteUser = async (data: { code: string }) => { await client.post(`/users.delete`, data); runInAction("AuthStore#deleteUser", () => { - this.user = null; - this.team = null; + this.currentUserId = null; + this.currentTeamId = null; this.collaborationToken = null; this.availableTeams = this.availableTeams?.filter( (team) => team.id !== this.team?.id ); - this.policies = []; }); }; @action deleteTeam = async (data: { code: string }) => { await client.post(`/teams.delete`, data); + runInAction("AuthStore#deleteTeam", () => { - this.user = null; + this.currentUserId = null; + this.currentTeamId = 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?.updateData(params); - const res = await client.post(`/users.update`, params); - invariant(res?.data, "User response not available"); - this.user?.updateData(res.data); - this.addPolicies(res.policies); - } catch (err) { - this.user?.updateData(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?.updateData(params); - const res = await client.post(`/team.update`, params); - invariant(res?.data, "Team response not available"); - this.team?.updateData(res.data); - this.addPolicies(res.policies); - } catch (err) { - this.team?.updateData(previousData); - throw err; - } finally { - this.isSaving = false; - } - }; - @action createTeam = async (params: { name: string }) => { this.isSaving = true; @@ -378,10 +345,9 @@ export default class AuthStore { } // clear all credentials from cache (and local storage via autorun) - this.user = null; - this.team = null; + this.currentUserId = null; + this.currentTeamId = null; this.collaborationToken = null; - this.policies = []; // Tell the host application we logged out, if any – allows window cleanup. void Desktop.bridge?.onLogout?.(); diff --git a/app/stores/RootStore.ts b/app/stores/RootStore.ts index 27fa44ace..df9fd1331 100644 --- a/app/stores/RootStore.ts +++ b/app/stores/RootStore.ts @@ -56,11 +56,8 @@ export default class RootStore { webhookSubscriptions: WebhookSubscriptionsStore; constructor() { - // PoliciesStore must be initialized before AuthStore - this.policies = new PoliciesStore(this); this.apiKeys = new ApiKeysStore(this); this.authenticationProviders = new AuthenticationProvidersStore(this); - this.auth = new AuthStore(this); this.collections = new CollectionsStore(this); this.collectionGroupMemberships = new CollectionGroupMembershipsStore(this); this.comments = new CommentsStore(this); @@ -73,6 +70,7 @@ export default class RootStore { this.memberships = new MembershipsStore(this); this.notifications = new NotificationsStore(this); this.pins = new PinsStore(this); + this.policies = new PoliciesStore(this); this.presence = new DocumentPresenceStore(); this.revisions = new RevisionsStore(this); this.searches = new SearchesStore(this); @@ -84,6 +82,9 @@ export default class RootStore { this.views = new ViewsStore(this); this.fileOperations = new FileOperationsStore(this); this.webhookSubscriptions = new WebhookSubscriptionsStore(this); + + // AuthStore must be initialized last as it makes use of the other stores. + this.auth = new AuthStore(this); } logout() { diff --git a/server/routes/api/teams/teams.ts b/server/routes/api/teams/teams.ts index 782fee94f..bf10c117b 100644 --- a/server/routes/api/teams/teams.ts +++ b/server/routes/api/teams/teams.ts @@ -21,34 +21,46 @@ import * as T from "./schema"; const router = new Router(); const emailEnabled = !!(env.SMTP_HOST || env.ENVIRONMENT === "development"); +const handleTeamUpdate = async (ctx: APIContext) => { + const { transaction } = ctx.state; + const { user } = ctx.state.auth; + const team = await Team.findByPk(user.teamId, { + include: [{ model: TeamDomain, separate: true }], + lock: transaction.LOCK.UPDATE, + transaction, + }); + authorize(user, "update", team); + + const updatedTeam = await teamUpdater({ + params: ctx.input.body, + user, + team, + ip: ctx.request.ip, + transaction, + }); + + ctx.body = { + data: presentTeam(updatedTeam), + policies: presentPolicies(user, [updatedTeam]), + }; +}; + router.post( "team.update", rateLimiter(RateLimiterStrategy.TenPerHour), auth(), validate(T.TeamsUpdateSchema), transaction(), - async (ctx: APIContext) => { - const { transaction } = ctx.state; - const { user } = ctx.state.auth; - const team = await Team.findByPk(user.teamId, { - include: [{ model: TeamDomain }], - transaction, - }); - authorize(user, "update", team); + handleTeamUpdate +); - const updatedTeam = await teamUpdater({ - params: ctx.input.body, - user, - team, - ip: ctx.request.ip, - transaction, - }); - - ctx.body = { - data: presentTeam(updatedTeam), - policies: presentPolicies(user, [updatedTeam]), - }; - } +router.post( + "teams.update", + rateLimiter(RateLimiterStrategy.TenPerHour), + auth(), + validate(T.TeamsUpdateSchema), + transaction(), + handleTeamUpdate ); router.post(