mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(site): inject permissions and organizations metadata to eliminate loading spinners (#22741)
## Problem Two network requests were blocking the initial page render with fullscreen `<Loader fullscreen />` spinners: 1. **`POST /api/v2/authcheck`** (permissions) — blocked in `RequireAuth` via `AuthProvider.isLoading` 2. **`GET /api/v2/organizations`** — blocked in `DashboardProvider` All other bootstrap queries (`user`, `entitlements`, `appearance`, `experiments`, `build-info`, `regions`) already used server-side metadata injection via `index.html` meta tags and resolved instantly. These two did not. ## Solution Follow the existing `cachedQuery` + `<meta>` tag pattern to inject both datasets server-side: ### Server-side (`site/site.go`) - Add `Permissions` and `Organizations` fields to `htmlState` - Fetch organizations via `GetOrganizationsByUserID` in parallel with existing queries - Evaluate all `permissionChecks` using the RBAC authorizer directly - Inject results as HTML-escaped JSON into `<meta>` tags ### Frontend - Register `permissions` and `organizations` in `useEmbeddedMetadata` - Update `checkAuthorization()` to accept optional metadata and use `disabledRefetchOptions` when available - Update `organizations()` to accept optional metadata and use `cachedQuery` when available - Wire metadata through `AuthProvider` and `DashboardProvider` ### Note The Go `permissionChecks` map in `site/site.go` mirrors `site/src/modules/permissions/index.ts` and must be kept in sync.
This commit is contained in:
@@ -662,6 +662,7 @@ func New(options *Options) *API {
|
||||
api.SiteHandler, err = site.New(&site.Options{
|
||||
CacheDir: siteCacheDir,
|
||||
Database: options.Database,
|
||||
Authorizer: options.Authorizer,
|
||||
SiteFS: site.FS(),
|
||||
OAuth2Configs: oauthConfigs,
|
||||
DocsURL: options.DeploymentValues.DocsURL.String(),
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
<meta property="logo-url" content="{{ .LogoURL }}" />
|
||||
<meta property="tasks-tab-visible" content="{{ .TasksTabVisible }}" />
|
||||
<meta property="agents-tab-visible" content="{{ .AgentsTabVisible }}" />
|
||||
<meta property="permissions" content="{{ .Permissions }}" />
|
||||
<meta property="organizations" content="{{ .Organizations }}" />
|
||||
<link
|
||||
rel="alternate icon"
|
||||
type="image/png"
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"viewAllUsers": {
|
||||
"object": { "resource_type": "user" },
|
||||
"action": "read"
|
||||
},
|
||||
"updateUsers": {
|
||||
"object": { "resource_type": "user" },
|
||||
"action": "update"
|
||||
},
|
||||
"createUser": {
|
||||
"object": { "resource_type": "user" },
|
||||
"action": "create"
|
||||
},
|
||||
"createTemplates": {
|
||||
"object": { "resource_type": "template", "any_org": true },
|
||||
"action": "create"
|
||||
},
|
||||
"updateTemplates": {
|
||||
"object": { "resource_type": "template" },
|
||||
"action": "update"
|
||||
},
|
||||
"deleteTemplates": {
|
||||
"object": { "resource_type": "template" },
|
||||
"action": "delete"
|
||||
},
|
||||
"viewDeploymentConfig": {
|
||||
"object": { "resource_type": "deployment_config" },
|
||||
"action": "read"
|
||||
},
|
||||
"editDeploymentConfig": {
|
||||
"object": { "resource_type": "deployment_config" },
|
||||
"action": "update"
|
||||
},
|
||||
"viewDeploymentStats": {
|
||||
"object": { "resource_type": "deployment_stats" },
|
||||
"action": "read"
|
||||
},
|
||||
"readWorkspaceProxies": {
|
||||
"object": { "resource_type": "workspace_proxy" },
|
||||
"action": "read"
|
||||
},
|
||||
"editWorkspaceProxies": {
|
||||
"object": { "resource_type": "workspace_proxy" },
|
||||
"action": "create"
|
||||
},
|
||||
"createOrganization": {
|
||||
"object": { "resource_type": "organization" },
|
||||
"action": "create"
|
||||
},
|
||||
"viewAnyGroup": {
|
||||
"object": { "resource_type": "group" },
|
||||
"action": "read"
|
||||
},
|
||||
"createGroup": {
|
||||
"object": { "resource_type": "group" },
|
||||
"action": "create"
|
||||
},
|
||||
"viewAllLicenses": {
|
||||
"object": { "resource_type": "license" },
|
||||
"action": "read"
|
||||
},
|
||||
"viewNotificationTemplate": {
|
||||
"object": { "resource_type": "notification_template" },
|
||||
"action": "read"
|
||||
},
|
||||
"viewOrganizationIDPSyncSettings": {
|
||||
"object": { "resource_type": "idpsync_settings" },
|
||||
"action": "read"
|
||||
},
|
||||
"viewAnyMembers": {
|
||||
"object": { "resource_type": "organization_member", "any_org": true },
|
||||
"action": "read"
|
||||
},
|
||||
"editAnyGroups": {
|
||||
"object": { "resource_type": "group", "any_org": true },
|
||||
"action": "update"
|
||||
},
|
||||
"assignAnyRoles": {
|
||||
"object": { "resource_type": "assign_org_role", "any_org": true },
|
||||
"action": "assign"
|
||||
},
|
||||
"viewAnyIdpSyncSettings": {
|
||||
"object": { "resource_type": "idpsync_settings", "any_org": true },
|
||||
"action": "read"
|
||||
},
|
||||
"editAnySettings": {
|
||||
"object": { "resource_type": "organization", "any_org": true },
|
||||
"action": "update"
|
||||
},
|
||||
"viewAnyAuditLog": {
|
||||
"object": { "resource_type": "audit_log", "any_org": true },
|
||||
"action": "read"
|
||||
},
|
||||
"viewAnyConnectionLog": {
|
||||
"object": { "resource_type": "connection_log", "any_org": true },
|
||||
"action": "read"
|
||||
},
|
||||
"viewDebugInfo": {
|
||||
"object": { "resource_type": "debug_info" },
|
||||
"action": "read"
|
||||
},
|
||||
"viewAnyAIBridgeInterception": {
|
||||
"object": { "resource_type": "aibridge_interception", "any_org": true },
|
||||
"action": "read"
|
||||
},
|
||||
"createOAuth2App": {
|
||||
"object": { "resource_type": "oauth2_app" },
|
||||
"action": "create"
|
||||
},
|
||||
"editOAuth2App": {
|
||||
"object": { "resource_type": "oauth2_app" },
|
||||
"action": "update"
|
||||
},
|
||||
"deleteOAuth2App": {
|
||||
"object": { "resource_type": "oauth2_app" },
|
||||
"action": "delete"
|
||||
},
|
||||
"viewOAuth2AppSecrets": {
|
||||
"object": { "resource_type": "oauth2_app_secret" },
|
||||
"action": "read"
|
||||
}
|
||||
}
|
||||
+153
-87
@@ -36,7 +36,10 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/entitlements"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/telemetry"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
@@ -70,6 +73,7 @@ func init() {
|
||||
type Options struct {
|
||||
CacheDir string
|
||||
Database database.Store
|
||||
Authorizer rbac.Authorizer
|
||||
SiteFS fs.FS
|
||||
OAuth2Configs *httpmw.OAuth2Configs
|
||||
DocsURL string
|
||||
@@ -264,6 +268,8 @@ type htmlState struct {
|
||||
|
||||
TasksTabVisible string
|
||||
AgentsTabVisible string
|
||||
Permissions string
|
||||
Organizations string
|
||||
}
|
||||
|
||||
type csrfState struct {
|
||||
@@ -394,6 +400,7 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht
|
||||
var themePreference string
|
||||
var terminalFont string
|
||||
orgIDs := []uuid.UUID{}
|
||||
var userOrgs []database.Organization
|
||||
eg.Go(func() error {
|
||||
var err error
|
||||
user, err = h.opts.Database.GetUserByID(ctx, apiKey.UserID)
|
||||
@@ -428,100 +435,159 @@ func (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state ht
|
||||
orgIDs = memberIDs[0].OrganizationIDs
|
||||
return err
|
||||
})
|
||||
eg.Go(func() error {
|
||||
orgs, err := h.opts.Database.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{
|
||||
UserID: apiKey.UserID,
|
||||
})
|
||||
if err == nil {
|
||||
userOrgs = orgs
|
||||
}
|
||||
// Don't fail the entire group if we can't fetch orgs.
|
||||
return nil
|
||||
})
|
||||
err := eg.Wait()
|
||||
if err == nil {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
user, err := json.Marshal(db2sdk.User(user, orgIDs))
|
||||
if err == nil {
|
||||
state.User = html.EscapeString(string(user))
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
userAppearance, err := json.Marshal(codersdk.UserAppearanceSettings{
|
||||
ThemePreference: themePreference,
|
||||
TerminalFont: codersdk.TerminalFontName(terminalFont),
|
||||
})
|
||||
if err == nil {
|
||||
state.UserAppearance = html.EscapeString(string(userAppearance))
|
||||
}
|
||||
}()
|
||||
|
||||
if h.Entitlements != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
state.Entitlements = html.EscapeString(string(h.Entitlements.AsJSON()))
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
cfg, err := af.Fetch(ctx)
|
||||
if err == nil {
|
||||
appr, err := json.Marshal(cfg)
|
||||
if err == nil {
|
||||
state.Appearance = html.EscapeString(string(appr))
|
||||
state.ApplicationName = applicationNameOrDefault(cfg)
|
||||
state.LogoURL = cfg.LogoURL
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if h.RegionsFetcher != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
regions, err := h.RegionsFetcher(ctx)
|
||||
if err == nil {
|
||||
regions, err := json.Marshal(regions)
|
||||
if err == nil {
|
||||
state.Regions = html.EscapeString(string(regions))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
experiments := h.Experiments.Load()
|
||||
if experiments != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
experiments, err := json.Marshal(experiments)
|
||||
if err == nil {
|
||||
state.Experiments = html.EscapeString(string(experiments))
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
tasksTabVisible, err := json.Marshal(!h.opts.HideAITasks)
|
||||
if err == nil {
|
||||
state.TasksTabVisible = html.EscapeString(string(tasksTabVisible))
|
||||
}
|
||||
}()
|
||||
wg.Go(func() {
|
||||
agentsTabVisible := false
|
||||
if experiments != nil {
|
||||
agentsTabVisible = experiments.Enabled(codersdk.ExperimentAgents)
|
||||
}
|
||||
data, err := json.Marshal(agentsTabVisible)
|
||||
if err == nil {
|
||||
state.AgentsTabVisible = html.EscapeString(string(data))
|
||||
}
|
||||
})
|
||||
wg.Wait()
|
||||
h.populateHTMLState(ctx, &state, af, actor, user, orgIDs, userOrgs, themePreference, terminalFont)
|
||||
}
|
||||
|
||||
return execTmpl(tmpl, state)
|
||||
}
|
||||
|
||||
// populateHTMLState runs concurrent goroutines to populate all
|
||||
// authenticated user metadata in the HTML state. This is extracted
|
||||
// from renderHTMLWithState to reduce nesting complexity.
|
||||
func (h *Handler) populateHTMLState(
|
||||
ctx context.Context,
|
||||
state *htmlState,
|
||||
af appearance.Fetcher,
|
||||
actor *rbac.Subject,
|
||||
user database.User,
|
||||
orgIDs []uuid.UUID,
|
||||
userOrgs []database.Organization,
|
||||
themePreference string,
|
||||
terminalFont string,
|
||||
) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
data, err := json.Marshal(db2sdk.User(user, orgIDs))
|
||||
if err == nil {
|
||||
state.User = html.EscapeString(string(data))
|
||||
}
|
||||
})
|
||||
wg.Go(func() {
|
||||
data, err := json.Marshal(codersdk.UserAppearanceSettings{
|
||||
ThemePreference: themePreference,
|
||||
TerminalFont: codersdk.TerminalFontName(terminalFont),
|
||||
})
|
||||
if err == nil {
|
||||
state.UserAppearance = html.EscapeString(string(data))
|
||||
}
|
||||
})
|
||||
if h.Entitlements != nil {
|
||||
wg.Go(func() {
|
||||
state.Entitlements = html.EscapeString(string(h.Entitlements.AsJSON()))
|
||||
})
|
||||
}
|
||||
wg.Go(func() {
|
||||
cfg, err := af.Fetch(ctx)
|
||||
if err == nil {
|
||||
appr, err := json.Marshal(cfg)
|
||||
if err == nil {
|
||||
state.Appearance = html.EscapeString(string(appr))
|
||||
state.ApplicationName = applicationNameOrDefault(cfg)
|
||||
state.LogoURL = cfg.LogoURL
|
||||
}
|
||||
}
|
||||
})
|
||||
if h.RegionsFetcher != nil {
|
||||
wg.Go(func() {
|
||||
regions, err := h.RegionsFetcher(ctx)
|
||||
if err == nil {
|
||||
data, err := json.Marshal(regions)
|
||||
if err == nil {
|
||||
state.Regions = html.EscapeString(string(data))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
experiments := h.Experiments.Load()
|
||||
if experiments != nil {
|
||||
wg.Go(func() {
|
||||
data, err := json.Marshal(experiments)
|
||||
if err == nil {
|
||||
state.Experiments = html.EscapeString(string(data))
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Go(func() {
|
||||
data, err := json.Marshal(!h.opts.HideAITasks)
|
||||
if err == nil {
|
||||
state.TasksTabVisible = html.EscapeString(string(data))
|
||||
}
|
||||
})
|
||||
wg.Go(func() {
|
||||
agentsTabVisible := false
|
||||
if experiments != nil {
|
||||
agentsTabVisible = experiments.Enabled(codersdk.ExperimentAgents)
|
||||
}
|
||||
data, err := json.Marshal(agentsTabVisible)
|
||||
if err == nil {
|
||||
state.AgentsTabVisible = html.EscapeString(string(data))
|
||||
}
|
||||
})
|
||||
wg.Go(func() {
|
||||
sdkOrgs := slice.List(userOrgs, db2sdk.Organization)
|
||||
data, err := json.Marshal(sdkOrgs)
|
||||
if err == nil {
|
||||
state.Organizations = html.EscapeString(string(data))
|
||||
}
|
||||
})
|
||||
if h.opts.Authorizer != nil {
|
||||
wg.Go(func() {
|
||||
state.Permissions = h.renderPermissions(ctx, *actor)
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// permissionChecks is the single source of truth for site-wide
|
||||
// permission checks, shared with the TypeScript frontend via
|
||||
// permissions.json.
|
||||
//
|
||||
//go:embed permissions.json
|
||||
var permissionChecksJSON []byte
|
||||
|
||||
var permissionChecks map[string]codersdk.AuthorizationCheck
|
||||
|
||||
func init() {
|
||||
if err := json.Unmarshal(permissionChecksJSON, &permissionChecks); err != nil {
|
||||
panic("failed to parse permissions.json: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// renderPermissions checks all the site-wide permissions for the
|
||||
// given actor and returns an HTML-escaped JSON string suitable for
|
||||
// embedding in a meta tag.
|
||||
func (h *Handler) renderPermissions(ctx context.Context, actor rbac.Subject) string {
|
||||
response := make(codersdk.AuthorizationResponse)
|
||||
for k, v := range permissionChecks {
|
||||
obj := rbac.Object{
|
||||
ID: v.Object.ResourceID,
|
||||
Owner: v.Object.OwnerID,
|
||||
OrgID: v.Object.OrganizationID,
|
||||
AnyOrgOwner: v.Object.AnyOrgOwner,
|
||||
Type: string(v.Object.ResourceType),
|
||||
}
|
||||
err := h.opts.Authorizer.Authorize(ctx, actor, policy.Action(v.Action), obj)
|
||||
response[k] = err == nil
|
||||
}
|
||||
data, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return html.EscapeString(string(data))
|
||||
}
|
||||
|
||||
// noopResponseWriter is a response writer that does nothing.
|
||||
type noopResponseWriter struct{}
|
||||
|
||||
|
||||
@@ -3,17 +3,29 @@ import type {
|
||||
AuthorizationRequest,
|
||||
AuthorizationResponse,
|
||||
} from "api/typesGenerated";
|
||||
import type { MetadataState, MetadataValue } from "hooks/useEmbeddedMetadata";
|
||||
import { disabledRefetchOptions } from "./util";
|
||||
|
||||
const AUTHORIZATION_KEY = "authorization";
|
||||
|
||||
export const getAuthorizationKey = (req: AuthorizationRequest) =>
|
||||
[AUTHORIZATION_KEY, req] as const;
|
||||
|
||||
export const checkAuthorization = <TResponse extends AuthorizationResponse>(
|
||||
export function checkAuthorization<TResponse extends AuthorizationResponse>(
|
||||
req: AuthorizationRequest,
|
||||
) => {
|
||||
return {
|
||||
metadata?: MetadataState<TResponse & MetadataValue>,
|
||||
) {
|
||||
const base = {
|
||||
queryKey: getAuthorizationKey(req),
|
||||
queryFn: () => API.checkAuthorization<TResponse>(req),
|
||||
};
|
||||
};
|
||||
|
||||
if (metadata?.available) {
|
||||
return {
|
||||
...base,
|
||||
initialData: metadata.value as TResponse,
|
||||
...disabledRefetchOptions,
|
||||
};
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
import type {
|
||||
CreateOrganizationRequest,
|
||||
GroupSyncSettings,
|
||||
Organization,
|
||||
PaginatedMembersRequest,
|
||||
PaginatedMembersResponse,
|
||||
RoleSyncSettings,
|
||||
UpdateOrganizationRequest,
|
||||
} from "api/typesGenerated";
|
||||
import type { MetadataState } from "hooks/useEmbeddedMetadata";
|
||||
import type { UsePaginatedQueryOptions } from "hooks/usePaginatedQuery";
|
||||
import {
|
||||
type OrganizationPermissionName,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
} from "modules/permissions/workspaces";
|
||||
import type { QueryClient, UseQueryOptions } from "react-query";
|
||||
import { meKey } from "./users";
|
||||
import { cachedQuery } from "./util";
|
||||
|
||||
export const createOrganization = (queryClient: QueryClient) => {
|
||||
return {
|
||||
@@ -160,11 +163,14 @@ export const updateOrganizationMemberRoles = (
|
||||
|
||||
export const organizationsKey = ["organizations"] as const;
|
||||
|
||||
export const organizations = () => {
|
||||
return {
|
||||
const notAvailable = { available: false, value: undefined } as const;
|
||||
|
||||
export const organizations = (metadata?: MetadataState<Organization[]>) => {
|
||||
return cachedQuery({
|
||||
metadata: metadata ?? notAvailable,
|
||||
queryKey: organizationsKey,
|
||||
queryFn: () => API.getOrganizations(),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const getProvisionerDaemonsKey = (
|
||||
|
||||
@@ -50,7 +50,10 @@ export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const hasFirstUserQuery = useQuery(hasFirstUser(userMetadataState));
|
||||
|
||||
const permissionsQuery = useQuery({
|
||||
...checkAuthorization({ checks: permissionChecks }),
|
||||
...checkAuthorization<Permissions>(
|
||||
{ checks: permissionChecks },
|
||||
metadata.permissions,
|
||||
),
|
||||
enabled: userQuery.data !== undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
MockBuildInfo,
|
||||
MockEntitlements,
|
||||
MockExperiments,
|
||||
MockOrganization,
|
||||
MockPermissions,
|
||||
MockTasksTabVisible,
|
||||
MockUserAppearanceSettings,
|
||||
MockUserOwner,
|
||||
@@ -45,6 +47,8 @@ const mockDataForTags = {
|
||||
regions: MockRegions,
|
||||
"tasks-tab-visible": MockTasksTabVisible,
|
||||
"agents-tab-visible": MockAgentsTabVisible,
|
||||
permissions: MockPermissions,
|
||||
organizations: [MockOrganization],
|
||||
} as const satisfies Record<MetadataKey, MetadataValue>;
|
||||
|
||||
const emptyMetadata: RuntimeHtmlMetadata = {
|
||||
@@ -84,6 +88,14 @@ const emptyMetadata: RuntimeHtmlMetadata = {
|
||||
available: false,
|
||||
value: undefined,
|
||||
},
|
||||
permissions: {
|
||||
available: false,
|
||||
value: undefined,
|
||||
},
|
||||
organizations: {
|
||||
available: false,
|
||||
value: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const populatedMetadata: RuntimeHtmlMetadata = {
|
||||
@@ -123,6 +135,14 @@ const populatedMetadata: RuntimeHtmlMetadata = {
|
||||
available: true,
|
||||
value: MockAgentsTabVisible,
|
||||
},
|
||||
permissions: {
|
||||
available: true,
|
||||
value: MockPermissions,
|
||||
},
|
||||
organizations: {
|
||||
available: true,
|
||||
value: [MockOrganization],
|
||||
},
|
||||
};
|
||||
|
||||
function seedInitialMetadata(metadataKey: string): () => void {
|
||||
|
||||
@@ -3,10 +3,12 @@ import type {
|
||||
BuildInfoResponse,
|
||||
Entitlements,
|
||||
Experiment,
|
||||
Organization,
|
||||
Region,
|
||||
User,
|
||||
UserAppearanceSettings,
|
||||
} from "api/typesGenerated";
|
||||
import type { Permissions } from "modules/permissions";
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
export const DEFAULT_METADATA_KEY = "property";
|
||||
|
||||
@@ -31,6 +33,8 @@ type AvailableMetadata = Readonly<{
|
||||
"build-info": BuildInfoResponse;
|
||||
"tasks-tab-visible": boolean;
|
||||
"agents-tab-visible": boolean;
|
||||
permissions: Permissions;
|
||||
organizations: Organization[];
|
||||
}>;
|
||||
|
||||
export type MetadataKey = keyof AvailableMetadata;
|
||||
@@ -94,6 +98,8 @@ export class MetadataManager implements MetadataManagerApi {
|
||||
regions: this.registerRegionValue(),
|
||||
"tasks-tab-visible": this.registerValue<boolean>("tasks-tab-visible"),
|
||||
"agents-tab-visible": this.registerValue<boolean>("agents-tab-visible"),
|
||||
permissions: this.registerValue<Permissions>("permissions"),
|
||||
organizations: this.registerValue<Organization[]>("organizations"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const experimentsQuery = useQuery(experiments(metadata.experiments));
|
||||
const appearanceQuery = useQuery(appearance(metadata.appearance));
|
||||
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
|
||||
const organizationsQuery = useQuery(organizations());
|
||||
const organizationsQuery = useQuery(organizations(metadata.organizations));
|
||||
|
||||
const error =
|
||||
entitlementsQuery.error ||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AuthorizationCheck } from "api/typesGenerated";
|
||||
import permissionChecksData from "../../../permissions.json";
|
||||
|
||||
export type Permissions = {
|
||||
[k in PermissionName]: boolean;
|
||||
@@ -7,200 +8,12 @@ export type Permissions = {
|
||||
type PermissionName = keyof typeof permissionChecks;
|
||||
|
||||
/**
|
||||
* Site-wide permission checks
|
||||
* Site-wide permission checks, loaded from the shared
|
||||
* permissions.json that is also used by the Go backend.
|
||||
*/
|
||||
export const permissionChecks = {
|
||||
viewAllUsers: {
|
||||
object: {
|
||||
resource_type: "user",
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
updateUsers: {
|
||||
object: {
|
||||
resource_type: "user",
|
||||
},
|
||||
action: "update",
|
||||
},
|
||||
createUser: {
|
||||
object: {
|
||||
resource_type: "user",
|
||||
},
|
||||
action: "create",
|
||||
},
|
||||
createTemplates: {
|
||||
object: {
|
||||
resource_type: "template",
|
||||
any_org: true,
|
||||
},
|
||||
action: "create",
|
||||
},
|
||||
updateTemplates: {
|
||||
object: {
|
||||
resource_type: "template",
|
||||
},
|
||||
action: "update",
|
||||
},
|
||||
deleteTemplates: {
|
||||
object: {
|
||||
resource_type: "template",
|
||||
},
|
||||
action: "delete",
|
||||
},
|
||||
viewDeploymentConfig: {
|
||||
object: {
|
||||
resource_type: "deployment_config",
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
editDeploymentConfig: {
|
||||
object: {
|
||||
resource_type: "deployment_config",
|
||||
},
|
||||
action: "update",
|
||||
},
|
||||
viewDeploymentStats: {
|
||||
object: {
|
||||
resource_type: "deployment_stats",
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
readWorkspaceProxies: {
|
||||
object: {
|
||||
resource_type: "workspace_proxy",
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
editWorkspaceProxies: {
|
||||
object: {
|
||||
resource_type: "workspace_proxy",
|
||||
},
|
||||
action: "create",
|
||||
},
|
||||
createOrganization: {
|
||||
object: {
|
||||
resource_type: "organization",
|
||||
},
|
||||
action: "create",
|
||||
},
|
||||
viewAnyGroup: {
|
||||
object: {
|
||||
resource_type: "group",
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
createGroup: {
|
||||
object: {
|
||||
resource_type: "group",
|
||||
},
|
||||
action: "create",
|
||||
},
|
||||
viewAllLicenses: {
|
||||
object: {
|
||||
resource_type: "license",
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
viewNotificationTemplate: {
|
||||
object: {
|
||||
resource_type: "notification_template",
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
viewOrganizationIDPSyncSettings: {
|
||||
object: {
|
||||
resource_type: "idpsync_settings",
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
|
||||
viewAnyMembers: {
|
||||
object: {
|
||||
resource_type: "organization_member",
|
||||
any_org: true,
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
editAnyGroups: {
|
||||
object: {
|
||||
resource_type: "group",
|
||||
any_org: true,
|
||||
},
|
||||
action: "update",
|
||||
},
|
||||
assignAnyRoles: {
|
||||
object: {
|
||||
resource_type: "assign_org_role",
|
||||
any_org: true,
|
||||
},
|
||||
action: "assign",
|
||||
},
|
||||
viewAnyIdpSyncSettings: {
|
||||
object: {
|
||||
resource_type: "idpsync_settings",
|
||||
any_org: true,
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
editAnySettings: {
|
||||
object: {
|
||||
resource_type: "organization",
|
||||
any_org: true,
|
||||
},
|
||||
action: "update",
|
||||
},
|
||||
viewAnyAuditLog: {
|
||||
object: {
|
||||
resource_type: "audit_log",
|
||||
any_org: true,
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
viewAnyConnectionLog: {
|
||||
object: {
|
||||
resource_type: "connection_log",
|
||||
any_org: true,
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
viewDebugInfo: {
|
||||
object: {
|
||||
resource_type: "debug_info",
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
viewAnyAIBridgeInterception: {
|
||||
object: {
|
||||
resource_type: "aibridge_interception",
|
||||
any_org: true,
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
createOAuth2App: {
|
||||
object: {
|
||||
resource_type: "oauth2_app",
|
||||
},
|
||||
action: "create",
|
||||
},
|
||||
editOAuth2App: {
|
||||
object: {
|
||||
resource_type: "oauth2_app",
|
||||
},
|
||||
action: "update",
|
||||
},
|
||||
deleteOAuth2App: {
|
||||
object: {
|
||||
resource_type: "oauth2_app",
|
||||
},
|
||||
action: "delete",
|
||||
},
|
||||
viewOAuth2AppSecrets: {
|
||||
object: {
|
||||
resource_type: "oauth2_app_secret",
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
} as const satisfies Record<string, AuthorizationCheck>;
|
||||
export const permissionChecks =
|
||||
permissionChecksData as typeof permissionChecksData &
|
||||
Record<string, AuthorizationCheck>;
|
||||
|
||||
export const canViewDeploymentSettings = (
|
||||
permissions: Permissions | undefined,
|
||||
|
||||
@@ -15,8 +15,8 @@ const WorkspaceSharingPage: FC = () => {
|
||||
const sharing = useWorkspaceSharing(workspace);
|
||||
|
||||
const checks = workspaceChecks(workspace);
|
||||
const permissionsQuery = useQuery<WorkspacePermissions>({
|
||||
...checkAuthorization({ checks }),
|
||||
const permissionsQuery = useQuery({
|
||||
...checkAuthorization<WorkspacePermissions>({ checks }),
|
||||
});
|
||||
const permissions = permissionsQuery.data;
|
||||
const canUpdatePermissions = Boolean(permissions?.updateWorkspace);
|
||||
|
||||
Reference in New Issue
Block a user