-
You need additional permissions to {resourceText}
-
-
- Contact your organization owner or adminstrator for assistance.
-
+const NoPermission: FC
= ({ resourceText, isFullPage = false }) => {
+ const NoPermissionMessage = ({}) => (
+
+
+
+
+
+
+
+
You need additional permissions to {resourceText}
+
+
+ Contact your organization owner or adminstrator for assistance.
+
+
-
-)
+ )
+
+ if (isFullPage) {
+ return (
+
+ )
+ } else {
+ return
+ }
+}
export default NoPermission
diff --git a/studio/hooks/analytics/useLogsPreview.tsx b/studio/hooks/analytics/useLogsPreview.tsx
index d22fa934c6c..b31575e956b 100644
--- a/studio/hooks/analytics/useLogsPreview.tsx
+++ b/studio/hooks/analytics/useLogsPreview.tsx
@@ -1,6 +1,9 @@
import {
Count,
+ EventChart,
+ EventChartData,
Filters,
+ genChartQuery,
genCountQuery,
genDefaultQuery,
genQueryParams,
@@ -27,6 +30,7 @@ interface Data {
filters: Filters
params: LogsEndpointParams
oldestTimestamp?: string
+ eventChartData: EventChartData[] | null
}
interface Handlers {
loadOlder: () => void
@@ -116,11 +120,40 @@ function useLogsPreview(
})
const newCount = countData?.result?.[0]?.count ?? 0
+ // chart data
+
+ const chartQuery = genChartQuery(table, params, filters)
+ const chartUrl = () => {
+ // cancel request if no sql provided
+ if (!params.sql) {
+ // return null to restrict unnecessary requests to api
+ // https://swr.vercel.app/docs/conditional-fetching#conditional
+ return null
+ }
+
+ return `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${genQueryParams({
+ iso_timestamp_end: params.iso_timestamp_end,
+ project: params.project,
+ sql: chartQuery,
+ } as any)}`
+ }
+
+ const { data: eventChartResponse, mutate: refreshEventChart } = useSWR
(
+ chartUrl,
+ get,
+ {
+ revalidateOnFocus: false,
+ dedupingInterval: 10000,
+ refreshInterval: 0,
+ }
+ )
+
const refresh = async () => {
const generatedSql = genDefaultQuery(table, filters)
setParams((prev) => ({ ...prev, sql: generatedSql }))
setLatestRefresh(new Date().toISOString())
setSize(1)
+ refreshEventChart()
}
let error: null | string | object = swrError ? swrError.message : null
@@ -155,6 +188,7 @@ function useLogsPreview(
filters,
params,
oldestTimestamp: oldestTimestamp ? String(oldestTimestamp) : undefined,
+ eventChartData: eventChartResponse?.result || null,
},
{
setFilters: handleSetFilters,
diff --git a/studio/pages/org/[slug]/settings.tsx b/studio/pages/org/[slug]/settings.tsx
index 7ca63b1cd18..c7d79c06efb 100644
--- a/studio/pages/org/[slug]/settings.tsx
+++ b/studio/pages/org/[slug]/settings.tsx
@@ -4,7 +4,7 @@ import { useRouter } from 'next/router'
import { createContext, useContext, useEffect, useState } from 'react'
import { Member, NextPageWithLayout, Organization, Project, Role, User } from 'types'
-import { useOrganizationDetail, useOrganizationRoles, useStore, withAuth } from 'hooks'
+import { useFlag, useOrganizationDetail, useOrganizationRoles, useStore, withAuth } from 'hooks'
import { AccountLayoutWithoutAuth } from 'components/layouts'
import {
GeneralSettings,
@@ -21,7 +21,14 @@ const OrgSettingsLayout = withAuth(
const router = useRouter()
const slug = ui.selectedOrganization?.slug || ''
- const { roles } = useOrganizationRoles(slug)
+
+ const { roles: allRoles } = useOrganizationRoles(slug)
+ const enableBillingOnlyReadOnly = useFlag('enableBillingOnlyReadOnlyRoles')
+ const roles = enableBillingOnlyReadOnly
+ ? allRoles
+ : (allRoles ?? []).filter((role) =>
+ ['Owner', 'Administrator', 'Developer'].includes(role.name)
+ )
// [Refactor] Eventually move away from useLocalObservable
const PageState: any = useLocalObservable(() => ({
diff --git a/studio/pages/project/[ref]/auth/auth-logs.tsx b/studio/pages/project/[ref]/auth/logs.tsx
similarity index 58%
rename from studio/pages/project/[ref]/auth/auth-logs.tsx
rename to studio/pages/project/[ref]/auth/logs.tsx
index fc343aa0542..bf2fbffeee6 100644
--- a/studio/pages/project/[ref]/auth/auth-logs.tsx
+++ b/studio/pages/project/[ref]/auth/logs.tsx
@@ -1,14 +1,21 @@
import { observer } from 'mobx-react-lite'
-import { useStore } from 'hooks'
+import { PermissionAction } from '@supabase/shared-types/out/constants'
+
+import { NextPageWithLayout } from 'types'
+import { checkPermissions, useStore } from 'hooks'
import { AuthLayout } from 'components/layouts'
import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer'
-import { NextPageWithLayout } from 'types'
+import NoPermission from 'components/ui/NoPermission'
const LogsPage: NextPageWithLayout = () => {
const { ui } = useStore()
const project = ui.selectedProject
- return (
+ const canReadAuthLogs = checkPermissions(PermissionAction.ANALYTICS_READ, 'logflare')
+
+ return !canReadAuthLogs ? (
+
+ ) : (
<>{project && }>
)
}
diff --git a/studio/pages/project/[ref]/auth/settings.tsx b/studio/pages/project/[ref]/auth/settings.tsx
index 2762edc57c9..ea14e36bc0d 100644
--- a/studio/pages/project/[ref]/auth/settings.tsx
+++ b/studio/pages/project/[ref]/auth/settings.tsx
@@ -9,6 +9,7 @@ import AutoSchemaForm from 'components/interfaces/Auth/AutoSchemaForm'
import RedirectDomains from 'components/interfaces/Auth/RedirectDomains/RedirectDomains'
import { AuthLayout } from 'components/layouts'
import { FormsContainer } from 'components/ui/Forms'
+import NoPermission from 'components/ui/NoPermission'
const PageLayout: NextPageWithLayout = () => {
const { ui, authConfig } = useStore()
@@ -17,9 +18,11 @@ const PageLayout: NextPageWithLayout = () => {
authConfig.load()
}, [ui.selectedProjectRef])
- // const canUpdate = checkPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
+ const canReadAuthSettings = checkPermissions(PermissionAction.READ, 'custom_config_gotrue')
- if (authConfig) {
+ if (!canReadAuthSettings) {
+ return
+ } else if (authConfig) {
return (
diff --git a/studio/pages/project/[ref]/auth/templates.tsx b/studio/pages/project/[ref]/auth/templates.tsx
index 74e27bf1e6f..56a21f3dee6 100644
--- a/studio/pages/project/[ref]/auth/templates.tsx
+++ b/studio/pages/project/[ref]/auth/templates.tsx
@@ -1,20 +1,26 @@
-import { EmailTemplates, SmtpForm } from 'components/interfaces'
-import { AuthLayout } from 'components/layouts'
-import { FormsContainer } from 'components/ui/Forms'
-import { useStore } from 'hooks'
-import { observer } from 'mobx-react-lite'
import { useEffect } from 'react'
+import { observer } from 'mobx-react-lite'
+import { PermissionAction } from '@supabase/shared-types/out/constants'
+
import { NextPageWithLayout } from 'types'
+import { useStore, checkPermissions } from 'hooks'
+import { AuthLayout } from 'components/layouts'
+import { EmailTemplates, SmtpForm } from 'components/interfaces'
+import { FormsContainer } from 'components/ui/Forms'
+import NoPermission from 'components/ui/NoPermission'
const PageLayout: NextPageWithLayout = () => {
const { ui, authConfig } = useStore()
useEffect(() => {
- // temporary store loader
authConfig.load()
}, [ui.selectedProjectRef])
- if (authConfig) {
+ const canReadAuthSettings = checkPermissions(PermissionAction.READ, 'custom_config_gotrue')
+
+ if (!canReadAuthSettings) {
+ return
+ } else if (authConfig) {
return (
diff --git a/studio/pages/project/[ref]/auth/users.tsx b/studio/pages/project/[ref]/auth/users.tsx
index d88d8b88a14..f6151f06474 100644
--- a/studio/pages/project/[ref]/auth/users.tsx
+++ b/studio/pages/project/[ref]/auth/users.tsx
@@ -4,10 +4,12 @@ import { useRouter } from 'next/router'
import { get } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
-import { useStore } from 'hooks'
+import { checkPermissions, useStore } from 'hooks'
import { AuthLayout } from 'components/layouts'
import { Users } from 'components/interfaces/Authentication'
import { NextPageWithLayout } from 'types'
+import { PermissionAction } from '@supabase/shared-types/out/constants'
+import NoPermission from 'components/ui/NoPermission'
export const PageContext = createContext(null)
@@ -84,7 +86,13 @@ const UsersPage: NextPageWithLayout = () => {
PageState!.projectKpsVersion = project?.kpsVersion
}, [project])
- return
+ const canReadUsers = checkPermissions(PermissionAction.TENANT_SQL_SELECT, 'auth.users')
+
+ return !canReadUsers ? (
+
+ ) : (
+
+ )
}
UsersPage.getLayout = (page) => (
diff --git a/studio/pages/project/[ref]/reports/index.tsx b/studio/pages/project/[ref]/reports/index.tsx
index d49fb9663fa..0a32a8ab1f4 100644
--- a/studio/pages/project/[ref]/reports/index.tsx
+++ b/studio/pages/project/[ref]/reports/index.tsx
@@ -22,9 +22,7 @@ const PageLayout: NextPageWithLayout = () => {
const { ui } = useStore()
const project = ui.selectedProject
- // const canCreateReport = checkPermissions(PermissionAction.CREATE, 'user_content', {
- // resource: { type: 'report' },
- // })
+ // const canCreateReport = checkPermissions(PermissionAction.CR
const contentStore = useProjectContentStore(ref)
useEffect(() => {
diff --git a/studio/tests/pages/projects/LogTable.test.js b/studio/tests/pages/projects/LogTable.test.js
index 12446159483..6ed545ea736 100644
--- a/studio/tests/pages/projects/LogTable.test.js
+++ b/studio/tests/pages/projects/LogTable.test.js
@@ -62,6 +62,47 @@ test('can display standard preview table columns', async () => {
await expect(screen.findByText(/12345/)).rejects.toThrow()
await expect(screen.findByText(fakeMicroTimestamp)).rejects.toThrow()
})
+
+test.each([
+ {
+ queryType: 'functions',
+ data: [
+ {
+ event_message: 'This is a error log\n',
+ event_type: 'log',
+ function_id: '001b0b08-331c-403e-810c-a2004b03a019',
+ level: 'error',
+ timestamp: 1659545029083869,
+ id: '3475cf6f-2929-4296-ab44-ce2c17069937',
+ },
+ ],
+ includes: [/ERROR/],
+ excludes: ['undefined', 'null'],
+ },
+ {
+ queryType: 'functions',
+ data: [
+ {
+ event_message: 'This is a uncaughtExceptop\n',
+ event_type: 'uncaughtException',
+ function_id: '001b0b08-331c-403e-810c-a2004b03a019',
+ timestamp: 1659545029083869,
+ id: '4475cf6f-2929-4296-ab44-ce2c17069937',
+ level: null,
+ },
+ ],
+ includes: [/uncaughtException/],
+ excludes: [/ERROR/],
+ },
+])('table col renderer for $queryType', async ({ queryType, data, includes, excludes }) => {
+ render()
+
+ await Promise.all([
+ ...includes.map((text) => screen.findByText(text)),
+ ...excludes.map((text) => expect(screen.findByText(text)).rejects.toThrow()),
+ ])
+})
+
test('can display custom columns and headers based on data input', async () => {
render()
await waitFor(() => screen.getByText(/some_header/))
diff --git a/studio/tests/pages/projects/Logs.utils.test.js b/studio/tests/pages/projects/Logs.utils.test.js
index eb3d0a56fe1..1b4f66eee48 100644
--- a/studio/tests/pages/projects/Logs.utils.test.js
+++ b/studio/tests/pages/projects/Logs.utils.test.js
@@ -1,8 +1,11 @@
import {
+ ensureNoTimestampConflict,
+ genChartQuery,
genDefaultQuery,
LogsTableName,
SQL_FILTER_TEMPLATES,
} from 'components/interfaces/Settings/Logs'
+import dayjs from 'dayjs'
describe.each(Object.values(LogsTableName))('%s', (table) => {
const templates = SQL_FILTER_TEMPLATES[table]
@@ -60,5 +63,38 @@ describe.each(Object.values(LogsTableName))('%s', (table) => {
contains.forEach((str) => expect(generated).toContain(str))
excludes.forEach((str) => expect(generated).not.toContain(str))
})
+ test('generated chart query should include filters', () => {
+ const generated = genChartQuery(table, {}, { 'override.nested': 'something' })
+ expect(generated).toContain('override.nested')
+ expect(generated).toContain('something')
+ expect(generated).toContain('timestamp_trunc')
+ })
})
})
+
+const base = dayjs().subtract(2, 'day')
+const baseIso = base.toISOString()
+test.each([
+ {
+ case: 'next start is after initial start',
+ initial: [base.subtract(1, 'day').toISOString(), baseIso],
+ next: [base.subtract(2, 'day').toISOString(), null],
+ expected: [base.subtract(2, 'day').toISOString(), baseIso],
+ },
+ {
+ case: 'next end is before initial start',
+ initial: [base.subtract(1, 'day').toISOString(), baseIso],
+ next: [null, base.subtract(2, 'day').toISOString()],
+ expected: [base.subtract(3, 'day').toISOString(), base.subtract(2, 'day').toISOString()],
+ },
+ {
+ case: 'next end is not before initial start',
+ initial: [base.subtract(2, 'day').toISOString(), baseIso],
+ next: [null, base.subtract(1, 'day').toISOString()],
+ expected: [base.subtract(2, 'day').toISOString(), base.subtract(1, 'day').toISOString()],
+ },
+])('ensure no timestamp conflict: $case', ({ initial, next, expected }) => {
+ const result = ensureNoTimestampConflict(initial, next)
+ expect(result[0]).toEqual(expected[0])
+ expect(result[1]).toEqual(expected[1])
+})
diff --git a/studio/tests/pages/projects/LogsPreviewer.test.js b/studio/tests/pages/projects/LogsPreviewer.test.js
index 0eac35647b3..a9e5abdfe25 100644
--- a/studio/tests/pages/projects/LogsPreviewer.test.js
+++ b/studio/tests/pages/projects/LogsPreviewer.test.js
@@ -193,6 +193,21 @@ test('poll count for new messages', async () => {
await waitFor(() => screen.queryByText(/125/) === null)
await screen.findByText(/some-uuid123/)
})
+test('log event chart', async () => {
+ get.mockImplementation((url) => {
+ // truncate
+ if (url.includes('trunc')) {
+ return { result: [{ timestamp: new Date().toISOString(), count: 125 }] }
+ }
+ return {
+ result: [logDataFixture({ id: 'some-uuid123' })],
+ }
+ })
+ render()
+
+ await waitFor(() => screen.queryByText(/some-uuid123/) === null)
+ expect(get).toBeCalledWith(expect.stringContaining('trunc'))
+})
test('s= query param will populate the search bar', async () => {
const router = defaultRouterMock()
@@ -219,7 +234,7 @@ test('te= query param will populate the timestamp to input', async () => {
expect.stringContaining(`iso_timestamp_end=${encodeURIComponent(iso)}`)
)
})
- userEvent.click(await screen.findByText('Custom'))
+ userEvent.click(await screen.findByTitle('Custom'))
})
test('ts= query param will populate the timestamp from input', async () => {
// get time 20 mins before
@@ -236,7 +251,7 @@ test('ts= query param will populate the timestamp from input', async () => {
expect.stringContaining(`iso_timestamp_start=${encodeURIComponent(iso)}`)
)
})
- userEvent.click(await screen.findByText('Custom'))
+ userEvent.click(await screen.findByTitle('Custom'))
await screen.findByText(new RegExp(newDate.getFullYear()))
})
@@ -289,6 +304,9 @@ test('bug: load older btn does not error out when previous page is empty', async
})
test('log event chart hide', async () => {
+ get.mockImplementation((url) => {
+ return { result: [] }
+ })
render()
await screen.findByText('Events')
const toggle = await screen.findByText(/Chart/)
diff --git a/studio/tests/pages/projects/PreviewFilterPanel.test.js b/studio/tests/pages/projects/PreviewFilterPanel.test.js
index 85ef27cdf1b..e8a55735627 100644
--- a/studio/tests/pages/projects/PreviewFilterPanel.test.js
+++ b/studio/tests/pages/projects/PreviewFilterPanel.test.js
@@ -49,11 +49,11 @@ test('Manual refresh', async () => {
expect(mockFn).toBeCalled()
})
test('Datepicker dropdown', async () => {
- render()
+ const fn = jest.fn()
+ render()
clickDropdown(await screen.findByText(/Last hour/))
userEvent.click(await screen.findByText(/Last 3 hours/))
- await screen.findByText(/Last 3 hours/)
- await expect(screen.findByText(/Last hour/)).rejects.toThrow()
+ expect(fn).toBeCalled()
})
test.todo('timestamp to/from filter default value')