diff --git a/frontend/__snapshots__/scenes-other-invitesignup--cloud--dark.png b/frontend/__snapshots__/scenes-other-invitesignup--cloud--dark.png index b22d122eed..eeee553481 100644 Binary files a/frontend/__snapshots__/scenes-other-invitesignup--cloud--dark.png and b/frontend/__snapshots__/scenes-other-invitesignup--cloud--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-invitesignup--cloud--light.png b/frontend/__snapshots__/scenes-other-invitesignup--cloud--light.png index f3f04d5595..6fc528026c 100644 Binary files a/frontend/__snapshots__/scenes-other-invitesignup--cloud--light.png and b/frontend/__snapshots__/scenes-other-invitesignup--cloud--light.png differ diff --git a/frontend/__snapshots__/scenes-other-invitesignup--cloud-eu--dark.png b/frontend/__snapshots__/scenes-other-invitesignup--cloud-eu--dark.png index 4fdaae8909..88a18824ec 100644 Binary files a/frontend/__snapshots__/scenes-other-invitesignup--cloud-eu--dark.png and b/frontend/__snapshots__/scenes-other-invitesignup--cloud-eu--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-invitesignup--cloud-eu--light.png b/frontend/__snapshots__/scenes-other-invitesignup--cloud-eu--light.png index 122f188128..3149e3953e 100644 Binary files a/frontend/__snapshots__/scenes-other-invitesignup--cloud-eu--light.png and b/frontend/__snapshots__/scenes-other-invitesignup--cloud-eu--light.png differ diff --git a/frontend/__snapshots__/scenes-other-login--cloud--dark.png b/frontend/__snapshots__/scenes-other-login--cloud--dark.png index 500aba81be..236e4626eb 100644 Binary files a/frontend/__snapshots__/scenes-other-login--cloud--dark.png and b/frontend/__snapshots__/scenes-other-login--cloud--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-login--cloud--light.png b/frontend/__snapshots__/scenes-other-login--cloud--light.png index 797a844c1c..bf1674dce0 100644 Binary files a/frontend/__snapshots__/scenes-other-login--cloud--light.png and b/frontend/__snapshots__/scenes-other-login--cloud--light.png differ diff --git a/frontend/__snapshots__/scenes-other-login--cloud-eu--dark.png b/frontend/__snapshots__/scenes-other-login--cloud-eu--dark.png index 5e67bd6761..189eb92e12 100644 Binary files a/frontend/__snapshots__/scenes-other-login--cloud-eu--dark.png and b/frontend/__snapshots__/scenes-other-login--cloud-eu--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-login--cloud-eu--light.png b/frontend/__snapshots__/scenes-other-login--cloud-eu--light.png index 55200e8732..dd455a8ba7 100644 Binary files a/frontend/__snapshots__/scenes-other-login--cloud-eu--light.png and b/frontend/__snapshots__/scenes-other-login--cloud-eu--light.png differ diff --git a/frontend/__snapshots__/scenes-other-login--self-hosted-with-saml--dark.png b/frontend/__snapshots__/scenes-other-login--self-hosted-with-saml--dark.png index 8da09345cf..1f2cc64ee5 100644 Binary files a/frontend/__snapshots__/scenes-other-login--self-hosted-with-saml--dark.png and b/frontend/__snapshots__/scenes-other-login--self-hosted-with-saml--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-login--self-hosted-with-saml--light.png b/frontend/__snapshots__/scenes-other-login--self-hosted-with-saml--light.png index 553d39e09a..67f8a590b3 100644 Binary files a/frontend/__snapshots__/scenes-other-login--self-hosted-with-saml--light.png and b/frontend/__snapshots__/scenes-other-login--self-hosted-with-saml--light.png differ diff --git a/frontend/__snapshots__/scenes-other-signup--self-hosted-sso--dark.png b/frontend/__snapshots__/scenes-other-signup--self-hosted-sso--dark.png index 879f3809e2..9a49af07bb 100644 Binary files a/frontend/__snapshots__/scenes-other-signup--self-hosted-sso--dark.png and b/frontend/__snapshots__/scenes-other-signup--self-hosted-sso--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-signup--self-hosted-sso--light.png b/frontend/__snapshots__/scenes-other-signup--self-hosted-sso--light.png index f82a162751..f4fdf51995 100644 Binary files a/frontend/__snapshots__/scenes-other-signup--self-hosted-sso--light.png and b/frontend/__snapshots__/scenes-other-signup--self-hosted-sso--light.png differ diff --git a/frontend/src/lib/components/SocialLoginButton/SocialLoginButton.tsx b/frontend/src/lib/components/SocialLoginButton/SocialLoginButton.tsx index 964ac0b5a0..21eb80f2f4 100644 --- a/frontend/src/lib/components/SocialLoginButton/SocialLoginButton.tsx +++ b/frontend/src/lib/components/SocialLoginButton/SocialLoginButton.tsx @@ -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 + 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 ( - }> + } + active={isLastUsed} + className="py-1 relative" + > {SSO_PROVIDER_NAMES[provider]} + {isLastUsed && ( + + Last used + + )} ) @@ -68,6 +88,7 @@ interface SocialLoginButtonsProps { topDivider?: boolean bottomDivider?: boolean extraQueryParams?: Record + 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) => ( - + ))} {caption && captionLocation === 'bottom' &&

{caption}

} @@ -120,12 +147,13 @@ export function SSOEnforcedLoginButton({ email, extraQueryParams, actionText = 'Log in', + isLastUsed, ...props }: SSOEnforcedLoginButtonProps): JSX.Element { return ( {actionText} with {SSO_PROVIDER_NAMES[provider]} + {isLastUsed && ( + + Last used + + )} ) diff --git a/frontend/src/lib/lemon-ui/LemonInput/LemonInput.tsx b/frontend/src/lib/lemon-ui/LemonInput/LemonInput.tsx index 3b59b2a16e..563c0c2ef4 100644 --- a/frontend/src/lib/lemon-ui/LemonInput/LemonInput.tsx +++ b/frontend/src/lib/lemon-ui/LemonInput/LemonInput.tsx @@ -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(func inputRef, disabled, disabledReason, + badgeText, ...props }, ref @@ -185,6 +189,7 @@ export const LemonInput = React.forwardRef(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(func {...props} /> {suffix} + {badgeText && ( + + {badgeText} + + )} ) diff --git a/frontend/src/scenes/authentication/Login.tsx b/frontend/src/scenes/authentication/Login.tsx index 2190578655..fe0365e969 100644 --- a/frontend/src/scenes/authentication/Login.tsx +++ b/frontend/src/scenes/authentication/Login.tsx @@ -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 = { 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} />
@@ -212,12 +220,20 @@ export function Login(): JSX.Element { {/* Show enforced SSO button if required */} {precheckResponse.sso_enforcement && ( - + )} {/* Show optional SAML SSO button if available */} {precheckResponse.saml_available && !precheckResponse.sso_enforcement && ( - + )} )} @@ -230,7 +246,7 @@ export function Login(): JSX.Element {
)} {!isEmailVerificationSent && !precheckResponse.saml_available && !precheckResponse.sso_enforcement && ( - + )} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b98567c07a..22f084ad53 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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' diff --git a/posthog/api/test/test_preflight.py b/posthog/api/test/test_preflight.py index 2d81a10ed3..4c84990c8a 100644 --- a/posthog/api/test/test_preflight.py +++ b/posthog/api/test/test_preflight.py @@ -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 diff --git a/posthog/constants.py b/posthog/constants.py index 51b9a8b4e2..e1c5d78195 100644 --- a/posthog/constants.py +++ b/posthog/constants.py @@ -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"]} diff --git a/posthog/middleware.py b/posthog/middleware.py index fcc9e0f626..3a4ff1833f 100644 --- a/posthog/middleware.py +++ b/posthog/middleware.py @@ -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 diff --git a/posthog/test/test_middleware.py b/posthog/test/test_middleware.py index 3a7d6ec329..caf0ad0f0f 100644 --- a/posthog/test/test_middleware.py +++ b/posthog/test/test_middleware.py @@ -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