Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
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
1 change: 1 addition & 0 deletions codegen.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"Comment": "../../postgres/types/index#Comment as CommentDB",
"Company": "./types/Company#CompanySource",
"CreateGcalEventInput": "./types/CreateGcalEventInput#default",
"CreateOAuthAPICodePayload": "./types/CreateOAuthAPICodePayload#CreateOAuthAPICodePayloadSource",
"DomainJoinRequest": "./types/DomainJoinRequest#DomainJoinRequestSource",
"CreateImposterTokenPayload": "./types/CreateImposterTokenPayload#CreateImposterTokenPayloadSource",
"CreateTaskPayload": "./types/CreateTaskPayload#CreateTaskPayloadSource",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -200,5 +200,5 @@
"y-protocols": "^1.0.6",
"yjs": "^13.6.27"
},
"packageManager": "pnpm@10.20.0+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd"
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a"
}
4 changes: 4 additions & 0 deletions packages/client/components/Action/Action.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ const InvitationLink = lazy(
const PageRoot = lazy(
() => import(/* webpackChunkName: 'PageRoot' */ '../../modules/pages/PageRoot')
)
const OAuthAuthorizePage = lazy(
() => import(/* webpackChunkName: 'OAuthAuthorizePage' */ '../OAuthAuthorizePage')
)

const Action = memo(() => {
useServiceWorkerUpdater()
Expand Down Expand Up @@ -99,6 +102,7 @@ const Action = memo(() => {
component={VerifyEmail}
/>
<Route path='/reset-password/:token' component={SetNewPassword} />
<Route exact path='/oauth/authorize' component={OAuthAuthorizePage} />
Copy link
Member

Choose a reason for hiding this comment

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

-1 I am very confused about this. This is a route on the server, but we're serving up something different on the client route? It seems odd that there are 2 ways to generate a code: The GraphQL way & the oauth2 spec of /oauth/authorize. Why is the GraphQL mutation needed when we have the authorize endpoint?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As I was sketching things out, I had been exploring the idea of being able to "Login with Parabol." This route would sees their session cookie and immediately redirects them back to the third-party app with an authorization code without needing the full SPA to load.

This route isn't necessary for the primary authentication path tho

<Route path='/team-invitation/:token' component={TeamInvitation} />
<Route path='/invitation-link/:token' component={InvitationLink} />
{!isAuthenticated && (
Expand Down
5 changes: 4 additions & 1 deletion packages/client/components/InputField/BasicInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const Error = styled(StyledError)({
})

interface Props {
autoComplete?: 'off'
autoComplete?: 'off' | 'new-password'
autoFocus?: boolean
className?: string
disabled?: boolean
Expand All @@ -30,6 +30,7 @@ interface Props {
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
placeholder?: string
spellCheck?: boolean
type?: string
Expand All @@ -48,6 +49,7 @@ const BasicInput = forwardRef((props: Props, ref: Ref<HTMLInputElement>) => {
onBlur,
onChange,
onFocus,
onKeyDown,
placeholder,
spellCheck,
type = 'text',
Expand All @@ -68,6 +70,7 @@ const BasicInput = forwardRef((props: Props, ref: Ref<HTMLInputElement>) => {
onBlur={onBlur}
onChange={onChange}
onFocus={onFocus}
onKeyDown={onKeyDown}
spellCheck={spellCheck}
type={type}
value={value}
Expand Down
96 changes: 96 additions & 0 deletions packages/client/components/OAuthAuthorizePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import graphql from 'babel-plugin-relay/macro'
import {useEffect, useState} from 'react'
import {useMutation} from 'react-relay'
import {useHistory, useLocation} from 'react-router'
import {LoaderSize} from '../types/constEnums'
import LoadingComponent from './LoadingComponent/LoadingComponent'

const OAuthAuthorizePage = () => {
const history = useHistory()
const location = useLocation()
const [error, setError] = useState<string | null>(null)

const [commitCreateCode] = useMutation(graphql`
mutation OAuthAuthorizePageMutation($input: CreateOAuthAPICodeInput!) {
createOAuthAPICode(input: $input) {
code
redirectUri
state
}
}
`)

useEffect(() => {
const params = new URLSearchParams(location.search)
const clientId = params.get('client_id')
const redirectUri = params.get('redirect_uri')
const responseType = params.get('response_type')
const scope = params.get('scope')
const state = params.get('state')

if (!clientId || !redirectUri || responseType !== 'code') {
setError('Invalid request parameters')
return
}

const scopes = scope ? scope.split(' ') : []

commitCreateCode({
variables: {
input: {
clientId,
redirectUri,
scopes,
state: state || undefined
}
},
onCompleted: (data: any) => {
const {code, redirectUri, state} = data.createOAuthAPICode
const redirectUrl = new URL(redirectUri)
redirectUrl.searchParams.set('code', code)
if (state) {
redirectUrl.searchParams.set('state', state)
}
window.location.href = redirectUrl.toString()
},
onError: (err: any) => {
let errorMessage = err.message || 'Authorization failed'

if (
errorMessage.includes('Not signed in') ||
errorMessage.includes('Not authenticated') ||
errorMessage.includes('401')
) {
const currentUrl = window.location.pathname + window.location.search
history.push(`/login?redirectTo=${encodeURIComponent(currentUrl)}`)
return
}

errorMessage = errorMessage.replace(/^OAuth Error:\s*/i, '').replace(/^Error:\s*/i, '')

setError(errorMessage)
}
})
}, [location.search, commitCreateCode, history])

if (error) {
return (
<div className='flex h-screen w-full items-center justify-center'>
<div className='rounded-lg bg-white p-8 shadow-lg'>
<h1 className='mb-4 font-bold text-2xl text-red-600'>Authorization Error</h1>
<p className='text-slate-700'>{error}</p>
<button
onClick={() => history.push('/')}
className='mt-4 rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600'
>
Return to Home
</button>
</div>
</div>
)
}

return <LoadingComponent spinnerSize={LoaderSize.WHOLE_PAGE} />
}

export default OAuthAuthorizePage
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {OrgAuthenticationQuery} from '../../../../__generated__/OrgAuthenti
import DialogTitle from '../../../../components/DialogTitle'
import Panel from '../../../../components/Panel/Panel'
import {ElementWidth} from '../../../../types/constEnums'
import OAuthProviderList from '../OrgIntegrations/OAuthProviderList'
import OrgAuthenticationMetadata from './OrgAuthenticationMetadata'
import OrgAuthenticationSignOnUrl from './OrgAuthenticationSignOnUrl'
import OrgAuthenticationSSOFrame from './OrgAuthenticationSSOFrame'
Expand All @@ -29,6 +30,8 @@ const OrgAuthentication = (props: Props) => {
...OrgAuthenticationMetadata_saml
id
}
...OAuthProviderList_organization
featureFlag(featureName: "oauthProvider")
}
}
}
Expand All @@ -39,15 +42,32 @@ const OrgAuthentication = (props: Props) => {
const {organization} = viewer
const saml = organization?.saml ?? null
const disabled = !saml
const showOAuthProvider = organization?.featureFlag

return (
<StyledPanel>
<DialogTitle className='px-6 pt-5 pb-6'>SAML Single Sign-On</DialogTitle>
<OrgAuthenticationSSOFrame samlRef={saml} />
<div className={disabled ? 'pointer-events-none select-none opacity-40' : ''}>
<OrgAuthenticationSignOnUrl samlRef={saml} />
<OrgAuthenticationMetadata samlRef={saml} />
</div>
</StyledPanel>
<div className='space-y-6'>
<StyledPanel>
<DialogTitle className='px-6 pt-5 pb-6'>SAML Single Sign-On</DialogTitle>
<OrgAuthenticationSSOFrame samlRef={saml} />
<div className={disabled ? 'pointer-events-none select-none opacity-40' : ''}>
<OrgAuthenticationSignOnUrl samlRef={saml} />
<OrgAuthenticationMetadata samlRef={saml} />
</div>
</StyledPanel>

{showOAuthProvider && (
<StyledPanel>
<DialogTitle className='px-6 pt-5 pb-6'>OAuth 2.0 API</DialogTitle>
<div className='px-6 pb-6'>
<div className='mb-6 text-base text-slate-700'>
Configure your organization as an OAuth 2.0 provider to allow external applications to
authenticate with your Parabol organization.
</div>
{organization && <OAuthProviderList organizationRef={organization} />}
</div>
</StyledPanel>
)}
</div>
)
}

Expand Down
Loading