Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Badge } from '@repo/ui/badge'
import { type UserAuthProvider } from '@repo/codegen/src/schema'
import { cn } from '@repo/ui/lib/utils'

type LastUsedBadgeProps = {
provider: UserAuthProvider
lastUsedProvider: UserAuthProvider | null
floating?: boolean
className?: string
}

export const LastUsedBadge = ({ provider, lastUsedProvider, floating = false, className }: LastUsedBadgeProps) => {
if (lastUsedProvider !== provider) return null

return (
<Badge
variant="primary"
className={cn('bg-blue-500/50 text-white border-transparent', floating ? 'absolute -top-3.5 left-1/2 -translate-x-1/2 z-10 w-fit whitespace-nowrap' : 'self-center', className)}
>
Last used
</Badge>
)
}
92 changes: 69 additions & 23 deletions apps/console/src/components/pages/auth/login/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { type LoginUser } from '@repo/dally/user'
import { Button } from '@repo/ui/button'
import { UserAuthProvider } from '@repo/codegen/src/schema'
import SimpleForm from '@repo/ui/simple-form'
import { ArrowRightCircle, Github, KeyRoundIcon } from 'lucide-react'
import { signIn, type SignInResponse } from 'next-auth/react'
Expand All @@ -22,6 +23,8 @@ import { isValidEmail } from '@/lib/validators'
import { OPENLANE_WEBSITE_URL } from '@/constants'
import { cn } from '@repo/ui/lib/utils'
import { sanitizeLoginRedirect } from '@/lib/auth/utils/redirect'
import { recordLastLoginMethod, getLastLoginMethod } from '@/lib/auth/utils/last-login-method'
import { LastUsedBadge } from './last-used-badge'

export const LoginPage = () => {
const { separator, buttons, form, input } = loginStyles()
Expand All @@ -45,6 +48,7 @@ export const LoginPage = () => {
const searchParams = useSearchParams()
const token = searchParams?.get('token')
const redirect = searchParams?.get('redirect')
const emailParam = searchParams?.get('email')
const urlErrorMessage = searchParams.get('error')
const showLoginError = !signInLoading && signInError

Expand Down Expand Up @@ -75,23 +79,26 @@ export const LoginPage = () => {
}, [webfingerResponse, usePasswordInsteadOfSSO])

const shouldShowSSOButton = useCallback((): boolean => {
if (!webfingerResponse) {
if (!webfingerResponse || !webfingerResponse.success) {
return false
}

// only show SSO button when it is enforced
if (webfingerResponse.enforced && webfingerResponse.provider !== 'NONE' && webfingerResponse.organization_id) {
// show the SSO button whenever the org has an identity provider configured
if (webfingerResponse.provider && webfingerResponse.provider !== 'NONE' && webfingerResponse.organization_id) {
// but if the user is the org admin and chooses to use password, don't show SSO button
if (webfingerResponse.is_org_owner && usePasswordInsteadOfSSO) {
return false
}
return webfingerResponse.success
return true
}

// don't show SSO button when SSO is not enforced
// don't show SSO button when no identity provider is configured
return false
}, [webfingerResponse, usePasswordInsteadOfSSO])

// the method the user most recently signed in with, remembered per-device
const [lastUsedProvider, setLastUsedProvider] = useState<UserAuthProvider | null>(null)

const shouldShowToggleOption = useCallback((): boolean => {
return Boolean(webfingerResponse?.enforced && webfingerResponse?.is_org_owner && webfingerResponse?.provider !== 'NONE' && webfingerResponse?.organization_id)
}, [webfingerResponse])
Expand All @@ -115,6 +122,7 @@ export const LoginPage = () => {
const data = await response.json()

if (response.ok && data.success && data.redirect_uri) {
recordLastLoginMethod(UserAuthProvider.OIDC)
window.location.href = data.redirect_uri
return true
}
Expand Down Expand Up @@ -176,6 +184,10 @@ export const LoginPage = () => {
}
}, [])

useEffect(() => {
setLastUsedProvider(getLastLoginMethod())
}, [])

const redirectUrl = useMemo(() => {
if (token) {
return `/invite?token=${token}`
Expand All @@ -188,7 +200,8 @@ export const LoginPage = () => {
setSignInError(false)

try {
if (shouldShowSSOButton()) {
// only block credential submit when SSO is the only available method
if (shouldShowSSOButton() && !shouldShowPasswordField()) {
return
}

Expand Down Expand Up @@ -217,6 +230,7 @@ export const LoginPage = () => {
})

if (res.ok && !res.error) {
recordLastLoginMethod(UserAuthProvider.CREDENTIALS)
router.push(redirectUrl)
} else {
let errMsg = 'There was an error. Please try again.'
Expand Down Expand Up @@ -251,6 +265,7 @@ export const LoginPage = () => {

const github = async () => {
setDirectOAuthCookie()
recordLastLoginMethod(UserAuthProvider.GITHUB)

await signIn('github', {
redirectTo: redirectUrl,
Expand All @@ -259,6 +274,7 @@ export const LoginPage = () => {

const google = async () => {
setDirectOAuthCookie()
recordLastLoginMethod(UserAuthProvider.GOOGLE)

await signIn('google', {
redirectTo: redirectUrl,
Expand Down Expand Up @@ -292,6 +308,7 @@ export const LoginPage = () => {

if (verificationResult.success) {
setDirectOAuthCookie()
recordLastLoginMethod(UserAuthProvider.WEBAUTHN)
await signIn('passkey', {
callbackUrl: redirectUrl,
email: email || '',
Expand Down Expand Up @@ -329,6 +346,14 @@ export const LoginPage = () => {
}
}, [urlErrorMessage, router])

// prefill the email from an invite link and resolve sign-in methods on load
useEffect(() => {
if (emailParam && isValidEmail(emailParam)) {
setEmail(emailParam)
checkLoginMethods(emailParam)
}
}, [emailParam, checkLoginMethods])

return (
<>
<div className="flex flex-col self-center text-center">
Expand All @@ -342,18 +367,27 @@ export const LoginPage = () => {
</div>
)}

<div className={cn(buttons(), 'flex justify-center mt-[32px]')}>
<Button variant="secondary" className="!py-1.5 !px-5" size="md" icon={<GoogleIcon />} iconPosition="left" onClick={() => google()} disabled={signInLoading}>
<p className="text-sm font-normal">Google</p>
</Button>
<div className={cn(buttons(), 'flex justify-center items-center mt-[32px]')}>
<div className="relative">
<LastUsedBadge provider={UserAuthProvider.GOOGLE} lastUsedProvider={lastUsedProvider} floating />
<Button variant="secondary" className="!py-1.5 !px-5 " size="md" icon={<GoogleIcon />} iconPosition="left" onClick={() => google()} disabled={signInLoading}>
<p className="text-sm font-normal">Google</p>
</Button>
</div>

<Button variant="secondary" className="!py-1.5 !px-5" size="md" icon={<Github className="text-input-text" />} iconPosition="left" onClick={() => github()} disabled={signInLoading}>
<p className="text-sm font-normal">GitHub</p>
</Button>
<div className="relative">
<LastUsedBadge provider={UserAuthProvider.GITHUB} lastUsedProvider={lastUsedProvider} floating />
<Button variant="secondary" className="!py-1.5 !px-5 " size="md" icon={<Github className="text-input-text" />} iconPosition="left" onClick={() => github()} disabled={signInLoading}>
<p className="text-sm font-normal">GitHub</p>
</Button>
</div>

<Button variant="secondary" className="!py-1.5 !px-5" icon={<KeyRoundIcon className="text-input-text" />} iconPosition="left" onClick={() => passKeySignIn()} disabled={signInLoading}>
<p className="text-sm font-normal">Passkey</p>
</Button>
<div className="relative">
<LastUsedBadge provider={UserAuthProvider.WEBAUTHN} lastUsedProvider={lastUsedProvider} floating />
<Button variant="secondary" className="!py-1.5 !px-5 " icon={<KeyRoundIcon className="text-input-text" />} iconPosition="left" onClick={() => passKeySignIn()} disabled={signInLoading}>
<p className="text-sm font-normal">Passkey</p>
</Button>
</div>
</div>

<Separator label="or" login className={cn(separator(), 'text-muted-foreground')} />
Expand All @@ -379,8 +413,9 @@ export const LoginPage = () => {
}}
>
<div className={input()}>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between items-centeer">
<p className="text-sm">Email</p>
<LastUsedBadge provider={UserAuthProvider.CREDENTIALS} lastUsedProvider={lastUsedProvider} />
{shouldShowSSOButton() && shouldShowToggleOption() && (
<button
type="button"
Expand All @@ -392,15 +427,23 @@ export const LoginPage = () => {
)}
</div>

<Input type="email" variant="light" name="username" placeholder="Enter your email" className={`bg-transparent ${showLoginError ? 'border border-toast-error-icon' : ''}`} />
<Input
type="email"
variant="light"
name="username"
placeholder="Enter your email"
defaultValue={emailParam ?? undefined}
className={`bg-transparent ${showLoginError ? 'border border-toast-error-icon' : ''}`}
/>
{showLoginError && <span className="text-xs text-toast-error-icon text-left">{signInErrorMessage}</span>}
</div>

{shouldShowSSOButton() && (
<div className="flex flex-col">
<div className="relative flex flex-col mt-[16px]">
<LastUsedBadge provider={UserAuthProvider.OIDC} lastUsedProvider={lastUsedProvider} floating />
<Button
variant="primary"
className="mt-[16px] p-4 flex justify-center items-center text-center rounded-md text-sm h-[36px] font-bold"
className="p-4 flex justify-center items-center text-center rounded-md text-sm h-[36px] font-bold"
type="button"
onClick={handleSSOLogin}
disabled={signInLoading || webfingerLoading}
Expand All @@ -425,9 +468,12 @@ export const LoginPage = () => {
</div>
<PasswordInput variant="light" name="password" placeholder="Enter your password" autoComplete="current-password" className="bg-transparent !text-text" />
</div>
<Button variant="primary" className="mt-[16px] p-4 flex justify-center items-center text-center rounded-md text-sm h-[36px] font-bold" type="submit" disabled={signInLoading}>
<span>Login</span>
</Button>
<div className="flex flex-col">
<Button variant="primary" className="mt-[16px] p-4 flex justify-center items-center text-center rounded-md text-sm h-[36px] font-bold" type="submit" disabled={signInLoading}>
{' '}
<span>Login</span>
</Button>
</div>
</>
}

Expand Down
13 changes: 12 additions & 1 deletion apps/console/src/components/pages/auth/signup/signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { SimpleForm } from '@repo/ui/simple-form'
import { Button } from '@repo/ui/button'
import { ArrowRightCircle, Github } from 'lucide-react'
import { registerUser, type RegisterUser } from '@/lib/user'
import { recordLastLoginMethod } from '@/lib/auth/utils/last-login-method'
import { UserAuthProvider } from '@repo/codegen/src/schema'
import { GoogleIcon } from '@repo/ui/icons/google'
import { signIn } from 'next-auth/react'
import { Separator } from '@repo/ui/separator'
Expand All @@ -29,10 +31,12 @@ export const SignupPage = () => {
const showError = !isLoading && !!registrationErrorMessage

const github = async () => {
recordLastLoginMethod(UserAuthProvider.GITHUB)
await signIn('github', { redirectTo: '/' })
}

const google = async () => {
recordLastLoginMethod(UserAuthProvider.GOOGLE)
await signIn('google', {
redirect: true,
redirectTo: '/signup',
Expand All @@ -49,7 +53,7 @@ export const SignupPage = () => {
{!isPasswordActive && (
<div className="mt-2 text-center">
<span className="text-muted-foreground text-sm">Already have an account?&nbsp;</span>
<Link href="/login" className="text-sm hover:text-blue-500 hover:opacity-80 transition-color duration-500">
<Link href={`/login${token ? `?token=${token}` : ''}`} className="text-sm hover:text-blue-500 hover:opacity-80 transition-color duration-500">
Login
</Link>
</div>
Expand Down Expand Up @@ -105,10 +109,17 @@ export const SignupPage = () => {

const res = await registerUser(payload)

if (res?.ok) {
recordLastLoginMethod(UserAuthProvider.CREDENTIALS)
}

if (res?.ok && token) {
router.push(`/login`)
} else if (res?.ok) {
router.push('/verify')
} else if (token && res?.message && /already exists/i.test(res.message)) {
// invitee already has an account, route to login carrying the invite
router.push(`/login?token=${token}&email=${encodeURIComponent(payload.email)}`)
} else if (res?.message) {
setRegistrationErrorMessage(res.message)
} else {
Expand Down
21 changes: 18 additions & 3 deletions apps/console/src/components/pages/invite/accept.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,25 @@ export const InviteAccepter = () => {
useEffect(() => {
if (status === 'authenticated' && token) {
setEnabled(true)
} else if (status === 'unauthenticated') {
push(`/login?token=${token}`)
return
}
}, [status, token, push])

if (status !== 'unauthenticated') {
return
}

const email = searchParams?.get('email')

// route on the invite link's account hint: new users to signup, existing users to login
const destination = searchParams?.get('new') === 'true' ? 'signup' : 'login'

const params = new URLSearchParams()
if (token) params.set('token', token)
if (email) params.set('email', email)
const query = params.toString()

push(`/${destination}${query ? `?${query}` : ''}`)
}, [status, token, push, searchParams])

useEffect(() => {
if (hasUpdatedRef.current || !verified?.success || !session) return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,10 @@ const ViewPolicyPage: React.FC<TViewPolicyPage> = ({ policyId }) => {
confirmationText="Switch to externally managed"
confirmationTextVariant="primary"
description={
<>The Policy view will switch to displaying the attached file and edits inside Openlane will be disabled. Any changes since the file was originally uploaded will not be reflected in the attached file. The underlying details data stays in place — you can switch back at any time.</>
<>
The Policy view will switch to displaying the attached file and edits inside Openlane will be disabled. Any changes since the file was originally uploaded will not be reflected in the
attached file. The underlying details data stays in place — you can switch back at any time.
</>
}
/>
</div>
Expand Down
22 changes: 22 additions & 0 deletions apps/console/src/lib/auth/utils/last-login-method.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { UserAuthProvider } from '@repo/codegen/src/schema'

const LAST_LOGIN_METHOD_KEY = 'last_login_method'

export const recordLastLoginMethod = (provider: UserAuthProvider) => {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(LAST_LOGIN_METHOD_KEY, provider)
} catch {
// storage unavailable
}
}

export const getLastLoginMethod = (): UserAuthProvider | null => {
if (typeof window === 'undefined') return null
try {
const raw = window.localStorage.getItem(LAST_LOGIN_METHOD_KEY)
return raw && Object.values(UserAuthProvider).includes(raw as UserAuthProvider) ? (raw as UserAuthProvider) : null
} catch {
return null
}
}