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:
Kyle Carberry
2026-03-09 16:12:04 +00:00
committed by GitHub
parent ff715c9f4c
commit 47846c0ee4
12 changed files with 342 additions and 291 deletions
+1
View File
@@ -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(),
+2
View File
@@ -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"
+122
View File
@@ -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
View File
@@ -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{}
+16 -4
View File
@@ -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;
}
+9 -3
View File
@@ -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 = (
+4 -1
View File
@@ -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 {
+6
View File
@@ -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 ||
+6 -193
View File
@@ -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);