From 9bd7824586d76b4c684e8c97b7d12bf63374ef5f Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Tue, 8 Jul 2025 16:48:13 -0600 Subject: [PATCH 1/6] create security endpoints strucutre --- .../web/src/api/buster_rest/security/index.ts | 0 .../api/buster_rest/security/queryRequests.ts | 83 ++++++ .../src/api/buster_rest/security/requests.ts | 64 +++++ apps/web/src/api/query_keys/index.ts | 4 +- apps/web/src/api/query_keys/security.ts | 24 ++ .../(admin-only)/security/page.tsx | 14 + .../features/security/InviteLinks.stories.tsx | 32 +++ .../features/security/InviteLinks.tsx | 68 +++++ .../security/SecurityCards.stories.tsx | 268 ++++++++++++++++++ .../features/security/SecurityCards.tsx | 39 +++ .../features/sidebars/SidebarSettings.tsx | 19 +- packages/server-shared/package.json | 4 + packages/server-shared/src/security/index.ts | 2 + .../server-shared/src/security/requests.ts | 41 +++ .../server-shared/src/security/responses.ts | 61 ++++ 15 files changed, 720 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/api/buster_rest/security/index.ts create mode 100644 apps/web/src/api/buster_rest/security/queryRequests.ts create mode 100644 apps/web/src/api/buster_rest/security/requests.ts create mode 100644 apps/web/src/api/query_keys/security.ts create mode 100644 apps/web/src/app/app/(settings_layout)/settings/(restricted-width)/(admin-only)/security/page.tsx create mode 100644 apps/web/src/components/features/security/InviteLinks.stories.tsx create mode 100644 apps/web/src/components/features/security/InviteLinks.tsx create mode 100644 apps/web/src/components/features/security/SecurityCards.stories.tsx create mode 100644 apps/web/src/components/features/security/SecurityCards.tsx create mode 100644 packages/server-shared/src/security/index.ts create mode 100644 packages/server-shared/src/security/requests.ts create mode 100644 packages/server-shared/src/security/responses.ts diff --git a/apps/web/src/api/buster_rest/security/index.ts b/apps/web/src/api/buster_rest/security/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/apps/web/src/api/buster_rest/security/queryRequests.ts b/apps/web/src/api/buster_rest/security/queryRequests.ts new file mode 100644 index 000000000..6fee80cc9 --- /dev/null +++ b/apps/web/src/api/buster_rest/security/queryRequests.ts @@ -0,0 +1,83 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { securityQueryKeys } from '@/api/query_keys/security'; +import { + getWorkspaceSettings, + getInviteLink, + getApprovedDomains, + updateWorkspaceSettings, + updateInviteLinks, + refreshInviteLink, + addApprovedDomain, + removeApprovedDomain +} from './requests'; + +export const useGetWorkspaceSettings = () => { + return useQuery({ + ...securityQueryKeys.securityGetWorkspaceSettings, + queryFn: getWorkspaceSettings + }); +}; + +export const useGetInviteLink = () => { + return useQuery({ + ...securityQueryKeys.securityInviteLink, + queryFn: getInviteLink + }); +}; + +export const useGetApprovedDomains = () => { + return useQuery({ + ...securityQueryKeys.securityApprovedDomains, + queryFn: getApprovedDomains + }); +}; + +export const useUpdateWorkspaceSettings = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: updateWorkspaceSettings, + onSuccess: (data) => { + queryClient.setQueryData(securityQueryKeys.securityGetWorkspaceSettings.queryKey, data); + } + }); +}; + +export const useUpdateInviteLinks = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: updateInviteLinks, + onSuccess: (data) => { + queryClient.setQueryData(securityQueryKeys.securityInviteLink.queryKey, data); + } + }); +}; + +export const useRefreshInviteLink = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: refreshInviteLink, + onSuccess: (data) => { + queryClient.setQueryData(securityQueryKeys.securityInviteLink.queryKey, data); + } + }); +}; + +export const useAddApprovedDomain = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: addApprovedDomain, + onSuccess: (data) => { + queryClient.setQueryData(securityQueryKeys.securityApprovedDomains.queryKey, data); + } + }); +}; + +export const useRemoveApprovedDomain = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: removeApprovedDomain, + onSuccess: (data) => { + queryClient.setQueryData(securityQueryKeys.securityApprovedDomains.queryKey, data); + } + }); +}; diff --git a/apps/web/src/api/buster_rest/security/requests.ts b/apps/web/src/api/buster_rest/security/requests.ts new file mode 100644 index 000000000..92f135152 --- /dev/null +++ b/apps/web/src/api/buster_rest/security/requests.ts @@ -0,0 +1,64 @@ +import { mainApiV2 } from '../instances'; +import { + type UpdateInviteLinkRequest, + type AddApprovedDomainRequest, + type RemoveApprovedDomainRequest, + type UpdateWorkspaceSettingsRequest, + type GetApprovedDomainsResponse, + type GetInviteLinkResponse, + type RefreshInviteLinkResponse, + type UpdateInviteLinkResponse, + type AddApprovedDomainsResponse, + type GetWorkspaceSettingsResponse, + type UpdateWorkspaceSettingsResponse +} from '@buster/server-shared/security'; + +export const updateInviteLinks = async (request: UpdateInviteLinkRequest) => { + return await mainApiV2 + .post('/security/invite-links', request) + .then((res) => res.data); +}; + +export const refreshInviteLink = async () => { + return await mainApiV2 + .post('/security/invite-links/refresh') + .then((res) => res.data); +}; + +export const getInviteLink = async () => { + return await mainApiV2 + .get('/security/invite-links') + .then((res) => res.data); +}; + +export const getApprovedDomains = async () => { + return await mainApiV2 + .get('/security/approved-domains') + .then((res) => res.data); +}; + +export const addApprovedDomain = async (request: AddApprovedDomainRequest) => { + return await mainApiV2 + .post('/security/approved-domains', request) + .then((res) => res.data); +}; + +export const removeApprovedDomain = async (request: RemoveApprovedDomainRequest) => { + return await mainApiV2 + .delete('/security/approved-domains', { + data: request + }) + .then((res) => res.data); +}; + +export const getWorkspaceSettings = async () => { + return await mainApiV2 + .get('/security/settings') + .then((res) => res.data); +}; + +export const updateWorkspaceSettings = async (request: UpdateWorkspaceSettingsRequest) => { + return await mainApiV2 + .put('/security/settings', request) + .then((res) => res.data); +}; diff --git a/apps/web/src/api/query_keys/index.ts b/apps/web/src/api/query_keys/index.ts index 6ea017d51..ceab64d1d 100644 --- a/apps/web/src/api/query_keys/index.ts +++ b/apps/web/src/api/query_keys/index.ts @@ -10,6 +10,7 @@ import { permissionGroupQueryKeys } from './permission_groups'; import { searchQueryKeys } from './search'; import { termsQueryKeys } from './terms'; import { userQueryKeys } from './users'; +import { securityQueryKeys } from './security'; export const queryKeys = { ...datasetQueryKeys, @@ -23,5 +24,6 @@ export const queryKeys = { ...datasourceQueryKeys, ...datasetGroupQueryKeys, ...permissionGroupQueryKeys, - ...currencyQueryKeys + ...currencyQueryKeys, + ...securityQueryKeys }; diff --git a/apps/web/src/api/query_keys/security.ts b/apps/web/src/api/query_keys/security.ts new file mode 100644 index 000000000..c7f701c58 --- /dev/null +++ b/apps/web/src/api/query_keys/security.ts @@ -0,0 +1,24 @@ +import type { + GetWorkspaceSettingsResponse, + GetApprovedDomainsResponse, + GetInviteLinkResponse +} from '@buster/server-shared/security'; +import { queryOptions } from '@tanstack/react-query'; + +export const securityGetWorkspaceSettings = queryOptions({ + queryKey: ['security', 'workspace-settings'] +}); + +export const securityApprovedDomains = queryOptions({ + queryKey: ['security', 'approved-domains'] +}); + +export const securityInviteLink = queryOptions({ + queryKey: ['security', 'invite-link'] +}); + +export const securityQueryKeys = { + securityGetWorkspaceSettings, + securityApprovedDomains, + securityInviteLink +}; diff --git a/apps/web/src/app/app/(settings_layout)/settings/(restricted-width)/(admin-only)/security/page.tsx b/apps/web/src/app/app/(settings_layout)/settings/(restricted-width)/(admin-only)/security/page.tsx new file mode 100644 index 000000000..07a1c2526 --- /dev/null +++ b/apps/web/src/app/app/(settings_layout)/settings/(restricted-width)/(admin-only)/security/page.tsx @@ -0,0 +1,14 @@ +import { SettingsPageHeader } from '../../../_components/SettingsPageHeader'; + +export default function Page() { + return ( +
+
+ +
+
+ ); +} diff --git a/apps/web/src/components/features/security/InviteLinks.stories.tsx b/apps/web/src/components/features/security/InviteLinks.stories.tsx new file mode 100644 index 000000000..247f75e66 --- /dev/null +++ b/apps/web/src/components/features/security/InviteLinks.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { InviteLinks } from './InviteLinks'; + +const meta: Meta = { + title: 'Features/InviteLinks', + component: InviteLinks, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'A security feature component that allows administrators to manage invite links for workspace access. Users can enable/disable invite links, generate new links, and copy them to clipboard.' + } + } + }, + tags: ['autodocs'] +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + name: 'Default', + parameters: { + docs: { + description: { + story: + 'The default state of the InviteLinks component showing the toggle switch, link input field, and action buttons.' + } + } + } +}; diff --git a/apps/web/src/components/features/security/InviteLinks.tsx b/apps/web/src/components/features/security/InviteLinks.tsx new file mode 100644 index 000000000..bb617acaa --- /dev/null +++ b/apps/web/src/components/features/security/InviteLinks.tsx @@ -0,0 +1,68 @@ +import React, { useState } from 'react'; +import { SecurityCards } from './SecurityCards'; +import { Switch } from '@/components/ui/switch'; +import { Input } from '@/components/ui/inputs'; +import { Button } from '@/components/ui/buttons'; +import { Text } from '@/components/ui/typography'; +import { cn } from '@/lib/classMerge'; +import { Copy2, Refresh } from '@/components/ui/icons'; +import { useBusterNotifications } from '@/context/BusterNotifications'; +import { AppTooltip } from '@/components/ui/tooltip'; + +export const InviteLinks = () => { + const [enabled, setEnabled] = useState(false); + const [link, setLink] = useState(''); + const { openInfoMessage } = useBusterNotifications(); + + const onClickCopy = () => { + navigator.clipboard.writeText(link); + openInfoMessage('Invite link copied to clipboard'); + }; + + const onClickRefresh = () => { + setLink(Math.random().toString(36).substring(2, 15)); + openInfoMessage('Invite link refreshed'); + }; + + return ( + + Enable invite links + + , + enabled && ( +
+
+ +
+ +
+
+ +
+ ) + ].filter(Boolean) + } + ]} + /> + ); +}; diff --git a/apps/web/src/components/features/security/SecurityCards.stories.tsx b/apps/web/src/components/features/security/SecurityCards.stories.tsx new file mode 100644 index 000000000..998d3ba86 --- /dev/null +++ b/apps/web/src/components/features/security/SecurityCards.stories.tsx @@ -0,0 +1,268 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { SecurityCards } from './SecurityCards'; +import { Button } from '@/components/ui/buttons'; +import { Pill } from '@/components/ui/pills/Pill'; +import { Text } from '@/components/ui/typography'; + +const meta: Meta = { + title: 'Features/SecurityCards', + component: SecurityCards, + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'A security cards component that displays security-related information in structured card sections.' + } + } + }, + tags: ['autodocs'] +}; + +export default meta; +type Story = StoryObj; + +// Mock data for different use cases +const basicSections = [ +
+
+ Two-Factor Authentication + + Add an extra layer of security to your account + +
+ +
, +
+
+ API Keys + + Manage your API access tokens + +
+ +
, +
+
+ Invite Links + + Share your account with others + +
+ +
+]; + +const detailedSections = [ +
+
+
+ Password Policy + Active +
+ + Minimum 8 characters with uppercase, lowercase, numbers, and symbols + +
+ +
, +
+
+
+ Session Timeout + 30 minutes +
+ + Automatic logout after period of inactivity + +
+ +
, +
+
+ IP Restrictions + + Limit access to specific IP addresses + +
+ +
+]; + +const accessLogSections = [ +
+
+ Recent Login Activity + + Last 7 days + +
+
+
+
+ Chrome on macOS + + 192.168.1.100 • 2 hours ago + +
+ Current +
+
+
+ Safari on iPhone + + 10.0.1.50 • 1 day ago + +
+ +
+
+
+]; + +export const Default: Story = { + args: { + title: 'Account Security', + description: 'Manage your security settings and authentication methods', + cards: [ + { + sections: basicSections + } + ] + } +}; + +export const MultipleCards: Story = { + args: { + title: 'Security Overview', + description: 'Complete security configuration for your organization', + cards: [ + { + sections: detailedSections + }, + { + sections: accessLogSections + } + ] + } +}; + +export const SingleSection: Story = { + args: { + title: 'Quick Setup', + description: 'Essential security setting that needs immediate attention', + cards: [ + { + sections: [ +
+
+ Enable Two-Factor Authentication + + Protect your account with an additional verification step + +
+ +
+ ] + } + ] + } +}; + +export const EmptyState: Story = { + args: { + title: 'Security Settings', + description: 'No security configurations available', + cards: [] + } +}; + +export const ComplexContent: Story = { + args: { + title: 'Advanced Security Configuration', + description: 'Comprehensive security settings with detailed controls', + cards: [ + { + sections: [ +
+
+
+ Organization Policies + + Configure security policies for all team members + +
+ +
+
+
+ + Password Requirements + +
    +
  • +
    + + Minimum 12 characters + +
  • +
  • +
    + + Special characters required + +
  • +
+
+
+ + Access Controls + +
    +
  • +
    + + Role-based permissions + +
  • +
  • +
    + + IP whitelisting enabled + +
  • +
+
+
+
, +
+
+ Audit Logging + + Track all security-related events and user actions + +
+
+ Enabled + +
+
+ ] + } + ] + } +}; diff --git a/apps/web/src/components/features/security/SecurityCards.tsx b/apps/web/src/components/features/security/SecurityCards.tsx new file mode 100644 index 000000000..49676a7b5 --- /dev/null +++ b/apps/web/src/components/features/security/SecurityCards.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Title, Paragraph } from '@/components/ui/typography'; +import { cn } from '@/lib/classMerge'; + +interface SecurityCardsProps { + title: string; + description: string; + cards: { + sections: React.ReactNode[]; + }[]; +} + +export const SecurityCards: React.FC = ({ title, description, cards }) => { + return ( +
+
+ + {title} + + {description} +
+ {cards.map((card, index) => ( + + ))} +
+ ); +}; + +const SecurityCard = ({ sections }: { sections: React.ReactNode[] }) => { + return ( +
+ {sections.map((section, index) => ( +
+ {section} +
+ ))} +
+ ); +}; diff --git a/apps/web/src/components/features/sidebars/SidebarSettings.tsx b/apps/web/src/components/features/sidebars/SidebarSettings.tsx index a4a650d6d..4e6392b28 100644 --- a/apps/web/src/components/features/sidebars/SidebarSettings.tsx +++ b/apps/web/src/components/features/sidebars/SidebarSettings.tsx @@ -52,6 +52,11 @@ const permissionAndSecurityItems = (currentParentRoute: BusterRoutes): ISidebarG id: 'permission-and-security', icon: , items: [ + { + label: 'Security', + route: createBusterRoute({ route: BusterRoutes.SETTINGS_SECURITY }), + id: createBusterRoute({ route: BusterRoutes.SETTINGS_SECURITY }) + }, { label: 'Users', route: createBusterRoute({ route: BusterRoutes.SETTINGS_USERS }), @@ -89,8 +94,18 @@ export const SidebarSettings: React.FC = React.memo(() => { return ( , [])} - footer={useMemo(() => , [])} + header={useMemo( + () => ( + + ), + [] + )} + footer={useMemo( + () => ( + + ), + [] + )} /> ); }); diff --git a/packages/server-shared/package.json b/packages/server-shared/package.json index 60adf11c6..f6afa5714 100644 --- a/packages/server-shared/package.json +++ b/packages/server-shared/package.json @@ -56,6 +56,10 @@ "./teams": { "types": "./dist/teams/index.d.ts", "default": "./dist/teams/index.js" + }, + "./security": { + "types": "./dist/security/index.d.ts", + "default": "./dist/security/index.js" } }, "dependencies": { diff --git a/packages/server-shared/src/security/index.ts b/packages/server-shared/src/security/index.ts new file mode 100644 index 000000000..0b4b018e3 --- /dev/null +++ b/packages/server-shared/src/security/index.ts @@ -0,0 +1,2 @@ +export * from './requests'; +export * from './responses'; diff --git a/packages/server-shared/src/security/requests.ts b/packages/server-shared/src/security/requests.ts new file mode 100644 index 000000000..ed6691999 --- /dev/null +++ b/packages/server-shared/src/security/requests.ts @@ -0,0 +1,41 @@ +import { z } from 'zod/v4'; +import { OrganizationRoleSchema } from '../organization'; + +export const UpdateInviteLinkRequestSchema = z.object({ + enabled: z.boolean(), + refresh_link: z.boolean().optional(), +}); + +export type UpdateInviteLinkRequest = z.infer; + +export const AddApprovedDomainRequestSchema = z.object({ + domains: z.array(z.string()), +}); + +export type AddApprovedDomainRequest = z.infer; + +export const RemoveApprovedDomainRequestSchema = z.object({ + domains: z.array(z.string()), +}); + +export type RemoveApprovedDomainRequest = z.infer; + +export const UpdateWorkspaceSettingsRequestSchema = z.object({ + enabled: z.boolean().optional(), + default_role: OrganizationRoleSchema.optional(), + // this can either be a uuid or "all" + default_datasets_ids: z + .array( + z.union([ + z + .string() + .regex( + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/ + ), + z.literal('all'), + ]) + ) + .optional(), +}); + +export type UpdateWorkspaceSettingsRequest = z.infer; diff --git a/packages/server-shared/src/security/responses.ts b/packages/server-shared/src/security/responses.ts new file mode 100644 index 000000000..1ef8af9d8 --- /dev/null +++ b/packages/server-shared/src/security/responses.ts @@ -0,0 +1,61 @@ +import { z } from "zod/v4"; +import { OrganizationRoleSchema } from "../organization"; + +export const GetInviteLinkResponseSchema = z.object({ + link: z.string(), + enabled: z.boolean(), +}); +export const UpdateInviteLinkResponseSchema = GetInviteLinkResponseSchema; +export const RefreshInviteLinkResponseSchema = GetInviteLinkResponseSchema; + +export const GetApprovedDomainsResponseSchema = z.array( + z.object({ + domain: z.string(), + created_at: z.string(), + }) +); +export const AddApprovedDomainsResponseSchema = + GetApprovedDomainsResponseSchema; +export const UpdateApprovedDomainsResponseSchema = + GetApprovedDomainsResponseSchema; +export const RemoveApprovedDomainsResponseSchema = + GetApprovedDomainsResponseSchema; + +export const GetWorkspaceSettingsResponseSchema = z.object({ + enabled: z.boolean(), + default_role: OrganizationRoleSchema, + default_datasets: z.array( + z.object({ + id: z.string(), + name: z.string(), + }) + ), +}); +export const UpdateWorkspaceSettingsResponseSchema = + GetWorkspaceSettingsResponseSchema; + +export type RefreshInviteLinkResponse = z.infer< + typeof RefreshInviteLinkResponseSchema +>; +export type UpdateInviteLinkResponse = z.infer< + typeof UpdateInviteLinkResponseSchema +>; +export type GetInviteLinkResponse = z.infer; +export type GetApprovedDomainsResponse = z.infer< + typeof GetApprovedDomainsResponseSchema +>; +export type AddApprovedDomainsResponse = z.infer< + typeof AddApprovedDomainsResponseSchema +>; +export type UpdateApprovedDomainsResponse = z.infer< + typeof UpdateApprovedDomainsResponseSchema +>; +export type RemoveApprovedDomainsResponse = z.infer< + typeof RemoveApprovedDomainsResponseSchema +>; +export type GetWorkspaceSettingsResponse = z.infer< + typeof GetWorkspaceSettingsResponseSchema +>; +export type UpdateWorkspaceSettingsResponse = z.infer< + typeof UpdateWorkspaceSettingsResponseSchema +>; From 46c3fccb9ba68d8a501e86ec2cdb4dd869b57c34 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Tue, 8 Jul 2025 17:20:31 -0600 Subject: [PATCH 2/6] invite link updates --- .../api/buster_rest/security/queryRequests.ts | 30 +++ .../(admin-only)/security/page.tsx | 7 + .../security/ApprovedEmailDomains.tsx | 173 ++++++++++++++++++ .../features/security/InviteLinks.tsx | 29 ++- .../FileContainerHeader/interfaces.ts | 2 +- .../server-shared/src/security/requests.ts | 24 ++- 6 files changed, 247 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/components/features/security/ApprovedEmailDomains.tsx diff --git a/apps/web/src/api/buster_rest/security/queryRequests.ts b/apps/web/src/api/buster_rest/security/queryRequests.ts index 6fee80cc9..1b93a6337 100644 --- a/apps/web/src/api/buster_rest/security/queryRequests.ts +++ b/apps/web/src/api/buster_rest/security/queryRequests.ts @@ -10,6 +10,7 @@ import { addApprovedDomain, removeApprovedDomain } from './requests'; +import type { GetApprovedDomainsResponse } from '@buster/server-shared/security'; export const useGetWorkspaceSettings = () => { return useQuery({ @@ -46,6 +47,15 @@ export const useUpdateInviteLinks = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: updateInviteLinks, + onMutate: (variables) => { + queryClient.setQueryData(securityQueryKeys.securityInviteLink.queryKey, (prev) => { + if (!prev) return prev; + return { + ...prev, + ...variables + }; + }); + }, onSuccess: (data) => { queryClient.setQueryData(securityQueryKeys.securityInviteLink.queryKey, data); } @@ -66,6 +76,18 @@ export const useAddApprovedDomain = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: addApprovedDomain, + onMutate: (variables) => { + queryClient.setQueryData(securityQueryKeys.securityApprovedDomains.queryKey, (prev) => { + if (!prev) return prev; + return { + ...prev, + ...variables.domains.map((domain) => ({ + domain, + created_at: new Date().toISOString() + })) + } satisfies GetApprovedDomainsResponse; + }); + }, onSuccess: (data) => { queryClient.setQueryData(securityQueryKeys.securityApprovedDomains.queryKey, data); } @@ -76,6 +98,14 @@ export const useRemoveApprovedDomain = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: removeApprovedDomain, + onMutate: (variables) => { + queryClient.setQueryData(securityQueryKeys.securityApprovedDomains.queryKey, (prev) => { + if (!prev) return prev; + return prev.filter( + (domain) => !variables.domains.includes(domain.domain) + ) satisfies GetApprovedDomainsResponse; + }); + }, onSuccess: (data) => { queryClient.setQueryData(securityQueryKeys.securityApprovedDomains.queryKey, data); } diff --git a/apps/web/src/app/app/(settings_layout)/settings/(restricted-width)/(admin-only)/security/page.tsx b/apps/web/src/app/app/(settings_layout)/settings/(restricted-width)/(admin-only)/security/page.tsx index 07a1c2526..034d04800 100644 --- a/apps/web/src/app/app/(settings_layout)/settings/(restricted-width)/(admin-only)/security/page.tsx +++ b/apps/web/src/app/app/(settings_layout)/settings/(restricted-width)/(admin-only)/security/page.tsx @@ -1,4 +1,6 @@ +import { InviteLinks } from '@/components/features/security/InviteLinks'; import { SettingsPageHeader } from '../../../_components/SettingsPageHeader'; +import { ApprovedEmailDomains } from '@/components/features/security/ApprovedEmailDomains'; export default function Page() { return ( @@ -8,6 +10,11 @@ export default function Page() { title="Security" description="Manage security and general permission settings" /> + +
+ + +
); diff --git a/apps/web/src/components/features/security/ApprovedEmailDomains.tsx b/apps/web/src/components/features/security/ApprovedEmailDomains.tsx new file mode 100644 index 000000000..cdb2671c5 --- /dev/null +++ b/apps/web/src/components/features/security/ApprovedEmailDomains.tsx @@ -0,0 +1,173 @@ +'use client'; + +import React, { useMemo, useState } from 'react'; +import { SecurityCards } from './SecurityCards'; +import { Input } from '@/components/ui/inputs'; +import { Button } from '@/components/ui/buttons'; +import { Text } from '@/components/ui/typography'; +import { Plus, Dots } from '@/components/ui/icons'; +import { useBusterNotifications } from '@/context/BusterNotifications'; +import { Dropdown } from '@/components/ui/dropdown'; +import { + useGetApprovedDomains, + useAddApprovedDomain, + useRemoveApprovedDomain +} from '@/api/buster_rest/security/queryRequests'; +import pluralize from 'pluralize'; +import { useMemoizedFn } from '@/hooks'; + +interface ApprovedDomain { + domain: string; + created_at: string; +} + +export const ApprovedEmailDomains = React.memo(() => { + const { data: approvedDomains = [] } = useGetApprovedDomains(); + const { mutateAsync: removeDomain } = useRemoveApprovedDomain(); + + const [isAddingDomain, setIsAddingDomain] = useState(false); + + const { openInfoMessage, openErrorMessage } = useBusterNotifications(); + + const domainCount = approvedDomains.length; + const countText = pluralize('approved email domain', domainCount, true); + + const handleRemoveDomain = useMemoizedFn(async (domain: string) => { + try { + await removeDomain({ domains: [domain] }); + openInfoMessage('Domain removed successfully'); + } catch (error) { + openErrorMessage('Failed to remove domain'); + } + }); + + const sections = useMemo( + () => + [ + // Header section with count and add button +
+ {countText} + +
, + + // Add domain input section (when adding) + isAddingDomain && , + + // Domain list sections + ...approvedDomains.map((domainData) => ( + + )) + ].filter(Boolean), + [countText, isAddingDomain, approvedDomains, handleRemoveDomain] + ); + + return ( + + ); +}); + +ApprovedEmailDomains.displayName = 'ApprovedEmailDomains'; + +const AddDomainInput = React.memo( + ({ setIsAddingDomain }: { setIsAddingDomain: (isAddingDomain: boolean) => void }) => { + const { mutateAsync: addDomain } = useAddApprovedDomain(); + const { openInfoMessage, openErrorMessage } = useBusterNotifications(); + + const [newDomain, setNewDomain] = useState(''); + + const handleAddDomain = useMemoizedFn(async () => { + if (!newDomain.trim()) return; + + try { + await addDomain({ domains: [newDomain.trim()] }); + setNewDomain(''); + setIsAddingDomain(false); + openInfoMessage('Domain added successfully'); + } catch (error) { + openErrorMessage('Failed to add domain'); + } + }); + + return ( +
+ setNewDomain(e.target.value)} + placeholder="Enter domain (e.g., company.com)" + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleAddDomain(); + } else if (e.key === 'Escape') { + setIsAddingDomain(false); + setNewDomain(''); + } + }} + autoFocus + /> + + +
+ ); + } +); + +AddDomainInput.displayName = 'AddDomainInput'; + +interface DomainListItemProps { + domainData: ApprovedDomain; + onRemove: (domain: string) => Promise; +} + +const DomainListItem = React.memo(({ domainData, onRemove }) => { + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }); + }; + + return ( +
+
+ {domainData.domain} + + Added {formatDate(domainData.created_at)} + +
+ onRemove(domainData.domain) + } + ]}> +
+ ); +}); + +DomainListItem.displayName = 'DomainListItem'; diff --git a/apps/web/src/components/features/security/InviteLinks.tsx b/apps/web/src/components/features/security/InviteLinks.tsx index bb617acaa..7b3db6d42 100644 --- a/apps/web/src/components/features/security/InviteLinks.tsx +++ b/apps/web/src/components/features/security/InviteLinks.tsx @@ -1,17 +1,22 @@ -import React, { useState } from 'react'; +'use client'; + +import React from 'react'; import { SecurityCards } from './SecurityCards'; import { Switch } from '@/components/ui/switch'; import { Input } from '@/components/ui/inputs'; import { Button } from '@/components/ui/buttons'; import { Text } from '@/components/ui/typography'; -import { cn } from '@/lib/classMerge'; import { Copy2, Refresh } from '@/components/ui/icons'; import { useBusterNotifications } from '@/context/BusterNotifications'; import { AppTooltip } from '@/components/ui/tooltip'; +import { useGetInviteLink, useUpdateInviteLinks } from '@/api/buster_rest/security/queryRequests'; + +export const InviteLinks = React.memo(() => { + const { data: inviteLink } = useGetInviteLink(); + const { mutateAsync: updateInviteLink } = useUpdateInviteLinks(); + const enabled = inviteLink?.enabled ?? false; + const link = inviteLink?.link ?? ''; -export const InviteLinks = () => { - const [enabled, setEnabled] = useState(false); - const [link, setLink] = useState(''); const { openInfoMessage } = useBusterNotifications(); const onClickCopy = () => { @@ -19,11 +24,15 @@ export const InviteLinks = () => { openInfoMessage('Invite link copied to clipboard'); }; - const onClickRefresh = () => { - setLink(Math.random().toString(36).substring(2, 15)); + const onClickRefresh = async () => { + await updateInviteLink({ refresh_link: true }); openInfoMessage('Invite link refreshed'); }; + const onToggleEnabled = (enabled: boolean) => { + updateInviteLink({ enabled }); + }; + return ( { sections: [
Enable invite links - +
, enabled && (
@@ -65,4 +74,6 @@ export const InviteLinks = () => { ]} /> ); -}; +}); + +InviteLinks.displayName = 'InviteLinks'; diff --git a/apps/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/interfaces.ts b/apps/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/interfaces.ts index d0edcd8d2..f6cb979b4 100644 --- a/apps/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/interfaces.ts +++ b/apps/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/interfaces.ts @@ -5,7 +5,7 @@ export type FileContainerSegmentProps = { selectedFileId: string | undefined; chatId: string | undefined; overrideOldVersionMessage?: boolean; - isVersionHistoryMode: boolean; + isVersionHistoryMode?: boolean; }; export type FileContainerButtonsProps = { diff --git a/packages/server-shared/src/security/requests.ts b/packages/server-shared/src/security/requests.ts index ed6691999..1d6e8cd88 100644 --- a/packages/server-shared/src/security/requests.ts +++ b/packages/server-shared/src/security/requests.ts @@ -1,24 +1,30 @@ -import { z } from 'zod/v4'; -import { OrganizationRoleSchema } from '../organization'; +import { z } from "zod/v4"; +import { OrganizationRoleSchema } from "../organization"; export const UpdateInviteLinkRequestSchema = z.object({ - enabled: z.boolean(), + enabled: z.boolean().optional(), refresh_link: z.boolean().optional(), }); -export type UpdateInviteLinkRequest = z.infer; +export type UpdateInviteLinkRequest = z.infer< + typeof UpdateInviteLinkRequestSchema +>; export const AddApprovedDomainRequestSchema = z.object({ domains: z.array(z.string()), }); -export type AddApprovedDomainRequest = z.infer; +export type AddApprovedDomainRequest = z.infer< + typeof AddApprovedDomainRequestSchema +>; export const RemoveApprovedDomainRequestSchema = z.object({ domains: z.array(z.string()), }); -export type RemoveApprovedDomainRequest = z.infer; +export type RemoveApprovedDomainRequest = z.infer< + typeof RemoveApprovedDomainRequestSchema +>; export const UpdateWorkspaceSettingsRequestSchema = z.object({ enabled: z.boolean().optional(), @@ -32,10 +38,12 @@ export const UpdateWorkspaceSettingsRequestSchema = z.object({ .regex( /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/ ), - z.literal('all'), + z.literal("all"), ]) ) .optional(), }); -export type UpdateWorkspaceSettingsRequest = z.infer; +export type UpdateWorkspaceSettingsRequest = z.infer< + typeof UpdateWorkspaceSettingsRequestSchema +>; From 59e6aacd9d32673eb548da202e133ade8e1762f6 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Wed, 9 Jul 2025 09:42:16 -0600 Subject: [PATCH 3/6] part 1 of making workspace toggles --- .../api/buster_rest/security/queryRequests.ts | 15 +- .../settings/(permissions)/users/config.ts | 9 -- .../(admin-only)/security/page.tsx | 2 + .../security/ApprovedEmailDomains.tsx | 66 ++++----- .../security/WorkspaceRestrictions.tsx | 131 ++++++++++++++++++ apps/web/src/lib/organization/translations.ts | 10 ++ .../server-shared/src/organization/index.ts | 1 + .../src/organization/role.enums.ts | 11 ++ .../server-shared/src/security/requests.ts | 2 +- .../server-shared/src/security/responses.ts | 2 +- 10 files changed, 202 insertions(+), 47 deletions(-) create mode 100644 apps/web/src/components/features/security/WorkspaceRestrictions.tsx create mode 100644 apps/web/src/lib/organization/translations.ts create mode 100644 packages/server-shared/src/organization/role.enums.ts diff --git a/apps/web/src/api/buster_rest/security/queryRequests.ts b/apps/web/src/api/buster_rest/security/queryRequests.ts index 1b93a6337..34d681a7c 100644 --- a/apps/web/src/api/buster_rest/security/queryRequests.ts +++ b/apps/web/src/api/buster_rest/security/queryRequests.ts @@ -10,12 +10,20 @@ import { addApprovedDomain, removeApprovedDomain } from './requests'; -import type { GetApprovedDomainsResponse } from '@buster/server-shared/security'; +import type { + GetApprovedDomainsResponse, + GetWorkspaceSettingsResponse +} from '@buster/server-shared/security'; export const useGetWorkspaceSettings = () => { return useQuery({ ...securityQueryKeys.securityGetWorkspaceSettings, - queryFn: getWorkspaceSettings + queryFn: getWorkspaceSettings, + initialData: { + restrict_new_user_invitations: false, + default_role: 'viewer', + default_datasets: [] + } satisfies GetWorkspaceSettingsResponse }); }; @@ -29,7 +37,8 @@ export const useGetInviteLink = () => { export const useGetApprovedDomains = () => { return useQuery({ ...securityQueryKeys.securityApprovedDomains, - queryFn: getApprovedDomains + queryFn: getApprovedDomains, + initialData: [] }); }; diff --git a/apps/web/src/app/app/(settings_layout)/settings/(permissions)/users/config.ts b/apps/web/src/app/app/(settings_layout)/settings/(permissions)/users/config.ts index 021e6f0a4..98a64fe6c 100644 --- a/apps/web/src/app/app/(settings_layout)/settings/(permissions)/users/config.ts +++ b/apps/web/src/app/app/(settings_layout)/settings/(permissions)/users/config.ts @@ -4,12 +4,3 @@ export const OrganizationUserStatusText: Record = { - data_admin: 'Data Admin', - workspace_admin: 'Workspace Admin', - querier: 'Querier', - restricted_querier: 'Restricted Querier', - viewer: 'Viewer', - none: 'None' -}; diff --git a/apps/web/src/app/app/(settings_layout)/settings/(restricted-width)/(admin-only)/security/page.tsx b/apps/web/src/app/app/(settings_layout)/settings/(restricted-width)/(admin-only)/security/page.tsx index 034d04800..c88316992 100644 --- a/apps/web/src/app/app/(settings_layout)/settings/(restricted-width)/(admin-only)/security/page.tsx +++ b/apps/web/src/app/app/(settings_layout)/settings/(restricted-width)/(admin-only)/security/page.tsx @@ -1,6 +1,7 @@ import { InviteLinks } from '@/components/features/security/InviteLinks'; import { SettingsPageHeader } from '../../../_components/SettingsPageHeader'; import { ApprovedEmailDomains } from '@/components/features/security/ApprovedEmailDomains'; +import { WorkspaceRestrictions } from '@/components/features/security/WorkspaceRestrictions'; export default function Page() { return ( @@ -14,6 +15,7 @@ export default function Page() {
+
diff --git a/apps/web/src/components/features/security/ApprovedEmailDomains.tsx b/apps/web/src/components/features/security/ApprovedEmailDomains.tsx index cdb2671c5..2911c95d4 100644 --- a/apps/web/src/components/features/security/ApprovedEmailDomains.tsx +++ b/apps/web/src/components/features/security/ApprovedEmailDomains.tsx @@ -5,7 +5,7 @@ import { SecurityCards } from './SecurityCards'; import { Input } from '@/components/ui/inputs'; import { Button } from '@/components/ui/buttons'; import { Text } from '@/components/ui/typography'; -import { Plus, Dots } from '@/components/ui/icons'; +import { Plus, Dots, Trash } from '@/components/ui/icons'; import { useBusterNotifications } from '@/context/BusterNotifications'; import { Dropdown } from '@/components/ui/dropdown'; import { @@ -15,6 +15,7 @@ import { } from '@/api/buster_rest/security/queryRequests'; import pluralize from 'pluralize'; import { useMemoizedFn } from '@/hooks'; +import { formatDate } from '@/lib/date'; interface ApprovedDomain { domain: string; @@ -24,12 +25,12 @@ interface ApprovedDomain { export const ApprovedEmailDomains = React.memo(() => { const { data: approvedDomains = [] } = useGetApprovedDomains(); const { mutateAsync: removeDomain } = useRemoveApprovedDomain(); + const domainCount = approvedDomains.length; - const [isAddingDomain, setIsAddingDomain] = useState(false); + const [isEnabledAddDomain, setIsEnabledAddDomain] = useState(false); const { openInfoMessage, openErrorMessage } = useBusterNotifications(); - const domainCount = approvedDomains.length; const countText = pluralize('approved email domain', domainCount, true); const handleRemoveDomain = useMemoizedFn(async (domain: string) => { @@ -47,13 +48,15 @@ export const ApprovedEmailDomains = React.memo(() => { // Header section with count and add button
{countText} -
, // Add domain input section (when adding) - isAddingDomain && , + isEnabledAddDomain && ( + + ), // Domain list sections ...approvedDomains.map((domainData) => ( @@ -64,7 +67,7 @@ export const ApprovedEmailDomains = React.memo(() => { /> )) ].filter(Boolean), - [countText, isAddingDomain, approvedDomains, handleRemoveDomain] + [countText, isEnabledAddDomain, approvedDomains, handleRemoveDomain] ); return ( @@ -79,7 +82,7 @@ export const ApprovedEmailDomains = React.memo(() => { ApprovedEmailDomains.displayName = 'ApprovedEmailDomains'; const AddDomainInput = React.memo( - ({ setIsAddingDomain }: { setIsAddingDomain: (isAddingDomain: boolean) => void }) => { + ({ setIsEnabledAddDomain }: { setIsEnabledAddDomain: (isEnabledAddDomain: boolean) => void }) => { const { mutateAsync: addDomain } = useAddApprovedDomain(); const { openInfoMessage, openErrorMessage } = useBusterNotifications(); @@ -91,7 +94,7 @@ const AddDomainInput = React.memo( try { await addDomain({ domains: [newDomain.trim()] }); setNewDomain(''); - setIsAddingDomain(false); + setIsEnabledAddDomain(false); openInfoMessage('Domain added successfully'); } catch (error) { openErrorMessage('Failed to add domain'); @@ -109,7 +112,7 @@ const AddDomainInput = React.memo( if (e.key === 'Enter') { handleAddDomain(); } else if (e.key === 'Escape') { - setIsAddingDomain(false); + setIsEnabledAddDomain(false); setNewDomain(''); } }} @@ -122,7 +125,7 @@ const AddDomainInput = React.memo( size="tall" variant={'ghost'} onClick={() => { - setIsAddingDomain(false); + setIsEnabledAddDomain(false); setNewDomain(''); }}> Cancel @@ -134,37 +137,34 @@ const AddDomainInput = React.memo( AddDomainInput.displayName = 'AddDomainInput'; -interface DomainListItemProps { +const DomainListItem = React.memo<{ domainData: ApprovedDomain; onRemove: (domain: string) => Promise; -} - -const DomainListItem = React.memo(({ domainData, onRemove }) => { - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric' - }); - }; +}>(({ domainData, onRemove }) => { + const items = useMemo( + () => [ + { + label: 'Remove domain', + value: 'remove', + icon: , + onClick: () => onRemove(domainData.domain) + } + ], + [domainData.domain, onRemove] + ); return (
-
- {domainData.domain} +
+ + {domainData.domain} + - Added {formatDate(domainData.created_at)} + Added {formatDate({ date: domainData.created_at, format: 'LL' })}
- onRemove(domainData.domain) - } - ]}> -
); diff --git a/apps/web/src/components/features/security/WorkspaceRestrictions.tsx b/apps/web/src/components/features/security/WorkspaceRestrictions.tsx new file mode 100644 index 000000000..29b48279f --- /dev/null +++ b/apps/web/src/components/features/security/WorkspaceRestrictions.tsx @@ -0,0 +1,131 @@ +'use client'; + +import React, { useMemo, type ReactNode } from 'react'; +import { SecurityCards } from './SecurityCards'; +import { Text } from '@/components/ui/typography'; +import { Button } from '@/components/ui/buttons'; +import { Switch } from '@/components/ui/switch'; +import { + useGetWorkspaceSettings, + useUpdateWorkspaceSettings +} from '@/api/buster_rest/security/queryRequests'; +import type { + GetWorkspaceSettingsResponse, + UpdateWorkspaceSettingsRequest +} from '@buster/server-shared/security'; +import { Select, type SelectItem } from '@/components/ui/select'; +import { type OrganizationRole } from '@buster/server-shared/organization'; +import { OrganizationRoleEnum } from '@buster/server-shared/organization'; +import { OrganizationUserRoleText } from '@/lib/organization/translations'; + +export const WorkspaceRestrictions = React.memo(() => { + const { data: workspaceSettings } = useGetWorkspaceSettings(); + const { mutateAsync: updateWorkspaceSettings } = useUpdateWorkspaceSettings(); + + const sections: ReactNode[] = useMemo( + () => [ + , + , + + ], + [workspaceSettings?.restrict_new_user_invitations] + ); + + return ( + + ); +}); + +WorkspaceRestrictions.displayName = 'WorkspaceRestrictions'; + +const EnableRestrictions = ({ + restrict_new_user_invitations = false, + updateWorkspaceSettings +}: Pick & { + updateWorkspaceSettings: (request: UpdateWorkspaceSettingsRequest) => Promise; +}) => { + return ( +
+
+ Restrict new user invitations + + {`Only allow admins to invite new members to workspace`} + +
+ { + updateWorkspaceSettings({ restrict_new_user_invitations: v }); + }} + /> +
+ ); +}; + +const DefaultRole = ({ + default_role = 'viewer', + updateWorkspaceSettings +}: Pick & { + updateWorkspaceSettings: (request: UpdateWorkspaceSettingsRequest) => Promise; +}) => { + const items: SelectItem[] = useMemo(() => { + return Object.values(OrganizationRoleEnum).map((role) => ({ + label: OrganizationUserRoleText[role as OrganizationRole], + value: role as OrganizationRole + })); + }, []); + + return ( +
+
+ Restrict new user invitations + + {`Only allow admins to invite new members to workspace`} + +
+ & { updateWorkspaceSettings: (request: UpdateWorkspaceSettingsRequest) => Promise; }) => { + const { data: datasets, isFetched: isDatasetsFetched } = useGetDatasets(); + + const items: SelectItem[] = useMemo(() => { + const baseItems = + datasets?.map((dataset) => ({ + label: dataset.name, + value: dataset.id + })) || []; + + return [{ label: 'All datasets', value: 'all' }, ...baseItems]; + }, [datasets]); + + const selectedItems = useMemo(() => { + return default_datasets.map((dataset) => dataset.id); + }, [default_datasets]); + return (
- Restrict new user invitations + Default Datasets - {`Only allow admins to invite new members to workspace`} + {`Select which datasets people can access by default`}
- <> + { + updateWorkspaceSettings({ default_datasets_ids: v }); + }} + />
); }; diff --git a/apps/web/src/components/ui/select/SelectMultiple.stories.tsx b/apps/web/src/components/ui/select/SelectMultiple.stories.tsx index 98510a3c3..71c1f3a2b 100644 --- a/apps/web/src/components/ui/select/SelectMultiple.stories.tsx +++ b/apps/web/src/components/ui/select/SelectMultiple.stories.tsx @@ -43,6 +43,7 @@ const SelectMultipleWithHooks = () => { onChange={handleSelect} placeholder="Select multiple options..." value={value} + loading={true} />
); diff --git a/apps/web/src/components/ui/select/SelectMultiple.tsx b/apps/web/src/components/ui/select/SelectMultiple.tsx index ca84a53f2..0520f4a19 100644 --- a/apps/web/src/components/ui/select/SelectMultiple.tsx +++ b/apps/web/src/components/ui/select/SelectMultiple.tsx @@ -4,10 +4,11 @@ import type { VariantProps } from 'class-variance-authority'; import React, { useMemo } from 'react'; import { useMemoizedFn } from '@/hooks'; import { cn } from '@/lib/classMerge'; -import { Dropdown, type DropdownItem } from '../dropdown/Dropdown'; +import { Dropdown, type DropdownItem, type DropdownProps } from '../dropdown/Dropdown'; import { InputTag } from '../inputs/InputTag'; import type { SelectItem } from './Select'; import { selectVariants } from './SelectBase'; +import { CircleSpinnerLoader } from '../loaders'; interface SelectMultipleProps extends VariantProps { items: SelectItem[]; @@ -17,6 +18,9 @@ interface SelectMultipleProps extends VariantProps { value: string[]; disabled?: boolean; useSearch?: boolean; + loading?: boolean; + align?: DropdownProps['align']; + side?: DropdownProps['side']; } export const SelectMultiple: React.FC = React.memo( @@ -29,6 +33,9 @@ export const SelectMultiple: React.FC = React.memo( variant = 'default', value, disabled, + align = 'start', + side = 'bottom', + loading = false, useSearch = true }) => { const selectedRecord = useMemo(() => { @@ -76,7 +83,8 @@ export const SelectMultiple: React.FC = React.memo( onSelect={handleSelect} menuHeader={useSearch ? 'Search...' : undefined} selectType="multiple" - align="start" + align={align} + side={side} modal={false} className="w-[var(--radix-dropdown-menu-trigger-width)] max-w-full!">
= React.memo( {selectedItems.length > 0 && (
)} + {loading && ( +
+ +
+ )}
); diff --git a/apps/web/src/lib/organization/index.ts b/apps/web/src/lib/organization/index.ts new file mode 100644 index 000000000..b01054046 --- /dev/null +++ b/apps/web/src/lib/organization/index.ts @@ -0,0 +1 @@ +export * from './translations'; diff --git a/packages/server-shared/src/organization/index.ts b/packages/server-shared/src/organization/index.ts index 216bd9f40..480844020 100644 --- a/packages/server-shared/src/organization/index.ts +++ b/packages/server-shared/src/organization/index.ts @@ -1,4 +1,3 @@ export * from './organization.types'; export * from './roles.types'; export * from './user.types'; -export * from './role.enums'; diff --git a/packages/server-shared/src/organization/role.enums.ts b/packages/server-shared/src/organization/role.enums.ts deleted file mode 100644 index 83052b3db..000000000 --- a/packages/server-shared/src/organization/role.enums.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { OrganizationRole } from './roles.types'; - -//We need this to avoid postgres dependency in the frontend ☹️ -export const OrganizationRoleEnum: Record = { - none: 'none', - viewer: 'viewer', - workspace_admin: 'workspace_admin', - data_admin: 'data_admin', - querier: 'querier', - restricted_querier: 'restricted_querier', -}; diff --git a/packages/server-shared/src/organization/roles.types.ts b/packages/server-shared/src/organization/roles.types.ts index 4317f7dba..c16a47b01 100644 --- a/packages/server-shared/src/organization/roles.types.ts +++ b/packages/server-shared/src/organization/roles.types.ts @@ -1,6 +1,19 @@ -import { userOrganizationRoleEnum } from '@buster/database'; +import type { userOrganizationRoleEnum } from '@buster/database'; //we import as type to avoid postgres dependency in the frontend ☹️ import { z } from 'zod/v4'; -export const OrganizationRoleSchema = z.enum([...userOrganizationRoleEnum.enumValues, 'none']); +type OrganizationRoleBase = (typeof userOrganizationRoleEnum.enumValues)[number] | 'none'; + +//We need this to avoid postgres dependency in the frontend ☹️ +export const OrganizationRoleEnum: Record = + Object.freeze({ + none: 'none', + viewer: 'viewer', + workspace_admin: 'workspace_admin', + data_admin: 'data_admin', + querier: 'querier', + restricted_querier: 'restricted_querier', + }); + +export const OrganizationRoleSchema = z.enum(Object.values(OrganizationRoleEnum)); export type OrganizationRole = z.infer; diff --git a/packages/server-shared/src/security/requests.ts b/packages/server-shared/src/security/requests.ts index 7554d9f1b..1efe27cff 100644 --- a/packages/server-shared/src/security/requests.ts +++ b/packages/server-shared/src/security/requests.ts @@ -1,30 +1,24 @@ -import { z } from "zod/v4"; -import { OrganizationRoleSchema } from "../organization"; +import { z } from 'zod/v4'; +import { OrganizationRoleSchema } from '../organization'; export const UpdateInviteLinkRequestSchema = z.object({ enabled: z.boolean().optional(), refresh_link: z.boolean().optional(), }); -export type UpdateInviteLinkRequest = z.infer< - typeof UpdateInviteLinkRequestSchema ->; +export type UpdateInviteLinkRequest = z.infer; export const AddApprovedDomainRequestSchema = z.object({ domains: z.array(z.string()), }); -export type AddApprovedDomainRequest = z.infer< - typeof AddApprovedDomainRequestSchema ->; +export type AddApprovedDomainRequest = z.infer; export const RemoveApprovedDomainRequestSchema = z.object({ domains: z.array(z.string()), }); -export type RemoveApprovedDomainRequest = z.infer< - typeof RemoveApprovedDomainRequestSchema ->; +export type RemoveApprovedDomainRequest = z.infer; export const UpdateWorkspaceSettingsRequestSchema = z.object({ restrict_new_user_invitations: z.boolean().optional(), @@ -38,12 +32,10 @@ export const UpdateWorkspaceSettingsRequestSchema = z.object({ .regex( /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/ ), - z.literal("all"), + z.literal('all'), ]) ) .optional(), }); -export type UpdateWorkspaceSettingsRequest = z.infer< - typeof UpdateWorkspaceSettingsRequestSchema ->; +export type UpdateWorkspaceSettingsRequest = z.infer; diff --git a/packages/server-shared/src/security/responses.ts b/packages/server-shared/src/security/responses.ts index 676646ea1..eb10a45d2 100644 --- a/packages/server-shared/src/security/responses.ts +++ b/packages/server-shared/src/security/responses.ts @@ -1,5 +1,5 @@ -import { z } from "zod/v4"; -import { OrganizationRoleSchema } from "../organization"; +import { z } from 'zod/v4'; +import { OrganizationRoleSchema } from '../organization'; export const GetInviteLinkResponseSchema = z.object({ link: z.string(), @@ -14,12 +14,9 @@ export const GetApprovedDomainsResponseSchema = z.array( created_at: z.string(), }) ); -export const AddApprovedDomainsResponseSchema = - GetApprovedDomainsResponseSchema; -export const UpdateApprovedDomainsResponseSchema = - GetApprovedDomainsResponseSchema; -export const RemoveApprovedDomainsResponseSchema = - GetApprovedDomainsResponseSchema; +export const AddApprovedDomainsResponseSchema = GetApprovedDomainsResponseSchema; +export const UpdateApprovedDomainsResponseSchema = GetApprovedDomainsResponseSchema; +export const RemoveApprovedDomainsResponseSchema = GetApprovedDomainsResponseSchema; export const GetWorkspaceSettingsResponseSchema = z.object({ restrict_new_user_invitations: z.boolean(), @@ -31,31 +28,14 @@ export const GetWorkspaceSettingsResponseSchema = z.object({ }) ), }); -export const UpdateWorkspaceSettingsResponseSchema = - GetWorkspaceSettingsResponseSchema; +export const UpdateWorkspaceSettingsResponseSchema = GetWorkspaceSettingsResponseSchema; -export type RefreshInviteLinkResponse = z.infer< - typeof RefreshInviteLinkResponseSchema ->; -export type UpdateInviteLinkResponse = z.infer< - typeof UpdateInviteLinkResponseSchema ->; +export type RefreshInviteLinkResponse = z.infer; +export type UpdateInviteLinkResponse = z.infer; export type GetInviteLinkResponse = z.infer; -export type GetApprovedDomainsResponse = z.infer< - typeof GetApprovedDomainsResponseSchema ->; -export type AddApprovedDomainsResponse = z.infer< - typeof AddApprovedDomainsResponseSchema ->; -export type UpdateApprovedDomainsResponse = z.infer< - typeof UpdateApprovedDomainsResponseSchema ->; -export type RemoveApprovedDomainsResponse = z.infer< - typeof RemoveApprovedDomainsResponseSchema ->; -export type GetWorkspaceSettingsResponse = z.infer< - typeof GetWorkspaceSettingsResponseSchema ->; -export type UpdateWorkspaceSettingsResponse = z.infer< - typeof UpdateWorkspaceSettingsResponseSchema ->; +export type GetApprovedDomainsResponse = z.infer; +export type AddApprovedDomainsResponse = z.infer; +export type UpdateApprovedDomainsResponse = z.infer; +export type RemoveApprovedDomainsResponse = z.infer; +export type GetWorkspaceSettingsResponse = z.infer; +export type UpdateWorkspaceSettingsResponse = z.infer; diff --git a/packages/server-shared/src/teams/teams.types.ts b/packages/server-shared/src/teams/teams.types.ts index 6f80c1f82..0f8db844e 100644 --- a/packages/server-shared/src/teams/teams.types.ts +++ b/packages/server-shared/src/teams/teams.types.ts @@ -1,8 +1,14 @@ -import { teamRoleEnum } from '@buster/database'; +import type { teamRoleEnum } from '@buster/database'; import { z } from 'zod/v4'; import { SharingSettingSchema } from '../user/sharing-setting.types'; -export const TeamRoleSchema = z.enum([...teamRoleEnum.enumValues, 'none']); +type TeamRoleBase = (typeof teamRoleEnum.enumValues)[number] | 'none'; +const TeamRoleEnums: Record = Object.freeze({ + none: 'none', + manager: 'manager', + member: 'member', +}); +export const TeamRoleSchema = z.enum(Object.values(TeamRoleEnums)); export type TeamRole = z.infer; diff --git a/packages/server-shared/src/user/roles.types.ts b/packages/server-shared/src/user/roles.types.ts index 3ebcef1a0..b49fb5ba8 100644 --- a/packages/server-shared/src/user/roles.types.ts +++ b/packages/server-shared/src/user/roles.types.ts @@ -1,6 +1,18 @@ -import { userOrganizationRoleEnum } from '@buster/database'; +import type { userOrganizationRoleEnum } from '@buster/database'; //we import as type to avoid postgres dependency in the frontend ☹️ import { z } from 'zod/v4'; -export const UserOrganizationRoleSchema = z.enum([...userOrganizationRoleEnum.enumValues, 'none']); +type UserOrganizationRoleBase = (typeof userOrganizationRoleEnum.enumValues)[number] | 'none'; + +const UserOrganizationRoleEnums: Record = + Object.freeze({ + none: 'none', + viewer: 'viewer', + workspace_admin: 'workspace_admin', + data_admin: 'data_admin', + querier: 'querier', + restricted_querier: 'restricted_querier', + }); + +export const UserOrganizationRoleSchema = z.enum(Object.values(UserOrganizationRoleEnums)); export type UserOrganizationRole = z.infer; diff --git a/packages/server-shared/src/user/sharing-setting.types.ts b/packages/server-shared/src/user/sharing-setting.types.ts index ef68a538a..c559f9867 100644 --- a/packages/server-shared/src/user/sharing-setting.types.ts +++ b/packages/server-shared/src/user/sharing-setting.types.ts @@ -1,6 +1,14 @@ -import { sharingSettingEnum } from '@buster/database'; +import type { sharingSettingEnum } from '@buster/database'; //we import as type to avoid postgres dependency in the frontend ☹️ import { z } from 'zod/v4'; -export const SharingSettingSchema = z.enum([...sharingSettingEnum.enumValues, 'none']); +type SharingSettingBase = (typeof sharingSettingEnum.enumValues)[number] | 'none'; + +const SharingSettingEnums: Record = Object.freeze({ + none: 'none', + public: 'public', + team: 'team', + organization: 'organization', +}); +export const SharingSettingSchema = z.enum(Object.values(SharingSettingEnums)); export type SharingSetting = z.infer; From aa22c9f378566581bb505d6c180e290965b6ad93 Mon Sep 17 00:00:00 2001 From: Nate Kelley <133379588+nate-kelley-buster@users.noreply.github.com> Date: Wed, 9 Jul 2025 11:02:51 -0600 Subject: [PATCH 5/6] Update apps/web/src/components/features/security/ApprovedEmailDomains.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../features/security/ApprovedEmailDomains.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/features/security/ApprovedEmailDomains.tsx b/apps/web/src/components/features/security/ApprovedEmailDomains.tsx index 2911c95d4..ee46b0ed3 100644 --- a/apps/web/src/components/features/security/ApprovedEmailDomains.tsx +++ b/apps/web/src/components/features/security/ApprovedEmailDomains.tsx @@ -89,8 +89,14 @@ const AddDomainInput = React.memo( const [newDomain, setNewDomain] = useState(''); const handleAddDomain = useMemoizedFn(async () => { - if (!newDomain.trim()) return; - + const domain = newDomain.trim(); + if (!domain) return; + + const domainRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]\.[a-zA-Z]{2,}$/; + if (!domainRegex.test(domain)) { + openErrorMessage('Please enter a valid domain name'); + return; + } try { await addDomain({ domains: [newDomain.trim()] }); setNewDomain(''); From e22a7953265d4b6e29e22c432552259f85bd693f Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Wed, 9 Jul 2025 11:06:25 -0600 Subject: [PATCH 6/6] Add additinal fixes --- apps/web/src/api/buster_rest/security/queryRequests.ts | 6 +++--- apps/web/src/api/buster_rest/security/requests.ts | 2 +- .../components/features/security/WorkspaceRestrictions.tsx | 2 +- packages/server-shared/src/teams/teams.types.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/src/api/buster_rest/security/queryRequests.ts b/apps/web/src/api/buster_rest/security/queryRequests.ts index 34d681a7c..8c10cbe29 100644 --- a/apps/web/src/api/buster_rest/security/queryRequests.ts +++ b/apps/web/src/api/buster_rest/security/queryRequests.ts @@ -88,13 +88,13 @@ export const useAddApprovedDomain = () => { onMutate: (variables) => { queryClient.setQueryData(securityQueryKeys.securityApprovedDomains.queryKey, (prev) => { if (!prev) return prev; - return { + return [ ...prev, ...variables.domains.map((domain) => ({ domain, created_at: new Date().toISOString() - })) - } satisfies GetApprovedDomainsResponse; + })) satisfies GetApprovedDomainsResponse + ] satisfies GetApprovedDomainsResponse; }); }, onSuccess: (data) => { diff --git a/apps/web/src/api/buster_rest/security/requests.ts b/apps/web/src/api/buster_rest/security/requests.ts index 92f135152..fcefb69af 100644 --- a/apps/web/src/api/buster_rest/security/requests.ts +++ b/apps/web/src/api/buster_rest/security/requests.ts @@ -46,7 +46,7 @@ export const addApprovedDomain = async (request: AddApprovedDomainRequest) => { export const removeApprovedDomain = async (request: RemoveApprovedDomainRequest) => { return await mainApiV2 .delete('/security/approved-domains', { - data: request + params: request }) .then((res) => res.data); }; diff --git a/apps/web/src/components/features/security/WorkspaceRestrictions.tsx b/apps/web/src/components/features/security/WorkspaceRestrictions.tsx index 03718a6a6..65401da93 100644 --- a/apps/web/src/components/features/security/WorkspaceRestrictions.tsx +++ b/apps/web/src/components/features/security/WorkspaceRestrictions.tsx @@ -41,7 +41,7 @@ export const WorkspaceRestrictions = React.memo(() => { updateWorkspaceSettings={updateWorkspaceSettings} /> ], - [workspaceSettings?.restrict_new_user_invitations] + [workspaceSettings] ); return ( diff --git a/packages/server-shared/src/teams/teams.types.ts b/packages/server-shared/src/teams/teams.types.ts index 0f8db844e..538176cd9 100644 --- a/packages/server-shared/src/teams/teams.types.ts +++ b/packages/server-shared/src/teams/teams.types.ts @@ -1,4 +1,4 @@ -import type { teamRoleEnum } from '@buster/database'; +import type { teamRoleEnum } from '@buster/database'; //we import as type to avoid postgres dependency in the frontend ☹️ import { z } from 'zod/v4'; import { SharingSettingSchema } from '../user/sharing-setting.types';