diff --git a/app/api/translate-user-id/route.tsx b/app/api/translate-user-id/route.tsx new file mode 100644 index 000000000..500ff4b33 --- /dev/null +++ b/app/api/translate-user-id/route.tsx @@ -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 = { + // 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, + }); + }); +} diff --git a/app/auth/sign-in/SignInForm.tsx b/app/auth/sign-in/SignInForm.tsx index 1a67dd2d1..d0edf9503 100644 --- a/app/auth/sign-in/SignInForm.tsx +++ b/app/auth/sign-in/SignInForm.tsx @@ -74,6 +74,11 @@ export const SignInForm = (props: Props) => { Přihlásit přes Slack

+

+ + Založit nový účet + +

); }; @@ -86,7 +91,7 @@ const describeError = ({ error, email }: { error: string; email: string }) => { return ( Tenhle mail neznáme. Buď zkus jiný,{" "} - + anebo se můžeš registrovat . diff --git a/app/join/SignUpForm.tsx b/app/join/SignUpForm.tsx index 3bdb1b680..8f8fde47d 100644 --- a/app/join/SignUpForm.tsx +++ b/app/join/SignUpForm.tsx @@ -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({ ...emptyFormState, email: defaultEmail ?? "", @@ -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, diff --git a/app/join/page.tsx b/app/join/page.tsx index 5b63363b0..44a6c86fd 100644 --- a/app/join/page.tsx +++ b/app/join/page.tsx @@ -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 (
- +
); }; diff --git a/app/opportunities/[slug]/ResponseButton.tsx b/app/opportunities/[slug]/ResponseButton.tsx new file mode 100644 index 000000000..4ae322a40 --- /dev/null +++ b/app/opportunities/[slug]/ResponseButton.tsx @@ -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; +}; + +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 ; + } else if (sessionStatus === "unauthenticated") { + return ; + } else if (!translatedUserId) { + // TBD: If we fail to translate the user ID we’re stuck here forever + return ; + } else { + return ( + + ); + } + } 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 ; + } else if (sessionStatus === "unauthenticated" || !translatedUserId) { + return ; + } else { + return ( + + ); + } + } 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 ; + } else if (sessionStatus === "unauthenticated") { + return ; + } else { + return ; + } + } else { + // 4. No fancy processing needed, just use the response URL from the DB + return ; + } +}; + +const LoadingSpinner = () => ( + +); + +const SignInButton = () => ( +
+ +

+ Pokud máš o nabízenou roli zájem, musíš se nejdřív přihlásit nebo + registrovat. +

+
+); + +function useTranslatedUserId(responseUrl: string) { + const signedInUser = useSignedInUser(); + const [translatedId, setTranslatedId] = useState(); + + 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; +} diff --git a/app/opportunities/[slug]/page.tsx b/app/opportunities/[slug]/page.tsx index 73402b499..b95c4b868 100644 --- a/app/opportunities/[slug]/page.tsx +++ b/app/opportunities/[slug]/page.tsx @@ -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"; @@ -102,7 +103,7 @@ const RoleSidebar = ({ label={owner.name} /> - + ); diff --git a/app/people/[id]/UpdateProfileButton.tsx b/app/people/[id]/UpdateProfileButton.tsx index 08c62e595..9d110fc9c 100644 --- a/app/people/[id]/UpdateProfileButton.tsx +++ b/app/people/[id]/UpdateProfileButton.tsx @@ -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 ? ( Upravit profil diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 0d2fe7cac..6effd96da 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -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 }) => (
{children}
@@ -24,12 +26,21 @@ export const SidebarSection = ({ export const SidebarCTA = ({ href, label, + disabled = false, }: { href: string; label: string; + disabled?: boolean; }) => (
- + {label}
diff --git a/components/hooks/user.ts b/components/hooks/user.ts index 6c82b72ba..ddf115326 100644 --- a/components/hooks/user.ts +++ b/components/hooks/user.ts @@ -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(); + useEffect(() => { + if (sessionStatus === "authenticated" && session?.user) { + assertIsOurUser(session.user); + setSignedInUser(session.user); + } else { + setSignedInUser(undefined); + } + }, [session, sessionStatus]); + return signedInUser; +}; diff --git a/src/auth.ts b/src/auth.ts index daf900fc0..a92eb3126 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,9 +1,5 @@ import sendgrid from "@sendgrid/mail"; -import { - getServerSession, - type DefaultSession, - type NextAuthOptions, -} from "next-auth"; +import { getServerSession, type NextAuthOptions } from "next-auth"; import EmailProvider from "next-auth/providers/email"; import SlackProvider from "next-auth/providers/slack"; import { optional, record, string } from "typescript-json-decoder"; @@ -16,14 +12,12 @@ import { logUnknownEmailSignInEvent, } from "~/src/data/auth"; import { Route } from "~/src/routing"; -import { devMode, isHttpSuccessCode } from "~/src/utils"; - -export type OurUser = { - id: string; - name: string; - email: string; - image: string; -}; +import { + assertIsOurUser, + devMode, + isHttpSuccessCode, + type OurUser, +} from "~/src/utils"; /** NextAuth options used to configure various parts of the authentication machinery */ export const authOptions: NextAuthOptions = { @@ -92,11 +86,6 @@ export const authOptions: NextAuthOptions = { // The session callback is called whenever a session is checked. // https://next-auth.js.org/configuration/callbacks#session-callback async session({ session, token }) { - function assertIsOurUser( - user: DefaultSession["user"], - ): asserts user is OurUser { - /* If there is a user it’s always OurUser */ - } if (session.user) { assertIsOurUser(session.user); // Expose our user ID to the client side diff --git a/src/data/opportunity.test.ts b/src/data/opportunity.test.ts index c9e8810d6..57755df2c 100644 --- a/src/data/opportunity.test.ts +++ b/src/data/opportunity.test.ts @@ -25,7 +25,9 @@ test("Decode opportunity", () => { summary: { source: "- Práce na **mobilní** _aplikaci_." }, timeRequirements: "3–5 hodin týdně", ownerId: "rec0ABdJtGIK9AeCB", - contactUrl: "https://cesko-digital.slack.com/archives/C01AENB1LPP", + responseUrl: "https://cesko-digital.slack.com/archives/C01AENB1LPP", + prefillUserId: false, + requireSignIn: false, creationTime: "2021-09-02T17:20:26.000Z", coverImageUrl: "https://data.cesko.digital/web/projects/loono/cover-loono.jpg", diff --git a/src/data/opportunity.ts b/src/data/opportunity.ts index a720d3f48..6d472163f 100644 --- a/src/data/opportunity.ts +++ b/src/data/opportunity.ts @@ -1,5 +1,6 @@ import { array, + boolean, field, optional, record, @@ -14,6 +15,7 @@ import { decodeValidItemsFromArray, markdown, takeFirst, + withDefault, } from "~/src/decoding"; import { appBase, unwrapRecords } from "./airtable"; @@ -33,7 +35,9 @@ export const decodeOpportunity = record({ summary: field("Summary", markdown), timeRequirements: field("Time Requirements", string), ownerId: field("Owner ID", takeFirst(array(string))), - contactUrl: field("RSVP URL", decodeUrl), + responseUrl: field("RSVP URL", decodeUrl), + prefillUserId: field("Prefill User ID", withDefault(boolean, false)), + requireSignIn: field("Require Sign-In", withDefault(boolean, false)), coverImageUrl: field("Cover URL", optional(string)), skills: field("Skills", decodeSkills), status: field("Status", union("draft", "live", "unlisted")), diff --git a/src/routing.test.ts b/src/routing.test.ts new file mode 100644 index 000000000..11fc2b0f7 --- /dev/null +++ b/src/routing.test.ts @@ -0,0 +1,11 @@ +import { Route } from "~/src/routing"; + +test("Registration params", () => { + expect(Route.register()).toBe("/join"); + expect(Route.register({ email: "foo@bar.cz" })).toBe( + "/join?email=foo%40bar.cz", + ); + expect(Route.register({ email: "foo@bar.cz", callbackUrl: undefined })).toBe( + "/join?email=foo%40bar.cz", + ); +}); diff --git a/src/routing.ts b/src/routing.ts index 06833d693..f4b542b50 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -5,8 +5,14 @@ import { type MetricDefinition } from "./data/metrics"; import { type Opportunity } from "./data/opportunity"; import { type Project } from "./data/project"; -const register = (email: string | undefined = undefined) => - email ? `/join?${new URLSearchParams({ email })}` : "/join"; +const filterUndefines = >(val: T): T => + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + JSON.parse(JSON.stringify(val)); + +const register = (params: { email?: string; callbackUrl?: string } = {}) => + Object.keys(params).length > 0 + ? `/join?${new URLSearchParams(filterUndefines(params))}` + : "/join"; /** Create URLs for frequently used routes */ export const Route = { diff --git a/src/utils.ts b/src/utils.ts index 971817016..40a79be55 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,7 @@ import crypto from "crypto"; import Markdoc, { type Node } from "@markdoc/markdoc"; +import { type DefaultSession } from "next-auth"; /** Default user avatar picture URL if we have no better one */ export const defaultAvatarUrl = @@ -190,3 +191,23 @@ export const devMode = () => process.env.NODE_ENV === "development"; /** Is given HTTP status code a successful one? */ export const isHttpSuccessCode = (code: number) => 200 <= code && code < 300; + +/** + * The type of user data we keep in the session + * + * TBD: It would be much better to setup NextAuth to operate with this type + * everywhere without having to cast. + */ +export type OurUser = { + id: string; + name: string; + email: string; + image: string; +}; + +/** Utility assert to make TypeScript believe the user value in session is `OurUser` */ +export function assertIsOurUser( + user: DefaultSession["user"], +): asserts user is OurUser { + /* If there is a user it’s always OurUser */ +}