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 (
- + Sign Up @@ -74,8 +60,9 @@ export function SignupForm(props: { render={({ field }) => ( First name - - + + + )} @@ -86,7 +73,9 @@ export function SignupForm(props: { render={({ field }) => ( Last name - + + + )} @@ -102,12 +91,14 @@ export function SignupForm(props: { If you change this, we will ask you to confirm your email again. - + + + )} @@ -119,15 +110,19 @@ export function SignupForm(props: { render={({ field }) => ( Password - + + + )} /> - +
{/*
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 ( +
+ + + + Join {community.name} + + Join {community.name} as a {role} + + + +
+ +
+
+
+
+ + ); +}; 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 ( + + ); +}; + +/** + * 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", }