feat: last login method badge (#41065)

This commit is contained in:
Alex Lider
2025-11-14 08:10:38 +01:00
committed by GitHub
parent e83672c529
commit 3298f806b1
20 changed files with 174 additions and 53 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -5,9 +5,10 @@ import { combineUrl, router } from 'kea-router'
import { SSO_PROVIDER_NAMES } from 'lib/constants'
import { LemonButton, LemonButtonWithoutSideActionProps } from 'lib/lemon-ui/LemonButton'
import { LemonDivider } from 'lib/lemon-ui/LemonDivider'
import { LemonTag } from 'lib/lemon-ui/LemonTag'
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
import { SSOProvider } from '~/types'
import { LoginMethod, SSOProvider } from '~/types'
import { SocialLoginIcon } from './SocialLoginIcon'
@@ -42,9 +43,14 @@ function SocialLoginLink({ provider, extraQueryParams, children }: SocialLoginLi
interface SocialLoginButtonProps {
provider: SSOProvider
extraQueryParams?: Record<string, string>
isLastUsed?: boolean
}
export function SocialLoginButton({ provider, extraQueryParams }: SocialLoginButtonProps): JSX.Element | null {
export function SocialLoginButton({
provider,
extraQueryParams,
isLastUsed,
}: SocialLoginButtonProps): JSX.Element | null {
const { preflight } = useValues(preflightLogic)
if (!preflight?.available_social_auth_providers[provider]) {
@@ -53,8 +59,22 @@ export function SocialLoginButton({ provider, extraQueryParams }: SocialLoginBut
return (
<SocialLoginLink provider={provider} extraQueryParams={extraQueryParams}>
<LemonButton size="medium" icon={<SocialLoginIcon provider={provider} />}>
<LemonButton
size="medium"
icon={<SocialLoginIcon provider={provider} />}
active={isLastUsed}
className="py-1 relative"
>
<span className="text-text-3000">{SSO_PROVIDER_NAMES[provider]}</span>
{isLastUsed && (
<LemonTag
type="muted"
size="small"
className="absolute -top-3 left-1/2 -translate-x-1/2 pointer-events-none"
>
Last used
</LemonTag>
)}
</LemonButton>
</SocialLoginLink>
)
@@ -68,6 +88,7 @@ interface SocialLoginButtonsProps {
topDivider?: boolean
bottomDivider?: boolean
extraQueryParams?: Record<string, string>
lastUsedProvider?: LoginMethod
}
export function SocialLoginButtons({
@@ -77,6 +98,7 @@ export function SocialLoginButtons({
className,
topDivider,
bottomDivider,
lastUsedProvider,
...props
}: SocialLoginButtonsProps): JSX.Element | null {
const { preflight, socialAuthAvailable } = useValues(preflightLogic)
@@ -98,7 +120,12 @@ export function SocialLoginButtons({
{Object.keys(preflight.available_social_auth_providers)
.sort((a, b) => order.indexOf(a) - order.indexOf(b))
.map((provider) => (
<SocialLoginButton key={provider} provider={provider as SSOProvider} {...props} />
<SocialLoginButton
key={provider}
provider={provider as SSOProvider}
isLastUsed={lastUsedProvider === provider}
{...props}
/>
))}
</div>
{caption && captionLocation === 'bottom' && <p className="text-secondary">{caption}</p>}
@@ -120,12 +147,13 @@ export function SSOEnforcedLoginButton({
email,
extraQueryParams,
actionText = 'Log in',
isLastUsed,
...props
}: SSOEnforcedLoginButtonProps): JSX.Element {
return (
<SocialLoginLink provider={provider} extraQueryParams={{ ...extraQueryParams, email }}>
<LemonButton
className="btn-bridge"
className="btn-bridge relative"
data-attr="sso-login"
htmlType="button"
type="secondary"
@@ -136,6 +164,11 @@ export function SSOEnforcedLoginButton({
{...props}
>
{actionText} with {SSO_PROVIDER_NAMES[provider]}
{isLastUsed && (
<LemonTag type="muted" size="medium" className="absolute -top-3 -right-2 pointer-events-none">
Last used
</LemonTag>
)}
</LemonButton>
</SocialLoginLink>
)

View File

@@ -8,6 +8,7 @@ import { IconEye, IconSearch, IconX } from '@posthog/icons'
import { Tooltip } from '@posthog/lemon-ui'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { LemonTag } from 'lib/lemon-ui/LemonTag'
import { IconEyeHidden } from 'lib/lemon-ui/icons'
import { RawInputAutosize } from './RawInputAutosize'
@@ -60,6 +61,8 @@ interface LemonInputPropsBase
'aria-label'?: string
/** Whether to stop propagation of events from the input */
stopPropagation?: boolean
/** Small label shown above the top-right corner, e.g. "last used" */
badgeText?: string
}
export interface LemonInputPropsText extends LemonInputPropsBase {
@@ -101,6 +104,7 @@ export const LemonInput = React.forwardRef<HTMLDivElement, LemonInputProps>(func
inputRef,
disabled,
disabledReason,
badgeText,
...props
},
ref
@@ -185,6 +189,7 @@ export const LemonInput = React.forwardRef<HTMLDivElement, LemonInputProps>(func
value && 'LemonInput--has-content',
!disabled && !disabledReason && focused && 'LemonInput--focused',
transparentBackground && 'LemonInput--transparent-background',
badgeText && 'relative',
className
)}
aria-disabled={disabled || !!disabledReason}
@@ -233,6 +238,11 @@ export const LemonInput = React.forwardRef<HTMLDivElement, LemonInputProps>(func
{...props}
/>
{suffix}
{badgeText && (
<LemonTag className="absolute -top-3 -right-2 pointer-events-none" size="small" type="muted">
{badgeText}
</LemonTag>
)}
</span>
</Tooltip>
)

View File

@@ -7,6 +7,7 @@ import { useEffect, useRef } from 'react'
import { LemonButton, LemonInput } from '@posthog/lemon-ui'
import { getCookie } from 'lib/api'
import { BridgePage } from 'lib/components/BridgePage/BridgePage'
import { SSOEnforcedLoginButton, SocialLoginButtons } from 'lib/components/SocialLoginButton/SocialLoginButton'
import { supportLogic } from 'lib/components/Support/supportLogic'
@@ -17,6 +18,8 @@ import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
import { SceneExport } from 'scenes/sceneTypes'
import { urls } from 'scenes/urls'
import { LoginMethod } from '~/types'
import { RedirectIfLoggedInOtherInstance } from './RedirectToLoggedInInstance'
import RegionSelect from './RegionSelect'
import { SupportModalButton } from './SupportModalButton'
@@ -55,6 +58,8 @@ export const ERROR_MESSAGES: Record<string, string | JSX.Element> = {
sso_enforced: "Please log in with your organization's required SSO method.",
}
const LAST_LOGIN_METHOD_COOKIE = 'ph_last_login_method'
export const scene: SceneExport = {
component: Login,
logic: loginLogic,
@@ -78,6 +83,8 @@ export function Login(): JSX.Element {
const isPasswordHidden = precheckResponse.status === 'pending' || precheckResponse.sso_enforcement
const isEmailVerificationSent = generalError?.code === 'email_verification_sent'
const lastLoginMethod = getCookie(LAST_LOGIN_METHOD_COOKIE) as LoginMethod
useEffect(() => {
if (!isPasswordHidden) {
passwordInputRef.current?.focus()
@@ -166,6 +173,7 @@ export function Login(): JSX.Element {
passwordInputRef.current?.focus()
}
}}
badgeText={lastLoginMethod === 'password' ? 'Last used' : undefined}
/>
</LemonField>
<div className={clsx('PasswordWrapper', isPasswordHidden && 'zero-height')}>
@@ -212,12 +220,20 @@ export function Login(): JSX.Element {
{/* Show enforced SSO button if required */}
{precheckResponse.sso_enforcement && (
<SSOEnforcedLoginButton provider={precheckResponse.sso_enforcement} email={login.email} />
<SSOEnforcedLoginButton
provider={precheckResponse.sso_enforcement}
email={login.email}
isLastUsed={lastLoginMethod === precheckResponse.sso_enforcement}
/>
)}
{/* Show optional SAML SSO button if available */}
{precheckResponse.saml_available && !precheckResponse.sso_enforcement && (
<SSOEnforcedLoginButton provider="saml" email={login.email} />
<SSOEnforcedLoginButton
provider="saml"
email={login.email}
isLastUsed={lastLoginMethod === 'saml'}
/>
)}
</Form>
)}
@@ -230,7 +246,7 @@ export function Login(): JSX.Element {
</div>
)}
{!isEmailVerificationSent && !precheckResponse.saml_available && !precheckResponse.sso_enforcement && (
<SocialLoginButtons caption="Or log in with" topDivider />
<SocialLoginButtons caption="Or log in with" topDivider lastUsedProvider={lastLoginMethod} />
)}
</div>
</BridgePage>

View File

@@ -284,11 +284,13 @@ export enum Region {
}
export type SSOProvider = 'google-oauth2' | 'github' | 'gitlab' | 'saml'
export type LoginMethod = SSOProvider | 'password' | null
export interface AuthBackends {
'google-oauth2'?: boolean
gitlab?: boolean
github?: boolean
saml?: boolean
}
export type ColumnChoice = string[] | 'DEFAULT'

View File

@@ -174,7 +174,7 @@ class TestPreflight(APIBaseTest, QueryMatchingTest):
def test_cloud_preflight_limited_db_queries(self):
with self.is_cloud(True):
# :IMPORTANT: This code is hit _every_ web request on cloud so avoid ever increasing db load.
with self.assertNumQueries(4): # session, user, team and slack instance setting.
with self.assertNumQueries(5): # session (2x), user, team and slack instance setting.
response = self.client.get("/_preflight/")
assert response.status_code == status.HTTP_200_OK

View File

@@ -347,13 +347,35 @@ DEFAULT_SURVEY_APPEARANCE = {
"surveyPopupDelaySeconds": None,
}
LOGIN_METHODS = [
{
"key": "password",
"display": "Email/password",
"backends": ["django.contrib.auth.backends.ModelBackend"],
},
{
"key": "google-oauth2",
"display": "Google OAuth",
"backends": ["google-oauth2", "ee.api.authentication.CustomGoogleOAuth2"],
},
{
"key": "github",
"display": "GitHub",
"backends": ["github"],
},
{
"key": "gitlab",
"display": "GitLab",
"backends": ["gitlab"],
},
{
"key": "saml",
"display": "SAML",
"backends": ["saml", "ee.api.authentication.MultitenantSAMLAuth"],
},
]
# Mapping of auth backend names to login method display names
AUTH_BACKEND_DISPLAY_NAMES = {
"django.contrib.auth.backends.ModelBackend": "Email/password",
"google-oauth2": "Google OAuth",
"github": "GitHub",
"gitlab": "GitLab",
"saml": "SAML",
"ee.api.authentication.CustomGoogleOAuth2": "Google OAuth",
"ee.api.authentication.MultitenantSAMLAuth": "SAML",
}
AUTH_BACKEND_DISPLAY_NAMES = {backend: m["display"] for m in LOGIN_METHODS for backend in m["backends"]}
AUTH_BACKEND_KEYS = {backend: m["key"] for m in LOGIN_METHODS for backend in m["backends"]}

View File

@@ -29,7 +29,8 @@ from posthog.api.decide import get_decide
from posthog.api.shared import UserBasicSerializer
from posthog.clickhouse.client.execute import clickhouse_query_counter
from posthog.clickhouse.query_tagging import QueryCounter, reset_query_tags, tag_queries
from posthog.cloud_utils import is_cloud
from posthog.cloud_utils import is_cloud, is_dev_mode
from posthog.constants import AUTH_BACKEND_KEYS
from posthog.exceptions import generate_exception_response
from posthog.geoip import get_geoip_properties
from posthog.models import Action, Cohort, Dashboard, FeatureFlag, Insight, Team, User
@@ -495,7 +496,14 @@ class PostHogTokenCookieMiddleware(SessionMiddleware):
def process_response(self, request, response):
response = super().process_response(request, response)
if not is_cloud():
if settings.TEST:
pass
elif is_dev_mode():
# for local development
default_cookie_options["domain"] = None
default_cookie_options["secure"] = False
elif not is_cloud():
# skip adding cookies for self-hosted instance
return response
# skip adding the cookie on API requests
@@ -507,39 +515,54 @@ class PostHogTokenCookieMiddleware(SessionMiddleware):
# clears the cookies that were previously set, except for ph_current_instance as that is used for the website login button
response.delete_cookie("ph_current_project_token", domain=default_cookie_options["domain"])
response.delete_cookie("ph_current_project_name", domain=default_cookie_options["domain"])
if request.user and request.user.is_authenticated and request.user.team:
response.set_cookie(
key="ph_current_project_token",
value=request.user.team.api_token,
max_age=365 * 24 * 60 * 60,
expires=default_cookie_options["expires"],
path=default_cookie_options["path"],
domain=default_cookie_options["domain"],
secure=default_cookie_options["secure"],
samesite=default_cookie_options["samesite"],
)
if request.user and request.user.is_authenticated:
if request.user.team:
response.set_cookie(
key="ph_current_project_token",
value=request.user.team.api_token,
max_age=default_cookie_options["max_age"],
expires=default_cookie_options["expires"],
path=default_cookie_options["path"],
domain=default_cookie_options["domain"],
secure=default_cookie_options["secure"],
samesite=default_cookie_options["samesite"],
)
response.set_cookie(
key="ph_current_project_name", # clarify which project is active (orgs can have multiple projects)
value=request.user.team.name.encode("utf-8").decode("latin-1"),
max_age=365 * 24 * 60 * 60,
expires=default_cookie_options["expires"],
path=default_cookie_options["path"],
domain=default_cookie_options["domain"],
secure=default_cookie_options["secure"],
samesite=default_cookie_options["samesite"],
)
response.set_cookie(
key="ph_current_project_name", # clarify which project is active (orgs can have multiple projects)
value=request.user.team.name.encode("utf-8").decode("latin-1"),
max_age=default_cookie_options["max_age"],
expires=default_cookie_options["expires"],
path=default_cookie_options["path"],
domain=default_cookie_options["domain"],
secure=default_cookie_options["secure"],
samesite=default_cookie_options["samesite"],
)
response.set_cookie(
key="ph_current_instance",
value=SITE_URL,
max_age=365 * 24 * 60 * 60,
expires=default_cookie_options["expires"],
path=default_cookie_options["path"],
domain=default_cookie_options["domain"],
secure=default_cookie_options["secure"],
samesite=default_cookie_options["samesite"],
)
response.set_cookie(
key="ph_current_instance",
value=SITE_URL,
max_age=default_cookie_options["max_age"],
expires=default_cookie_options["expires"],
path=default_cookie_options["path"],
domain=default_cookie_options["domain"],
secure=default_cookie_options["secure"],
samesite=default_cookie_options["samesite"],
)
auth_backend = request.session.get("_auth_user_backend")
login_method = AUTH_BACKEND_KEYS.get(auth_backend)
if login_method:
response.set_cookie(
key="ph_last_login_method",
value=login_method,
max_age=default_cookie_options["max_age"],
expires=default_cookie_options["expires"],
path=default_cookie_options["path"],
domain=default_cookie_options["domain"],
secure=default_cookie_options["secure"],
samesite=default_cookie_options["samesite"],
)
return response

View File

@@ -386,7 +386,7 @@ class TestPostHogTokenCookieMiddleware(APIBaseTest):
self.assertEqual(0, len(response.cookies))
def test_logged_in_client(self):
self.client.force_login(self.user)
self.client.force_login(self.user, backend="django.contrib.auth.backends.ModelBackend")
response = self.client.get("/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -423,8 +423,19 @@ class TestPostHogTokenCookieMiddleware(APIBaseTest):
self.assertEqual(ph_instance_cookie["secure"], True)
self.assertEqual(ph_instance_cookie["max-age"], 31536000)
ph_last_login_method_cookie = response.cookies["ph_last_login_method"]
self.assertEqual(ph_last_login_method_cookie.key, "ph_last_login_method")
self.assertEqual(ph_last_login_method_cookie.value, "password")
self.assertEqual(ph_last_login_method_cookie["path"], "/")
self.assertEqual(ph_last_login_method_cookie["samesite"], "Strict")
self.assertEqual(ph_last_login_method_cookie["httponly"], "")
self.assertEqual(ph_last_login_method_cookie["domain"], "posthog.com")
self.assertEqual(ph_last_login_method_cookie["comment"], "")
self.assertEqual(ph_last_login_method_cookie["secure"], True)
self.assertEqual(ph_last_login_method_cookie["max-age"], 31536000)
def test_logout(self):
self.client.force_login(self.user)
self.client.force_login(self.user, backend="django.contrib.auth.backends.ModelBackend")
response = self.client.get("/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -440,6 +451,10 @@ class TestPostHogTokenCookieMiddleware(APIBaseTest):
self.assertEqual(response.cookies["ph_current_instance"].value, SITE_URL)
self.assertEqual(response.cookies["ph_current_instance"]["max-age"], 31536000)
self.assertEqual(response.cookies["ph_last_login_method"].key, "ph_last_login_method")
self.assertEqual(response.cookies["ph_last_login_method"].value, "password")
self.assertEqual(response.cookies["ph_last_login_method"]["max-age"], 31536000)
response = self.client.get("/logout")
# Check that the local cookies will be removed by having 'expires' in the past