Compare commits

...

5 Commits

Author SHA1 Message Date
Kayla はな 3f396b5a04 fix: disable sharing ui when sharing is unavailable (#22390)
Currently the sharing UI is only hidden under certain circumstances,
rather than on a permission basis. This makes it permissions based, and
makes some backend changes to make sure permissions are correct.
2026-03-03 16:58:10 +00:00
Jakub Domeracki 955637a79d fix(codersdk): use header auth for non-browser websocket dials (#22461) (cherry-pick/v2.31) (#22508)
Cherry-pick of #22461 to `release/2.31`.

Applies the non-browser websocket auth principle from #22226 to
remaining
`codersdk` websocket callsites, replacing cookie-jar session auth with
header-token auth. Fixes `401` failures on deployments with
`--host-prefix-cookie` enabled.

Closes #22461 (cherry-pick)

---------

Co-authored-by: ethan <ethanndickson@gmail.com>
2026-03-02 20:40:43 +01:00
Cian Johnston 85f1d70c4f ci: add temporary deploy override (#22378) (#22475)
Temporary override for deploying `main` to `dev.coder.com`.

(cherry picked from commit 67da4e8b56)
2026-03-02 13:58:06 +00:00
Cian Johnston e9e438b06e fix(stringutil): operate on runes instead of bytes in Truncate (#22388) (#22469)
Fixes https://github.com/coder/coder/issues/22375

Updates `stringutil.Truncate` to properly handle multi-byte UTF-8
characters.
Adds tests for multi-byte truncation with word boundary.

Created by Mux using Opus 4.6

(cherry picked from commit 0cfa03718e)
2026-03-02 11:19:16 +00:00
Steven Masley c339aa99ee chore: use header auth over cookies for agents (#22226) (cherry-pick/v2.31) (#22313)
All non-browser connections should not use cookies

(cherry picked from commit 3353e687e7)
2026-02-26 11:11:00 -06:00
38 changed files with 295 additions and 229 deletions
+3 -2
View File
@@ -329,9 +329,10 @@ func New(options *Options) *API {
panic("developer error: options.PrometheusRegistry is nil and not running a unit test")
}
if options.DeploymentValues.DisableOwnerWorkspaceExec {
if options.DeploymentValues.DisableOwnerWorkspaceExec || options.DeploymentValues.DisableWorkspaceSharing {
rbac.ReloadBuiltinRoles(&rbac.RoleOptions{
NoOwnerWorkspaceExec: true,
NoOwnerWorkspaceExec: bool(options.DeploymentValues.DisableOwnerWorkspaceExec),
NoWorkspaceSharing: bool(options.DeploymentValues.DisableWorkspaceSharing),
})
}
+38 -18
View File
@@ -244,6 +244,7 @@ func SystemRoleName(name string) bool {
type RoleOptions struct {
NoOwnerWorkspaceExec bool
NoWorkspaceSharing bool
}
// ReservedRoleName exists because the database should only allow unique role
@@ -267,12 +268,23 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
opts = &RoleOptions{}
}
denyPermissions := []Permission{}
if opts.NoWorkspaceSharing {
denyPermissions = append(denyPermissions, Permission{
Negate: true,
ResourceType: ResourceWorkspace.Type,
Action: policy.ActionShare,
})
}
ownerWorkspaceActions := ResourceWorkspace.AvailableActions()
if opts.NoOwnerWorkspaceExec {
// Remove ssh and application connect from the owner role. This
// prevents owners from have exec access to all workspaces.
ownerWorkspaceActions = slice.Omit(ownerWorkspaceActions,
policy.ActionApplicationConnect, policy.ActionSSH)
ownerWorkspaceActions = slice.Omit(
ownerWorkspaceActions,
policy.ActionApplicationConnect, policy.ActionSSH,
)
}
// Static roles that never change should be allocated in a closure.
@@ -295,7 +307,8 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
// Explicitly setting PrebuiltWorkspace permissions for clarity.
// Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions.
ResourcePrebuiltWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete},
})...),
})...,
),
User: []Permission{},
ByOrgID: map[string]OrgPermissions{},
}.withCachedRegoValue()
@@ -303,13 +316,17 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
memberRole := Role{
Identifier: RoleMember(),
DisplayName: "Member",
Site: Permissions(map[string][]policy.Action{
ResourceAssignRole.Type: {policy.ActionRead},
// All users can see OAuth2 provider applications.
ResourceOauth2App.Type: {policy.ActionRead},
ResourceWorkspaceProxy.Type: {policy.ActionRead},
}),
User: append(allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceOrganizationMember, ResourceBoundaryUsage),
Site: append(
Permissions(map[string][]policy.Action{
ResourceAssignRole.Type: {policy.ActionRead},
// All users can see OAuth2 provider applications.
ResourceOauth2App.Type: {policy.ActionRead},
ResourceWorkspaceProxy.Type: {policy.ActionRead},
}),
denyPermissions...,
),
User: append(
allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceOrganizationMember, ResourceBoundaryUsage),
Permissions(map[string][]policy.Action{
// Users cannot do create/update/delete on themselves, but they
// can read their own details.
@@ -433,14 +450,17 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
ByOrgID: map[string]OrgPermissions{
// Org admins should not have workspace exec perms.
organizationID.String(): {
Org: append(allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole, ResourceUserSecret, ResourceBoundaryUsage), Permissions(map[string][]policy.Action{
ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent, policy.ActionUpdateAgent},
ResourceWorkspace.Type: slice.Omit(ResourceWorkspace.AvailableActions(), policy.ActionApplicationConnect, policy.ActionSSH),
// PrebuiltWorkspaces are a subset of Workspaces.
// Explicitly setting PrebuiltWorkspace permissions for clarity.
// Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions.
ResourcePrebuiltWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete},
})...),
Org: append(
allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole, ResourceUserSecret, ResourceBoundaryUsage),
Permissions(map[string][]policy.Action{
ResourceWorkspace.Type: slice.Omit(ResourceWorkspace.AvailableActions(), policy.ActionApplicationConnect, policy.ActionSSH),
ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent, policy.ActionUpdateAgent},
// PrebuiltWorkspaces are a subset of Workspaces.
// Explicitly setting PrebuiltWorkspace permissions for clarity.
// Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions.
ResourcePrebuiltWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete},
})...,
),
Member: []Permission{},
},
},
+2 -2
View File
@@ -177,7 +177,7 @@ func generateFromPrompt(prompt string) (TaskName, error) {
// Ensure display name is never empty
displayName = strings.ReplaceAll(name, "-", " ")
}
displayName = strings.ToUpper(displayName[:1]) + displayName[1:]
displayName = strutil.Capitalize(displayName)
return TaskName{
Name: taskName,
@@ -269,7 +269,7 @@ func generateFromAnthropic(ctx context.Context, prompt string, apiKey string, mo
// Ensure display name is never empty
displayName = strings.ReplaceAll(taskNameResponse.Name, "-", " ")
}
displayName = strings.ToUpper(displayName[:1]) + displayName[1:]
displayName = strutil.Capitalize(displayName)
return TaskName{
Name: name,
+13
View File
@@ -49,6 +49,19 @@ func TestGenerate(t *testing.T) {
require.NotEmpty(t, taskName.DisplayName)
})
t.Run("FromPromptMultiByte", func(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "")
ctx := testutil.Context(t, testutil.WaitShort)
taskName := taskname.Generate(ctx, testutil.Logger(t), "über cool feature")
require.NoError(t, codersdk.NameValid(taskName.Name))
require.True(t, len(taskName.DisplayName) > 0)
// The display name must start with "Ü", not corrupted bytes.
require.Equal(t, "Über cool feature", taskName.DisplayName)
})
t.Run("Fallback", func(t *testing.T) {
// Ensure no API key
t.Setenv("ANTHROPIC_API_KEY", "")
+22 -10
View File
@@ -5,6 +5,7 @@ import (
"strconv"
"strings"
"unicode"
"unicode/utf8"
"github.com/acarl005/stripansi"
"github.com/microcosm-cc/bluemonday"
@@ -53,7 +54,7 @@ const (
TruncateWithFullWords TruncateOption = 1 << 1
)
// Truncate truncates s to n characters.
// Truncate truncates s to n runes.
// Additional behaviors can be specified using TruncateOptions.
func Truncate(s string, n int, opts ...TruncateOption) string {
var options TruncateOption
@@ -63,7 +64,8 @@ func Truncate(s string, n int, opts ...TruncateOption) string {
if n < 1 {
return ""
}
if len(s) <= n {
runes := []rune(s)
if len(runes) <= n {
return s
}
@@ -72,18 +74,18 @@ func Truncate(s string, n int, opts ...TruncateOption) string {
maxLen--
}
var sb strings.Builder
// If we need to truncate to full words, find the last word boundary before n.
if options&TruncateWithFullWords != 0 {
lastWordBoundary := strings.LastIndexFunc(s[:maxLen], unicode.IsSpace)
// Convert the rune-safe prefix to a string, then find
// the last word boundary (byte offset within that prefix).
truncated := string(runes[:maxLen])
lastWordBoundary := strings.LastIndexFunc(truncated, unicode.IsSpace)
if lastWordBoundary < 0 {
// We cannot find a word boundary. At this point, we'll truncate the string.
// It's better than nothing.
_, _ = sb.WriteString(s[:maxLen])
} else { // lastWordBoundary <= maxLen
_, _ = sb.WriteString(s[:lastWordBoundary])
_, _ = sb.WriteString(truncated)
} else {
_, _ = sb.WriteString(truncated[:lastWordBoundary])
}
} else {
_, _ = sb.WriteString(s[:maxLen])
_, _ = sb.WriteString(string(runes[:maxLen]))
}
if options&TruncateWithEllipsis != 0 {
@@ -126,3 +128,13 @@ func UISanitize(in string) string {
}
return strings.TrimSpace(b.String())
}
// Capitalize returns s with its first rune upper-cased. It is safe for
// multi-byte UTF-8 characters, unlike naive byte-slicing approaches.
func Capitalize(s string) string {
r, size := utf8.DecodeRuneInString(s)
if size == 0 {
return s
}
return string(unicode.ToUpper(r)) + s[size:]
}
+32
View File
@@ -57,6 +57,17 @@ func TestTruncate(t *testing.T) {
{"foo bar", 1, "…", []strings.TruncateOption{strings.TruncateWithFullWords, strings.TruncateWithEllipsis}},
{"foo bar", 0, "", []strings.TruncateOption{strings.TruncateWithFullWords, strings.TruncateWithEllipsis}},
{"This is a very long task prompt that should be truncated to 160 characters. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", 160, "This is a very long task prompt that should be truncated to 160 characters. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor…", []strings.TruncateOption{strings.TruncateWithFullWords, strings.TruncateWithEllipsis}},
// Multi-byte rune handling.
{"日本語テスト", 3, "日本語", nil},
{"日本語テスト", 4, "日本語テ", nil},
{"日本語テスト", 6, "日本語テスト", nil},
{"日本語テスト", 4, "日本語…", []strings.TruncateOption{strings.TruncateWithEllipsis}},
{"🎉🎊🎈🎁", 2, "🎉🎊", nil},
{"🎉🎊🎈🎁", 3, "🎉🎊…", []strings.TruncateOption{strings.TruncateWithEllipsis}},
// Multi-byte with full-word truncation.
{"hello 日本語", 7, "hello…", []strings.TruncateOption{strings.TruncateWithFullWords, strings.TruncateWithEllipsis}},
{"hello 日本語", 8, "hello 日…", []strings.TruncateOption{strings.TruncateWithEllipsis}},
{"日本語 テスト", 4, "日本語", []strings.TruncateOption{strings.TruncateWithFullWords}},
} {
tName := fmt.Sprintf("%s_%d", tt.s, tt.n)
for _, opt := range tt.options {
@@ -107,3 +118,24 @@ func TestUISanitize(t *testing.T) {
})
}
}
func TestCapitalize(t *testing.T) {
t.Parallel()
tests := []struct {
input string
expected string
}{
{"", ""},
{"hello", "Hello"},
{"über", "Über"},
{"Hello", "Hello"},
{"a", "A"},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%q", tt.input), func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.expected, strings.Capitalize(tt.input))
})
}
}
+4
View File
@@ -5572,6 +5572,10 @@ func TestWorkspaceSharingDisabled(t *testing.T) {
})
t.Run("NoAccessWhenDisabled", func(t *testing.T) {
t.Cleanup(func() {
rbac.ReloadBuiltinRoles(nil)
})
var (
client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{
DeploymentValues: coderdtest.DeploymentValues(t, func(dv *codersdk.DeploymentValues) {
+4 -19
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"sync"
"time"
@@ -321,21 +320,15 @@ func (c *Client) connectRPCVersion(ctx context.Context, version *apiversion.APIV
}
rpcURL.RawQuery = q.Encode()
jar, err := cookiejar.New(nil)
if err != nil {
return nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(rpcURL, []*http.Cookie{{
Name: codersdk.SessionTokenCookie,
Value: c.SDK.SessionToken(),
}})
httpClient := &http.Client{
Jar: jar,
Transport: c.SDK.HTTPClient.Transport,
}
// nolint:bodyclose
conn, res, err := websocket.Dial(ctx, rpcURL.String(), &websocket.DialOptions{
HTTPClient: httpClient,
HTTPHeader: http.Header{
codersdk.SessionTokenHeader: []string{c.SDK.SessionToken()},
},
})
if err != nil {
if res == nil {
@@ -709,16 +702,7 @@ func (c *Client) WaitForReinit(ctx context.Context) (*ReinitializationEvent, err
return nil, xerrors.Errorf("parse url: %w", err)
}
jar, err := cookiejar.New(nil)
if err != nil {
return nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(rpcURL, []*http.Cookie{{
Name: codersdk.SessionTokenCookie,
Value: c.SDK.SessionToken(),
}})
httpClient := &http.Client{
Jar: jar,
Transport: c.SDK.HTTPClient.Transport,
}
@@ -726,6 +710,7 @@ func (c *Client) WaitForReinit(ctx context.Context) (*ReinitializationEvent, err
if err != nil {
return nil, xerrors.Errorf("build request: %w", err)
}
req.Header[codersdk.SessionTokenHeader] = []string{c.SDK.SessionToken()}
res, err := httpClient.Do(req)
if err != nil {
+6 -21
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"slices"
"strings"
"time"
@@ -239,20 +238,14 @@ func (c *Client) provisionerJobLogsAfter(ctx context.Context, path string, after
if err != nil {
return nil, nil, err
}
jar, err := cookiejar.New(nil)
if err != nil {
return nil, nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(followURL, []*http.Cookie{{
Name: SessionTokenCookie,
Value: c.SessionToken(),
}})
httpClient := &http.Client{
Jar: jar,
Transport: c.HTTPClient.Transport,
}
conn, res, err := websocket.Dial(ctx, followURL.String(), &websocket.DialOptions{
HTTPClient: httpClient,
HTTPClient: httpClient,
HTTPHeader: http.Header{
SessionTokenHeader: []string{c.SessionToken()},
},
CompressionMode: websocket.CompressionDisabled,
})
if err != nil {
@@ -325,16 +318,8 @@ func (c *Client) ServeProvisionerDaemon(ctx context.Context, req ServeProvisione
headers.Set(ProvisionerDaemonPSK, req.PreSharedKey)
}
if req.ProvisionerKey == "" && req.PreSharedKey == "" {
// use session token if we don't have a PSK or provisioner key.
jar, err := cookiejar.New(nil)
if err != nil {
return nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(serverURL, []*http.Cookie{{
Name: SessionTokenCookie,
Value: c.SessionToken(),
}})
httpClient.Jar = jar
// Use session token if we don't have a PSK or provisioner key.
headers.Set(SessionTokenHeader, c.SessionToken())
}
conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{
+7 -22
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"strings"
"time"
@@ -580,24 +579,16 @@ func (c *Client) WatchWorkspaceAgentContainers(ctx context.Context, agentID uuid
return nil, nil, err
}
jar, err := cookiejar.New(nil)
if err != nil {
return nil, nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(reqURL, []*http.Cookie{{
Name: SessionTokenCookie,
Value: c.SessionToken(),
}})
conn, res, err := websocket.Dial(ctx, reqURL.String(), &websocket.DialOptions{
// We want `NoContextTakeover` compression to balance improving
// bandwidth cost/latency with minimal memory usage overhead.
CompressionMode: websocket.CompressionNoContextTakeover,
HTTPClient: &http.Client{
Jar: jar,
Transport: c.HTTPClient.Transport,
},
HTTPHeader: http.Header{
SessionTokenHeader: []string{c.SessionToken()},
},
})
if err != nil {
if res == nil {
@@ -687,20 +678,14 @@ func (c *Client) WorkspaceAgentLogsAfter(ctx context.Context, agentID uuid.UUID,
return ch, closeFunc(func() error { return nil }), nil
}
jar, err := cookiejar.New(nil)
if err != nil {
return nil, nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(reqURL, []*http.Cookie{{
Name: SessionTokenCookie,
Value: c.SessionToken(),
}})
httpClient := &http.Client{
Jar: jar,
Transport: c.HTTPClient.Transport,
}
conn, res, err := websocket.Dial(ctx, reqURL.String(), &websocket.DialOptions{
HTTPClient: httpClient,
HTTPClient: httpClient,
HTTPHeader: http.Header{
SessionTokenHeader: []string{c.SessionToken()},
},
CompressionMode: websocket.CompressionDisabled,
})
if err != nil {
+13 -17
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"net"
"net/http"
"net/http/cookiejar"
"net/netip"
"os"
"strconv"
@@ -363,26 +362,23 @@ func (c *Client) AgentReconnectingPTY(ctx context.Context, opts WorkspaceAgentRe
}
serverURL.RawQuery = q.Encode()
// If we're not using a signed token, we need to set the session token as a
// cookie.
httpClient := c.client.HTTPClient
// Shallow-clone the HTTP client so we never inherit a caller-provided
// cookie jar. Non-browser websocket auth uses the Coder-Session-Token
// header or a signed-token query param — never cookies. A stale jar
// cookie would take precedence on the server (cookies are checked
// before headers) and cause spurious 401s.
wsHTTPClient := *c.client.HTTPClient
wsHTTPClient.Jar = nil
headers := http.Header{}
// If we're not using a signed token, set the session token header.
if opts.SignedToken == "" {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(serverURL, []*http.Cookie{{
Name: codersdk.SessionTokenCookie,
Value: c.client.SessionToken(),
}})
httpClient = &http.Client{
Jar: jar,
Transport: c.client.HTTPClient.Transport,
}
headers.Set(codersdk.SessionTokenHeader, c.client.SessionToken())
}
//nolint:bodyclose
conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{
HTTPClient: httpClient,
HTTPClient: &wsHTTPClient,
HTTPHeader: headers,
})
if err != nil {
if res == nil {
+15
View File
@@ -16,6 +16,21 @@ deploy_branch=main
# Determine the current branch name and check that it is one of the supported
# branch names.
branch_name=$(git branch --show-current)
# --- BEGIN TEMPORARY SHORT-CIRCUIT ---
# Forces deployment of main. Remove after 2026-03-04T12:00Z.
if [[ "$branch_name" == "main" ]]; then
log "TEMPORARY SHORT-CIRCUIT: deploying main"
log "VERDICT: DEPLOY"
echo "DEPLOY"
exit 0
else
log "VERDICT: DO NOT DEPLOY"
echo "NOOP"
exit 0
fi
# --- END TEMPORARY SHORT-CIRCUIT ---
if [[ "$branch_name" != "main" && ! "$branch_name" =~ ^release/[0-9]+\.[0-9]+$ ]]; then
error "Current branch '$branch_name' is not a supported branch name for dogfood, must be 'main' or 'release/x.y'"
fi
+1 -1
View File
@@ -248,7 +248,7 @@ export const patchRoleSyncSettings = (
};
};
const getWorkspaceSharingSettingsKey = (organization: string) => [
export const getWorkspaceSharingSettingsKey = (organization: string) => [
"organization",
organization,
"workspaceSharingSettings",
+1 -1
View File
@@ -479,7 +479,7 @@ export const workspacePermissions = (workspace?: Workspace) => {
checks: workspace ? workspaceChecks(workspace) : {},
}),
queryKey: ["workspaces", workspace?.id, "permissions"],
enabled: !!workspace,
enabled: Boolean(workspace),
staleTime: Number.POSITIVE_INFINITY,
};
};
@@ -1,3 +1,4 @@
import { workspaceSharingSettings } from "api/queries/organizations";
import type {
Group,
WorkspaceACL,
@@ -37,6 +38,7 @@ import { TableLoader } from "components/TableLoader/TableLoader";
import { EllipsisVertical, UserPlusIcon } from "lucide-react";
import { getGroupSubtitle } from "modules/groups";
import type { FC, ReactNode } from "react";
import { useQuery } from "react-query";
interface RoleSelectProps {
value: WorkspaceRole;
@@ -139,6 +141,7 @@ export const RoleSelectField: FC<RoleSelectFieldProps> = ({
};
interface WorkspaceSharingFormProps {
organizationId: string;
workspaceACL: WorkspaceACL | undefined;
canUpdatePermissions: boolean;
isTaskWorkspace: boolean;
@@ -155,6 +158,7 @@ interface WorkspaceSharingFormProps {
}
export const WorkspaceSharingForm: FC<WorkspaceSharingFormProps> = ({
organizationId,
workspaceACL,
canUpdatePermissions,
isTaskWorkspace,
@@ -169,6 +173,46 @@ export const WorkspaceSharingForm: FC<WorkspaceSharingFormProps> = ({
isCompact,
showRestartWarning,
}) => {
const sharingSettingsQuery = useQuery(
workspaceSharingSettings(organizationId),
);
if (sharingSettingsQuery.isLoading) {
return (
<TableBody>
<TableLoader />
</TableBody>
);
}
if (!sharingSettingsQuery.data) {
return (
<TableBody>
<TableRow>
<TableCell colSpan={999}>
<ErrorAlert error={sharingSettingsQuery.error} />
</TableCell>
</TableRow>
</TableBody>
);
}
if (sharingSettingsQuery.data.sharing_disabled) {
return (
<TableBody>
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message="This workspace cannot be shared"
description="Workspace sharing has been disabled for this organization."
isCompact={isCompact}
/>
</TableCell>
</TableRow>
</TableBody>
);
}
const isEmpty = Boolean(
workspaceACL &&
workspaceACL.users.length === 0 &&
+8 -8
View File
@@ -10,6 +10,14 @@ export const workspaceChecks = (workspace: Workspace) =>
},
action: "read",
},
shareWorkspace: {
object: {
resource_type: "workspace",
resource_id: workspace.id,
owner_id: workspace.owner_id,
},
action: "share",
},
updateWorkspace: {
object: {
resource_type: "workspace",
@@ -34,14 +42,6 @@ export const workspaceChecks = (workspace: Workspace) =>
},
action: "update",
},
// To run a build in debug mode we need to be able to read the deployment
// config (enable_terraform_debug_mode).
deploymentConfig: {
object: {
resource_type: "deployment_config",
},
action: "read",
},
}) satisfies Record<string, AuthorizationCheck>;
export type WorkspacePermissions = Record<
@@ -24,9 +24,9 @@ const createTimestamp = (
const permissions: WorkspacePermissions = {
readWorkspace: true,
shareWorkspace: true,
updateWorkspace: true,
updateWorkspaceVersion: true,
deploymentConfig: true,
deleteFailedWorkspace: true,
};
@@ -57,7 +57,6 @@ export const Workspace: FC<WorkspaceProps> = ({
latestVersion,
permissions,
timings,
sharingDisabled,
handleStart,
handleStop,
handleRestart,
@@ -111,7 +110,6 @@ export const Workspace: FC<WorkspaceProps> = ({
latestVersion={latestVersion}
isUpdating={isUpdating}
isRestarting={isRestarting}
sharingDisabled={sharingDisabled}
handleStart={handleStart}
handleStop={handleStop}
handleRestart={handleRestart}
@@ -38,6 +38,7 @@ export const ShareButton: FC<ShareButtonProps> = ({
<FeatureStageBadge contentType="beta" size="sm" />
</div>
<WorkspaceSharingForm
organizationId={workspace.organization_id}
workspaceACL={sharing.workspaceACL}
canUpdatePermissions={canUpdatePermissions}
isTaskWorkspace={Boolean(workspace.task_id)}
@@ -16,11 +16,11 @@ const meta: Meta<typeof WorkspaceActions> = {
args: {
isUpdating: false,
permissions: {
deleteFailedWorkspace: true,
deploymentConfig: true,
readWorkspace: true,
shareWorkspace: true,
updateWorkspace: true,
updateWorkspaceVersion: true,
deleteFailedWorkspace: true,
},
},
decorators: [withDashboardProvider, withDesktopViewport, withAuthProvider],
@@ -172,11 +172,11 @@ export const FailedWithDebug: Story = {
args: {
workspace: Mocks.MockFailedWorkspace,
permissions: {
deploymentConfig: true,
deleteFailedWorkspace: true,
readWorkspace: true,
shareWorkspace: true,
updateWorkspace: true,
updateWorkspaceVersion: true,
deleteFailedWorkspace: true,
},
},
};
@@ -29,7 +29,6 @@ interface WorkspaceActionsProps {
isUpdating: boolean;
isRestarting: boolean;
permissions: WorkspacePermissions;
sharingDisabled?: boolean;
handleToggleFavorite: () => void;
handleStart: (buildParameters?: WorkspaceBuildParameter[]) => void;
handleStop: () => void;
@@ -46,7 +45,6 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
isUpdating,
isRestarting,
permissions,
sharingDisabled,
handleToggleFavorite,
handleStart,
handleStop,
@@ -57,10 +55,13 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
handleDebug,
handleDormantActivate,
}) => {
const { user } = useAuthenticated();
const {
permissions: { viewDeploymentConfig },
user,
} = useAuthenticated();
const { data: deployment } = useQuery({
...deploymentConfig(),
enabled: permissions.deploymentConfig,
enabled: viewDeploymentConfig,
});
const { actions, canCancel, canAcceptJobs } = abilitiesByWorkspaceStatus(
workspace,
@@ -191,7 +192,7 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
onToggle={handleToggleFavorite}
/>
{!sharingDisabled && (
{permissions.shareWorkspace && (
<ShareButton
workspace={workspace}
canUpdatePermissions={permissions.updateWorkspace}
@@ -14,9 +14,9 @@ import { WorkspaceNotifications } from "./WorkspaceNotifications";
export const defaultPermissions: WorkspacePermissions = {
readWorkspace: true,
updateWorkspaceVersion: true,
shareWorkspace: true,
updateWorkspace: true,
deploymentConfig: true,
updateWorkspaceVersion: true,
deleteFailedWorkspace: true,
};
@@ -125,11 +125,11 @@ describe("WorkspacePage", () => {
server.use(
http.post("/api/v2/authcheck", async () => {
const permissions: WorkspacePermissions = {
deleteFailedWorkspace: true,
deploymentConfig: true,
readWorkspace: true,
shareWorkspace: true,
updateWorkspace: true,
updateWorkspaceVersion: true,
deleteFailedWorkspace: true,
};
return HttpResponse.json(permissions);
}),
@@ -1,5 +1,4 @@
import { watchWorkspace } from "api/api";
import { workspaceSharingSettings } from "api/queries/organizations";
import { template as templateQueryOptions } from "api/queries/templates";
import { workspaceBuildsKey } from "api/queries/workspaceBuilds";
import {
@@ -45,12 +44,6 @@ const WorkspacePage: FC = () => {
const permissionsQuery = useQuery(workspacePermissions(workspace));
const permissions = permissionsQuery.data;
const sharingSettingsQuery = useQuery({
...workspaceSharingSettings(workspace?.organization_id ?? ""),
enabled: !!workspace,
});
const sharingDisabled = sharingSettingsQuery.data?.sharing_disabled ?? false;
// Watch workspace changes
const updateWorkspaceData = useEffectEvent(
async (newWorkspaceData: Workspace) => {
@@ -121,7 +114,6 @@ const WorkspacePage: FC = () => {
workspace={workspace}
template={template}
permissions={permissions}
sharingDisabled={sharingDisabled}
/>
);
};
@@ -34,14 +34,12 @@ interface WorkspaceReadyPageProps {
template: TypesGen.Template;
workspace: TypesGen.Workspace;
permissions: WorkspacePermissions;
sharingDisabled?: boolean;
}
export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
workspace,
template,
permissions,
sharingDisabled,
}) => {
const queryClient = useQueryClient();
@@ -285,7 +283,6 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
template={template}
buildLogs={buildLogs}
timings={timingsQuery.data}
sharingDisabled={sharingDisabled}
handleStart={async (buildParameters) => {
const { hasEphemeral, ephemeralParameters } =
await checkEphemeralParameters(buildParameters);
@@ -35,9 +35,9 @@ const meta: Meta<typeof WorkspaceTopbar> = {
latestVersion: MockTemplateVersion,
permissions: {
readWorkspace: true,
updateWorkspaceVersion: true,
shareWorkspace: true,
updateWorkspace: true,
deploymentConfig: true,
updateWorkspaceVersion: true,
deleteFailedWorkspace: true,
},
},
@@ -44,7 +44,6 @@ interface WorkspaceProps {
template: TypesGen.Template;
permissions: WorkspacePermissions;
latestVersion?: TypesGen.TemplateVersion;
sharingDisabled?: boolean;
handleStart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void;
handleStop: () => void;
handleRestart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void;
@@ -63,7 +62,6 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
permissions,
isUpdating,
isRestarting,
sharingDisabled,
handleStart,
handleStop,
handleRestart,
@@ -238,7 +236,6 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
permissions={permissions}
isUpdating={isUpdating}
isRestarting={isRestarting}
sharingDisabled={sharingDisabled}
handleStart={handleStart}
handleStop={handleStop}
handleRestart={handleRestart}
@@ -1,4 +1,3 @@
import type { Workspace } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge";
import {
@@ -12,19 +11,11 @@ import {
TimerIcon as ScheduleIcon,
Users as SharingIcon,
} from "lucide-react";
import type { FC } from "react";
import { useWorkspaceSettings } from "./WorkspaceSettingsLayout";
interface SidebarProps {
username: string;
workspace: Workspace;
sharingDisabled?: boolean;
}
export const Sidebar: React.FC = () => {
const { owner, workspace, permissions } = useWorkspaceSettings();
export const Sidebar: FC<SidebarProps> = ({
username,
workspace,
sharingDisabled,
}) => {
return (
<BaseSidebar>
<SidebarHeader
@@ -36,7 +27,7 @@ export const Sidebar: FC<SidebarProps> = ({
/>
}
title={workspace.name}
linkTo={`/@${username}/${workspace.name}`}
linkTo={`/@${owner}/${workspace.name}`}
subtitle={workspace.template_display_name ?? workspace.template_name}
/>
@@ -49,7 +40,7 @@ export const Sidebar: FC<SidebarProps> = ({
<SidebarNavItem href="schedule" icon={ScheduleIcon}>
Schedule
</SidebarNavItem>
{!sharingDisabled && (
{permissions?.shareWorkspace && (
<SidebarNavItem href="sharing" icon={SharingIcon}>
Sharing
<FeatureStageBadge contentType="beta" size="sm" />
@@ -4,7 +4,7 @@ import WorkspaceParametersPage from "./WorkspaceParametersPage";
import WorkspaceParametersPageExperimental from "./WorkspaceParametersPageExperimental";
const WorkspaceParametersExperimentRouter: FC = () => {
const workspace = useWorkspaceSettings();
const { workspace } = useWorkspaceSettings();
return (
<>
@@ -29,7 +29,7 @@ import {
} from "./WorkspaceParametersForm";
const WorkspaceParametersPage: FC = () => {
const workspace = useWorkspaceSettings();
const { workspace } = useWorkspaceSettings();
const build = workspace.latest_build;
const { data: templateVersionParameters } = useQuery(
richParameters(build.template_version_id),
@@ -33,7 +33,7 @@ import { useWorkspaceSettings } from "../WorkspaceSettingsLayout";
import { WorkspaceParametersPageViewExperimental } from "./WorkspaceParametersPageViewExperimental";
const WorkspaceParametersPageExperimental: FC = () => {
const workspace = useWorkspaceSettings();
const { workspace } = useWorkspaceSettings();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const templateVersionId = searchParams.get("templateVersionId") ?? undefined;
@@ -6,10 +6,10 @@ import {
} from "testHelpers/entities";
import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { getAuthorizationKey } from "api/queries/authCheck";
import { templateByNameKey } from "api/queries/templates";
import { workspaceByOwnerAndNameKey } from "api/queries/workspaces";
import type { Workspace } from "api/typesGenerated";
import type { WorkspacePermissions } from "modules/workspaces/permissions";
import {
reactRouterOutlet,
reactRouterParameters,
@@ -68,19 +68,14 @@ function workspaceQueries(workspace: Workspace) {
data: workspace,
},
{
key: getAuthorizationKey({
checks: {
updateWorkspace: {
object: {
resource_type: "workspace",
resource_id: MockWorkspace.id,
owner_id: MockWorkspace.owner_id,
},
action: "update",
},
},
}),
data: { updateWorkspace: true },
key: ["workspaces", workspace.id, "permissions"],
data: {
readWorkspace: true,
shareWorkspace: true,
updateWorkspace: true,
updateWorkspaceVersion: true,
deleteFailedWorkspace: true,
} satisfies WorkspacePermissions,
},
{
key: templateByNameKey(
@@ -1,5 +1,4 @@
import { API } from "api/api";
import { checkAuthorization } from "api/queries/authCheck";
import { templateByName } from "api/queries/templates";
import { workspaceByOwnerAndNameKey } from "api/queries/workspaces";
import type * as TypesGen from "api/typesGenerated";
@@ -28,28 +27,13 @@ import {
} from "./formToRequest";
import { WorkspaceScheduleForm } from "./WorkspaceScheduleForm";
const permissionsToCheck = (workspace: TypesGen.Workspace) =>
({
updateWorkspace: {
object: {
resource_type: "workspace",
resource_id: workspace.id,
owner_id: workspace.owner_id,
},
action: "update",
},
}) as const;
const WorkspaceSchedulePage: FC = () => {
const params = useParams() as { username: string; workspace: string };
const navigate = useNavigate();
const username = params.username.replace("@", "");
const workspaceName = params.workspace;
const queryClient = useQueryClient();
const workspace = useWorkspaceSettings();
const { data: permissions, error: checkPermissionsError } = useQuery(
checkAuthorization({ checks: permissionsToCheck(workspace) }),
);
const { permissions, workspace } = useWorkspaceSettings();
const { data: template, error: getTemplateError } = useQuery(
templateByName(workspace.organization_id, workspace.template_name),
);
@@ -66,8 +50,8 @@ const WorkspaceSchedulePage: FC = () => {
},
onError: () => displayError("Failed to update workspace schedule"),
});
const error = checkPermissionsError || getTemplateError;
const isLoading = !template || !permissions;
const error = getTemplateError;
const isLoading = !template;
const [isConfirmingApply, setIsConfirmingApply] = useState(false);
const { mutate: updateWorkspace } = useMutation({
@@ -1,17 +1,28 @@
import { workspaceSharingSettings } from "api/queries/organizations";
import { workspaceByOwnerAndName } from "api/queries/workspaces";
import {
workspaceByOwnerAndName,
workspacePermissions,
} from "api/queries/workspaces";
import type { Workspace } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader";
import { Margins } from "components/Margins/Margins";
import { Stack } from "components/Stack/Stack";
import type { WorkspacePermissions } from "modules/workspaces/permissions";
import { createContext, type FC, Suspense, useContext } from "react";
import { useQuery } from "react-query";
import { Outlet, useParams } from "react-router";
import { pageTitle } from "utils/page";
import { Sidebar } from "./Sidebar";
const WorkspaceSettings = createContext<Workspace | undefined>(undefined);
type WorkspaceSettingsContext = {
owner: string;
workspace: Workspace;
permissions?: WorkspacePermissions;
};
const WorkspaceSettings = createContext<WorkspaceSettingsContext | undefined>(
undefined,
);
export function useWorkspaceSettings() {
const value = useContext(WorkspaceSettings);
@@ -31,39 +42,36 @@ export const WorkspaceSettingsLayout: FC = () => {
};
const workspaceName = params.workspace;
const username = params.username.replace("@", "");
const {
data: workspace,
error,
isLoading,
isError,
} = useQuery(workspaceByOwnerAndName(username, workspaceName));
const workspaceQuery = useQuery(
workspaceByOwnerAndName(username, workspaceName),
);
const sharingSettingsQuery = useQuery({
...workspaceSharingSettings(workspace?.organization_id ?? ""),
enabled: !!workspace,
});
const sharingDisabled = sharingSettingsQuery.data?.sharing_disabled ?? false;
const permissionsQuery = useQuery(workspacePermissions(workspaceQuery.data));
if (isLoading) {
if (workspaceQuery.isLoading) {
return <Loader />;
}
const error = workspaceQuery.error || permissionsQuery.error;
return (
<>
<title>{pageTitle(workspaceName, "Settings")}</title>
<Margins>
<Stack css={{ padding: "48px 0" }} direction="row" spacing={10}>
{isError ? (
{error ? (
<ErrorAlert error={error} />
) : (
workspace && (
<WorkspaceSettings.Provider value={workspace}>
<Sidebar
workspace={workspace}
username={username}
sharingDisabled={sharingDisabled}
/>
workspaceQuery.data && (
<WorkspaceSettings.Provider
value={{
owner: username,
workspace: workspaceQuery.data,
permissions: permissionsQuery.data,
}}
>
<Sidebar />
<Suspense fallback={<Loader />}>
<main css={{ width: "100%" }}>
<Outlet />
@@ -15,7 +15,7 @@ const WorkspaceSettingsPage: FC = () => {
};
const workspaceName = params.workspace;
const username = params.username.replace("@", "");
const workspace = useWorkspaceSettings();
const { workspace } = useWorkspaceSettings();
const navigate = useNavigate();
const mutation = useMutation({
@@ -11,7 +11,7 @@ import { useWorkspaceSettings } from "../WorkspaceSettingsLayout";
import { WorkspaceSharingPageView } from "./WorkspaceSharingPageView";
const WorkspaceSharingPage: FC = () => {
const workspace = useWorkspaceSettings();
const { workspace } = useWorkspaceSettings();
const sharing = useWorkspaceSharing(workspace);
const checks = workspaceChecks(workspace);
@@ -25,7 +25,7 @@ const WorkspaceSharingPage: FC = () => {
sharing.error ?? permissionsQuery.error ?? sharing.mutationError;
return (
<div className="flex flex-col gap-12 max-w-screen-md">
<div className="flex flex-col gap-12">
<title>{pageTitle(workspace.name, "Sharing")}</title>
<header className="flex flex-col">
@@ -7,6 +7,7 @@ import {
mockApiError,
} from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { getWorkspaceSharingSettingsKey } from "api/queries/organizations";
import type {
WorkspaceACL,
WorkspaceGroup,
@@ -63,6 +64,14 @@ const aclWithUsersAndGroups: WorkspaceACL = {
const meta: Meta<typeof WorkspaceSharingPageView> = {
title: "pages/WorkspaceSharingPageView",
component: WorkspaceSharingPageView,
parameters: {
queries: [
{
key: getWorkspaceSharingSettingsKey(MockWorkspace.organization_id),
data: { sharing_disabled: false },
},
],
},
args: {
workspace: MockWorkspace,
workspaceACL: emptyACL,
@@ -52,6 +52,7 @@ export const WorkspaceSharingPageView: FC<WorkspaceSharingPageViewProps> = ({
}) => {
return (
<WorkspaceSharingForm
organizationId={workspace.organization_id}
workspaceACL={workspaceACL}
canUpdatePermissions={canUpdatePermissions}
isTaskWorkspace={Boolean(workspace.task_id)}