diff --git a/packages/ui/src/components/Checkbox/Checkbox.tsx b/packages/ui/src/components/Checkbox/Checkbox.tsx index 13707b536a9..0f325e4a18a 100644 --- a/packages/ui/src/components/Checkbox/Checkbox.tsx +++ b/packages/ui/src/components/Checkbox/Checkbox.tsx @@ -84,6 +84,7 @@ function Group({ checked={option.checked} name={option.name} description={option.description} + defaultChecked={option.defaultChecked} /> ) }) diff --git a/packages/ui/src/components/SidePanel/SidePanel.tsx b/packages/ui/src/components/SidePanel/SidePanel.tsx index f5dd4fe552c..13622bbce5d 100644 --- a/packages/ui/src/components/SidePanel/SidePanel.tsx +++ b/packages/ui/src/components/SidePanel/SidePanel.tsx @@ -61,10 +61,6 @@ const SidePanel = ({ }: SidePanelProps) => { const __styles = styleHandler('sidepanel') - // function stopPropagation(e: React.MouseEvent) { - // e.stopPropagation() - // } - const footerContent = customFooter ? ( customFooter ) : ( @@ -74,35 +70,37 @@ const SidePanel = ({ {cancelText} - - -
- -
-
- {tooltip !== undefined && ( - - - -
+ +
+
- - - )} - + {confirmText} + +
+ + {tooltip !== undefined && ( + + + +
+ {tooltip} +
+
+
+ )} +
+ )} ) diff --git a/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx b/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx new file mode 100644 index 00000000000..c463ce98397 --- /dev/null +++ b/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx @@ -0,0 +1,346 @@ +import * as Tooltip from '@radix-ui/react-tooltip' +import dayjs from 'dayjs' +import Image from 'next/image' +import { useState } from 'react' + +import { useParams } from 'common' +import Table from 'components/to-be-cleaned/Table' +import AlertError from 'components/ui/AlertError' +import { DatePicker } from 'components/ui/DatePicker' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { + OrganizationAuditLog, + useOrganizationAuditLogsQuery, +} from 'data/organizations/organization-audit-logs-query' +import { useOrganizationDetailQuery } from 'data/organizations/organization-detail-query' +import { useOrganizationRolesQuery } from 'data/organizations/organization-roles-query' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useProjectsQuery } from 'data/projects/projects-query' +import { Alert, Button, IconArrowDown, IconArrowUp, IconRefreshCw, IconUser } from 'ui' +import FilterPopover from './FilterPopover' +import LogDetailsPanel from './LogDetailsPanel' +import { ScaffoldContainerLegacy } from 'components/layouts/Scaffold' + +// [Joshen considerations] +// - Maybe fix the height of the table to the remaining height of the viewport, so that the search input is always visible +// - We'll need pagination as well if the audit logs get too large, but that needs to be implemented on the API side first if possible +// - I've hidden time input in the date picker for now cause the time support in the component is a bit iffy, need to investigate +// - Maybe a rule to follow from here is just everytime we call dayjs, use UTC(), one TZ to rule them all + +const AuditLogs = () => { + const { slug } = useParams() + const currentTime = dayjs().utc().set('millisecond', 0) + const [dateSortDesc, setDateSortDesc] = useState(true) + const [dateRange, setDateRange] = useState({ + from: currentTime.subtract(1, 'day').toISOString(), + to: currentTime.toISOString(), + }) + const [selectedLog, setSelectedLog] = useState() + const [filters, setFilters] = useState<{ users: string[]; projects: string[] }>({ + users: [], // gotrue_id[] + projects: [], // project_ref[] + }) + + const { data: projects } = useProjectsQuery() + const { data: organizations } = useOrganizationsQuery() + const { data: detailData } = useOrganizationDetailQuery({ slug }) + const { data: rolesData } = useOrganizationRolesQuery({ slug }) + const { data, error, isLoading, isSuccess, isError, isRefetching, refetch } = + useOrganizationAuditLogsQuery({ + slug, + iso_timestamp_start: dateRange.from, + iso_timestamp_end: dateRange.to, + }) + + const members = detailData?.members ?? [] + const roles = rolesData?.roles ?? [] + + const retentionPeriod = data?.retention_period ?? 0 + const logs = data?.result ?? [] + const sortedLogs = logs + ?.sort((a, b) => + dateSortDesc + ? Number(new Date(b.occurred_at)) - Number(new Date(a.occurred_at)) + : Number(new Date(a.occurred_at)) - Number(new Date(b.occurred_at)) + ) + ?.filter((log) => { + if (filters.users.length > 0) { + return filters.users.includes(log.actor.id) + } else { + return log + } + }) + ?.filter((log) => { + if (filters.projects.length > 0) { + return filters.projects.includes(log.target.metadata.project_ref || '') + } else { + return log + } + }) + + return ( + <> + +
+
+
+

Filter by

+ setFilters({ ...filters, users: values })} + /> + setFilters({ ...filters, projects: values })} + /> + { + if (value.from !== null && value.to !== null) { + const current = dayjs().utc() + const from = dayjs(value.from) + .utc() + .hour(current.hour()) + .minute(current.minute()) + .second(current.second()) + .toISOString() + const to = dayjs(value.to) + .utc() + .hour(current.hour()) + .minute(current.minute()) + .second(current.second()) + .toISOString() + setDateRange({ from, to }) + } + }} + renderFooter={() => { + return ( + + Your organization has a log retention period of{' '} + + {retentionPeriod} day + {retentionPeriod > 1 ? 's' : ''} + + . You may only view logs from{' '} + {dayjs().subtract(retentionPeriod, 'days').format('DD MMM YYYY')} as the + earliest date. + + ) + }} + /> + {isSuccess && ( + <> +
+

Viewing {sortedLogs.length} logs in total

+ + )} +
+ +
+ + {isLoading && ( +
+ + + +
+ )} + + {isError && } + + {isSuccess && ( + <> + {logs.length === 0 ? ( +
+

+ Your organization does not have any audit logs available yet +

+
+ ) : logs.length > 0 && sortedLogs.length === 0 ? ( +
+

No audit logs found based on the filters applied

+
+ ) : ( + + User + , + + Action + , + + Target + , + +
+

Date

+ + + +
+
, + , + ]} + body={ + sortedLogs?.map((log) => { + const user = members.find((member) => member.gotrue_id === log.actor.id) + const role = roles.find((role) => user?.role_ids?.[0] === role.id) + const project = projects?.find( + (project) => project.ref === log.target.metadata.project_ref + ) + const organization = organizations?.find( + (org) => org.slug === log.target.metadata.org_slug + ) + + const hasStatusCode = log.action.metadata[0]?.status !== undefined + const userIcon = + user === undefined ? ( +
+

?

+
+ ) : user?.invited_id || user?.username === user?.primary_email ? ( +
+ +
+ ) : ( + + ) + + return ( + setSelectedLog(log)} + className="cursor-pointer hover:!bg-scale-100 transition duration-100" + > + +
+
{userIcon}
+
+

{user?.username ?? 'Unknown'}

+ {role && ( +

{role?.name}

+ )} +
+
+
+ +
+ {hasStatusCode && ( +

+ {log.action.metadata[0].status} +

+ )} +

+ {log.action.name} +

+
+
+ +

+ {project?.name + ? 'Project: ' + : organization?.name + ? 'Organization: ' + : null} + {project?.name ?? organization?.name ?? 'Unknown'} +

+

+ {log.target.metadata.project_ref + ? 'Ref: ' + : log.target.metadata.org_slug + ? 'Slug: ' + : null} + {log.target.metadata.project_ref ?? log.target.metadata.org_slug} +

+
+ + {dayjs(log.occurred_at).format('DD MMM YYYY, HH:mm:ss')} + + + + +
+ ) + }) ?? [] + } + /> + )} + + )} + + + + setSelectedLog(undefined)} /> + + ) +} + +export default AuditLogs diff --git a/studio/components/interfaces/Organization/AuditLogs/FilterPopover.tsx b/studio/components/interfaces/Organization/AuditLogs/FilterPopover.tsx new file mode 100644 index 00000000000..c38c96736e3 --- /dev/null +++ b/studio/components/interfaces/Organization/AuditLogs/FilterPopover.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react' +import { Button, Checkbox, Popover } from 'ui' + +interface FilterPopoverProps { + options: any[] + activeOptions: any[] + valueKey: string + labelKey: string + name: string + onSaveFilters: (options: string[]) => void +} + +// [Joshen] Form + Checkbox.Group doesn't seem to work properly RE form state +// hence why the manual implementation here as a workaround + +const FilterPopover = ({ + options = [], + activeOptions = [], + valueKey, + labelKey, + name, + onSaveFilters, +}: FilterPopoverProps) => { + const [open, setOpen] = useState(false) + const [selectedOptions, setSelectedOptions] = useState([]) + + const formattedOptions = activeOptions.map((option) => { + const base = options.find((x) => x[valueKey] === option) + return base[labelKey] + }) + + return ( + setOpen(!open)} + header={
Select {name.toLowerCase()}
} + overlay={ + <> +
+ { + const value = event.target.value + if (selectedOptions.includes(value)) { + setSelectedOptions(selectedOptions.filter((x) => x !== value)) + } else { + setSelectedOptions(selectedOptions.concat(value)) + } + }} + options={options.map((option) => { + return { + value: option[valueKey], + label: option[labelKey], + defaultChecked: activeOptions.includes(option[valueKey]), + } + })} + > +
+
+ + +
+ + } + > + +
+ ) +} + +export default FilterPopover diff --git a/studio/components/interfaces/Organization/AuditLogs/LogDetailsPanel.tsx b/studio/components/interfaces/Organization/AuditLogs/LogDetailsPanel.tsx new file mode 100644 index 00000000000..0f374319b15 --- /dev/null +++ b/studio/components/interfaces/Organization/AuditLogs/LogDetailsPanel.tsx @@ -0,0 +1,97 @@ +import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms' +import { OrganizationAuditLog } from 'data/organizations/organization-audit-logs-query' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useProjectsQuery } from 'data/projects/projects-query' +import dayjs from 'dayjs' +import { Input, SidePanel } from 'ui' + +export interface LogDetailsPanelProps { + selectedLog?: OrganizationAuditLog + onClose: () => void +} + +const LogDetailsPanel = ({ selectedLog, onClose }: LogDetailsPanelProps) => { + const { data: projects } = useProjectsQuery() + const { data: organizations } = useOrganizationsQuery() + + const project = projects?.find( + (project) => project.ref === selectedLog?.target.metadata.project_ref + ) + const organization = organizations?.find( + (org) => org.slug === selectedLog?.target.metadata.org_slug + ) + + return ( + + General}> + + + + + + + + Actor}> + + + + + + + + + Action}> + + + + + + + + + Target}> + + + + + + + ) +} + +export default LogDetailsPanel diff --git a/studio/components/interfaces/Organization/index.ts b/studio/components/interfaces/Organization/index.ts index 1c6271c6d6f..800b5c31cfa 100644 --- a/studio/components/interfaces/Organization/index.ts +++ b/studio/components/interfaces/Organization/index.ts @@ -3,6 +3,7 @@ import GeneralSettings from './GeneralSettings/GeneralSettings' import IntegrationSettings from './IntegrationSettings/IntegrationSettings' import InvoicesSettings from './InvoicesSettings/InvoicesSettings' import NewOrgForm from './NewOrg/NewOrgForm' +import AuditLogs from './AuditLogs/AuditLogs' import OAuthApps from './OAuthApps/OAuthApps' import TeamSettings from './TeamSettings/TeamSettings' @@ -13,5 +14,6 @@ export { InvoicesSettings, NewOrgForm, OAuthApps, + AuditLogs, TeamSettings, } diff --git a/studio/components/layouts/OrganizationLayout.tsx b/studio/components/layouts/OrganizationLayout.tsx index c79d90c60c2..48a780dd744 100644 --- a/studio/components/layouts/OrganizationLayout.tsx +++ b/studio/components/layouts/OrganizationLayout.tsx @@ -12,6 +12,7 @@ const OrganizationLayout = ({ children }: PropsWithChildren<{}>) => { const { slug } = useParams() const id = router.asPath.split('/').at(-1) const showOAuthApps = useFlag('oauthApps') + const showAuditLogs = useFlag('auditLogs') const showIntegrationsV2 = useFlag('integrationsV2') return ( @@ -42,6 +43,7 @@ const OrganizationLayout = ({ children }: PropsWithChildren<{}>) => { {showOAuthApps && } + {showAuditLogs && } diff --git a/studio/components/ui/DatePicker/DatePicker.tsx b/studio/components/ui/DatePicker/DatePicker.tsx index aca6f5d504d..ba89eeecfe1 100644 --- a/studio/components/ui/DatePicker/DatePicker.tsx +++ b/studio/components/ui/DatePicker/DatePicker.tsx @@ -17,11 +17,15 @@ import { DatePickerToFrom } from 'components/interfaces/Settings/Logs' export interface DatePickerProps { onChange?: (args: DatePickerToFrom) => void - to?: string - from?: string + to?: string // ISO string + from?: string // ISO string triggerButtonType?: ButtonProps['type'] triggerButtonClassName?: string triggerButtonTitle?: string + minDate?: Date + maxDate?: Date + hideTime?: boolean + hideClear?: boolean renderFooter?: (args: DatePickerToFrom) => React.ReactNode | void } @@ -38,6 +42,10 @@ function _DatePicker({ triggerButtonType = 'default', triggerButtonClassName = '', triggerButtonTitle, + minDate, + maxDate, + hideTime = false, + hideClear = false, renderFooter = () => null, }: DatePickerProps) { const [open, setOpen] = useState(false) @@ -64,6 +72,7 @@ function _DatePicker({ setAppliedEndDate(end) setEndDate(end) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [to, from]) function handleDatePickerChange(dates: [from: Date | null, to: Date | null]) { @@ -81,15 +90,15 @@ function _DatePicker({ const payload = { from: dayjs .utc(startDate) - .second(startTime.ss) - .minute(startTime.mm) - .hour(startTime.HH) + .second(Number(startTime.ss)) + .minute(Number(startTime.mm)) + .hour(Number(startTime.HH)) .toISOString(), to: dayjs .utc(endDate || startDate) - .second(endTime.ss) - .minute(endTime.mm) - .hour(endTime.HH) + .second(Number(endTime.ss)) + .minute(Number(endTime.mm)) + .hour(Number(endTime.HH)) .toISOString(), } if (onChange) onChange(payload) @@ -114,52 +123,56 @@ function _DatePicker({ align="center" side="bottom" header={ - <> -
-
- -
-
+
+
+ +
+
- + > + +
+
+ +
-
- -
-
- + + ) } overlay={ <>
{ handleDatePickerChange(dates) @@ -167,8 +180,8 @@ function _DatePicker({ dateFormat="MMMM d, yyyy h:mm aa" startDate={startDate} endDate={endDate} - selectsRange - inline + minDate={minDate} + maxDate={maxDate} dayClassName={() => 'cursor-pointer'} renderCustomHeader={({ date, @@ -213,9 +226,11 @@ function _DatePicker({ })}
- + {!hideClear && ( + + )}
diff --git a/studio/data/organizations/keys.ts b/studio/data/organizations/keys.ts index a7b2d230164..a1c97b3ac4f 100644 --- a/studio/data/organizations/keys.ts +++ b/studio/data/organizations/keys.ts @@ -7,6 +7,10 @@ export const organizationKeys = { ['organizations', slug, 'free-project-limit-check'] as const, customerProfile: (slug: string | undefined) => ['organizations', slug, 'customer-profile'] as const, + auditLogs: ( + slug: string | undefined, + { date_start, date_end }: { date_start: string | undefined; date_end: string | undefined } + ) => ['organizations', slug, 'audit-logs', { date_start, date_end }] as const, migrateBilling: (slug: string | undefined) => ['organizations', slug, 'migrate-billing'] as const, migrateBillingPreview: (slug: string | undefined) => ['organizations', slug, 'migrate-billing', 'preview'] as const, diff --git a/studio/data/organizations/organization-audit-logs-query.ts b/studio/data/organizations/organization-audit-logs-query.ts new file mode 100644 index 00000000000..8b5663dd6c2 --- /dev/null +++ b/studio/data/organizations/organization-audit-logs-query.ts @@ -0,0 +1,99 @@ +import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'lib/common/fetch' +import { API_URL } from 'lib/constants' +import { useCallback } from 'react' +import { organizationKeys } from './keys' +import { ResponseError } from 'types' +import dayjs from 'dayjs' + +export type OrganizationAuditLog = { + action: { + metadata: { + method?: string + status?: number + }[] + name: string + } + actor: { + id: string + type: 'user' | string + metadata: { + email?: string + }[] + } + target: { + description: string + metadata: { + org_slug?: string + project_ref?: string + } + } + occurred_at: string +} + +export type OrganizationAuditLogsResponse = { + result: OrganizationAuditLog[] + retention_period: number +} + +export type OrganizationAuditLogsVariables = { + slug?: string + iso_timestamp_start?: string + iso_timestamp_end?: string + project_refs?: string // Comma-separated +} + +export async function getOrganizationAuditLogs( + { slug, iso_timestamp_start, iso_timestamp_end, project_refs }: OrganizationAuditLogsVariables, + signal?: AbortSignal +) { + if (!slug) throw new Error('slug is required') + + const response = await get( + `${API_URL}/organizations/${slug}/audit?iso_timestamp_start=${iso_timestamp_start}&iso_timestamp_end=${iso_timestamp_end}`, + { signal } + ) + if (response.error) throw response.error + return response as OrganizationAuditLogsResponse +} + +export type OrganizationAuditLogsData = Awaited> +export type OrganizationAuditLogsError = ResponseError + +export const useOrganizationAuditLogsQuery = ( + vars: OrganizationAuditLogsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => { + const { slug, iso_timestamp_start, iso_timestamp_end } = vars + const date_start = dayjs(iso_timestamp_start).utc().format('YYYY-MM-DD') + const date_end = dayjs(iso_timestamp_end).utc().format('YYYY-MM-DD') + + return useQuery( + organizationKeys.auditLogs(slug, { date_start, date_end }), + ({ signal }) => getOrganizationAuditLogs(vars, signal), + { + enabled: enabled && typeof slug !== 'undefined', + ...options, + } + ) +} + +export const useOrganizationAuditLogsPrefetch = (vars: OrganizationAuditLogsVariables) => { + const { slug, iso_timestamp_start, iso_timestamp_end } = vars + const client = useQueryClient() + + return useCallback(() => { + if (slug) { + const date_start = dayjs(iso_timestamp_start).utc().format('YYYY-MM-DD') + const date_end = dayjs(iso_timestamp_end).utc().format('YYYY-MM-DD') + client.prefetchQuery( + organizationKeys.auditLogs(slug, { date_start, date_end }), + ({ signal }) => getOrganizationAuditLogs(vars, signal) + ) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [client, vars]) +} diff --git a/studio/pages/org/[slug]/apps.tsx b/studio/pages/org/[slug]/apps.tsx index c53ee052c42..961fe12b405 100644 --- a/studio/pages/org/[slug]/apps.tsx +++ b/studio/pages/org/[slug]/apps.tsx @@ -7,20 +7,12 @@ import { NextPageWithLayout } from 'types' const OrgOAuthApps: NextPageWithLayout = () => { const selectedOrganization = useSelectedOrganization() - // const { isLoading: isLoadingPermissions } = usePermissionsQuery() - const { data: permissions } = usePermissionsQuery() + const { isLoading: isLoadingPermissions } = usePermissionsQuery() return ( - <> - {selectedOrganization === undefined && (permissions ?? []).length === 0 ? ( - - ) : ( - - )} - + <>{selectedOrganization === undefined && isLoadingPermissions ? : } ) } OrgOAuthApps.getLayout = (page) => {page} - export default OrgOAuthApps diff --git a/studio/pages/org/[slug]/audit.tsx b/studio/pages/org/[slug]/audit.tsx new file mode 100644 index 00000000000..895526ed470 --- /dev/null +++ b/studio/pages/org/[slug]/audit.tsx @@ -0,0 +1,18 @@ +import { AuditLogs } from 'components/interfaces/Organization' +import { OrganizationLayout } from 'components/layouts' +import Loading from 'components/ui/Loading' +import { usePermissionsQuery } from 'data/permissions/permissions-query' +import { useSelectedOrganization } from 'hooks' +import { NextPageWithLayout } from 'types' + +const OrgAuditLogs: NextPageWithLayout = () => { + const { isLoading: isLoadingPermissions } = usePermissionsQuery() + const selectedOrganization = useSelectedOrganization() + + return ( + <>{selectedOrganization === undefined && isLoadingPermissions ? : } + ) +} + +OrgAuditLogs.getLayout = (page) => {page} +export default OrgAuditLogs diff --git a/studio/pages/org/[slug]/billing.tsx b/studio/pages/org/[slug]/billing.tsx index 17b7d313641..c6cae0c44ad 100644 --- a/studio/pages/org/[slug]/billing.tsx +++ b/studio/pages/org/[slug]/billing.tsx @@ -6,12 +6,12 @@ import { useSelectedOrganization } from 'hooks' import { NextPageWithLayout } from 'types' const OrgBillingSettings: NextPageWithLayout = () => { - const { data: permissions } = usePermissionsQuery() + const { isLoading: isLoadingPermissions } = usePermissionsQuery() const selectedOrganization = useSelectedOrganization() return ( <> - {selectedOrganization === undefined && (permissions ?? []).length === 0 ? ( + {selectedOrganization === undefined && isLoadingPermissions ? ( ) : ( @@ -21,5 +21,4 @@ const OrgBillingSettings: NextPageWithLayout = () => { } OrgBillingSettings.getLayout = (page) => {page} - export default OrgBillingSettings diff --git a/studio/pages/org/[slug]/general.tsx b/studio/pages/org/[slug]/general.tsx index a2144d6383f..245a3e23f05 100644 --- a/studio/pages/org/[slug]/general.tsx +++ b/studio/pages/org/[slug]/general.tsx @@ -6,12 +6,12 @@ import { useSelectedOrganization } from 'hooks' import { NextPageWithLayout } from 'types' const OrgGeneralSettings: NextPageWithLayout = () => { - const { data: permissions } = usePermissionsQuery() + const { isLoading: isLoadingPermissions } = usePermissionsQuery() const selectedOrganization = useSelectedOrganization() return ( <> - {selectedOrganization === undefined && (permissions ?? []).length === 0 ? ( + {selectedOrganization === undefined && isLoadingPermissions ? ( ) : ( @@ -21,5 +21,4 @@ const OrgGeneralSettings: NextPageWithLayout = () => { } OrgGeneralSettings.getLayout = (page) => {page} - export default OrgGeneralSettings diff --git a/studio/pages/org/[slug]/invoices.tsx b/studio/pages/org/[slug]/invoices.tsx index bb21316adbd..418510243c3 100644 --- a/studio/pages/org/[slug]/invoices.tsx +++ b/studio/pages/org/[slug]/invoices.tsx @@ -6,12 +6,12 @@ import { useSelectedOrganization } from 'hooks' import { NextPageWithLayout } from 'types' const OrgInvoices: NextPageWithLayout = () => { - const { data: permissions } = usePermissionsQuery() + const { isLoading: isLoadingPermissions } = usePermissionsQuery() const selectedOrganization = useSelectedOrganization() return ( <> - {selectedOrganization === undefined && (permissions ?? []).length === 0 ? ( + {selectedOrganization === undefined && isLoadingPermissions ? ( ) : ( @@ -21,5 +21,4 @@ const OrgInvoices: NextPageWithLayout = () => { } OrgInvoices.getLayout = (page) => {page} - export default OrgInvoices diff --git a/studio/pages/org/[slug]/team.tsx b/studio/pages/org/[slug]/team.tsx index 579249d3001..d1a75aec2f4 100644 --- a/studio/pages/org/[slug]/team.tsx +++ b/studio/pages/org/[slug]/team.tsx @@ -6,20 +6,15 @@ import { useSelectedOrganization } from 'hooks' import { NextPageWithLayout } from 'types' const OrgTeamSettings: NextPageWithLayout = () => { - const { data: permissions } = usePermissionsQuery() + const { isLoading: isLoadingPermissions } = usePermissionsQuery() const selectedOrganization = useSelectedOrganization() return ( <> - {selectedOrganization === undefined && (permissions ?? []).length === 0 ? ( - - ) : ( - - )} + {selectedOrganization === undefined && isLoadingPermissions ? : } ) } OrgTeamSettings.getLayout = (page) => {page} - export default OrgTeamSettings diff --git a/studio/styles/main.scss b/studio/styles/main.scss index 70970273ab4..344bc70f909 100644 --- a/studio/styles/main.scss +++ b/studio/styles/main.scss @@ -150,6 +150,13 @@ a { } } +.input-xs { + input, + textarea { + @apply text-xs; + } +} + input[type='radio'] { @apply p-0; }