Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f396b5a04 | |||
| 955637a79d | |||
| 85f1d70c4f | |||
| e9e438b06e | |||
| c339aa99ee |
+3
-2
@@ -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
@@ -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{},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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:]
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -248,7 +248,7 @@ export const patchRoleSyncSettings = (
|
||||
};
|
||||
};
|
||||
|
||||
const getWorkspaceSharingSettingsKey = (organization: string) => [
|
||||
export const getWorkspaceSharingSettingsKey = (organization: string) => [
|
||||
"organization",
|
||||
organization,
|
||||
"workspaceSharingSettings",
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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}
|
||||
|
||||
+2
-2
@@ -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" />
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import WorkspaceParametersPage from "./WorkspaceParametersPage";
|
||||
import WorkspaceParametersPageExperimental from "./WorkspaceParametersPageExperimental";
|
||||
|
||||
const WorkspaceParametersExperimentRouter: FC = () => {
|
||||
const workspace = useWorkspaceSettings();
|
||||
const { workspace } = useWorkspaceSettings();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
+1
-1
@@ -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),
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+9
-14
@@ -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(
|
||||
|
||||
+3
-19
@@ -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">
|
||||
|
||||
+9
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user