Skip to content

Commit 1dc0f75

Browse files
Add last login method and better invite handling (#1916)
* add last login method local storage and better url handling based on updated server side * fixing code * making badges overlap * change credentials position and making reusable component * blue 50% opacity --------- Co-authored-by: Bruno Papista <78353799+papistacoding@users.noreply.github.com> Co-authored-by: papistacoding <bruno.papista@gmail.com>
1 parent 51a0390 commit 1dc0f75

6 files changed

Lines changed: 148 additions & 28 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Badge } from '@repo/ui/badge'
2+
import { type UserAuthProvider } from '@repo/codegen/src/schema'
3+
import { cn } from '@repo/ui/lib/utils'
4+
5+
type LastUsedBadgeProps = {
6+
provider: UserAuthProvider
7+
lastUsedProvider: UserAuthProvider | null
8+
floating?: boolean
9+
className?: string
10+
}
11+
12+
export const LastUsedBadge = ({ provider, lastUsedProvider, floating = false, className }: LastUsedBadgeProps) => {
13+
if (lastUsedProvider !== provider) return null
14+
15+
return (
16+
<Badge
17+
variant="primary"
18+
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)}
19+
>
20+
Last used
21+
</Badge>
22+
)
23+
}

apps/console/src/components/pages/auth/login/login.tsx

Lines changed: 69 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

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

2629
export const LoginPage = () => {
2730
const { separator, buttons, form, input } = loginStyles()
@@ -45,6 +48,7 @@ export const LoginPage = () => {
4548
const searchParams = useSearchParams()
4649
const token = searchParams?.get('token')
4750
const redirect = searchParams?.get('redirect')
51+
const emailParam = searchParams?.get('email')
4852
const urlErrorMessage = searchParams.get('error')
4953
const showLoginError = !signInLoading && signInError
5054

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

7781
const shouldShowSSOButton = useCallback((): boolean => {
78-
if (!webfingerResponse) {
82+
if (!webfingerResponse || !webfingerResponse.success) {
7983
return false
8084
}
8185

82-
// only show SSO button when it is enforced
83-
if (webfingerResponse.enforced && webfingerResponse.provider !== 'NONE' && webfingerResponse.organization_id) {
86+
// show the SSO button whenever the org has an identity provider configured
87+
if (webfingerResponse.provider && webfingerResponse.provider !== 'NONE' && webfingerResponse.organization_id) {
8488
// but if the user is the org admin and chooses to use password, don't show SSO button
8589
if (webfingerResponse.is_org_owner && usePasswordInsteadOfSSO) {
8690
return false
8791
}
88-
return webfingerResponse.success
92+
return true
8993
}
9094

91-
// don't show SSO button when SSO is not enforced
95+
// don't show SSO button when no identity provider is configured
9296
return false
9397
}, [webfingerResponse, usePasswordInsteadOfSSO])
9498

99+
// the method the user most recently signed in with, remembered per-device
100+
const [lastUsedProvider, setLastUsedProvider] = useState<UserAuthProvider | null>(null)
101+
95102
const shouldShowToggleOption = useCallback((): boolean => {
96103
return Boolean(webfingerResponse?.enforced && webfingerResponse?.is_org_owner && webfingerResponse?.provider !== 'NONE' && webfingerResponse?.organization_id)
97104
}, [webfingerResponse])
@@ -115,6 +122,7 @@ export const LoginPage = () => {
115122
const data = await response.json()
116123

117124
if (response.ok && data.success && data.redirect_uri) {
125+
recordLastLoginMethod(UserAuthProvider.OIDC)
118126
window.location.href = data.redirect_uri
119127
return true
120128
}
@@ -176,6 +184,10 @@ export const LoginPage = () => {
176184
}
177185
}, [])
178186

187+
useEffect(() => {
188+
setLastUsedProvider(getLastLoginMethod())
189+
}, [])
190+
179191
const redirectUrl = useMemo(() => {
180192
if (token) {
181193
return `/invite?token=${token}`
@@ -188,7 +200,8 @@ export const LoginPage = () => {
188200
setSignInError(false)
189201

190202
try {
191-
if (shouldShowSSOButton()) {
203+
// only block credential submit when SSO is the only available method
204+
if (shouldShowSSOButton() && !shouldShowPasswordField()) {
192205
return
193206
}
194207

@@ -217,6 +230,7 @@ export const LoginPage = () => {
217230
})
218231

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

252266
const github = async () => {
253267
setDirectOAuthCookie()
268+
recordLastLoginMethod(UserAuthProvider.GITHUB)
254269

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

260275
const google = async () => {
261276
setDirectOAuthCookie()
277+
recordLastLoginMethod(UserAuthProvider.GOOGLE)
262278

263279
await signIn('google', {
264280
redirectTo: redirectUrl,
@@ -292,6 +308,7 @@ export const LoginPage = () => {
292308

293309
if (verificationResult.success) {
294310
setDirectOAuthCookie()
311+
recordLastLoginMethod(UserAuthProvider.WEBAUTHN)
295312
await signIn('passkey', {
296313
callbackUrl: redirectUrl,
297314
email: email || '',
@@ -329,6 +346,14 @@ export const LoginPage = () => {
329346
}
330347
}, [urlErrorMessage, router])
331348

349+
// prefill the email from an invite link and resolve sign-in methods on load
350+
useEffect(() => {
351+
if (emailParam && isValidEmail(emailParam)) {
352+
setEmail(emailParam)
353+
checkLoginMethods(emailParam)
354+
}
355+
}, [emailParam, checkLoginMethods])
356+
332357
return (
333358
<>
334359
<div className="flex flex-col self-center text-center">
@@ -342,18 +367,27 @@ export const LoginPage = () => {
342367
</div>
343368
)}
344369

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

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

354-
<Button variant="secondary" className="!py-1.5 !px-5" icon={<KeyRoundIcon className="text-input-text" />} iconPosition="left" onClick={() => passKeySignIn()} disabled={signInLoading}>
355-
<p className="text-sm font-normal">Passkey</p>
356-
</Button>
385+
<div className="relative">
386+
<LastUsedBadge provider={UserAuthProvider.WEBAUTHN} lastUsedProvider={lastUsedProvider} floating />
387+
<Button variant="secondary" className="!py-1.5 !px-5 " icon={<KeyRoundIcon className="text-input-text" />} iconPosition="left" onClick={() => passKeySignIn()} disabled={signInLoading}>
388+
<p className="text-sm font-normal">Passkey</p>
389+
</Button>
390+
</div>
357391
</div>
358392

359393
<Separator label="or" login className={cn(separator(), 'text-muted-foreground')} />
@@ -379,8 +413,9 @@ export const LoginPage = () => {
379413
}}
380414
>
381415
<div className={input()}>
382-
<div className="flex items-center justify-between">
416+
<div className="flex items-center justify-between items-centeer">
383417
<p className="text-sm">Email</p>
418+
<LastUsedBadge provider={UserAuthProvider.CREDENTIALS} lastUsedProvider={lastUsedProvider} />
384419
{shouldShowSSOButton() && shouldShowToggleOption() && (
385420
<button
386421
type="button"
@@ -392,15 +427,23 @@ export const LoginPage = () => {
392427
)}
393428
</div>
394429

395-
<Input type="email" variant="light" name="username" placeholder="Enter your email" className={`bg-transparent ${showLoginError ? 'border border-toast-error-icon' : ''}`} />
430+
<Input
431+
type="email"
432+
variant="light"
433+
name="username"
434+
placeholder="Enter your email"
435+
defaultValue={emailParam ?? undefined}
436+
className={`bg-transparent ${showLoginError ? 'border border-toast-error-icon' : ''}`}
437+
/>
396438
{showLoginError && <span className="text-xs text-toast-error-icon text-left">{signInErrorMessage}</span>}
397439
</div>
398440

399441
{shouldShowSSOButton() && (
400-
<div className="flex flex-col">
442+
<div className="relative flex flex-col mt-[16px]">
443+
<LastUsedBadge provider={UserAuthProvider.OIDC} lastUsedProvider={lastUsedProvider} floating />
401444
<Button
402445
variant="primary"
403-
className="mt-[16px] p-4 flex justify-center items-center text-center rounded-md text-sm h-[36px] font-bold"
446+
className="p-4 flex justify-center items-center text-center rounded-md text-sm h-[36px] font-bold"
404447
type="button"
405448
onClick={handleSSOLogin}
406449
disabled={signInLoading || webfingerLoading}
@@ -425,9 +468,12 @@ export const LoginPage = () => {
425468
</div>
426469
<PasswordInput variant="light" name="password" placeholder="Enter your password" autoComplete="current-password" className="bg-transparent !text-text" />
427470
</div>
428-
<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}>
429-
<span>Login</span>
430-
</Button>
471+
<div className="flex flex-col">
472+
<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}>
473+
{' '}
474+
<span>Login</span>
475+
</Button>
476+
</div>
431477
</>
432478
}
433479

apps/console/src/components/pages/auth/signup/signup.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { SimpleForm } from '@repo/ui/simple-form'
66
import { Button } from '@repo/ui/button'
77
import { ArrowRightCircle, Github } from 'lucide-react'
88
import { registerUser, type RegisterUser } from '@/lib/user'
9+
import { recordLastLoginMethod } from '@/lib/auth/utils/last-login-method'
10+
import { UserAuthProvider } from '@repo/codegen/src/schema'
911
import { GoogleIcon } from '@repo/ui/icons/google'
1012
import { signIn } from 'next-auth/react'
1113
import { Separator } from '@repo/ui/separator'
@@ -29,10 +31,12 @@ export const SignupPage = () => {
2931
const showError = !isLoading && !!registrationErrorMessage
3032

3133
const github = async () => {
34+
recordLastLoginMethod(UserAuthProvider.GITHUB)
3235
await signIn('github', { redirectTo: '/' })
3336
}
3437

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

106110
const res = await registerUser(payload)
107111

112+
if (res?.ok) {
113+
recordLastLoginMethod(UserAuthProvider.CREDENTIALS)
114+
}
115+
108116
if (res?.ok && token) {
109117
router.push(`/login`)
110118
} else if (res?.ok) {
111119
router.push('/verify')
120+
} else if (token && res?.message && /already exists/i.test(res.message)) {
121+
// invitee already has an account, route to login carrying the invite
122+
router.push(`/login?token=${token}&email=${encodeURIComponent(payload.email)}`)
112123
} else if (res?.message) {
113124
setRegistrationErrorMessage(res.message)
114125
} else {

apps/console/src/components/pages/invite/accept.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,25 @@ export const InviteAccepter = () => {
2222
useEffect(() => {
2323
if (status === 'authenticated' && token) {
2424
setEnabled(true)
25-
} else if (status === 'unauthenticated') {
26-
push(`/login?token=${token}`)
25+
return
2726
}
28-
}, [status, token, push])
27+
28+
if (status !== 'unauthenticated') {
29+
return
30+
}
31+
32+
const email = searchParams?.get('email')
33+
34+
// route on the invite link's account hint: new users to signup, existing users to login
35+
const destination = searchParams?.get('new') === 'true' ? 'signup' : 'login'
36+
37+
const params = new URLSearchParams()
38+
if (token) params.set('token', token)
39+
if (email) params.set('email', email)
40+
const query = params.toString()
41+
42+
push(`/${destination}${query ? `?${query}` : ''}`)
43+
}, [status, token, push, searchParams])
2944

3045
useEffect(() => {
3146
if (hasUpdatedRef.current || !verified?.success || !session) return

apps/console/src/components/pages/protected/policies/view/view-policy-page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,10 @@ const ViewPolicyPage: React.FC<TViewPolicyPage> = ({ policyId }) => {
372372
confirmationText="Switch to externally managed"
373373
confirmationTextVariant="primary"
374374
description={
375-
<>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.</>
375+
<>
376+
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
377+
attached file. The underlying details data stays in place — you can switch back at any time.
378+
</>
376379
}
377380
/>
378381
</div>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { UserAuthProvider } from '@repo/codegen/src/schema'
2+
3+
const LAST_LOGIN_METHOD_KEY = 'last_login_method'
4+
5+
export const recordLastLoginMethod = (provider: UserAuthProvider) => {
6+
if (typeof window === 'undefined') return
7+
try {
8+
window.localStorage.setItem(LAST_LOGIN_METHOD_KEY, provider)
9+
} catch {
10+
// storage unavailable
11+
}
12+
}
13+
14+
export const getLastLoginMethod = (): UserAuthProvider | null => {
15+
if (typeof window === 'undefined') return null
16+
try {
17+
const raw = window.localStorage.getItem(LAST_LOGIN_METHOD_KEY)
18+
return raw && Object.values(UserAuthProvider).includes(raw as UserAuthProvider) ? (raw as UserAuthProvider) : null
19+
} catch {
20+
return null
21+
}
22+
}

0 commit comments

Comments
 (0)