Skip to content

Commit b9f8295

Browse files
authored
feat: add workspace sharing page (#20931)
resolves coder/internal#849 <img width="870" height="554" alt="Screenshot 2025-12-10 at 20 37 38" src="/api/flow.js?q=https%3A%2F%2Fwww.github.com%2Fcoder%2Fcoder%2Fcommit%2F%253Ca%2520href%3D"https://github.com/user-attachments/assets/8712bf81-dc6f-4645-9e32-65eee7882e76">https://github.com/user-attachments/assets/8712bf81-dc6f-4645-9e32-65eee7882e76" />
1 parent aba0e36 commit b9f8295

File tree

8 files changed

+771
-66
lines changed

8 files changed

+771
-66
lines changed

site/src/api/api.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1925,6 +1925,16 @@ class ApiMethods {
19251925
return response.data;
19261926
};
19271927

1928+
getWorkspaceACL = async (
1929+
workspaceId: string,
1930+
): Promise<TypesGen.WorkspaceACL> => {
1931+
const response = await this.axios.get(
1932+
`/api/v2/workspaces/${workspaceId}/acl`,
1933+
);
1934+
1935+
return response.data;
1936+
};
1937+
19281938
updateWorkspaceACL = async (
19291939
workspaceId: string,
19301940
data: TypesGen.UpdateWorkspaceACL,

site/src/api/queries/workspaces.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import type {
55
ProvisionerLogLevel,
66
UsageAppName,
77
Workspace,
8+
WorkspaceACL,
89
WorkspaceAgentLog,
910
WorkspaceBuild,
1011
WorkspaceBuildParameter,
12+
WorkspaceRole,
1113
WorkspacesRequest,
1214
WorkspacesResponse,
1315
} from "api/typesGenerated";
@@ -18,6 +20,7 @@ import {
1820
} from "modules/workspaces/permissions";
1921
import type { ConnectionStatus } from "pages/TerminalPage/types";
2022
import type {
23+
MutationOptions,
2124
QueryClient,
2225
QueryOptions,
2326
UseMutationOptions,
@@ -42,6 +45,63 @@ export const workspaceByOwnerAndName = (owner: string, name: string) => {
4245
};
4346
};
4447

48+
const workspaceACLKey = (workspaceId: string) => ["workspaceAcl", workspaceId];
49+
50+
export const workspaceACL = (workspaceId: string) => {
51+
return {
52+
queryKey: workspaceACLKey(workspaceId),
53+
queryFn: () => API.getWorkspaceACL(workspaceId),
54+
} satisfies QueryOptions<WorkspaceACL>;
55+
};
56+
57+
export const setWorkspaceUserRole = (
58+
queryClient: QueryClient,
59+
): MutationOptions<
60+
void,
61+
unknown,
62+
{
63+
workspaceId: string;
64+
userId: string;
65+
role: WorkspaceRole;
66+
}
67+
> => {
68+
return {
69+
mutationFn: ({ workspaceId, userId, role }) =>
70+
API.updateWorkspaceACL(workspaceId, {
71+
user_roles: {
72+
[userId]: role,
73+
},
74+
}),
75+
onSuccess: async (_res, { workspaceId }) => {
76+
await queryClient.invalidateQueries({
77+
queryKey: workspaceACLKey(workspaceId),
78+
});
79+
},
80+
};
81+
};
82+
83+
export const setWorkspaceGroupRole = (
84+
queryClient: QueryClient,
85+
): MutationOptions<
86+
void,
87+
unknown,
88+
{ workspaceId: string; groupId: string; role: WorkspaceRole }
89+
> => {
90+
return {
91+
mutationFn: ({ workspaceId, groupId, role }) =>
92+
API.updateWorkspaceACL(workspaceId, {
93+
group_roles: {
94+
[groupId]: role,
95+
},
96+
}),
97+
onSuccess: async (_res, { workspaceId }) => {
98+
await queryClient.invalidateQueries({
99+
queryKey: workspaceACLKey(workspaceId),
100+
});
101+
},
102+
};
103+
};
104+
45105
type CreateWorkspaceMutationVariables = CreateWorkspaceRequest & {
46106
userId: string;
47107
};

site/src/components/Select/Select.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ export const SelectItem = React.forwardRef<
141141
)}
142142
{...props}
143143
>
144-
<span className="absolute right-2 flex items-center justify-center">
144+
<span className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center">
145145
<SelectPrimitive.ItemIndicator className="size-icon-sm">
146146
<Check className="size-icon-sm" />
147147
</SelectPrimitive.ItemIndicator>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { Group, ReducedUser, User } from "api/typesGenerated";
2+
import { AvatarData } from "components/Avatar/AvatarData";
3+
import type { HTMLAttributes } from "react";
4+
import { getGroupSubtitle } from "utils/groups";
5+
6+
type UserOrGroupAutocompleteValue = User | ReducedUser | Group | null;
7+
8+
type UserOption = User | ReducedUser;
9+
type OptionType = UserOption | Group;
10+
11+
/**
12+
* Type guard to check if the value is a Group.
13+
* Groups have a "members" property that users don't have.
14+
*/
15+
export const isGroup = (
16+
value: UserOrGroupAutocompleteValue,
17+
): value is Group => {
18+
return value !== null && typeof value === "object" && "members" in value;
19+
};
20+
21+
interface UserOrGroupOptionProps {
22+
option: OptionType;
23+
htmlProps: HTMLAttributes<HTMLLIElement>;
24+
}
25+
26+
export const UserOrGroupOption = ({
27+
option,
28+
htmlProps,
29+
}: UserOrGroupOptionProps) => {
30+
const isOptionGroup = isGroup(option);
31+
32+
return (
33+
<li {...htmlProps}>
34+
<AvatarData
35+
title={
36+
isOptionGroup ? option.display_name || option.name : option.username
37+
}
38+
subtitle={isOptionGroup ? getGroupSubtitle(option) : option.email}
39+
src={option.avatar_url}
40+
/>
41+
</li>
42+
);
43+
};
Lines changed: 31 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
1-
import { css } from "@emotion/react";
21
import Autocomplete from "@mui/material/Autocomplete";
32
import CircularProgress from "@mui/material/CircularProgress";
43
import TextField from "@mui/material/TextField";
54
import { templaceACLAvailable } from "api/queries/templates";
65
import type { Group, ReducedUser } from "api/typesGenerated";
7-
import { AvatarData } from "components/Avatar/AvatarData";
6+
import {
7+
isGroup,
8+
UserOrGroupOption,
9+
} from "components/UserOrGroupAutocomplete/UserOrGroupOption";
810
import { useDebouncedFunction } from "hooks/debounce";
911
import { type ChangeEvent, type FC, useState } from "react";
1012
import { keepPreviousData, useQuery } from "react-query";
1113
import { prepareQuery } from "utils/filters";
12-
import { getGroupSubtitle } from "utils/groups";
1314

1415
export type UserOrGroupAutocompleteValue = ReducedUser | Group | null;
16+
type AutocompleteOption = Exclude<UserOrGroupAutocompleteValue, null>;
1517

1618
type UserOrGroupAutocompleteProps = {
1719
value: UserOrGroupAutocompleteValue;
@@ -38,7 +40,7 @@ export const UserOrGroupAutocomplete: FC<UserOrGroupAutocompleteProps> = ({
3840
enabled: autoComplete.open,
3941
placeholderData: keepPreviousData,
4042
});
41-
const options = aclAvailableQuery.data
43+
const options: AutocompleteOption[] = aclAvailableQuery.data
4244
? [
4345
...aclAvailableQuery.data.groups,
4446
...aclAvailableQuery.data.users,
@@ -81,68 +83,38 @@ export const UserOrGroupAutocomplete: FC<UserOrGroupAutocompleteProps> = ({
8183
onChange={(_, newValue) => {
8284
onChange(newValue);
8385
}}
84-
isOptionEqualToValue={(option, value) => option.id === value.id}
86+
isOptionEqualToValue={(option, optionValue) =>
87+
option.id === optionValue.id
88+
}
8589
getOptionLabel={(option) =>
8690
isGroup(option) ? option.display_name || option.name : option.email
8791
}
88-
renderOption={(props, option) => {
89-
const isOptionGroup = isGroup(option);
90-
91-
return (
92-
<li {...props}>
93-
<AvatarData
94-
title={
95-
isOptionGroup
96-
? option.display_name || option.name
97-
: option.username
98-
}
99-
subtitle={isOptionGroup ? getGroupSubtitle(option) : option.email}
100-
src={option.avatar_url}
101-
/>
102-
</li>
103-
);
104-
}}
92+
renderOption={({ key, ...props }, option) => (
93+
<UserOrGroupOption key={key} htmlProps={props} option={option} />
94+
)}
10595
options={options}
10696
loading={aclAvailableQuery.isFetching}
107-
css={autoCompleteStyles}
97+
className="w-[300px] [&_.MuiFormControl-root]:w-full [&_.MuiInputBase-root]:w-full"
10898
renderInput={(params) => (
109-
<>
110-
<TextField
111-
{...params}
112-
margin="none"
113-
size="small"
114-
placeholder="Search for user or group"
115-
InputProps={{
116-
...params.InputProps,
117-
onChange: handleFilterChange,
118-
endAdornment: (
119-
<>
120-
{aclAvailableQuery.isFetching ? (
121-
<CircularProgress size={16} />
122-
) : null}
123-
{params.InputProps.endAdornment}
124-
</>
125-
),
126-
}}
127-
/>
128-
</>
99+
<TextField
100+
{...params}
101+
margin="none"
102+
size="small"
103+
placeholder="Search for user or group"
104+
InputProps={{
105+
...params.InputProps,
106+
onChange: handleFilterChange,
107+
endAdornment: (
108+
<>
109+
{aclAvailableQuery.isFetching ? (
110+
<CircularProgress size={16} />
111+
) : null}
112+
{params.InputProps.endAdornment}
113+
</>
114+
),
115+
}}
116+
/>
129117
)}
130118
/>
131119
);
132120
};
133-
134-
const isGroup = (value: UserOrGroupAutocompleteValue): value is Group => {
135-
return value !== null && "members" in value;
136-
};
137-
138-
const autoCompleteStyles = css`
139-
width: 300px;
140-
141-
& .MuiFormControl-root {
142-
width: 100%;
143-
}
144-
145-
& .MuiInputBase-root {
146-
width: 100%;
147-
}
148-
`;

0 commit comments

Comments
 (0)