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,
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: []
});
};

View File

@ -4,12 +4,3 @@ export const OrganizationUserStatusText: Record<OrganizationUser['status'], stri
active: 'Active',
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 { 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() {
<div className="flex flex-col space-y-6">
<InviteLinks />
<ApprovedEmailDomains />
<WorkspaceRestrictions />
</div>
</div>
</div>

View File

@ -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
<div key="header" className="flex items-center justify-between">
<Text>{countText}</Text>
<Button onClick={() => setIsAddingDomain(true)} suffix={<Plus />}>
<Button onClick={() => setIsEnabledAddDomain(true)} suffix={<Plus />}>
Add domain
</Button>
</div>,
// Add domain input section (when adding)
isAddingDomain && <AddDomainInput key="add-domain" setIsAddingDomain={setIsAddingDomain} />,
isEnabledAddDomain && (
<AddDomainInput key="add-domain" setIsEnabledAddDomain={setIsEnabledAddDomain} />
),
// 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<void>;
}
const DomainListItem = React.memo<DomainListItemProps>(({ 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: <Trash />,
onClick: () => onRemove(domainData.domain)
}
],
[domainData.domain, onRemove]
);
return (
<div className="flex items-center justify-between">
<div>
<Text className="font-medium">{domainData.domain}</Text>
<div className="mr-2 flex min-w-0 flex-1 flex-col space-y-0.5">
<Text className="font-medium" truncate>
{domainData.domain}
</Text>
<Text variant="secondary" size="sm">
Added {formatDate(domainData.created_at)}
Added {formatDate({ date: domainData.created_at, format: 'LL' })}
</Text>
</div>
<Dropdown
items={[
{
label: 'Remove domain',
value: 'remove',
onClick: () => onRemove(domainData.domain)
}
]}>
<Button variant="ghost" size="small" prefix={<Dots />} />
<Dropdown side="left" align="center" items={items}>
<Button variant="ghost" prefix={<Dots />} />
</Dropdown>
</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 './roles.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({
enabled: z.boolean().optional(),
restrict_new_user_invitations: z.boolean().optional(),
default_role: OrganizationRoleSchema.optional(),
// this can either be a uuid or "all"
default_datasets_ids: z

View File

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