feat(datatables): badge each data table row with the role it is reached through

Serve the caller's usable roles and the default one alongside the tree, so
the badge costs no extra round trip per data table. Shown only where naming
the role says something: a data table without permissions, or one whose only
usable role is the implicit admin, gets none.

The left-pane filter also matches schema names now.
This commit is contained in:
Diego Imbert
2026-08-25 21:00:26 +02:00
parent 983b514a5d
commit 7e5ac78447
6 changed files with 114 additions and 20 deletions
@@ -44,12 +44,12 @@ use windmill_common::workspaces::GitRepositorySettings;
#[cfg(feature = "enterprise")]
use windmill_common::workspaces::WorkspaceDeploymentUISettings;
use windmill_common::workspaces::{
check_deploy_rules, check_user_against_rule, get_datatable_resource_from_db,
get_datatable_resource_from_db_unchecked, redact_datatable_settings_for_export,
validate_dev_workspace_id, validate_fork_workspace_id, validate_workspace_name, DataTable,
DataTableCatalogResourceType, DataTableForkBehavior, DatatableAccess, ProtectionRuleKind,
ProtectionRules, ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings,
DEV_WORKSPACE_LOCK_RULE_NAME,
can_use_datatable_role, check_deploy_rules, check_user_against_rule,
get_datatable_resource_from_db, get_datatable_resource_from_db_unchecked,
redact_datatable_settings_for_export, validate_dev_workspace_id, validate_fork_workspace_id,
validate_workspace_name, DataTable, DataTableCatalogResourceType, DataTableForkBehavior,
DatatableAccess, ProtectionRuleKind, ProtectionRules, ProtectionRuleset, RuleCheckResult,
WorkspaceGitSyncSettings, ADMIN_DATATABLE_ROLE, DEV_WORKSPACE_LOCK_RULE_NAME,
};
use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType};
use windmill_common::PgDatabase;
@@ -2105,6 +2105,12 @@ struct DataTableTables {
schemas: TableListMap,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
/// The roles the caller may run this data table as. Empty when it has no
/// permissions, which is also what tells the UI it has no role to show.
#[serde(default)]
usable_roles: Vec<String>,
/// The role the caller gets without naming one.
default_role: String,
}
#[derive(Deserialize)]
@@ -2272,15 +2278,23 @@ async fn list_datatable_tables(
Path(w_id): Path<String>,
) -> JsonResult<Vec<DataTableTables>> {
let datatable_names = list_datatable_names(&db, &w_id).await?;
let mut roles = list_datatable_roles(&db, &authed, &w_id).await?;
let mut results = Vec::new();
for datatable_name in datatable_names {
let (usable_roles, default_role) = roles
.remove(&datatable_name)
.unwrap_or_else(|| (vec![], ADMIN_DATATABLE_ROLE.to_string()));
let tables = match get_datatable_tables(&db, &authed, &w_id, &datatable_name).await {
Ok(schemas) => DataTableTables { datatable_name, schemas, error: None },
Ok(schemas) => {
DataTableTables { datatable_name, schemas, error: None, usable_roles, default_role }
}
Err(e) => DataTableTables {
datatable_name,
schemas: HashMap::new(),
error: Some(e.to_string()),
usable_roles,
default_role,
},
};
results.push(tables);
@@ -2289,6 +2303,45 @@ async fn list_datatable_tables(
Ok(Json(results))
}
/// Which roles the caller may use on each data table of the workspace, and the
/// one they get by default. Read in one go: the tree lists every data table, and
/// this is config only, so it costs a single query rather than one per table.
async fn list_datatable_roles(
db: &DB,
authed: &ApiAuthed,
w_id: &str,
) -> Result<HashMap<String, (Vec<String>, String)>> {
let Some(datatables) = sqlx::query_scalar!(
"SELECT ws.datatable->'datatables' FROM workspace_settings ws WHERE ws.workspace_id = $1",
w_id
)
.fetch_optional(db)
.await?
.flatten()
.and_then(|v| serde_json::from_value::<HashMap<String, DataTable>>(v).ok()) else {
return Ok(HashMap::new());
};
let authed_ref = authed.to_authed_ref();
Ok(datatables
.into_iter()
.map(|(name, dt)| {
let info = match dt.permissions.filter(|p| p.enabled) {
Some(p) => (
p.roles
.iter()
.filter(|(_, role)| can_use_datatable_role(role, &authed_ref))
.map(|(role_name, _)| role_name.clone())
.collect(),
p.default_role().to_string(),
),
None => (vec![], ADMIN_DATATABLE_ROLE.to_string()),
};
(name, info)
})
.collect())
}
async fn get_datatable_table_schema(
authed: ApiAuthed,
Extension(db): Extension<DB>,
+9 -1
View File
@@ -32898,7 +32898,7 @@ components:
DataTableTables:
type: object
required: [datatable_name, schemas]
required: [datatable_name, schemas, default_role]
properties:
datatable_name:
type: string
@@ -32911,6 +32911,14 @@ components:
type: string
error:
type: string
usable_roles:
type: array
description: roles the caller may run this data table as, empty when it has no permissions
items:
type: string
default_role:
type: string
description: the role the caller gets without naming one
DataTableTableSchema:
type: object
+30 -5
View File
@@ -34,8 +34,9 @@
import { capitalize, onlyAlphaNumAndUnderscore, pluralize } from '$lib/utils'
import type { DbFeatures } from './apps/components/display/dbtable/dbFeatures'
import Star from './Star.svelte'
import Badge from './common/badge/Badge.svelte'
import type { Asset, DataTableTables } from '$lib/gen'
import type { DatatableRowAction } from './dbTypes'
import { ADMIN_DATATABLE_ROLE, type DatatableRowAction } from './dbTypes'
import TextInput from './text_input/TextInput.svelte'
import Checkbox from './common/checkbox/Checkbox.svelte'
@@ -70,6 +71,8 @@
datatableTree?: DataTableTables[]
datatableTreeLoading?: boolean
onSelectDatatable?: (datatable: string) => void
/** Role the manager is connected as, when it is not the default one. */
currentRole?: string
pendingCreate?: PendingCreate | undefined
/** Row-menu actions on a data table, run against that row's data table. */
onDatatableAction?: (datatable: string, action: DatatableRowAction) => void
@@ -100,6 +103,7 @@
datatableTree,
datatableTreeLoading,
onSelectDatatable,
currentRole,
pendingCreate = $bindable(undefined),
onDatatableAction,
canManageDatatable = false,
@@ -201,6 +205,18 @@
return datatableTree?.find((d) => d.datatable_name === datatable)?.error
}
/** The role a data table row is reached through, shown as a badge. Left out
* where naming it says nothing: a data table without permissions, or one whose
* single usable role is already the implicit `admin`. */
function roleOf(datatable: string): string | undefined {
const entry = datatableTree?.find((d) => d.datatable_name === datatable)
const usable = entry?.usable_roles ?? []
if (usable.length === 0 || (usable.length === 1 && usable[0] === ADMIN_DATATABLE_ROLE)) {
return undefined
}
return (datatable === currentDatatable ? currentRole : undefined) ?? entry?.default_role
}
const matchesSearch = (t: string) => t.toLowerCase().includes(search.trim().toLowerCase())
/** The tree as rendered: only nodes with a matching descendant survive a search. */
@@ -213,9 +229,14 @@
const schemas = Object.entries(schemasOf(dt))
.map(([schemaKey, tables]) => ({
schemaKey,
tables: tables.filter(matchesSearch).sort()
// A schema that matches keeps all of its tables — the search named
// the schema, so what is in it is the answer. Copy before sorting:
// `tables` belongs to the tree snapshot, which is reactive state.
tables: (matchesSearch(schemaKey) ? [...tables] : tables.filter(matchesSearch)).sort()
}))
.filter((sc) => search.trim() === '' || sc.tables.length > 0)
.filter(
(sc) => search.trim() === '' || matchesSearch(sc.schemaKey) || sc.tables.length > 0
)
schemas.sort((a, b) => a.schemaKey.localeCompare(b.schemaKey))
return { datatable: dt, schemas, error: dt ? errorOf(dt) : undefined }
})
@@ -431,7 +452,7 @@
<Splitpanes>
<Pane size={24} class="relative flex flex-col">
<div class="mx-3 mt-3 flex flex-col gap-2">
<TextInput bind:value={search} inputProps={{ placeholder: 'Search table...' }} />
<TextInput bind:value={search} inputProps={{ placeholder: 'Search table or schema...' }} />
</div>
<div class="overflow-x-clip overflow-y-auto relative mt-1.5 flex-1">
<!-- Normal mode: data table -> schema -> table, each level dropping out
@@ -461,7 +482,11 @@
/>
{/if}
<DatabaseIcon class="shrink-0" size={14} />
<span class="truncate text-ellipsis grow text-left text-xs">{root.datatable}</span>
<span class="truncate text-ellipsis text-left text-xs">{root.datatable}</span>
{#if roleOf(root.datatable) !== undefined}
<Badge small color="gray">{roleOf(root.datatable)}</Badge>
{/if}
<div class="grow"></div>
<div class="relative shrink-0 w-6 h-8 flex items-center justify-end mr-2">
<ChevronDownIcon class={rowChevronClass(dtOpen)} size={14} />
{#if !multiSelectMode}
@@ -325,6 +325,7 @@
{datatableTree}
{datatableTreeLoading}
{onSelectDatatable}
currentRole={input.type === 'database' ? input.role : undefined}
bind:pendingCreate
{onDatatableAction}
{canManageDatatable}
+4
View File
@@ -29,3 +29,7 @@ export const isDbType = (str?: string): str is DbType => !!str && dbTypes.includ
/** A row-menu action on a data table in the database manager's tree. */
export type DatatableRowAction = 'migrations' | 'roles' | 'export' | 'import'
/** The role a permissioned data table always has: the connection it resolved to
* before permissions were turned on, so it owns every object created so far. */
export const ADMIN_DATATABLE_ROLE = 'admin'
@@ -25,6 +25,7 @@
} from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { randomUUID } from '$lib/utils/uuid'
import { ADMIN_DATATABLE_ROLE } from '../dbTypes'
import { resource } from 'runed'
import { deepEqual } from 'fast-equals'
@@ -42,7 +43,6 @@
hideTrigger?: boolean
} = $props()
const ADMIN_ROLE = 'admin'
// Matches every workspace member, unlike the `all` group whose membership is
// bookkeeping that can drift.
const WILDCARD_TENANT = '*'
@@ -102,11 +102,14 @@
// since it is the one every other role is defined against.
const loaded = res.roles
.map(toEdited)
.sort((a, b) => Number(b.name === ADMIN_ROLE) - Number(a.name === ADMIN_ROLE))
.sort(
(a, b) =>
Number(b.name === ADMIN_DATATABLE_ROLE) - Number(a.name === ADMIN_DATATABLE_ROLE)
)
// A data table that has never been opted in comes back with no roles;
// showing admin straight away is what the toggle is about to create.
if (!loaded.some((r) => r.name === ADMIN_ROLE)) {
loaded.unshift({ id: randomUUID(), name: ADMIN_ROLE, tenants: [] })
if (!loaded.some((r) => r.name === ADMIN_DATATABLE_ROLE)) {
loaded.unshift({ id: randomUUID(), name: ADMIN_DATATABLE_ROLE, tenants: [] })
}
enabled = res.enabled
roles = loaded
@@ -143,7 +146,7 @@
if (defaultRoleId === id) {
// Deleting the default falls back to admin rather than leaving the save
// pointing at a role that no longer exists.
defaultRoleId = roles.find((r) => r.name === ADMIN_ROLE)?.id
defaultRoleId = roles.find((r) => r.name === ADMIN_DATATABLE_ROLE)?.id
}
}
@@ -172,7 +175,7 @@
return {
enabled,
roles: roles.map((r) => ({ name: r.name.trim(), tenants: $state.snapshot(r.tenants) })),
default_role: roles.find((r) => r.id === defaultRoleId)?.name.trim() ?? ADMIN_ROLE,
default_role: roles.find((r) => r.id === defaultRoleId)?.name.trim() ?? ADMIN_DATATABLE_ROLE,
renames: roles
.filter((r) => savedById.has(r.id) && savedById.get(r.id) !== r.name.trim())
.map((r) => ({ from: savedById.get(r.id)!, to: r.name.trim() }))
@@ -283,7 +286,7 @@
</Head>
<tbody class="divide-y bg-surface-tertiary">
{#each roles as role (role.id)}
{@const isRoot = role.name === ADMIN_ROLE}
{@const isRoot = role.name === ADMIN_DATATABLE_ROLE}
<Row>
<Cell first class="w-56 align-top">
<div class="flex flex-col gap-1">