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..8c10cbe29 --- /dev/null +++ b/apps/web/src/api/buster_rest/security/queryRequests.ts @@ -0,0 +1,122 @@ +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'; +import type { + GetApprovedDomainsResponse, + GetWorkspaceSettingsResponse +} from '@buster/server-shared/security'; + +export const useGetWorkspaceSettings = () => { + return useQuery({ + ...securityQueryKeys.securityGetWorkspaceSettings, + queryFn: getWorkspaceSettings, + initialData: { + restrict_new_user_invitations: false, + default_role: 'viewer', + default_datasets: [] + } satisfies GetWorkspaceSettingsResponse + }); +}; + +export const useGetInviteLink = () => { + return useQuery({ + ...securityQueryKeys.securityInviteLink, + queryFn: getInviteLink + }); +}; + +export const useGetApprovedDomains = () => { + return useQuery({ + ...securityQueryKeys.securityApprovedDomains, + queryFn: getApprovedDomains, + initialData: [] + }); +}; + +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, + onMutate: (variables) => { + queryClient.setQueryData(securityQueryKeys.securityInviteLink.queryKey, (prev) => { + if (!prev) return prev; + return { + ...prev, + ...variables + }; + }); + }, + 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, + 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 + ] satisfies GetApprovedDomainsResponse; + }); + }, + onSuccess: (data) => { + queryClient.setQueryData(securityQueryKeys.securityApprovedDomains.queryKey, data); + } + }); +}; + +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/api/buster_rest/security/requests.ts b/apps/web/src/api/buster_rest/security/requests.ts new file mode 100644 index 000000000..fcefb69af --- /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', { + params: 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/(permissions)/users/ListUsersComponent.tsx b/apps/web/src/app/app/(settings_layout)/settings/(permissions)/users/ListUsersComponent.tsx index bf1b372e0..3b0ad6bbf 100644 --- a/apps/web/src/app/app/(settings_layout)/settings/(permissions)/users/ListUsersComponent.tsx +++ b/apps/web/src/app/app/(settings_layout)/settings/(permissions)/users/ListUsersComponent.tsx @@ -12,7 +12,7 @@ import { import { BusterInfiniteList } from '@/components/ui/list/BusterInfiniteList'; import { Text } from '@/components/ui/typography'; import { BusterRoutes, createBusterRoute } from '@/routes'; -import { OrganizationUserRoleText } from './config'; +import { OrganizationUserRoleText } from '@/lib/organization/translations'; export const ListUsersComponent: React.FC<{ users: OrganizationUser[]; 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 new file mode 100644 index 000000000..c88316992 --- /dev/null +++ b/apps/web/src/app/app/(settings_layout)/settings/(restricted-width)/(admin-only)/security/page.tsx @@ -0,0 +1,23 @@ +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 ( +
+
+ + +
+ + + +
+
+
+ ); +} 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..ee46b0ed3 --- /dev/null +++ b/apps/web/src/components/features/security/ApprovedEmailDomains.tsx @@ -0,0 +1,179 @@ +'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, Trash } 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'; +import { formatDate } from '@/lib/date'; + +interface ApprovedDomain { + domain: string; + created_at: string; +} + +export const ApprovedEmailDomains = React.memo(() => { + const { data: approvedDomains = [] } = useGetApprovedDomains(); + const { mutateAsync: removeDomain } = useRemoveApprovedDomain(); + const domainCount = approvedDomains.length; + + const [isEnabledAddDomain, setIsEnabledAddDomain] = useState(false); + + const { openInfoMessage, openErrorMessage } = useBusterNotifications(); + + 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) + isEnabledAddDomain && ( + + ), + + // Domain list sections + ...approvedDomains.map((domainData) => ( + + )) + ].filter(Boolean), + [countText, isEnabledAddDomain, approvedDomains, handleRemoveDomain] + ); + + return ( + + ); +}); + +ApprovedEmailDomains.displayName = 'ApprovedEmailDomains'; + +const AddDomainInput = React.memo( + ({ setIsEnabledAddDomain }: { setIsEnabledAddDomain: (isEnabledAddDomain: boolean) => void }) => { + const { mutateAsync: addDomain } = useAddApprovedDomain(); + const { openInfoMessage, openErrorMessage } = useBusterNotifications(); + + const [newDomain, setNewDomain] = useState(''); + + const handleAddDomain = useMemoizedFn(async () => { + 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(''); + setIsEnabledAddDomain(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') { + setIsEnabledAddDomain(false); + setNewDomain(''); + } + }} + autoFocus + /> + + +
+ ); + } +); + +AddDomainInput.displayName = 'AddDomainInput'; + +const DomainListItem = React.memo<{ + domainData: ApprovedDomain; + onRemove: (domain: string) => Promise; +}>(({ domainData, onRemove }) => { + const items = useMemo( + () => [ + { + label: 'Remove domain', + value: 'remove', + icon: , + onClick: () => onRemove(domainData.domain) + } + ], + [domainData.domain, onRemove] + ); + + return ( +
+
+ + {domainData.domain} + + + Added {formatDate({ date: domainData.created_at, format: 'LL' })} + +
+ +
+ ); +}); + +DomainListItem.displayName = 'DomainListItem'; 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..7b3db6d42 --- /dev/null +++ b/apps/web/src/components/features/security/InviteLinks.tsx @@ -0,0 +1,79 @@ +'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 { 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 ?? ''; + + const { openInfoMessage } = useBusterNotifications(); + + const onClickCopy = () => { + navigator.clipboard.writeText(link); + openInfoMessage('Invite link copied to clipboard'); + }; + + const onClickRefresh = async () => { + await updateInviteLink({ refresh_link: true }); + openInfoMessage('Invite link refreshed'); + }; + + const onToggleEnabled = (enabled: boolean) => { + updateInviteLink({ enabled }); + }; + + return ( + + Enable invite links + + , + enabled && ( +
+
+ +
+ +
+
+ +
+ ) + ].filter(Boolean) + } + ]} + /> + ); +}); + +InviteLinks.displayName = 'InviteLinks'; 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/security/WorkspaceRestrictions.tsx b/apps/web/src/components/features/security/WorkspaceRestrictions.tsx new file mode 100644 index 000000000..65401da93 --- /dev/null +++ b/apps/web/src/components/features/security/WorkspaceRestrictions.tsx @@ -0,0 +1,159 @@ +'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, OrganizationRoleEnum } from '@buster/server-shared/organization'; +import { OrganizationUserRoleText } from '@/lib/organization/translations'; +import { useGetDatasets } from '@/api/buster_rest/datasets'; +import { SelectMultiple } from '@/components/ui/select/SelectMultiple'; + +export const WorkspaceRestrictions = React.memo(() => { + const { data: workspaceSettings } = useGetWorkspaceSettings(); + const { mutateAsync: updateWorkspaceSettings } = useUpdateWorkspaceSettings(); + + const sections: ReactNode[] = useMemo( + () => [ + , + , + + ], + [workspaceSettings] + ); + + 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 ( +
+
+ Default Role + + {`Select which default role is assigned to new users`} + +
+