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 */
+}