From 46c3fccb9ba68d8a501e86ec2cdb4dd869b57c34 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Tue, 8 Jul 2025 17:20:31 -0600 Subject: [PATCH] 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 +>;