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 (
+
+
+ Continue with {ProviderNames[provider]}
+
+ )
+}
+
+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 (
+
+ )
+}
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 (
+ } block onClick={() => push('/')}>
+ Go back to PostHog
+
+ )
+}
+
+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.
+
+ } href={window.location.pathname}>
+ Try again
+
+
+
+ )}
+
+ >
+ ),
+ 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 ? (
+ <>
+
acceptInvite(null)}
+ disabled={acceptedInviteLoading}
+ >
+ Accept invite
+
+
+
+
Go back to PostHog
+
+
+ >
+ ) : (
+
(window.location.href = '/')}>
+ Go 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
+
+ } type="default" onClick={handleScroll}>
+ Continue
+
+
+
+
+
+
+
+
+
+
+ {socialAuthAvailable ? 'Or create your own passowrd' : 'Create your PostHog account'}
+
+
+
+
+ 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(),
}
)