diff --git a/config/tsconfig/nextjs.json b/config/tsconfig/nextjs.json
index 3db2ba7138..52eebb4380 100644
--- a/config/tsconfig/nextjs.json
+++ b/config/tsconfig/nextjs.json
@@ -3,7 +3,6 @@
"display": "Next.js",
"extends": "./base.json",
"compilerOptions": {
- "plugins": [{ "name": "next" }],
"allowImportingTsExtensions": true,
"target": "es2018",
"lib": ["dom", "dom.iterable", "esnext"],
diff --git a/core/app/(user)/signup/page.tsx b/core/app/(user)/signup/page.tsx
index a63913b1e6..0652af2dd1 100644
--- a/core/app/(user)/signup/page.tsx
+++ b/core/app/(user)/signup/page.tsx
@@ -1,7 +1,7 @@
import { AuthTokenType } from "db/public";
import { getLoginData } from "~/lib/authentication/loginData";
-import { SignupForm } from "./SignupForm";
+import { LegacySignupForm } from "../../components/Signup/LegacySignupForm";
export default async function Page() {
const { user, session } = await getLoginData({
@@ -19,7 +19,7 @@ export default async function Page() {
return (
-
+
);
}
diff --git a/core/app/c/(public)/[communitySlug]/layout.tsx b/core/app/c/(public)/[communitySlug]/layout.tsx
index f3da6ac937..07a53d8e5d 100644
--- a/core/app/c/(public)/[communitySlug]/layout.tsx
+++ b/core/app/c/(public)/[communitySlug]/layout.tsx
@@ -1,5 +1,7 @@
import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+
import { CommunityProvider } from "~/app/components/providers/CommunityProvider";
import { getLoginData } from "~/lib/authentication/loginData";
import { getCommunityRole } from "~/lib/authentication/roles";
@@ -30,20 +32,10 @@ export default async function MainLayout(props: Props) {
const { children } = props;
- const { user } = await getLoginData();
-
const community = await findCommunityBySlug(params.communitySlug);
if (!community) {
- return null;
- }
-
- const role = getCommunityRole(user, { slug: params.communitySlug });
-
- // the user is logged in, but not a member of the community
- // we should bar them from accessing the page
- if (user && !role) {
- return null;
+ return notFound();
}
return {children} ;
diff --git a/core/app/c/(public)/[communitySlug]/public/forms/[formSlug]/fill/page.tsx b/core/app/c/(public)/[communitySlug]/public/forms/[formSlug]/fill/page.tsx
index dd16c959f0..5f54f8f18e 100644
--- a/core/app/c/(public)/[communitySlug]/public/forms/[formSlug]/fill/page.tsx
+++ b/core/app/c/(public)/[communitySlug]/public/forms/[formSlug]/fill/page.tsx
@@ -3,10 +3,10 @@ import { randomUUID } from "crypto";
import type { Metadata } from "next";
import type { ReactNode } from "react";
-import { notFound } from "next/navigation";
+import { notFound, redirect } from "next/navigation";
import type { Communities, PubsId } from "db/public";
-import { ElementType, MemberRole } from "db/public";
+import { ElementType, FormAccessType, MemberRole } from "db/public";
import { expect } from "utils";
import type { Form } from "~/lib/server/form";
@@ -164,6 +164,13 @@ export default async function FormPage(props: {
}
if (!user && !session) {
+ if (form.access === "public") {
+ // redirect user to signup/login
+ redirect(
+ `/c/${params.communitySlug}/public/signup?redirectTo=/c/${params.communitySlug}/public/forms/${params.formSlug}/fill`
+ );
+ }
+
const result = await handleFormToken({
params,
searchParams,
@@ -184,12 +191,18 @@ export default async function FormPage(props: {
const role = getCommunityRole(user, { slug: params.communitySlug });
if (!role) {
+ // user is not a member of the community, but is logged in, and the form is public
+ if (form.access === "public") {
+ redirect(
+ `/c/${params.communitySlug}/public/signup?redirectTo=/c/${params.communitySlug}/public/forms/${params.formSlug}/fill`
+ );
+ }
// TODO: show no access page
return notFound();
}
// all other roles always have access to the form
- if (role === MemberRole.contributor) {
+ if (role === MemberRole.contributor && form.access !== FormAccessType.public) {
const memberHasAccessToForm = await userHasPermissionToForm({
formSlug: params.formSlug,
userId: user.id,
diff --git a/core/app/c/(public)/[communitySlug]/public/signup/page.tsx b/core/app/c/(public)/[communitySlug]/public/signup/page.tsx
new file mode 100644
index 0000000000..deeface7a0
--- /dev/null
+++ b/core/app/c/(public)/[communitySlug]/public/signup/page.tsx
@@ -0,0 +1,72 @@
+import { notFound, redirect, RedirectType, unstable_rethrow } from "next/navigation";
+
+import { MemberRole } from "db/public";
+import { logger } from "logger";
+
+import { JoinCommunityForm } from "~/app/components/Signup/JoinCommunityForm";
+import { PublicSignupForm } from "~/app/components/Signup/PublicSignupForm";
+import { getLoginData } from "~/lib/authentication/loginData";
+import { findCommunityBySlug } from "~/lib/server/community";
+import { publicSignupsAllowed } from "~/lib/server/user";
+
+export default async function Page({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ communitySlug: string }>;
+ searchParams: Promise<{ redirectTo?: string }>;
+}) {
+ const [community, { user }] = await Promise.all([findCommunityBySlug(), getLoginData()]);
+
+ if (!community) {
+ logger.debug({
+ msg: "Community not found on signup page",
+ communitySlug: (await params).communitySlug,
+ });
+ notFound();
+ }
+
+ const isAllowedToSignup = await publicSignupsAllowed(community.id);
+
+ if (!isAllowedToSignup) {
+ // this community does not allow public signups
+ notFound();
+ }
+
+ const { redirectTo } = await searchParams;
+
+ if (user) {
+ if (user.memberships.some((m) => m.communityId === community.id)) {
+ redirect(redirectTo ?? `/c/${community.slug}/stages`);
+ // TODO: redirect to wherever they were redirected to before signing up
+ throw new Error("User is already member of community");
+ }
+
+ // TODO: figure this out based on the invite
+ const joinRole = MemberRole.contributor;
+
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+/**
+ * just a wrapper that centers stuff on the page.
+ * could be put in a layout later
+ */
+const Wrapper = ({ children }: { children: React.ReactNode }) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/core/app/c/[communitySlug]/pubs/[pubId]/edit/page.tsx b/core/app/c/[communitySlug]/pubs/[pubId]/edit/page.tsx
index 59f09b4738..8ed5219f2f 100644
--- a/core/app/c/[communitySlug]/pubs/[pubId]/edit/page.tsx
+++ b/core/app/c/[communitySlug]/pubs/[pubId]/edit/page.tsx
@@ -131,6 +131,7 @@ export default async function Page(props: {
>
+ {/** TODO: Add suspense */}
;
+export function BaseSignupForm(props: {
+ user: Pick | null;
+ onSubmit: (data: SignupFormSchema) => Promise;
+ redirectTo?: string;
}) {
- const runSignup = useServerAction(signup);
+ const searchParams = useSearchParams();
+
+ const redirectTo = props.redirectTo ?? searchParams.get("redirectTo");
- const resolver = useMemo(() => typeboxResolver(formSchema), []);
+ const resolver = useMemo(() => typeboxResolver(compiledSignupFormSchema), []);
- const form = useForm>({
+ const form = useForm({
resolver,
- defaultValues: { ...props.user, lastName: props.user.lastName ?? undefined },
+ defaultValues: { ...(props?.user ?? {}), lastName: props.user?.lastName ?? undefined },
});
- const searchParams = useSearchParams();
-
- const handleSubmit = useCallback(async (data: Static) => {
- await runSignup({
- id: props.user.id,
- firstName: data.firstName,
- lastName: data.lastName,
- email: data.email,
- password: data.password,
- redirect: searchParams.get("redirectTo"),
- });
- }, []);
-
return (
{/*
Already have an account?{" "}
@@ -136,6 +131,16 @@ export function SignupForm(props: {
*/}
+
+ Or{" "}
+
+ sign in
+ {" "}
+ if you already have an account
+
diff --git a/core/app/components/Signup/JoinCommunityForm.tsx b/core/app/components/Signup/JoinCommunityForm.tsx
new file mode 100644
index 0000000000..fef1729527
--- /dev/null
+++ b/core/app/components/Signup/JoinCommunityForm.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { useCallback } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useForm } from "react-hook-form";
+
+import type { Communities } from "db/public";
+import { MemberRole } from "db/public";
+import { Button } from "ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "ui/card";
+import { Form } from "ui/form";
+import { toast } from "ui/use-toast";
+
+import { publicJoinCommunity } from "~/lib/authentication/actions";
+import { useServerAction } from "~/lib/serverActions";
+import { FormSubmitButton } from "../SubmitButton";
+
+export const JoinCommunityForm = ({
+ community,
+ role = MemberRole.contributor,
+ redirectTo,
+}: {
+ community: Communities;
+ role?: MemberRole;
+ redirectTo?: string;
+}) => {
+ const form = useForm();
+ const runJoin = useServerAction(publicJoinCommunity);
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const redirectPath = redirectTo ?? searchParams.get("redirectTo") ?? `/c/${community.slug}`;
+
+ const onSubmit = useCallback(async () => {
+ const result = await runJoin();
+ if ("success" in result) {
+ toast({
+ title: "Success",
+ description: result.report,
+ });
+ router.push(redirectPath);
+ }
+ }, [redirectPath, runJoin]);
+
+ return (
+
+
+ );
+};
diff --git a/core/app/components/Signup/LegacySignupForm.tsx b/core/app/components/Signup/LegacySignupForm.tsx
new file mode 100644
index 0000000000..0e71ce71bb
--- /dev/null
+++ b/core/app/components/Signup/LegacySignupForm.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import type { Static } from "@sinclair/typebox";
+
+import { useCallback } from "react";
+import { useSearchParams } from "next/navigation";
+
+import type { Users } from "db/public";
+
+import type { SignupFormSchema } from "./schema";
+import { legacySignup } from "~/lib/authentication/actions";
+import { useServerAction } from "~/lib/serverActions";
+import { BaseSignupForm } from "./BaseSignupForm";
+
+export function LegacySignupForm(props: {
+ user: Pick
;
+}) {
+ const signup = useServerAction(legacySignup);
+ const searchParams = useSearchParams();
+ const onSubmit = useCallback(async (data: SignupFormSchema) => {
+ await signup({
+ id: props.user.id,
+ firstName: data.firstName,
+ lastName: data.lastName,
+ email: data.email,
+ password: data.password,
+ redirect: searchParams.get("redirectTo"),
+ });
+ }, []);
+
+ return ;
+}
diff --git a/core/app/components/Signup/PublicSignupForm.tsx b/core/app/components/Signup/PublicSignupForm.tsx
new file mode 100644
index 0000000000..f281c967ef
--- /dev/null
+++ b/core/app/components/Signup/PublicSignupForm.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import { useCallback } from "react";
+import { useSearchParams } from "next/navigation";
+
+import type { CommunitiesId } from "db/public";
+
+import type { SignupFormSchema } from "./schema";
+import { publicSignup } from "~/lib/authentication/actions";
+import { useServerAction } from "~/lib/serverActions";
+import { BaseSignupForm } from "./BaseSignupForm";
+
+export function PublicSignupForm(props: { communityId: CommunitiesId; redirectTo?: string }) {
+ const runSignup = useServerAction(publicSignup);
+
+ const searchParams = useSearchParams();
+ const redirectTo = props.redirectTo ?? searchParams.get("redirectTo") ?? undefined;
+
+ const handleSubmit = useCallback(
+ async (data: SignupFormSchema) => {
+ // TODO: this is not very nice UX, we should wait a sec and show a loading state
+ await runSignup({
+ firstName: data.firstName,
+ lastName: data.lastName,
+ email: data.email,
+ password: data.password,
+ redirectTo,
+ communityId: props.communityId,
+ });
+ },
+ [redirectTo, runSignup]
+ );
+
+ return ;
+}
diff --git a/core/app/components/Signup/schema.ts b/core/app/components/Signup/schema.ts
new file mode 100644
index 0000000000..937f48d0b6
--- /dev/null
+++ b/core/app/components/Signup/schema.ts
@@ -0,0 +1,21 @@
+import type { Static } from "@sinclair/typebox";
+
+import { Type } from "@sinclair/typebox";
+import { TypeCompiler } from "@sinclair/typebox/compiler";
+import { registerFormats } from "schemas";
+
+registerFormats();
+
+const signupFormSchema = Type.Object({
+ firstName: Type.String(),
+ lastName: Type.String(),
+ email: Type.String({ format: "email" }),
+ password: Type.String({
+ minLength: 8,
+ maxLength: 72,
+ }),
+});
+
+export const compiledSignupFormSchema = TypeCompiler.Compile(signupFormSchema);
+
+export type SignupFormSchema = Static;
diff --git a/core/app/components/SubmitButton.tsx b/core/app/components/SubmitButton.tsx
new file mode 100644
index 0000000000..e6055aba5c
--- /dev/null
+++ b/core/app/components/SubmitButton.tsx
@@ -0,0 +1,183 @@
+import type { FormState } from "react-hook-form";
+
+import { useEffect, useState } from "react";
+import { CheckCircle, Loader2, XCircle } from "lucide-react";
+
+import { Button } from "ui/button";
+import { cn } from "utils";
+
+type ButtonState = "idle" | "loading" | "success" | "error";
+
+type SubmitButtonProps = {
+ // direct control props
+ state?: ButtonState;
+ isSubmitting?: boolean;
+ isSubmitSuccessful?: boolean;
+ isSubmitError?: boolean;
+
+ // form integration
+ formState?: FormState;
+
+ // customization
+ idleText?: string;
+ loadingText?: string;
+ successText?: string;
+ errorText?: string;
+
+ // button props
+ className?: string;
+ onClick?: () => void;
+ type?: "button" | "submit" | "reset";
+};
+
+export const SubmitButton = ({
+ state,
+ isSubmitting,
+ isSubmitSuccessful,
+ isSubmitError,
+ formState,
+ idleText = "Submit",
+ loadingText = "Submitting...",
+ successText = "Success!",
+ errorText = "Error",
+ className = "",
+ onClick,
+ type = "submit",
+}: SubmitButtonProps) => {
+ const [buttonState, setButtonState] = useState("idle");
+ const [errorTimeout, setErrorTimeout] = useState(null);
+
+ useEffect(() => {
+ // determine state based on props
+ if (state) {
+ setButtonState(state);
+ return;
+ }
+
+ if (formState) {
+ if (formState.isSubmitting) {
+ setButtonState("loading");
+ return;
+ }
+
+ if (formState.isSubmitSuccessful) {
+ setButtonState("success");
+ return;
+ }
+
+ if (formState.errors && Object.keys(formState.errors).length > 0) {
+ setButtonState("error");
+
+ // reset error state after 2 seconds
+ if (errorTimeout) clearTimeout(errorTimeout);
+ const timeout = setTimeout(() => setButtonState("idle"), 2000);
+ setErrorTimeout(timeout);
+ return;
+ }
+
+ setButtonState("idle");
+ return;
+ }
+
+ // direct prop control
+ if (isSubmitting) {
+ setButtonState("loading");
+ } else if (isSubmitError) {
+ setButtonState("error");
+
+ // reset error state after 2 seconds
+ if (errorTimeout) clearTimeout(errorTimeout);
+ const timeout = setTimeout(() => setButtonState("idle"), 2000);
+ setErrorTimeout(timeout);
+ } else if (isSubmitSuccessful) {
+ setButtonState("success");
+ } else {
+ setButtonState("idle");
+ }
+ }, [state, formState, isSubmitting, isSubmitSuccessful, isSubmitError]);
+
+ // clean up timeout on unmount
+ useEffect(() => {
+ return () => {
+ if (errorTimeout) clearTimeout(errorTimeout);
+ };
+ }, [errorTimeout]);
+
+ const getButtonText = () => {
+ switch (buttonState) {
+ case "loading":
+ return loadingText;
+ case "success":
+ return successText;
+ case "error":
+ return errorText;
+ default:
+ return idleText;
+ }
+ };
+
+ const getButtonVariant = () => {
+ return buttonState === "error" ? "destructive" : "default";
+ };
+
+ const getButtonIcon = () => {
+ switch (buttonState) {
+ case "loading":
+ return ;
+ case "success":
+ return ;
+ case "error":
+ return ;
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+ {getButtonIcon()}
+ {getButtonText()}
+
+ );
+};
+
+/**
+ * Form submit button that automatically handles loading state
+ */
+export const FormSubmitButton = ({
+ formState,
+ idleText = "Submit",
+ loadingText = "Submitting...",
+ successText = "Success!",
+ errorText = "Error",
+ className = "",
+}: {
+ formState: FormState;
+ /**
+ * Default text.
+ *
+ * @default "Submit"
+ */
+ idleText?: string;
+ loadingText?: string;
+ successText?: string;
+ errorText?: string;
+ className?: string;
+}) => {
+ return (
+
+ );
+};
diff --git a/core/app/components/pubs/PubEditor/actions.ts b/core/app/components/pubs/PubEditor/actions.ts
index da2f31f0b9..17a3e6093f 100644
--- a/core/app/components/pubs/PubEditor/actions.ts
+++ b/core/app/components/pubs/PubEditor/actions.ts
@@ -2,7 +2,7 @@
import type { JsonValue } from "contracts";
import type { PubsId, StagesId, UsersId } from "db/public";
-import { Capabilities, MembershipType } from "db/public";
+import { Capabilities, FormAccessType, MemberRole, MembershipType } from "db/public";
import { logger } from "logger";
import { db } from "~/kysely/database";
@@ -14,14 +14,17 @@ import { createLastModifiedBy } from "~/lib/lastModifiedBy";
import { ApiError, createPubRecursiveNew } from "~/lib/server";
import { findCommunityBySlug } from "~/lib/server/community";
import { defineServerAction } from "~/lib/server/defineServerAction";
-import { addMemberToForm, userHasPermissionToForm } from "~/lib/server/form";
-import { deletePub, normalizePubValues } from "~/lib/server/pub";
+import { addMemberToForm, getForm, userHasPermissionToForm } from "~/lib/server/form";
+import { deletePub, maybeWithTrx, normalizePubValues } from "~/lib/server/pub";
import { PubOp } from "~/lib/server/pub-op";
type CreatePubRecursiveProps = Omit[0], "lastModifiedBy">;
export const createPubRecursive = defineServerAction(async function createPubRecursive(
- props: CreatePubRecursiveProps & { formSlug?: string; addUserToForm?: boolean }
+ props: CreatePubRecursiveProps & {
+ formSlug?: string;
+ addUserToForm?: boolean;
+ }
) {
const { formSlug, addUserToForm, ...createPubProps } = props;
const loginData = await getLoginData();
@@ -31,16 +34,21 @@ export const createPubRecursive = defineServerAction(async function createPubRec
}
const { user } = loginData;
- const canCreatePub = await userCan(
- Capabilities.createPub,
- { type: MembershipType.community, communityId: props.communityId },
- user.id
- );
- const canCreateFromForm = formSlug
- ? await userHasPermissionToForm({ formSlug, userId: loginData.user.id })
- : false;
+ const [form, canCreatePub, canCreateFromForm] = await Promise.all([
+ formSlug
+ ? await getForm({ communityId: props.communityId, slug: formSlug }).executeTakeFirst()
+ : null,
+ userCan(
+ Capabilities.createPub,
+ { type: MembershipType.community, communityId: props.communityId },
+ user.id
+ ),
+ formSlug ? userHasPermissionToForm({ formSlug, userId: loginData.user.id }) : false,
+ ]);
+
+ const isPublicForm = form?.access === FormAccessType.public;
- if (!canCreatePub && !canCreateFromForm) {
+ if (!canCreatePub && !canCreateFromForm && !isPublicForm) {
return ApiError.UNAUTHORIZED;
}
@@ -49,20 +57,38 @@ export const createPubRecursive = defineServerAction(async function createPubRec
});
try {
- const createdPub = await createPubRecursiveNew({ ...createPubProps, lastModifiedBy });
-
- if (addUserToForm && formSlug) {
- await addMemberToForm({
- communityId: props.communityId,
- userId: user.id,
- slug: formSlug,
- pubId: createdPub.id,
+ // need this in order to test it properly
+ const result = await maybeWithTrx(db, async (trx) => {
+ const createdPub = await createPubRecursiveNew({
+ ...createPubProps,
+ body: {
+ ...createPubProps.body,
+ // adds user to the pub
+ // TODO: this should be configured on the form
+ members: { [user.id]: MemberRole.contributor },
+ },
+ lastModifiedBy,
+ trx,
});
- }
- return {
- success: true,
- report: `Successfully created a new Pub`,
- };
+
+ if (addUserToForm && formSlug) {
+ await addMemberToForm(
+ {
+ communityId: props.communityId,
+ userId: user.id,
+ slug: formSlug,
+ pubId: createdPub.id,
+ },
+ trx
+ );
+ }
+ return {
+ success: true,
+ report: `Successfully created a new Pub`,
+ };
+ });
+
+ return result;
} catch (error) {
logger.error(error);
return {
diff --git a/core/lib/__tests__/transactions.ts b/core/lib/__tests__/transactions.ts
new file mode 100644
index 0000000000..abb5d264cf
--- /dev/null
+++ b/core/lib/__tests__/transactions.ts
@@ -0,0 +1,69 @@
+import type { Kysely, Transaction } from "kysely";
+
+import type { PublicSchema } from "db/public";
+
+/**
+ * Taken from https://github.com/kysely-org/kysely/issues/257#issuecomment-1676079354
+ * Workaround until Kysely adds more to their transaction API
+ *
+ * Usage:
+ * const { trx, rollback } = await begin(db);
+ * trx.insertInto(...);
+ * rollback();
+ */
+export async function beginTransaction(db: Kysely) {
+ const connection = new Deferred>();
+ const result = new Deferred();
+
+ // Do NOT await this line.
+ db.transaction()
+ .execute((trx) => {
+ connection.resolve(trx);
+ return result.promise;
+ })
+ .catch((err) => {
+ // Don't do anything here. Just swallow the exception.
+ });
+
+ const trx = await connection.promise;
+
+ return {
+ trx,
+ commit() {
+ result.resolve(null);
+ },
+ rollback() {
+ result.reject(new Error("rollback"));
+ },
+ };
+}
+
+export class Deferred {
+ readonly #promise: Promise;
+
+ #resolve?: (value: T | PromiseLike) => void;
+ #reject?: (reason?: any) => void;
+
+ constructor() {
+ this.#promise = new Promise((resolve, reject) => {
+ this.#reject = reject;
+ this.#resolve = resolve;
+ });
+ }
+
+ get promise(): Promise {
+ return this.#promise;
+ }
+
+ resolve = (value: T | PromiseLike): void => {
+ if (this.#resolve) {
+ this.#resolve(value);
+ }
+ };
+
+ reject = (reason?: any): void => {
+ if (this.#reject) {
+ this.#reject(reason);
+ }
+ };
+}
diff --git a/core/lib/__tests__/utils.ts b/core/lib/__tests__/utils.ts
index 4cb861162b..cd488e106a 100644
--- a/core/lib/__tests__/utils.ts
+++ b/core/lib/__tests__/utils.ts
@@ -1,74 +1,10 @@
-import type { Kysely, Transaction } from "kysely";
+import type { Transaction } from "kysely";
import { afterEach, beforeEach, vi } from "vitest";
import type { PublicSchema } from "db/public";
-/**
- * Taken from https://github.com/kysely-org/kysely/issues/257#issuecomment-1676079354
- * Workaround until Kysely adds more to their transaction API
- *
- * Usage:
- * const { trx, rollback } = await begin(db);
- * trx.insertInto(...);
- * rollback();
- */
-export async function beginTransaction(db: Kysely) {
- const connection = new Deferred>();
- const result = new Deferred();
-
- // Do NOT await this line.
- db.transaction()
- .execute((trx) => {
- connection.resolve(trx);
- return result.promise;
- })
- .catch((err) => {
- // Don't do anything here. Just swallow the exception.
- });
-
- const trx = await connection.promise;
-
- return {
- trx,
- commit() {
- result.resolve(null);
- },
- rollback() {
- result.reject(new Error("rollback"));
- },
- };
-}
-
-export class Deferred {
- readonly #promise: Promise;
-
- #resolve?: (value: T | PromiseLike) => void;
- #reject?: (reason?: any) => void;
-
- constructor() {
- this.#promise = new Promise((resolve, reject) => {
- this.#reject = reject;
- this.#resolve = resolve;
- });
- }
-
- get promise(): Promise {
- return this.#promise;
- }
-
- resolve = (value: T | PromiseLike): void => {
- if (this.#resolve) {
- this.#resolve(value);
- }
- };
-
- reject = (reason?: any): void => {
- if (this.#reject) {
- this.#reject(reason);
- }
- };
-}
+import { beginTransaction } from "./transactions";
export const mockServerCode = async () => {
const { getLoginData, findCommunityBySlug, testDb } = await vi.hoisted(async () => {
diff --git a/core/lib/authentication/actions.ts b/core/lib/authentication/actions.ts
index 0610928432..e7fbcab692 100644
--- a/core/lib/authentication/actions.ts
+++ b/core/lib/authentication/actions.ts
@@ -3,20 +3,36 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { captureException } from "@sentry/nextjs";
+import * as Sentry from "@sentry/nextjs";
import { z } from "zod";
-import type { Communities, CommunityMemberships, Users, UsersId } from "db/public";
-import { AuthTokenType } from "db/public";
+import type { Communities, CommunitiesId, CommunityMemberships, Users, UsersId } from "db/public";
+import { AuthTokenType, MemberRole } from "db/public";
+import { logger } from "logger";
-import type { Prettify } from "../types";
+import type { Prettify, XOR } from "../types";
+import type { SafeUser } from "~/lib/server/user";
+import { compiledSignupFormSchema } from "~/app/components/Signup/schema";
import { db } from "~/kysely/database";
+import { isUniqueConstraintError } from "~/kysely/errors";
import { lucia, validateRequest } from "~/lib/authentication/lucia";
-import { validatePassword } from "~/lib/authentication/password";
+import { createPasswordHash, validatePassword } from "~/lib/authentication/password";
import { defineServerAction } from "~/lib/server/defineServerAction";
-import { getUser, setUserPassword, updateUser } from "~/lib/server/user";
+import {
+ addUser,
+ generateUserSlug,
+ getUser,
+ publicSignupsAllowed,
+ setUserPassword,
+ updateUser,
+} from "~/lib/server/user";
import { LAST_VISITED_COOKIE } from "../../app/components/LastVisitedCommunity/constants";
+import { findCommunityBySlug } from "../server/community";
import * as Email from "../server/email";
+import { insertCommunityMember, selectCommunityMember } from "../server/member";
import { invalidateTokensForUser } from "../server/token";
+import { isClientExceptionOptions } from "../serverActions";
+import { SignupErrors } from "./errors";
import { getLoginData } from "./loginData";
const schema = z.object({
@@ -191,7 +207,206 @@ export const resetPassword = defineServerAction(async function resetPassword({
return { success: true };
});
-export const signup = defineServerAction(async function signup(props: {
+const addUserToCommunity = defineServerAction(async function addUserToCommunity(props: {
+ userId: UsersId;
+ communityId: CommunitiesId;
+ /**
+ * @default MemberRole.contributor
+ */
+ role?: MemberRole;
+}) {
+ const existingMembership = await selectCommunityMember({
+ userId: props.userId,
+ communityId: props.communityId,
+ }).executeTakeFirst();
+
+ if (existingMembership) {
+ return {
+ error: "User already in community",
+ };
+ }
+
+ const newMembership = await insertCommunityMember({
+ userId: props.userId,
+ communityId: props.communityId,
+ role: props.role ?? MemberRole.contributor,
+ }).executeTakeFirstOrThrow();
+
+ return {
+ success: true,
+ report: "User added to community",
+ };
+});
+
+/**
+ * When a user joins a community by signing up
+ */
+export const publicJoinCommunity = defineServerAction(async function joinCommunity() {
+ const [{ user }, community] = await Promise.all([
+ await getLoginData(),
+ await findCommunityBySlug(),
+ ]);
+
+ // TODO: base this off the invite token
+ const toBeGrantedRole = MemberRole.contributor;
+
+ if (!community) {
+ return SignupErrors.COMMUNITY_NOT_FOUND({ communityName: "unknown" });
+ }
+
+ if (!user) {
+ return SignupErrors.NOT_LOGGED_IN({ communityName: community.name });
+ }
+
+ if (user.memberships.some((m) => m.communityId === community.id)) {
+ return SignupErrors.ALREADY_MEMBER({ communityName: community.name });
+ }
+
+ const isAllowedSignup = await publicSignupsAllowed(community.id);
+
+ if (!isAllowedSignup) {
+ return SignupErrors.NOT_ALLOWED({ communityName: community.name });
+ }
+
+ const member = await insertCommunityMember({
+ userId: user.id,
+ communityId: community.id,
+ role: toBeGrantedRole,
+ }).executeTakeFirstOrThrow();
+
+ // don't redirect, better to do it client side, better ux
+ return {
+ success: true,
+ report: `You have joined ${community.name}`,
+ };
+});
+
+export const publicSignup = defineServerAction(async function signup(props: {
+ firstName: string;
+ lastName: string;
+ email: string;
+ password: string;
+ redirectTo?: string;
+ slug?: string;
+ role?: MemberRole;
+ communityId: CommunitiesId;
+}) {
+ const [isAllowedSignup, community, { user }] = await Promise.all([
+ publicSignupsAllowed(props.communityId),
+ findCommunityBySlug(),
+ getLoginData(),
+ ]);
+
+ if (!community) {
+ return SignupErrors.COMMUNITY_NOT_FOUND({ communityName: "unknown" });
+ }
+
+ if (user) {
+ redirect(`/c/${community.slug}/public/join?redirectTo=${props.redirectTo}`);
+ }
+
+ if (!isAllowedSignup) {
+ return SignupErrors.NOT_ALLOWED({ communityName: community.name });
+ }
+
+ const input = {
+ firstName: props.firstName,
+ lastName: props.lastName,
+ email: props.email,
+ password: props.password,
+ };
+
+ const checked = compiledSignupFormSchema.Errors(input);
+ const firstError = checked.First();
+
+ if (firstError) {
+ return {
+ title: "Invalid signup",
+ error: `${firstError.message} for ${firstError.path}`,
+ };
+ }
+
+ const trx = db.transaction();
+
+ const newUser = await trx.execute(async (trx) => {
+ try {
+ const newUser = await addUser(
+ {
+ firstName: props.firstName,
+ lastName: props.lastName,
+ email: props.email,
+ slug:
+ props.slug ??
+ generateUserSlug({ firstName: props.firstName, lastName: props.lastName }),
+ passwordHash: await createPasswordHash(props.password),
+ },
+ trx
+ ).executeTakeFirstOrThrow((err) => {
+ Sentry.captureException(err);
+ return new Error(
+ `Unable to create user for public signup with email ${props.email}`
+ );
+ });
+
+ // TODO: add to community
+ const newMember = await insertCommunityMember(
+ {
+ userId: newUser.id,
+ communityId: community.id,
+ role: props.role ?? MemberRole.contributor,
+ },
+ trx
+ ).executeTakeFirstOrThrow();
+
+ // TODO: send verification email
+ return { ...newUser, needsVerification: false };
+ } catch (e) {
+ if (isUniqueConstraintError(e) && e.table === "users") {
+ return SignupErrors.EMAIL_ALREADY_EXISTS({ email: props.email });
+ }
+ logger.error({ msg: e });
+ Sentry.captureException(e);
+ throw e;
+ }
+ });
+
+ if ("error" in newUser) {
+ return newUser;
+ }
+
+ if ("needsVerification" in newUser && newUser.needsVerification) {
+ return {
+ success: true,
+ report: "Please check your email to verify your account!",
+ needsVerification: true,
+ };
+ }
+
+ // log them in
+
+ // lucia authentication
+ const newSession = await lucia.createSession(newUser.id, { type: AuthTokenType.generic });
+ const newSessionCookie = lucia.createSessionCookie(newSession.id);
+ (await cookies()).set(
+ newSessionCookie.name,
+ newSessionCookie.value,
+ newSessionCookie.attributes
+ );
+
+ if (props.redirectTo) {
+ redirect(props.redirectTo);
+ }
+
+ await redirectUser();
+
+ // typescript cannot sense Promise not returning
+ return "" as never;
+});
+
+/**
+ * flow for when a user has been invited to a community already
+ */
+export const legacySignup = defineServerAction(async function signup(props: {
id: UsersId;
firstName: string;
lastName: string;
@@ -233,8 +448,8 @@ export const signup = defineServerAction(async function signup(props: {
const trx = db.transaction();
- const result = await trx.execute(async (trx) => {
- const newUser = await updateUser(
+ const updatedUser = await trx.execute(async (trx) => {
+ const updatedUser = await updateUser(
{
id: props.id,
firstName: props.firstName,
@@ -252,15 +467,18 @@ export const signup = defineServerAction(async function signup(props: {
trx
);
- if (newUser.email !== user.email) {
- return { ...newUser, needsVerification: true };
+ if (updatedUser.email !== user.email) {
+ return { ...updatedUser, needsVerification: true };
// TODO: send email verification
}
- return newUser;
+ return {
+ ...updatedUser,
+ needsVerification: false,
+ };
});
- if ("needsVerification" in result && result.needsVerification) {
+ if ("needsVerification" in updatedUser && updatedUser.needsVerification) {
return {
success: true,
report: "Please check your email to verify your account!",
@@ -268,15 +486,14 @@ export const signup = defineServerAction(async function signup(props: {
};
}
- // log them in
-
+ // invalidate sessions and tokens
const [invalidatedSessions, invalidatedTokens] = await Promise.all([
- lucia.invalidateUserSessions(user.id),
- invalidateTokensForUser(user.id, [AuthTokenType.signup]),
+ lucia.invalidateUserSessions(updatedUser.id),
+ invalidateTokensForUser(updatedUser.id, [AuthTokenType.signup]),
]);
- // lucia authentication
- const newSession = await lucia.createSession(user.id, { type: AuthTokenType.generic });
+ // log them in
+ const newSession = await lucia.createSession(updatedUser.id, { type: AuthTokenType.generic });
const newSessionCookie = lucia.createSessionCookie(newSession.id);
(await cookies()).set(
newSessionCookie.name,
@@ -289,3 +506,12 @@ export const signup = defineServerAction(async function signup(props: {
}
await redirectUser();
});
+
+export const invitedSignup = defineServerAction(async function signup(props: {
+ id: UsersId;
+ inviteToken: string;
+ firstName: string;
+ lastName: string;
+ email: string;
+ password: string;
+}) {});
diff --git a/core/lib/authentication/errors.ts b/core/lib/authentication/errors.ts
new file mode 100644
index 0000000000..a35d071265
--- /dev/null
+++ b/core/lib/authentication/errors.ts
@@ -0,0 +1,58 @@
+import * as Sentry from "@sentry/nextjs";
+
+import { logger } from "logger";
+
+export const SIGNUP_ERRORS = [
+ "NOT_LOGGED_IN",
+ "ALREADY_MEMBER",
+ "NOT_ALLOWED",
+ "COMMUNITY_NOT_FOUND",
+ "EMAIL_ALREADY_EXISTS",
+] as const;
+export type SIGNUP_ERROR = (typeof SIGNUP_ERRORS)[number];
+
+export const createAndLogError = (
+ error: T,
+ message: string,
+ captureInSentry = false
+) => {
+ logger.debug({
+ msg: "Signup error",
+ error,
+ message,
+ });
+ if (captureInSentry) {
+ Sentry.captureException(new Error(message));
+ }
+
+ return {
+ type: error,
+ error: message,
+ };
+};
+
+export const SignupErrors = {
+ NOT_LOGGED_IN: (props: { communityName: string }) =>
+ createAndLogError("NOT_LOGGED_IN", `You must be logged in to join ${props.communityName}`),
+ ALREADY_MEMBER: (props: { communityName: string }) =>
+ createAndLogError("ALREADY_MEMBER", `You are already a member of ${props.communityName}`),
+ NOT_ALLOWED: (props: { communityName: string }) =>
+ createAndLogError(
+ "NOT_ALLOWED",
+ `Public signups are not allowed for ${props.communityName}`
+ ),
+ COMMUNITY_NOT_FOUND: (props: { communityName: string }) =>
+ createAndLogError("COMMUNITY_NOT_FOUND", `Community not found`),
+ EMAIL_ALREADY_EXISTS: (props: { email: string }) =>
+ createAndLogError("EMAIL_ALREADY_EXISTS", `Email ${props.email} is already taken`),
+} as const satisfies {
+ [E in SIGNUP_ERROR]:
+ | ((props: { communityName: string }) => {
+ type: E;
+ error: string;
+ })
+ | ((props: { email: string }) => {
+ type: E;
+ error: string;
+ });
+};
diff --git a/core/lib/server/form.ts b/core/lib/server/form.ts
index fb296b4f48..629ea7fdca 100644
--- a/core/lib/server/form.ts
+++ b/core/lib/server/form.ts
@@ -5,7 +5,13 @@ import { jsonArrayFrom } from "kysely/helpers/postgres";
import { defaultComponent } from "schemas";
import type { CommunitiesId, FormsId, PublicSchema, PubsId, PubTypesId, UsersId } from "db/public";
-import { AuthTokenType, ElementType, InputComponent, StructuralFormElement } from "db/public";
+import {
+ AuthTokenType,
+ ElementType,
+ formsIdSchema,
+ InputComponent,
+ StructuralFormElement,
+} from "db/public";
import type { XOR } from "../types";
import type { GetPubTypesResult } from "./pubtype";
@@ -116,14 +122,15 @@ export const addMemberToForm = async (
props: { communityId: CommunitiesId; userId: UsersId; pubId: PubsId } & XOR<
{ slug: string },
{ id: FormsId }
- >
+ >,
+ trx = db
) => {
// TODO: Rewrite as single, `autoRevalidate`-d query with CTEs
const { userId, pubId, ...getFormProps } = props;
- const form = await getForm(getFormProps).executeTakeFirstOrThrow();
+ const form = await getForm(getFormProps, trx).executeTakeFirstOrThrow();
const existingPermission = await autoCache(
- db
+ trx
.selectFrom("form_memberships")
.selectAll("form_memberships")
.where("form_memberships.formId", "=", form.id)
@@ -133,7 +140,7 @@ export const addMemberToForm = async (
if (existingPermission === undefined) {
await autoRevalidate(
- db.insertInto("form_memberships").values({ formId: form.id, userId, pubId })
+ trx.insertInto("form_memberships").values({ formId: form.id, userId, pubId })
).execute();
}
};
diff --git a/core/lib/server/pub.ts b/core/lib/server/pub.ts
index cc86f499e2..9b0a146c87 100644
--- a/core/lib/server/pub.ts
+++ b/core/lib/server/pub.ts
@@ -327,7 +327,7 @@ export const createPubRecursiveNew = async ({
@@ -336,6 +336,8 @@ export const createPubRecursiveNew = async {
// `node.attributes` should always be defined for directive nodes
- const attrs = expect(node.attributes, "Invalid syntax in link directive");
+ const attrs = expect(node.attributes, "Invalid syntax in link directive") as LinkOptions;
// All directives are considered parent nodes
assert(isParent(node));
let href: string;
@@ -170,14 +171,14 @@ const visitLinkDirective = (node: Directive, context: utils.RenderWithPubContext
if ("email" in attrs) {
// The `email` attribute must have a value. For example, :link{email=""}
// is invalid.
- let address = expect(attrs.email, 'Unexpected missing value in ":link{email=?}" directive');
+ let email = expect(attrs.email, 'Unexpected missing value in ":link{email=?}" directive');
// If the user defines the recipient as `"assignee"`, the pub must have an
// assignee for the email to be sent.
- href = utils.renderLink(context, { address });
+ href = utils.renderLink(context, { email });
// If the email has no label, default to the email address, e.g.
// :link{email=all@pubpub.org} -> :link[all@pubpub.org]{email=all@pubpub.org}
- if (node.children.length === 0) {
- node.children.push({ type: "text", value: address });
+ if (node.children.length === 0 && attrs.text === undefined) {
+ node.children.push({ type: "text", value: email });
}
}
// :link{form=review}
@@ -189,7 +190,7 @@ const visitLinkDirective = (node: Directive, context: utils.RenderWithPubContext
// :link{to=https://example.com}
else if ("to" in attrs) {
href = utils.renderLink(context, {
- url: expect(attrs.to, 'Unexpected missing value in ":link{to=?}" directive'),
+ to: expect(attrs.to, 'Unexpected missing value in ":link{to=?}" directive'),
});
}
// :link{field=pubpub:url}
@@ -198,6 +199,10 @@ const visitLinkDirective = (node: Directive, context: utils.RenderWithPubContext
field: expect(attrs.field, "Unexpected missing value in ':link{field=?}' directive"),
rel: attrs.rel,
});
+ } else if ("page" in attrs) {
+ href = utils.renderLink(context, {
+ page: expect(attrs.page, "Unexpected missing value in ':link{page=?}' directive"),
+ });
} else {
throw new Error("Invalid link directive");
}
@@ -206,7 +211,7 @@ const visitLinkDirective = (node: Directive, context: utils.RenderWithPubContext
if (node.children.length === 0) {
node.children.push({
type: "text",
- value: href,
+ value: attrs.text ?? href,
});
}
node.data = {
diff --git a/core/lib/server/render/pub/renderWithPubUtils.ts b/core/lib/server/render/pub/renderWithPubUtils.ts
index fdfe3ad269..9a3eb49d67 100644
--- a/core/lib/server/render/pub/renderWithPubUtils.ts
+++ b/core/lib/server/render/pub/renderWithPubUtils.ts
@@ -3,6 +3,7 @@ import { CoreSchemaType } from "db/public";
import { expect } from "utils";
import { db } from "~/kysely/database";
+import { env } from "~/lib/env/env.mjs";
import { autoCache } from "~/lib/server/cache/autoCache";
import { addMemberToForm, createFormInviteLink } from "../../form";
@@ -125,24 +126,38 @@ export const renderMemberFields = async ({
};
type LinkEmailOptions = {
- address: string;
+ email: string;
rel?: string;
+ text?: string;
};
type LinkFormOptions = {
form: string;
+ text?: string;
};
type LinkUrlOptions = {
- url: string;
+ to: string;
+ text?: string;
};
type LinkFieldOptions = {
field: string;
rel?: string;
+ text?: string;
};
-type LinkOptions = LinkEmailOptions | LinkFormOptions | LinkUrlOptions | LinkFieldOptions;
+type LinkPageOptions = {
+ page: string;
+ text?: string;
+};
+
+export type LinkOptions =
+ | LinkEmailOptions
+ | LinkFormOptions
+ | LinkUrlOptions
+ | LinkFieldOptions
+ | LinkPageOptions;
const isLinkEmailOptions = (options: LinkOptions): options is LinkEmailOptions => {
return "address" in options;
@@ -160,10 +175,20 @@ const isLinkFieldOptions = (options: LinkOptions): options is LinkFieldOptions =
return "field" in options;
};
+const isLinkPageOptions = (options: LinkOptions): options is LinkPageOptions => {
+ return "page" in options;
+};
+
+const pageLinkHref = {
+ pubs: () => `/pubs`,
+ currentPub: (context) => `/pubs/${context.pub.id}`,
+ stages: () => `/stages`,
+} as const satisfies Record string>;
+
export const renderLink = (context: RenderWithPubContext, options: LinkOptions) => {
let href: string;
if (isLinkEmailOptions(options)) {
- let to = options.address;
+ let to = options.email;
// If the user defines the recipient as `"assignee"`, the pub must have an
// assignee for the email to be sent.
if (to === "assignee") {
@@ -175,9 +200,21 @@ export const renderLink = (context: RenderWithPubContext, options: LinkOptions)
// Form hrefs are handled by `ensureFormMembershipAndCreateInviteLink`
href = "";
} else if (isLinkUrlOptions(options)) {
- href = options.url;
+ href = options.to;
} else if (isLinkFieldOptions(options)) {
href = getPubValue(context, options.field, options.rel).value as string;
+ } else if (isLinkPageOptions(options)) {
+ const baseUrl = `${env.PUBPUB_URL}/c/${context.communitySlug}`;
+
+ let tempHref = baseUrl;
+
+ if (options.page in pageLinkHref) {
+ tempHref = `${baseUrl}${pageLinkHref[options.page as keyof typeof pageLinkHref](context)}`;
+ } else {
+ tempHref = `${baseUrl}/${options.page}`;
+ }
+
+ href = tempHref;
} else {
throw new Error("Unexpected link variant");
}
diff --git a/core/lib/server/user.ts b/core/lib/server/user.ts
index c0ac53a908..09d45f555f 100644
--- a/core/lib/server/user.ts
+++ b/core/lib/server/user.ts
@@ -1,6 +1,7 @@
import type { SelectExpression, Transaction } from "kysely";
import { cache } from "react";
+import { sql } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/postgres";
import type { Database } from "db/Database";
@@ -14,7 +15,7 @@ import type {
UsersId,
UsersUpdate,
} from "db/public";
-import { Capabilities, MemberRole, MembershipType } from "db/public";
+import { Capabilities, FormAccessType, MemberRole, MembershipType } from "db/public";
import type { CapabilityTarget } from "../authorization/capabilities";
import type { XOR } from "../types";
@@ -206,6 +207,12 @@ export const addUser = (props: NewUsers, trx = db) =>
additionalRevalidateTags: ["all-users"],
});
+export const generateUserSlug = (props: Pick) => {
+ return `${slugifyString(props.firstName)}${
+ props.lastName ? `-${slugifyString(props.lastName)}` : ""
+ }-${generateHash(4, "0123456789")}`;
+};
+
export const createUserWithMembership = async (data: {
firstName: string;
lastName?: string | null;
@@ -343,9 +350,7 @@ export const createUserWithMembership = async (data: {
email,
firstName,
lastName,
- slug: `${slugifyString(firstName)}${
- lastName ? `-${slugifyString(lastName)}` : ""
- }-${generateHash(4, "0123456789")}`,
+ slug: generateUserSlug({ firstName, lastName }),
isSuperAdmin: isSuperAdmin === true,
},
trx
@@ -389,3 +394,19 @@ export const createUserWithMembership = async (data: {
};
}
};
+
+/**
+ * Public signups are allowed if
+ * - there are >1 forms that are public
+ */
+export const publicSignupsAllowed = async (communityId: CommunitiesId) => {
+ const publicForms = await db
+ .selectFrom("forms")
+ .select(sql`1`.as("count"))
+ .where("access", "=", FormAccessType.public)
+ .where("communityId", "=", communityId)
+ .limit(1)
+ .executeTakeFirst();
+
+ return Boolean(publicForms);
+};
diff --git a/core/playwright/fixtures/login-page.ts b/core/playwright/fixtures/login-page.ts
index 25839f00fc..79d252d0a8 100644
--- a/core/playwright/fixtures/login-page.ts
+++ b/core/playwright/fixtures/login-page.ts
@@ -1,5 +1,7 @@
import type { Page } from "@playwright/test";
+import { waitForBaseCommunityPage } from "../helpers";
+
export class LoginPage {
constructor(public readonly page: Page) {}
@@ -15,6 +17,7 @@ export class LoginPage {
async loginAndWaitForNavigation(email: string, password: string) {
await this.login(email, password);
- await this.page.waitForURL(/.*\/c\/.+\/stages.*/);
+ // await this.page.waitForURL(/.*\/c\/.+\/stages.*/);
+ await waitForBaseCommunityPage(this.page);
}
}
diff --git a/core/playwright/formAccess.spec.ts b/core/playwright/formAccess.spec.ts
new file mode 100644
index 0000000000..0d58cffba9
--- /dev/null
+++ b/core/playwright/formAccess.spec.ts
@@ -0,0 +1,441 @@
+import type { Page } from "@playwright/test";
+
+import { faker } from "@faker-js/faker";
+import { expect, test } from "@playwright/test";
+
+import type { FormsId, PubsId, UsersId } from "db/public";
+import { CoreSchemaType, ElementType, FormAccessType, InputComponent, MemberRole } from "db/public";
+
+import type { CommunitySeedOutput } from "~/prisma/seed/createSeed";
+import { db } from "~/lib/__tests__/db";
+import { beginTransaction } from "~/lib/__tests__/transactions";
+import { createSeed } from "~/prisma/seed/createSeed";
+import { seedCommunity } from "~/prisma/seed/seedCommunity";
+import { LoginPage } from "./fixtures/login-page";
+import { PubFieldsOfEachType, waitForBaseCommunityPage } from "./helpers";
+
+test.describe.configure({ mode: "serial" });
+
+let page: Page;
+const jimothyId = crypto.randomUUID() as UsersId;
+const crossUserId = crypto.randomUUID() as UsersId;
+
+const communitySlug = `test-community-${new Date().getTime()}`;
+
+const password = "password";
+const seed = createSeed({
+ community: {
+ name: "test community",
+ slug: communitySlug,
+ },
+ pubFields: {
+ Title: {
+ schemaName: CoreSchemaType.String,
+ },
+ Content: {
+ schemaName: CoreSchemaType.String,
+ },
+ ...PubFieldsOfEachType,
+ },
+ users: {
+ admin: {
+ role: MemberRole.admin,
+ password,
+ },
+ cross: {
+ id: crossUserId,
+ role: MemberRole.admin,
+ password,
+ },
+ baseMember: {
+ role: MemberRole.contributor,
+ password,
+ },
+ },
+ pubTypes: {
+ Submission: {
+ Title: { isTitle: true },
+ Content: { isTitle: false },
+ },
+ Evaluation: {
+ Title: { isTitle: true },
+ Content: { isTitle: false },
+ Email: { isTitle: false },
+ },
+ "Title Only": {
+ Title: { isTitle: true },
+ },
+ },
+ stages: {
+ Evaluating: {},
+ },
+ pubs: [
+ {
+ pubType: "Submission",
+ values: {
+ Title: "The Activity of Snails",
+ },
+ stage: "Evaluating",
+ },
+ {
+ pubType: "Submission",
+ values: {
+ Title: "Do not let anyone edit me",
+ },
+ stage: "Evaluating",
+ },
+ {
+ pubType: "Submission",
+ values: {
+ Title: "I have a title and content",
+ Content: "My content",
+ },
+ stage: "Evaluating",
+ },
+ ],
+ forms: {
+ Evaluation: {
+ slug: "evaluation",
+ access: FormAccessType.public,
+ pubType: "Evaluation",
+ elements: [
+ {
+ type: ElementType.pubfield,
+ field: "Title",
+ component: InputComponent.textInput,
+ config: {
+ label: "Title",
+ },
+ },
+ {
+ type: ElementType.pubfield,
+ field: "Content",
+ component: InputComponent.textArea,
+ config: {
+ label: "Content",
+ },
+ },
+ {
+ type: ElementType.pubfield,
+ field: CoreSchemaType.Email,
+ component: InputComponent.textInput,
+ config: {
+ label: "Email",
+ },
+ },
+ {
+ type: ElementType.button,
+ content: `Go see your pubs :link{page='currentPub' text='here'}`,
+ label: "Submit",
+ stage: "Evaluating",
+ },
+ ],
+ },
+ "Title Only (default)": {
+ slug: "title-only-default",
+ pubType: "Title Only",
+ elements: [
+ {
+ type: ElementType.pubfield,
+ field: "Title",
+ component: InputComponent.textInput,
+ config: {
+ label: "Title",
+ },
+ },
+ ],
+ },
+ },
+});
+
+const formIdThatllSneakilyBeSwitchedToPublic = crypto.randomUUID() as FormsId;
+
+const seed2 = createSeed({
+ community: {
+ name: "test community 2",
+ slug: "test-community-2",
+ },
+ pubFields: {
+ Title: {
+ schemaName: CoreSchemaType.String,
+ },
+ },
+ pubTypes: {
+ Submission: {
+ Title: { isTitle: true },
+ },
+ },
+ users: {
+ jimothy: {
+ id: jimothyId,
+ role: MemberRole.editor,
+ password,
+ },
+ becky: {
+ role: MemberRole.editor,
+ password,
+ },
+ cross: {
+ id: crossUserId,
+ existing: true,
+ role: MemberRole.admin,
+ password,
+ },
+ },
+ forms: {
+ "Simple Private": {
+ id: formIdThatllSneakilyBeSwitchedToPublic,
+ slug: "simple-private",
+ pubType: "Submission",
+ access: FormAccessType.private,
+ elements: [
+ {
+ type: ElementType.pubfield,
+ field: "Title",
+ component: InputComponent.textInput,
+ config: {
+ label: "Title",
+ },
+ },
+ ],
+ },
+ },
+});
+
+let community: CommunitySeedOutput;
+let community2: CommunitySeedOutput;
+
+test.beforeAll(async ({ browser }) => {
+ community = await seedCommunity(seed);
+ community2 = await seedCommunity(seed2);
+
+ page = await browser.newPage();
+});
+
+test.describe("public signup cases", () => {
+ test.describe("single community cases", () => {
+ test("non-users are not able to signup for communities with private forms", async ({
+ page,
+ }) => {
+ const response = await page.goto(`/c/${community2.community.slug}/public/signup`);
+ expect(response?.status()).toBe(404);
+ });
+
+ test("non-users are able to access the public signup page for communities with public forms", async ({
+ page,
+ }) => {
+ await page.goto(`/c/${community.community.slug}/public/signup`);
+ await expect(page.getByRole("heading", { name: "Sign up" })).toBeVisible();
+ });
+
+ test("non-users are redirected to the signup page for public forms", async ({ page }) => {
+ const fillUrl = `/c/${community.community.slug}/public/forms/${community.forms.Evaluation.slug}/fill`;
+
+ const res = await page.goto(fillUrl);
+ await page.waitForURL(
+ `/c/${community.community.slug}/public/signup?redirectTo=${fillUrl}`,
+ { timeout: 10_000 }
+ );
+ });
+
+ test("signed in community members should be redirected to base community page instead if no redirect is set", async ({
+ page,
+ }) => {
+ const loginPage = new LoginPage(page);
+ await loginPage.goto();
+ await loginPage.loginAndWaitForNavigation(community.users.baseMember.email, password);
+
+ const res = await page.goto(`/c/${community.community.slug}/public/signup`);
+ await waitForBaseCommunityPage(page, community.community.slug);
+ });
+ });
+});
+
+test.describe("public forms", () => {
+ test("non-users are able to signup for communityies and fill out public forms", async ({
+ page,
+ }) => {
+ const fillUrl = `/c/${community.community.slug}/public/forms/${community.forms.Evaluation.slug}/fill`;
+ await test.step("non-users are able to access the public form", async () => {
+ await page.goto(fillUrl);
+ await page.waitForURL(
+ `/c/${community.community.slug}/public/signup?redirectTo=${fillUrl}`
+ );
+ await page.getByRole("heading", { name: "Sign up" }).waitFor({ state: "visible" });
+ });
+
+ const testEmail = faker.internet.email();
+ await test.step("non-users are able to signup for the community", async () => {
+ await page.getByLabel("Email").fill(testEmail, {
+ timeout: 1_000,
+ });
+ await page.getByLabel("Password").fill(password);
+ await page.getByLabel("First Name").fill(faker.person.firstName());
+ await page.getByLabel("Last Name").fill(faker.person.lastName());
+ await page.getByRole("button", { name: "Sign up" }).click();
+ await page.waitForURL(fillUrl, { timeout: 10_000 });
+ });
+
+ let pubId: PubsId;
+ await test.step("non-users are able to fill out the form", async () => {
+ await page.getByLabel("Title").fill("Test Title");
+ await page.getByLabel("Content").fill("Test Content");
+ await page.getByRole("button", { name: "Submit" }).click();
+ const submissionMessage = await page.getByText("Go see you").textContent({
+ timeout: 1_000,
+ });
+ pubId = new URL(page.url()).searchParams.get("pubId") as PubsId;
+ });
+
+ await test.step("user should be able to access their pub through a link on the post submission message", async () => {
+ await page.getByRole("link", { name: "here" }).click();
+ await page.waitForURL(`**/pubs/${pubId}`, { timeout: 10_000 });
+ });
+
+ await test.step("user should have been added as a contributor to the pub", async () => {
+ const allTestEmails = await page.getByText(testEmail).all();
+ expect(
+ allTestEmails.length,
+ "Expected 2 emails on the page, one for the user thingy and one for the contributor membership"
+ ).toBe(2);
+
+ await page
+ .getByText("contributor", {
+ exact: true,
+ })
+ .waitFor({ state: "visible", timeout: 1_000 });
+ });
+ });
+
+ test("users from a different community are able to fill out a public form", async ({
+ page,
+ }) => {
+ await test.step("login as a user from a different community", async () => {
+ const loginPage = new LoginPage(page);
+ await loginPage.goto();
+ await loginPage.loginAndWaitForNavigation(community2.users.jimothy.email, password);
+ });
+
+ const fillUrl = `/c/${community.community.slug}/public/forms/${community.forms.Evaluation.slug}/fill`;
+ await test.step("user should be shown the join form", async () => {
+ await page.goto(fillUrl);
+ await page.waitForTimeout(1_000);
+ await page.waitForURL(
+ `/c/${community.community.slug}/public/signup?redirectTo=${fillUrl}`,
+ { timeout: 10_000 }
+ );
+ await page.getByRole("heading", { name: "Join" }).waitFor({ state: "visible" });
+ });
+
+ await test.step("user is able to instantly join the community and is redirected to the form", async () => {
+ await page.getByRole("button", { name: `Join ${community.community.name}` }).click();
+ await page
+ .getByText(`You have joined ${community.community.name}`, { exact: true })
+ .waitFor({
+ state: "visible",
+ timeout: 10_000,
+ });
+ await page.waitForURL(fillUrl, { timeout: 10_000 });
+ });
+ });
+});
+
+test.describe("public signup error cases", () => {
+ test("should show error toast when trying to signup with existing email", async ({ page }) => {
+ const fillUrl = `/c/${community.community.slug}/public/forms/${community.forms.Evaluation.slug}/fill`;
+ const existingEmail = community.users.baseMember.email;
+
+ await test.step("attempt signup with existing email", async () => {
+ await page.goto(fillUrl);
+ await page.getByLabel("Email").fill(existingEmail);
+ await page.getByLabel("Password").fill(password);
+ await page.getByLabel("First Name").fill(faker.person.firstName());
+ await page.getByLabel("Last Name").fill(faker.person.lastName());
+ await page.getByRole("button", { name: "Sign up" }).click();
+
+ // error should be shown in toast
+ const text = `Email ${existingEmail} is already taken`;
+ await page
+ .getByText(text, { exact: true })
+ .waitFor({ state: "visible", timeout: 2_000 });
+ // should stay on signup page
+ await expect(page).toHaveURL(
+ new RegExp(`/c/${community.community.slug}/public/signup`)
+ );
+ });
+ });
+
+ test("should show error toast when trying to join community that disables public signup", async ({
+ page,
+ }) => {
+ const setFormAccess = async (formId: FormsId, access: FormAccessType) => {
+ await db
+ .updateTable("forms")
+ .set({
+ access,
+ })
+ .where("id", "=", formId)
+ .execute();
+ };
+
+ try {
+ await setFormAccess(community2.forms["Simple Private"].id, FormAccessType.public);
+
+ // try to join community that has disabled public signups
+ const privateFormUrl = `/c/${community2.community.slug}/public/forms/${community2.forms["Simple Private"].slug}/fill`;
+
+ await test.step("attempt to join private community", async () => {
+ await page.goto(privateFormUrl);
+
+ await page.goto(privateFormUrl);
+ await page.getByLabel("Email").fill(faker.internet.email());
+ await page.getByLabel("Password").fill(password);
+ await page.getByLabel("First Name").fill(faker.person.firstName());
+ await page.getByLabel("Last Name").fill(faker.person.lastName());
+
+ // now set form to private again
+ await setFormAccess(community2.forms["Simple Private"].id, FormAccessType.private);
+
+ await page.getByRole("button", { name: "Sign up" }).click();
+
+ // error should be shown in toast
+ await page
+ .getByText(`Public signups are not allowed for ${community2.community.name}`, {
+ exact: true,
+ })
+ .waitFor({ state: "visible", timeout: 2_000 });
+ // should stay on join page
+ await expect(page).toHaveURL(
+ new RegExp(`/c/${community2.community.slug}/public/signup`)
+ );
+ });
+ } finally {
+ // "Rollback"
+ await setFormAccess(community2.forms["Simple Private"].id, FormAccessType.public);
+ }
+ });
+
+ test("person should be able to go to login page and then be redirected to the form", async ({
+ page,
+ }) => {
+ const fillUrl = `/c/${community.community.slug}/public/forms/${community.forms.Evaluation.slug}/fill`;
+ await test.step("go to form page and be redirected to signup page", async () => {
+ await page.goto(fillUrl);
+ await page.waitForURL(
+ `/c/${community.community.slug}/public/signup?redirectTo=${fillUrl}`
+ );
+ });
+
+ await test.step("click sign in button and be redirected login page", async () => {
+ await page.getByRole("link", { name: "sign in" }).click();
+ await page.waitForURL(`/login?redirectTo=${fillUrl}`);
+ });
+
+ await test.step("sign in and be redirected to the form", async () => {
+ await page.getByLabel("Email").fill(community.users.baseMember.email);
+ await page.getByLabel("Password").fill(password);
+ await page.getByRole("button", { name: "Sign in" }).click();
+ await page.waitForURL(fillUrl);
+ });
+ });
+});
diff --git a/core/playwright/helpers.ts b/core/playwright/helpers.ts
index 3fd02795b6..0b9b58ea21 100644
--- a/core/playwright/helpers.ts
+++ b/core/playwright/helpers.ts
@@ -99,3 +99,9 @@ export const PubFieldsOfEachType = Object.fromEntries(
},
])
) as Record;
+
+export const waitForBaseCommunityPage = async (page: Page, communitySlug?: string) => {
+ await page.waitForURL(new RegExp(`.*/c/${communitySlug ?? ".*"}/stages.*`), {
+ timeout: 5_000,
+ });
+};
diff --git a/core/prisma/exampleCommunitySeeds/croccroc.ts b/core/prisma/exampleCommunitySeeds/croccroc.ts
index 19fc10ed81..a3c0522547 100644
--- a/core/prisma/exampleCommunitySeeds/croccroc.ts
+++ b/core/prisma/exampleCommunitySeeds/croccroc.ts
@@ -4,6 +4,7 @@ import {
CoreSchemaType,
ElementType,
Event,
+ FormAccessType,
InputComponent,
MemberRole,
StructuralFormElement,
@@ -117,6 +118,7 @@ export async function seedCroccroc(communityId?: CommunitiesId) {
],
forms: {
Review: {
+ access: FormAccessType.public,
pubType: "Evaluation",
elements: [
{
@@ -153,6 +155,12 @@ export async function seedCroccroc(communityId?: CommunitiesId) {
help: "Please attach the file for your review here.",
},
},
+ {
+ type: ElementType.button,
+ content: `Go see your pubs :link{page='currentPub' text='here'}`,
+ label: "Submit",
+ stage: "Under Evaluation",
+ },
],
},
},
diff --git a/core/prisma/migrations/20250327190100_remove_invite_only_form_access_option/migration.sql b/core/prisma/migrations/20250327190100_remove_invite_only_form_access_option/migration.sql
new file mode 100644
index 0000000000..bd7ff81b1a
--- /dev/null
+++ b/core/prisma/migrations/20250327190100_remove_invite_only_form_access_option/migration.sql
@@ -0,0 +1,19 @@
+/*
+ Warnings:
+
+ - The values [inviteOnly] on the enum `FormAccessType` will be removed. If these variants are still used in the database, this will fail.
+
+*/
+-- AlterEnum
+BEGIN;
+
+UPDATE "forms" SET "access" = 'private' WHERE "access" = 'inviteOnly';
+
+CREATE TYPE "FormAccessType_new" AS ENUM ('private', 'public');
+ALTER TABLE "forms" ALTER COLUMN "access" DROP DEFAULT;
+ALTER TABLE "forms" ALTER COLUMN "access" TYPE "FormAccessType_new" USING ("access"::text::"FormAccessType_new");
+ALTER TYPE "FormAccessType" RENAME TO "FormAccessType_old";
+ALTER TYPE "FormAccessType_new" RENAME TO "FormAccessType";
+DROP TYPE "FormAccessType_old";
+ALTER TABLE "forms" ALTER COLUMN "access" SET DEFAULT 'private';
+COMMIT;
diff --git a/core/prisma/migrations/20250401134158_update_comments/migration.sql b/core/prisma/migrations/20250401134158_update_comments/migration.sql
new file mode 100644
index 0000000000..7d5a886990
--- /dev/null
+++ b/core/prisma/migrations/20250401134158_update_comments/migration.sql
@@ -0,0 +1,194 @@
+-- generator-version: 1.0.0
+
+-- Model pub_values_history comments
+
+
+
+-- Model users comments
+
+
+
+-- Model sessions comments
+
+COMMENT ON COLUMN "sessions"."type" IS 'With what type of token is this session created? Used for determining on a page-by-page basis whether to allow a certain session to access it. For instance, a verify email token/session should not allow you to access the password reset page.';
+
+
+-- Model auth_tokens comments
+
+
+
+-- Model communities comments
+
+
+
+-- Model pubs comments
+
+
+
+-- Model pub_fields comments
+
+
+
+-- Model PubFieldSchema comments
+
+COMMENT ON COLUMN "PubFieldSchema"."schema" IS '@type(JSONSchemaType, ''ajv'', true, false, true)';
+
+
+-- Model pub_values comments
+
+COMMENT ON COLUMN "pub_values"."lastModifiedBy" IS '@type(LastModifiedBy, ''../types'', true, false, true)';
+
+
+-- Model pub_types comments
+
+
+
+-- Model _PubFieldToPubType comments
+
+
+
+-- Model stages comments
+
+
+
+-- Model PubsInStages comments
+
+
+
+-- Model move_constraint comments
+
+
+
+-- Model member_groups comments
+
+
+
+-- Model community_memberships comments
+
+
+
+-- Model pub_memberships comments
+
+
+
+-- Model stage_memberships comments
+
+
+
+-- Model form_memberships comments
+
+
+
+-- Model action_instances comments
+
+
+
+-- Model action_runs comments
+
+
+
+-- Model rules comments
+
+
+
+-- Model forms comments
+
+
+
+-- Model form_elements comments
+
+
+
+-- Model api_access_tokens comments
+
+
+
+-- Model api_access_logs comments
+
+
+
+-- Model api_access_permissions comments
+
+COMMENT ON COLUMN "api_access_permissions"."constraints" IS '@type(ApiAccessPermissionConstraints, ''../types'', true, false, true)';
+
+
+-- Model membership_capabilities comments
+
+
+
+-- Enum AuthTokenType comments
+
+COMMENT ON TYPE "AuthTokenType" IS '@property generic - For most use-cases. This will just authenticate you with a regular session.
+@property passwordReset - For resetting your password only
+@property signup - For signing up, but also when you''re invited to a community
+@property verifyEmail - For verifying your email address';
+
+
+-- Enum CoreSchemaType comments
+
+
+
+
+-- Enum OperationType comments
+
+
+
+
+-- Enum MemberRole comments
+
+
+
+
+-- Enum Action comments
+
+
+
+
+-- Enum ActionRunStatus comments
+
+
+
+
+-- Enum Event comments
+
+
+
+
+-- Enum FormAccessType comments
+
+
+
+
+-- Enum StructuralFormElement comments
+
+
+
+
+-- Enum ElementType comments
+
+
+
+
+-- Enum InputComponent comments
+
+
+
+
+-- Enum ApiAccessType comments
+
+
+
+
+-- Enum ApiAccessScope comments
+
+
+
+
+-- Enum Capabilities comments
+
+
+
+
+-- Enum MembershipType comments
+
+
diff --git a/core/prisma/schema/schema.dbml b/core/prisma/schema/schema.dbml
index 1827c5bfcd..9e01791868 100644
--- a/core/prisma/schema/schema.dbml
+++ b/core/prisma/schema/schema.dbml
@@ -515,7 +515,6 @@ Enum Event {
Enum FormAccessType {
private
- inviteOnly
public
}
diff --git a/core/prisma/schema/schema.prisma b/core/prisma/schema/schema.prisma
index d1c46b7fdc..d65236d1ca 100644
--- a/core/prisma/schema/schema.prisma
+++ b/core/prisma/schema/schema.prisma
@@ -6,14 +6,10 @@ datasource db {
url = env("DATABASE_URL")
}
-generator client {
- provider = "prisma-client-js"
- previewFeatures = ["fullTextSearch", "tracing", "omitApi", "prismaSchemaFolder"]
-}
-
generator dbml {
- provider = "prisma-dbml-generator"
- output = "."
+ provider = "prisma-dbml-generator"
+ previewFeatures = ["prismaSchemaFolder"]
+ output = "."
}
generator comments {
@@ -444,7 +440,7 @@ model ActionRun {
sourceActionRun ActionRun? @relation("source_action_run", fields: [sourceActionRunId], references: [id], onDelete: SetNull)
// action runs that were triggered by this action run
- sequentialActionRuns ActionRun[] @relation("source_action_run")
+ sequentialActionRuns ActionRun[] @relation("source_action_run")
@@map(name: "action_runs")
}
@@ -480,7 +476,6 @@ enum Event {
enum FormAccessType {
private
- inviteOnly
public
}
diff --git a/core/tsconfig.json b/core/tsconfig.json
index 3855c7d4fb..e127230eba 100644
--- a/core/tsconfig.json
+++ b/core/tsconfig.json
@@ -5,6 +5,8 @@
"allowImportingTsExtensions": true,
"noErrorTruncation": true,
"allowSyntheticDefaultImports": true,
+ // next just straight up does not let you not use their stupid plugin
+ // see docs/development/common-issues.mdx for more
"plugins": [
{
"name": "next"
@@ -17,11 +19,6 @@
"strictNullChecks": true,
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
- "ts-node": {
- "compilerOptions": {
- "module": "CommonJS"
- }
- },
"include": [
"next-env.d.ts",
"**/*.ts",
diff --git a/docs/content/development/common-issues.mdx b/docs/content/development/common-issues.mdx
new file mode 100644
index 0000000000..c39662e4af
--- /dev/null
+++ b/docs/content/development/common-issues.mdx
@@ -0,0 +1,43 @@
+# Common issues
+
+## Hard to explain "Foregin key constraints failed" errors
+
+This might be a caching issue. Did you do a `pnpm reset` when the dev server was running?
+Try quitting the dev server, running `pnpm --filter core clear-cache` and then restarting the dev server.
+
+This should be a dev only error, although it might also appear when build previews are deployed (as `pnpm reset` is currently (March 25, 2025) run during the build).
+
+## Typescript dev server keeps crashing
+
+This might be due to extremely large types, but it's likely due to a bug in another VS Code extension or a bug in a Typescript extension.
+
+### `next` typescript plugin
+
+Most often, this is due to the `next` typescript plugin. Sadly, `next` does not want to disable auto adding this plugin to `core/tsconfig.json`, see https://github.com/vercel/next.js/discussions/39942.
+
+So either, we patch `next` to not do this, which can accomplished by doing something like this
+
+```diff filename=node_modules/.pnpm/next@.../node_modules/next/dist/lib/typescript/writeConfigurationDefaults.js:314
+
+ if (suggestedActions.length < 1 && requiredActions.length < 1) {
+ return;
+ }
+- await _fs.promises.writeFile(tsConfigPath, _commentjson.stringify(userTsConfig, null, 2) + _os.default.EOL);
++ // await _fs.promises.writeFile(tsConfigPath, _commentjson.stringify(userTsConfig, null, 2) + _os.default.EOL);
+ _log.info('');
+ if (isFirstTimeSetup) {
+ _log.info(`We detected TypeScript in your project and created a ${(0, _picocolors.cyan)('tsconfig.json')} file for you.`);
+```
+
+You might also need to do the same in `node_modules/.pnpm/next@.../node_modules/next/dist/esm/lib/typescript/writeConfigurationDefaults.js` (the ESM version).
+
+Another solution is to use the VS Code version of Typescript rather than the workspace version. Do `CMD + SHIFT + P` and then `> Typescript: Select TypeScript version` and then select `Use VS Code Version`. Make sure they are both using the same version.
+
+### Rogue VS Code extensions
+
+Some common VS Code extensions can also cause issues.
+
+Ones I've observed to cause issues are:
+
+- Astro plugin
+- `better-ts-errors`
diff --git a/packages/db/src/public/FormAccessType.ts b/packages/db/src/public/FormAccessType.ts
index d3c48e29d0..0307299886 100644
--- a/packages/db/src/public/FormAccessType.ts
+++ b/packages/db/src/public/FormAccessType.ts
@@ -6,7 +6,6 @@ import { z } from "zod";
/** Represents the enum public.FormAccessType */
export enum FormAccessType {
private = "private",
- inviteOnly = "inviteOnly",
public = "public",
}