diff --git a/app/scenes/Login/index.js b/app/scenes/Login/index.js index 0743c298..f150e51a 100644 --- a/app/scenes/Login/index.js +++ b/app/scenes/Login/index.js @@ -5,6 +5,7 @@ import { BackIcon, EmailIcon } from "outline-icons"; import * as React from "react"; import { Redirect, Link, type Location } from "react-router-dom"; import styled from "styled-components"; +import { setCookie } from "tiny-cookie"; import ButtonLarge from "components/ButtonLarge"; import Fade from "components/Fade"; import Flex from "components/Flex"; @@ -16,6 +17,7 @@ import TeamLogo from "components/TeamLogo"; import Notices from "./Notices"; import Provider from "./Provider"; import env from "env"; +import useQuery from "hooks/useQuery"; import useStores from "hooks/useStores"; type Props = {| @@ -23,6 +25,7 @@ type Props = {| |}; function Login({ location }: Props) { + const query = useQuery(); const { auth } = useStores(); const { config } = auth; const [emailLinkSentTo, setEmailLinkSentTo] = React.useState(""); @@ -40,6 +43,17 @@ function Login({ location }: Props) { auth.fetchConfig(); }, [auth]); + React.useEffect(() => { + const entries = Object.fromEntries(query.entries()); + + // We don't want to override this cookie if we're viewing an error notice + // sent back from the server via query string (notice=), or if there are no + // query params at all. + if (Object.keys(entries).length && !query.get("notice")) { + setCookie("signupQueryParams", JSON.stringify(entries)); + } + }, [query]); + if (auth.authenticated) { return ; } diff --git a/server/auth/providers/email.js b/server/auth/providers/email.js index d54c698c..472ecc13 100644 --- a/server/auth/providers/email.js +++ b/server/auth/providers/email.js @@ -101,7 +101,7 @@ router.get("email.callback", async (ctx) => { await user.update({ lastActiveAt: new Date() }); // set cookies on response and redirect to team subdomain - signIn(ctx, user, user.team, "email", false); + await signIn(ctx, user, user.team, "email", false, false); } catch (err) { ctx.redirect(`/?notice=expired-token`); } diff --git a/server/middlewares/passport.js b/server/middlewares/passport.js index 4a78056e..030ca1ea 100644 --- a/server/middlewares/passport.js +++ b/server/middlewares/passport.js @@ -9,7 +9,7 @@ export default function createMiddleware(providerName: string) { return passport.authorize( providerName, { session: false }, - (err, _, result: AccountProvisionerResult) => { + async (err, _, result: AccountProvisionerResult) => { if (err) { console.error(err); @@ -39,7 +39,14 @@ export default function createMiddleware(providerName: string) { return ctx.redirect("/?notice=suspended"); } - signIn(ctx, result.user, result.team, providerName, result.isNewUser); + await signIn( + ctx, + result.user, + result.team, + providerName, + result.isNewUser, + result.isNewTeam + ); } )(ctx); }; diff --git a/server/migrations/20210430024222-marketing-tracking.js b/server/migrations/20210430024222-marketing-tracking.js new file mode 100644 index 00000000..b7a10ee1 --- /dev/null +++ b/server/migrations/20210430024222-marketing-tracking.js @@ -0,0 +1,14 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn("teams", "signupQueryParams", { + type: Sequelize.JSONB, + allowNull: true, + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.removeColumn("teams", "signupQueryParams"); + } +}; diff --git a/server/models/Team.js b/server/models/Team.js index ded4bd89..04f8e4d2 100644 --- a/server/models/Team.js +++ b/server/models/Team.js @@ -55,6 +55,10 @@ const Team = sequelize.define( googleId: { type: DataTypes.STRING, allowNull: true }, avatarUrl: { type: DataTypes.STRING, allowNull: true }, sharing: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true }, + signupQueryParams: { + type: DataTypes.JSONB, + allowNull: true, + }, guestSignin: { type: DataTypes.BOOLEAN, allowNull: false, diff --git a/server/utils/authentication.js b/server/utils/authentication.js index cc10f4be..ead036ec 100644 --- a/server/utils/authentication.js +++ b/server/utils/authentication.js @@ -1,6 +1,9 @@ // @flow +import querystring from "querystring"; +import * as Sentry from "@sentry/node"; import addMonths from "date-fns/add_months"; import { type Context } from "koa"; +import { pick } from "lodash"; import { User, Event, Team } from "../models"; import { getCookieDomain } from "../utils/domains"; @@ -10,17 +13,36 @@ export function getAllowedDomains(): string[] { return env ? env.split(",") : []; } -export function signIn( +export async function signIn( ctx: Context, user: User, team: Team, service: string, - isFirstSignin: boolean = false + isNewUser: boolean = false, + isNewTeam: boolean = false ) { if (user.isSuspended) { return ctx.redirect("/?notice=suspended"); } + if (isNewTeam) { + // see: scenes/Login/index.js for where this cookie is written when + // viewing the /login or /create pages. It is a URI encoded JSON string. + const cookie = ctx.cookies.get("signupQueryParams"); + + if (cookie) { + try { + const signupQueryParams = pick( + JSON.parse(querystring.unescape(cookie)), + ["ref", "utm_content", "utm_medium", "utm_source", "utm_campaign"] + ); + await team.update({ signupQueryParams }); + } catch (err) { + Sentry.captureException(err); + } + } + } + // update the database when the user last signed in user.updateSignedIn(ctx.request.ip); @@ -77,6 +99,6 @@ export function signIn( httpOnly: false, expires, }); - ctx.redirect(`${team.url}/home${isFirstSignin ? "?welcome" : ""}`); + ctx.redirect(`${team.url}/home${isNewUser ? "?welcome" : ""}`); } }