diff --git a/apps/studio/components/ui/GridFooter.tsx b/apps/studio/components/ui/GridFooter.tsx
index 962422a62539c..4e297d9b4c65c 100644
--- a/apps/studio/components/ui/GridFooter.tsx
+++ b/apps/studio/components/ui/GridFooter.tsx
@@ -4,6 +4,7 @@ import { cn } from 'ui'
export const GridFooter = ({ children, className }: PropsWithChildren<{ className?: string }>) => {
return (
{
const { ref: projectRef } = useParams()
- const { projectSettingsShowDisableLegacyApiKeys } = useIsFeatureEnabled([
- 'project_settings:show_disable_legacy_api_keys',
- ])
const {
data: settings,
@@ -74,151 +69,145 @@ export const DisplayApiSettings = ({
}, [lastUsedLogData, apiKeys])
return (
- <>
-
- Project API Keys
-
- Your API is secured behind an API gateway which requires an API Key for every
- request.
-
- You can use the keys below in the Supabase client libraries.
-
-
-
- )
- }
- >
- {isLoading ? (
-
-
-
Retrieving API keys
-
- ) : !canReadAPIKeys ? (
-
-
+
+ Project API Keys
- You don't have permission to view API keys. These keys restricted to users with higher
- access levels.
+ Your API is secured behind an API gateway which requires an API Key for every request.
+
+ You can use the keys below in the Supabase client libraries.
+
- ) : isProjectSettingsError || isJwtSecretUpdateStatusError ? (
-
-
-
- {isProjectSettingsError
- ? 'Failed to retrieve API keys'
- : 'Failed to update JWT secret'}
-
-
- ) : isApiKeysEmpty || isProjectSettingsLoading || isJwtSecretUpdateStatusLoading ? (
-
-
-
- {isProjectSettingsLoading || isApiKeysEmpty
- ? 'Retrieving API keys'
- : 'JWT secret is being updated'}
-
-
- ) : (
- apiKeys.map((x, i: number) => (
-
= 1 &&
- 'border-t border-panel-border-interior-light [[data-theme*=dark]_&]:border-panel-border-interior-dark'
+ )
+ }
+ >
+ {isLoading ? (
+
+
+
Retrieving API keys
+
+ ) : !canReadAPIKeys ? (
+
+
+
+ You don't have permission to view API keys. These keys restricted to users with higher
+ access levels.
+
+
+ ) : isProjectSettingsError || isJwtSecretUpdateStatusError ? (
+
+
+
+ {isProjectSettingsError ? 'Failed to retrieve API keys' : 'Failed to update JWT secret'}
+
+
+ ) : isApiKeysEmpty || isProjectSettingsLoading || isJwtSecretUpdateStatusLoading ? (
+
+
+
+ {isProjectSettingsLoading || isApiKeysEmpty
+ ? 'Retrieving API keys'
+ : 'JWT secret is being updated'}
+
+
+ ) : (
+ apiKeys.map((x, i: number) => (
+ = 1 &&
+ 'border-t border-panel-border-interior-light [[data-theme*=dark]_&]:border-panel-border-interior-dark'
+ }
+ >
+
+ {x.tags?.split(',').map((x, i: number) => (
+
+ {x}
+
+ ))}
+ {x.tags === 'service_role' && (
+ <>
+
+ secret
+
+ >
+ )}
+ {x.tags === 'anon' && public}
+ >
}
- >
- {}}
+ descriptionText={
+ x.tags === 'service_role' ? (
<>
- {x.tags?.split(',').map((x, i: number) => (
-
- {x}
-
- ))}
- {x.tags === 'service_role' && (
- <>
-
- secret
-
- >
+ This key has the ability to bypass Row Level Security. Never share it publicly.
+ If leaked, generate a new JWT secret immediately.{' '}
+ {showLegacyText && (
+
+ Prefer using{' '}
+
+ Secret API keys
+ {' '}
+ instead.
+
)}
- {x.tags === 'anon' && public}
>
- }
- copy={canReadAPIKeys && isNotUpdatingJwtSecret}
- reveal={x.tags !== 'anon' && canReadAPIKeys && isNotUpdatingJwtSecret}
- value={
- !canReadAPIKeys
- ? 'You need additional permissions to view API keys'
- : jwtSecretUpdateStatus === JwtSecretUpdateStatus.Failed
- ? 'JWT secret update failed, new API key may have issues'
- : jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updating
- ? 'Updating JWT secret...'
- : x?.api_key ?? 'You need additional permissions to view API keys'
- }
- onChange={() => {}}
- descriptionText={
- x.tags === 'service_role' ? (
- <>
- This key has the ability to bypass Row Level Security. Never share it
- publicly. If leaked, generate a new JWT secret immediately.{' '}
- {showLegacyText && (
-
- Prefer using{' '}
-
- Secret API keys
- {' '}
- instead.
-
- )}
- >
- ) : (
- <>
- This key is safe to use in a browser if you have enabled Row Level Security
- for your tables and configured policies.{' '}
- {showLegacyText && (
-
- Prefer using{' '}
-
- Publishable API keys
- {' '}
- instead.
-
- )}
- >
- )
- }
- />
-
-
- {lastUsedAPIKeys[x.api_key]
- ? `Last request was ${lastUsedAPIKeys[x.api_key]} ago.`
- : 'No requests in the past 24 hours.'}
-
-
- ))
- )}
-
+ ) : (
+ <>
+ This key is safe to use in a browser if you have enabled Row Level Security for
+ your tables and configured policies.{' '}
+ {showLegacyText && (
+
+ Prefer using{' '}
+
+ Publishable API keys
+ {' '}
+ instead.
+
+ )}
+ >
+ )
+ }
+ />
+
+ {lastUsedAPIKeys[x.api_key]
+ ? `Last request was ${lastUsedAPIKeys[x.api_key]} ago.`
+ : 'No requests in the past 24 hours.'}
+
+
+ ))
+ )}
{showNotice ? (
- ) : projectSettingsShowDisableLegacyApiKeys ? (
-
) : null}
- >
+
)
}
diff --git a/apps/studio/hooks/custom-content/CustomContent.types.ts b/apps/studio/hooks/custom-content/CustomContent.types.ts
new file mode 100644
index 0000000000000..66816dffa963a
--- /dev/null
+++ b/apps/studio/hooks/custom-content/CustomContent.types.ts
@@ -0,0 +1,33 @@
+import { ConnectionType } from 'components/interfaces/Connect/Connect.constants'
+
+export type CustomContentTypes = {
+ organizationLegalDocuments: {
+ id: string
+ name: string
+ description: string
+ action: { text: string; url: string }
+ }[]
+
+ projectHomepageExampleProjects: {
+ title: string
+ description: string
+ iconUrl: string
+ url: string
+ }[]
+
+ logsDefaultQuery: string
+
+ /**
+ * When declaring files for each framework, there are 3 properties that can be dynamically rendered into the file content using handlebar notation:
+ * - {{apiUrl}}: The API URL of the project
+ * - {{anonKey}}: The anonymous key of the project (if still using legacy API keys)
+ * - {{publishableKey}}: The publishable API key of the project (if using new API keys)
+ *
+ * These could be helpful in rendering, for e.g an environment file like `.env`
+ */
+ connectFrameworks: {
+ key: string
+ label: string
+ obj: ConnectionType[]
+ }
+}
diff --git a/apps/studio/hooks/custom-content/custom-content.json b/apps/studio/hooks/custom-content/custom-content.json
new file mode 100644
index 0000000000000..1378a1822a96f
--- /dev/null
+++ b/apps/studio/hooks/custom-content/custom-content.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "./custom-content.schema.json",
+
+ "organization:legal_documents": null,
+
+ "project_homepage:example_projects": null,
+
+ "logs:default_query": null,
+
+ "connect:frameworks": null
+}
diff --git a/apps/studio/hooks/custom-content/custom-content.sample.json b/apps/studio/hooks/custom-content/custom-content.sample.json
new file mode 100644
index 0000000000000..7992201514dc8
--- /dev/null
+++ b/apps/studio/hooks/custom-content/custom-content.sample.json
@@ -0,0 +1,76 @@
+{
+ "$schema": "./custom-content.schema.json",
+
+ "organization:legal_documents": [
+ {
+ "id": "doc1",
+ "name": "Document 1",
+ "description": "This is a description of Document 1",
+ "action": {
+ "text": "Download document",
+ "url": "https://supabase.com"
+ }
+ },
+ {
+ "id": "doc2",
+ "name": "Document 2",
+ "description": "This is a description of Document 2",
+ "action": {
+ "text": "Download document",
+ "url": "https://supabase.com"
+ }
+ }
+ ],
+
+ "project_homepage:example_projects": [
+ {
+ "title": "Framework 1",
+ "description": "This is a description of Framework 1",
+ "iconUrl": "https://supabase.com/dashboard/img/supabase-logo.svg",
+ "url": "https://supabase.com"
+ },
+ {
+ "title": "Framework 2",
+ "description": "This is a description of Framework 2",
+ "iconUrl": "https://supabase.com/dashboard/img/supabase-logo.svg",
+ "url": "https://supabase.com"
+ }
+ ],
+
+ "logs:default_query": "This is a sample query",
+
+ "connect:frameworks": {
+ "key": "frameworks",
+ "label": "Frameworks",
+ "obj": [
+ {
+ "key": "framework-1",
+ "label": "Framework 1",
+ "icon": "https://supabase.com/dashboard/img/supabase-logo.svg",
+ "guideLink": "https://supabase.com/docs",
+ "children": [],
+ "files": [
+ {
+ "name": "sample_env",
+ "content": "NEXT_PUBLIC_SUPABASE_URL={{apiUrl}}\nNEXT_PUBLIC_SUPABASE_ANON_KEY={{anonKey}}"
+ },
+ {
+ "name": "sample.ts",
+ "content": "import { createClient } from \"@supabase/supabase-js\";\n\nconst supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;\nconst supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;\n\nexport const supabase = createClient(supabaseUrl, supabaseKey);"
+ }
+ ]
+ },
+ {
+ "key": "framework-2",
+ "label": "Framework 2",
+ "icon": "https://supabase.com/dashboard/img/supabase-logo.svg",
+ "guideLink": "https://supabase.com/docs",
+ "children": [],
+ "files": [
+ { "name": "file3.tsx", "content": "Content of File 3" },
+ { "name": "file4.tsx", "content": "Content of File 4" }
+ ]
+ }
+ ]
+ }
+}
diff --git a/apps/studio/hooks/custom-content/custom-content.schema.json b/apps/studio/hooks/custom-content/custom-content.schema.json
new file mode 100644
index 0000000000000..6d7020a3447ae
--- /dev/null
+++ b/apps/studio/hooks/custom-content/custom-content.schema.json
@@ -0,0 +1,84 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "properties": {
+ "$schema": {
+ "type": "string"
+ },
+
+ "organization:legal_documents": {
+ "type": ["array", "null"],
+ "description": "Renders a provided set of documents under the organization legal documents page",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "string" },
+ "name": { "type": "string" },
+ "description": { "type": "string" },
+ "action": {
+ "type": "object",
+ "properties": {
+ "text": { "type": "string" },
+ "url": { "type": "string" }
+ }
+ }
+ }
+ }
+ },
+
+ "project_homepage:example_projects": {
+ "type": ["array", "null"],
+ "description": "Renders a provided set of example projects under the project's home page",
+ "items": {
+ "type": "object",
+ "properties": {
+ "title": { "type": "string" },
+ "description": { "type": "string" },
+ "iconUrl": { "type": "string" },
+ "url": { "type": "string" }
+ }
+ }
+ },
+
+ "logs:default_query": {
+ "type": ["string", "null"],
+ "description": "Renders a default query when landing on the logs explorer"
+ },
+
+ "connect:frameworks": {
+ "type": ["object", "null"],
+ "description": "Replaces the frameworks tab in the Connect modal when a defined set of frameworks",
+ "properties": {
+ "key": { "type": "string" },
+ "label": { "type": "string" },
+ "obj": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "key": { "type": "string" },
+ "icon": { "type": "string" },
+ "label": { "type": "string" },
+ "guideLink": { "type": "string" },
+ "children": { "type": "array" },
+ "files": {
+ "type": "array",
+ "properties": {
+ "name": { "type": "string" },
+ "content": { "type": "string" }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "required": [
+ "organization:legal_documents",
+ "project_homepage:example_projects",
+ "logs:default_query",
+ "connect:frameworks"
+ ],
+ "additionalProperties": false
+}
diff --git a/apps/studio/hooks/custom-content/useCustomContent.test.ts b/apps/studio/hooks/custom-content/useCustomContent.test.ts
new file mode 100644
index 0000000000000..e6ca936f758c9
--- /dev/null
+++ b/apps/studio/hooks/custom-content/useCustomContent.test.ts
@@ -0,0 +1,36 @@
+import { renderHook, cleanup } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ vi.resetModules()
+ cleanup()
+})
+
+describe('useCustomContent', () => {
+ it('should return null if content is not found in the custom-content.json file', async () => {
+ vi.doMock('./custom-content.json', () => ({
+ default: {
+ 'organization:legal_documents': null,
+ },
+ }))
+
+ const { useCustomContent } = await import('./useCustomContent')
+ const { result } = renderHook(() => useCustomContent(['organization:legal_documents']))
+ expect(result.current.organizationLegalDocuments).toEqual(null)
+ })
+
+ it('should return the content for the key passed in if it exists in the custom-content.json file', async () => {
+ vi.doMock('./custom-content.json', () => ({
+ default: {
+ 'organization:legal_documents': {
+ someValue: 'foo',
+ },
+ },
+ }))
+
+ const { useCustomContent } = await import('./useCustomContent')
+ const { result } = renderHook(() => useCustomContent(['organization:legal_documents']))
+ expect(result.current.organizationLegalDocuments).toEqual({ someValue: 'foo' })
+ })
+})
diff --git a/apps/studio/hooks/custom-content/useCustomContent.ts b/apps/studio/hooks/custom-content/useCustomContent.ts
new file mode 100644
index 0000000000000..0d1b23426420e
--- /dev/null
+++ b/apps/studio/hooks/custom-content/useCustomContent.ts
@@ -0,0 +1,44 @@
+import customContentRaw from './custom-content.json'
+import { CustomContentTypes } from './CustomContent.types'
+
+// [Joshen] See if we can de-dupe any of the logic here with enabled-features
+// For now just getting something working going first
+// Also not sure if CustomContentTypes is the right way to go here with trying to dynamically type
+
+const customContentStaticObj = customContentRaw as Omit
+type CustomContent = keyof typeof customContentStaticObj
+
+type SnakeToCamelCase = S extends `${infer First}_${infer Rest}`
+ ? `${First}${SnakeToCamelCase>}`
+ : S
+
+type CustomContentToCamelCase = S extends `${infer P}:${infer R}`
+ ? `${SnakeToCamelCase }${Capitalize>}`
+ : SnakeToCamelCase
+
+function contentToCamelCase(feature: CustomContent) {
+ return feature
+ .replace(/:/g, '_')
+ .split('_')
+ .map((word, index) => (index === 0 ? word : word[0].toUpperCase() + word.slice(1)))
+ .join('') as CustomContentToCamelCase
+}
+
+const useCustomContent = (
+ contents: T
+): {
+ [key in CustomContentToCamelCase]:
+ | (typeof customContentStaticObj)[CustomContent]
+ | CustomContentTypes[CustomContentToCamelCase]
+} => {
+ // [Joshen] Running into some TS errors without the `as` here - must be overlooking something super simple
+ return Object.fromEntries(
+ contents.map((content) => [contentToCamelCase(content), customContentStaticObj[content]])
+ ) as {
+ [key in CustomContentToCamelCase]:
+ | (typeof customContentStaticObj)[CustomContent]
+ | CustomContentTypes[CustomContentToCamelCase]
+ }
+}
+
+export { useCustomContent }
diff --git a/apps/studio/pages/project/[ref]/index.tsx b/apps/studio/pages/project/[ref]/index.tsx
index f075fd2b228bd..567ecaa180002 100644
--- a/apps/studio/pages/project/[ref]/index.tsx
+++ b/apps/studio/pages/project/[ref]/index.tsx
@@ -3,8 +3,9 @@ import Link from 'next/link'
import { useEffect, useMemo, useRef } from 'react'
import { useParams } from 'common'
-import { ClientLibrary, ExampleProject } from 'components/interfaces/Home'
+import { ClientLibrary } from 'components/interfaces/Home'
import { AdvisorWidget } from 'components/interfaces/Home/AdvisorWidget'
+import { ExampleProject } from 'components/interfaces/Home/ExampleProject'
import { CLIENT_LIBRARIES, EXAMPLE_PROJECTS } from 'components/interfaces/Home/Home.constants'
import { NewProjectPanel } from 'components/interfaces/Home/NewProjectPanel/NewProjectPanel'
import { ProjectUsageSection } from 'components/interfaces/Home/ProjectUsageSection'
@@ -19,6 +20,7 @@ import { useBranchesQuery } from 'data/branches/branches-query'
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
import { useTablesQuery } from 'data/tables/tables-query'
+import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import {
@@ -50,6 +52,8 @@ const Home: NextPageWithLayout = () => {
const snap = useAppStateSnapshot()
const { ref, enableBranching } = useParams()
+ const { projectHomepageExampleProjects } = useCustomContent(['project_homepage:example_projects'])
+
const {
projectHomepageShowAllClientLibraries: showAllClientLibraries,
projectHomepageShowInstanceSize: showInstanceSize,
@@ -243,36 +247,46 @@ const Home: NextPageWithLayout = () => {