Skip to content

refactor: Refactor auth provider #5782

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Jan 20, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Create dashboard provider
  • Loading branch information
BrunoQuaresma committed Jan 18, 2023
commit 9f0e0eeede9c0715a8c54b2889ef0967a6880cf7
2 changes: 1 addition & 1 deletion site/src/AppRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import UsersPage from "pages/UsersPage/UsersPage"
import WorkspacesPage from "pages/WorkspacesPage/WorkspacesPage"
import { FC, lazy, Suspense } from "react"
import { Route, Routes, BrowserRouter as Router } from "react-router-dom"
import { DashboardLayout } from "./components/DashboardLayout/DashboardLayout"
import { DashboardLayout } from "./components/Dashboard/DashboardLayout"
import { RequireAuth } from "./components/RequireAuth/RequireAuth"
import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout"
import { DeploySettingsLayout } from "components/DeploySettingsLayout/DeploySettingsLayout"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ServiceBanner } from "components/ServiceBanner/ServiceBanner"
import { updateCheckMachine } from "xServices/updateCheck/updateCheckXService"
import { usePermissions } from "hooks/usePermissions"
import { UpdateCheckResponse } from "api/typesGenerated"
import { DashboardProvider } from "./DashboardProvider"

export const DashboardLayout: FC = () => {
const styles = useStyles()
Expand All @@ -23,7 +24,7 @@ export const DashboardLayout: FC = () => {
const { error: updateCheckError, updateCheck } = updateCheckState.context

return (
<>
<DashboardProvider>
<ServiceBanner />
<LicenseBanner />

Expand All @@ -50,7 +51,7 @@ export const DashboardLayout: FC = () => {
</Suspense>
</div>
</div>
</>
</DashboardProvider>
)
}

Expand Down
83 changes: 83 additions & 0 deletions site/src/components/Dashboard/DashboardProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useMachine } from "@xstate/react"
import {
AppearanceConfig,
BuildInfoResponse,
Entitlements,
} from "api/typesGenerated"
import { FullScreenLoader } from "components/Loader/FullScreenLoader"
import { createContext, FC, PropsWithChildren, useContext } from "react"
import { appearanceMachine } from "xServices/appearance/appearanceXService"
import { buildInfoMachine } from "xServices/buildInfo/buildInfoXService"
import { entitlementsMachine } from "xServices/entitlements/entitlementsXService"

interface Appearance {
config: AppearanceConfig
preview: boolean
setPreview: (config: AppearanceConfig) => void
save: (config: AppearanceConfig) => void
}

interface DashboardProviderValue {
buildInfo: BuildInfoResponse
entitlements: Entitlements
appearance: Appearance
}

export const DashboardProviderContext = createContext<
DashboardProviderValue | undefined
>(undefined)

export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
const [buildInfoState] = useMachine(buildInfoMachine)
const [entitlementsState] = useMachine(entitlementsMachine)
const [appearanceState, appearanceSend] = useMachine(appearanceMachine)
const { buildInfo } = buildInfoState.context
const { entitlements } = entitlementsState.context
const { appearance, preview } = appearanceState.context
const isLoading = !buildInfo || !entitlements || !appearance

const setAppearancePreview = (config: AppearanceConfig) => {
appearanceSend({
type: "SET_PREVIEW_APPEARANCE",
appearance: config,
})
}

const saveAppearance = (config: AppearanceConfig) => {
appearanceSend({
type: "SAVE_APPEARANCE",
appearance: config,
})
}

if (isLoading) {
return <FullScreenLoader />
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about keeping this context 'pure' meaning it doesn't really dictate UI at all? Then we could expose isLoading and isError and the consumer could handle those UI changes. Benefit would be keeping our UI all in one place so folks opening up that page component can see how it's handled at first glance, without having to dig into the context.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if I got it. I don't see too much value on having it pure since the value, from what I see, is to have a provider that handle that in one single place but maybe I'm missing something. I based this provider on https://kentcdodds.com/blog/authentication-in-react-applications

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in that example, he's showcasing functionality instead of organization, but I'm happy to talk it through in Discord. It's also not a blocker.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting. I'm usually agnostic about where to put these, but I don't know if I've ever seen a provider render a spinner. But I see that Kent really does it intentionally:

  // 🚨 this is the important bit.
  // Normally your provider components render the context provider with a value.
  // But we post-pone rendering any of the children until after we've determined
  // whether or not we have a user token and if we do, then we render a spinner
  // while we go retrieve that user's information.```

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I could see that. I was looking at his comment below:

The key idea that drastically simplifies authentication in your app is this:

The component which has the user data prevents the rest of the app from being rendered until the user data is retrieved or it's determined that there is no logged-in user

So I figured the component is the consumer of the context, and it should have something like this:

const {isLoading} = useAuth()

if {isLoading} return <Loader/>

return (
 <App />
)

I guess this is an argument for colocation, kind of similar to what we were discussing with long files. But just because it isn't a pattern I've seen before doesn't mean it's wrong! I'm happy to keep the loaders in the context.

Copy link
Collaborator Author

@BrunoQuaresma BrunoQuaresma Jan 19, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But just because it isn't a pattern I've seen before doesn't mean it's wrong!

I agree 100%. In this case, I just think would be too much to have another component to handle the spinner. If at some point it starts to do more and more stuff, I think would make sense to split it.


return (
<DashboardProviderContext.Provider
value={{
buildInfo,
entitlements,
appearance: {
preview,
config: appearance,
setPreview: setAppearancePreview,
save: saveAppearance,
},
}}
>
{children}
</DashboardProviderContext.Provider>
)
}

export const useDashboard = (): DashboardProviderValue => {
const context = useContext(DashboardProviderContext)

if (!context) {
throw new Error("useDashboard only can be used inside of DashboardProvider")
}

return context
}
16 changes: 3 additions & 13 deletions site/src/components/LicenseBanner/LicenseBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,9 @@
import { useActor } from "@xstate/react"
import { useContext, useEffect } from "react"
import { XServiceContext } from "xServices/StateContext"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { LicenseBannerView } from "./LicenseBannerView"

export const LicenseBanner: React.FC = () => {
const xServices = useContext(XServiceContext)
const [entitlementsState, entitlementsSend] = useActor(
xServices.entitlementsXService,
)
const { errors, warnings } = entitlementsState.context.entitlements

/** Gets license data on app mount because LicenseBanner is mounted in App */
useEffect(() => {
entitlementsSend("GET_ENTITLEMENTS")
}, [entitlementsSend])
const { entitlements } = useDashboard()
const { errors, warnings } = entitlements

if (errors.length > 0 || warnings.length > 0) {
return <LicenseBannerView errors={errors} warnings={warnings} />
Expand Down
21 changes: 7 additions & 14 deletions site/src/components/Navbar/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,17 @@
import { shallowEqual, useActor, useSelector } from "@xstate/react"
import { useAuth } from "components/AuthProvider/AuthProvider"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { useFeatureVisibility } from "hooks/useFeatureVisibility"
import { useMe } from "hooks/useMe"
import { usePermissions } from "hooks/usePermissions"
import { useContext, FC } from "react"
import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
import { XServiceContext } from "../../xServices/StateContext"
import { FC } from "react"
import { NavbarView } from "../NavbarView/NavbarView"

export const Navbar: FC = () => {
const xServices = useContext(XServiceContext)
const [appearanceState] = useActor(xServices.appearanceXService)
const [buildInfoState] = useActor(xServices.buildInfoXService)
const { appearance, buildInfo } = useDashboard()
const [_, authSend] = useAuth()
const me = useMe()
const permissions = usePermissions()
const featureVisibility = useSelector(
xServices.entitlementsXService,
selectFeatureVisibility,
shallowEqual,
)
const featureVisibility = useFeatureVisibility()
const canViewAuditLog =
featureVisibility["audit_log"] && Boolean(permissions.viewAuditLog)
const canViewDeployment = Boolean(permissions.viewDeploymentConfig)
Expand All @@ -27,8 +20,8 @@ export const Navbar: FC = () => {
return (
<NavbarView
user={me}
logo_url={appearanceState.context.appearance.logo_url}
buildInfo={buildInfoState.context.buildInfo}
logo_url={appearance.config.logo_url}
buildInfo={buildInfo}
onSignOut={onSignOut}
canViewAuditLog={canViewAuditLog}
canViewDeployment={canViewDeployment}
Expand Down
21 changes: 4 additions & 17 deletions site/src/components/ServiceBanner/ServiceBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,10 @@
import { useActor } from "@xstate/react"
import { useAuth } from "components/AuthProvider/AuthProvider"
import { useContext, useEffect } from "react"
import { XServiceContext } from "xServices/StateContext"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { ServiceBannerView } from "./ServiceBannerView"

export const ServiceBanner: React.FC = () => {
const xServices = useContext(XServiceContext)
const [appearanceState, appearanceSend] = useActor(
xServices.appearanceXService,
)
const [authState] = useAuth()
const { appearance } = useDashboard()
const { message, background_color, enabled } =
appearanceState.context.appearance.service_banner

useEffect(() => {
if (authState.matches("signedIn")) {
appearanceSend("GET_APPEARANCE")
}
}, [appearanceSend, authState])
appearance.config.service_banner

if (!enabled) {
return null
Expand All @@ -28,7 +15,7 @@ export const ServiceBanner: React.FC = () => {
<ServiceBannerView
message={message}
backgroundColor={background_color}
preview={appearanceState.context.preview}
preview={appearance.preview}
/>
)
} else {
Expand Down
8 changes: 3 additions & 5 deletions site/src/hooks/useFeatureVisibility.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { useSelector } from "@xstate/react"
import { FeatureName } from "api/typesGenerated"
import { useContext } from "react"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
import { XServiceContext } from "xServices/StateContext"

export const useFeatureVisibility = (): Record<FeatureName, boolean> => {
const xServices = useContext(XServiceContext)
return useSelector(xServices.entitlementsXService, selectFeatureVisibility)
const { entitlements } = useDashboard()
return selectFeatureVisibility(entitlements)
}
Original file line number Diff line number Diff line change
@@ -1,46 +1,32 @@
import { useActor } from "@xstate/react"
import { AppearanceConfig } from "api/typesGenerated"
import { useContext, FC } from "react"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { pageTitle } from "util/page"
import { XServiceContext } from "xServices/StateContext"
import { AppearanceSettingsPageView } from "./AppearanceSettingsPageView"

// ServiceBanner is unlike the other Deployment Settings pages because it
// implements a form, whereas the others are read-only. We make this
// exception because the Service Banner is visual, and configuring it from
// the command line would be a significantly worse user experience.
const AppearanceSettingsPage: FC = () => {
const xServices = useContext(XServiceContext)
const [appearanceXService, appearanceSend] = useActor(
xServices.appearanceXService,
)
const [entitlementsState] = useActor(xServices.entitlementsXService)
const appearance = appearanceXService.context.appearance

const { appearance, entitlements } = useDashboard()
const isEntitled =
entitlementsState.context.entitlements.features["appearance"]
.entitlement !== "not_entitled"
entitlements.features["appearance"].entitlement !== "not_entitled"

const updateAppearance = (
newConfig: Partial<AppearanceConfig>,
preview: boolean,
) => {
const newAppearance = {
...appearance,
...appearance.config,
...newConfig,
}
if (preview) {
appearanceSend({
type: "SET_PREVIEW_APPEARANCE",
appearance: newAppearance,
})
appearance.setPreview(newAppearance)
return
}
appearanceSend({
type: "SET_APPEARANCE",
appearance: newAppearance,
})
appearance.save(newAppearance)
}

return (
Expand All @@ -50,7 +36,7 @@ const AppearanceSettingsPage: FC = () => {
</Helmet>

<AppearanceSettingsPageView
appearance={appearance}
appearance={appearance.config}
isEntitled={isEntitled}
updateAppearance={updateAppearance}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { useActor } from "@xstate/react"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { useDeploySettings } from "components/DeploySettingsLayout/DeploySettingsLayout"
import { useContext, FC } from "react"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { pageTitle } from "util/page"
import { XServiceContext } from "xServices/StateContext"
import { SecuritySettingsPageView } from "./SecuritySettingsPageView"

const SecuritySettingsPage: FC = () => {
const { deploymentConfig: deploymentConfig } = useDeploySettings()
const xServices = useContext(XServiceContext)
const [entitlementsState] = useActor(xServices.entitlementsXService)
const { entitlements } = useDashboard()

return (
<>
Expand All @@ -19,12 +17,9 @@ const SecuritySettingsPage: FC = () => {

<SecuritySettingsPageView
deploymentConfig={deploymentConfig}
featureAuditLogEnabled={
entitlementsState.context.entitlements.features["audit_log"].enabled
}
featureAuditLogEnabled={entitlements.features["audit_log"].enabled}
featureBrowserOnlyEnabled={
entitlementsState.context.entitlements.features["browser_only"]
.enabled
entitlements.features["browser_only"].enabled
}
/>
</>
Expand Down
7 changes: 3 additions & 4 deletions site/src/pages/SetupPage/SetupPage.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { useActor, useMachine } from "@xstate/react"
import { FC, useContext, useEffect } from "react"
import { useMachine } from "@xstate/react"
import { useAuth } from "components/AuthProvider/AuthProvider"
import { FC, useEffect } from "react"
import { Helmet } from "react-helmet-async"
import { pageTitle } from "util/page"
import { setupMachine } from "xServices/setup/setupXService"
import { XServiceContext } from "xServices/StateContext"
import { SetupPageView } from "./SetupPageView"

export const SetupPage: FC = () => {
const xServices = useContext(XServiceContext)
const [authState, authSend] = useAuth()
const [setupState, setupSend] = useMachine(setupMachine, {
actions: {
Expand Down
Loading