feat: last login method badge (#41065)
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||