diff --git a/frontend/public/GoshaSans-Bold.woff b/frontend/public/GoshaSans-Bold.woff new file mode 100644 index 0000000000000..0505f3b71ab9f Binary files /dev/null and b/frontend/public/GoshaSans-Bold.woff differ diff --git a/frontend/public/GoshaSans-Bold.woff2 b/frontend/public/GoshaSans-Bold.woff2 new file mode 100644 index 0000000000000..9c7f0f23ca698 Binary files /dev/null and b/frontend/public/GoshaSans-Bold.woff2 differ diff --git a/frontend/src/global.scss b/frontend/src/global.scss index 50b9bee690b2c..e2336a4b77713 100644 --- a/frontend/src/global.scss +++ b/frontend/src/global.scss @@ -6,6 +6,11 @@ style files without adding already imported styles. */ @import 'node_modules/react-toastify/dist/ReactToastify'; @import './vars'; +@font-face { + font-family: 'GoshaSans-Bold'; + src: url('../public/GoshaSans-Bold.woff2') format('woff2'), url('../public/GoshaSans-Bold.woff') format('woff'); +} + :root { --primary: #{$primary}; --primary-alt: #{$primary_alt}; @@ -56,6 +61,7 @@ style files without adding already imported styles. */ margin-top: 0.5em; font-weight: 700; color: $text_default; + font-family: $gosha_sans; } .page-caption { @@ -294,7 +300,7 @@ code.code { .caption { color: $danger; } - .ant-input-password, + input[type='password'], input[type='text'] { border-color: $danger !important; } diff --git a/frontend/src/layout/navigation/TopNavigation.tsx b/frontend/src/layout/navigation/TopNavigation.tsx index be0747f220882..9bc6b597257c0 100644 --- a/frontend/src/layout/navigation/TopNavigation.tsx +++ b/frontend/src/layout/navigation/TopNavigation.tsx @@ -28,6 +28,19 @@ import { commandPaletteLogic } from 'lib/components/CommandPalette/commandPalett import { Link } from 'lib/components/Link' import { LinkButton } from 'lib/components/LinkButton' import { BulkInviteModal } from 'scenes/organization/TeamMembers/BulkInviteModal' +import { UserType } from '~/types' + +export function WhoAmI({ user }: { user: UserType }): JSX.Element { + return ( +
+
{user.name[0]?.toUpperCase()}
+
+ {user.name} + {user.organization?.name} +
+
+ ) +} export const TopNavigation = hot(_TopNavigation) export function _TopNavigation(): JSX.Element { @@ -235,17 +248,15 @@ export function _TopNavigation(): JSX.Element { -
- -
-
{user?.name[0]?.toUpperCase()}
-
- {user?.name} - {user?.organization?.name} + {user && ( +
+ +
+
-
- -
+ +
+ )}
setInviteMembersModalOpen(false)} /> diff --git a/frontend/src/lib/components/SocialLoginButton/github.svg b/frontend/src/lib/components/SocialLoginButton/github.svg new file mode 100644 index 0000000000000..f1e1d0b78d41f --- /dev/null +++ b/frontend/src/lib/components/SocialLoginButton/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/lib/components/SocialLoginButton/gitlab.svg b/frontend/src/lib/components/SocialLoginButton/gitlab.svg new file mode 100644 index 0000000000000..bd74aa6659245 --- /dev/null +++ b/frontend/src/lib/components/SocialLoginButton/gitlab.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/lib/components/SocialLoginButton/google.svg b/frontend/src/lib/components/SocialLoginButton/google.svg new file mode 100644 index 0000000000000..01ce002d4754a --- /dev/null +++ b/frontend/src/lib/components/SocialLoginButton/google.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/lib/components/SocialLoginButton/index.scss b/frontend/src/lib/components/SocialLoginButton/index.scss new file mode 100644 index 0000000000000..849f4671cff20 --- /dev/null +++ b/frontend/src/lib/components/SocialLoginButton/index.scss @@ -0,0 +1,84 @@ +@import '~/vars'; + +$google: #4285f4; +$github: #171516; +$gitlab: #e65328; + +.social-logins { + margin-bottom: $default_spacing; + margin-top: $default_spacing; + text-align: center; + + button, + a { + margin-bottom: $default_spacing / 2; + } + + .caption { + margin-bottom: $default_spacing / 2; + color: $text_muted; + } +} + +.btn-social-login { + color: white; + border: 0; + font-weight: bold; + position: relative; + padding-left: 60px; + padding-right: 32px; // 60px - 28px (icon width) + + &:hover { + color: white; + } + + &.google-oauth2 { + background-color: $google; + + &:hover { + background-color: lighten($google, 12%); + } + + .btn-social-icon .img { + background-image: url('./google.svg'); + } + } + + &.github { + background-color: $github; + &:hover { + background-color: lighten($github, 12%); + } + + .btn-social-icon .img { + background-image: url('./github.svg'); + } + } + + &.gitlab { + background-color: $gitlab; + &:hover { + background-color: lighten($gitlab, 12%); + } + + .btn-social-icon .img { + background-image: url('./gitlab.svg'); + } + } + + .btn-social-icon { + background-color: white; + border-radius: $radius; + height: 28px; + width: 28px; + position: absolute; + left: 2px; + top: 2px; + .img { + background-position: center center; + background-repeat: no-repeat; + height: 100%; + width: 100%; + } + } +} diff --git a/frontend/src/lib/components/SocialLoginButton/index.tsx b/frontend/src/lib/components/SocialLoginButton/index.tsx new file mode 100644 index 0000000000000..63f3a0fec0249 --- /dev/null +++ b/frontend/src/lib/components/SocialLoginButton/index.tsx @@ -0,0 +1,70 @@ +import { Button } from 'antd' +import { useValues } from 'kea' +import React from 'react' +import { preflightLogic } from 'scenes/PreflightCheck/logic' +import './index.scss' + +enum SocialAuthProviders { + Google = 'google-oauth2', + GitHub = 'github', + GitLab = 'gitlab', +} + +const ProviderNames: Record = { + [SocialAuthProviders.Google]: 'Google', + [SocialAuthProviders.GitHub]: 'GitHub', + [SocialAuthProviders.GitLab]: 'GitLab', +} + +interface SharedProps { + queryString?: string +} + +interface SocialLoginButtonProps extends SharedProps { + provider: SocialAuthProviders +} + +interface SocialLoginButtonsProps extends SharedProps { + title?: string + caption?: string +} + +export function SocialLoginButton({ provider, queryString }: SocialLoginButtonProps): JSX.Element | null { + const { preflight } = useValues(preflightLogic) + + if (!preflight?.available_social_auth_providers[provider]) { + return null + } + + return ( + + ) +} + +export function SocialLoginButtons({ title, caption, ...props }: SocialLoginButtonsProps): JSX.Element | null { + const { preflight } = useValues(preflightLogic) + + if ( + !preflight?.available_social_auth_providers || + !Object.values(preflight.available_social_auth_providers).filter((val) => !!val).length + ) { + return null + } + + return ( +
+ {title &&

{title}

} + {caption &&
{caption}
} + {Object.values(SocialAuthProviders).map((provider) => ( +
+ +
+ ))} +
+ ) +} diff --git a/frontend/src/lib/components/StarryBackground/index.scss b/frontend/src/lib/components/StarryBackground/index.scss new file mode 100644 index 0000000000000..e9930e3a5b884 --- /dev/null +++ b/frontend/src/lib/components/StarryBackground/index.scss @@ -0,0 +1,22 @@ +@import '~/vars'; + +.starry-background { + height: 100%; + background: $night_sky; + + .stars { + z-index: $z_city_background_image; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: url('./stars.svg') repeat center center; + } + + .children { + height: 100%; + position: relative; + z-index: $z_city_background_content; + } +} diff --git a/frontend/src/lib/components/StarryBackground/index.tsx b/frontend/src/lib/components/StarryBackground/index.tsx new file mode 100644 index 0000000000000..a5c9259407738 --- /dev/null +++ b/frontend/src/lib/components/StarryBackground/index.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import './index.scss' + +export function StarryBackground({ + children, + style, +}: { + children: JSX.Element + style?: React.CSSProperties +}): JSX.Element { + return ( +
+
+
{children}
+
+ ) +} diff --git a/frontend/src/lib/components/StarryBackground/stars.svg b/frontend/src/lib/components/StarryBackground/stars.svg new file mode 100644 index 0000000000000..5bb11129b5db1 --- /dev/null +++ b/frontend/src/lib/components/StarryBackground/stars.svg @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/scenes/App.tsx b/frontend/src/scenes/App.tsx index 9b870022a7e5b..dae600f7ae2c1 100644 --- a/frontend/src/scenes/App.tsx +++ b/frontend/src/scenes/App.tsx @@ -35,7 +35,7 @@ function _App(): JSX.Element | null { const { featureFlags } = useValues(featureFlagLogic) useEffect(() => { - if (scene === Scene.Signup && !preflight.cloud && preflight.initiated) { + if (scene === Scene.Signup && preflight && !preflight.cloud && preflight.initiated) { // If user is on an initiated self-hosted instance, redirect away from signup replace('/login') return @@ -44,8 +44,8 @@ function _App(): JSX.Element | null { useEffect(() => { if (user) { - // If user is already logged in, redirect away from unauthenticated routes like signup - if (sceneConfig.unauthenticated) { + // If user is already logged in, redirect away from unauthenticated-only routes like signup + if (sceneConfig.onlyUnauthenticated) { replace('/') return } @@ -86,7 +86,7 @@ function _App(): JSX.Element | null { ) if (!user) { - return sceneConfig.unauthenticated ? ( + return sceneConfig.onlyUnauthenticated || sceneConfig.allowUnauthenticated ? ( {essentialElements} diff --git a/frontend/src/scenes/PreflightCheck/index.js b/frontend/src/scenes/PreflightCheck/index.js index b97fff81b5c0c..7a914178fbf1c 100644 --- a/frontend/src/scenes/PreflightCheck/index.js +++ b/frontend/src/scenes/PreflightCheck/index.js @@ -62,10 +62,11 @@ function PreflightItem({ name, status, caption, failedState }) { } function PreflightCheck() { - const [state, setState] = useState({}) + const [state, setState] = useState({ mode: null }) const { preflight, preflightLoading } = useValues(preflightLogic) const { resetPreflight } = useActions(preflightLogic) const isReady = + preflight && preflight.django && preflight.db && preflight.redis && @@ -76,27 +77,27 @@ function PreflightCheck() { { id: 'database', name: 'Database (Postgres)', - status: preflight.db, + status: preflight?.db, }, { id: 'backend', name: 'Backend server (Django)', - status: preflight.django, + status: preflight?.django, }, { id: 'redis', name: 'Cache & queue (Redis)', - status: preflight.redis, + status: preflight?.redis, }, { id: 'celery', name: 'Background jobs (Celery)', - status: preflight.celery, + status: preflight?.celery, }, { id: 'plugins', name: 'Plugin server (Node)', - status: preflight.plugins, + status: preflight?.plugins, caption: state.mode === 'Experimentation' ? 'Required in production environments' : '', failedState: state.mode === 'Experimentation' ? 'warning' : 'error', }, @@ -191,7 +192,7 @@ function PreflightCheck() { data-attr="preflight-refresh" icon={} onClick={() => window.location.reload()} - disabled={preflightLoading || Object.keys(preflight).length === 0} + disabled={preflightLoading || !preflight} > Refresh diff --git a/frontend/src/scenes/PreflightCheck/logic.ts b/frontend/src/scenes/PreflightCheck/logic.ts index 57ff25a5f8189..45f1e1df8cb1e 100644 --- a/frontend/src/scenes/PreflightCheck/logic.ts +++ b/frontend/src/scenes/PreflightCheck/logic.ts @@ -1,42 +1,38 @@ import { kea } from 'kea' import api from 'lib/api' +import { PreflightStatus } from '~/types' import { preflightLogicType } from './logicType' -interface PreflightStatus { - django?: boolean - redis?: boolean - db?: boolean - initiated?: boolean - cloud?: boolean -} - -export const preflightLogic = kea({ +export const preflightLogic = kea>({ loaders: { preflight: [ - {} as PreflightStatus, + null as PreflightStatus | null, { - loadPreflight: async () => (await api.get('_preflight/')) as PreflightStatus, + loadPreflight: async () => await api.get('_preflight/'), }, ], }, - actions: { resetPreflight: true, }, - reducers: { preflight: { - resetPreflight: () => ({} as PreflightStatus), + resetPreflight: () => null, }, }, - + selectors: { + socialAuthAvailable: [ + (s) => [s.preflight], + (preflight: PreflightStatus | null) => + preflight && Object.values(preflight.available_social_auth_providers).filter((i) => i).length, + ], + }, listeners: ({ actions }) => ({ resetPreflight: async (_, breakpoint) => { await breakpoint(1000) actions.loadPreflight() }, }), - events: ({ actions }) => ({ afterMount: () => { actions.loadPreflight() diff --git a/frontend/src/scenes/onboarding/InviteSignup.scss b/frontend/src/scenes/onboarding/InviteSignup.scss new file mode 100644 index 0000000000000..82b7f0d9f8bd8 --- /dev/null +++ b/frontend/src/scenes/onboarding/InviteSignup.scss @@ -0,0 +1,94 @@ +@import '~/vars'; + +.invite-signup { + height: 100vh; + + &.authenticated { + height: calc(100vh - #{$top_nav_height}); + } + + .inner { + text-align: center; + max-width: 400px; + padding: 0 $default_spacing; + color: $text_light; + h1 { + color: $text_light; + } + } + + .error-view-container { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + .inner { + .error-message { + margin-top: $default_spacing * 2; + } + + .actions { + margin-top: $default_spacing * 2; + } + } + } + + .authenticated-invite { + background: $dusk_sky; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + + .inner { + > div { + margin-bottom: $default_spacing; + + &:last-of-type { + margin-bottom: 0; + } + } + } + + .whoami-mock { + display: flex; + align-items: center; + padding-left: $default_spacing; + height: 100%; + width: 100%; + + .whoami-inner-container { + display: inline-block; + .whoami { + cursor: default !important; + border: 1px $text_light dashed; + border-radius: $radius; + background-color: rgba(white, 0.3); + padding-left: 4px; + padding-right: 4px; + } + .whoami, + span { + color: $text_light !important; + } + } + } + } + + a.plain-link { + color: $text_light; + font-weight: bold; + &:after { + content: ' | '; + &:hover { + color: $text_light; + } + } + &:last-of-type:after { + content: ''; + } + &:hover { + color: darken($text_light, 20%); + } + } +} diff --git a/frontend/src/scenes/onboarding/InviteSignup.tsx b/frontend/src/scenes/onboarding/InviteSignup.tsx new file mode 100644 index 0000000000000..087cd7d893c12 --- /dev/null +++ b/frontend/src/scenes/onboarding/InviteSignup.tsx @@ -0,0 +1,196 @@ +import { useActions, useValues } from 'kea' +import React from 'react' +import { hot } from 'react-hot-loader/root' +import { inviteSignupLogic, ErrorCodes } from './inviteSignupLogic' +import { SceneLoading } from 'lib/utils' +import './InviteSignup.scss' +import { StarryBackground } from 'lib/components/StarryBackground' +import { userLogic } from 'scenes/userLogic' +import { Button, Row, Col } from 'antd' +import { ArrowLeftOutlined, ArrowRightOutlined } from '@ant-design/icons' +import { router } from 'kea-router' +import { PrevalidatedInvite } from '~/types' +import { Link } from 'lib/components/Link' +import { WhoAmI } from '~/layout/navigation/TopNavigation' +import { LoginSignup } from './LoginSignup' + +const UTM_TAGS = 'utm_medium=in-product&utm_campaign=invite-signup' + +interface ErrorMessage { + title: string + detail: JSX.Element | string + actions: JSX.Element +} + +function HelperLinks(): JSX.Element { + return ( + <> + + App Home + + + PostHog Website + + + Contact Us + + + ) +} + +function BackToPostHog(): JSX.Element { + const { push } = useActions(router) + return ( + + ) +} + +function ErrorView(): JSX.Element | null { + const { error } = useValues(inviteSignupLogic) + const { user } = useValues(userLogic) + + const ErrorMessages: Record = { + [ErrorCodes.InvalidInvite]: { + title: 'Oops! This invite link is invalid or has expired', + detail: ( + <> + {error?.detail} If you believe this is a mistake, please contact whoever created this invite and{' '} + ask them for a new invite. + + ), + actions: user ? : , + }, + [ErrorCodes.InvalidRecipient]: { + title: 'Oops! You cannot use this invite link', + detail: ( + <> +
{error?.detail}
+
+ {user ? ( + + You can either log out and create a new account under the new email address or ask the + organization admin to send a{' '} + new invite to the email address on your account, {user?.email}. + + ) : ( +
+ You need to log in with the email address above, or create your own password. +
+ +
+
+ )} +
+ + ), + actions: user ? : , + }, + [ErrorCodes.Unknown]: { + title: 'Oops! We could not validate this invite link', + detail: `${error?.detail} There was an issue with your invite link, please try again in a few seconds. If the problem persists, contact us.`, + actions: user ? : , + }, + } + + if (!error) { + return null + } + + return ( + +
+
+

{ErrorMessages[error.code].title}

+
{ErrorMessages[error.code].detail}
+
{ErrorMessages[error.code].actions}
+
+
+
+ ) +} + +function AuthenticatedAcceptInvite({ invite }: { invite: PrevalidatedInvite }): JSX.Element { + const { user } = useValues(userLogic) + const { acceptInvite } = useActions(inviteSignupLogic) + const { acceptedInviteLoading, acceptedInvite } = useValues(inviteSignupLogic) + + return ( +
+
+
+

You have been invited to join {invite.organization_name}

+
+
+ You will accept the invite under your existing PostHog account ({user?.email}) +
+ + + You can change organizations at any time by clicking on the dropdown at the top right corner of + the navigation bar. + + + {user && ( +
+
+ +
+
+ )} + +
+
+ {!acceptedInvite ? ( + <> + +
+ + Go back to PostHog + +
+ + ) : ( + + )} +
+
+
+ ) +} + +export const InviteSignup = hot(_InviteSignup) +function _InviteSignup(): JSX.Element { + const { invite, inviteLoading } = useValues(inviteSignupLogic) + const { user } = useValues(userLogic) + + if (inviteLoading) { + return + } + + return ( +
+ + {invite && (user ? : )} +
+ ) +} diff --git a/frontend/src/scenes/onboarding/LoginSignup.scss b/frontend/src/scenes/onboarding/LoginSignup.scss new file mode 100644 index 0000000000000..19db865f19012 --- /dev/null +++ b/frontend/src/scenes/onboarding/LoginSignup.scss @@ -0,0 +1,136 @@ +@import '~/vars'; + +.login-signup { + height: 100vh; + position: relative; + + .ant-row { + height: 100%; + } + + .image-showcase-container { + height: 100%; + position: relative; + + .image-showcase { + position: fixed; + top: 0; + bottom: 0; + left: 0; + width: 100%; + background: $sky; + + .the-mountains { + z-index: $z_city_background_image; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 180px; + background: url('./mountains.svg'); + background-size: cover; + } + } + + .main-logo { + padding-top: $default_spacing; + padding-left: $default_spacing * 2; + img { + height: 60px; + } + } + + .company { + font-family: $gosha_sans; + font-size: 2.7rem; + margin-bottom: 0; + } + + .showcase-content { + z-index: $z_city_background_content; + color: $text_light; + height: calc(100% - 100px); + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 0 $default_spacing * 2; + position: relative; + + h1, + h2, + h3 { + b { + color: white; + } + } + + .page-title { + color: rgba(255, 255, 255, 0.6); + } + + h3 { + color: $text_light; + } + + .mobile-continue { + display: none; + } + } + } + + .rhs-content { + display: flex; + justify-content: center; + align-items: center; + padding-top: $default_spacing * 2; + + .rhs-inner { + width: 100%; + max-width: 382px; // 350px + 32px padding + padding: 0 $default_spacing; + } + + .password-login { + margin-top: $default_spacing * 2; + } + } + + @media screen and (max-width: $md) { + overflow: auto; + scroll-behavior: smooth; + + .image-showcase { + position: relative !important; + height: 100%; + } + + .showcase-content { + .page-title { + margin-top: 0; + } + + .mobile-continue { + display: block !important; + padding-top: $default_spacing; + } + } + + .rhs-content { + height: initial; + overflow: initial; + + .top-helper { + text-align: left; + margin-right: 0; + padding: 0 $default_spacing; + color: $text_muted; + b { + font-weight: normal; + } + } + } + } +} diff --git a/frontend/src/scenes/onboarding/LoginSignup.tsx b/frontend/src/scenes/onboarding/LoginSignup.tsx new file mode 100644 index 0000000000000..ddae7b636b4d7 --- /dev/null +++ b/frontend/src/scenes/onboarding/LoginSignup.tsx @@ -0,0 +1,193 @@ +import { Button, Col, Input, Row } from 'antd' +import React, { lazy, Suspense, useRef, useState } from 'react' +import './LoginSignup.scss' +import smLogo from 'public/icon-white.svg' +import { SocialLoginButtons } from 'lib/components/SocialLoginButton' +import { PrevalidatedInvite } from '~/types' +import { Link } from 'lib/components/Link' +import { ArrowDownOutlined } from '@ant-design/icons' +import { useActions, useValues } from 'kea' +import { inviteSignupLogic } from './inviteSignupLogic' +import Checkbox from 'antd/lib/checkbox/Checkbox' +import { preflightLogic } from 'scenes/PreflightCheck/logic' + +const PasswordStrength = lazy(() => import('../../lib/components/PasswordStrength')) + +interface LoginSignupProps { + invite?: PrevalidatedInvite | null +} + +export function LoginSignup({ invite }: LoginSignupProps): JSX.Element { + /* + UI component for the login & signup pages. + Currently used for: InviteSignup. + */ + const [formValues, setFormValues] = useState({ + firstName: invite?.first_name || '', + password: '', + emailOptIn: true, + }) + const [formState, setFormState] = useState({ submitted: false, passwordInvalid: false }) + const mainContainerRef = useRef(null) + const rhsContainerRef = useRef(null) + const passwordInputRef = useRef(null) + const { acceptInvite } = useActions(inviteSignupLogic) + const { acceptedInviteLoading } = useValues(inviteSignupLogic) + const { socialAuthAvailable } = useValues(preflightLogic) + + const handleScroll = (): void => { + const yPos = rhsContainerRef.current ? rhsContainerRef.current.getBoundingClientRect().top : null + if (yPos) { + mainContainerRef.current?.scrollTo(0, yPos) + } + } + + const handlePasswordChanged = (e: React.ChangeEvent): void => { + const { value } = e.target + setFormValues({ ...formValues, password: value }) + if (value.length >= 8) { + setFormState({ ...formState, passwordInvalid: false }) + } else { + setFormState({ ...formState, passwordInvalid: true }) + } + } + + const handleFormSubmit = (e: React.FormEvent): void => { + e.preventDefault() + if (formState.passwordInvalid) { + setFormState({ ...formState, submitted: true }) + if (passwordInputRef.current) { + passwordInputRef.current.focus() + } + return + } + + const payload = { + first_name: formValues.firstName, + password: formValues.password, + email_opt_in: formValues.emailOptIn, + } + acceptInvite(payload) + } + + return ( +
+ + +
+
+
+ +
+
+

+ Hello{invite?.first_name ? ` ${invite.first_name}` : ''}! You've been invited to join +

+
{invite?.organization_name || 'us'}
+

on PostHog

+
+ +
+
+
+ + +
+ +
+

+ {socialAuthAvailable ? 'Or create your own passowrd' : 'Create your PostHog account'} +

+
+
+ + +
+
+ + = 768} // do not autofocus on small-width screens + value={formValues.password} + onChange={handlePasswordChanged} + id="password" + ref={passwordInputRef} + /> + Your password must have at least 8 characters. + }> + + +
+
+ + setFormValues({ ...formValues, firstName: e.target.value })} + /> + {invite?.first_name && ( + + Your name was provided in the invite, feel free to change it. + + )} +
+
+ setFormValues({ ...formValues, emailOptIn: e.target.checked })} + disabled={acceptedInviteLoading} + style={{ fontSize: 12, color: 'var(--text-muted)' }} + > + Send me product and security updates + +
+ +
+
+ By clicking continue you agree to our{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + + . +
+
+ Already have an account? Log in +
+
+
+ + +
+ ) +} diff --git a/frontend/src/scenes/onboarding/index.js b/frontend/src/scenes/onboarding/Signup.js similarity index 99% rename from frontend/src/scenes/onboarding/index.js rename to frontend/src/scenes/onboarding/Signup.js index 0485f5345448f..84b4ba3a03bf6 100644 --- a/frontend/src/scenes/onboarding/index.js +++ b/frontend/src/scenes/onboarding/Signup.js @@ -1,3 +1,6 @@ +{ + /* DEPRECATED in favor of LoginSignup.tsx (see #3339) */ +} import React, { useState, useRef, lazy, Suspense } from 'react' import { useActions, useValues } from 'kea' import { signupLogic } from './logic' diff --git a/frontend/src/scenes/onboarding/inviteSignupLogic.ts b/frontend/src/scenes/onboarding/inviteSignupLogic.ts new file mode 100644 index 0000000000000..681e22f3cfc39 --- /dev/null +++ b/frontend/src/scenes/onboarding/inviteSignupLogic.ts @@ -0,0 +1,100 @@ +import { kea } from 'kea' +import api from 'lib/api' +import { toast } from 'react-toastify' +import { PrevalidatedInvite } from '~/types' +import { inviteSignupLogicType } from './inviteSignupLogicType' + +export enum ErrorCodes { + InvalidInvite = 'invalid_invite', + InvalidRecipient = 'invalid_recipient', + Unknown = 'unknown', +} + +interface ErrorInterface { + code: ErrorCodes + detail?: string +} + +interface AcceptInvitePayloadInterface { + first_name?: string + password: string + email_opt_in: boolean +} + +export const inviteSignupLogic = kea< + inviteSignupLogicType +>({ + actions: { + setError: (payload: ErrorInterface) => ({ payload }), + }, + reducers: { + error: [ + null as ErrorInterface | null, + { + setError: (_, { payload }) => payload, + }, + ], + }, + loaders: ({ actions, values }) => ({ + invite: [ + null as PrevalidatedInvite | null, + { + prevalidateInvite: async (id: string, breakpoint) => { + breakpoint() + + try { + return await api.get(`api/signup/${id}/`) + } catch (e) { + if (e.status === 400) { + if (e.code === 'invalid_recipient') { + actions.setError({ code: ErrorCodes.InvalidRecipient, detail: e.detail }) + } else { + actions.setError({ code: ErrorCodes.InvalidInvite, detail: e.detail }) + } + } else { + actions.setError({ code: ErrorCodes.Unknown }) + } + return null + } + }, + }, + ], + acceptedInvite: [ + null, + { + acceptInvite: async (payload: AcceptInvitePayloadInterface | null, breakpoint) => { + breakpoint() + + if (!values.invite) { + return null + } + + return await api.create(`api/signup/${values.invite.id}/`, payload) + }, + }, + ], + }), + listeners: ({ values }) => ({ + acceptInviteSuccess: async (_, breakpoint) => { + toast.success(`You have joined ${values.invite?.organization_name}! Taking you to PostHog now...`) + await breakpoint(2000) // timeout for the user to read the toast + window.location.href = '/' // hard refresh because the current_organization changed + }, + }), + urlToAction: ({ actions }) => ({ + '/signup/*': ( + { _: id }: { _: string }, + { error_code, error_detail }: { error_code?: string; error_detail?: string } + ) => { + if (error_code) { + if ((Object.values(ErrorCodes) as string[]).includes(error_code)) { + actions.setError({ code: error_code as ErrorCodes, detail: error_detail }) + } else { + actions.setError({ code: ErrorCodes.Unknown, detail: error_detail }) + } + } else { + actions.prevalidateInvite(id) + } + }, + }), +}) diff --git a/frontend/src/scenes/onboarding/mountains.svg b/frontend/src/scenes/onboarding/mountains.svg new file mode 100644 index 0000000000000..56d58044d311e --- /dev/null +++ b/frontend/src/scenes/onboarding/mountains.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/scenes/sceneLogic.ts b/frontend/src/scenes/sceneLogic.ts index bf1873d948894..bb71b6b701a09 100644 --- a/frontend/src/scenes/sceneLogic.ts +++ b/frontend/src/scenes/sceneLogic.ts @@ -33,6 +33,7 @@ export enum Scene { // Onboarding / setup routes PreflightCheck = 'preflightCheck', Signup = 'signup', + InviteSignup = 'inviteSignup', Personalization = 'personalization', Ingestion = 'ingestion', OnboardingSetup = 'onboardingSetup', @@ -71,7 +72,8 @@ export const scenes: Record any> = { [Scene.MySettings]: () => import(/* webpackChunkName: 'mySettings' */ './me/Settings'), [Scene.Annotations]: () => import(/* webpackChunkName: 'annotations' */ './annotations'), [Scene.PreflightCheck]: () => import(/* webpackChunkName: 'preflightCheck' */ './PreflightCheck'), - [Scene.Signup]: () => import(/* webpackChunkName: 'signup' */ './onboarding'), + [Scene.Signup]: () => import(/* webpackChunkName: 'signup' */ './onboarding/Signup'), + [Scene.InviteSignup]: () => import(/* webpackChunkName: 'inviteSignup' */ './onboarding/InviteSignup'), [Scene.Ingestion]: () => import(/* webpackChunkName: 'ingestion' */ './ingestion/IngestionWizard'), [Scene.Billing]: () => import(/* webpackChunkName: 'billing' */ './billing/Billing'), [Scene.Plugins]: () => import(/* webpackChunkName: 'plugins' */ './plugins/Plugins'), @@ -80,11 +82,12 @@ export const scenes: Record any> = { } interface SceneConfig { - unauthenticated?: boolean // If route is to be accessed when logged out (N.B. add to posthog/urls.py too) + onlyUnauthenticated?: boolean // Route should only be accessed when logged out (N.B. should be added to posthog/urls.py too) + allowUnauthenticated?: boolean // Route **can** be accessed when logged out (i.e. can be accessed when logged in too) dark?: boolean // Background is $bg_mid plain?: boolean // Only keeps the main content and the top navigation bar hideTopNav?: boolean // Hides the top navigation bar (regardless of whether `plain` is `true` or not) - hideDemoWarnings?: boolean // Hides demo project (DemoWarning.tsx) or project has no events (App.tsx) warnings + hideDemoWarnings?: boolean // Hides demo project (DemoWarning.tsx) } export const sceneConfigurations: Partial> = { @@ -105,10 +108,14 @@ export const sceneConfigurations: Partial> = { }, // Onboarding / setup routes [Scene.PreflightCheck]: { - unauthenticated: true, + onlyUnauthenticated: true, }, [Scene.Signup]: { - unauthenticated: true, + onlyUnauthenticated: true, + }, + [Scene.InviteSignup]: { + allowUnauthenticated: true, + plain: true, }, [Scene.Personalization]: { plain: true, @@ -160,6 +167,7 @@ export const routes: Record = { // Onboarding / setup routes '/preflight': Scene.PreflightCheck, '/signup': Scene.Signup, + '/signup/:id': Scene.InviteSignup, '/personalization': Scene.Personalization, '/ingestion': Scene.Ingestion, '/ingestion/*': Scene.Ingestion, diff --git a/frontend/src/scenes/userLogic.tsx b/frontend/src/scenes/userLogic.tsx index a42198ee73261..80d104b77bef2 100644 --- a/frontend/src/scenes/userLogic.tsx +++ b/frontend/src/scenes/userLogic.tsx @@ -46,7 +46,9 @@ export const userLogic = kea ({ - afterMount: () => actions.loadUser(true), + afterMount: () => { + actions.loadUser(true) + }, }), selectors: ({ selectors }) => ({ diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 1e98fd9fbc577..81b0d79a3e008 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -505,3 +505,27 @@ interface DisabledSetupState { } export type SetupState = EnabledSetupState | DisabledSetupState + +export interface PrevalidatedInvite { + id: string + target_email: string + first_name: string + organization_name: string +} + +interface AuthBackends { + 'google-oauth2'?: boolean + gitlab?: boolean + github?: boolean +} + +export interface PreflightStatus { + django: boolean + plugins: boolean + redis: boolean + db: boolean + initiated: boolean + cloud: boolean + celery: boolean + available_social_auth_providers: AuthBackends +} diff --git a/frontend/src/vars.scss b/frontend/src/vars.scss index 1877c562d0175..9bfd89f42d55a 100644 --- a/frontend/src/vars.scss +++ b/frontend/src/vars.scss @@ -53,6 +53,14 @@ $purple_700: #7c4286; $purple_500: #c278cf; $purple_300: #dcb1e3; +// Gradients +$sky: linear-gradient(180deg, #373088 0%, #d05783 60.94%, #ffb269 100%); +$dusk_sky: linear-gradient(180deg, #20305a 0%, #373088 33.85%, #d05783 100%); +$night_sky: linear-gradient(180deg, #200c39 0%, #373088 75%, #4a3587 100%); + +// Fonts +$gosha_sans: 'GoshaSans-Bold', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue'; + // Additional style configurations .mixin-elevated { box-shadow: 0px 80px 80px rgba(0, 0, 0, 0.075), 0px 10px 10px rgba(0, 0, 0, 0.035) !important; @@ -68,6 +76,13 @@ $z_main_nav: 948; $z_pinned_dashboards_popup: 962; $z_drawer: 1000; $z_command_palette: 1875; +$z_city_background_content: 1; +$z_city_background_image: 0; + +// Breakpoints (from AntD) +$sm: 576px; +$md: 768px; +$lg: 992px; // Export variables for TS access :export { diff --git a/posthog/api/organization.py b/posthog/api/organization.py index b61fd8c8cfba5..598c4e3e689e3 100644 --- a/posthog/api/organization.py +++ b/posthog/api/organization.py @@ -3,9 +3,10 @@ import posthoganalytics from django.conf import settings from django.contrib.auth import login, password_validation +from django.core.exceptions import ValidationError from django.db import transaction from django.db.models import QuerySet -from django.shortcuts import get_object_or_404, redirect +from django.shortcuts import get_object_or_404 from django.urls.base import reverse from rest_framework import exceptions, generics, permissions, response, serializers, status, viewsets from rest_framework.request import Request @@ -342,18 +343,16 @@ def get(self, request, *args, **kwargs): try: invite: OrganizationInvite = OrganizationInvite.objects.get(id=invite_id) - except (OrganizationInvite.DoesNotExist): + except (OrganizationInvite.DoesNotExist, ValidationError): raise serializers.ValidationError("The provided invite ID is not valid.") user = request.user if request.user.is_authenticated else None - try: - invite.validate(user=user) - except ValueError as e: - raise serializers.ValidationError(str(e)) + invite.validate(user=user) return response.Response( { + "id": str(invite.id), "target_email": mask_email_address(invite.target_email), "first_name": invite.first_name, "organization_name": invite.organization.name, diff --git a/posthog/api/organization_invite.py b/posthog/api/organization_invite.py index 4fe4b65332d71..9f8e2bc998d51 100644 --- a/posthog/api/organization_invite.py +++ b/posthog/api/organization_invite.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, cast +from typing import Any, Dict, List from django.db import transaction from rest_framework import exceptions, mixins, serializers, viewsets diff --git a/posthog/api/test/test_organization.py b/posthog/api/test/test_organization.py index 1ea1b73c07793..48f28f2c9b025 100644 --- a/posthog/api/test/test_organization.py +++ b/posthog/api/test/test_organization.py @@ -400,6 +400,7 @@ def test_api_invite_sign_up_prevalidate(self): self.assertEqual( response.data, { + "id": str(invite.id), "target_email": "t*****9@posthog.com", "first_name": "", "organization_name": self.CONFIG_ORGANIZATION_NAME, @@ -416,6 +417,7 @@ def test_api_invite_sign_up_with_first_nameprevalidate(self): self.assertEqual( response.data, { + "id": str(invite.id), "target_email": "t*****8@posthog.com", "first_name": "Jane", "organization_name": self.CONFIG_ORGANIZATION_NAME, @@ -432,23 +434,31 @@ def test_api_invite_sign_up_prevalidate_for_existing_user(self): self.client.force_login(user) response = self.client.get(f"/api/signup/{invite.id}/") self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - response.data, {"target_email": "t*****9@posthog.com", "first_name": "", "organization_name": "Test, Inc",}, - ) - - def test_api_invite_sign_up_prevalidate_invalid_invite(self): - response = self.client.get(f"/api/signup/{uuid.uuid4()}/") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( response.data, { - "type": "validation_error", - "code": "invalid_input", - "detail": "The provided invite ID is not valid.", - "attr": None, + "id": str(invite.id), + "target_email": "t*****9@posthog.com", + "first_name": "", + "organization_name": "Test, Inc", }, ) + def test_api_invite_sign_up_prevalidate_invalid_invite(self): + + for invalid_invite in [uuid.uuid4(), "abc", "1234"]: + response = self.client.get(f"/api/signup/{invalid_invite}/") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + { + "type": "validation_error", + "code": "invalid_input", + "detail": "The provided invite ID is not valid.", + "attr": None, + }, + ) + def test_existing_user_cant_claim_invite_if_it_doesnt_match_target_email(self): user = self._create_user("test+39@posthog.com", "test_password") invite: OrganizationInvite = OrganizationInvite.objects.create( @@ -462,8 +472,9 @@ def test_existing_user_cant_claim_invite_if_it_doesnt_match_target_email(self): response.data, { "type": "validation_error", - "code": "invalid_input", - "detail": "This invite is intended for another email address.", + "code": "invalid_recipient", + "detail": "This invite is intended for another email address: t*****9@posthog.com." + " You tried to sign up with test+39@posthog.com.", "attr": None, }, ) @@ -481,7 +492,7 @@ def test_api_invite_sign_up_prevalidate_expired_invite(self): response.data, { "type": "validation_error", - "code": "invalid_input", + "code": "expired", "detail": "This invite has expired. Please ask your admin for a new one.", "attr": None, }, @@ -572,7 +583,7 @@ def test_existing_user_can_sign_up_to_a_new_organization(self, mock_capture, moc self.assertEqual(user.organization_memberships.count(), 2) self.assertTrue(user.organization_memberships.filter(organization=new_org).exists()) - # Defaults are set correctly + # User is now changed to the new organization self.assertEqual(user.organization, new_org) self.assertEqual(user.team, new_team) @@ -698,7 +709,7 @@ def test_cant_claim_invite_sign_up_with_short_password(self): response = self.client.post(f"/api/signup/{invite.id}/", {"first_name": "Charlie", "password": "123"}) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( - response.data, + response.json(), { "type": "validation_error", "code": "password_too_short", @@ -721,7 +732,7 @@ def test_cant_claim_invalid_invite(self): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( - response.data, + response.json(), { "type": "validation_error", "code": "invalid_input", @@ -748,10 +759,10 @@ def test_cant_claim_expired_invite(self): response = self.client.post(f"/api/signup/{invite.id}/", {"first_name": "Charlie", "password": "test_password"}) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( - response.data, + response.json(), { "type": "validation_error", - "code": "invalid_input", + "code": "expired", "detail": "This invite has expired. Please ask your admin for a new one.", "attr": None, }, diff --git a/posthog/api/test/test_preflight.py b/posthog/api/test/test_preflight.py index 3ec7bb61f609b..e37ad80bce56b 100644 --- a/posthog/api/test/test_preflight.py +++ b/posthog/api/test/test_preflight.py @@ -11,19 +11,66 @@ def test_preflight_request(self): response = self.client.get("/_preflight/") self.assertEqual(response.status_code, status.HTTP_200_OK) response = response.json() - self.assertEqual(response["django"], True) - self.assertEqual(response["db"], True) - self.assertEqual(response["initiated"], True) - self.assertEqual(response["cloud"], False) + self.assertEqual( + response, + { + "django": True, + "redis": True, + "plugins": True, + "celery": True, + "db": True, + "initiated": True, + "cloud": False, + "available_social_auth_providers": {"google-oauth2": False, "github": False, "gitlab": False}, + }, + ) + + def test_cloud_preflight_request(self): - def test_preflight_request_bis(self): self.client.logout() # make sure it works anonymously User.objects.all().delete() + with self.settings(MULTI_TENANCY=True): response = self.client.get("/_preflight/") self.assertEqual(response.status_code, status.HTTP_200_OK) response = response.json() - self.assertEqual(response["django"], True) - self.assertEqual(response["db"], True) - self.assertEqual(response["initiated"], False) - self.assertEqual(response["cloud"], True) + self.assertEqual( + response, + { + "django": True, + "redis": True, + "plugins": True, + "celery": True, + "db": True, + "initiated": False, + "cloud": True, + "available_social_auth_providers": {"google-oauth2": False, "github": False, "gitlab": False}, + }, + ) + + def test_cloud_preflight_request_with_social_auth_providers(self): + + self.client.logout() # make sure it works anonymously + User.objects.all().delete() + + with self.settings( + SOCIAL_AUTH_GOOGLE_OAUTH2_KEY="test_key", + SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET="test_secret", + MULTI_TENANCY=True, + ): + response = self.client.get("/_preflight/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + response = response.json() + self.assertEqual( + response, + { + "django": True, + "redis": True, + "plugins": True, + "celery": True, + "db": True, + "initiated": False, + "cloud": True, + "available_social_auth_providers": {"google-oauth2": True, "github": False, "gitlab": False}, + }, + ) diff --git a/posthog/models/organization.py b/posthog/models/organization.py index ec7c053c35249..804105cc9cfe1 100644 --- a/posthog/models/organization.py +++ b/posthog/models/organization.py @@ -8,6 +8,8 @@ from django.utils import timezone from rest_framework import exceptions +from posthog.utils import mask_email_address + from .utils import UUIDModel, sane_repr try: @@ -191,18 +193,29 @@ def validate(self, *, user: Any = None, email: Optional[str] = None) -> None: _email = email or (hasattr(user, "email") and user.email) if _email and _email != self.target_email: - raise ValueError("This invite is intended for another email address.") + raise exceptions.ValidationError( + f"This invite is intended for another email address: {mask_email_address(self.target_email)}" + f". You tried to sign up with {_email}.", + code="invalid_recipient", + ) if self.is_expired(): - raise ValueError("This invite has expired. Please ask your admin for a new one.") + raise exceptions.ValidationError( + "This invite has expired. Please ask your admin for a new one.", code="expired", + ) if OrganizationMembership.objects.filter(organization=self.organization, user=user).exists(): - raise ValueError("User already is a member of the organization.") + raise exceptions.ValidationError( + "You already are a member of this organization.", code="user_already_member", + ) if OrganizationMembership.objects.filter( organization=self.organization, user__email=self.target_email, ).exists(): - raise ValueError("A user with this email address already belongs to the organization.") + raise exceptions.ValidationError( + "Another user with this email address already belongs to this organization.", + code="existing_email_address", + ) def use(self, user: Any, *, prevalidated: bool = False) -> None: if not prevalidated: diff --git a/posthog/test/test_urls.py b/posthog/test/test_urls.py index 2da98aff0c84c..74c85b6e6e7fb 100644 --- a/posthog/test/test_urls.py +++ b/posthog/test/test_urls.py @@ -1,71 +1,36 @@ -from django.test import Client, TestCase +import uuid + from rest_framework import status -from posthog.models import OrganizationInvite, OrganizationMembership, User +from posthog.models import User from posthog.test.base import BaseTest -class TestUrls(TestCase): - def setUp(self): - super().setUp() - self.client = Client() +class TestUrls(BaseTest): + TESTS_API = True def test_logout_temporary_token_reset(self): - # create random team - invited_email = "jane@acme.com" - organization, team, user = User.objects.bootstrap("test", "adminuser@posthog.com", None) - invite = OrganizationInvite.objects.create(organization=organization, target_email=invited_email) - # create a new user and log them in - with self.settings(TEST=False): - response = self.client.post( - f"/signup/{invite.id}", - {"name": "Jane", "email": invited_email, "password": "hunter2", "emailOptIn": "",}, - follow=True, - ) - self.assertRedirects(response, "/") - - # fetch the user model - user = User.objects.get(email=invited_email) - user.temporary_token = "token123" - user.save() - - # token still there after reload - user = User.objects.get(id=user.id) - self.assertEqual(user.temporary_token, "token123") + + # update temporary token + self.user.temporary_token = "token123" + self.user.save() # logout with self.settings(TEST=False): - response = self.client.post("/logout", follow=True,) - + response = self.client.post("/logout", follow=True) self.assertRedirects(response, "/login") # no more token - user = User.objects.get(id=user.id) - self.assertEqual(user.temporary_token, None) - - def test_invitation_signup_token(self): - # create random team - invited_email = "jane@acme.com" - signup_token = "abcd1234" - organization, team, user = User.objects.bootstrap( - "test", "adminuser@posthog.com", None, team_fields={"signup_token": signup_token} - ) - # create a new user and log them in - with self.settings(TEST=False): - response = self.client.post( - f"/signup/{signup_token}", - {"name": "Jane", "email": invited_email, "password": "hunter2", "emailOptIn": ""}, - follow=True, - ) - self.assertRedirects(response, "/") - self.assertEqual(organization.members.count(), 2) - self.assertTrue( - OrganizationMembership.objects.filter(organization=organization, user__email=invited_email).exists() - ) + self.user.refresh_from_db() + self.assertEqual(self.user.temporary_token, None) def test_logged_out_user_is_redirected_to_login(self): self.client.logout() + response = self.client.get("/events") + self.assertRedirects(response, "/login?next=/events") + + # Complex URL response = self.client.get( '/insights?interval=day&display=ActionsLineGraph&events=[{"id":"$pageview","name":"$pageview","type":"events","order":0}]&properties=[]', ) @@ -78,37 +43,34 @@ def test_logged_out_user_is_redirected_to_login(self): ) def test_login_with_next_url(self): - organization, team, user = User.objects.bootstrap( - "test", "adminuser@posthog.com", None, team_fields={"signup_token": "abcd1234"} - ) - User.objects.create_and_join(organization=organization, email="jane@acme.com", password="password") + + User.objects.create_and_join(organization=self.organization, email="jane@acme.com", password="unsafe_password") # Standard redirect - response = self.client.post("/login?next=/demo", {"email": "jane@acme.com", "password": "password"}) + self.client.logout() + response = self.client.post("/login?next=/demo", {"email": "jane@acme.com", "password": "unsafe_password"}) self.assertRedirects(response, "/demo") # Complex redirect (url-encoded) self.client.logout() response = self.client.post( "/login?next=/insights%3Finterval%3Dday%26display%3DActionsLineGraph%26events%3D%5B%257B%2522id%2522%3A%2522%24pageview%2522%2C%2522name%2522%3A%2522%24pageview%2522%2C%2522type%2522%3A%2522events%2522%2C%2522order%2522%3A0%257D%5D%26properties%3D%5B%5D", - {"email": "jane@acme.com", "password": "password"}, + {"email": "jane@acme.com", "password": "unsafe_password"}, ) self.assertRedirects( response, '/insights?interval=day&display=ActionsLineGraph&events=[{"id":"$pageview","name":"$pageview","type":"events","order":0}]&properties=[]', ) + def test_unauthenticated_routes_get_loaded_on_the_frontend(self): -class TestUrlsLoggedIn(BaseTest): - TESTS_API = True + self.client.logout() - def test_invitation_join(self): - organization, team, user = User.objects.bootstrap("test", "adminuser@posthog.com", None) - invite = OrganizationInvite.objects.create( - organization=organization, target_email=self.TESTS_EMAIL, created_by=user - ) - with self.settings(TEST=False): - response = self.client.post(f"/signup/{invite.id}", follow=True,) - self.assertRedirects(response, "/") - self.assertEqual(organization.members.count(), 2) - self.assertTrue(OrganizationMembership.objects.filter(organization=organization, user=self.user).exists()) + response = self.client.get("/signup") + self.assertEqual(response.status_code, status.HTTP_200_OK) # no redirect + + response = self.client.get(f"/signup/{uuid.uuid4()}") + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = self.client.get(f"/preflight") + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/posthog/urls.py b/posthog/urls.py index e497936f56cf0..b49ffd5646006 100644 --- a/posthog/urls.py +++ b/posthog/urls.py @@ -10,11 +10,11 @@ from django.core.exceptions import ValidationError from django.http import HttpResponse from django.shortcuts import redirect, render -from django.template.loader import render_to_string from django.urls import URLPattern, include, path, re_path, reverse from django.views.decorators.csrf import csrf_exempt, csrf_protect from django.views.generic.base import TemplateView from loginas.utils import is_impersonated_session, restore_original_login +from rest_framework import exceptions from sentry_sdk import capture_exception from social_core.pipeline.partial import partial from social_django.strategy import DjangoStrategy @@ -239,26 +239,23 @@ def social_create_user(strategy: DjangoStrategy, details, backend, request, user try: invite = TeamInviteSurrogate(invite_id) except Team.DoesNotExist: - processed = render_to_string("auth_error.html", {"message": "Invalid invite link!"},) - return HttpResponse(processed, status=401) + return redirect(f"/signup/{invite_id}?error_code=invalid_invite&source=social_create_user") try: invite.validate(user=None, email=user_email) - except ValueError as e: - processed = render_to_string("auth_error.html", {"message": str(e)},) - return HttpResponse(processed, status=401) + except exceptions.ValidationError as e: + return redirect( + f"/signup/{invite_id}?error_code={e.get_codes()[0]}&error_detail={e.args[0]}&source=social_create_user" + ) try: user = strategy.create_user(email=user_email, first_name=user_name, password=None) except Exception as e: capture_exception(e) - processed = render_to_string( - "auth_error.html", - { - "message": "Account unable to be created. This account may already exist. Please try again or use different credentials!" - }, - ) - return HttpResponse(processed, status=401) + message = "Account unable to be created. This account may already exist. Please try again" + " or use different credentials." + return redirect(f"/signup/{invite_id}?error_code=unknown&error_detail={message}&source=social_create_user") + invite.use(user, prevalidated=True) report_user_signed_up( @@ -357,7 +354,6 @@ def opt_slash_path(route: str, view: Callable, name: Optional[str] = None) -> UR path("logout", logout, name="login"), path("login", login_view, name="login"), path("signup/finish/", finish_social_signup, name="signup_finish"), - path("signup/", signup_to_organization_view, name="signup"), path("", include("social_django.urls", namespace="social")), *( [] @@ -404,9 +400,9 @@ def delete_events(request): urlpatterns.append(path("delete_events/", delete_events)) # Routes added individually to remove login requirement -frontend_unauthenticated_routes = ["preflight", "signup"] +frontend_unauthenticated_routes = ["preflight", "signup", r"signup\/[A-Za-z0-9\-]*"] for route in frontend_unauthenticated_routes: - urlpatterns.append(path(route, home)) + urlpatterns.append(re_path(route, home)) urlpatterns += [ re_path(r"^.*", login_required(home)), diff --git a/posthog/utils.py b/posthog/utils.py index 1a623adbcfaa4..b1984c844a40b 100644 --- a/posthog/utils.py +++ b/posthog/utils.py @@ -202,6 +202,9 @@ def render_template(template_name: str, request: HttpRequest, context: Dict = {} else: context["opt_out_capture"] = team.opt_out_capture if team else False + # TODO: BEGINS DEPRECATED CODE + # Code deprecated in favor of posthog.api.authentication.AuthenticationSerializer + # Remove after migrating login to React if settings.SOCIAL_AUTH_GITHUB_KEY and settings.SOCIAL_AUTH_GITHUB_SECRET: context["github_auth"] = True if settings.SOCIAL_AUTH_GITLAB_KEY and settings.SOCIAL_AUTH_GITLAB_SECRET: @@ -225,6 +228,7 @@ def render_template(template_name: str, request: HttpRequest, context: Dict = {} context["google_auth"] = True else: print_warning(["You have Google login set up, but not the required premium PostHog plan!"]) + # ENDS DEPRECATED CODE if os.environ.get("SENTRY_DSN"): context["sentry_dsn"] = os.environ["SENTRY_DSN"] @@ -502,6 +506,32 @@ def get_instance_realm() -> str: return "cloud" if getattr(settings, "MULTI_TENANCY", False) else "hosted" +def get_available_social_auth_providers() -> Dict[str, bool]: + github: bool = bool(settings.SOCIAL_AUTH_GITHUB_KEY and settings.SOCIAL_AUTH_GITHUB_SECRET) + gitlab: bool = bool(settings.SOCIAL_AUTH_GITLAB_KEY and settings.SOCIAL_AUTH_GITLAB_SECRET) + google: bool = False + + if getattr(settings, "SOCIAL_AUTH_GOOGLE_OAUTH2_KEY", None) and getattr( + settings, "SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET", None, + ): + if settings.MULTI_TENANCY: + google = True + else: + + try: + from ee.models.license import License + except ImportError: + pass + else: + license = License.objects.first_valid() + if license is not None and "google_login" in license.available_features: + google = True + else: + print_warning(["You have Google login set up, but not the required premium PostHog plan!"]) + + return {"google-oauth2": google, "github": github, "gitlab": gitlab} + + def flatten(l: Union[List, Tuple]) -> Generator: for el in l: if isinstance(el, list): diff --git a/posthog/views.py b/posthog/views.py index 528594219d12e..6966033d9d643 100644 --- a/posthog/views.py +++ b/posthog/views.py @@ -25,7 +25,7 @@ is_redis_alive, ) -from .utils import get_celery_heartbeat, get_plugin_server_version +from .utils import get_available_social_auth_providers, get_celery_heartbeat, get_plugin_server_version def login_required(view): @@ -177,7 +177,7 @@ def system_status(request): @never_cache -def preflight_check(request): +def preflight_check(_): return JsonResponse( { "django": True, @@ -187,5 +187,6 @@ def preflight_check(request): "db": is_postgres_alive(), "initiated": User.objects.exists(), "cloud": settings.MULTI_TENANCY, + "available_social_auth_providers": get_available_social_auth_providers(), } )