feat(approvals): tenant-scoped queue (no cross-tenant work-items)

Plumbed me.tenant_id through api.ping + loginAs into the store as
tenantId. Approvals.loadQueue filters /api/ea2/work-items by
tenant_id matching the signed-in user so the queue only shows
actionable rows. Probe of EA2 work-items returned 73/80 in canvas's
tenant (a0000000-...), 6 in hms_dev, 1 in hub-cnt-f56ce0 -- those
7 are now hidden.
This commit is contained in:
2026-06-14 16:01:54 +04:00
parent 224c259bfd
commit 071166cae1
4 changed files with 65 additions and 3 deletions
+56
View File
@@ -0,0 +1,56 @@
// Audit: Approvals scene loads, queue populates, hub filter works,
// selecting a row shows the active step, and clicking an action posts
// to /api/runtime/transactions/<id>/actions/<id> (real EA2 write).
import { chromium } from "playwright";
const URL = "https://canvas.flow-master.ai/";
const args = ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"];
const b = await chromium.launch({ headless: true, args });
const p = await (await b.newContext({ viewport: { width: 1440, height: 900 } })).newPage();
const writes = [];
p.on("request", (req) => {
const u = req.url();
const m = req.method();
if ((m === "POST" || m === "PUT") && /\/api\/runtime\/transactions\//.test(u)) {
writes.push({ method: m, path: u.replace("https://canvas.flow-master.ai", "") });
}
});
p.on("response", async (res) => {
if (/\/api\/runtime\/transactions\//.test(res.url()) && res.request().method() === "POST") {
const t = await res.text().catch(() => "");
console.log(`[resp ${res.status()}] ${res.request().method()} ${res.url().replace("https://canvas.flow-master.ai","")}${t.slice(0,200)}`);
}
});
await p.goto(URL, { waitUntil: "networkidle" });
await p.waitForTimeout(800);
await p.locator(".persona-chip", { hasText: /Mariana/ }).click();
await p.locator(".dev-login-btn").click();
await p.waitForSelector(".hero-actions", { timeout: 15000 });
await p.waitForFunction(() => document.querySelectorAll(".hub-chip").length >= 8, undefined, { timeout: 15000 }).catch(() => {});
await p.locator(".hub-chip", { hasText: /Approvals queue/ }).click();
await p.waitForSelector(".approvals-row", { timeout: 15000 }).catch(() => {});
await p.waitForTimeout(1500);
const rowCount = await p.locator(".approvals-row").count();
const hubChips = await p.locator(".approvals-filter-row .hub-chip").count();
console.log(`[queue] rows=${rowCount} hubFilters=${hubChips}`);
if (rowCount > 0) {
await p.locator(".approvals-row").first().click();
await p.waitForSelector(".approvals-card", { timeout: 8000 }).catch(() => {});
const stepTitle = (await p.locator(".approvals-card-title").first().textContent()) || "";
const actionCount = await p.locator(".approvals-actions .btn").count();
console.log(`[detail] step="${stepTitle.trim().slice(0,60)}" actions=${actionCount}`);
if (actionCount > 0) {
await p.locator(".approvals-actions .btn").first().click();
await p.waitForTimeout(2500);
}
}
console.log(`\n=== runtime writes during run ===`);
writes.forEach((w) => console.log(` ${w.method} ${w.path}`));
console.log(`\ntotal runtime writes: ${writes.length}`);
await b.close();
process.exit(0);
+2 -2
View File
@@ -333,10 +333,10 @@ export const api = {
}, },
/** Probe whether the backend is reachable. Never throws. */ /** Probe whether the backend is reachable. Never throws. */
async ping(signal?: AbortSignal): Promise<{ ok: boolean; reason?: string; user?: string; user_id?: string }> { async ping(signal?: AbortSignal): Promise<{ ok: boolean; reason?: string; user?: string; user_id?: string; tenant_id?: string }> {
try { try {
const me = await this.me(signal); const me = await this.me(signal);
return { ok: true, user: me.email, user_id: me.user_id }; return { ok: true, user: me.email, user_id: me.user_id, tenant_id: me.tenant_id };
} catch (e) { } catch (e) {
return { ok: false, reason: (e as Error).message }; return { ok: false, reason: (e as Error).message };
} }
+3 -1
View File
@@ -35,10 +35,12 @@ export default function Approvals() {
const [tx, setTx] = useState<RuntimeTransaction | null>(null); const [tx, setTx] = useState<RuntimeTransaction | null>(null);
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const tenantId = useApp((s) => s.tenantId);
const loadQueue = async () => { const loadQueue = async () => {
try { try {
const rows = await api.workItems(); const rows = await api.workItems();
setItems(rows.map((it) => ({ ...it, age_label: ageLabel(it) }))); const scoped = tenantId ? rows.filter((it) => (it as any).tenant_id === tenantId) : rows;
setItems(scoped.map((it) => ({ ...it, age_label: ageLabel(it) })));
} catch (err: any) { } catch (err: any) {
pushToast("err", `Queue failed: ${err.message}`); pushToast("err", `Queue failed: ${err.message}`);
} }
+4
View File
@@ -94,6 +94,7 @@ interface AppState {
actor: Actor | null; actor: Actor | null;
userEmail: string; userEmail: string;
userDisplayName: string | null; userDisplayName: string | null;
tenantId: string | null;
isAuthed: boolean; isAuthed: boolean;
setUserEmail: (e: string) => void; setUserEmail: (e: string) => void;
loginAs: (email: string, password?: string, options?: { method?: "password" | "dev" }) => Promise<void>; loginAs: (email: string, password?: string, options?: { method?: "password" | "dev" }) => Promise<void>;
@@ -141,6 +142,7 @@ async function runLiveFetch(
set({ set({
actor: { mode: "direct_user", user_id: ping.user_id }, actor: { mode: "direct_user", user_id: ping.user_id },
userEmail: ping.user ?? get().userEmail, userEmail: ping.user ?? get().userEmail,
tenantId: ping.tenant_id ?? get().tenantId,
}); });
} }
const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi(); const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi();
@@ -256,6 +258,7 @@ export const useApp = create<AppState>((set, get) => {
actor: null, actor: null,
userEmail: prefs.email ?? "dev@flow-master.ai", userEmail: prefs.email ?? "dev@flow-master.ai",
userDisplayName: null, userDisplayName: null,
tenantId: null,
isAuthed: typeof sessionStorage !== "undefined" && !!sessionStorage.getItem("fm.mc.token.v1"), isAuthed: typeof sessionStorage !== "undefined" && !!sessionStorage.getItem("fm.mc.token.v1"),
setUserEmail: (e) => { set({ userEmail: e }); persist(); }, setUserEmail: (e) => { set({ userEmail: e }); persist(); },
loginAs: async (email, password, options) => { loginAs: async (email, password, options) => {
@@ -274,6 +277,7 @@ export const useApp = create<AppState>((set, get) => {
set({ set({
actor: { mode: "direct_user", user_id: me.user_id }, actor: { mode: "direct_user", user_id: me.user_id },
userDisplayName: me.display_name ?? null, userDisplayName: me.display_name ?? null,
tenantId: me.tenant_id ?? null,
isAuthed: true, isAuthed: true,
}); });
get().pushToast("ok", `Signed in as ${me.email}`); get().pushToast("ok", `Signed in as ${me.email}`);