diff --git a/packages/cms/src/access/hidden-unless-owner.ts b/packages/cms/src/access/hidden-unless-owner.ts new file mode 100644 index 00000000..92da8279 --- /dev/null +++ b/packages/cms/src/access/hidden-unless-owner.ts @@ -0,0 +1,10 @@ +import {type User} from 'payload/auth'; +import {type SessionUser} from '../collections/users'; + +export const hiddenUnlessOwner = ({user}: {user: User}) => { + const sessionUser = user as unknown as SessionUser; + const roles = + 'roles' in sessionUser ? sessionUser.roles : sessionUser.user.roles; + + return !roles.includes('owner'); +}; diff --git a/packages/cms/src/collections/features.ts b/packages/cms/src/collections/features.ts new file mode 100644 index 00000000..79a4a1a4 --- /dev/null +++ b/packages/cms/src/collections/features.ts @@ -0,0 +1,39 @@ +import {type CollectionConfig} from 'payload/types'; +import {hiddenUnlessOwner} from '../access/hidden-unless-owner'; +import {requireOneOf} from '../access/require-one-of'; + +export const Features: CollectionConfig = { + slug: 'feature', + access: { + create: requireOneOf(), + update: requireOneOf(), + delete: requireOneOf(), + }, + admin: { + useAsTitle: 'name', + hidden: hiddenUnlessOwner, + }, + fields: [ + { + name: 'name', + type: 'text', + required: true, + }, + { + name: 'pathPrefix', + type: 'text', + required: true, + }, + { + name: 'navPath', + type: 'text', + hooks: { + beforeValidate: [({value, data}) => value ?? data.pathPrefix], + }, + }, + { + name: 'enabled', + type: 'checkbox', + }, + ], +}; diff --git a/packages/cms/src/collections/users.ts b/packages/cms/src/collections/users.ts index 1871c073..735eb266 100644 --- a/packages/cms/src/collections/users.ts +++ b/packages/cms/src/collections/users.ts @@ -1,6 +1,7 @@ import {type GeneratedTypes} from 'payload'; import parseCookies from 'payload/dist/utilities/parseCookies'; import {type CollectionConfig} from 'payload/types'; +import {hiddenUnlessOwner} from '../access/hidden-unless-owner'; import {requireOneOf} from '../access/require-one-of'; import {YtfAuthStrategy} from '../lib/auth-strategy'; import {getUserIdFromRequest} from '../lib/get-user-id-from-request'; @@ -49,22 +50,19 @@ export const Users: CollectionConfig = { create: requireOneOf(), update: requireOneOf(), delete: requireOneOf(), - read: ({req, id}) => { + read: ({req}) => { const {user} = req; if (!user?.user) return false; const {roles} = user.user; if (roles.includes('owner')) return true; - // Setting it up this way, hides the users collection from the UI while still allowing the /me endpoint to - // function. - if (id) return user.user.id === id; - - return false; + return true; }, }, admin: { useAsTitle: 'id', + hidden: hiddenUnlessOwner, }, endpoints: [ { diff --git a/packages/cms/src/payload-types.ts b/packages/cms/src/payload-types.ts index 1ed56ed6..f52898ff 100644 --- a/packages/cms/src/payload-types.ts +++ b/packages/cms/src/payload-types.ts @@ -11,6 +11,7 @@ export interface Config { users: User; groupchats: Groupchat; 'groupchat-keywords': GroupchatKeyword; + feature: Feature; 'payload-preferences': PayloadPreference; 'payload-migrations': PayloadMigration; }; @@ -49,6 +50,15 @@ export interface GroupchatKeyword { updatedAt: string; createdAt: string; } +export interface Feature { + id: number; + name: string; + pathPrefix: string; + navPath?: string | null; + enabled?: boolean | null; + updatedAt: string; + createdAt: string; +} export interface PayloadPreference { id: number; user: { diff --git a/packages/cms/src/payload.config.ts b/packages/cms/src/payload.config.ts index 3429908a..d391ab56 100644 --- a/packages/cms/src/payload.config.ts +++ b/packages/cms/src/payload.config.ts @@ -3,6 +3,7 @@ import {viteBundler} from '@payloadcms/bundler-vite'; import {postgresAdapter} from '@payloadcms/db-postgres'; import {slateEditor} from '@payloadcms/richtext-slate'; import {buildConfig, type Config} from 'payload/config'; +import {Features} from './collections/features'; import {GroupchatKeywords} from './collections/groupchat-keywords'; import {Groupchats} from './collections/groupchats'; import {Users} from './collections/users'; @@ -26,7 +27,7 @@ const config: Config = { }, }, editor: slateEditor({}), - collections: [Users, Groupchats, GroupchatKeywords], + collections: [Users, Groupchats, GroupchatKeywords, Features], db: postgresAdapter({ migrationDir: path.resolve(__dirname, 'migrations'), pool: {connectionString: process.env.DATABASE_URI}, diff --git a/packages/server/src/servers/public.ts b/packages/server/src/servers/public.ts index 0a63a1db..e69cc156 100644 --- a/packages/server/src/servers/public.ts +++ b/packages/server/src/servers/public.ts @@ -34,7 +34,7 @@ import {type YtfApolloContext} from '../types'; const logger = createServerLogger('server', 'public'); const allowedPayloadOperations = { - Query: ['groupchatSearchToken'], + Query: ['groupchatSearchToken', 'Features'], Mutation: [], Subscription: [], }; diff --git a/packages/web/src/__generated__/graphql.ts b/packages/web/src/__generated__/graphql.ts index 644a78ae..ace5e5ea 100644 --- a/packages/web/src/__generated__/graphql.ts +++ b/packages/web/src/__generated__/graphql.ts @@ -142,6 +142,23 @@ export type LogoutMutationVariables = Exact<{ [key: string]: never }>; export type LogoutMutation = { __typename?: "Mutation"; logout: boolean }; +export type FeaturesQueryVariables = Exact<{ [key: string]: never }>; + +export type FeaturesQuery = { + __typename?: "Query"; + Features?: { + __typename?: "Features"; + docs?: Array<{ + __typename?: "Feature"; + id?: number | null; + name: string; + enabled?: boolean | null; + navPath?: string | null; + pathPrefix: string; + } | null> | null; + } | null; +}; + export const ServerStateDocument = gql` query ServerState { me { @@ -191,6 +208,19 @@ export const LogoutDocument = gql` logout } `; +export const FeaturesDocument = gql` + query Features { + Features { + docs { + id + name + enabled + navPath + pathPrefix + } + } + } +`; export type SdkFunctionWrapper = ( action: (requestHeaders?: Record) => Promise, @@ -304,6 +334,21 @@ export function getSdk( variables, ); }, + Features( + variables?: FeaturesQueryVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise { + return withWrapper( + (wrappedRequestHeaders) => + client.request(FeaturesDocument, variables, { + ...requestHeaders, + ...wrappedRequestHeaders, + }), + "Features", + "query", + variables, + ); + }, }; } export type Sdk = ReturnType; diff --git a/packages/web/src/app/buddyproject/page.tsx b/packages/web/src/app/buddyproject/page.tsx index 76374934..09aa40ae 100644 --- a/packages/web/src/app/buddyproject/page.tsx +++ b/packages/web/src/app/buddyproject/page.tsx @@ -3,6 +3,7 @@ import {Image} from 'ui'; import {InfoGrid} from 'ui/buddyproject'; import {ScrollToActionContainer} from 'ui/client'; import {buddyProjectSvg, yesbotBuddyProjectWebp} from '../../../assets'; +import {ensureNavEnabled} from '../../lib/features/features'; import {graphqlWithHeaders} from '../../lib/graphql/client'; import {BuddyProjectButton} from './components/buddy-project-button'; @@ -44,6 +45,8 @@ const CTA = () => { }; const Page = async () => { + await ensureNavEnabled('buddyproject'); + const x = await graphqlWithHeaders((sdk) => sdk.ServerState()); const currentUser = x.me; diff --git a/packages/web/src/app/groupchats/page.tsx b/packages/web/src/app/groupchats/page.tsx index e501eed3..0afaeb47 100644 --- a/packages/web/src/app/groupchats/page.tsx +++ b/packages/web/src/app/groupchats/page.tsx @@ -2,6 +2,7 @@ import {type Metadata} from 'next'; import {Heading} from 'ui'; import {TypesenseProvider} from '../../context/typesense'; import {getIsLoggedIn} from '../../context/user/user'; +import {ensureNavEnabled} from '../../lib/features/features'; import {graphqlWithHeaders} from '../../lib/graphql/client'; import {GroupChatSearch} from './components/group-chat-search'; @@ -10,6 +11,8 @@ export const metadata: Metadata = { }; const GroupChats = async () => { + await ensureNavEnabled('groupchats'); + const isLoggedIn = await getIsLoggedIn(); const apiKey = await graphqlWithHeaders((sdk) => sdk.TypesenseApiKey()); diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index c03acfdf..506ae580 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -3,6 +3,7 @@ import {type PropsWithChildren, Suspense} from 'react'; import {Footer} from 'ui'; import {CookieConsent} from '../components/cookie-consent/cookie-consent'; import {getCurrentUser} from '../context/user/user'; +import {getNavRoutes} from '../lib/features/features'; import {Nav} from './nav'; import {Providers} from './providers'; @@ -36,6 +37,7 @@ export const metadata: Metadata = { const RootLayout = async ({children}: PropsWithChildren) => { const user = await getCurrentUser(); + const routes = await getNavRoutes(); return ( @@ -50,7 +52,7 @@ const RootLayout = async ({children}: PropsWithChildren) => {
-