NocoDB Issue #13572

https://github.com/nocodb/nocodb/issues/13572 — "Only Super Admin can change database order"
BUG CONFIRMED — RED on v0.301.3
Spec exit code: 1 (1 test failed, 1 passed)
Primary assertion: expect(orderAfter).not.toEqual(orderBefore)
Root cause: Sortable.js is conditionally initialized on .nc-bases-grid only for super admins. Elevated users see the same UI but no Sortable instance exists on their grid — drag gestures are a complete no-op.

Root Cause Detail

Sortable.js instance check on .nc-bases-grid:

Object.keys(document.querySelector('.nc-bases-grid')).some(k => k.startsWith('Sortable'))

Admin: true (Sortable1776951510459 attached)
Elevated user: false (no Sortable instance — drag is a no-op)
There is no drag handle element in the modal — the entire .nc-base-node card is the drag target. The bug is that the Sortable.js library is only wired up to the grid for super admins. The API endpoint PATCH /api/v1/db/meta/projects/{id} is accessible to elevated users (returns 200 when called directly), confirming the restriction is purely frontend.

Metadata

0.301.3
2026-04-23
nocodb/nocodb:0.301.3
http://localhost:8383

Test Results

User Role Sortable on .nc-bases-grid DOM order changed PATCH calls Result
Super Admin org-level-creator,super yes yes 1 — HTTP 200
{"order": 4.5}
PASS
Elevated User org-level-creator + base owner no no 0 — no call made FAIL (bug)

DOM Order Evidence

Admin (PASS)
Before: ["Database 2", "Database 3", "Database 1", ...]
After: ["Database 3", "Database 2", "Database 1", ...]
Changed: YES
Elevated User (FAIL — bug)
Before: ["Database 3", "Database 2", "Database 1", ...]
After: ["Database 3", "Database 2", "Database 1", ...]
Changed: NO

Screenshots — Admin (Super Admin, drag works)

Admin: modal opened, bases visible
Admin: modal opened, bases visible
Admin: DURING drag (mouse down, mid-move — proof drag started)
Admin: DURING drag (mouse down, mid-move — proof drag started)
Admin: after drag — order changed
Admin: after drag — order changed

Screenshots — Elevated User (drag silently blocked)

Elevated: modal opened — same UI as admin
Elevated: modal opened — same UI as admin
Elevated: DURING drag (mouse down, mid-move — drag executed but no Sortable)
Elevated: DURING drag (mouse down, mid-move — drag executed but no Sortable)
Elevated: after drag — order UNCHANGED (bug)
Elevated: after drag — order UNCHANGED (bug)
Elevated: Playwright failure screenshot
Elevated: Playwright failure screenshot

Spec Source

/** * Spec: NocoDB issue #13572 * "Only Super Admin can change database order" * Bug version: 0.301.3 * * Root cause: Sortable.js is only initialized on .nc-bases-grid for super admins. * Elevated users (org-level-creator + base owner) see the same UI but .nc-bases-grid * has NO Sortable instance — drag gestures have no effect. * * Test 1 (admin): drag succeeds → DOM order changes → PASS * Test 2 (elevated): drag silently blocked → DOM order unchanged → FAIL (bug) * * Exit code: 1 (RED) on v0.301.3 */ const { test, expect } = require('/home/bacher/prj/prv/vlyby-kyron/playwright/node_modules/@playwright/test'); const http = require('http'); const BASE_URL = process.env.NC_BASE || 'http://localhost:8383'; const ADMIN_EMAIL = process.env.NC_ADMIN; const ELEV_EMAIL = process.env.NC_USER2; const PASSWORD = process.env.NC_APWD; // ─────────────────────────── helpers ─────────────────────────── function apiReq(method, path, body, token) { return new Promise((resolve, reject) => { const data = body ? JSON.stringify(body) : null; const urlObj = new URL(BASE_URL + path); const opts = { hostname: urlObj.hostname, port: urlObj.port || 80, path: urlObj.pathname + (urlObj.search || ''), method, headers: { 'Content-Type': 'application/json', ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}), ...(token ? { 'xc-auth': token } : {}), }, }; const req = http.request(opts, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve({ status: res.statusCode, body: JSON.parse(d) }); } catch (e) { resolve({ status: res.statusCode, body: d }); } }); }); req.on('error', reject); if (data) req.write(data); req.end(); }); } async function getToken(email) { const res = await apiReq('POST', '/api/v1/auth/user/signin', { email, password: PASSWORD }, null); if (!res.body.token) throw new Error(`Login failed for ${email}: ${JSON.stringify(res.body)}`); return res.body.token; } async function ensureBase(title, token) { const listRes = await apiReq('GET', '/api/v1/db/meta/projects/', null, token); const existing = (listRes.body.list || []).find(b => b.title === title); if (existing) return existing.id; const createRes = await apiReq('POST', '/api/v1/db/meta/projects/', { title }, token); return createRes.body.id; } async function ensureElevatedIsOwner(baseId, adminToken) { const r = await apiReq('POST', `/api/v1/db/meta/projects/${baseId}/users`, { email: ELEV_EMAIL, roles: 'owner' }, adminToken); return r; } // ─────────────────────── beforeAll setup ──────────────────────── let adminToken; let alphaBaseId; let betaBaseId; test.beforeAll(async () => { adminToken = await getToken(ADMIN_EMAIL); alphaBaseId = await ensureBase('AlphaBase', adminToken); betaBaseId = await ensureBase('BetaBase', adminToken); await ensureElevatedIsOwner(alphaBaseId, adminToken); await ensureElevatedIsOwner(betaBaseId, adminToken); console.log(`Setup: AlphaBase=${alphaBaseId} BetaBase=${betaBaseId}`); }); // ─────────────────────── page helpers ──────────────────────────── async function loginViaUI(page, email) { await page.goto(`${BASE_URL}/dashboard/#/signin`); await page.waitForSelector('input[type="email"]', { timeout: 20000 }); await page.fill('input[type="email"]', email); await page.fill('input[type="password"]', PASSWORD); await Promise.all([ page.waitForURL(url => !url.toString().includes('signin'), { timeout: 20000 }).catch(() => {}), page.click('button[type="submit"]'), ]); await page.waitForTimeout(3000); } async function dismissSurvey(page) { const skipBtn = page.locator('button', { hasText: 'Skip' }); if (await skipBtn.isVisible({ timeout: 3000 }).catch(() => false)) { await skipBtn.click(); await page.waitForTimeout(2000); } } async function openWorkspaceModal(page) { const wsBtn = page.locator('.nc-mini-sidebar-ws-item').first(); await expect(wsBtn).toBeVisible({ timeout: 15000 }); await wsBtn.click(); await page.waitForTimeout(2000); const modal = page.locator('.nc-workspace-base-list-modal'); await expect(modal).toBeVisible({ timeout: 8000 }); } /** Read DOM order of base node titles */ async function getBaseOrder(page) { return page.evaluate(() => Array.from(document.querySelectorAll('.nc-base-node')) .map(el => (el.querySelector('.truncate')?.textContent || '').trim()) .filter(Boolean) ); } /** Check whether the Sortable.js instance is attached to .nc-bases-grid */ async function hasSortableInstance(page) { return page.evaluate(() => { const grid = document.querySelector('.nc-bases-grid'); if (!grid) return false; return Object.keys(grid).some(k => k.startsWith('Sortable')); }); } /** * Drag the first visible base node toward the second. * Takes a mid-drag screenshot as proof that the drag gesture was executed. * Returns { orderBefore, orderAfter, patchCalls, sortablePresent }. */ async function dragFirstToSecond(page, screenshotBase) { const orderBefore = await getBaseOrder(page); console.log(`Order before drag: ${JSON.stringify(orderBefore)}`); const sortablePresent = await hasSortableInstance(page); console.log(`Sortable instance on .nc-bases-grid: ${sortablePresent}`); const patchCalls = []; page.on('request', req => { if (req.method() === 'PATCH' && req.url().includes('/api/v1/db/meta/projects/')) { patchCalls.push({ url: req.url().replace(BASE_URL, ''), body: req.postData() }); } }); const baseNodes = page.locator('.nc-base-node'); await expect(baseNodes.first()).toBeVisible({ timeout: 5000 }); const count = await baseNodes.count(); if (count < 2) throw new Error(`Not enough base nodes: ${count}`); const srcBox = await baseNodes.first().boundingBox(); const dstBox = await baseNodes.nth(1).boundingBox(); const srcX = srcBox.x + srcBox.width / 2; const srcY = srcBox.y + srcBox.height / 2; const dstX = dstBox.x + dstBox.width / 2; const dstY = dstBox.y + dstBox.height / 2; console.log(`Drag: (${srcX.toFixed(0)},${srcY.toFixed(0)}) → (${dstX.toFixed(0)},${dstY.toFixed(0)})`); // Drag sequence await page.mouse.move(srcX, srcY); await page.mouse.down(); await page.waitForTimeout(300); await page.mouse.move(dstX, dstY, { steps: 20 }); // Screenshot WHILE mouse is down — proves drag was executed await page.screenshot({ path: `test-results/${screenshotBase}-during-drag.png` }); await page.mouse.up(); // Wait for DOM to settle; watch for PATCH response if one fires await Promise.race([ page.waitForResponse( r => r.url().includes('/api/v1/db/meta/projects/') && r.request().method() === 'PATCH', { timeout: 3000 } ).catch(() => null), page.waitForTimeout(3000), ]); await page.waitForTimeout(500); const orderAfter = await getBaseOrder(page); console.log(`Order after drag: ${JSON.stringify(orderAfter)}`); await page.screenshot({ path: `test-results/${screenshotBase}-after-drag.png` }); return { orderBefore, orderAfter, patchCalls, sortablePresent }; } // ─────────────────────── TEST 1: Super Admin ───────────────────── test('super admin can reorder databases via drag-and-drop', async ({ page }) => { await loginViaUI(page, ADMIN_EMAIL); await dismissSurvey(page); await openWorkspaceModal(page); await page.screenshot({ path: 'test-results/spec-admin-01-modal.png' }); const { orderBefore, orderAfter, patchCalls, sortablePresent } = await dragFirstToSecond(page, 'spec-admin'); console.log(`Admin PATCH calls: ${patchCalls.length}`, JSON.stringify(patchCalls)); // Confirm Sortable IS initialized for admin (secondary, documents root cause) expect(sortablePresent, 'Admin: Sortable.js must be initialized on .nc-bases-grid').toBe(true); // PRIMARY assertion: DOM order must change after admin drag expect(orderAfter, 'Admin: DOM order of bases must change after drag').not.toEqual(orderBefore); }); // ─────────────────────── TEST 2: Elevated User ─────────────────── test('elevated user (org-level-creator + base owner) can reorder databases via drag-and-drop', async ({ page }) => { await loginViaUI(page, ELEV_EMAIL); await dismissSurvey(page); await openWorkspaceModal(page); await page.screenshot({ path: 'test-results/spec-elevated-01-modal.png' }); const { orderBefore, orderAfter, patchCalls, sortablePresent } = await dragFirstToSecond(page, 'spec-elevated'); console.log(`Elevated PATCH calls: ${patchCalls.length}`, JSON.stringify(patchCalls)); console.log(`Elevated Sortable initialized: ${sortablePresent}`); // PRIMARY assertion: DOM order MUST change after drag // BUG on v0.301.3: Sortable.js not initialized for non-super-admin → // drag has no effect → DOM order unchanged → this assertion FAILS expect(orderAfter, `Elevated user: DOM order must change after drag — BUG: Sortable.js not initialized (sortablePresent=${sortablePresent}), 0 PATCH calls fired` ).not.toEqual(orderBefore); });

Evidence JSON

{ "issue": "https://github.com/nocodb/nocodb/issues/13572", "title": "Only Super Admin can change database order", "version": "0.301.3", "reproduced": true, "verdict": "BUG_CONFIRMED", "date": "2026-04-23", "environment": { "docker_image": "nocodb/nocodb:0.301.3", "base_url": "http://localhost:8383", "os": "Linux WSL2", "playwright": "latest" }, "users": { "admin": { "email": "admin@kyron.local", "role": "org-level-creator,super", "description": "Super admin with full workspace permissions" }, "elevated": { "email": "elevated@kyron.local", "role": "org-level-creator", "description": "Elevated user with creator role, added as owner to AlphaBase and BetaBase" } }, "root_cause": "Sortable.js (drag-and-drop library) is conditionally initialized on .nc-bases-grid only for super admins. For elevated users (org-level-creator + base owner), no Sortable instance is created on the grid, so drag gestures have no effect.", "bug_mechanism": { "description": "Sortable.js instance absent for non-super-admin on .nc-bases-grid", "admin": { "sortable_instance_on_grid": true, "drag_effect": "DOM order changes", "patch_calls_fired": 1, "patch_endpoint": "/api/v1/db/meta/projects/{id}", "patch_body": "{\"order\": 4.5}", "http_status": 200 }, "elevated": { "sortable_instance_on_grid": false, "drag_effect": "No effect — drag gesture is a no-op", "patch_calls_fired": 0, "http_status": "N/A (no call made)" } }, "test_results": { "admin": { "test": "super admin can reorder databases via drag-and-drop", "result": "PASS", "order_before": [ "Database 2", "Database 3", "Database 1", "Getting Started", "AlphaBase", "BetaBase" ], "order_after": [ "Database 3", "Database 2", "Database 1", "Getting Started", "AlphaBase", "BetaBase" ], "order_changed": true }, "elevated": { "test": "elevated user (org-level-creator + base owner) can reorder databases via drag-and-drop", "result": "FAIL", "order_before": [ "Database 3", "Database 2", "Database 1", "Getting Started", "AlphaBase", "BetaBase" ], "order_after": [ "Database 3", "Database 2", "Database 1", "Getting Started", "AlphaBase", "BetaBase" ], "order_changed": false, "failure_reason": "DOM order unchanged after drag — Sortable.js not initialized for non-super-admin" } }, "spec_file": "spec.spec.js", "spec_exit_code": 1, "spec_iterations": 1, "primary_assertion": "expect(orderAfter).not.toEqual(orderBefore)", "secondary_evidence": "Sortable instance check: Object.keys(.nc-bases-grid).some(k => k.startsWith('Sortable'))", "screenshots": { "admin_modal": "test-results/spec-admin-01-modal.png", "admin_during_drag": "test-results/spec-admin-during-drag.png", "admin_after_drag": "test-results/spec-admin-after-drag.png", "elevated_modal": "test-results/spec-elevated-01-modal.png", "elevated_during_drag": "test-results/spec-elevated-during-drag.png", "elevated_after_drag": "test-results/spec-elevated-after-drag.png", "elevated_failure": "test-results/spec-elevated-failed.png" }, "api_endpoint": "PATCH /api/v1/db/meta/projects/{baseId}", "api_body_format": "{\"order\": <number>}" }

How to Reproduce

  1. Start NocoDB v0.301.3 (docker compose up)
  2. Create a super admin and an elevated user (org-level-creator + base owner)
  3. Log in as elevated user
  4. Click workspace icon in mini-sidebar → base list modal opens
  5. Drag a database card to a new position
  6. Observe: no reorder occurs (DOM order unchanged, no PATCH call, no Sortable instance on grid)
  7. Log in as super admin → same drag → order changes (Sortable.js active, PATCH fired)