From 1889e7db6ba5c2ab9c01f8cdfec2c20196a636c3 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 18 Mar 2025 18:19:36 +0100 Subject: [PATCH 01/43] first bad pass --- core/app/(user)/signup/page.tsx | 2 +- .../(public)/[communitySlug]/signup/page.tsx | 17 ++ .../Signup}/SignupForm.tsx | 10 +- core/lib/authentication/actions.ts | 219 ++++++++++++++++-- core/lib/server/user.ts | 29 ++- packages/db/src/public/PublicSchema.ts | 44 ++-- 6 files changed, 271 insertions(+), 50 deletions(-) create mode 100644 core/app/c/(public)/[communitySlug]/signup/page.tsx rename core/app/{(user)/signup => components/Signup}/SignupForm.tsx (92%) diff --git a/core/app/(user)/signup/page.tsx b/core/app/(user)/signup/page.tsx index a63913b1e6..03c83fc084 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 { SignupForm } from "../../components/Signup/SignupForm"; export default async function Page() { const { user, session } = await getLoginData({ diff --git a/core/app/c/(public)/[communitySlug]/signup/page.tsx b/core/app/c/(public)/[communitySlug]/signup/page.tsx new file mode 100644 index 0000000000..648647491a --- /dev/null +++ b/core/app/c/(public)/[communitySlug]/signup/page.tsx @@ -0,0 +1,17 @@ +import { AuthTokenType } from "db/public"; + +import { SignupForm } from "~/app/components/SignUp/SignupForm"; +import { getLoginData } from "~/lib/authentication/loginData"; + +export default async function Page() { + const { user, session } = await getLoginData({ + allowedSessions: [AuthTokenType.signup], + }); + console.log("ignup page"); + + return ( +
+ +
+ ); +} diff --git a/core/app/(user)/signup/SignupForm.tsx b/core/app/components/Signup/SignupForm.tsx similarity index 92% rename from core/app/(user)/signup/SignupForm.tsx rename to core/app/components/Signup/SignupForm.tsx index 1525e2dc09..07eae84136 100644 --- a/core/app/(user)/signup/SignupForm.tsx +++ b/core/app/components/Signup/SignupForm.tsx @@ -15,7 +15,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "ui/ca import { Form, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "ui/form"; import { Input } from "ui/input"; -import { signup } from "~/lib/authentication/actions"; +import { publicSignup } from "~/lib/authentication/actions"; import { isClientException, useServerAction } from "~/lib/serverActions"; registerFormats(); @@ -31,22 +31,22 @@ const formSchema = Type.Object({ }); export function SignupForm(props: { - user: Pick; + user: Pick | null; }) { - const runSignup = useServerAction(signup); + const runSignup = useServerAction(publicSignup); const resolver = useMemo(() => typeboxResolver(formSchema), []); 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, + id: props.user?.id ?? "", firstName: data.firstName, lastName: data.lastName, email: data.email, diff --git a/core/lib/authentication/actions.ts b/core/lib/authentication/actions.ts index 0610928432..109ee53574 100644 --- a/core/lib/authentication/actions.ts +++ b/core/lib/authentication/actions.ts @@ -3,19 +3,22 @@ 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 type { Prettify } from "../types"; +import type { Prettify, XOR } from "../types"; +import type { SafeUser } from "~/lib/server/user"; import { db } from "~/kysely/database"; 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, setUserPassword, updateUser } from "~/lib/server/user"; import { LAST_VISITED_COOKIE } from "../../app/components/LastVisitedCommunity/constants"; import * as Email from "../server/email"; +import { insertCommunityMember, selectCommunityMember } from "../server/member"; import { invalidateTokensForUser } from "../server/token"; import { getLoginData } from "./loginData"; @@ -191,7 +194,185 @@ 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", + }; +}); + +export const publicSignup = defineServerAction(async function signup( + props: { + firstName: string; + lastName: string; + email: string; + password: string; + redirect: string | null; + slug?: string; + role?: MemberRole; + } & XOR< + { + communityId: CommunitiesId; + }, + { + id: UsersId; + } + > +) { + const { user, session } = await getLoginData({ + allowedSessions: [AuthTokenType.signup], + }); + + if (!user && !props.allowUserCreation) { + captureException(new Error("User tried to signup without existing"), { + user: { + id: props.id, + firstName: props.firstName, + lastName: props.lastName, + email: props.email, + }, + }); + return { + error: "Something went wrong. Please try again later.", + }; + } + + if (user && user.id !== props.id) { + captureException(new Error("User tried to signup with a different id"), { + user: { + id: props.id, + firstName: props.firstName, + lastName: props.lastName, + email: props.email, + }, + }); + return { + error: "Something went wrong. Please try again later.", + }; + } + + const trx = db.transaction(); + + const newUser = await trx.execute(async (trx) => { + if (props.communityId !== undefined) { + 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 ${props.id}`); + }); + + // TODO: add to community + await addUserToCommunity({ + userId: newUser.id, + communityId: props.communityId, + role: props.role ?? MemberRole.contributor, + }); + + // TODO: send verification email + return { ...newUser, needsVerification: false }; + } + + if (!user) { + throw new Error("Something went wrong. Expected user to exist"); + } + + const updatedUser = await updateUser( + { + id: props.id, + firstName: props.firstName, + lastName: props.lastName, + email: props.email, + }, + trx + ); + + await setUserPassword( + { + userId: props.id, + password: props.password, + }, + trx + ); + + if (updatedUser.email !== user.email) { + return { ...updatedUser, needsVerification: true }; + // TODO: send email verification + } + + return { + ...updatedUser, + needsVerification: false, + }; + }); + + if ("needsVerification" in newUser && newUser.needsVerification) { + return { + success: true, + report: "Please check your email to verify your account!", + needsVerification: true, + }; + } + + // log them in + + const [invalidatedSessions, invalidatedTokens] = await Promise.all([ + lucia.invalidateUserSessions(newUser.id), + invalidateTokensForUser(newUser.id, [AuthTokenType.signup]), + ]); + + // 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.redirect) { + redirect(props.redirect); + } + await redirectUser(); +}); + +/** + * flow for when a user has been invited to a community already + */ +export const invitedSignup = defineServerAction(async function signup(props: { id: UsersId; firstName: string; lastName: string; @@ -233,8 +414,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 +433,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 +452,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, 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/packages/db/src/public/PublicSchema.ts b/packages/db/src/public/PublicSchema.ts index 62eb7d16d4..9be2ccf724 100644 --- a/packages/db/src/public/PublicSchema.ts +++ b/packages/db/src/public/PublicSchema.ts @@ -33,28 +33,6 @@ import type { StagesTable } from "./Stages"; import type { UsersTable } from "./Users"; export interface PublicSchema { - _prisma_migrations: PrismaMigrationsTable; - - users: UsersTable; - - pubs: PubsTable; - - pub_types: PubTypesTable; - - stages: StagesTable; - - member_groups: MemberGroupsTable; - - communities: CommunitiesTable; - - move_constraint: MoveConstraintTable; - - pub_fields: PubFieldsTable; - - pub_values: PubValuesTable; - - _PubFieldToPubType: PubFieldToPubTypeTable; - _MemberGroupToUser: MemberGroupToUserTable; auth_tokens: AuthTokensTable; @@ -92,4 +70,26 @@ export interface PublicSchema { membership_capabilities: MembershipCapabilitiesTable; pub_values_history: PubValuesHistoryTable; + + _prisma_migrations: PrismaMigrationsTable; + + users: UsersTable; + + pubs: PubsTable; + + pub_types: PubTypesTable; + + stages: StagesTable; + + member_groups: MemberGroupsTable; + + communities: CommunitiesTable; + + move_constraint: MoveConstraintTable; + + pub_fields: PubFieldsTable; + + pub_values: PubValuesTable; + + _PubFieldToPubType: PubFieldToPubTypeTable; } From 827ca4cb9eb8d5b8feff7d4a5fa2b53c8b94fcb3 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 20 Mar 2025 14:54:24 +0100 Subject: [PATCH 02/43] feat: add invite table --- .../20250320132732_add_invites/migration.sql | 76 +++++++ .../migration.sql | 198 ++++++++++++++++++ core/prisma/schema/comments/.comments-lock | 4 + core/prisma/schema/schema.dbml | 55 ++++- core/prisma/schema/schema.prisma | 61 +++++- packages/db/src/public/Invites.ts | 154 ++++++++++++++ packages/db/src/public/PublicSchema.ts | 59 +++--- packages/db/src/table-names.ts | 184 ++++++++++++++++ 8 files changed, 755 insertions(+), 36 deletions(-) create mode 100644 core/prisma/migrations/20250320132732_add_invites/migration.sql create mode 100644 core/prisma/migrations/20250320132733_update_comments/migration.sql create mode 100644 packages/db/src/public/Invites.ts diff --git a/core/prisma/migrations/20250320132732_add_invites/migration.sql b/core/prisma/migrations/20250320132732_add_invites/migration.sql new file mode 100644 index 0000000000..69cae44e1d --- /dev/null +++ b/core/prisma/migrations/20250320132732_add_invites/migration.sql @@ -0,0 +1,76 @@ +-- CreateTable +CREATE TABLE "invites" ( + "id" TEXT NOT NULL DEFAULT gen_random_uuid(), + "email" TEXT, + "userId" TEXT, + "token" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "acceptedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "communityId" TEXT NOT NULL, + "communityRole" "MemberRole" NOT NULL DEFAULT 'contributor', + "pubId" TEXT, + "formId" TEXT, + "stageId" TEXT, + "otherRole" "MemberRole", + "message" TEXT, + "sentAt" TIMESTAMP(3), + "lastSentAt" TIMESTAMP(3), + "sendAttempts" INTEGER NOT NULL DEFAULT 0, + "status" TEXT NOT NULL DEFAULT 'active', + "revokedAt" TIMESTAMP(3), + "invitedByUserId" TEXT, + "invitedByActionRunId" TEXT, + CONSTRAINT "invites_pkey" PRIMARY KEY ("id") +); + +-- Create unique token constraint +CREATE UNIQUE INDEX "invites_token_key" ON "invites"("token"); + +-- Add constraint: exactly one of pubId, formId, stageId +ALTER TABLE "invites" ADD CONSTRAINT "exclusive_resource_constraint" +CHECK ( + (CASE WHEN "pubId" IS NOT NULL THEN 1 ELSE 0 END) + + (CASE WHEN "formId" IS NOT NULL THEN 1 ELSE 0 END) + + (CASE WHEN "stageId" IS NOT NULL THEN 1 ELSE 0 END) <= 1 +); + +-- Add constraint: if pubId or stageId is set, otherRole must be set +ALTER TABLE "invites" ADD CONSTRAINT "other_role_required" +CHECK ( + ("pubId" IS NULL AND "stageId" IS NULL) OR "otherRole" IS NOT NULL +); + +-- Add constraint: exactly one of email or userId must be set +ALTER TABLE "invites" ADD CONSTRAINT "user_identification_constraint" +CHECK ( + (("email" IS NOT NULL)::integer + ("userId" IS NOT NULL)::integer) = 1 +); + +-- Add constraint: ensure invitedByUserId or invitedByActionRunId is set +ALTER TABLE "invites" ADD CONSTRAINT "invited_by_required" +CHECK ( + ("invitedByUserId" IS NOT NULL) OR ("invitedByActionRunId" IS NOT NULL) +); + +-- AddForeignKey +ALTER TABLE "invites" ADD CONSTRAINT "invites_communityId_fkey" FOREIGN KEY ("communityId") REFERENCES "communities"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invites" ADD CONSTRAINT "invites_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invites" ADD CONSTRAINT "invites_pubId_fkey" FOREIGN KEY ("pubId") REFERENCES "pubs"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invites" ADD CONSTRAINT "invites_formId_fkey" FOREIGN KEY ("formId") REFERENCES "forms"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invites" ADD CONSTRAINT "invites_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "stages"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invites" ADD CONSTRAINT "invites_invitedByUserId_fkey" FOREIGN KEY ("invitedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invites" ADD CONSTRAINT "invites_invitedByActionRunId_fkey" FOREIGN KEY ("invitedByActionRunId") REFERENCES "action_runs"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/core/prisma/migrations/20250320132733_update_comments/migration.sql b/core/prisma/migrations/20250320132733_update_comments/migration.sql new file mode 100644 index 0000000000..f2a4c3ce05 --- /dev/null +++ b/core/prisma/migrations/20250320132733_update_comments/migration.sql @@ -0,0 +1,198 @@ +-- 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 + + + +-- Model invites 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/comments/.comments-lock b/core/prisma/schema/comments/.comments-lock index 7d5a886990..f2a4c3ce05 100644 --- a/core/prisma/schema/comments/.comments-lock +++ b/core/prisma/schema/comments/.comments-lock @@ -116,6 +116,10 @@ COMMENT ON COLUMN "api_access_permissions"."constraints" IS '@type(ApiAccessPerm +-- Model invites comments + + + -- Enum AuthTokenType comments COMMENT ON TYPE "AuthTokenType" IS '@property generic - For most use-cases. This will just authenticate you with a regular session. diff --git a/core/prisma/schema/schema.dbml b/core/prisma/schema/schema.dbml index ae94d7a4b3..d56d600f49 100644 --- a/core/prisma/schema/schema.dbml +++ b/core/prisma/schema/schema.dbml @@ -41,6 +41,8 @@ Table users { pubMemberships pub_memberships [not null] stageMemberships stage_memberships [not null] PubValueHistory pub_values_history [not null] + Invite invites [not null] + InvitedBy invites [not null] } Table sessions { @@ -80,6 +82,7 @@ Table communities { Form forms [not null] pubFields pub_fields [not null] members community_memberships [not null] + Invite invites [not null] } Table pubs { @@ -100,6 +103,7 @@ Table pubs { relatedValues pub_values [not null] members pub_memberships [not null] formMemberships form_memberships [not null] + Invite invites [not null] } Table pub_fields { @@ -194,6 +198,7 @@ Table stages { actionInstances action_instances [not null] formElements form_elements [not null] members stage_memberships [not null] + Invite invites [not null] } Table PubsInStages { @@ -341,6 +346,7 @@ Table action_runs { sourceActionRunId String sourceActionRun action_runs sequentialActionRuns action_runs [not null] + Invite invites [not null] } Table rules { @@ -370,6 +376,7 @@ Table forms { createdAt DateTime [default: `now()`, not null] updatedAt DateTime [default: `now()`, not null] members form_memberships [not null] + Invite invites [not null] indexes { (name, communityId) [unique] @@ -448,6 +455,38 @@ Table membership_capabilities { } } +Table invites { + id String [pk] + email String + InvitedUser users + userId String + token String [unique, not null] + expiresAt DateTime [not null] + acceptedAt DateTime + createdAt DateTime [default: `now()`, not null] + updatedAt DateTime [default: `now()`, not null] + community communities [not null] + communityId String [not null] + communityRole MemberRole [not null, default: 'contributor'] + Pub pubs + pubId String + Form forms + formId String + Stage stages + stageId String + otherRole MemberRole + message String + sentAt DateTime + lastSentAt DateTime + sendAttempts Int [not null, default: 0] + status String [not null, default: 'active'] + revokedAt DateTime + InvitedByUser users + invitedByUserId String + InvitedByActionRun action_runs + invitedByActionRunId String +} + Table MemberGroupToUser { membergroupsId String [ref: > member_groups.id] usersId String [ref: > users.id] @@ -707,4 +746,18 @@ Ref: api_access_tokens.issuedById > users.id [delete: Set Null] Ref: api_access_logs.accessTokenId > api_access_tokens.id [delete: Set Null] -Ref: api_access_permissions.apiAccessTokenId > api_access_tokens.id [delete: Cascade] \ No newline at end of file +Ref: api_access_permissions.apiAccessTokenId > api_access_tokens.id [delete: Cascade] + +Ref: invites.userId > users.id [delete: Set Null] + +Ref: invites.communityId > communities.id [delete: Cascade] + +Ref: invites.pubId > pubs.id [delete: Set Null] + +Ref: invites.formId > forms.id [delete: Set Null] + +Ref: invites.stageId > stages.id [delete: Set Null] + +Ref: invites.invitedByUserId > users.id [delete: Set Null] + +Ref: invites.invitedByActionRunId > action_runs.id [delete: Set Null] \ No newline at end of file diff --git a/core/prisma/schema/schema.prisma b/core/prisma/schema/schema.prisma index 7af736c63a..9fe823d7d3 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 { @@ -45,6 +41,9 @@ model User { stageMemberships StageMembership[] PubValueHistory PubValueHistory[] + Invite Invite[] @relation("invited_user") + InvitedBy Invite[] @relation("invited_by") + @@map(name: "users") } @@ -104,6 +103,7 @@ model Community { Form Form[] pubFields PubField[] members CommunityMembership[] + Invite Invite[] @@map(name: "communities") } @@ -133,6 +133,7 @@ model Pub { formMemberships FormMembership[] searchVector Unsupported("tsvector")? + Invite Invite[] @@index([searchVector], type: Gin) @@map(name: "pubs") @@ -265,6 +266,7 @@ model Stage { actionInstances ActionInstance[] formElements FormElement[] members StageMembership[] + Invite Invite[] @@map(name: "stages") } @@ -445,6 +447,7 @@ model ActionRun { // action runs that were triggered by this action run sequentialActionRuns ActionRun[] @relation("source_action_run") + Invite Invite[] @relation("invited_by_action_run") @@map(name: "action_runs") } @@ -500,6 +503,7 @@ model Form { updatedAt DateTime @default(now()) @updatedAt members FormMembership[] + Invite Invite[] @@unique([name, communityId]) @@unique([slug, communityId]) @@ -676,3 +680,46 @@ model MembershipCapabilities { @@id([role, type, capability]) @@map(name: "membership_capabilities") } + +model Invite { + id String @id @default(dbgenerated("gen_random_uuid()")) + + // either one of these is set + email String? + InvitedUser User? @relation("invited_user", fields: [userId], references: [id], onDelete: SetNull) + userId String? + + token String @unique + expiresAt DateTime + acceptedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + community Community @relation(fields: [communityId], references: [id], onDelete: Cascade) + communityId String + communityRole MemberRole @default(contributor) + + Pub Pub? @relation(fields: [pubId], references: [id], onDelete: SetNull) + pubId String? + Form Form? @relation(fields: [formId], references: [id], onDelete: SetNull) + formId String? + Stage Stage? @relation(fields: [stageId], references: [id], onDelete: SetNull) + stageId String? + + otherRole MemberRole? + + message String? + sentAt DateTime? + lastSentAt DateTime? + sendAttempts Int @default(0) + + status String @default("active") + revokedAt DateTime? + + InvitedByUser User? @relation("invited_by", fields: [invitedByUserId], references: [id], onDelete: SetNull) + invitedByUserId String? + InvitedByActionRun ActionRun? @relation("invited_by_action_run", fields: [invitedByActionRunId], references: [id], onDelete: SetNull) + invitedByActionRunId String? + + @@map(name: "invites") +} diff --git a/packages/db/src/public/Invites.ts b/packages/db/src/public/Invites.ts new file mode 100644 index 0000000000..bb9e5994ea --- /dev/null +++ b/packages/db/src/public/Invites.ts @@ -0,0 +1,154 @@ +import type { ColumnType, Insertable, Selectable, Updateable } from "kysely"; + +import { z } from "zod"; + +import type { ActionRunsId } from "./ActionRuns"; +import type { CommunitiesId } from "./Communities"; +import type { FormsId } from "./Forms"; +import type { MemberRole } from "./MemberRole"; +import type { PubsId } from "./Pubs"; +import type { StagesId } from "./Stages"; +import type { UsersId } from "./Users"; +import { actionRunsIdSchema } from "./ActionRuns"; +import { communitiesIdSchema } from "./Communities"; +import { formsIdSchema } from "./Forms"; +import { memberRoleSchema } from "./MemberRole"; +import { pubsIdSchema } from "./Pubs"; +import { stagesIdSchema } from "./Stages"; +import { usersIdSchema } from "./Users"; + +// @generated +// This file is automatically generated by Kanel. Do not modify manually. + +/** Identifier type for public.invites */ +export type InvitesId = string & { __brand: "InvitesId" }; + +/** Represents the table public.invites */ +export interface InvitesTable { + id: ColumnType; + + email: ColumnType; + + userId: ColumnType; + + token: ColumnType; + + expiresAt: ColumnType; + + acceptedAt: ColumnType; + + createdAt: ColumnType; + + updatedAt: ColumnType; + + communityId: ColumnType; + + communityRole: ColumnType; + + pubId: ColumnType; + + formId: ColumnType; + + stageId: ColumnType; + + otherRole: ColumnType; + + message: ColumnType; + + sentAt: ColumnType; + + lastSentAt: ColumnType; + + sendAttempts: ColumnType; + + status: ColumnType; + + revokedAt: ColumnType; + + invitedByUserId: ColumnType; + + invitedByActionRunId: ColumnType; +} + +export type Invites = Selectable; + +export type NewInvites = Insertable; + +export type InvitesUpdate = Updateable; + +export const invitesIdSchema = z.string().uuid() as unknown as z.Schema; + +export const invitesSchema = z.object({ + id: invitesIdSchema, + email: z.string().nullable(), + userId: usersIdSchema.nullable(), + token: z.string(), + expiresAt: z.date(), + acceptedAt: z.date().nullable(), + createdAt: z.date(), + updatedAt: z.date(), + communityId: communitiesIdSchema, + communityRole: memberRoleSchema, + pubId: pubsIdSchema.nullable(), + formId: formsIdSchema.nullable(), + stageId: stagesIdSchema.nullable(), + otherRole: memberRoleSchema.nullable(), + message: z.string().nullable(), + sentAt: z.date().nullable(), + lastSentAt: z.date().nullable(), + sendAttempts: z.number(), + status: z.string(), + revokedAt: z.date().nullable(), + invitedByUserId: usersIdSchema.nullable(), + invitedByActionRunId: actionRunsIdSchema.nullable(), +}); + +export const invitesInitializerSchema = z.object({ + id: invitesIdSchema.optional(), + email: z.string().optional().nullable(), + userId: usersIdSchema.optional().nullable(), + token: z.string(), + expiresAt: z.date(), + acceptedAt: z.date().optional().nullable(), + createdAt: z.date().optional(), + updatedAt: z.date().optional(), + communityId: communitiesIdSchema, + communityRole: memberRoleSchema.optional(), + pubId: pubsIdSchema.optional().nullable(), + formId: formsIdSchema.optional().nullable(), + stageId: stagesIdSchema.optional().nullable(), + otherRole: memberRoleSchema.optional().nullable(), + message: z.string().optional().nullable(), + sentAt: z.date().optional().nullable(), + lastSentAt: z.date().optional().nullable(), + sendAttempts: z.number().optional(), + status: z.string().optional(), + revokedAt: z.date().optional().nullable(), + invitedByUserId: usersIdSchema.optional().nullable(), + invitedByActionRunId: actionRunsIdSchema.optional().nullable(), +}); + +export const invitesMutatorSchema = z.object({ + id: invitesIdSchema.optional(), + email: z.string().optional().nullable(), + userId: usersIdSchema.optional().nullable(), + token: z.string().optional(), + expiresAt: z.date().optional(), + acceptedAt: z.date().optional().nullable(), + createdAt: z.date().optional(), + updatedAt: z.date().optional(), + communityId: communitiesIdSchema.optional(), + communityRole: memberRoleSchema.optional(), + pubId: pubsIdSchema.optional().nullable(), + formId: formsIdSchema.optional().nullable(), + stageId: stagesIdSchema.optional().nullable(), + otherRole: memberRoleSchema.optional().nullable(), + message: z.string().optional().nullable(), + sentAt: z.date().optional().nullable(), + lastSentAt: z.date().optional().nullable(), + sendAttempts: z.number().optional(), + status: z.string().optional(), + revokedAt: z.date().optional().nullable(), + invitedByUserId: usersIdSchema.optional().nullable(), + invitedByActionRunId: actionRunsIdSchema.optional().nullable(), +}); diff --git a/packages/db/src/public/PublicSchema.ts b/packages/db/src/public/PublicSchema.ts index 164fb0f466..b06be2683e 100644 --- a/packages/db/src/public/PublicSchema.ts +++ b/packages/db/src/public/PublicSchema.ts @@ -12,6 +12,7 @@ import type { CommunityMembershipsTable } from "./CommunityMemberships"; import type { FormElementsTable } from "./FormElements"; import type { FormMembershipsTable } from "./FormMemberships"; import type { FormsTable } from "./Forms"; +import type { InvitesTable } from "./Invites"; import type { MemberGroupsTable } from "./MemberGroups"; import type { MemberGroupToUserTable } from "./MemberGroupToUser"; import type { MembershipCapabilitiesTable } from "./MembershipCapabilities"; @@ -33,34 +34,6 @@ import type { StagesTable } from "./Stages"; import type { UsersTable } from "./Users"; export interface PublicSchema { - rules: RulesTable; - - action_runs: ActionRunsTable; - - forms: FormsTable; - - api_access_tokens: ApiAccessTokensTable; - - api_access_logs: ApiAccessLogsTable; - - api_access_permissions: ApiAccessPermissionsTable; - - form_elements: FormElementsTable; - - sessions: SessionsTable; - - community_memberships: CommunityMembershipsTable; - - pub_memberships: PubMembershipsTable; - - stage_memberships: StageMembershipsTable; - - form_memberships: FormMembershipsTable; - - membership_capabilities: MembershipCapabilitiesTable; - - pub_values_history: PubValuesHistoryTable; - _prisma_migrations: PrismaMigrationsTable; users: UsersTable; @@ -92,4 +65,34 @@ export interface PublicSchema { action_instances: ActionInstancesTable; PubsInStages: PubsInStagesTable; + + rules: RulesTable; + + action_runs: ActionRunsTable; + + forms: FormsTable; + + api_access_tokens: ApiAccessTokensTable; + + api_access_logs: ApiAccessLogsTable; + + api_access_permissions: ApiAccessPermissionsTable; + + form_elements: FormElementsTable; + + sessions: SessionsTable; + + community_memberships: CommunityMembershipsTable; + + pub_memberships: PubMembershipsTable; + + stage_memberships: StageMembershipsTable; + + form_memberships: FormMembershipsTable; + + membership_capabilities: MembershipCapabilitiesTable; + + pub_values_history: PubValuesHistoryTable; + + invites: InvitesTable; } diff --git a/packages/db/src/table-names.ts b/packages/db/src/table-names.ts index ea4b4bd05c..4a90351eaa 100644 --- a/packages/db/src/table-names.ts +++ b/packages/db/src/table-names.ts @@ -18,6 +18,7 @@ export const databaseTableNames = [ "form_elements", "form_memberships", "forms", + "invites", "member_groups", "membership_capabilities", "move_constraint", @@ -1077,6 +1078,189 @@ export const databaseTables = [ }, ], }, + { + name: "invites", + isView: false, + schema: "public", + columns: [ + { + name: "id", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: true, + }, + { + name: "email", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "userId", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "token", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "expiresAt", + dataType: "timestamp", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "acceptedAt", + dataType: "timestamp", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "createdAt", + dataType: "timestamp", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: true, + }, + { + name: "updatedAt", + dataType: "timestamp", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: true, + }, + { + name: "communityId", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "communityRole", + dataType: "MemberRole", + dataTypeSchema: "public", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: true, + }, + { + name: "pubId", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "formId", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "stageId", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "otherRole", + dataType: "MemberRole", + dataTypeSchema: "public", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "message", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "sentAt", + dataType: "timestamp", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "lastSentAt", + dataType: "timestamp", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "sendAttempts", + dataType: "int4", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: true, + }, + { + name: "status", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: true, + }, + { + name: "revokedAt", + dataType: "timestamp", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "invitedByUserId", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + { + name: "invitedByActionRunId", + dataType: "text", + dataTypeSchema: "pg_catalog", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + }, + ], + }, { name: "member_groups", isView: false, From 95d6d8136a60b85ab95f2a5751af00a12b45b9ce Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 24 Mar 2025 12:16:05 +0100 Subject: [PATCH 03/43] refactor: split up the signup code somewhat --- core/app/components/Signup/SignupForm.tsx | 10 +- core/lib/authentication/actions.ts | 144 ++++++---------------- 2 files changed, 42 insertions(+), 112 deletions(-) diff --git a/core/app/components/Signup/SignupForm.tsx b/core/app/components/Signup/SignupForm.tsx index 07eae84136..19e796b805 100644 --- a/core/app/components/Signup/SignupForm.tsx +++ b/core/app/components/Signup/SignupForm.tsx @@ -3,7 +3,7 @@ import type { Static } from "@sinclair/typebox"; import React, { useCallback, useMemo } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; import { typeboxResolver } from "@hookform/resolvers/typebox"; import { Type } from "@sinclair/typebox"; import { useForm } from "react-hook-form"; @@ -15,8 +15,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "ui/ca import { Form, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "ui/form"; import { Input } from "ui/input"; -import { publicSignup } from "~/lib/authentication/actions"; -import { isClientException, useServerAction } from "~/lib/serverActions"; +import { legacySignup } from "~/lib/authentication/actions"; +import { useServerAction } from "~/lib/serverActions"; registerFormats(); @@ -33,7 +33,7 @@ const formSchema = Type.Object({ export function SignupForm(props: { user: Pick | null; }) { - const runSignup = useServerAction(publicSignup); + const runSignup = useServerAction(legacySignup); const resolver = useMemo(() => typeboxResolver(formSchema), []); @@ -46,7 +46,7 @@ export function SignupForm(props: { const handleSubmit = useCallback(async (data: Static) => { await runSignup({ - id: props.user?.id ?? "", + id: props.user?.id, firstName: data.firstName, lastName: data.lastName, email: data.email, diff --git a/core/lib/authentication/actions.ts b/core/lib/authentication/actions.ts index 109ee53574..bde33b9007 100644 --- a/core/lib/authentication/actions.ts +++ b/core/lib/authentication/actions.ts @@ -225,118 +225,44 @@ const addUserToCommunity = defineServerAction(async function addUserToCommunity( }; }); -export const publicSignup = defineServerAction(async function signup( - props: { - firstName: string; - lastName: string; - email: string; - password: string; - redirect: string | null; - slug?: string; - role?: MemberRole; - } & XOR< - { - communityId: CommunitiesId; - }, - { - id: UsersId; - } - > -) { - const { user, session } = await getLoginData({ - allowedSessions: [AuthTokenType.signup], - }); - - if (!user && !props.allowUserCreation) { - captureException(new Error("User tried to signup without existing"), { - user: { - id: props.id, - firstName: props.firstName, - lastName: props.lastName, - email: props.email, - }, - }); - return { - error: "Something went wrong. Please try again later.", - }; - } - - if (user && user.id !== props.id) { - captureException(new Error("User tried to signup with a different id"), { - user: { - id: props.id, - firstName: props.firstName, - lastName: props.lastName, - email: props.email, - }, - }); - return { - error: "Something went wrong. Please try again later.", - }; - } - +export const publicSignup = defineServerAction(async function signup(props: { + firstName: string; + lastName: string; + email: string; + password: string; + redirect: string | null; + slug?: string; + role?: MemberRole; + communityId: CommunitiesId; +}) { const trx = db.transaction(); const newUser = await trx.execute(async (trx) => { - if (props.communityId !== undefined) { - 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 ${props.id}`); - }); - - // TODO: add to community - await addUserToCommunity({ - userId: newUser.id, - communityId: props.communityId, - role: props.role ?? MemberRole.contributor, - }); - - // TODO: send verification email - return { ...newUser, needsVerification: false }; - } - - if (!user) { - throw new Error("Something went wrong. Expected user to exist"); - } - - const updatedUser = await updateUser( + const newUser = await addUser( { - id: props.id, 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 - ); - - await setUserPassword( - { - userId: props.id, - password: props.password, - }, - trx - ); + ).executeTakeFirstOrThrow((err) => { + Sentry.captureException(err); + return new Error(`Unable to create user ${props.id}`); + }); - if (updatedUser.email !== user.email) { - return { ...updatedUser, needsVerification: true }; - // TODO: send email verification - } + // TODO: add to community + await addUserToCommunity({ + userId: newUser.id, + communityId: props.communityId, + role: props.role ?? MemberRole.contributor, + }); - return { - ...updatedUser, - needsVerification: false, - }; + // TODO: send verification email + return { ...newUser, needsVerification: false }; }); if ("needsVerification" in newUser && newUser.needsVerification) { @@ -349,11 +275,6 @@ export const publicSignup = defineServerAction(async function signup( // log them in - const [invalidatedSessions, invalidatedTokens] = await Promise.all([ - lucia.invalidateUserSessions(newUser.id), - invalidateTokensForUser(newUser.id, [AuthTokenType.signup]), - ]); - // lucia authentication const newSession = await lucia.createSession(newUser.id, { type: AuthTokenType.generic }); const newSessionCookie = lucia.createSessionCookie(newSession.id); @@ -372,7 +293,7 @@ export const publicSignup = defineServerAction(async function signup( /** * flow for when a user has been invited to a community already */ -export const invitedSignup = defineServerAction(async function signup(props: { +export const legacySignup = defineServerAction(async function signup(props: { id: UsersId; firstName: string; lastName: string; @@ -472,3 +393,12 @@ export const invitedSignup = 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; +}) {}); From 7e0ccdf8af6aee9bd39bc55dc3f40eb669dd8b85 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 24 Mar 2025 12:24:31 +0100 Subject: [PATCH 04/43] refactor: split up signup form into three forms --- core/app/(user)/signup/page.tsx | 4 +-- .../(public)/[communitySlug]/signup/page.tsx | 4 +-- .../{SignupForm.tsx => BaseSignupForm.tsx} | 25 +++----------- .../components/Signup/LegacySignupForm.tsx | 31 +++++++++++++++++ .../components/Signup/PublicSignupForm.tsx | 34 +++++++++++++++++++ 5 files changed, 73 insertions(+), 25 deletions(-) rename core/app/components/Signup/{SignupForm.tsx => BaseSignupForm.tsx} (84%) create mode 100644 core/app/components/Signup/LegacySignupForm.tsx create mode 100644 core/app/components/Signup/PublicSignupForm.tsx diff --git a/core/app/(user)/signup/page.tsx b/core/app/(user)/signup/page.tsx index 03c83fc084..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 "../../components/Signup/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]/signup/page.tsx b/core/app/c/(public)/[communitySlug]/signup/page.tsx index 648647491a..f63819204f 100644 --- a/core/app/c/(public)/[communitySlug]/signup/page.tsx +++ b/core/app/c/(public)/[communitySlug]/signup/page.tsx @@ -1,6 +1,6 @@ import { AuthTokenType } from "db/public"; -import { SignupForm } from "~/app/components/SignUp/SignupForm"; +import { LegacySignupForm } from "~/app/components/Signup/LegacySignupForm"; import { getLoginData } from "~/lib/authentication/loginData"; export default async function Page() { @@ -11,7 +11,7 @@ export default async function Page() { return (
- +
); } diff --git a/core/app/components/Signup/SignupForm.tsx b/core/app/components/Signup/BaseSignupForm.tsx similarity index 84% rename from core/app/components/Signup/SignupForm.tsx rename to core/app/components/Signup/BaseSignupForm.tsx index 19e796b805..4acbbc791e 100644 --- a/core/app/components/Signup/SignupForm.tsx +++ b/core/app/components/Signup/BaseSignupForm.tsx @@ -15,12 +15,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "ui/ca import { Form, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "ui/form"; import { Input } from "ui/input"; -import { legacySignup } from "~/lib/authentication/actions"; -import { useServerAction } from "~/lib/serverActions"; - registerFormats(); -const formSchema = Type.Object({ +export const formSchema = Type.Object({ firstName: Type.String(), lastName: Type.String(), email: Type.String({ format: "email" }), @@ -30,11 +27,10 @@ const formSchema = Type.Object({ }), }); -export function SignupForm(props: { +export function BaseSignupForm(props: { user: Pick | null; + onSubmit: (data: Static) => Promise; }) { - const runSignup = useServerAction(legacySignup); - const resolver = useMemo(() => typeboxResolver(formSchema), []); const form = useForm>({ @@ -42,22 +38,9 @@ export function SignupForm(props: { 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 diff --git a/core/app/components/Signup/LegacySignupForm.tsx b/core/app/components/Signup/LegacySignupForm.tsx new file mode 100644 index 0000000000..33ade43170 --- /dev/null +++ b/core/app/components/Signup/LegacySignupForm.tsx @@ -0,0 +1,31 @@ +"use client"; + +import type { Static } from "@sinclair/typebox"; + +import { useCallback } from "react"; +import { useSearchParams } from "next/navigation"; + +import type { Users } from "db/public"; + +import { legacySignup } from "~/lib/authentication/actions"; +import { useServerAction } from "~/lib/serverActions"; +import { BaseSignupForm, formSchema } from "./BaseSignupForm"; + +export function LegacySignupForm(props: { + user: Pick; +}) { + const signup = useServerAction(legacySignup); + const searchParams = useSearchParams(); + const onSubmit = useCallback(async (data: Static) => { + 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..de1f5eabe3 --- /dev/null +++ b/core/app/components/Signup/PublicSignupForm.tsx @@ -0,0 +1,34 @@ +"use client"; + +import type { Static } from "@sinclair/typebox"; + +import { useCallback } from "react"; +import { useSearchParams } from "next/navigation"; + +import type { CommunitiesId, Users } from "db/public"; + +import { publicSignup } from "~/lib/authentication/actions"; +import { useServerAction } from "~/lib/serverActions"; +import { BaseSignupForm, formSchema } from "./BaseSignupForm"; + +export function PublicSignupForm(props: { + user: Pick | null; + communityId: CommunitiesId; +}) { + const runSignup = useServerAction(publicSignup); + + const searchParams = useSearchParams(); + + const handleSubmit = useCallback(async (data: Static) => { + await runSignup({ + firstName: data.firstName, + lastName: data.lastName, + email: data.email, + password: data.password, + redirect: searchParams.get("redirectTo"), + communityId: props.communityId, + }); + }, []); + + return ; +} From 19304b2f69d5794af03f56ae7007b16d96d3ac2f Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 24 Mar 2025 12:33:51 +0100 Subject: [PATCH 05/43] refactor: move public signup form to different place, do some checks --- .../[communitySlug]/public/signup/page.tsx | 40 +++++++++++++++++++ .../(public)/[communitySlug]/signup/page.tsx | 17 -------- .../components/Signup/PublicSignupForm.tsx | 9 ++--- 3 files changed, 43 insertions(+), 23 deletions(-) create mode 100644 core/app/c/(public)/[communitySlug]/public/signup/page.tsx delete mode 100644 core/app/c/(public)/[communitySlug]/signup/page.tsx 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..41909873a4 --- /dev/null +++ b/core/app/c/(public)/[communitySlug]/public/signup/page.tsx @@ -0,0 +1,40 @@ +import { notFound, redirect } from "next/navigation"; + +import { AuthTokenType } from "db/public"; + +import { PublicSignupForm } from "~/app/components/Signup/PublicSignupForm"; +import { getLoginData } from "~/lib/authentication/loginData"; +import { findCommunityBySlug } from "~/lib/server/community"; + +export default async function Page({ + searchParams, +}: { + searchParams: Promise<{ redirectTo?: string }>; +}) { + const [community, { user }] = await Promise.all([findCommunityBySlug(), getLoginData()]); + + if (!community) { + notFound(); + } + + const { redirectTo } = await searchParams; + + if (user) { + if (user.memberships.some((m) => m.communityId === community.id)) { + redirect(redirectTo ?? "/"); + // TODO: redirect to wherever they were redirected to before signing up + throw new Error("User is already member of community"); + } + + // TODO: user is already member of community + + // TODO: redirect to join community page instead + throw new Error("User is already logged in"); + } + + return ( +
+ +
+ ); +} diff --git a/core/app/c/(public)/[communitySlug]/signup/page.tsx b/core/app/c/(public)/[communitySlug]/signup/page.tsx deleted file mode 100644 index f63819204f..0000000000 --- a/core/app/c/(public)/[communitySlug]/signup/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { AuthTokenType } from "db/public"; - -import { LegacySignupForm } from "~/app/components/Signup/LegacySignupForm"; -import { getLoginData } from "~/lib/authentication/loginData"; - -export default async function Page() { - const { user, session } = await getLoginData({ - allowedSessions: [AuthTokenType.signup], - }); - console.log("ignup page"); - - return ( -
- -
- ); -} diff --git a/core/app/components/Signup/PublicSignupForm.tsx b/core/app/components/Signup/PublicSignupForm.tsx index de1f5eabe3..bfe535b9ef 100644 --- a/core/app/components/Signup/PublicSignupForm.tsx +++ b/core/app/components/Signup/PublicSignupForm.tsx @@ -5,16 +5,13 @@ import type { Static } from "@sinclair/typebox"; import { useCallback } from "react"; import { useSearchParams } from "next/navigation"; -import type { CommunitiesId, Users } from "db/public"; +import type { CommunitiesId } from "db/public"; import { publicSignup } from "~/lib/authentication/actions"; import { useServerAction } from "~/lib/serverActions"; import { BaseSignupForm, formSchema } from "./BaseSignupForm"; -export function PublicSignupForm(props: { - user: Pick | null; - communityId: CommunitiesId; -}) { +export function PublicSignupForm(props: { communityId: CommunitiesId }) { const runSignup = useServerAction(publicSignup); const searchParams = useSearchParams(); @@ -30,5 +27,5 @@ export function PublicSignupForm(props: { }); }, []); - return ; + return ; } From 437e87de16a94a50e151d195570a3b59adfb2f56 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 24 Mar 2025 12:42:44 +0100 Subject: [PATCH 06/43] feat: add public join page --- .../[communitySlug]/public/join/page.tsx | 47 +++++++++++++++++++ .../[communitySlug]/public/signup/page.tsx | 10 +++- 2 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 core/app/c/(public)/[communitySlug]/public/join/page.tsx diff --git a/core/app/c/(public)/[communitySlug]/public/join/page.tsx b/core/app/c/(public)/[communitySlug]/public/join/page.tsx new file mode 100644 index 0000000000..d004609bfe --- /dev/null +++ b/core/app/c/(public)/[communitySlug]/public/join/page.tsx @@ -0,0 +1,47 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; + +import { Button } from "ui/button"; +import { Card, CardContent } from "ui/card"; + +import { getLoginData } from "~/lib/authentication/loginData"; +import { findCommunityBySlug } from "~/lib/server/community"; +import { publicSignupsAllowed } from "~/lib/server/user"; + +export default async function JoinPage({ + searchParams, +}: { + searchParams: Promise<{ redirectTo?: string }>; +}) { + const [community, { user }] = await Promise.all([findCommunityBySlug(), getLoginData()]); + + if (!community) { + notFound(); + } + + const isAllowedToSignup = await publicSignupsAllowed(community.id); + if (!isAllowedToSignup) { + // no public signups allowed + notFound(); + } + + const { redirectTo } = await searchParams; + if (!user) { + redirect(`/c/${community.slug}/public/signup?redirectTo=${redirectTo}`); + } + + if (user.memberships.some((m) => m.communityId === community.id)) { + return ( + + + You are already a member of this community + + + + ); + } + + return
Join
; +} diff --git a/core/app/c/(public)/[communitySlug]/public/signup/page.tsx b/core/app/c/(public)/[communitySlug]/public/signup/page.tsx index 41909873a4..0bbe005ce8 100644 --- a/core/app/c/(public)/[communitySlug]/public/signup/page.tsx +++ b/core/app/c/(public)/[communitySlug]/public/signup/page.tsx @@ -1,10 +1,9 @@ import { notFound, redirect } from "next/navigation"; -import { AuthTokenType } from "db/public"; - 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({ searchParams, @@ -17,6 +16,13 @@ export default async function Page({ notFound(); } + const isAllowedToSignup = await publicSignupsAllowed(community.id); + + if (!isAllowedToSignup) { + // this community does not allow public signups + notFound(); + } + const { redirectTo } = await searchParams; if (user) { From 8ecd5aa647c1b4eb8c4eb0bda2131941d75039e8 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 25 Mar 2025 12:33:09 +0100 Subject: [PATCH 07/43] fix: export invites --- packages/db/src/public.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/db/src/public.ts b/packages/db/src/public.ts index 36c8cc3410..d1e665d640 100644 --- a/packages/db/src/public.ts +++ b/packages/db/src/public.ts @@ -20,6 +20,7 @@ export * from "./public/FormElements"; export * from "./public/FormMemberships"; export * from "./public/Forms"; export * from "./public/InputComponent"; +export * from "./public/Invites"; export * from "./public/MemberGroups"; export * from "./public/MemberGroupToUser"; export * from "./public/MemberRole"; From a90dda3ca339be6d9d52eaca741b1ffd59b4bdd3 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 25 Mar 2025 12:47:01 +0100 Subject: [PATCH 08/43] feat: add public signup stuff --- .../public/forms/[formSlug]/fill/page.tsx | 9 +- core/app/components/Signup/BaseSignupForm.tsx | 14 +- .../components/Signup/JoinCommunityForm.tsx | 41 +++++ core/lib/authentication/actions.ts | 115 ++++++++++--- core/lib/authentication/errors.ts | 41 +++++ core/playwright/formAccess.spec.ts | 162 ++++++++++++++++++ 6 files changed, 354 insertions(+), 28 deletions(-) create mode 100644 core/app/components/Signup/JoinCommunityForm.tsx create mode 100644 core/lib/authentication/errors.ts create mode 100644 core/playwright/formAccess.spec.ts 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..fccd0f95dd 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,7 +3,7 @@ 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"; @@ -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, diff --git a/core/app/components/Signup/BaseSignupForm.tsx b/core/app/components/Signup/BaseSignupForm.tsx index 4acbbc791e..a02eb4014c 100644 --- a/core/app/components/Signup/BaseSignupForm.tsx +++ b/core/app/components/Signup/BaseSignupForm.tsx @@ -3,6 +3,7 @@ import type { Static } from "@sinclair/typebox"; import React, { useCallback, useMemo } from "react"; +import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { typeboxResolver } from "@hookform/resolvers/typebox"; import { Type } from "@sinclair/typebox"; @@ -11,7 +12,7 @@ import { registerFormats } from "schemas"; import type { Users } from "db/public"; import { Button } from "ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "ui/card"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "ui/card"; import { Form, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "ui/form"; import { Input } from "ui/input"; @@ -30,6 +31,7 @@ export const formSchema = Type.Object({ export function BaseSignupForm(props: { user: Pick | null; onSubmit: (data: Static) => Promise; + redirectTo?: string; }) { const resolver = useMemo(() => typeboxResolver(formSchema), []); @@ -119,6 +121,16 @@ export function BaseSignupForm(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..bd916452ec --- /dev/null +++ b/core/app/components/Signup/JoinCommunityForm.tsx @@ -0,0 +1,41 @@ +"use client"; + +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"; + +export const JoinCommunityForm = ({ + community, + role = MemberRole.contributor, +}: { + community: Communities; + role?: MemberRole; +}) => { + const form = useForm(); + + return ( +
+ + + + Join {community.name} + + Join {community.name} as a {role} + + + +
+ +
+
+
+
+ + ); +}; diff --git a/core/lib/authentication/actions.ts b/core/lib/authentication/actions.ts index bde33b9007..d74778d73f 100644 --- a/core/lib/authentication/actions.ts +++ b/core/lib/authentication/actions.ts @@ -12,14 +12,25 @@ import { AuthTokenType, MemberRole } from "db/public"; import type { Prettify, XOR } from "../types"; import type { SafeUser } from "~/lib/server/user"; import { db } from "~/kysely/database"; +import { isUniqueConstraintError } from "~/kysely/errors"; import { lucia, validateRequest } from "~/lib/authentication/lucia"; import { createPasswordHash, validatePassword } from "~/lib/authentication/password"; import { defineServerAction } from "~/lib/server/defineServerAction"; -import { addUser, generateUserSlug, 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({ @@ -225,6 +236,45 @@ const addUserToCommunity = defineServerAction(async function addUserToCommunity( }; }); +/** + * 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(), + ]); + + 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: MemberRole.contributor, + }).executeTakeFirstOrThrow(); + + return { + success: true, + report: `You have joined ${community.name}`, + }; +}); + export const publicSignup = defineServerAction(async function signup(props: { firstName: string; lastName: string; @@ -238,33 +288,46 @@ export const publicSignup = defineServerAction(async function signup(props: { const trx = db.transaction(); const newUser = await trx.execute(async (trx) => { - 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 ${props.id}`); - }); - - // TODO: add to community - await addUserToCommunity({ - userId: newUser.id, - communityId: props.communityId, - role: props.role ?? MemberRole.contributor, - }); - - // TODO: send verification email - return { ...newUser, needsVerification: false }; + 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 + await addUserToCommunity({ + userId: newUser.id, + communityId: props.communityId, + role: props.role ?? MemberRole.contributor, + }); + + // TODO: send verification email + return { ...newUser, needsVerification: false }; + } catch (e) { + if (isUniqueConstraintError(e) && e.table === "users") { + return SignupErrors.EMAIL_ALREADY_EXISTS({ email: props.email }); + } + throw e; + } }); + if ("error" in newUser) { + return newUser; + } + if ("needsVerification" in newUser && newUser.needsVerification) { return { success: true, diff --git a/core/lib/authentication/errors.ts b/core/lib/authentication/errors.ts new file mode 100644 index 0000000000..af0478553f --- /dev/null +++ b/core/lib/authentication/errors.ts @@ -0,0 +1,41 @@ +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 SignupErrors = { + NOT_LOGGED_IN: (props: { communityName: string }) => ({ + error: `You must be logged in to join ${props.communityName}`, + type: "NOT_LOGGED_IN" as const, + }), + ALREADY_MEMBER: (props: { communityName: string }) => ({ + error: `You are already a member of ${props.communityName}`, + type: "ALREADY_MEMBER" as const, + }), + NOT_ALLOWED: (props: { communityName: string }) => ({ + error: `Public signups are not allowed for ${props.communityName}`, + type: "NOT_ALLOWED" as const, + }), + COMMUNITY_NOT_FOUND: (props: { communityName: string }) => ({ + error: `Community not found`, + type: "COMMUNITY_NOT_FOUND" as const, + }), + EMAIL_ALREADY_EXISTS: (props: { email: string }) => ({ + error: `Email ${props.email} already exists`, + type: "EMAIL_ALREADY_EXISTS" as const, + }), +} 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/playwright/formAccess.spec.ts b/core/playwright/formAccess.spec.ts new file mode 100644 index 0000000000..febc8fefb5 --- /dev/null +++ b/core/playwright/formAccess.spec.ts @@ -0,0 +1,162 @@ +import type { Page } from "@playwright/test"; + +import { expect, test } from "@playwright/test"; + +import type { UsersId } from "db/public"; +import { CoreSchemaType, ElementType, InputComponent, MemberRole } from "db/public"; + +import type { CommunitySeedOutput } from "~/prisma/seed/createSeed"; +import { createSeed } from "~/prisma/seed/createSeed"; +import { seedCommunity } from "~/prisma/seed/seedCommunity"; +import { FieldsPage } from "./fixtures/fields-page"; +import { FormsEditPage } from "./fixtures/forms-edit-page"; +import { FormsPage } from "./fixtures/forms-page"; +import { LoginPage } from "./fixtures/login-page"; +import { PubDetailsPage } from "./fixtures/pub-details-page"; +import { PubTypesPage } from "./fixtures/pub-types-page"; +import { PubsPage } from "./fixtures/pubs-page"; +import { createBaseSeed, PubFieldsOfEachType } from "./helpers"; + +test.describe.configure({ mode: "serial" }); + +let page: Page; +const jimothyId = crypto.randomUUID() as UsersId; +const crossUserId = crypto.randomUUID() as UsersId; + +const seed = createSeed({ + community: { + name: "test community", + slug: "test-community", + }, + pubFields: { + Title: { + schemaName: CoreSchemaType.String, + }, + Content: { + schemaName: CoreSchemaType.String, + }, + ...PubFieldsOfEachType, + }, + users: { + admin: {}, + }, + 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", + 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", + }, + }, + ], + }, + "Title Only (default)": { + slug: "title-only-default", + pubType: "Title Only", + elements: [ + { + type: ElementType.pubfield, + field: "Title", + component: InputComponent.textInput, + config: { + label: "Title", + }, + }, + ], + }, + }, +}); + +const seed2 = createSeed({ + community: { + name: "test community 2", + slug: "test-community-2", + }, + users: { + jimothy: { + id: jimothyId, + role: MemberRole.admin, + }, + cross: { + id: crossUserId, + existing: true, + role: MemberRole.admin, + }, + }, +}); + +let community: CommunitySeedOutput; +let community2: CommunitySeedOutput; + +test.beforeAll(async ({ browser }) => { + community = await seedCommunity(seed); + community2 = await seedCommunity(seed2); + + page = await browser.newPage(); + + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.loginAndWaitForNavigation(community.users.admin.email, "password"); +}); From 36e152deea223cdfefe936bc3b89c3c8dc03c8ca Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 25 Mar 2025 13:59:44 +0100 Subject: [PATCH 09/43] feat: creaet happy path for unauthed signup --- .../public/forms/[formSlug]/fill/page.tsx | 4 +-- core/lib/authentication/actions.ts | 34 ++++++++++++++++--- core/prisma/exampleCommunitySeeds/croccroc.ts | 2 ++ 3 files changed, 33 insertions(+), 7 deletions(-) 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 fccd0f95dd..ea3fc745ba 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 @@ -6,7 +6,7 @@ import type { ReactNode } from "react"; 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"; @@ -196,7 +196,7 @@ export default async function FormPage(props: { } // 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/lib/authentication/actions.ts b/core/lib/authentication/actions.ts index d74778d73f..24ffdb1b57 100644 --- a/core/lib/authentication/actions.ts +++ b/core/lib/authentication/actions.ts @@ -8,6 +8,7 @@ import { z } from "zod"; import type { Communities, CommunitiesId, CommunityMemberships, Users, UsersId } from "db/public"; import { AuthTokenType, MemberRole } from "db/public"; +import { logger } from "logger"; import type { Prettify, XOR } from "../types"; import type { SafeUser } from "~/lib/server/user"; @@ -285,6 +286,24 @@ export const publicSignup = defineServerAction(async function signup(props: { 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.redirect}`); + } + + if (!isAllowedSignup) { + return SignupErrors.NOT_ALLOWED({ communityName: community.name }); + } + const trx = db.transaction(); const newUser = await trx.execute(async (trx) => { @@ -308,11 +327,14 @@ export const publicSignup = defineServerAction(async function signup(props: { }); // TODO: add to community - await addUserToCommunity({ - userId: newUser.id, - communityId: props.communityId, - role: props.role ?? MemberRole.contributor, - }); + 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 }; @@ -320,6 +342,8 @@ export const publicSignup = defineServerAction(async function signup(props: { if (isUniqueConstraintError(e) && e.table === "users") { return SignupErrors.EMAIL_ALREADY_EXISTS({ email: props.email }); } + logger.error({ msg: e }); + Sentry.captureException(e); throw e; } }); diff --git a/core/prisma/exampleCommunitySeeds/croccroc.ts b/core/prisma/exampleCommunitySeeds/croccroc.ts index 19fc10ed81..b9ba9f0ed5 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: [ { From dbee4c387f19f58e554dc0e90b9b1ac2fb547177 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 25 Mar 2025 14:30:45 +0100 Subject: [PATCH 10/43] feat: add user to pub after creation --- .../pubs/PubEditor/PubEditorClient.tsx | 1 + core/app/components/pubs/PubEditor/actions.ts | 80 +++++++++++++------ core/lib/server/form.ts | 19 +++-- core/lib/server/pub.ts | 7 +- 4 files changed, 76 insertions(+), 31 deletions(-) diff --git a/core/app/components/pubs/PubEditor/PubEditorClient.tsx b/core/app/components/pubs/PubEditor/PubEditorClient.tsx index 35f18c5409..bf21ea32ad 100644 --- a/core/app/components/pubs/PubEditor/PubEditorClient.tsx +++ b/core/app/components/pubs/PubEditor/PubEditorClient.tsx @@ -350,6 +350,7 @@ export const PubEditorClient = ({ }, communityId: community.id, addUserToForm: isExternalForm, + addUserToPub: isExternalForm, }); // TODO: this currently overwrites existing pub values of the same field if (relatedPub) { diff --git a/core/app/components/pubs/PubEditor/actions.ts b/core/app/components/pubs/PubEditor/actions.ts index da2f31f0b9..b2df3cb97d 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,18 @@ 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 { addMemberToForm, getForm, userHasPermissionToForm } from "~/lib/server/form"; import { deletePub, 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; + addUserToPub?: boolean; + } ) { const { formSlug, addUserToForm, ...createPubProps } = props; const loginData = await getLoginData(); @@ -31,16 +35,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 +58,41 @@ 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, + const trx = db.transaction(); + + const result = await trx.execute(async (trx) => { + const createdPub = await createPubRecursiveNew({ + ...createPubProps, + body: { + ...createPubProps.body, + // adds user to the pub + // TODO: this should be configured on the form + ...(props.addUserToPub + ? { 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/server/form.ts b/core/lib/server/form.ts index fb296b4f48..e51e6889c7 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,16 @@ 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(); + console.log(form); const existingPermission = await autoCache( - db + trx .selectFrom("form_memberships") .selectAll("form_memberships") .where("form_memberships.formId", "=", form.id) @@ -131,9 +139,10 @@ export const addMemberToForm = async ( .where("form_memberships.pubId", "=", pubId) ).executeTakeFirst(); + console.log(userId, pubId, getFormProps); 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..ecfd80bedc 100644 --- a/core/lib/server/pub.ts +++ b/core/lib/server/pub.ts @@ -326,8 +326,9 @@ export const createPubRecursiveNew = async ({ @@ -336,7 +337,11 @@ export const createPubRecursiveNew = async eb.columns(["pubId", "userId"]).doNothing()) .execute(); + + console.log("hey", res); } const rankedValues = await getRankedValues({ pubId: newPub.id, From 766df140e9735b6d0b16390387fc90b040660937 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 25 Mar 2025 14:31:06 +0100 Subject: [PATCH 11/43] docs: add warning about foreign key issues --- docs/content/development/common-issues.mdx | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 docs/content/development/common-issues.mdx diff --git a/docs/content/development/common-issues.mdx b/docs/content/development/common-issues.mdx new file mode 100644 index 0000000000..b5473dd922 --- /dev/null +++ b/docs/content/development/common-issues.mdx @@ -0,0 +1,8 @@ +# 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). From 5fd5b8b100a08a2fd9eddaab9a925415f9489232 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Wed, 26 Mar 2025 13:53:54 +0100 Subject: [PATCH 12/43] chore: add todo comment --- core/app/components/forms/types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/app/components/forms/types.ts b/core/app/components/forms/types.ts index fa5b930bdb..c2622361cc 100644 --- a/core/app/components/forms/types.ts +++ b/core/app/components/forms/types.ts @@ -9,6 +9,7 @@ import type { PubFieldsId, PubsId, PubValuesId, + StagesId, StructuralFormElement, } from "db/public"; @@ -62,7 +63,7 @@ export type ButtonElement = { element: null; content: null; required: null; - stageId: null; + stageId?: StagesId; config: null; component: null; schemaName: null; From 7e50b0f9379f22862bfd9c576617a7e3cd96aa98 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Wed, 26 Mar 2025 13:54:07 +0100 Subject: [PATCH 13/43] fix: set correct type for subitbuttons stageid --- core/app/c/[communitySlug]/pubs/[pubId]/edit/page.tsx | 1 + 1 file changed, 1 insertion(+) 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 */} Date: Wed, 26 Mar 2025 14:02:30 +0100 Subject: [PATCH 14/43] fix: set correct type for stageId --- core/app/components/forms/types.ts | 2 +- core/app/components/pubs/PubEditor/PubEditorClient.tsx | 1 - core/app/components/pubs/PubEditor/actions.ts | 5 +---- core/lib/server/form.ts | 2 -- core/lib/server/pub.ts | 7 ++----- 5 files changed, 4 insertions(+), 13 deletions(-) diff --git a/core/app/components/forms/types.ts b/core/app/components/forms/types.ts index c2622361cc..27c0f74f8d 100644 --- a/core/app/components/forms/types.ts +++ b/core/app/components/forms/types.ts @@ -63,7 +63,7 @@ export type ButtonElement = { element: null; content: null; required: null; - stageId?: StagesId; + stageId: StagesId | null; config: null; component: null; schemaName: null; diff --git a/core/app/components/pubs/PubEditor/PubEditorClient.tsx b/core/app/components/pubs/PubEditor/PubEditorClient.tsx index bf21ea32ad..35f18c5409 100644 --- a/core/app/components/pubs/PubEditor/PubEditorClient.tsx +++ b/core/app/components/pubs/PubEditor/PubEditorClient.tsx @@ -350,7 +350,6 @@ export const PubEditorClient = ({ }, communityId: community.id, addUserToForm: isExternalForm, - addUserToPub: isExternalForm, }); // TODO: this currently overwrites existing pub values of the same field if (relatedPub) { diff --git a/core/app/components/pubs/PubEditor/actions.ts b/core/app/components/pubs/PubEditor/actions.ts index b2df3cb97d..1bf511914b 100644 --- a/core/app/components/pubs/PubEditor/actions.ts +++ b/core/app/components/pubs/PubEditor/actions.ts @@ -24,7 +24,6 @@ export const createPubRecursive = defineServerAction(async function createPubRec props: CreatePubRecursiveProps & { formSlug?: string; addUserToForm?: boolean; - addUserToPub?: boolean; } ) { const { formSlug, addUserToForm, ...createPubProps } = props; @@ -67,9 +66,7 @@ export const createPubRecursive = defineServerAction(async function createPubRec ...createPubProps.body, // adds user to the pub // TODO: this should be configured on the form - ...(props.addUserToPub - ? { members: { [user.id]: MemberRole.contributor } } - : {}), + members: { [user.id]: MemberRole.contributor }, }, lastModifiedBy, trx, diff --git a/core/lib/server/form.ts b/core/lib/server/form.ts index e51e6889c7..629ea7fdca 100644 --- a/core/lib/server/form.ts +++ b/core/lib/server/form.ts @@ -128,7 +128,6 @@ export const addMemberToForm = async ( // TODO: Rewrite as single, `autoRevalidate`-d query with CTEs const { userId, pubId, ...getFormProps } = props; const form = await getForm(getFormProps, trx).executeTakeFirstOrThrow(); - console.log(form); const existingPermission = await autoCache( trx @@ -139,7 +138,6 @@ export const addMemberToForm = async ( .where("form_memberships.pubId", "=", pubId) ).executeTakeFirst(); - console.log(userId, pubId, getFormProps); if (existingPermission === undefined) { await autoRevalidate( trx.insertInto("form_memberships").values({ formId: form.id, userId, pubId }) diff --git a/core/lib/server/pub.ts b/core/lib/server/pub.ts index ecfd80bedc..9b0a146c87 100644 --- a/core/lib/server/pub.ts +++ b/core/lib/server/pub.ts @@ -326,7 +326,6 @@ export const createPubRecursiveNew = async eb.columns(["pubId", "userId"]).doNothing()) + // no conflict resolution is needed, as the user cannot be a member of the pub + // since we are just now creating the pub .execute(); - - console.log("hey", res); } const rankedValues = await getRankedValues({ pubId: newPub.id, From cb9d39df2843cfb33c838fd19edd33a8a52e4e92 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Wed, 26 Mar 2025 18:27:43 +0100 Subject: [PATCH 15/43] fix: no justify between for button formbuilder form --- .../FormBuilder/ElementPanel/ButtonConfigurationForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/app/components/FormBuilder/ElementPanel/ButtonConfigurationForm.tsx b/core/app/components/FormBuilder/ElementPanel/ButtonConfigurationForm.tsx index 005d0bbdfd..6675d472ce 100644 --- a/core/app/components/FormBuilder/ElementPanel/ButtonConfigurationForm.tsx +++ b/core/app/components/FormBuilder/ElementPanel/ButtonConfigurationForm.tsx @@ -126,7 +126,7 @@ export const ButtonConfigurationForm = ({ e.stopPropagation(); //prevent submission from propagating to parent form form.handleSubmit(onSubmit)(e); }} - className="flex h-full flex-col justify-between gap-2 pt-2" + className="flex h-full flex-col gap-4 pt-2" > Date: Wed, 26 Mar 2025 18:28:09 +0100 Subject: [PATCH 16/43] fix: do not check authentication in public layout --- core/app/c/(public)/[communitySlug]/layout.tsx | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) 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}; From 741bc0de86b3ea6c6cf353455260e26f275f6fc2 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Wed, 26 Mar 2025 18:28:39 +0100 Subject: [PATCH 17/43] refactor: move join page to community signup page and make it work --- .../[communitySlug]/public/join/page.tsx | 47 ------------------- .../[communitySlug]/public/signup/page.tsx | 24 ++++++++-- .../components/Signup/JoinCommunityForm.tsx | 18 ++++++- 3 files changed, 35 insertions(+), 54 deletions(-) delete mode 100644 core/app/c/(public)/[communitySlug]/public/join/page.tsx diff --git a/core/app/c/(public)/[communitySlug]/public/join/page.tsx b/core/app/c/(public)/[communitySlug]/public/join/page.tsx deleted file mode 100644 index d004609bfe..0000000000 --- a/core/app/c/(public)/[communitySlug]/public/join/page.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import Link from "next/link"; -import { notFound, redirect } from "next/navigation"; - -import { Button } from "ui/button"; -import { Card, CardContent } from "ui/card"; - -import { getLoginData } from "~/lib/authentication/loginData"; -import { findCommunityBySlug } from "~/lib/server/community"; -import { publicSignupsAllowed } from "~/lib/server/user"; - -export default async function JoinPage({ - searchParams, -}: { - searchParams: Promise<{ redirectTo?: string }>; -}) { - const [community, { user }] = await Promise.all([findCommunityBySlug(), getLoginData()]); - - if (!community) { - notFound(); - } - - const isAllowedToSignup = await publicSignupsAllowed(community.id); - if (!isAllowedToSignup) { - // no public signups allowed - notFound(); - } - - const { redirectTo } = await searchParams; - if (!user) { - redirect(`/c/${community.slug}/public/signup?redirectTo=${redirectTo}`); - } - - if (user.memberships.some((m) => m.communityId === community.id)) { - return ( - - - You are already a member of this community - - - - ); - } - - return
Join
; -} diff --git a/core/app/c/(public)/[communitySlug]/public/signup/page.tsx b/core/app/c/(public)/[communitySlug]/public/signup/page.tsx index 0bbe005ce8..a49fa72445 100644 --- a/core/app/c/(public)/[communitySlug]/public/signup/page.tsx +++ b/core/app/c/(public)/[communitySlug]/public/signup/page.tsx @@ -1,18 +1,28 @@ -import { notFound, redirect } from "next/navigation"; +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(); } @@ -27,15 +37,19 @@ export default async function Page({ if (user) { if (user.memberships.some((m) => m.communityId === community.id)) { - redirect(redirectTo ?? "/"); + 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: user is already member of community + // TODO: figure this out based on the invite + const joinRole = MemberRole.contributor; - // TODO: redirect to join community page instead - throw new Error("User is already logged in"); + return ( +
+ +
+ ); } return ( diff --git a/core/app/components/Signup/JoinCommunityForm.tsx b/core/app/components/Signup/JoinCommunityForm.tsx index bd916452ec..73579e7899 100644 --- a/core/app/components/Signup/JoinCommunityForm.tsx +++ b/core/app/components/Signup/JoinCommunityForm.tsx @@ -1,5 +1,7 @@ "use client"; +import { useCallback } from "react"; +import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import type { Communities } from "db/public"; @@ -8,18 +10,30 @@ import { Button } from "ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "ui/card"; import { Form } from "ui/form"; +import { publicJoinCommunity } from "~/lib/authentication/actions"; +import { useServerAction } from "~/lib/serverActions"; + 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 onSubmit = useCallback(async () => { + await runJoin(); + router.push(redirectTo ?? `/c/${community.slug}`); + }, [redirectTo, runJoin]); return (
- + Join {community.name} @@ -30,7 +44,7 @@ export const JoinCommunityForm = ({
From a57ed774562d20b1a9ce20928caa611b7174e85d Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Wed, 26 Mar 2025 18:29:25 +0100 Subject: [PATCH 18/43] refactor: auto log signup errors --- core/lib/authentication/actions.ts | 5 +- core/lib/authentication/errors.ts | 57 ++++++++---- core/lib/authentication/signup.ts | 142 +++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 21 deletions(-) create mode 100644 core/lib/authentication/signup.ts diff --git a/core/lib/authentication/actions.ts b/core/lib/authentication/actions.ts index 24ffdb1b57..2412ae3591 100644 --- a/core/lib/authentication/actions.ts +++ b/core/lib/authentication/actions.ts @@ -246,6 +246,9 @@ export const publicJoinCommunity = defineServerAction(async function joinCommuni await findCommunityBySlug(), ]); + // TODO: base this off the invite token + const toBeGrantedRole = MemberRole.contributor; + if (!community) { return SignupErrors.COMMUNITY_NOT_FOUND({ communityName: "unknown" }); } @@ -267,7 +270,7 @@ export const publicJoinCommunity = defineServerAction(async function joinCommuni const member = await insertCommunityMember({ userId: user.id, communityId: community.id, - role: MemberRole.contributor, + role: toBeGrantedRole, }).executeTakeFirstOrThrow(); return { diff --git a/core/lib/authentication/errors.ts b/core/lib/authentication/errors.ts index af0478553f..2655656408 100644 --- a/core/lib/authentication/errors.ts +++ b/core/lib/authentication/errors.ts @@ -1,3 +1,7 @@ +import * as Sentry from "@sentry/nextjs"; + +import { logger } from "logger"; + export const SIGNUP_ERRORS = [ "NOT_LOGGED_IN", "ALREADY_MEMBER", @@ -7,27 +11,40 @@ export const SIGNUP_ERRORS = [ ] 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 }) => ({ - error: `You must be logged in to join ${props.communityName}`, - type: "NOT_LOGGED_IN" as const, - }), - ALREADY_MEMBER: (props: { communityName: string }) => ({ - error: `You are already a member of ${props.communityName}`, - type: "ALREADY_MEMBER" as const, - }), - NOT_ALLOWED: (props: { communityName: string }) => ({ - error: `Public signups are not allowed for ${props.communityName}`, - type: "NOT_ALLOWED" as const, - }), - COMMUNITY_NOT_FOUND: (props: { communityName: string }) => ({ - error: `Community not found`, - type: "COMMUNITY_NOT_FOUND" as const, - }), - EMAIL_ALREADY_EXISTS: (props: { email: string }) => ({ - error: `Email ${props.email} already exists`, - type: "EMAIL_ALREADY_EXISTS" as const, - }), + 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} already exists`), } as const satisfies { [E in SIGNUP_ERROR]: | ((props: { communityName: string }) => { diff --git a/core/lib/authentication/signup.ts b/core/lib/authentication/signup.ts new file mode 100644 index 0000000000..46c6794ffc --- /dev/null +++ b/core/lib/authentication/signup.ts @@ -0,0 +1,142 @@ +import type { + CommunitiesId, + FormsId, + Invites, + InvitesId, + MemberRole, + PubsId, + StagesId, + UsersId, +} from "db/public"; + +export class InviteService { + // Starting points - factory methods + static inviteByEmail(email: string) { + return new InviteBuilder().forEmail(email); + } + + static inviteByUserId(userId: UsersId) { + return new InviteBuilder().forUser(userId); + } + + // The rest of the service methods for managing invites + async getInviteByToken(token: string) {} + async acceptInvite(token: string) {} + async revokeInvite(inviteId: InvitesId, reason?: string) {} +} + +// Builder for fluent invitation creation +class InviteBuilder { + private inviteData: Partial = { + status: "CREATED", + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // Default 7 days + }; + + forEmail(email: string) { + this.inviteData.email = email; + return this; + } + + forUser(userId: UsersId) { + this.inviteData.userId = userId; + return this; + } + + forCommunity(communityId: CommunitiesId) { + this.inviteData.communityId = communityId; + return this; + } + + forPub(pubId: PubsId) { + this.inviteData.pubId = pubId; + return this; + } + + forForm(formId: FormsId) { + this.inviteData.formId = formId; + return this; + } + + forStage(stageId: StagesId) { + this.inviteData.stageId = stageId; + return this; + } + + as(role: MemberRole) { + if (this.inviteData.pubId) { + this.inviteData.otherRole = role; + } else { + this.inviteData.communityRole = role; + } + return this; + } + + withMessage(message: string) { + this.inviteData.message = message; + return this; + } + + expires(date: Date) { + this.inviteData.expiresAt = date; + return this; + } + + expiresInDays(days: number) { + this.inviteData.expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000); + return this; + } + + async create() { + // Validate required fields + this.validateInviteData(); + + // Generate token + this.inviteData.token = await this.generateUniqueToken(); + + // Create in database + const invite = await prisma.invite.create({ + data: this.inviteData as any, + }); + + return invite; + } + + async send() { + const invite = await this.create(); + + // Send email + await sendInviteEmail(invite); + + // Update status + await prisma.invite.update({ + where: { id: invite.id }, + data: { + status: "SENT", + sentAt: new Date(), + lastSentAt: new Date(), + sendAttempts: 1, + }, + }); + + return invite; + } + + private validateInviteData() { + // Make sure we have either email or userId + if (!this.inviteData.email && !this.inviteData.userId) { + throw new Error("Invite must have either email or userId"); + } + + // Make sure we have communityId + if (!this.inviteData.communityId) { + throw new Error("Invite must have a community"); + } + + // Additional validations as needed + } + + private async generateUniqueToken(): Promise { + // Generate unique token for the invite + // ... + } +} From 4c57700cd1ee4e319bbfd2106ad1c1160951c51e Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Wed, 26 Mar 2025 18:29:40 +0100 Subject: [PATCH 19/43] dev: add a bunch of tests for the public form --- core/playwright/fixtures/login-page.ts | 5 +- core/playwright/formAccess.spec.ts | 143 ++++++++++++++++++++++--- core/playwright/helpers.ts | 6 ++ 3 files changed, 140 insertions(+), 14 deletions(-) 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 index febc8fefb5..390df066d1 100644 --- a/core/playwright/formAccess.spec.ts +++ b/core/playwright/formAccess.spec.ts @@ -1,21 +1,18 @@ +import { describe } from "node:test"; + import type { Page } from "@playwright/test"; +import { faker } from "@faker-js/faker"; import { expect, test } from "@playwright/test"; import type { UsersId } from "db/public"; -import { CoreSchemaType, ElementType, InputComponent, MemberRole } from "db/public"; +import { CoreSchemaType, ElementType, FormAccessType, InputComponent, MemberRole } from "db/public"; import type { CommunitySeedOutput } from "~/prisma/seed/createSeed"; import { createSeed } from "~/prisma/seed/createSeed"; import { seedCommunity } from "~/prisma/seed/seedCommunity"; -import { FieldsPage } from "./fixtures/fields-page"; -import { FormsEditPage } from "./fixtures/forms-edit-page"; -import { FormsPage } from "./fixtures/forms-page"; import { LoginPage } from "./fixtures/login-page"; -import { PubDetailsPage } from "./fixtures/pub-details-page"; -import { PubTypesPage } from "./fixtures/pub-types-page"; -import { PubsPage } from "./fixtures/pubs-page"; -import { createBaseSeed, PubFieldsOfEachType } from "./helpers"; +import { PubFieldsOfEachType, waitForBaseCommunityPage } from "./helpers"; test.describe.configure({ mode: "serial" }); @@ -23,6 +20,7 @@ let page: Page; const jimothyId = crypto.randomUUID() as UsersId; const crossUserId = crypto.randomUUID() as UsersId; +const password = "password"; const seed = createSeed({ community: { name: "test community", @@ -38,7 +36,19 @@ const seed = createSeed({ ...PubFieldsOfEachType, }, users: { - admin: {}, + admin: { + role: MemberRole.admin, + password, + }, + cross: { + id: crossUserId, + role: MemberRole.admin, + password, + }, + baseMember: { + role: MemberRole.contributor, + password, + }, }, pubTypes: { Submission: { @@ -84,6 +94,7 @@ const seed = createSeed({ forms: { Evaluation: { slug: "evaluation", + access: FormAccessType.public, pubType: "Evaluation", elements: [ { @@ -134,15 +145,44 @@ const seed2 = createSeed({ name: "test community 2", slug: "test-community-2", }, + pubFields: { + Title: { + schemaName: CoreSchemaType.String, + }, + }, + pubTypes: { + Submission: { + Title: { isTitle: true }, + }, + }, users: { jimothy: { id: jimothyId, - role: MemberRole.admin, + role: MemberRole.editor, + password, }, cross: { id: crossUserId, existing: true, role: MemberRole.admin, + password, + }, + }, + forms: { + "Simple Private": { + slug: "simple-private", + pubType: "Submission", + access: FormAccessType.private, + elements: [ + { + type: ElementType.pubfield, + field: "Title", + component: InputComponent.textInput, + config: { + label: "Title", + }, + }, + ], }, }, }); @@ -155,8 +195,85 @@ test.beforeAll(async ({ browser }) => { community2 = await seedCommunity(seed2); page = await browser.newPage(); +}); + +describe("public signup ", () => { + describe("single community cases", () => { + test("non-users are not able to signup for communities with private forms", async () => { + 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 () => { + await page.goto(`/c/${community.community.slug}/public/signup`); + await expect(page.getByRole("heading", { name: "Sign up" })).toBeVisible(); + }); + + test("signed in community members should be redirected to base community page instead if no redirect is set", async () => { + 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("non-users are redirected to the signup page for public forms", async () => { + 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}` + ); + }); + }); + + describe("cross community cases", () => { + test("signed in users from outside the community should see a join form instead of a signup form", async () => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.loginAndWaitForNavigation(community2.users.jimothy.email, password); + + await page.waitForTimeout(1_000); + const signup = await page.goto(`/c/${community.community.slug}/public/signup`); + await page.waitForURL(`/c/${community.community.slug}/public/signup`); + await page.waitForTimeout(1_000); + await page + .getByRole("button", { name: `Join ${community.community.name}` }) + .waitFor({ state: "visible", timeout: 5_000 }); + + test.step("users from outside the community should be able to join the community", async () => { + await page + .getByRole("button", { name: `Join ${community.community.name}` }) + .click(); + await page.waitForURL(`/c/${community.community.slug}`); + }); + }); + }); +}); + +describe("public forms", () => { + test("non-users are able to signup for communityies and fill out public forms", async () => { + test.step("non-users are able to access the public form", async () => { + await page.goto( + `/c/${community.community.slug}/public/forms/${community.forms.Evaluation.slug}/fill` + ); + await page.waitForURL(`/c/${community.community.slug}/public/signup`); + }); + + test.step("non-users are able to signup for the community", async () => { + 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()); + await page.getByRole("button", { name: "Sign up" }).click(); + await page.waitForURL(`/c/${community.community.slug}/stages`); + }); - const loginPage = new LoginPage(page); - await loginPage.goto(); - await loginPage.loginAndWaitForNavigation(community.users.admin.email, "password"); + 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(); + }); + }); }); 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, + }); +}; From 04d34e2dcfbe77b2d65a2c7ce713fb3ea2db1f7b Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 12:17:03 +0100 Subject: [PATCH 20/43] chore: remove signup service experiment --- core/lib/authentication/signup.ts | 142 ------------------------------ 1 file changed, 142 deletions(-) delete mode 100644 core/lib/authentication/signup.ts diff --git a/core/lib/authentication/signup.ts b/core/lib/authentication/signup.ts deleted file mode 100644 index 46c6794ffc..0000000000 --- a/core/lib/authentication/signup.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { - CommunitiesId, - FormsId, - Invites, - InvitesId, - MemberRole, - PubsId, - StagesId, - UsersId, -} from "db/public"; - -export class InviteService { - // Starting points - factory methods - static inviteByEmail(email: string) { - return new InviteBuilder().forEmail(email); - } - - static inviteByUserId(userId: UsersId) { - return new InviteBuilder().forUser(userId); - } - - // The rest of the service methods for managing invites - async getInviteByToken(token: string) {} - async acceptInvite(token: string) {} - async revokeInvite(inviteId: InvitesId, reason?: string) {} -} - -// Builder for fluent invitation creation -class InviteBuilder { - private inviteData: Partial = { - status: "CREATED", - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // Default 7 days - }; - - forEmail(email: string) { - this.inviteData.email = email; - return this; - } - - forUser(userId: UsersId) { - this.inviteData.userId = userId; - return this; - } - - forCommunity(communityId: CommunitiesId) { - this.inviteData.communityId = communityId; - return this; - } - - forPub(pubId: PubsId) { - this.inviteData.pubId = pubId; - return this; - } - - forForm(formId: FormsId) { - this.inviteData.formId = formId; - return this; - } - - forStage(stageId: StagesId) { - this.inviteData.stageId = stageId; - return this; - } - - as(role: MemberRole) { - if (this.inviteData.pubId) { - this.inviteData.otherRole = role; - } else { - this.inviteData.communityRole = role; - } - return this; - } - - withMessage(message: string) { - this.inviteData.message = message; - return this; - } - - expires(date: Date) { - this.inviteData.expiresAt = date; - return this; - } - - expiresInDays(days: number) { - this.inviteData.expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000); - return this; - } - - async create() { - // Validate required fields - this.validateInviteData(); - - // Generate token - this.inviteData.token = await this.generateUniqueToken(); - - // Create in database - const invite = await prisma.invite.create({ - data: this.inviteData as any, - }); - - return invite; - } - - async send() { - const invite = await this.create(); - - // Send email - await sendInviteEmail(invite); - - // Update status - await prisma.invite.update({ - where: { id: invite.id }, - data: { - status: "SENT", - sentAt: new Date(), - lastSentAt: new Date(), - sendAttempts: 1, - }, - }); - - return invite; - } - - private validateInviteData() { - // Make sure we have either email or userId - if (!this.inviteData.email && !this.inviteData.userId) { - throw new Error("Invite must have either email or userId"); - } - - // Make sure we have communityId - if (!this.inviteData.communityId) { - throw new Error("Invite must have a community"); - } - - // Additional validations as needed - } - - private async generateUniqueToken(): Promise { - // Generate unique token for the invite - // ... - } -} From 0a65544719207f658a1d98a15dd304765bb91def Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 13:01:02 +0100 Subject: [PATCH 21/43] chore: remove next plugin bc it crashes vscode --- core/tsconfig.json | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/core/tsconfig.json b/core/tsconfig.json index 3855c7d4fb..2898eaf8cc 100644 --- a/core/tsconfig.json +++ b/core/tsconfig.json @@ -5,11 +5,8 @@ "allowImportingTsExtensions": true, "noErrorTruncation": true, "allowSyntheticDefaultImports": true, - "plugins": [ - { - "name": "next" - } - ], + // maybe add back next plugin if it doesnt crash the ts-server all the time + "plugins": [], "baseUrl": ".", "paths": { "~/*": ["./*"] @@ -17,11 +14,6 @@ "strictNullChecks": true, "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" }, - "ts-node": { - "compilerOptions": { - "module": "CommonJS" - } - }, "include": [ "next-env.d.ts", "**/*.ts", From 20b83abc498eec803853585786b2d31741389f81 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 13:01:34 +0100 Subject: [PATCH 22/43] dev: fix tests by adding formcontrols around inputs --- core/app/components/Signup/BaseSignupForm.tsx | 37 +++++++--- core/playwright/formAccess.spec.ts | 69 +++++++++++++------ 2 files changed, 74 insertions(+), 32 deletions(-) diff --git a/core/app/components/Signup/BaseSignupForm.tsx b/core/app/components/Signup/BaseSignupForm.tsx index a02eb4014c..1861e53470 100644 --- a/core/app/components/Signup/BaseSignupForm.tsx +++ b/core/app/components/Signup/BaseSignupForm.tsx @@ -13,7 +13,15 @@ import { registerFormats } from "schemas"; import type { Users } from "db/public"; import { Button } from "ui/button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "ui/card"; -import { Form, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "ui/form"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "ui/form"; import { Input } from "ui/input"; registerFormats(); @@ -59,8 +67,9 @@ export function BaseSignupForm(props: { render={({ field }) => ( First name - - + + + )} @@ -71,7 +80,9 @@ export function BaseSignupForm(props: { render={({ field }) => ( Last name - + + + )} @@ -87,12 +98,14 @@ export function BaseSignupForm(props: { If you change this, we will ask you to confirm your email again. - + + + )} @@ -104,7 +117,9 @@ export function BaseSignupForm(props: { render={({ field }) => ( Password - + + + )} diff --git a/core/playwright/formAccess.spec.ts b/core/playwright/formAccess.spec.ts index 390df066d1..59e7b92208 100644 --- a/core/playwright/formAccess.spec.ts +++ b/core/playwright/formAccess.spec.ts @@ -20,11 +20,13 @@ 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: "test-community", + slug: communitySlug, }, pubFields: { Title: { @@ -121,6 +123,12 @@ const seed = createSeed({ label: "Email", }, }, + { + type: ElementType.button, + content: `Go see your pubs [here](/pubs)`, + label: "Submit", + stage: "Evaluating", + }, ], }, "Title Only (default)": { @@ -199,17 +207,34 @@ test.beforeAll(async ({ browser }) => { describe("public signup ", () => { describe("single community cases", () => { - test("non-users are not able to signup for communities with private forms", async () => { + 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 () => { + 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("signed in community members should be redirected to base community page instead if no redirect is set", async () => { + 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); + console.log(res?.url()); + 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); @@ -217,15 +242,6 @@ describe("public signup ", () => { const res = await page.goto(`/c/${community.community.slug}/public/signup`); await waitForBaseCommunityPage(page, community.community.slug); }); - - test("non-users are redirected to the signup page for public forms", async () => { - 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}` - ); - }); }); describe("cross community cases", () => { @@ -254,26 +270,37 @@ describe("public signup ", () => { describe("public forms", () => { test("non-users are able to signup for communityies and fill out public forms", async () => { - test.step("non-users are able to access the public form", async () => { - await page.goto( - `/c/${community.community.slug}/public/forms/${community.forms.Evaluation.slug}/fill` + 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.waitForURL(`/c/${community.community.slug}/public/signup`); + await page.getByRole("heading", { name: "Sign up" }).waitFor({ state: "visible" }); }); - test.step("non-users are able to signup for the community", async () => { - await page.getByLabel("Email").fill(faker.internet.email()); + 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(`/c/${community.community.slug}/stages`); + await page.waitForURL(fillUrl, { timeout: 10_000 }); }); - test.step("non-users are able to fill out the form", async () => { + 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, + }); + console.log(submissionMessage); + await page.getByRole("link", { name: "here" }).click(); + await page.waitForTimeout(2_000); }); }); }); From 90535496e09ed6b29a899d039d3027aad6f0beb0 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 13:11:05 +0100 Subject: [PATCH 23/43] fix: actually remove next plugin --- config/tsconfig/nextjs.json | 1 - core/tsconfig.json | 69 +++++++++++++++++++++---------------- 2 files changed, 39 insertions(+), 31 deletions(-) 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/tsconfig.json b/core/tsconfig.json index 2898eaf8cc..27dca8877f 100644 --- a/core/tsconfig.json +++ b/core/tsconfig.json @@ -1,32 +1,41 @@ { - "extends": "tsconfig/nextjs.json", - "compilerOptions": { - "moduleResolution": "Bundler", - "allowImportingTsExtensions": true, - "noErrorTruncation": true, - "allowSyntheticDefaultImports": true, - // maybe add back next plugin if it doesnt crash the ts-server all the time - "plugins": [], - "baseUrl": ".", - "paths": { - "~/*": ["./*"] - }, - "strictNullChecks": true, - "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" - }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - "instrumentation.node.mts", - "../packages/db/scripts/scripts/generateDatabaseObject.mts", - "../packages/db/src/prisma/comment-generator.ts", - "cache-handler.js", - "prisma/generate-history-table.mts", - "prisma/seed.cts", - "prisma/scripts/comment-generator.mts", - "prisma/seed/stubs/*.js" - ], - "exclude": ["node_modules", ".next/types/**/*.ts"] + "extends": "tsconfig/nextjs.json", + "compilerOptions": { + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "noErrorTruncation": true, + "allowSyntheticDefaultImports": true, + // maybe add back next plugin if it doesnt crash the ts-server all the time + "plugins": [ + { + "name": "next" + } + ], + "baseUrl": ".", + "paths": { + "~/*": [ + "./*" + ] + }, + "strictNullChecks": true, + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "instrumentation.node.mts", + "../packages/db/scripts/scripts/generateDatabaseObject.mts", + "../packages/db/src/prisma/comment-generator.ts", + "cache-handler.js", + "prisma/generate-history-table.mts", + "prisma/seed.cts", + "prisma/scripts/comment-generator.mts", + "prisma/seed/stubs/*.js" + ], + "exclude": [ + "node_modules", + ".next/types/**/*.ts" + ] } From dfed56c7bdc6418ea2b419a8e1d33f06b2018bea Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 14:11:07 +0100 Subject: [PATCH 24/43] fix: write some docs on how to deal with next's bullshit --- core/tsconfig.json | 74 ++++++++++------------ docs/content/development/common-issues.mdx | 35 ++++++++++ 2 files changed, 70 insertions(+), 39 deletions(-) diff --git a/core/tsconfig.json b/core/tsconfig.json index 27dca8877f..e127230eba 100644 --- a/core/tsconfig.json +++ b/core/tsconfig.json @@ -1,41 +1,37 @@ { - "extends": "tsconfig/nextjs.json", - "compilerOptions": { - "moduleResolution": "Bundler", - "allowImportingTsExtensions": true, - "noErrorTruncation": true, - "allowSyntheticDefaultImports": true, - // maybe add back next plugin if it doesnt crash the ts-server all the time - "plugins": [ - { - "name": "next" - } - ], - "baseUrl": ".", - "paths": { - "~/*": [ - "./*" - ] - }, - "strictNullChecks": true, - "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" - }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - "instrumentation.node.mts", - "../packages/db/scripts/scripts/generateDatabaseObject.mts", - "../packages/db/src/prisma/comment-generator.ts", - "cache-handler.js", - "prisma/generate-history-table.mts", - "prisma/seed.cts", - "prisma/scripts/comment-generator.mts", - "prisma/seed/stubs/*.js" - ], - "exclude": [ - "node_modules", - ".next/types/**/*.ts" - ] + "extends": "tsconfig/nextjs.json", + "compilerOptions": { + "moduleResolution": "Bundler", + "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" + } + ], + "baseUrl": ".", + "paths": { + "~/*": ["./*"] + }, + "strictNullChecks": true, + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "instrumentation.node.mts", + "../packages/db/scripts/scripts/generateDatabaseObject.mts", + "../packages/db/src/prisma/comment-generator.ts", + "cache-handler.js", + "prisma/generate-history-table.mts", + "prisma/seed.cts", + "prisma/scripts/comment-generator.mts", + "prisma/seed/stubs/*.js" + ], + "exclude": ["node_modules", ".next/types/**/*.ts"] } diff --git a/docs/content/development/common-issues.mdx b/docs/content/development/common-issues.mdx index b5473dd922..c39662e4af 100644 --- a/docs/content/development/common-issues.mdx +++ b/docs/content/development/common-issues.mdx @@ -6,3 +6,38 @@ This might be a caching issue. Did you do a `pnpm reset` when the dev server was 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` From dd433ab55b19e72995c1b70ea129ad40fde376f3 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 14:30:26 +0100 Subject: [PATCH 25/43] feat: add ability to link to current pub and other places with markdown tokens --- core/lib/authentication/signup.ts | 142 ++++++++++++++++++ .../render/pub/renderMarkdownWithPub.ts | 19 ++- .../server/render/pub/renderWithPubUtils.ts | 47 +++++- 3 files changed, 196 insertions(+), 12 deletions(-) create mode 100644 core/lib/authentication/signup.ts diff --git a/core/lib/authentication/signup.ts b/core/lib/authentication/signup.ts new file mode 100644 index 0000000000..46c6794ffc --- /dev/null +++ b/core/lib/authentication/signup.ts @@ -0,0 +1,142 @@ +import type { + CommunitiesId, + FormsId, + Invites, + InvitesId, + MemberRole, + PubsId, + StagesId, + UsersId, +} from "db/public"; + +export class InviteService { + // Starting points - factory methods + static inviteByEmail(email: string) { + return new InviteBuilder().forEmail(email); + } + + static inviteByUserId(userId: UsersId) { + return new InviteBuilder().forUser(userId); + } + + // The rest of the service methods for managing invites + async getInviteByToken(token: string) {} + async acceptInvite(token: string) {} + async revokeInvite(inviteId: InvitesId, reason?: string) {} +} + +// Builder for fluent invitation creation +class InviteBuilder { + private inviteData: Partial = { + status: "CREATED", + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // Default 7 days + }; + + forEmail(email: string) { + this.inviteData.email = email; + return this; + } + + forUser(userId: UsersId) { + this.inviteData.userId = userId; + return this; + } + + forCommunity(communityId: CommunitiesId) { + this.inviteData.communityId = communityId; + return this; + } + + forPub(pubId: PubsId) { + this.inviteData.pubId = pubId; + return this; + } + + forForm(formId: FormsId) { + this.inviteData.formId = formId; + return this; + } + + forStage(stageId: StagesId) { + this.inviteData.stageId = stageId; + return this; + } + + as(role: MemberRole) { + if (this.inviteData.pubId) { + this.inviteData.otherRole = role; + } else { + this.inviteData.communityRole = role; + } + return this; + } + + withMessage(message: string) { + this.inviteData.message = message; + return this; + } + + expires(date: Date) { + this.inviteData.expiresAt = date; + return this; + } + + expiresInDays(days: number) { + this.inviteData.expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000); + return this; + } + + async create() { + // Validate required fields + this.validateInviteData(); + + // Generate token + this.inviteData.token = await this.generateUniqueToken(); + + // Create in database + const invite = await prisma.invite.create({ + data: this.inviteData as any, + }); + + return invite; + } + + async send() { + const invite = await this.create(); + + // Send email + await sendInviteEmail(invite); + + // Update status + await prisma.invite.update({ + where: { id: invite.id }, + data: { + status: "SENT", + sentAt: new Date(), + lastSentAt: new Date(), + sendAttempts: 1, + }, + }); + + return invite; + } + + private validateInviteData() { + // Make sure we have either email or userId + if (!this.inviteData.email && !this.inviteData.userId) { + throw new Error("Invite must have either email or userId"); + } + + // Make sure we have communityId + if (!this.inviteData.communityId) { + throw new Error("Invite must have a community"); + } + + // Additional validations as needed + } + + private async generateUniqueToken(): Promise { + // Generate unique token for the invite + // ... + } +} diff --git a/core/lib/server/render/pub/renderMarkdownWithPub.ts b/core/lib/server/render/pub/renderMarkdownWithPub.ts index ec589e5bdc..ae9efa78d8 100644 --- a/core/lib/server/render/pub/renderMarkdownWithPub.ts +++ b/core/lib/server/render/pub/renderMarkdownWithPub.ts @@ -19,6 +19,7 @@ import type { PubsId } from "db/public"; import { CoreSchemaType } from "db/public"; import { expect } from "utils"; +import type { LinkOptions } from "./renderWithPubUtils"; import { hydratePubValues } from "~/lib/fields/utils"; import { getPubTitle } from "~/lib/pubs"; import { getExclusivelyRelatedPub } from "../../pub"; @@ -161,7 +162,7 @@ const visitRecipientLastNameDirective = (node: Directive, context: utils.RenderW const visitLinkDirective = (node: Directive, context: utils.RenderWithPubContext) => { // `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"); } From 50bc7c65ff8b5cc39466b16fa25c746e5df06c70 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 16:10:27 +0100 Subject: [PATCH 26/43] dev: more tests! --- .../public/forms/[formSlug]/fill/page.tsx | 6 + .../[communitySlug]/public/signup/page.tsx | 2 +- .../components/Signup/JoinCommunityForm.tsx | 18 ++- core/lib/authentication/actions.ts | 1 + core/lib/authentication/signup.ts | 142 ------------------ core/playwright/formAccess.spec.ts | 60 +++++++- 6 files changed, 77 insertions(+), 152 deletions(-) delete mode 100644 core/lib/authentication/signup.ts 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 ea3fc745ba..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 @@ -191,6 +191,12 @@ 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(); } diff --git a/core/app/c/(public)/[communitySlug]/public/signup/page.tsx b/core/app/c/(public)/[communitySlug]/public/signup/page.tsx index a49fa72445..5af93451dd 100644 --- a/core/app/c/(public)/[communitySlug]/public/signup/page.tsx +++ b/core/app/c/(public)/[communitySlug]/public/signup/page.tsx @@ -47,7 +47,7 @@ export default async function Page({ return (
- +
); } diff --git a/core/app/components/Signup/JoinCommunityForm.tsx b/core/app/components/Signup/JoinCommunityForm.tsx index 73579e7899..18f756fda6 100644 --- a/core/app/components/Signup/JoinCommunityForm.tsx +++ b/core/app/components/Signup/JoinCommunityForm.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useForm } from "react-hook-form"; import type { Communities } from "db/public"; @@ -9,6 +9,7 @@ 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"; @@ -25,11 +26,20 @@ export const JoinCommunityForm = ({ 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 () => { - await runJoin(); - router.push(redirectTo ?? `/c/${community.slug}`); - }, [redirectTo, runJoin]); + const result = await runJoin(); + if ("success" in result) { + toast({ + title: "Success", + description: result.report, + }); + router.push(redirectPath); + } + }, [redirectPath, runJoin]); return ( diff --git a/core/lib/authentication/actions.ts b/core/lib/authentication/actions.ts index 2412ae3591..d1fd7c9184 100644 --- a/core/lib/authentication/actions.ts +++ b/core/lib/authentication/actions.ts @@ -273,6 +273,7 @@ export const publicJoinCommunity = defineServerAction(async function joinCommuni role: toBeGrantedRole, }).executeTakeFirstOrThrow(); + // don't redirect, better to do it client side, better ux return { success: true, report: `You have joined ${community.name}`, diff --git a/core/lib/authentication/signup.ts b/core/lib/authentication/signup.ts deleted file mode 100644 index 46c6794ffc..0000000000 --- a/core/lib/authentication/signup.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { - CommunitiesId, - FormsId, - Invites, - InvitesId, - MemberRole, - PubsId, - StagesId, - UsersId, -} from "db/public"; - -export class InviteService { - // Starting points - factory methods - static inviteByEmail(email: string) { - return new InviteBuilder().forEmail(email); - } - - static inviteByUserId(userId: UsersId) { - return new InviteBuilder().forUser(userId); - } - - // The rest of the service methods for managing invites - async getInviteByToken(token: string) {} - async acceptInvite(token: string) {} - async revokeInvite(inviteId: InvitesId, reason?: string) {} -} - -// Builder for fluent invitation creation -class InviteBuilder { - private inviteData: Partial = { - status: "CREATED", - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // Default 7 days - }; - - forEmail(email: string) { - this.inviteData.email = email; - return this; - } - - forUser(userId: UsersId) { - this.inviteData.userId = userId; - return this; - } - - forCommunity(communityId: CommunitiesId) { - this.inviteData.communityId = communityId; - return this; - } - - forPub(pubId: PubsId) { - this.inviteData.pubId = pubId; - return this; - } - - forForm(formId: FormsId) { - this.inviteData.formId = formId; - return this; - } - - forStage(stageId: StagesId) { - this.inviteData.stageId = stageId; - return this; - } - - as(role: MemberRole) { - if (this.inviteData.pubId) { - this.inviteData.otherRole = role; - } else { - this.inviteData.communityRole = role; - } - return this; - } - - withMessage(message: string) { - this.inviteData.message = message; - return this; - } - - expires(date: Date) { - this.inviteData.expiresAt = date; - return this; - } - - expiresInDays(days: number) { - this.inviteData.expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000); - return this; - } - - async create() { - // Validate required fields - this.validateInviteData(); - - // Generate token - this.inviteData.token = await this.generateUniqueToken(); - - // Create in database - const invite = await prisma.invite.create({ - data: this.inviteData as any, - }); - - return invite; - } - - async send() { - const invite = await this.create(); - - // Send email - await sendInviteEmail(invite); - - // Update status - await prisma.invite.update({ - where: { id: invite.id }, - data: { - status: "SENT", - sentAt: new Date(), - lastSentAt: new Date(), - sendAttempts: 1, - }, - }); - - return invite; - } - - private validateInviteData() { - // Make sure we have either email or userId - if (!this.inviteData.email && !this.inviteData.userId) { - throw new Error("Invite must have either email or userId"); - } - - // Make sure we have communityId - if (!this.inviteData.communityId) { - throw new Error("Invite must have a community"); - } - - // Additional validations as needed - } - - private async generateUniqueToken(): Promise { - // Generate unique token for the invite - // ... - } -} diff --git a/core/playwright/formAccess.spec.ts b/core/playwright/formAccess.spec.ts index 59e7b92208..97e2c61a1f 100644 --- a/core/playwright/formAccess.spec.ts +++ b/core/playwright/formAccess.spec.ts @@ -5,7 +5,7 @@ import type { Page } from "@playwright/test"; import { faker } from "@faker-js/faker"; import { expect, test } from "@playwright/test"; -import type { UsersId } from "db/public"; +import type { PubsId, UsersId } from "db/public"; import { CoreSchemaType, ElementType, FormAccessType, InputComponent, MemberRole } from "db/public"; import type { CommunitySeedOutput } from "~/prisma/seed/createSeed"; @@ -125,7 +125,7 @@ const seed = createSeed({ }, { type: ElementType.button, - content: `Go see your pubs [here](/pubs)`, + content: `Go see your pubs :link{page='currentPub' text='here'}`, label: "Submit", stage: "Evaluating", }, @@ -269,7 +269,9 @@ describe("public signup ", () => { }); describe("public forms", () => { - test("non-users are able to signup for communityies and fill out public forms", async () => { + 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); @@ -291,6 +293,7 @@ describe("public forms", () => { 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"); @@ -298,9 +301,56 @@ describe("public forms", () => { const submissionMessage = await page.getByText("Go see you").textContent({ timeout: 1_000, }); - console.log(submissionMessage); + 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.waitForTimeout(2_000); + 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}`).waitFor({ + state: "visible", + timeout: 10_000, + }); + await page.waitForURL(fillUrl, { timeout: 10_000 }); }); }); }); From 4c19832401e53b30274c218002a78f08d560ceef Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 16:26:21 +0100 Subject: [PATCH 27/43] chore: update test --- core/playwright/formAccess.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/core/playwright/formAccess.spec.ts b/core/playwright/formAccess.spec.ts index 97e2c61a1f..935f2c67ea 100644 --- a/core/playwright/formAccess.spec.ts +++ b/core/playwright/formAccess.spec.ts @@ -225,7 +225,6 @@ describe("public signup ", () => { const fillUrl = `/c/${community.community.slug}/public/forms/${community.forms.Evaluation.slug}/fill`; const res = await page.goto(fillUrl); - console.log(res?.url()); await page.waitForURL( `/c/${community.community.slug}/public/signup?redirectTo=${fillUrl}`, { timeout: 10_000 } From 7e7f8a1261765ddc409cbe4f392156c195ca7c56 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 16:56:26 +0100 Subject: [PATCH 28/43] fix: use maybeWithTransaction instead of directly creating transactiont to make test pass --- core/app/components/pubs/PubEditor/actions.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/core/app/components/pubs/PubEditor/actions.ts b/core/app/components/pubs/PubEditor/actions.ts index 1bf511914b..17a3e6093f 100644 --- a/core/app/components/pubs/PubEditor/actions.ts +++ b/core/app/components/pubs/PubEditor/actions.ts @@ -15,7 +15,7 @@ import { ApiError, createPubRecursiveNew } from "~/lib/server"; import { findCommunityBySlug } from "~/lib/server/community"; import { defineServerAction } from "~/lib/server/defineServerAction"; import { addMemberToForm, getForm, userHasPermissionToForm } from "~/lib/server/form"; -import { deletePub, normalizePubValues } from "~/lib/server/pub"; +import { deletePub, maybeWithTrx, normalizePubValues } from "~/lib/server/pub"; import { PubOp } from "~/lib/server/pub-op"; type CreatePubRecursiveProps = Omit[0], "lastModifiedBy">; @@ -57,9 +57,8 @@ export const createPubRecursive = defineServerAction(async function createPubRec }); try { - const trx = db.transaction(); - - const result = await trx.execute(async (trx) => { + // need this in order to test it properly + const result = await maybeWithTrx(db, async (trx) => { const createdPub = await createPubRecursiveNew({ ...createPubProps, body: { From 7d99902063f6d975246d5f1644a8ac3649180fe4 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 17:52:54 +0100 Subject: [PATCH 29/43] fix: remove superfluous test --- core/playwright/formAccess.spec.ts | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/core/playwright/formAccess.spec.ts b/core/playwright/formAccess.spec.ts index 935f2c67ea..f91cd97976 100644 --- a/core/playwright/formAccess.spec.ts +++ b/core/playwright/formAccess.spec.ts @@ -242,29 +242,6 @@ describe("public signup ", () => { await waitForBaseCommunityPage(page, community.community.slug); }); }); - - describe("cross community cases", () => { - test("signed in users from outside the community should see a join form instead of a signup form", async () => { - const loginPage = new LoginPage(page); - await loginPage.goto(); - await loginPage.loginAndWaitForNavigation(community2.users.jimothy.email, password); - - await page.waitForTimeout(1_000); - const signup = await page.goto(`/c/${community.community.slug}/public/signup`); - await page.waitForURL(`/c/${community.community.slug}/public/signup`); - await page.waitForTimeout(1_000); - await page - .getByRole("button", { name: `Join ${community.community.name}` }) - .waitFor({ state: "visible", timeout: 5_000 }); - - test.step("users from outside the community should be able to join the community", async () => { - await page - .getByRole("button", { name: `Join ${community.community.name}` }) - .click(); - await page.waitForURL(`/c/${community.community.slug}`); - }); - }); - }); }); describe("public forms", () => { From 8b86f1f18dd978de78d5622e7c0ede14d322066c Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 19:27:55 +0100 Subject: [PATCH 30/43] fix: add authentication and add nicer submit button --- core/app/components/Signup/BaseSignupForm.tsx | 34 ++-- .../components/Signup/JoinCommunityForm.tsx | 9 +- .../components/Signup/LegacySignupForm.tsx | 4 +- .../components/Signup/PublicSignupForm.tsx | 7 +- core/app/components/Signup/schema.ts | 21 +++ core/app/components/SubmitButton.tsx | 176 ++++++++++++++++++ core/lib/__tests__/transactions.ts | 69 +++++++ core/lib/__tests__/utils.ts | 68 +------ core/lib/authentication/actions.ts | 22 +++ core/lib/authentication/errors.ts | 2 +- core/playwright/formAccess.spec.ts | 150 ++++++++++++++- 11 files changed, 461 insertions(+), 101 deletions(-) create mode 100644 core/app/components/Signup/schema.ts create mode 100644 core/app/components/SubmitButton.tsx create mode 100644 core/lib/__tests__/transactions.ts diff --git a/core/app/components/Signup/BaseSignupForm.tsx b/core/app/components/Signup/BaseSignupForm.tsx index 1861e53470..f3e0079b84 100644 --- a/core/app/components/Signup/BaseSignupForm.tsx +++ b/core/app/components/Signup/BaseSignupForm.tsx @@ -1,12 +1,8 @@ "use client"; -import type { Static } from "@sinclair/typebox"; - -import React, { useCallback, useMemo } from "react"; +import { useMemo } from "react"; import Link from "next/link"; -import { useSearchParams } from "next/navigation"; import { typeboxResolver } from "@hookform/resolvers/typebox"; -import { Type } from "@sinclair/typebox"; import { useForm } from "react-hook-form"; import { registerFormats } from "schemas"; @@ -24,26 +20,20 @@ import { } from "ui/form"; import { Input } from "ui/input"; -registerFormats(); +import type { SignupFormSchema } from "./schema"; +import { FormSubmitButton, SubmitButton } from "../SubmitButton"; +import { compiledSignupFormSchema } from "./schema"; -export const formSchema = Type.Object({ - firstName: Type.String(), - lastName: Type.String(), - email: Type.String({ format: "email" }), - password: Type.String({ - minLength: 8, - maxLength: 72, - }), -}); +registerFormats(); export function BaseSignupForm(props: { user: Pick | null; - onSubmit: (data: Static) => Promise; + onSubmit: (data: SignupFormSchema) => Promise; redirectTo?: string; }) { - 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 }, }); @@ -125,9 +115,11 @@ export function BaseSignupForm(props: { )} /> - +
{/*
Already have an account?{" "} diff --git a/core/app/components/Signup/JoinCommunityForm.tsx b/core/app/components/Signup/JoinCommunityForm.tsx index 18f756fda6..fef1729527 100644 --- a/core/app/components/Signup/JoinCommunityForm.tsx +++ b/core/app/components/Signup/JoinCommunityForm.tsx @@ -13,6 +13,7 @@ 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, @@ -53,9 +54,11 @@ export const JoinCommunityForm = ({
- +
diff --git a/core/app/components/Signup/LegacySignupForm.tsx b/core/app/components/Signup/LegacySignupForm.tsx index 33ade43170..c85deea28f 100644 --- a/core/app/components/Signup/LegacySignupForm.tsx +++ b/core/app/components/Signup/LegacySignupForm.tsx @@ -9,14 +9,14 @@ import type { Users } from "db/public"; import { legacySignup } from "~/lib/authentication/actions"; import { useServerAction } from "~/lib/serverActions"; -import { BaseSignupForm, formSchema } from "./BaseSignupForm"; +import { BaseSignupForm, signupFormSchema } from "./BaseSignupForm"; export function LegacySignupForm(props: { user: Pick; }) { const signup = useServerAction(legacySignup); const searchParams = useSearchParams(); - const onSubmit = useCallback(async (data: Static) => { + const onSubmit = useCallback(async (data: Static) => { await signup({ id: props.user.id, firstName: data.firstName, diff --git a/core/app/components/Signup/PublicSignupForm.tsx b/core/app/components/Signup/PublicSignupForm.tsx index bfe535b9ef..3d95e5e3e5 100644 --- a/core/app/components/Signup/PublicSignupForm.tsx +++ b/core/app/components/Signup/PublicSignupForm.tsx @@ -6,17 +6,20 @@ import { useCallback } from "react"; import { useSearchParams } from "next/navigation"; import type { CommunitiesId } from "db/public"; +import { toast } from "ui/use-toast"; +import type { SignupFormSchema } from "./schema"; import { publicSignup } from "~/lib/authentication/actions"; import { useServerAction } from "~/lib/serverActions"; -import { BaseSignupForm, formSchema } from "./BaseSignupForm"; +import { BaseSignupForm } from "./BaseSignupForm"; export function PublicSignupForm(props: { communityId: CommunitiesId }) { const runSignup = useServerAction(publicSignup); const searchParams = useSearchParams(); - const handleSubmit = useCallback(async (data: Static) => { + const handleSubmit = useCallback(async (data: SignupFormSchema) => { + // TODO: this is not very nice UX await runSignup({ firstName: data.firstName, lastName: data.lastName, 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..0c5b2fbf18 --- /dev/null +++ b/core/app/components/SubmitButton.tsx @@ -0,0 +1,176 @@ +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 ( + + ); +}; + +// convenience wrapper specifically for react-hook-form +export const FormSubmitButton = ({ + formState, + idleText = "Submit", + loadingText = "Submitting...", + successText = "Success!", + errorText = "Error", + className = "", +}: { + formState: FormState; + idleText?: string; + loadingText?: string; + successText?: string; + errorText?: string; + className?: string; +}) => { + 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 d1fd7c9184..d6499bdfb7 100644 --- a/core/lib/authentication/actions.ts +++ b/core/lib/authentication/actions.ts @@ -12,6 +12,7 @@ import { logger } from "logger"; 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"; @@ -308,6 +309,23 @@ export const publicSignup = defineServerAction(async function signup(props: { 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) => { @@ -378,7 +396,11 @@ export const publicSignup = defineServerAction(async function signup(props: { if (props.redirect) { redirect(props.redirect); } + await redirectUser(); + + // typescript cannot sense Promise not returning + return "" as never; }); /** diff --git a/core/lib/authentication/errors.ts b/core/lib/authentication/errors.ts index 2655656408..a35d071265 100644 --- a/core/lib/authentication/errors.ts +++ b/core/lib/authentication/errors.ts @@ -44,7 +44,7 @@ export const SignupErrors = { 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} already exists`), + createAndLogError("EMAIL_ALREADY_EXISTS", `Email ${props.email} is already taken`), } as const satisfies { [E in SIGNUP_ERROR]: | ((props: { communityName: string }) => { diff --git a/core/playwright/formAccess.spec.ts b/core/playwright/formAccess.spec.ts index f91cd97976..da0f89fdf7 100644 --- a/core/playwright/formAccess.spec.ts +++ b/core/playwright/formAccess.spec.ts @@ -1,14 +1,14 @@ -import { describe } from "node:test"; - import type { Page } from "@playwright/test"; import { faker } from "@faker-js/faker"; import { expect, test } from "@playwright/test"; -import type { PubsId, UsersId } from "db/public"; +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"; @@ -148,6 +148,8 @@ const seed = createSeed({ }, }); +const formIdThatllSneakilyBeSwitchedToPublic = crypto.randomUUID() as FormsId; + const seed2 = createSeed({ community: { name: "test community 2", @@ -169,6 +171,10 @@ const seed2 = createSeed({ role: MemberRole.editor, password, }, + becky: { + role: MemberRole.editor, + password, + }, cross: { id: crossUserId, existing: true, @@ -178,6 +184,7 @@ const seed2 = createSeed({ }, forms: { "Simple Private": { + id: formIdThatllSneakilyBeSwitchedToPublic, slug: "simple-private", pubType: "Submission", access: FormAccessType.private, @@ -205,8 +212,8 @@ test.beforeAll(async ({ browser }) => { page = await browser.newPage(); }); -describe("public signup ", () => { - describe("single community cases", () => { +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, }) => { @@ -244,7 +251,7 @@ describe("public signup ", () => { }); }); -describe("public forms", () => { +test.describe("public forms", () => { test("non-users are able to signup for communityies and fill out public forms", async ({ page, }) => { @@ -330,3 +337,134 @@ describe("public forms", () => { }); }); }); + +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(); + + await page.waitForTimeout(1_000); + // 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 db + .updateTable("forms") + .set({ + access: FormAccessType.private, + }) + .where("id", "=", community2.forms["Simple Private"].id) + .execute(); + } + }); + + test("should preserve form data when server validation fails", async ({ page }) => { + const fillUrl = `/c/${community.community.slug}/public/forms/${community.forms.Evaluation.slug}/fill`; + const testData = { + email: "invalid-email", // invalid email to trigger validation + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + password: "test", + }; + + await test.step("fill form with invalid data", async () => { + await page.goto(fillUrl); + await page.getByLabel("Email").fill(testData.email); + await page.getByLabel("Password").fill(testData.password); + await page.getByLabel("First Name").fill(testData.firstName); + await page.getByLabel("Last Name").fill(testData.lastName); + await page.getByRole("button", { name: "Sign up" }).click(); + + // error should be shown in toast + await page.getByText("Invalid email").waitFor({ state: "visible" }); + + // form should preserve valid inputs + await expect(page.getByLabel("First Name")).toHaveValue(testData.firstName); + await expect(page.getByLabel("Last Name")).toHaveValue(testData.lastName); + }); + }); + + test("should handle redirect parameter through error cases", async ({ page }) => { + const fillUrl = `/c/${community.community.slug}/public/forms/${community.forms.Evaluation.slug}/fill`; + + await test.step("preserve redirect after failed attempt", async () => { + await page.goto(fillUrl); + + // Submit with invalid data first + await page.getByLabel("Email").fill("invalid-email"); + await page.getByRole("button", { name: "Sign up" }).click(); + + // After error, fix the data and submit again + 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()); + await page.getByRole("button", { name: "Sign up" }).click(); + + // Should redirect to original form URL + await page.waitForURL(fillUrl); + }); + }); +}); From f6427215200be6146aab6e7f1a167135039fd836 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 19:34:10 +0100 Subject: [PATCH 31/43] fix: remove uneccesary tests --- core/playwright/formAccess.spec.ts | 59 +++--------------------------- 1 file changed, 6 insertions(+), 53 deletions(-) diff --git a/core/playwright/formAccess.spec.ts b/core/playwright/formAccess.spec.ts index da0f89fdf7..d9172072da 100644 --- a/core/playwright/formAccess.spec.ts +++ b/core/playwright/formAccess.spec.ts @@ -329,10 +329,12 @@ test.describe("public forms", () => { 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}`).waitFor({ - state: "visible", - timeout: 10_000, - }); + await page + .getByText(`You have joined ${community.community.name}`, { exact: true }) + .waitFor({ + state: "visible", + timeout: 10_000, + }); await page.waitForURL(fillUrl, { timeout: 10_000 }); }); }); @@ -351,7 +353,6 @@ test.describe("public signup error cases", () => { await page.getByLabel("Last Name").fill(faker.person.lastName()); await page.getByRole("button", { name: "Sign up" }).click(); - await page.waitForTimeout(1_000); // error should be shown in toast const text = `Email ${existingEmail} is already taken`; await page @@ -419,52 +420,4 @@ test.describe("public signup error cases", () => { .execute(); } }); - - test("should preserve form data when server validation fails", async ({ page }) => { - const fillUrl = `/c/${community.community.slug}/public/forms/${community.forms.Evaluation.slug}/fill`; - const testData = { - email: "invalid-email", // invalid email to trigger validation - firstName: faker.person.firstName(), - lastName: faker.person.lastName(), - password: "test", - }; - - await test.step("fill form with invalid data", async () => { - await page.goto(fillUrl); - await page.getByLabel("Email").fill(testData.email); - await page.getByLabel("Password").fill(testData.password); - await page.getByLabel("First Name").fill(testData.firstName); - await page.getByLabel("Last Name").fill(testData.lastName); - await page.getByRole("button", { name: "Sign up" }).click(); - - // error should be shown in toast - await page.getByText("Invalid email").waitFor({ state: "visible" }); - - // form should preserve valid inputs - await expect(page.getByLabel("First Name")).toHaveValue(testData.firstName); - await expect(page.getByLabel("Last Name")).toHaveValue(testData.lastName); - }); - }); - - test("should handle redirect parameter through error cases", async ({ page }) => { - const fillUrl = `/c/${community.community.slug}/public/forms/${community.forms.Evaluation.slug}/fill`; - - await test.step("preserve redirect after failed attempt", async () => { - await page.goto(fillUrl); - - // Submit with invalid data first - await page.getByLabel("Email").fill("invalid-email"); - await page.getByRole("button", { name: "Sign up" }).click(); - - // After error, fix the data and submit again - 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()); - await page.getByRole("button", { name: "Sign up" }).click(); - - // Should redirect to original form URL - await page.waitForURL(fillUrl); - }); - }); }); From 3c87c163bcff879499f6bf30510c0f77e4273bdb Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 19:38:17 +0100 Subject: [PATCH 32/43] fix: preserve redirect correctly --- core/app/components/Signup/BaseSignupForm.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/core/app/components/Signup/BaseSignupForm.tsx b/core/app/components/Signup/BaseSignupForm.tsx index f3e0079b84..326dbee35f 100644 --- a/core/app/components/Signup/BaseSignupForm.tsx +++ b/core/app/components/Signup/BaseSignupForm.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import Link from "next/link"; +import { useSearchParams } from "next/navigation"; import { typeboxResolver } from "@hookform/resolvers/typebox"; import { useForm } from "react-hook-form"; import { registerFormats } from "schemas"; @@ -31,6 +32,10 @@ export function BaseSignupForm(props: { onSubmit: (data: SignupFormSchema) => Promise; redirectTo?: string; }) { + const searchParams = useSearchParams(); + + const redirectTo = props.redirectTo ?? searchParams.get("redirectTo"); + const resolver = useMemo(() => typeboxResolver(compiledSignupFormSchema), []); const form = useForm({ @@ -131,8 +136,8 @@ export function BaseSignupForm(props: { Or{" "} sign in {" "} From c05e26d4102bc8de81f146ead2d52a2cd5df65b7 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 19:38:32 +0100 Subject: [PATCH 33/43] dev: add test for sign in behavior --- core/playwright/formAccess.spec.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/core/playwright/formAccess.spec.ts b/core/playwright/formAccess.spec.ts index d9172072da..6f3f4bf178 100644 --- a/core/playwright/formAccess.spec.ts +++ b/core/playwright/formAccess.spec.ts @@ -420,4 +420,28 @@ test.describe("public signup error cases", () => { .execute(); } }); + + 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); + }); + }); }); From 1e622788da94c05597f27a586f5df2040bd40bea Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 19:39:07 +0100 Subject: [PATCH 34/43] chore: slightly dry out test --- core/playwright/formAccess.spec.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/core/playwright/formAccess.spec.ts b/core/playwright/formAccess.spec.ts index 6f3f4bf178..0d58cffba9 100644 --- a/core/playwright/formAccess.spec.ts +++ b/core/playwright/formAccess.spec.ts @@ -411,13 +411,7 @@ test.describe("public signup error cases", () => { }); } finally { // "Rollback" - await db - .updateTable("forms") - .set({ - access: FormAccessType.private, - }) - .where("id", "=", community2.forms["Simple Private"].id) - .execute(); + await setFormAccess(community2.forms["Simple Private"].id, FormAccessType.public); } }); From efae5f4558da1f0d74147d3233bc865ca62fc20b Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 19:42:24 +0100 Subject: [PATCH 35/43] fix: slight improvements --- core/app/components/Signup/BaseSignupForm.tsx | 4 +-- .../components/Signup/PublicSignupForm.tsx | 35 ++++++++++--------- core/lib/authentication/actions.ts | 8 ++--- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/core/app/components/Signup/BaseSignupForm.tsx b/core/app/components/Signup/BaseSignupForm.tsx index 326dbee35f..d9161d99b1 100644 --- a/core/app/components/Signup/BaseSignupForm.tsx +++ b/core/app/components/Signup/BaseSignupForm.tsx @@ -22,11 +22,9 @@ import { import { Input } from "ui/input"; import type { SignupFormSchema } from "./schema"; -import { FormSubmitButton, SubmitButton } from "../SubmitButton"; +import { FormSubmitButton } from "../SubmitButton"; import { compiledSignupFormSchema } from "./schema"; -registerFormats(); - export function BaseSignupForm(props: { user: Pick | null; onSubmit: (data: SignupFormSchema) => Promise; diff --git a/core/app/components/Signup/PublicSignupForm.tsx b/core/app/components/Signup/PublicSignupForm.tsx index 3d95e5e3e5..228828ed3a 100644 --- a/core/app/components/Signup/PublicSignupForm.tsx +++ b/core/app/components/Signup/PublicSignupForm.tsx @@ -1,12 +1,9 @@ "use client"; -import type { Static } from "@sinclair/typebox"; - import { useCallback } from "react"; import { useSearchParams } from "next/navigation"; import type { CommunitiesId } from "db/public"; -import { toast } from "ui/use-toast"; import type { SignupFormSchema } from "./schema"; import { publicSignup } from "~/lib/authentication/actions"; @@ -17,18 +14,22 @@ export function PublicSignupForm(props: { communityId: CommunitiesId }) { const runSignup = useServerAction(publicSignup); const searchParams = useSearchParams(); - - const handleSubmit = useCallback(async (data: SignupFormSchema) => { - // TODO: this is not very nice UX - await runSignup({ - firstName: data.firstName, - lastName: data.lastName, - email: data.email, - password: data.password, - redirect: searchParams.get("redirectTo"), - communityId: props.communityId, - }); - }, []); - - return ; + const 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/lib/authentication/actions.ts b/core/lib/authentication/actions.ts index d6499bdfb7..e7fbcab692 100644 --- a/core/lib/authentication/actions.ts +++ b/core/lib/authentication/actions.ts @@ -286,7 +286,7 @@ export const publicSignup = defineServerAction(async function signup(props: { lastName: string; email: string; password: string; - redirect: string | null; + redirectTo?: string; slug?: string; role?: MemberRole; communityId: CommunitiesId; @@ -302,7 +302,7 @@ export const publicSignup = defineServerAction(async function signup(props: { } if (user) { - redirect(`/c/${community.slug}/public/join?redirectTo=${props.redirect}`); + redirect(`/c/${community.slug}/public/join?redirectTo=${props.redirectTo}`); } if (!isAllowedSignup) { @@ -393,8 +393,8 @@ export const publicSignup = defineServerAction(async function signup(props: { newSessionCookie.attributes ); - if (props.redirect) { - redirect(props.redirect); + if (props.redirectTo) { + redirect(props.redirectTo); } await redirectUser(); From 96720d30dff97c136c0d78309cb033f5a00c3c31 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 19:43:36 +0100 Subject: [PATCH 36/43] fix: fix type error --- core/app/components/Signup/LegacySignupForm.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/core/app/components/Signup/LegacySignupForm.tsx b/core/app/components/Signup/LegacySignupForm.tsx index c85deea28f..0e71ce71bb 100644 --- a/core/app/components/Signup/LegacySignupForm.tsx +++ b/core/app/components/Signup/LegacySignupForm.tsx @@ -7,16 +7,17 @@ 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, signupFormSchema } from "./BaseSignupForm"; +import { BaseSignupForm } from "./BaseSignupForm"; export function LegacySignupForm(props: { user: Pick; }) { const signup = useServerAction(legacySignup); const searchParams = useSearchParams(); - const onSubmit = useCallback(async (data: Static) => { + const onSubmit = useCallback(async (data: SignupFormSchema) => { await signup({ id: props.user.id, firstName: data.firstName, From d842f33d8d0e12646a0c2661afa196823f19598f Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 20:00:26 +0100 Subject: [PATCH 37/43] fix: give croccroc auto public form --- core/app/components/SubmitButton.tsx | 9 ++++++++- core/prisma/exampleCommunitySeeds/croccroc.ts | 6 ++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/core/app/components/SubmitButton.tsx b/core/app/components/SubmitButton.tsx index 0c5b2fbf18..e6055aba5c 100644 --- a/core/app/components/SubmitButton.tsx +++ b/core/app/components/SubmitButton.tsx @@ -147,7 +147,9 @@ export const SubmitButton = ({ ); }; -// convenience wrapper specifically for react-hook-form +/** + * Form submit button that automatically handles loading state + */ export const FormSubmitButton = ({ formState, idleText = "Submit", @@ -157,6 +159,11 @@ export const FormSubmitButton = ({ className = "", }: { formState: FormState; + /** + * Default text. + * + * @default "Submit" + */ idleText?: string; loadingText?: string; successText?: string; diff --git a/core/prisma/exampleCommunitySeeds/croccroc.ts b/core/prisma/exampleCommunitySeeds/croccroc.ts index b9ba9f0ed5..a3c0522547 100644 --- a/core/prisma/exampleCommunitySeeds/croccroc.ts +++ b/core/prisma/exampleCommunitySeeds/croccroc.ts @@ -155,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", + }, ], }, }, From 8ceb12783997ce64265c21301a203e9ec7b046f2 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 20:03:18 +0100 Subject: [PATCH 38/43] feat: remove invite only access type --- .../FormBuilder/ElementPanel/SelectAccess.tsx | 10 +--- .../migration.sql | 16 +++++ core/prisma/schema/schema.dbml | 1 - core/prisma/schema/schema.prisma | 1 - packages/db/src/public/FormAccessType.ts | 1 - packages/db/src/public/PublicSchema.ts | 60 +++++++++---------- 6 files changed, 48 insertions(+), 41 deletions(-) create mode 100644 core/prisma/migrations/20250327190100_remove_invite_only_form_access_option/migration.sql diff --git a/core/app/components/FormBuilder/ElementPanel/SelectAccess.tsx b/core/app/components/FormBuilder/ElementPanel/SelectAccess.tsx index 77809458de..22d89b90e8 100644 --- a/core/app/components/FormBuilder/ElementPanel/SelectAccess.tsx +++ b/core/app/components/FormBuilder/ElementPanel/SelectAccess.tsx @@ -8,23 +8,17 @@ import { Contact, Lock, Users } from "ui/icon"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "ui/select"; const iconsAndCopy = { - [FormAccessType.inviteOnly]: { - Icon: Contact, - description: "Accessible via URL with tracked submissions", - name: "Invite Only", - help: "Community members & invited contributors can submit", - }, [FormAccessType.private]: { Icon: Lock, description: "Only accessible via Pub editor", name: "Private", - help: "Only community members can create and edit", + help: "Only community members or invitees can create and edit", }, [FormAccessType.public]: { Icon: Users, description: "Accessible via URL with untracked submissions", name: "Public", - help: "Anyone with the link can submit", + help: "Anyone with the link can signup can submit. NOTE: this enables public signups to your community.", }, }; 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..27147a7645 --- /dev/null +++ b/core/prisma/migrations/20250327190100_remove_invite_only_form_access_option/migration.sql @@ -0,0 +1,16 @@ +/* + 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; +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/schema/schema.dbml b/core/prisma/schema/schema.dbml index 19dd4accf3..dcf3702520 100644 --- a/core/prisma/schema/schema.dbml +++ b/core/prisma/schema/schema.dbml @@ -554,7 +554,6 @@ Enum Event { Enum FormAccessType { private - inviteOnly public } diff --git a/core/prisma/schema/schema.prisma b/core/prisma/schema/schema.prisma index 411636ea2c..9cdf2d82d1 100644 --- a/core/prisma/schema/schema.prisma +++ b/core/prisma/schema/schema.prisma @@ -483,7 +483,6 @@ enum Event { enum FormAccessType { private - inviteOnly public } 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", } diff --git a/packages/db/src/public/PublicSchema.ts b/packages/db/src/public/PublicSchema.ts index b06be2683e..de02d1f83c 100644 --- a/packages/db/src/public/PublicSchema.ts +++ b/packages/db/src/public/PublicSchema.ts @@ -34,6 +34,36 @@ import type { StagesTable } from "./Stages"; import type { UsersTable } from "./Users"; export interface PublicSchema { + rules: RulesTable; + + action_runs: ActionRunsTable; + + forms: FormsTable; + + api_access_tokens: ApiAccessTokensTable; + + api_access_logs: ApiAccessLogsTable; + + api_access_permissions: ApiAccessPermissionsTable; + + form_elements: FormElementsTable; + + sessions: SessionsTable; + + community_memberships: CommunityMembershipsTable; + + pub_memberships: PubMembershipsTable; + + stage_memberships: StageMembershipsTable; + + form_memberships: FormMembershipsTable; + + membership_capabilities: MembershipCapabilitiesTable; + + pub_values_history: PubValuesHistoryTable; + + invites: InvitesTable; + _prisma_migrations: PrismaMigrationsTable; users: UsersTable; @@ -65,34 +95,4 @@ export interface PublicSchema { action_instances: ActionInstancesTable; PubsInStages: PubsInStagesTable; - - rules: RulesTable; - - action_runs: ActionRunsTable; - - forms: FormsTable; - - api_access_tokens: ApiAccessTokensTable; - - api_access_logs: ApiAccessLogsTable; - - api_access_permissions: ApiAccessPermissionsTable; - - form_elements: FormElementsTable; - - sessions: SessionsTable; - - community_memberships: CommunityMembershipsTable; - - pub_memberships: PubMembershipsTable; - - stage_memberships: StageMembershipsTable; - - form_memberships: FormMembershipsTable; - - membership_capabilities: MembershipCapabilitiesTable; - - pub_values_history: PubValuesHistoryTable; - - invites: InvitesTable; } From 6b3d44ad11f094a1e2d329d3156519c0490ad6aa Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 20:04:58 +0100 Subject: [PATCH 39/43] fix: add data migration part --- .../migration.sql | 3 +++ 1 file changed, 3 insertions(+) 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 index 27147a7645..bd7ff81b1a 100644 --- 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 @@ -6,6 +6,9 @@ */ -- 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"); From 0c321a8da01255cfe33aa666e7d09343ad3a8bcc Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 20:09:05 +0100 Subject: [PATCH 40/43] fix: center signup form a bit --- .../[communitySlug]/public/signup/page.tsx | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/core/app/c/(public)/[communitySlug]/public/signup/page.tsx b/core/app/c/(public)/[communitySlug]/public/signup/page.tsx index 5af93451dd..212b7e1592 100644 --- a/core/app/c/(public)/[communitySlug]/public/signup/page.tsx +++ b/core/app/c/(public)/[communitySlug]/public/signup/page.tsx @@ -46,15 +46,27 @@ export default async function Page({ 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} +
+ ); +}; From fcd721f8f3a3e996f07e87b4f07ff6630aaeaf05 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 20:10:31 +0100 Subject: [PATCH 41/43] fix: actually add top margin --- core/app/c/(public)/[communitySlug]/public/signup/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/app/c/(public)/[communitySlug]/public/signup/page.tsx b/core/app/c/(public)/[communitySlug]/public/signup/page.tsx index 212b7e1592..deeface7a0 100644 --- a/core/app/c/(public)/[communitySlug]/public/signup/page.tsx +++ b/core/app/c/(public)/[communitySlug]/public/signup/page.tsx @@ -65,7 +65,7 @@ export default async function Page({ */ const Wrapper = ({ children }: { children: React.ReactNode }) => { return ( -
+
{children}
); From 78ab091827718756dd2e45e1bcb8443e1f1fdbc1 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Thu, 27 Mar 2025 20:12:35 +0100 Subject: [PATCH 42/43] fix: grr forgot to save --- core/app/components/Signup/PublicSignupForm.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/app/components/Signup/PublicSignupForm.tsx b/core/app/components/Signup/PublicSignupForm.tsx index 228828ed3a..f281c967ef 100644 --- a/core/app/components/Signup/PublicSignupForm.tsx +++ b/core/app/components/Signup/PublicSignupForm.tsx @@ -10,11 +10,11 @@ import { publicSignup } from "~/lib/authentication/actions"; import { useServerAction } from "~/lib/serverActions"; import { BaseSignupForm } from "./BaseSignupForm"; -export function PublicSignupForm(props: { communityId: CommunitiesId }) { +export function PublicSignupForm(props: { communityId: CommunitiesId; redirectTo?: string }) { const runSignup = useServerAction(publicSignup); const searchParams = useSearchParams(); - const redirectTo = searchParams.get("redirectTo") ?? undefined; + const redirectTo = props.redirectTo ?? searchParams.get("redirectTo") ?? undefined; const handleSubmit = useCallback( async (data: SignupFormSchema) => { From af18745feda21da08a32ca0f3ad6faa2428d22b0 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 1 Apr 2025 15:42:24 +0200 Subject: [PATCH 43/43] fix: remove invite table for now --- .../20250320132732_add_invites/migration.sql | 76 -------- .../migration.sql | 4 - core/prisma/schema/comments/.comments-lock | 4 - core/prisma/schema/schema.dbml | 55 +----- core/prisma/schema/schema.prisma | 53 +---- packages/db/src/public.ts | 1 - packages/db/src/public/Invites.ts | 154 --------------- packages/db/src/public/PublicSchema.ts | 59 +++--- packages/db/src/table-names.ts | 184 ------------------ 9 files changed, 30 insertions(+), 560 deletions(-) delete mode 100644 core/prisma/migrations/20250320132732_add_invites/migration.sql rename core/prisma/migrations/{20250320132733_update_comments => 20250401134158_update_comments}/migration.sql (98%) delete mode 100644 packages/db/src/public/Invites.ts diff --git a/core/prisma/migrations/20250320132732_add_invites/migration.sql b/core/prisma/migrations/20250320132732_add_invites/migration.sql deleted file mode 100644 index 69cae44e1d..0000000000 --- a/core/prisma/migrations/20250320132732_add_invites/migration.sql +++ /dev/null @@ -1,76 +0,0 @@ --- CreateTable -CREATE TABLE "invites" ( - "id" TEXT NOT NULL DEFAULT gen_random_uuid(), - "email" TEXT, - "userId" TEXT, - "token" TEXT NOT NULL, - "expiresAt" TIMESTAMP(3) NOT NULL, - "acceptedAt" TIMESTAMP(3), - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "communityId" TEXT NOT NULL, - "communityRole" "MemberRole" NOT NULL DEFAULT 'contributor', - "pubId" TEXT, - "formId" TEXT, - "stageId" TEXT, - "otherRole" "MemberRole", - "message" TEXT, - "sentAt" TIMESTAMP(3), - "lastSentAt" TIMESTAMP(3), - "sendAttempts" INTEGER NOT NULL DEFAULT 0, - "status" TEXT NOT NULL DEFAULT 'active', - "revokedAt" TIMESTAMP(3), - "invitedByUserId" TEXT, - "invitedByActionRunId" TEXT, - CONSTRAINT "invites_pkey" PRIMARY KEY ("id") -); - --- Create unique token constraint -CREATE UNIQUE INDEX "invites_token_key" ON "invites"("token"); - --- Add constraint: exactly one of pubId, formId, stageId -ALTER TABLE "invites" ADD CONSTRAINT "exclusive_resource_constraint" -CHECK ( - (CASE WHEN "pubId" IS NOT NULL THEN 1 ELSE 0 END) + - (CASE WHEN "formId" IS NOT NULL THEN 1 ELSE 0 END) + - (CASE WHEN "stageId" IS NOT NULL THEN 1 ELSE 0 END) <= 1 -); - --- Add constraint: if pubId or stageId is set, otherRole must be set -ALTER TABLE "invites" ADD CONSTRAINT "other_role_required" -CHECK ( - ("pubId" IS NULL AND "stageId" IS NULL) OR "otherRole" IS NOT NULL -); - --- Add constraint: exactly one of email or userId must be set -ALTER TABLE "invites" ADD CONSTRAINT "user_identification_constraint" -CHECK ( - (("email" IS NOT NULL)::integer + ("userId" IS NOT NULL)::integer) = 1 -); - --- Add constraint: ensure invitedByUserId or invitedByActionRunId is set -ALTER TABLE "invites" ADD CONSTRAINT "invited_by_required" -CHECK ( - ("invitedByUserId" IS NOT NULL) OR ("invitedByActionRunId" IS NOT NULL) -); - --- AddForeignKey -ALTER TABLE "invites" ADD CONSTRAINT "invites_communityId_fkey" FOREIGN KEY ("communityId") REFERENCES "communities"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "invites" ADD CONSTRAINT "invites_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "invites" ADD CONSTRAINT "invites_pubId_fkey" FOREIGN KEY ("pubId") REFERENCES "pubs"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "invites" ADD CONSTRAINT "invites_formId_fkey" FOREIGN KEY ("formId") REFERENCES "forms"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "invites" ADD CONSTRAINT "invites_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "stages"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "invites" ADD CONSTRAINT "invites_invitedByUserId_fkey" FOREIGN KEY ("invitedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "invites" ADD CONSTRAINT "invites_invitedByActionRunId_fkey" FOREIGN KEY ("invitedByActionRunId") REFERENCES "action_runs"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/core/prisma/migrations/20250320132733_update_comments/migration.sql b/core/prisma/migrations/20250401134158_update_comments/migration.sql similarity index 98% rename from core/prisma/migrations/20250320132733_update_comments/migration.sql rename to core/prisma/migrations/20250401134158_update_comments/migration.sql index f2a4c3ce05..7d5a886990 100644 --- a/core/prisma/migrations/20250320132733_update_comments/migration.sql +++ b/core/prisma/migrations/20250401134158_update_comments/migration.sql @@ -116,10 +116,6 @@ COMMENT ON COLUMN "api_access_permissions"."constraints" IS '@type(ApiAccessPerm --- Model invites comments - - - -- Enum AuthTokenType comments COMMENT ON TYPE "AuthTokenType" IS '@property generic - For most use-cases. This will just authenticate you with a regular session. diff --git a/core/prisma/schema/comments/.comments-lock b/core/prisma/schema/comments/.comments-lock index f2a4c3ce05..7d5a886990 100644 --- a/core/prisma/schema/comments/.comments-lock +++ b/core/prisma/schema/comments/.comments-lock @@ -116,10 +116,6 @@ COMMENT ON COLUMN "api_access_permissions"."constraints" IS '@type(ApiAccessPerm --- Model invites comments - - - -- Enum AuthTokenType comments COMMENT ON TYPE "AuthTokenType" IS '@property generic - For most use-cases. This will just authenticate you with a regular session. diff --git a/core/prisma/schema/schema.dbml b/core/prisma/schema/schema.dbml index dcf3702520..9e01791868 100644 --- a/core/prisma/schema/schema.dbml +++ b/core/prisma/schema/schema.dbml @@ -41,8 +41,6 @@ Table users { pubMemberships pub_memberships [not null] stageMemberships stage_memberships [not null] PubValueHistory pub_values_history [not null] - Invite invites [not null] - InvitedBy invites [not null] } Table sessions { @@ -82,7 +80,6 @@ Table communities { Form forms [not null] pubFields pub_fields [not null] members community_memberships [not null] - Invite invites [not null] } Table pubs { @@ -103,7 +100,6 @@ Table pubs { relatedValues pub_values [not null] members pub_memberships [not null] formMemberships form_memberships [not null] - Invite invites [not null] } Table pub_fields { @@ -198,7 +194,6 @@ Table stages { actionInstances action_instances [not null] formElements form_elements [not null] members stage_memberships [not null] - Invite invites [not null] } Table PubsInStages { @@ -346,7 +341,6 @@ Table action_runs { sourceActionRunId String sourceActionRun action_runs sequentialActionRuns action_runs [not null] - Invite invites [not null] } Table rules { @@ -376,7 +370,6 @@ Table forms { createdAt DateTime [default: `now()`, not null] updatedAt DateTime [default: `now()`, not null] members form_memberships [not null] - Invite invites [not null] indexes { (name, communityId) [unique] @@ -455,38 +448,6 @@ Table membership_capabilities { } } -Table invites { - id String [pk] - email String - InvitedUser users - userId String - token String [unique, not null] - expiresAt DateTime [not null] - acceptedAt DateTime - createdAt DateTime [default: `now()`, not null] - updatedAt DateTime [default: `now()`, not null] - community communities [not null] - communityId String [not null] - communityRole MemberRole [not null, default: 'contributor'] - Pub pubs - pubId String - Form forms - formId String - Stage stages - stageId String - otherRole MemberRole - message String - sentAt DateTime - lastSentAt DateTime - sendAttempts Int [not null, default: 0] - status String [not null, default: 'active'] - revokedAt DateTime - InvitedByUser users - invitedByUserId String - InvitedByActionRun action_runs - invitedByActionRunId String -} - Table MemberGroupToUser { membergroupsId String [ref: > member_groups.id] usersId String [ref: > users.id] @@ -746,18 +707,4 @@ Ref: api_access_tokens.issuedById > users.id [delete: Set Null] Ref: api_access_logs.accessTokenId > api_access_tokens.id [delete: Set Null] -Ref: api_access_permissions.apiAccessTokenId > api_access_tokens.id [delete: Cascade] - -Ref: invites.userId > users.id [delete: Set Null] - -Ref: invites.communityId > communities.id [delete: Cascade] - -Ref: invites.pubId > pubs.id [delete: Set Null] - -Ref: invites.formId > forms.id [delete: Set Null] - -Ref: invites.stageId > stages.id [delete: Set Null] - -Ref: invites.invitedByUserId > users.id [delete: Set Null] - -Ref: invites.invitedByActionRunId > action_runs.id [delete: Set Null] \ No newline at end of file +Ref: api_access_permissions.apiAccessTokenId > api_access_tokens.id [delete: Cascade] \ No newline at end of file diff --git a/core/prisma/schema/schema.prisma b/core/prisma/schema/schema.prisma index 9cdf2d82d1..d65236d1ca 100644 --- a/core/prisma/schema/schema.prisma +++ b/core/prisma/schema/schema.prisma @@ -41,9 +41,6 @@ model User { stageMemberships StageMembership[] PubValueHistory PubValueHistory[] - Invite Invite[] @relation("invited_user") - InvitedBy Invite[] @relation("invited_by") - @@map(name: "users") } @@ -103,7 +100,6 @@ model Community { Form Form[] pubFields PubField[] members CommunityMembership[] - Invite Invite[] @@map(name: "communities") } @@ -133,7 +129,6 @@ model Pub { formMemberships FormMembership[] searchVector Unsupported("tsvector")? - Invite Invite[] @@index([searchVector], type: Gin) @@map(name: "pubs") @@ -266,7 +261,6 @@ model Stage { actionInstances ActionInstance[] formElements FormElement[] members StageMembership[] - Invite Invite[] @@map(name: "stages") } @@ -446,8 +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") - Invite Invite[] @relation("invited_by_action_run") + sequentialActionRuns ActionRun[] @relation("source_action_run") @@map(name: "action_runs") } @@ -502,7 +495,6 @@ model Form { updatedAt DateTime @default(now()) @updatedAt members FormMembership[] - Invite Invite[] @@unique([name, communityId]) @@unique([slug, communityId]) @@ -683,46 +675,3 @@ model MembershipCapabilities { @@id([role, type, capability]) @@map(name: "membership_capabilities") } - -model Invite { - id String @id @default(dbgenerated("gen_random_uuid()")) - - // either one of these is set - email String? - InvitedUser User? @relation("invited_user", fields: [userId], references: [id], onDelete: SetNull) - userId String? - - token String @unique - expiresAt DateTime - acceptedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - - community Community @relation(fields: [communityId], references: [id], onDelete: Cascade) - communityId String - communityRole MemberRole @default(contributor) - - Pub Pub? @relation(fields: [pubId], references: [id], onDelete: SetNull) - pubId String? - Form Form? @relation(fields: [formId], references: [id], onDelete: SetNull) - formId String? - Stage Stage? @relation(fields: [stageId], references: [id], onDelete: SetNull) - stageId String? - - otherRole MemberRole? - - message String? - sentAt DateTime? - lastSentAt DateTime? - sendAttempts Int @default(0) - - status String @default("active") - revokedAt DateTime? - - InvitedByUser User? @relation("invited_by", fields: [invitedByUserId], references: [id], onDelete: SetNull) - invitedByUserId String? - InvitedByActionRun ActionRun? @relation("invited_by_action_run", fields: [invitedByActionRunId], references: [id], onDelete: SetNull) - invitedByActionRunId String? - - @@map(name: "invites") -} diff --git a/packages/db/src/public.ts b/packages/db/src/public.ts index d1e665d640..36c8cc3410 100644 --- a/packages/db/src/public.ts +++ b/packages/db/src/public.ts @@ -20,7 +20,6 @@ export * from "./public/FormElements"; export * from "./public/FormMemberships"; export * from "./public/Forms"; export * from "./public/InputComponent"; -export * from "./public/Invites"; export * from "./public/MemberGroups"; export * from "./public/MemberGroupToUser"; export * from "./public/MemberRole"; diff --git a/packages/db/src/public/Invites.ts b/packages/db/src/public/Invites.ts deleted file mode 100644 index bb9e5994ea..0000000000 --- a/packages/db/src/public/Invites.ts +++ /dev/null @@ -1,154 +0,0 @@ -import type { ColumnType, Insertable, Selectable, Updateable } from "kysely"; - -import { z } from "zod"; - -import type { ActionRunsId } from "./ActionRuns"; -import type { CommunitiesId } from "./Communities"; -import type { FormsId } from "./Forms"; -import type { MemberRole } from "./MemberRole"; -import type { PubsId } from "./Pubs"; -import type { StagesId } from "./Stages"; -import type { UsersId } from "./Users"; -import { actionRunsIdSchema } from "./ActionRuns"; -import { communitiesIdSchema } from "./Communities"; -import { formsIdSchema } from "./Forms"; -import { memberRoleSchema } from "./MemberRole"; -import { pubsIdSchema } from "./Pubs"; -import { stagesIdSchema } from "./Stages"; -import { usersIdSchema } from "./Users"; - -// @generated -// This file is automatically generated by Kanel. Do not modify manually. - -/** Identifier type for public.invites */ -export type InvitesId = string & { __brand: "InvitesId" }; - -/** Represents the table public.invites */ -export interface InvitesTable { - id: ColumnType; - - email: ColumnType; - - userId: ColumnType; - - token: ColumnType; - - expiresAt: ColumnType; - - acceptedAt: ColumnType; - - createdAt: ColumnType; - - updatedAt: ColumnType; - - communityId: ColumnType; - - communityRole: ColumnType; - - pubId: ColumnType; - - formId: ColumnType; - - stageId: ColumnType; - - otherRole: ColumnType; - - message: ColumnType; - - sentAt: ColumnType; - - lastSentAt: ColumnType; - - sendAttempts: ColumnType; - - status: ColumnType; - - revokedAt: ColumnType; - - invitedByUserId: ColumnType; - - invitedByActionRunId: ColumnType; -} - -export type Invites = Selectable; - -export type NewInvites = Insertable; - -export type InvitesUpdate = Updateable; - -export const invitesIdSchema = z.string().uuid() as unknown as z.Schema; - -export const invitesSchema = z.object({ - id: invitesIdSchema, - email: z.string().nullable(), - userId: usersIdSchema.nullable(), - token: z.string(), - expiresAt: z.date(), - acceptedAt: z.date().nullable(), - createdAt: z.date(), - updatedAt: z.date(), - communityId: communitiesIdSchema, - communityRole: memberRoleSchema, - pubId: pubsIdSchema.nullable(), - formId: formsIdSchema.nullable(), - stageId: stagesIdSchema.nullable(), - otherRole: memberRoleSchema.nullable(), - message: z.string().nullable(), - sentAt: z.date().nullable(), - lastSentAt: z.date().nullable(), - sendAttempts: z.number(), - status: z.string(), - revokedAt: z.date().nullable(), - invitedByUserId: usersIdSchema.nullable(), - invitedByActionRunId: actionRunsIdSchema.nullable(), -}); - -export const invitesInitializerSchema = z.object({ - id: invitesIdSchema.optional(), - email: z.string().optional().nullable(), - userId: usersIdSchema.optional().nullable(), - token: z.string(), - expiresAt: z.date(), - acceptedAt: z.date().optional().nullable(), - createdAt: z.date().optional(), - updatedAt: z.date().optional(), - communityId: communitiesIdSchema, - communityRole: memberRoleSchema.optional(), - pubId: pubsIdSchema.optional().nullable(), - formId: formsIdSchema.optional().nullable(), - stageId: stagesIdSchema.optional().nullable(), - otherRole: memberRoleSchema.optional().nullable(), - message: z.string().optional().nullable(), - sentAt: z.date().optional().nullable(), - lastSentAt: z.date().optional().nullable(), - sendAttempts: z.number().optional(), - status: z.string().optional(), - revokedAt: z.date().optional().nullable(), - invitedByUserId: usersIdSchema.optional().nullable(), - invitedByActionRunId: actionRunsIdSchema.optional().nullable(), -}); - -export const invitesMutatorSchema = z.object({ - id: invitesIdSchema.optional(), - email: z.string().optional().nullable(), - userId: usersIdSchema.optional().nullable(), - token: z.string().optional(), - expiresAt: z.date().optional(), - acceptedAt: z.date().optional().nullable(), - createdAt: z.date().optional(), - updatedAt: z.date().optional(), - communityId: communitiesIdSchema.optional(), - communityRole: memberRoleSchema.optional(), - pubId: pubsIdSchema.optional().nullable(), - formId: formsIdSchema.optional().nullable(), - stageId: stagesIdSchema.optional().nullable(), - otherRole: memberRoleSchema.optional().nullable(), - message: z.string().optional().nullable(), - sentAt: z.date().optional().nullable(), - lastSentAt: z.date().optional().nullable(), - sendAttempts: z.number().optional(), - status: z.string().optional(), - revokedAt: z.date().optional().nullable(), - invitedByUserId: usersIdSchema.optional().nullable(), - invitedByActionRunId: actionRunsIdSchema.optional().nullable(), -}); diff --git a/packages/db/src/public/PublicSchema.ts b/packages/db/src/public/PublicSchema.ts index de02d1f83c..62eb7d16d4 100644 --- a/packages/db/src/public/PublicSchema.ts +++ b/packages/db/src/public/PublicSchema.ts @@ -12,7 +12,6 @@ import type { CommunityMembershipsTable } from "./CommunityMemberships"; import type { FormElementsTable } from "./FormElements"; import type { FormMembershipsTable } from "./FormMemberships"; import type { FormsTable } from "./Forms"; -import type { InvitesTable } from "./Invites"; import type { MemberGroupsTable } from "./MemberGroups"; import type { MemberGroupToUserTable } from "./MemberGroupToUser"; import type { MembershipCapabilitiesTable } from "./MembershipCapabilities"; @@ -34,36 +33,6 @@ import type { StagesTable } from "./Stages"; import type { UsersTable } from "./Users"; export interface PublicSchema { - rules: RulesTable; - - action_runs: ActionRunsTable; - - forms: FormsTable; - - api_access_tokens: ApiAccessTokensTable; - - api_access_logs: ApiAccessLogsTable; - - api_access_permissions: ApiAccessPermissionsTable; - - form_elements: FormElementsTable; - - sessions: SessionsTable; - - community_memberships: CommunityMembershipsTable; - - pub_memberships: PubMembershipsTable; - - stage_memberships: StageMembershipsTable; - - form_memberships: FormMembershipsTable; - - membership_capabilities: MembershipCapabilitiesTable; - - pub_values_history: PubValuesHistoryTable; - - invites: InvitesTable; - _prisma_migrations: PrismaMigrationsTable; users: UsersTable; @@ -95,4 +64,32 @@ export interface PublicSchema { action_instances: ActionInstancesTable; PubsInStages: PubsInStagesTable; + + rules: RulesTable; + + action_runs: ActionRunsTable; + + forms: FormsTable; + + api_access_tokens: ApiAccessTokensTable; + + api_access_logs: ApiAccessLogsTable; + + api_access_permissions: ApiAccessPermissionsTable; + + form_elements: FormElementsTable; + + sessions: SessionsTable; + + community_memberships: CommunityMembershipsTable; + + pub_memberships: PubMembershipsTable; + + stage_memberships: StageMembershipsTable; + + form_memberships: FormMembershipsTable; + + membership_capabilities: MembershipCapabilitiesTable; + + pub_values_history: PubValuesHistoryTable; } diff --git a/packages/db/src/table-names.ts b/packages/db/src/table-names.ts index 4a90351eaa..ea4b4bd05c 100644 --- a/packages/db/src/table-names.ts +++ b/packages/db/src/table-names.ts @@ -18,7 +18,6 @@ export const databaseTableNames = [ "form_elements", "form_memberships", "forms", - "invites", "member_groups", "membership_capabilities", "move_constraint", @@ -1078,189 +1077,6 @@ export const databaseTables = [ }, ], }, - { - name: "invites", - isView: false, - schema: "public", - columns: [ - { - name: "id", - dataType: "text", - dataTypeSchema: "pg_catalog", - isNullable: false, - isAutoIncrementing: false, - hasDefaultValue: true, - }, - { - name: "email", - dataType: "text", - dataTypeSchema: "pg_catalog", - isNullable: true, - isAutoIncrementing: false, - hasDefaultValue: false, - }, - { - name: "userId", - dataType: "text", - dataTypeSchema: "pg_catalog", - isNullable: true, - isAutoIncrementing: false, - hasDefaultValue: false, - }, - { - name: "token", - dataType: "text", - dataTypeSchema: "pg_catalog", - isNullable: false, - isAutoIncrementing: false, - hasDefaultValue: false, - }, - { - name: "expiresAt", - dataType: "timestamp", - dataTypeSchema: "pg_catalog", - isNullable: false, - isAutoIncrementing: false, - hasDefaultValue: false, - }, - { - name: "acceptedAt", - dataType: "timestamp", - dataTypeSchema: "pg_catalog", - isNullable: true, - isAutoIncrementing: false, - hasDefaultValue: false, - }, - { - name: "createdAt", - dataType: "timestamp", - dataTypeSchema: "pg_catalog", - isNullable: false, - isAutoIncrementing: false, - hasDefaultValue: true, - }, - { - name: "updatedAt", - dataType: "timestamp", - dataTypeSchema: "pg_catalog", - isNullable: false, - isAutoIncrementing: false, - hasDefaultValue: true, - }, - { - name: "communityId", - dataType: "text", - dataTypeSchema: "pg_catalog", - isNullable: false, - isAutoIncrementing: false, - hasDefaultValue: false, - }, - { - name: "communityRole", - dataType: "MemberRole", - dataTypeSchema: "public", - isNullable: false, - isAutoIncrementing: false, - hasDefaultValue: true, - }, - { - name: "pubId", - dataType: "text", - dataTypeSchema: "pg_catalog", - isNullable: true, - isAutoIncrementing: false, - hasDefaultValue: false, - }, - { - name: "formId", - dataType: "text", - dataTypeSchema: "pg_catalog", - isNullable: true, - isAutoIncrementing: false, - hasDefaultValue: false, - }, - { - name: "stageId", - dataType: "text", - dataTypeSchema: "pg_catalog", - isNullable: true, - isAutoIncrementing: false, - hasDefaultValue: false, - }, - { - name: "otherRole", - dataType: "MemberRole", - dataTypeSchema: "public", - isNullable: true, - isAutoIncrementing: false, - hasDefaultValue: false, - }, - { - name: "message", - dataType: "text", - dataTypeSchema: "pg_catalog", - isNullable: true, - isAutoIncrementing: false, - hasDefaultValue: false, - }, - { - name: "sentAt", - dataType: "timestamp", - dataTypeSchema: "pg_catalog", - isNullable: true, - isAutoIncrementing: false, - hasDefaultValue: false, - }, - { - name: "lastSentAt", - dataType: "timestamp", - dataTypeSchema: "pg_catalog", - isNullable: true, - isAutoIncrementing: false, - hasDefaultValue: false, - }, - { - name: "sendAttempts", - dataType: "int4", - dataTypeSchema: "pg_catalog", - isNullable: false, - isAutoIncrementing: false, - hasDefaultValue: true, - }, - { - name: "status", - dataType: "text", - dataTypeSchema: "pg_catalog", - isNullable: false, - isAutoIncrementing: false, - hasDefaultValue: true, - }, - { - name: "revokedAt", - dataType: "timestamp", - dataTypeSchema: "pg_catalog", - isNullable: true, - isAutoIncrementing: false, - hasDefaultValue: false, - }, - { - name: "invitedByUserId", - dataType: "text", - dataTypeSchema: "pg_catalog", - isNullable: true, - isAutoIncrementing: false, - hasDefaultValue: false, - }, - { - name: "invitedByActionRunId", - dataType: "text", - dataTypeSchema: "pg_catalog", - isNullable: true, - isAutoIncrementing: false, - hasDefaultValue: false, - }, - ], - }, { name: "member_groups", isView: false,