From 721d7f948c305d219601a76e5d38d37cbd8446a6 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Tue, 28 Jan 2025 11:27:36 +0100 Subject: [PATCH 1/9] feat: initial additional token restrictions --- .../site/[...ts-rest]/route.ts | 6 +- .../settings/tokens/CreateTokenForm.tsx | 28 +++- .../tokens/CreateTokenFormContext.tsx | 10 ++ .../settings/tokens/PermissionField.tsx | 134 ++++++++++++++---- .../[communitySlug]/settings/tokens/page.tsx | 12 +- packages/db/src/types/ApiAccessToken.ts | 12 +- 6 files changed, 158 insertions(+), 44 deletions(-) create mode 100644 core/app/c/[communitySlug]/settings/tokens/CreateTokenFormContext.tsx diff --git a/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts b/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts index 90b3d92ba..d0d4e8382 100644 --- a/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts +++ b/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts @@ -107,12 +107,12 @@ const getAuthorization = async () => { user, authorization: rules.reduce((acc, curr) => { const { scope, constraints, accessType } = curr; - if (!constraints) { - acc[scope][accessType] = true; + if (!curr.constraints) { + acc[curr.scope][curr.accessType] = true; return acc; } - acc[scope][accessType] = constraints ?? true; + acc[curr.scope][curr.accessType] = curr.constraints ?? true; return acc; }, baseAuthorizationObject), apiAccessTokenId: validatedAccessToken.id, diff --git a/core/app/c/[communitySlug]/settings/tokens/CreateTokenForm.tsx b/core/app/c/[communitySlug]/settings/tokens/CreateTokenForm.tsx index 0f018f663..3636a3f1e 100644 --- a/core/app/c/[communitySlug]/settings/tokens/CreateTokenForm.tsx +++ b/core/app/c/[communitySlug]/settings/tokens/CreateTokenForm.tsx @@ -5,7 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import type { CreateTokenFormContext } from "db/types"; +import type { CreateTokenFormContext as CreateTokenFormContextType } from "db/types"; import { ApiAccessScope, apiAccessTokensInitializerSchema } from "db/public"; import { permissionsSchema } from "db/types"; import { Button } from "ui/button"; @@ -19,6 +19,7 @@ import { Separator } from "ui/separator"; import { useServerAction } from "~/lib/serverActions"; import * as actions from "./actions"; +import { CreateTokenFormContext } from "./CreateTokenFormContext"; import { PermissionField } from "./PermissionField"; export const createTokenFormSchema = apiAccessTokensInitializerSchema @@ -58,7 +59,7 @@ export const createTokenFormSchema = apiAccessTokensInitializerSchema export type CreateTokenFormSchema = z.infer; export type CreateTokenForm = ReturnType>; -export const CreateTokenForm = ({ context }: { context: CreateTokenFormContext }) => { +export const CreateTokenForm = () => { const form = useForm({ resolver: zodResolver(createTokenFormSchema), defaultValues: { @@ -140,7 +141,6 @@ export const CreateTokenForm = ({ context }: { context: CreateTokenFormContext } key={scope} name={scope} form={form} - context={context} prettyName={`${scope[0].toUpperCase()}${scope.slice(1)}`} /> @@ -194,3 +194,25 @@ export const CreateTokenForm = ({ context }: { context: CreateTokenFormContext } ); }; + +/** + * Exported here instead of just importing CreateTokenFormContext.tsx + * in page.tsx, because doing so triggers the following strange error + * + * React.jsx: type is invalid -- expected a string (for built-in components) + * or a class/function (for composite components) but got: undefined. + * You likely forgot to export your component from the file it's defined in, + * or you might have mixed up default and named imports + */ +export const CreateTokenFormWithContext = ({ stages, pubTypes }: CreateTokenFormContextType) => { + return ( + + + + ); +}; diff --git a/core/app/c/[communitySlug]/settings/tokens/CreateTokenFormContext.tsx b/core/app/c/[communitySlug]/settings/tokens/CreateTokenFormContext.tsx new file mode 100644 index 000000000..a7bdd2275 --- /dev/null +++ b/core/app/c/[communitySlug]/settings/tokens/CreateTokenFormContext.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { createContext } from "react"; + +import type { CreateTokenFormContext as CreateTokenFormContextType } from "db/types"; + +export const CreateTokenFormContext = createContext({ + stages: [], + pubTypes: [], +}); diff --git a/core/app/c/[communitySlug]/settings/tokens/PermissionField.tsx b/core/app/c/[communitySlug]/settings/tokens/PermissionField.tsx index 36972ddd4..5be2eab62 100644 --- a/core/app/c/[communitySlug]/settings/tokens/PermissionField.tsx +++ b/core/app/c/[communitySlug]/settings/tokens/PermissionField.tsx @@ -2,10 +2,10 @@ import type { ControllerRenderProps } from "react-hook-form"; -import { createContext, useContext, useMemo } from "react"; +import { useContext, useMemo } from "react"; -import type { ApiAccessScope } from "db/public"; -import type { ApiAccessPermissionConstraintsInput, CreateTokenFormContext } from "db/types"; +import type { ApiAccessScope, PubTypesId, StagesId } from "db/public"; +import type { ApiAccessPermissionConstraintsInput } from "db/types"; import { ApiAccessType } from "db/public"; import { Button } from "ui/button"; import { Checkbox } from "ui/checkbox"; @@ -14,6 +14,7 @@ import { MultiSelect } from "ui/multi-select"; import { Popover, PopoverContent, PopoverTrigger } from "ui/popover"; import type { CreateTokenForm, CreateTokenFormSchema } from "./CreateTokenForm"; +import { CreateTokenFormContext } from "./CreateTokenFormContext"; /** * This is a type for a configuration object for form fields. It allows you to specify @@ -123,19 +124,96 @@ type ScopesWithOnlyNormalConstraints< type CustomConstraintFormElement = (props: { value: boolean | Value; - onChange: (...args: any[]) => void; + onChange: (value: boolean | Value) => void; }) => React.ReactNode; -const CreateTokenFormContextContext = createContext({ stages: [] }); - /** * Here you configure the specific form elements for each permission type */ const permissionContraintMap: PermissionContraintMap = { community: null, pub: { + [ApiAccessType.read]: ({ value, onChange }) => { + const context = useContext(CreateTokenFormContext); + return ( +
+

Stages

+ + Select the stages this token can read Pubs from + + ({ + label: stage.name, + value: stage.id, + }))} + /** + * This just means: if it is set to `true`, allow it to act on all stages. + * If it is set to `false`, allow it to act on no stages. + * Otherwise just reuse the value of the `stages` field. (this is a bit of a cop-out as this situation should never come to pass) + */ + defaultValue={ + value === true + ? context.stages.map((stage) => stage.id) + : !value + ? [] + : (value.stages ?? []) + } + onValueChange={(val) => { + onChange( + val.length > 0 && val.length !== context.stages.length + ? { + stages: val as StagesId[], + pubTypes: + typeof value == "object" ? value.pubTypes : [], + } + : true + ); + }} + animation={0} + data-testid={`pub-${ApiAccessType.write}-stages-select`} + /> + +

Types

+ + Select the types of Pubs this token can read + + ({ + label: pubType.name, + value: pubType.id, + }))} + /** + * This just means: if it is set to `true`, allow it to act on all stages. + * If it is set to `false`, allow it to act on no stages. + * Otherwise just reuse the value of the `stages` field. (this is a bit of a cop-out as this situation should never come to pass) + */ + defaultValue={ + value === true + ? context.pubTypes.map((stage) => stage.id) + : !value + ? [] + : (value.pubTypes ?? []) + } + onValueChange={(val) => { + onChange( + val.length > 0 && val.length !== context.pubTypes.length + ? { + pubTypes: val as PubTypesId[], + stages: typeof value == "object" ? value.stages : [], + } + : true + ); + }} + animation={0} + data-testid={`pub-${ApiAccessType.write}-pub-types-select`} + /> +
+ ); + }, [ApiAccessType.write]: ({ value, onChange }) => { - const context = useContext(CreateTokenFormContextContext); + const context = useContext(CreateTokenFormContext); return (

Stages

@@ -176,7 +254,7 @@ const permissionContraintMap: PermissionContraintMap = { }, stage: { [ApiAccessType.read]: ({ value, onChange }) => { - const context = useContext(CreateTokenFormContextContext); + const context = useContext(CreateTokenFormContext); return (

Stages

@@ -214,35 +292,31 @@ export const PermissionField = ({ form, name, prettyName, - context, }: { form: CreateTokenForm; name: ApiAccessScope; prettyName: string; - context: CreateTokenFormContext; }) => { return ( - - ( -
-

{prettyName}

-
- {Object.values(ApiAccessType).map((type) => ( - - ))} -
+ ( +
+

{prettyName}

+
+ {Object.values(ApiAccessType).map((type) => ( + + ))}
- )} - /> - +
+ )} + /> ); }; diff --git a/core/app/c/[communitySlug]/settings/tokens/page.tsx b/core/app/c/[communitySlug]/settings/tokens/page.tsx index f3c3391b0..9218bdfba 100644 --- a/core/app/c/[communitySlug]/settings/tokens/page.tsx +++ b/core/app/c/[communitySlug]/settings/tokens/page.tsx @@ -3,10 +3,11 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { getPageLoginData } from "~/lib/authentication/loginData"; +import { getPubTypesForCommunity } from "~/lib/server"; import { getApiAccessTokensByCommunity } from "~/lib/server/apiAccessTokens"; import { findCommunityBySlug } from "~/lib/server/community"; import { getStages } from "~/lib/server/stages"; -import { CreateTokenForm } from "./CreateTokenForm"; +import { CreateTokenFormWithContext } from "./CreateTokenForm"; import { ExistingToken } from "./ExistingToken"; export const metadata: Metadata = { @@ -21,8 +22,9 @@ export default async function Page(props: { params: { communitySlug: string } }) return notFound(); } - const [stages, existingTokens] = await Promise.all([ + const [stages, pubTypes, existingTokens] = await Promise.all([ getStages({ communityId: community.id, userId: user.id }).execute(), + getPubTypesForCommunity(community.id), getApiAccessTokensByCommunity(community.id).execute(), ]); @@ -47,11 +49,7 @@ export default async function Page(props: { params: { communitySlug: string } })
)} - +
diff --git a/packages/db/src/types/ApiAccessToken.ts b/packages/db/src/types/ApiAccessToken.ts index a8442ca2f..b1de4b1c8 100644 --- a/packages/db/src/types/ApiAccessToken.ts +++ b/packages/db/src/types/ApiAccessToken.ts @@ -6,8 +6,10 @@ import { z } from "zod"; import type { ApiAccessPermissions as NonGenericApiAccessPermissions } from "../public/ApiAccessPermissions"; import type { ApiAccessType } from "../public/ApiAccessType"; +import type { PubTypes } from "../public/PubTypes"; import type { Stages } from "../public/Stages"; import { ApiAccessScope } from "../public/ApiAccessScope"; +import { pubTypesIdSchema } from "../public/PubTypes"; import { stagesIdSchema } from "../public/Stages"; /** @@ -55,7 +57,14 @@ export const permissionsSchema = z.object({ archive: z.boolean().optional(), }), [ApiAccessScope.pub]: z.object({ - read: z.boolean().optional(), + read: z + .object({ + stages: z.array(stagesIdSchema), + pubTypes: z.array(pubTypesIdSchema), + }) + .partial() + .or(z.boolean()) + .optional(), write: z .object({ stages: z.array(stagesIdSchema), @@ -78,6 +87,7 @@ export const permissionsSchema = z.object({ export type CreateTokenFormContext = { stages: Stages[]; + pubTypes: PubTypes[]; }; export type ApiAccessPermissionConstraintsInput = z.infer; From 91ff6eb6c9d0c7204c8fc2aeeb1123f9d539d0c3 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Wed, 29 Jan 2025 15:34:13 +0100 Subject: [PATCH 2/9] feat: add ability to set pub read restrictions for api tokens --- .../site/[...ts-rest]/route.ts | 50 +++- .../tokens/CreateTokenFormContext.tsx | 13 +- .../settings/tokens/PermissionField.tsx | 82 +++---- .../[communitySlug]/settings/tokens/page.tsx | 21 +- .../app/c/[communitySlug]/types/TypeBlock.tsx | 62 ++--- core/lib/server/pub.db.test.ts | 24 +- core/lib/server/pub.ts | 39 +++- core/playwright.config.ts | 2 +- core/playwright/api/site.spec.ts | 69 +++++- core/playwright/fixtures/api-token-page.ts | 54 ++++- core/playwright/fixtures/pub-types-page.ts | 13 ++ .../playwright/fixtures/stages-manage-page.ts | 16 +- core/playwright/site-api.spec.ts | 218 +++++++++++++++++- packages/contracts/src/resources/site.ts | 19 +- packages/db/src/types/ApiAccessToken.ts | 31 ++- packages/ui/src/multi-select.tsx | 7 + 16 files changed, 599 insertions(+), 121 deletions(-) diff --git a/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts b/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts index d0d4e8382..627f60e69 100644 --- a/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts +++ b/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts @@ -222,7 +222,7 @@ const handler = createNextHandler( { pubs: { get: async ({ params, query }) => { - const { user, community } = await checkAuthorization({ + const { user, community, authorization } = await checkAuthorization({ token: { scope: ApiAccessScope.pub, type: ApiAccessType.read }, cookies: { capability: Capabilities.viewPub, @@ -239,25 +239,65 @@ const handler = createNextHandler( query ); + if (typeof authorization === "object") { + const allowedStages = authorization.stages; + if (allowedStages && allowedStages.length > 0) { + throw new ForbiddenError( + `You are not authorized to view this pub in stage ${pub.stageId}` + ); + } + } + return { status: 200, body: pub, }; }, getMany: async ({ query }) => { - const { user, community } = await checkAuthorization({ + const { user, community, authorization } = await checkAuthorization({ token: { scope: ApiAccessScope.pub, type: ApiAccessType.read }, // TODO: figure out capability here cookies: false, }); - const { pubTypeId, stageId, ...rest } = query; + const allowedPubTypes = + typeof authorization === "object" ? authorization.pubTypes : undefined; + const allowedStages = + typeof authorization === "object" ? authorization.stages : undefined; + + let { pubTypeId, stageId, pubIds, ...rest } = query; + + const requestedPubTypes = pubTypeId + ? Array.isArray(pubTypeId) + ? pubTypeId + : [pubTypeId] + : undefined; + const requestedStages = stageId + ? Array.isArray(stageId) + ? stageId + : [stageId] + : undefined; + const requestedPubIds = pubIds + ? Array.isArray(pubIds) + ? pubIds + : [pubIds] + : undefined; + + const pubTypes = requestedPubTypes + ? requestedPubTypes.filter((pubType) => allowedPubTypes?.includes(pubType)) + : allowedPubTypes; + const stages = requestedStages + ? requestedStages.filter((stage) => allowedStages?.includes(stage)) + : allowedStages; + + console.log(pubTypes, stages); const pubs = await getPubsWithRelatedValuesAndChildren( { communityId: community.id, - pubTypeId, - stageId, + pubTypeId: pubTypes, + stageId: stages, + pubIds: requestedPubIds, userId: user.id, }, rest diff --git a/core/app/c/[communitySlug]/settings/tokens/CreateTokenFormContext.tsx b/core/app/c/[communitySlug]/settings/tokens/CreateTokenFormContext.tsx index a7bdd2275..546e3717f 100644 --- a/core/app/c/[communitySlug]/settings/tokens/CreateTokenFormContext.tsx +++ b/core/app/c/[communitySlug]/settings/tokens/CreateTokenFormContext.tsx @@ -3,8 +3,17 @@ import { createContext } from "react"; import type { CreateTokenFormContext as CreateTokenFormContextType } from "db/types"; +import { NO_STAGE_OPTION } from "db/types"; export const CreateTokenFormContext = createContext({ - stages: [], - pubTypes: [], + stages: { + stages: [], + allOptions: [NO_STAGE_OPTION], + allValues: [NO_STAGE_OPTION.value], + }, + pubTypes: { + pubTypes: [], + allOptions: [], + allValues: [], + }, }); diff --git a/core/app/c/[communitySlug]/settings/tokens/PermissionField.tsx b/core/app/c/[communitySlug]/settings/tokens/PermissionField.tsx index 5be2eab62..479bca0ad 100644 --- a/core/app/c/[communitySlug]/settings/tokens/PermissionField.tsx +++ b/core/app/c/[communitySlug]/settings/tokens/PermissionField.tsx @@ -135,6 +135,7 @@ const permissionContraintMap: PermissionContraintMap = { pub: { [ApiAccessType.read]: ({ value, onChange }) => { const context = useContext(CreateTokenFormContext); + return (

Stages

@@ -143,35 +144,34 @@ const permissionContraintMap: PermissionContraintMap = { ({ - label: stage.name, - value: stage.id, - }))} - /** - * This just means: if it is set to `true`, allow it to act on all stages. - * If it is set to `false`, allow it to act on no stages. - * Otherwise just reuse the value of the `stages` field. (this is a bit of a cop-out as this situation should never come to pass) - */ + options={context.stages.allOptions} defaultValue={ value === true - ? context.stages.map((stage) => stage.id) + ? context.stages.allValues : !value ? [] : (value.stages ?? []) } onValueChange={(val) => { - onChange( - val.length > 0 && val.length !== context.stages.length - ? { - stages: val as StagesId[], - pubTypes: - typeof value == "object" ? value.pubTypes : [], - } - : true - ); + const allStagesSelected = + val.length === context.stages.allValues.length; + + const allPubTypesSelected = + typeof value === "object" && + value.pubTypes?.length === context.pubTypes.allValues.length; + + if (allStagesSelected && allPubTypesSelected) { + onChange(true); + return; + } + + onChange({ + stages: val as StagesId[], + pubTypes: typeof value === "object" ? value.pubTypes : [], + }); }} animation={0} - data-testid={`pub-${ApiAccessType.write}-stages-select`} + data-testid={`pub-${ApiAccessType.read}-stages-select`} />

Types

@@ -180,34 +180,34 @@ const permissionContraintMap: PermissionContraintMap = { ({ - label: pubType.name, - value: pubType.id, - }))} - /** - * This just means: if it is set to `true`, allow it to act on all stages. - * If it is set to `false`, allow it to act on no stages. - * Otherwise just reuse the value of the `stages` field. (this is a bit of a cop-out as this situation should never come to pass) - */ + options={context.pubTypes.allOptions} defaultValue={ value === true - ? context.pubTypes.map((stage) => stage.id) + ? context.pubTypes.allValues : !value ? [] : (value.pubTypes ?? []) } onValueChange={(val) => { - onChange( - val.length > 0 && val.length !== context.pubTypes.length - ? { - pubTypes: val as PubTypesId[], - stages: typeof value == "object" ? value.stages : [], - } - : true - ); + const allPubTypesSelected = + val.length === context.pubTypes.allValues.length; + + const allStagesSelected = + typeof value === "object" && + value.stages?.length === context.stages.allValues.length; + + if (allPubTypesSelected && allStagesSelected) { + onChange(true); + return; + } + + onChange({ + pubTypes: val as PubTypesId[], + stages: typeof value == "object" ? value.stages : [], + }); }} animation={0} - data-testid={`pub-${ApiAccessType.write}-pub-types-select`} + data-testid={`pub-${ApiAccessType.read}-pubTypes-select`} />
); @@ -241,7 +241,7 @@ const permissionContraintMap: PermissionContraintMap = { onValueChange={(value) => { onChange( value.length > 0 && value.length !== context.stages.length - ? { stages: value } + ? { stages: value as StagesId[] } : true ); }} @@ -275,7 +275,7 @@ const permissionContraintMap: PermissionContraintMap = { : value.stages } onValueChange={(value) => { - onChange(value.length > 0 ? value : true); + onChange(value.length > 0 ? { stages: value as StagesId[] } : true); }} animation={0} data-testid={`stage-${ApiAccessType.read}-stages-select`} diff --git a/core/app/c/[communitySlug]/settings/tokens/page.tsx b/core/app/c/[communitySlug]/settings/tokens/page.tsx index 9218bdfba..a6cb74284 100644 --- a/core/app/c/[communitySlug]/settings/tokens/page.tsx +++ b/core/app/c/[communitySlug]/settings/tokens/page.tsx @@ -2,6 +2,8 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; +import { NO_STAGE_OPTION } from "db/types"; + import { getPageLoginData } from "~/lib/authentication/loginData"; import { getPubTypesForCommunity } from "~/lib/server"; import { getApiAccessTokensByCommunity } from "~/lib/server/apiAccessTokens"; @@ -49,7 +51,24 @@ export default async function Page(props: { params: { communitySlug: string } }) )} - + ({ label: stage.name, value: stage.id })), + ], + allValues: [NO_STAGE_OPTION.value, ...stages.map((stage) => stage.id)], + }} + pubTypes={{ + pubTypes, + allOptions: pubTypes.map((pubType) => ({ + label: pubType.name, + value: pubType.id, + })), + allValues: pubTypes.map((pubType) => pubType.id), + }} + /> diff --git a/core/app/c/[communitySlug]/types/TypeBlock.tsx b/core/app/c/[communitySlug]/types/TypeBlock.tsx index ab1224160..d79486715 100644 --- a/core/app/c/[communitySlug]/types/TypeBlock.tsx +++ b/core/app/c/[communitySlug]/types/TypeBlock.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import type { CoreSchemaType, PubFieldsId } from "db/public"; import { Button } from "ui/button"; -import { Card, CardContent } from "ui/card"; +import { Card, CardContent, CardHeader } from "ui/card"; import { Check, ChevronDown, ChevronUp, Pencil } from "ui/icon"; import { Label } from "ui/label"; import { usePubFieldContext } from "ui/pubFields"; @@ -90,44 +90,52 @@ const TypeBlock: React.FC = function ({ type, allowEditing }) { }; return ( - -
-

{type.name}

+ +

+ {type.name}{" "} + +
{type.id}
+
+

+ + {allowEditing && ( - {allowEditing && ( - - )} -
+ )} + +
{type.description}
{(expanded || editing) && ( -
+
- +
@@ -196,7 +204,7 @@ const TypeBlock: React.FC = function ({ type, allowEditing }) { )} {editing && ( -
+
diff --git a/core/lib/server/pub.db.test.ts b/core/lib/server/pub.db.test.ts index 3634f7c67..ce1dc199f 100644 --- a/core/lib/server/pub.db.test.ts +++ b/core/lib/server/pub.db.test.ts @@ -658,7 +658,7 @@ describe("getPubsWithRelatedValuesAndChildren", () => { }); }); - it("should be able to filter by pubtype or stage and pubtype and stage", async () => { + it("should be able to filter by pubtype or stage or no stage and pubtype and stage", async () => { const { getPubsWithRelatedValuesAndChildren } = await import("./pub"); const allPubs = await getPubsWithRelatedValuesAndChildren( @@ -668,23 +668,29 @@ describe("getPubsWithRelatedValuesAndChildren", () => { expect(allPubs.length).toBe(5); - const [minimalPubs, pubsInStage1, basicPubsInStage1] = await Promise.all([ + const [minimalPubs, pubsInStage1, basicPubsInStage1, pubsInNoStage] = await Promise.all([ getPubsWithRelatedValuesAndChildren( - { pubTypeId: pubTypes["Minimal Pub"].id, communityId: community.id }, + { pubTypeId: [pubTypes["Minimal Pub"].id], communityId: community.id }, { withPubType: true, depth: 10 } ), getPubsWithRelatedValuesAndChildren( - { stageId: stages["Stage 1"].id, communityId: community.id }, + { stageId: [stages["Stage 1"].id], communityId: community.id }, { withStage: true, depth: 10 } ), getPubsWithRelatedValuesAndChildren( { - pubTypeId: pubTypes["Basic Pub"].id, - stageId: stages["Stage 1"].id, + pubTypeId: [pubTypes["Basic Pub"].id], + stageId: [stages["Stage 1"].id], communityId: community.id, }, { withPubType: true, withStage: true, depth: 10 } ), + getPubsWithRelatedValuesAndChildren( + // passing ['no-stage'] is different from passing null, + // as null will just not filter by stage at all + { communityId: community.id, stageId: ["no-stage"] }, + { withPubType: true, withStage: true, depth: 10 } + ), ]); expect(minimalPubs.length).toBe(1); @@ -695,6 +701,12 @@ describe("getPubsWithRelatedValuesAndChildren", () => { expect(basicPubsInStage1.length).toBe(1); expect(basicPubsInStage1[0].pubType?.id).toBe(pubTypes["Basic Pub"].id); expect(basicPubsInStage1[0].stage?.id).toBe(stages["Stage 1"].id); + + const allPubsWithoutStage = allPubs.filter((p) => p.stageId === null); + expect(pubsInNoStage.length).toBe(allPubsWithoutStage.length); + pubsInNoStage.forEach((p) => { + expect(p.stageId).toBeNull(); + }); }); it("should be able to limit the amount of top-level pubs retrieved while still fetching children and related pubs", async () => { diff --git a/core/lib/server/pub.ts b/core/lib/server/pub.ts index bb248af78..24c561e9a 100644 --- a/core/lib/server/pub.ts +++ b/core/lib/server/pub.ts @@ -34,8 +34,9 @@ import type { StagesId, UsersId, } from "db/public"; -import type { LastModifiedBy } from "db/types"; +import type { LastModifiedBy, StageConstraint } from "db/types"; import { Capabilities, CoreSchemaType, MemberRole, MembershipType, OperationType } from "db/public"; +import { NO_STAGE_OPTION } from "db/types"; import { assert, expect } from "utils"; import type { MaybeHas, Prettify, XOR } from "../types"; @@ -1211,6 +1212,7 @@ interface GetPubsWithRelatedValuesAndChildrenOptions extends GetManyParams, Mayb type PubIdOrPubTypeIdOrStageIdOrCommunityId = | { pubId: PubsId; + pubIds?: never; pubTypeId?: never; stageId?: never; communityId: CommunitiesId; @@ -1218,8 +1220,12 @@ type PubIdOrPubTypeIdOrStageIdOrCommunityId = } | { pubId?: never; - pubTypeId?: PubTypesId; - stageId?: StagesId; + /** + * Multiple pubIds to filter by + */ + pubIds?: PubsId[]; + pubTypeId?: PubTypesId[]; + stageId?: StageConstraint[]; communityId: CommunitiesId; userId?: UsersId; }; @@ -1617,12 +1623,29 @@ export async function getPubsWithRelatedValuesAndChildren< ) ) ) - .$if(Boolean(props.pubId), (qb) => qb.where("pubs.id", "=", props.pubId!)) - .$if(Boolean(props.stageId), (qb) => - qb.where("PubsInStages.stageId", "=", props.stageId!) + .$if(!!props.pubId, (qb) => qb.where("pubs.id", "=", props.pubId!)) + .$if(!!props.pubIds && props.pubIds.length > 0, (qb) => + qb.where("pubs.id", "in", props.pubIds!) ) - .$if(Boolean(props.pubTypeId), (qb) => - qb.where("pubs.pubTypeId", "=", props.pubTypeId!) + .$if(!!props.stageId && props.stageId.length > 0, (qb) => { + const noStage = props.stageId!.find( + (stage) => stage === NO_STAGE_OPTION.value + ); + const stages = props.stageId!.filter( + (stage): stage is StagesId => stage !== NO_STAGE_OPTION.value + ); + + return qb.where((eb) => + eb.or([ + ...(stages.length > 0 + ? [eb("PubsInStages.stageId", "in", stages)] + : []), + ...(noStage ? [eb("PubsInStages.stageId", "is", null)] : []), + ]) + ); + }) + .$if(!!props.pubTypeId && props.pubTypeId.length > 0, (qb) => + qb.where("pubs.pubTypeId", "in", props.pubTypeId!) ) .$if(Boolean(limit), (qb) => qb.limit(limit!)) .$if(Boolean(offset), (qb) => qb.offset(offset!)) diff --git a/core/playwright.config.ts b/core/playwright.config.ts index 0d000edd6..de7804e74 100644 --- a/core/playwright.config.ts +++ b/core/playwright.config.ts @@ -35,7 +35,7 @@ export default defineConfig({ }, ], // max 30 seconds per test in CI - timeout: process.env.CI ? 30 * 1000 : 10 * 60 * 1000, + timeout: process.env.CI ? 30 * 1000 : 30 * 1000, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: process.env.CI ? "github" : "html", /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ diff --git a/core/playwright/api/site.spec.ts b/core/playwright/api/site.spec.ts index c7c1d56a1..40e4b2cc0 100644 --- a/core/playwright/api/site.spec.ts +++ b/core/playwright/api/site.spec.ts @@ -2,8 +2,12 @@ import type { Page } from "@playwright/test"; import { expect, test } from "@playwright/test"; +import type { PubTypesId, StagesId } from "db/public"; + import { ApiTokenPage } from "../fixtures/api-token-page"; import { LoginPage } from "../fixtures/login-page"; +import { PubTypesPage } from "../fixtures/pub-types-page"; +import { StagesManagePage } from "../fixtures/stages-manage-page"; import { createCommunity } from "../helpers"; const now = new Date().getTime(); @@ -13,6 +17,14 @@ test.describe.configure({ mode: "serial" }); let page: Page; +const TEST_STAGE_1 = "test stage" as const; +const TEST_STAGE_2 = "test stage 2" as const; + +let testStage1Id: StagesId; +let testStage2Id: StagesId; + +let testPubTypeId: PubTypesId; + test.beforeAll(async ({ browser }) => { page = await browser.newPage(); const loginPage = new LoginPage(page); @@ -24,9 +36,25 @@ test.beforeAll(async ({ browser }) => { community: { name: `test community ${now}`, slug: COMMUNITY_SLUG }, }); + const stagesPage = new StagesManagePage(page, COMMUNITY_SLUG); + await stagesPage.goTo(); + const testStage1 = await stagesPage.addStage(TEST_STAGE_1); + testStage1Id = testStage1.id; + const testStage2 = await stagesPage.addStage(TEST_STAGE_2); + testStage2Id = testStage2.id; + + const pubTypesPage = new PubTypesPage(page, COMMUNITY_SLUG); + await pubTypesPage.goto(); + const testPubType = await pubTypesPage.addType("test pub type", "test pub type description", [ + `title`, + ]); + testPubTypeId = testPubType.id; +}); + +test("should be able to create token with all permissions", async () => { const tokenPage = new ApiTokenPage(page, COMMUNITY_SLUG); await tokenPage.goto(); - await tokenPage.createToken({ + const token = await tokenPage.createToken({ // expiration: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), name: "test token", permissions: { @@ -37,8 +65,43 @@ test.beforeAll(async ({ browser }) => { member: { read: true, write: true, archive: true }, }, }); + + expect(token).not.toBeNull(); + + tokenPage.goto(); }); -test("token should exist", async () => { - await expect(page.getByText("test token")).toBeVisible(); +test("should be able to create token with special permissions", async () => { + const tokenPage = new ApiTokenPage(page, COMMUNITY_SLUG); + await tokenPage.goto(); + const token = await tokenPage.createToken({ + // expiration: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), + name: "test token", + permissions: { + pub: { + read: { + stages: [testStage1Id], + pubTypes: [testPubTypeId], + }, + }, + }, + }); + + test.expect(token).not.toBeNull(); + + await tokenPage.goto(); + + await page.getByRole("button", { name: "Permissions" }).first().click(); + + const permissionsText = await page.getByText("pub: read").textContent(); + + const permissionContraints = permissionsText?.match(/\{.*\}/)?.[0]; + + test.expect(permissionContraints).not.toBeNull(); + + const permissionContraintsJson = JSON.parse(permissionContraints!); + test.expect(permissionContraintsJson).toMatchObject({ + stages: [testStage1Id], + pubTypes: [testPubTypeId], + }); }); diff --git a/core/playwright/fixtures/api-token-page.ts b/core/playwright/fixtures/api-token-page.ts index eb8ea11c7..f691ba72f 100644 --- a/core/playwright/fixtures/api-token-page.ts +++ b/core/playwright/fixtures/api-token-page.ts @@ -6,6 +6,34 @@ import { expect } from "@playwright/test"; import { ApiAccessScope, ApiAccessType } from "db/public"; import type { createTokenFormSchema } from "~/app/c/[communitySlug]/settings/tokens/CreateTokenForm"; +import type { Prettify } from "~/lib/types"; + +type Permissions = z.infer["permissions"]; + +// this is so we can specify the stage name rather than the stage id +// type PermissionsButWithStringsInsteadOfIds = { +// [Scope in keyof Permissions]?: { +// [Permission in keyof Permissions[Scope]]: Permissions[Scope][Permission] extends +// | boolean +// | undefined +// ? Permissions[Scope][Permission] +// : Exclude< +// Permissions[Scope][Permission], +// boolean | undefined +// > extends infer Restrictions +// ? +// | { +// [Restriction in keyof Restrictions]: any[] extends Restrictions[Restriction] +// ? string[] +// : Restrictions[Restriction]; +// } +// | boolean +// | undefined +// : never; +// }; +// }; + +// declare const x: PermissionsButWithStringsInsteadOfIds; export class ApiTokenPage { private readonly newTokenNameBox: Locator; @@ -38,7 +66,7 @@ export class ApiTokenPage { z.infer, "permissions" | "issuedById" | "expiration" > & { - permissions: Partial["permissions"]> | true; + permissions: Partial | true; } ) { await this.newTokenNameBox.fill(input.name); @@ -57,18 +85,30 @@ export class ApiTokenPage { continue; } - if (value && "stage" in value) { + if (typeof value === "object") { await this.page.getByTestId(`${scope}-${type}-options`).click(); - await this.page.getByTestId(`${scope}-${type}-stages-select`).click(); - for (const stage of value.stages) { - await this.page.getByLabel("Suggestions").getByText(stage).click(); + for (const [key, values] of Object.entries(value)) { + await this.page.getByTestId(`${scope}-${type}-${key}-select`).click({ + timeout: 2_000, + }); + for (const val of values) { + await this.page + .getByLabel("Suggestions") + .getByTestId(`multi-select-option-${val}`) + .click({ + timeout: 2_000, + }); + } + await this.page.getByTestId(`multi-select-close`).click(); + continue; } - continue; } } } - await this.newTokenCreateButton.click(); + await this.newTokenCreateButton.click({ + timeout: 1_000, + }); const token = await this.page.getByTestId("token-value").textContent(); diff --git a/core/playwright/fixtures/pub-types-page.ts b/core/playwright/fixtures/pub-types-page.ts index 440369e53..fb2d78582 100644 --- a/core/playwright/fixtures/pub-types-page.ts +++ b/core/playwright/fixtures/pub-types-page.ts @@ -1,5 +1,9 @@ import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; + +import type { PubTypesId } from "db/public"; + export class PubTypesPage { private readonly communitySlug: string; @@ -67,5 +71,14 @@ export class PubTypesPage { // check whether the new type is created await this.page.getByRole("heading", { name: name }).waitFor(); + + const pubTypeId = await this.page.getByTestId(`pubtype-${name}-id`).textContent(); + + expect(pubTypeId).toBeTruthy(); + + return { + name, + id: pubTypeId! as PubTypesId, + }; } } diff --git a/core/playwright/fixtures/stages-manage-page.ts b/core/playwright/fixtures/stages-manage-page.ts index bbd0a4dfc..cdcacafd0 100644 --- a/core/playwright/fixtures/stages-manage-page.ts +++ b/core/playwright/fixtures/stages-manage-page.ts @@ -1,6 +1,8 @@ import type { Page } from "@playwright/test"; -import type { Action } from "db/public"; +import { test } from "@playwright/test"; + +import type { Action, StagesId } from "db/public"; import { slugifyString } from "~/lib/string"; @@ -22,10 +24,22 @@ export class StagesManagePage { await this.page.keyboard.press("Control+n"); const node = this.getStageNode("Untitled Stage"); await node.dblclick(); + + const configureButton = node.getByRole("link"); + const stageHref = await configureButton.getAttribute("href"); + const stageId = stageHref?.split("editingStageId=")[1]; + test.expect(stageId).not.toBeNull(); + const nameInput = node.getByLabel("Edit stage name"); + await nameInput.fill(stageName); await nameInput.press("Enter"); await this.page.waitForTimeout(1000); + + return { + id: stageId! as StagesId, + name: stageName, + }; } async addMoveConstraint(sourceStage: string, destStage: string) { diff --git a/core/playwright/site-api.spec.ts b/core/playwright/site-api.spec.ts index 0225dc327..85468468e 100644 --- a/core/playwright/site-api.spec.ts +++ b/core/playwright/site-api.spec.ts @@ -3,11 +3,14 @@ import type { APIRequestContext, Page } from "@playwright/test"; import { expect, test } from "@playwright/test"; import { initClient } from "@ts-rest/core"; -import type { PubsId } from "db/public"; +import type { PubsId, PubTypesId, StagesId } from "db/public"; import { siteApi } from "contracts"; +import { NO_STAGE_OPTION } from "db/types"; import { ApiTokenPage, expectStatus } from "./fixtures/api-token-page"; import { LoginPage } from "./fixtures/login-page"; +import { PubTypesPage } from "./fixtures/pub-types-page"; +import { StagesManagePage } from "./fixtures/stages-manage-page"; import { createCommunity } from "./helpers"; const now = new Date().getTime(); @@ -17,6 +20,14 @@ let page: Page; let client: ReturnType>; +const createClient = (token: string) => + initClient(siteApi, { + baseUrl: `http://localhost:3000/`, + baseHeaders: { + Authorization: `Bearer ${token}`, + }, + }); + test.beforeAll(async ({ browser }) => { page = await browser.newPage(); @@ -36,13 +47,9 @@ test.beforeAll(async ({ browser }) => { description: "test description", permissions: true, }); + expect(token).not.toBeNull(); - client = initClient(siteApi, { - baseUrl: `http://localhost:3000/`, - baseHeaders: { - Authorization: `Bearer ${token}`, - }, - }); + client = createClient(token!); }); test.describe("Site API", () => { @@ -104,4 +111,201 @@ test.describe("Site API", () => { expect(response.body.id).toBe(newPubId); }); }); + + test.describe("restrictions", () => { + let clientOnlyPubTypeClient: ReturnType; + let clientOnlyStageClient: ReturnType; + let clientBothClient: ReturnType; + let clientNoStageClient: ReturnType; + let testPubType1: PubTypesId; + let testPubType2: PubTypesId; + let testStage1: StagesId; + let testStage2: StagesId; + + test.beforeAll(async () => { + const pubTypesPage = new PubTypesPage(page, COMMUNITY_SLUG); + await pubTypesPage.goto(); + const pubType1 = await pubTypesPage.addType( + "test pub type", + "test pub type description", + [`title`] + ); + testPubType1 = pubType1.id; + const pubType2 = await pubTypesPage.addType( + "test pub type 2", + "test pub type description 2", + [`title`] + ); + testPubType2 = pubType2.id; + + const stagePage = new StagesManagePage(page, COMMUNITY_SLUG); + await stagePage.goTo(); + const stage1 = await stagePage.addStage("test stage"); + testStage1 = stage1.id; + const stage2 = await stagePage.addStage("test stage 2"); + testStage2 = stage2.id; + + const pubResponses = await Promise.all( + [pubType1, pubType2].flatMap((pubType) => + [null, stage1, stage2].map((stage) => + client.pubs.create({ + headers: { + prefer: "return=representation", + }, + params: { + communitySlug: COMMUNITY_SLUG, + }, + body: { + pubTypeId: pubType.id, + stageId: stage?.id, + values: { + [`${COMMUNITY_SLUG}:title`]: `pubType: "${pubType.name}"; stage: "${stage?.name}"`, + }, + }, + }) + ) + ) + ); + + expect(pubResponses).toHaveLength(6); + pubResponses.forEach((response) => { + expectStatus(response, 201); + }); + }); + + test("if only pub type is restricted, only pubs of that pub type are returned", async () => { + const tokenPage = new ApiTokenPage(page, COMMUNITY_SLUG); + await tokenPage.goto(); + + const onlyPubTypeToken = await tokenPage.createToken({ + name: "test token with pub type restriction", + permissions: { + pub: { + read: { + pubTypes: [testPubType1], + }, + write: true, + }, + }, + }); + + clientOnlyPubTypeClient = createClient(onlyPubTypeToken!); + + const pubResponseRestricted = await clientOnlyPubTypeClient.pubs.getMany({ + params: { + communitySlug: COMMUNITY_SLUG, + }, + query: {}, + }); + + expectStatus(pubResponseRestricted, 200); + expect(pubResponseRestricted.body).toHaveLength(3); + expect(pubResponseRestricted.body.every((pub) => pub.pubTypeId === testPubType1)).toBe( + true + ); + }); + + test("if only stage is restricted, only pubs of that stage are returned", async () => { + const tokenPage = new ApiTokenPage(page, COMMUNITY_SLUG); + const onlyStageToken = await tokenPage.createToken({ + name: "test token", + permissions: { + pub: { + read: { + stages: [testStage1], + }, + }, + }, + }); + clientOnlyStageClient = createClient(onlyStageToken!); + + const pubResponseRestricted = await clientOnlyStageClient.pubs.getMany({ + params: { + communitySlug: COMMUNITY_SLUG, + }, + query: {}, + }); + + expectStatus(pubResponseRestricted, 200); + expect(pubResponseRestricted.body).toHaveLength(2); + expect(pubResponseRestricted.body.every((pub) => pub.stageId === testStage1)).toBe( + true + ); + }); + + test("if both pub type and stage are restricted, only pubs of that pub type and stage are returned", async () => { + const tokenPage = new ApiTokenPage(page, COMMUNITY_SLUG); + const bothToken = await tokenPage.createToken({ + name: "test token with pub type and stage restriction", + permissions: { + pub: { + read: { + pubTypes: [testPubType1], + stages: [testStage1], + }, + }, + }, + }); + clientBothClient = createClient(bothToken!); + + const pubResponseRestricted = await clientBothClient.pubs.getMany({ + params: { + communitySlug: COMMUNITY_SLUG, + }, + query: {}, + }); + + expectStatus(pubResponseRestricted, 200); + expect(pubResponseRestricted.body).toHaveLength(1); + expect(pubResponseRestricted.body[0].pubTypeId).toBe(testPubType1); + expect(pubResponseRestricted.body[0].stageId).toBe(testStage1); + }); + + test("if stage is restricted, we can still further filter by pub type", async () => { + const pubResponseRestrictedToStage1FilteredByPubType1 = + await clientOnlyStageClient.pubs.getMany({ + params: { + communitySlug: COMMUNITY_SLUG, + }, + query: { + pubTypeId: testPubType1, + }, + }); + + expectStatus(pubResponseRestrictedToStage1FilteredByPubType1, 200); + expect(pubResponseRestrictedToStage1FilteredByPubType1.body).toHaveLength(1); + expect(pubResponseRestrictedToStage1FilteredByPubType1.body[0].pubTypeId).toBe( + testPubType1 + ); + expect(pubResponseRestrictedToStage1FilteredByPubType1.body[0].stageId).toBe( + testStage1 + ); + }); + + test("if stages are restricted to pubs not in a stage ", async () => { + const tokenPage = new ApiTokenPage(page, COMMUNITY_SLUG); + const noStageToken = await tokenPage.createToken({ + name: "test token with no stage restriction", + permissions: { + pub: { + read: { + stages: ["no-stage"], + }, + }, + }, + }); + clientNoStageClient = createClient(noStageToken!); + + const pubResponseRestricted = await clientNoStageClient.pubs.getMany({ + params: { + communitySlug: COMMUNITY_SLUG, + }, + query: {}, + }); + + expectStatus(pubResponseRestricted, 200); + expect(pubResponseRestricted.body).toHaveLength(2); + expect(pubResponseRestricted.body[0].stageId).toBe(null); + }); + }); }); diff --git a/packages/contracts/src/resources/site.ts b/packages/contracts/src/resources/site.ts index f67951584..1942d2f4a 100644 --- a/packages/contracts/src/resources/site.ts +++ b/packages/contracts/src/resources/site.ts @@ -1,7 +1,5 @@ -import type { AppRouteResponse, ContractOtherResponse, Opaque } from "@ts-rest/core"; - import { initContract } from "@ts-rest/core"; -import { z, ZodNull } from "zod"; +import { z } from "zod"; import type { CommunitiesId, @@ -34,6 +32,7 @@ import { usersIdSchema, usersSchema, } from "db/public"; +import { stageConstraintSchema } from "db/types"; import type { Json } from "./types"; import { @@ -373,8 +372,18 @@ export const siteApi = contract.router( description: "Get a list of pubs by ID. This endpoint is used by the PubPub site builder to get a list of pubs.", query: getPubQuerySchema.extend({ - pubTypeId: pubTypesIdSchema.optional().describe("Filter by pub type ID."), - stageId: stagesIdSchema.optional().describe("Filter by stage ID."), + pubIds: pubsIdSchema + .or(z.array(pubsIdSchema)) + .optional() + .describe("Filter by pub ID."), + pubTypeId: pubTypesIdSchema + .or(z.array(pubTypesIdSchema)) + .optional() + .describe("Filter by pub type ID."), + stageId: stageConstraintSchema + .or(z.array(stageConstraintSchema)) + .optional() + .describe("Filter by stage ID."), limit: z.number().default(10), offset: z.number().default(0).optional(), orderBy: z.enum(["createdAt", "updatedAt"]).optional(), diff --git a/packages/db/src/types/ApiAccessToken.ts b/packages/db/src/types/ApiAccessToken.ts index b1de4b1c8..a20702f1a 100644 --- a/packages/db/src/types/ApiAccessToken.ts +++ b/packages/db/src/types/ApiAccessToken.ts @@ -6,8 +6,8 @@ import { z } from "zod"; import type { ApiAccessPermissions as NonGenericApiAccessPermissions } from "../public/ApiAccessPermissions"; import type { ApiAccessType } from "../public/ApiAccessType"; -import type { PubTypes } from "../public/PubTypes"; -import type { Stages } from "../public/Stages"; +import type { PubTypes, PubTypesId } from "../public/PubTypes"; +import type { Stages, StagesId } from "../public/Stages"; import { ApiAccessScope } from "../public/ApiAccessScope"; import { pubTypesIdSchema } from "../public/PubTypes"; import { stagesIdSchema } from "../public/Stages"; @@ -40,6 +40,15 @@ export type ApiAccessPermissionContraintsObjectShape = { [key in ApiAccessScope]: ApiAccessPermissionConstraintsShape; }; +export const NO_STAGE_OPTION = { + label: "[Pubs with no stage]", + value: "no-stage", +} as const; + +export const stageConstraintSchema = z.union([z.literal(NO_STAGE_OPTION.value), stagesIdSchema]); + +export type StageConstraint = z.infer; + export const permissionsSchema = z.object({ [ApiAccessScope.community]: z.object({ read: z.boolean().optional(), @@ -49,7 +58,7 @@ export const permissionsSchema = z.object({ [ApiAccessScope.stage]: z.object({ read: z .object({ - stages: z.array(stagesIdSchema), + stages: z.array(stageConstraintSchema), }) .or(z.boolean()) .optional(), @@ -59,7 +68,7 @@ export const permissionsSchema = z.object({ [ApiAccessScope.pub]: z.object({ read: z .object({ - stages: z.array(stagesIdSchema), + stages: z.array(stageConstraintSchema), pubTypes: z.array(pubTypesIdSchema), }) .partial() @@ -67,7 +76,7 @@ export const permissionsSchema = z.object({ .optional(), write: z .object({ - stages: z.array(stagesIdSchema), + stages: z.array(stageConstraintSchema), }) .or(z.boolean()) .optional(), @@ -86,8 +95,16 @@ export const permissionsSchema = z.object({ }) satisfies z.Schema; export type CreateTokenFormContext = { - stages: Stages[]; - pubTypes: PubTypes[]; + stages: { + stages: Stages[]; + allOptions: [typeof NO_STAGE_OPTION, ...{ label: string; value: StagesId }[]]; + allValues: [typeof NO_STAGE_OPTION.value, ...StagesId[]]; + }; + pubTypes: { + pubTypes: PubTypes[]; + allOptions: { label: string; value: PubTypesId }[]; + allValues: PubTypesId[]; + }; }; export type ApiAccessPermissionConstraintsInput = z.infer; diff --git a/packages/ui/src/multi-select.tsx b/packages/ui/src/multi-select.tsx index f6e1f5cbd..ca5de8114 100644 --- a/packages/ui/src/multi-select.tsx +++ b/packages/ui/src/multi-select.tsx @@ -181,6 +181,7 @@ export const MultiSelect = React.forwardRef {option?.label} { event.stopPropagation(); toggleOption(value); @@ -209,6 +210,7 @@ export const MultiSelect = React.forwardRef event.stopPropagation(); clearExtraOptions(); }} + data-testid={`multi-select-clear-extra`} /> )} @@ -216,6 +218,7 @@ export const MultiSelect = React.forwardRef
{ event.stopPropagation(); handleClear(); @@ -252,6 +255,7 @@ export const MultiSelect = React.forwardRef key="all" onSelect={toggleAll} className="cursor-pointer" + data-testid={`multi-select-toggle-all`} >
key={option.value} onSelect={() => toggleOption(option.value)} className="cursor-pointer" + data-testid={`multi-select-option-${option.value}`} >
Clear @@ -312,6 +318,7 @@ export const MultiSelect = React.forwardRef setIsPopoverOpen(false)} className="flex-1 cursor-pointer justify-center" + data-testid={`multi-select-close`} > Close From 8102f7b696df2dfe2ded265d98ad0b7cb0e6599e Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 3 Feb 2025 10:04:01 +0100 Subject: [PATCH 3/9] improve read restrictions --- .../site/[...ts-rest]/route.ts | 24 +++++--- .../settings/tokens/CreateTokenForm.tsx | 7 ++- .../settings/tokens/PermissionField.tsx | 60 +++++++++---------- .../stages/components/StageList.tsx | 2 +- core/globalSetup.ts | 2 +- core/playwright/fixtures/api-token-page.ts | 4 ++ core/playwright/site-api.spec.ts | 19 +++++- packages/db/src/public/PublicSchema.ts | 44 +++++++------- packages/ui/src/checkbox.tsx | 8 ++- 9 files changed, 103 insertions(+), 67 deletions(-) diff --git a/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts b/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts index 627f60e69..5c31a8ef3 100644 --- a/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts +++ b/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts @@ -283,14 +283,22 @@ const handler = createNextHandler( : [pubIds] : undefined; - const pubTypes = requestedPubTypes - ? requestedPubTypes.filter((pubType) => allowedPubTypes?.includes(pubType)) - : allowedPubTypes; - const stages = requestedStages - ? requestedStages.filter((stage) => allowedStages?.includes(stage)) - : allowedStages; - - console.log(pubTypes, stages); + const pubTypes = + requestedPubTypes?.length && allowedPubTypes?.length + ? requestedPubTypes.filter((pubType) => allowedPubTypes?.includes(pubType)) + : allowedPubTypes?.length || requestedPubTypes; + + const stages = + requestedStages?.length && allowedStages?.length + ? requestedStages.filter((stage) => allowedStages?.includes(stage)) + : (allowedStages ?? requestedStages); + + console.log("requestedPubTypes", requestedPubTypes); + console.log("allowedPubTypes", allowedPubTypes); + console.log("pubTypes", pubTypes); + console.log("requestedStages", requestedStages); + console.log("allowedStages", allowedStages); + console.log("stages", stages); const pubs = await getPubsWithRelatedValuesAndChildren( { diff --git a/core/app/c/[communitySlug]/settings/tokens/CreateTokenForm.tsx b/core/app/c/[communitySlug]/settings/tokens/CreateTokenForm.tsx index 3636a3f1e..87badca72 100644 --- a/core/app/c/[communitySlug]/settings/tokens/CreateTokenForm.tsx +++ b/core/app/c/[communitySlug]/settings/tokens/CreateTokenForm.tsx @@ -28,6 +28,7 @@ export const createTokenFormSchema = apiAccessTokensInitializerSchema issuedById: true, }) .extend({ + name: z.string().min(1, "Name is required").max(255, "Name is too long"), description: z.string().max(255).optional(), token: apiAccessTokensInitializerSchema.shape.token.optional(), expiration: z @@ -63,6 +64,8 @@ export const CreateTokenForm = () => { const form = useForm({ resolver: zodResolver(createTokenFormSchema), defaultValues: { + name: "", + description: "", // default to 1 day from now, mostly to make testing easier expiration: new Date(Date.now() + 1000 * 60 * 60 * 24), }, @@ -80,6 +83,8 @@ export const CreateTokenForm = () => { // this `as const` should not be necessary, not sure why it is const token = form.watch("token" as const); + console.log(form.getValues()); + return (
@@ -165,7 +170,7 @@ export const CreateTokenForm = () => {
Fields