Skip to content

Commit

Permalink
Add option to pass signed-in user ID to Airtable forms (#1121)
Browse files Browse the repository at this point in the history
* Prefill user ID when replying to an opportunity

* Fix useCurrentUser hook

* Add convenience hook to get currently signed-in user

* Translate user IDs to target database

* Add support for the requireSignIn flag

* Implement the requireSignIn flag for opportunities

* Comment various flag combinations

* Fix case where sign-in is optional

* Refactor code

* Trivial copy improvement

* Add option to redirect to custom page after user sign-up

* Add option to route to registration page with a callback URL

* Add more explicit sign-up option to sign-in page

* Add support for user ID translation to DIA UX research base
  • Loading branch information
zoul authored Nov 12, 2024
1 parent bb86249 commit 6bd2248
Show file tree
Hide file tree
Showing 15 changed files with 298 additions and 35 deletions.
67 changes: 67 additions & 0 deletions app/api/translate-user-id/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import Airtable from "airtable";

import { withAuthenticatedUser } from "~/src/auth";

/** The list of all synced User Profiles tables indexed by their containing DB ID */
const syncedUserTablesByDatabase: Record<string, string | undefined> = {
// App -> User Profiles
appkn1DkvgVI5jpME: "tbl3QK2aTskyu2rNQ",
// Uživatelský výzkum DIA -> Users
appKWumcDDL9KI00N: "tblTf8usuYWgIZD9x",
};

/** Translate signed-in user’s ID to a different database */
export async function GET(request: Request) {
return withAuthenticatedUser(async (currentUser) => {
const { searchParams } = new URL(request.url);
const formUrl = searchParams.get("formUrl");
if (!formUrl || typeof formUrl !== "string") {
return new Response("The `formUrl` argument is missing or malformed.", {
status: 400,
});
}

// Parse URL, extract target database ID
const matches = /https:\/\/airtable.com\/(app\w+)/.exec(formUrl);
if (matches?.length !== 2) {
return new Response(
"The `formUrl` argument does not match the expected pattern.",
{
status: 400,
},
);
}

const [_, databaseId] = matches;

// If the database is Users, no user ID translation is needed
if (databaseId === "apppZX1QC3fl1RTBM") {
return new Response(
JSON.stringify({ targetUserId: currentUser.id }, null, 2),
{
status: 200,
},
);
}

// Otherwise, look up the ID of the synced User Profiles table in the target DB
const userTableId = syncedUserTablesByDatabase[databaseId];
if (!userTableId) {
return new Response(`Unknown database ID: "${databaseId}".`, {
status: 400,
});
}

// And once we have that, look up the record ID of currently signed-in user
const airtable = new Airtable();
const table = airtable.base(databaseId)(userTableId);
const targetUserId = await table
.select({ filterByFormula: `{id} = "${currentUser.id}"`, maxRecords: 1 })
.all()
.then((records) => records[0].id);

return new Response(JSON.stringify({ targetUserId }, null, 2), {
status: 200,
});
});
}
7 changes: 6 additions & 1 deletion app/auth/sign-in/SignInForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ export const SignInForm = (props: Props) => {
Přihlásit přes Slack
</a>
</p>
<p className="typo-caption">
<a className="typo-link" href={Route.register({ email, callbackUrl })}>
Založit nový účet
</a>
</p>
</form>
);
};
Expand All @@ -86,7 +91,7 @@ const describeError = ({ error, email }: { error: string; email: string }) => {
return (
<Fragment>
Tenhle mail neznáme. Buď zkus jiný,{" "}
<a href={Route.register(email)} className="typo-link">
<a href={Route.register({ email })} className="typo-link">
anebo se můžeš registrovat
</a>
.
Expand Down
5 changes: 3 additions & 2 deletions app/join/SignUpForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ import {

type Props = {
defaultEmail?: string;
callbackUrl?: string;
};

/** Main sign-up form */
export const SignUpForm = ({ defaultEmail }: Props) => {
export const SignUpForm = ({ defaultEmail, callbackUrl = "/" }: Props) => {
const [state, setState] = useState<FormState>({
...emptyFormState,
email: defaultEmail ?? "",
Expand Down Expand Up @@ -57,7 +58,7 @@ export const SignUpForm = ({ defaultEmail }: Props) => {
submissionState: { tag: "submitted_successfully" },
});
trackCustomEvent("SignUp");
await signIn("email", { email: validatedData.email, callbackUrl: "/" });
await signIn("email", { email: validatedData.email, callbackUrl });
} catch (error) {
setState({
...state,
Expand Down
5 changes: 3 additions & 2 deletions app/join/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,17 @@ import { SignUpForm } from "./SignUpForm";
type Props = {
searchParams: {
email?: string;
callbackUrl?: string;
};
};

const Page = ({ searchParams }: Props) => {
const { email } = searchParams;
const { email, callbackUrl = "/" } = searchParams;

return (
<main className="m-auto max-w-content px-7 py-20">
<Breadcrumbs currentPage="Registrace" />
<SignUpForm defaultEmail={email} />
<SignUpForm defaultEmail={email} callbackUrl={callbackUrl} />
</main>
);
};
Expand Down
127 changes: 127 additions & 0 deletions app/opportunities/[slug]/ResponseButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"use client";

import { useEffect, useState } from "react";

import { signIn, useSession } from "next-auth/react";
import { record, string } from "typescript-json-decoder";

import { useSignedInUser } from "~/components/hooks/user";
import { SidebarCTA } from "~/components/Sidebar";
import { type Opportunity } from "~/src/data/opportunity";

type Props = {
role: Pick<Opportunity, "responseUrl" | "prefillUserId" | "requireSignIn">;
};

export const ResponseButton = ({ role }: Props) => {
const { status: sessionStatus } = useSession();
const translatedUserId = useTranslatedUserId(role.responseUrl);

const shouldPrefill =
role.prefillUserId && role.responseUrl.startsWith("https://");

const prefillUserId = (responseUrl: string, userId: string) => {
const prefilledUrl = new URL(responseUrl);
prefilledUrl.searchParams.append("prefill_User", userId);
prefilledUrl.searchParams.append("hide_User", "true");
return prefilledUrl.toString();
};

const { requireSignIn } = role;

if (requireSignIn && shouldPrefill) {
//
// 1. Both sign-in and prefill are on. This is expected to be the
// default for most use cases – users are required to sign in and after
// that we pass their ID to the form.
//
if (sessionStatus === "loading") {
return <LoadingSpinner />;
} else if (sessionStatus === "unauthenticated") {
return <SignInButton />;
} else if (!translatedUserId) {
// TBD: If we fail to translate the user ID we’re stuck here forever
return <LoadingSpinner />;
} else {
return (
<SidebarCTA
href={prefillUserId(role.responseUrl, translatedUserId)}
label="Mám zájem ✨"
/>
);
}
} else if (!requireSignIn && shouldPrefill) {
//
// 2. Prefill is on, but sign-in is optional. If the user is signed in,
// we pass their ID to the form. Not sure if this is going to be used in
// practice.
//
if (sessionStatus === "loading") {
return <LoadingSpinner />;
} else if (sessionStatus === "unauthenticated" || !translatedUserId) {
return <SidebarCTA href={role.responseUrl} label="Mám zájem" />;
} else {
return (
<SidebarCTA
href={prefillUserId(role.responseUrl, translatedUserId)}
label="Mám zájem ✨"
/>
);
}
} else if (requireSignIn && !shouldPrefill) {
//
// 3. Sign-in is required, but user ID is not passed to the form. This may be
// handy for fully custom forms where you don’t want any autofilling, but
// want to be sure users sign in (and therefore accept our general T&C)
// before filling the form.
//
if (sessionStatus === "authenticated") {
return <SidebarCTA href={role.responseUrl} label="Mám zájem 🔓" />;
} else if (sessionStatus === "unauthenticated") {
return <SignInButton />;
} else {
return <LoadingSpinner />;
}
} else {
// 4. No fancy processing needed, just use the response URL from the DB
return <SidebarCTA href={role.responseUrl} label="Mám zájem" />;
}
};

const LoadingSpinner = () => (
<SidebarCTA href="" label="Malý moment…" disabled />
);

const SignInButton = () => (
<div className="flex flex-col gap-2">
<button className="btn-primary block text-center" onClick={() => signIn()}>
Mám zájem 🔒
</button>
<p className="typo-caption text-balance text-center">
Pokud máš o nabízenou roli zájem, musíš se nejdřív přihlásit nebo
registrovat.
</p>
</div>
);

function useTranslatedUserId(responseUrl: string) {
const signedInUser = useSignedInUser();
const [translatedId, setTranslatedId] = useState<string | undefined>();

useEffect(() => {
if (!signedInUser) {
return;
}
const decodeResponse = record({ targetUserId: string });
async function fetchTranslatedId() {
return fetch(`/api/translate-user-id?formUrl=${responseUrl}`)
.then((response) => response.json())
.then(decodeResponse)
.then((response) => setTranslatedId(response.targetUserId))
.catch((e) => console.error(e));
}
void fetchTranslatedId();
}, [signedInUser, responseUrl]);

return translatedId;
}
5 changes: 3 additions & 2 deletions app/opportunities/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { type Metadata } from "next";
import Image from "next/image";
import { notFound } from "next/navigation";

import { ResponseButton } from "~/app/opportunities/[slug]/ResponseButton";
import { Breadcrumbs } from "~/components/Breadcrumbs";
import { ImageLabel, ProjectImageLabel } from "~/components/ImageLabel";
import { MarkdownContent } from "~/components/MarkdownContent";
import { OpportunityRow } from "~/components/OpportunityRow";
import { RelatedContent } from "~/components/RelatedContent";
import { Sidebar, SidebarCTA, SidebarSection } from "~/components/Sidebar";
import { Sidebar, SidebarSection } from "~/components/Sidebar";
import { getAllOpportunities, type Opportunity } from "~/src/data/opportunity";
import { getAllProjects, type Project } from "~/src/data/project";
import { getAlternativeOpenRoles } from "~/src/data/queries";
Expand Down Expand Up @@ -102,7 +103,7 @@ const RoleSidebar = ({
label={owner.name}
/>
</SidebarSection>
<SidebarCTA href={role.contactUrl} label="Mám zájem" />
<ResponseButton role={role} />
</Sidebar>
);

Expand Down
4 changes: 2 additions & 2 deletions app/people/[id]/UpdateProfileButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

import Link from "next/link";

import { useCurrentUser } from "~/components/hooks/user";
import { useIsCurrentUser } from "~/components/hooks/user";
import { type UserProfile } from "~/src/data/user-profile";
import { Route } from "~/src/routing";

export const UpdateProfileButton = ({ profile }: { profile: UserProfile }) => {
const isCurrentUser = useCurrentUser(profile.id);
const isCurrentUser = useIsCurrentUser(profile.id);
return isCurrentUser ? (
<Link className="btn-inverted" href={Route.account}>
Upravit profil
Expand Down
13 changes: 12 additions & 1 deletion components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { type ReactNode } from "react";
import Link from "next/link";

import clsx from "clsx";

/** Generic sidebar container */
export const Sidebar = ({ children }: { children: ReactNode }) => (
<div className="flex flex-col gap-7 rounded-xl bg-pebble p-7">{children}</div>
Expand All @@ -24,12 +26,21 @@ export const SidebarSection = ({
export const SidebarCTA = ({
href,
label,
disabled = false,
}: {
href: string;
label: string;
disabled?: boolean;
}) => (
<div>
<Link href={href} className="btn-primary block text-center">
<Link
href={disabled ? "" : href}
aria-disabled={disabled}
className={clsx(
"block text-center",
disabled ? "btn-disabled" : "btn-primary",
)}
>
{label}
</Link>
</div>
Expand Down
23 changes: 20 additions & 3 deletions components/hooks/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,31 @@ import { useEffect, useState } from "react";

import { useSession } from "next-auth/react";

import { type OurUser } from "~/src/auth";
import { assertIsOurUser, type OurUser } from "~/src/utils";

/** Is the user with given ID currently signed in? */
export const useCurrentUser = (id: string) => {
export const useIsCurrentUser = (id: string) => {
const { data: session } = useSession();
const [isCurrentUser, setCurrentUser] = useState(false);
useEffect(() => {
setCurrentUser((session?.user as OurUser)?.id === id);
if (session?.user) {
assertIsOurUser(session.user);
setCurrentUser(session.user.id === id);
}
}, [session, id]);
return isCurrentUser;
};

export const useSignedInUser = () => {
const { data: session, status: sessionStatus } = useSession();
const [signedInUser, setSignedInUser] = useState<OurUser | undefined>();
useEffect(() => {
if (sessionStatus === "authenticated" && session?.user) {
assertIsOurUser(session.user);
setSignedInUser(session.user);
} else {
setSignedInUser(undefined);
}
}, [session, sessionStatus]);
return signedInUser;
};
Loading

0 comments on commit 6bd2248

Please sign in to comment.