part 1 of making workspace toggles

This commit is contained in:
Nate Kelley 2025-07-09 09:42:16 -06:00
parent 46c3fccb9b
commit 59e6aacd9d
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
10 changed files with 202 additions and 47 deletions

View File

@ -10,12 +10,20 @@ import {
addApprovedDomain, addApprovedDomain,
removeApprovedDomain removeApprovedDomain
} from './requests'; } from './requests';
import type { GetApprovedDomainsResponse } from '@buster/server-shared/security'; import type {
GetApprovedDomainsResponse,
GetWorkspaceSettingsResponse
} from '@buster/server-shared/security';
export const useGetWorkspaceSettings = () => { export const useGetWorkspaceSettings = () => {
return useQuery({ return useQuery({
...securityQueryKeys.securityGetWorkspaceSettings, ...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 = () => { export const useGetApprovedDomains = () => {
return useQuery({ return useQuery({
...securityQueryKeys.securityApprovedDomains, ...securityQueryKeys.securityApprovedDomains,
queryFn: getApprovedDomains queryFn: getApprovedDomains,
initialData: []
}); });
}; };

View File

@ -4,12 +4,3 @@ export const OrganizationUserStatusText: Record<OrganizationUser['status'], stri
active: 'Active', active: 'Active',
inactive: 'Inactive' inactive: 'Inactive'
}; };
export const OrganizationUserRoleText: Record<OrganizationUser['role'], string> = {
data_admin: 'Data Admin',
workspace_admin: 'Workspace Admin',
querier: 'Querier',
restricted_querier: 'Restricted Querier',
viewer: 'Viewer',
none: 'None'
};

View File

@ -1,6 +1,7 @@
import { InviteLinks } from '@/components/features/security/InviteLinks'; import { InviteLinks } from '@/components/features/security/InviteLinks';
import { SettingsPageHeader } from '../../../_components/SettingsPageHeader'; import { SettingsPageHeader } from '../../../_components/SettingsPageHeader';
import { ApprovedEmailDomains } from '@/components/features/security/ApprovedEmailDomains'; import { ApprovedEmailDomains } from '@/components/features/security/ApprovedEmailDomains';
import { WorkspaceRestrictions } from '@/components/features/security/WorkspaceRestrictions';
export default function Page() { export default function Page() {
return ( return (
@ -14,6 +15,7 @@ export default function Page() {
<div className="flex flex-col space-y-6"> <div className="flex flex-col space-y-6">
<InviteLinks /> <InviteLinks />
<ApprovedEmailDomains /> <ApprovedEmailDomains />
<WorkspaceRestrictions />
</div> </div>
</div> </div>
</div> </div>

View File

@ -5,7 +5,7 @@ import { SecurityCards } from './SecurityCards';
import { Input } from '@/components/ui/inputs'; import { Input } from '@/components/ui/inputs';
import { Button } from '@/components/ui/buttons'; import { Button } from '@/components/ui/buttons';
import { Text } from '@/components/ui/typography'; 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 { useBusterNotifications } from '@/context/BusterNotifications';
import { Dropdown } from '@/components/ui/dropdown'; import { Dropdown } from '@/components/ui/dropdown';
import { import {
@ -15,6 +15,7 @@ import {
} from '@/api/buster_rest/security/queryRequests'; } from '@/api/buster_rest/security/queryRequests';
import pluralize from 'pluralize'; import pluralize from 'pluralize';
import { useMemoizedFn } from '@/hooks'; import { useMemoizedFn } from '@/hooks';
import { formatDate } from '@/lib/date';
interface ApprovedDomain { interface ApprovedDomain {
domain: string; domain: string;
@ -24,12 +25,12 @@ interface ApprovedDomain {
export const ApprovedEmailDomains = React.memo(() => { export const ApprovedEmailDomains = React.memo(() => {
const { data: approvedDomains = [] } = useGetApprovedDomains(); const { data: approvedDomains = [] } = useGetApprovedDomains();
const { mutateAsync: removeDomain } = useRemoveApprovedDomain(); const { mutateAsync: removeDomain } = useRemoveApprovedDomain();
const domainCount = approvedDomains.length;
const [isAddingDomain, setIsAddingDomain] = useState(false); const [isEnabledAddDomain, setIsEnabledAddDomain] = useState(false);
const { openInfoMessage, openErrorMessage } = useBusterNotifications(); const { openInfoMessage, openErrorMessage } = useBusterNotifications();
const domainCount = approvedDomains.length;
const countText = pluralize('approved email domain', domainCount, true); const countText = pluralize('approved email domain', domainCount, true);
const handleRemoveDomain = useMemoizedFn(async (domain: string) => { const handleRemoveDomain = useMemoizedFn(async (domain: string) => {
@ -47,13 +48,15 @@ export const ApprovedEmailDomains = React.memo(() => {
// Header section with count and add button // Header section with count and add button
<div key="header" className="flex items-center justify-between"> <div key="header" className="flex items-center justify-between">
<Text>{countText}</Text> <Text>{countText}</Text>
<Button onClick={() => setIsAddingDomain(true)} suffix={<Plus />}> <Button onClick={() => setIsEnabledAddDomain(true)} suffix={<Plus />}>
Add domain Add domain
</Button> </Button>
</div>, </div>,
// Add domain input section (when adding) // Add domain input section (when adding)
isAddingDomain && <AddDomainInput key="add-domain" setIsAddingDomain={setIsAddingDomain} />, isEnabledAddDomain && (
<AddDomainInput key="add-domain" setIsEnabledAddDomain={setIsEnabledAddDomain} />
),
// Domain list sections // Domain list sections
...approvedDomains.map((domainData) => ( ...approvedDomains.map((domainData) => (
@ -64,7 +67,7 @@ export const ApprovedEmailDomains = React.memo(() => {
/> />
)) ))
].filter(Boolean), ].filter(Boolean),
[countText, isAddingDomain, approvedDomains, handleRemoveDomain] [countText, isEnabledAddDomain, approvedDomains, handleRemoveDomain]
); );
return ( return (
@ -79,7 +82,7 @@ export const ApprovedEmailDomains = React.memo(() => {
ApprovedEmailDomains.displayName = 'ApprovedEmailDomains'; ApprovedEmailDomains.displayName = 'ApprovedEmailDomains';
const AddDomainInput = React.memo( const AddDomainInput = React.memo(
({ setIsAddingDomain }: { setIsAddingDomain: (isAddingDomain: boolean) => void }) => { ({ setIsEnabledAddDomain }: { setIsEnabledAddDomain: (isEnabledAddDomain: boolean) => void }) => {
const { mutateAsync: addDomain } = useAddApprovedDomain(); const { mutateAsync: addDomain } = useAddApprovedDomain();
const { openInfoMessage, openErrorMessage } = useBusterNotifications(); const { openInfoMessage, openErrorMessage } = useBusterNotifications();
@ -91,7 +94,7 @@ const AddDomainInput = React.memo(
try { try {
await addDomain({ domains: [newDomain.trim()] }); await addDomain({ domains: [newDomain.trim()] });
setNewDomain(''); setNewDomain('');
setIsAddingDomain(false); setIsEnabledAddDomain(false);
openInfoMessage('Domain added successfully'); openInfoMessage('Domain added successfully');
} catch (error) { } catch (error) {
openErrorMessage('Failed to add domain'); openErrorMessage('Failed to add domain');
@ -109,7 +112,7 @@ const AddDomainInput = React.memo(
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleAddDomain(); handleAddDomain();
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
setIsAddingDomain(false); setIsEnabledAddDomain(false);
setNewDomain(''); setNewDomain('');
} }
}} }}
@ -122,7 +125,7 @@ const AddDomainInput = React.memo(
size="tall" size="tall"
variant={'ghost'} variant={'ghost'}
onClick={() => { onClick={() => {
setIsAddingDomain(false); setIsEnabledAddDomain(false);
setNewDomain(''); setNewDomain('');
}}> }}>
Cancel Cancel
@ -134,37 +137,34 @@ const AddDomainInput = React.memo(
AddDomainInput.displayName = 'AddDomainInput'; AddDomainInput.displayName = 'AddDomainInput';
interface DomainListItemProps { const DomainListItem = React.memo<{
domainData: ApprovedDomain; domainData: ApprovedDomain;
onRemove: (domain: string) => Promise<void>; onRemove: (domain: string) => Promise<void>;
} }>(({ domainData, onRemove }) => {
const items = useMemo(
const DomainListItem = React.memo<DomainListItemProps>(({ domainData, onRemove }) => { () => [
const formatDate = (dateString: string) => { {
return new Date(dateString).toLocaleDateString('en-US', { label: 'Remove domain',
month: 'short', value: 'remove',
day: 'numeric', icon: <Trash />,
year: 'numeric' onClick: () => onRemove(domainData.domain)
}); }
}; ],
[domainData.domain, onRemove]
);
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div className="mr-2 flex min-w-0 flex-1 flex-col space-y-0.5">
<Text className="font-medium">{domainData.domain}</Text> <Text className="font-medium" truncate>
{domainData.domain}
</Text>
<Text variant="secondary" size="sm"> <Text variant="secondary" size="sm">
Added {formatDate(domainData.created_at)} Added {formatDate({ date: domainData.created_at, format: 'LL' })}
</Text> </Text>
</div> </div>
<Dropdown <Dropdown side="left" align="center" items={items}>
items={[ <Button variant="ghost" prefix={<Dots />} />
{
label: 'Remove domain',
value: 'remove',
onClick: () => onRemove(domainData.domain)
}
]}>
<Button variant="ghost" size="small" prefix={<Dots />} />
</Dropdown> </Dropdown>
</div> </div>
); );

View File

@ -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(
() => [
<EnableRestrictions
key="enable-restrictions"
restrict_new_user_invitations={workspaceSettings?.restrict_new_user_invitations}
updateWorkspaceSettings={updateWorkspaceSettings}
/>,
<DefaultRole
key="default-role"
default_role={workspaceSettings?.default_role}
updateWorkspaceSettings={updateWorkspaceSettings}
/>,
<DefaultDatasets
key="default-datasets"
default_datasets={workspaceSettings?.default_datasets}
updateWorkspaceSettings={updateWorkspaceSettings}
/>
],
[workspaceSettings?.restrict_new_user_invitations]
);
return (
<SecurityCards
title="Workspace restrictions"
description="Restrict the workspace to only allow users with an email address at these domains"
cards={[{ sections }]}
/>
);
});
WorkspaceRestrictions.displayName = 'WorkspaceRestrictions';
const EnableRestrictions = ({
restrict_new_user_invitations = false,
updateWorkspaceSettings
}: Pick<GetWorkspaceSettingsResponse, 'restrict_new_user_invitations'> & {
updateWorkspaceSettings: (request: UpdateWorkspaceSettingsRequest) => Promise<unknown>;
}) => {
return (
<div className="flex items-center justify-between">
<div className="flex flex-col space-y-0.5">
<Text>Restrict new user invitations</Text>
<Text variant="secondary" size={'sm'}>
{`Only allow admins to invite new members to workspace`}
</Text>
</div>
<Switch
checked={restrict_new_user_invitations}
onCheckedChange={(v) => {
updateWorkspaceSettings({ restrict_new_user_invitations: v });
}}
/>
</div>
);
};
const DefaultRole = ({
default_role = 'viewer',
updateWorkspaceSettings
}: Pick<GetWorkspaceSettingsResponse, 'default_role'> & {
updateWorkspaceSettings: (request: UpdateWorkspaceSettingsRequest) => Promise<unknown>;
}) => {
const items: SelectItem<OrganizationRole>[] = useMemo(() => {
return Object.values(OrganizationRoleEnum).map((role) => ({
label: OrganizationUserRoleText[role as OrganizationRole],
value: role as OrganizationRole
}));
}, []);
return (
<div className="flex items-center justify-between">
<div className="flex min-w-0 flex-1 flex-col space-y-0.5">
<Text>Restrict new user invitations</Text>
<Text variant="secondary" size={'sm'}>
{`Only allow admins to invite new members to workspace`}
</Text>
</div>
<Select
items={items}
className="w-36 max-w-72"
value={default_role}
onChange={(v) => {
updateWorkspaceSettings({ default_role: v });
}}
/>
</div>
);
};
const DefaultDatasets = ({
default_datasets = [],
updateWorkspaceSettings
}: Pick<GetWorkspaceSettingsResponse, 'default_datasets'> & {
updateWorkspaceSettings: (request: UpdateWorkspaceSettingsRequest) => Promise<unknown>;
}) => {
return (
<div className="flex items-center justify-between">
<div className="flex flex-col space-y-0.5">
<Text>Restrict new user invitations</Text>
<Text variant="secondary" size={'sm'}>
{`Only allow admins to invite new members to workspace`}
</Text>
</div>
<></>
</div>
);
};

View File

@ -0,0 +1,10 @@
import type { OrganizationRole } from '@buster/server-shared/organization';
export const OrganizationUserRoleText: Record<OrganizationRole, string> = {
data_admin: 'Data Admin',
workspace_admin: 'Workspace Admin',
querier: 'Querier',
restricted_querier: 'Restricted Querier',
viewer: 'Viewer',
none: 'None'
};

View File

@ -1,3 +1,4 @@
export * from './organization.types'; export * from './organization.types';
export * from './roles.types'; export * from './roles.types';
export * from './user.types'; export * from './user.types';
export * from './role.enums';

View File

@ -0,0 +1,11 @@
import type { OrganizationRole } from './roles.types';
//We need this to avoid postgres dependency in the frontend ☹️
export const OrganizationRoleEnum: Record<OrganizationRole, OrganizationRole> = {
none: 'none',
viewer: 'viewer',
workspace_admin: 'workspace_admin',
data_admin: 'data_admin',
querier: 'querier',
restricted_querier: 'restricted_querier',
};

View File

@ -27,7 +27,7 @@ export type RemoveApprovedDomainRequest = z.infer<
>; >;
export const UpdateWorkspaceSettingsRequestSchema = z.object({ export const UpdateWorkspaceSettingsRequestSchema = z.object({
enabled: z.boolean().optional(), restrict_new_user_invitations: z.boolean().optional(),
default_role: OrganizationRoleSchema.optional(), default_role: OrganizationRoleSchema.optional(),
// this can either be a uuid or "all" // this can either be a uuid or "all"
default_datasets_ids: z default_datasets_ids: z

View File

@ -22,7 +22,7 @@ export const RemoveApprovedDomainsResponseSchema =
GetApprovedDomainsResponseSchema; GetApprovedDomainsResponseSchema;
export const GetWorkspaceSettingsResponseSchema = z.object({ export const GetWorkspaceSettingsResponseSchema = z.object({
enabled: z.boolean(), restrict_new_user_invitations: z.boolean(),
default_role: OrganizationRoleSchema, default_role: OrganizationRoleSchema,
default_datasets: z.array( default_datasets: z.array(
z.object({ z.object({