Skip to content

Commit 81994e6

Browse files
authored
feat(userspace): install-time permission consent dialog (#1579)
Adds the PermissionConsent dialog for userspace app installs: lists each requested capability with a plain-English description mirrored from tinyagentos/userspace/capabilities.py, flags the gated ones (app.net, app.agent, app.llm, app.memory), and Allow/Deny grants or skips them via POST /api/userspace-apps/{app_id}/permissions. Wires it into the Store's Apps tab via a new ImportAppButton that installs a .taosapp package and shows the dialog whenever the install response sets needs_consent. Docs-Reviewed: frontend-only Store components (install-consent dialog + .taosapp import button); no README app-list or route surface change, userspace install/consent tracked under App Runtime epic #196
1 parent 75532f6 commit 81994e6

6 files changed

Lines changed: 452 additions & 0 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, it, expect, vi, afterEach } from "vitest";
2+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
3+
import { ImportAppButton } from "./ImportAppButton";
4+
5+
function mockFetchSequence(responses: Array<{ url: string; body: unknown; ok?: boolean }>) {
6+
return vi.fn((url: string) => {
7+
const match = responses.find((r) => url.includes(r.url));
8+
if (!match) throw new Error(`unexpected fetch: ${url}`);
9+
return Promise.resolve({ ok: match.ok ?? true, json: async () => match.body });
10+
});
11+
}
12+
13+
describe("ImportAppButton", () => {
14+
afterEach(() => vi.unstubAllGlobals());
15+
16+
it("installing a package requesting a gated capability shows the consent dialog", async () => {
17+
const mockFetch = mockFetchSequence([
18+
{
19+
url: "/api/userspace-apps/install",
20+
body: { app_id: "todo", permissions_requested: ["app.net", "app.kv"], needs_consent: true, new_permissions: ["app.net"] },
21+
},
22+
{
23+
url: "/api/userspace-apps",
24+
body: [{ app_id: "todo", name: "Todo", icon: "", app_type: "web", version: "1", enabled: 1, permissions_requested: ["app.net", "app.kv"], permissions_granted: [] }],
25+
},
26+
]);
27+
vi.stubGlobal("fetch", mockFetch);
28+
29+
render(<ImportAppButton />);
30+
const file = new File(["data"], "todo.taosapp");
31+
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
32+
fireEvent.change(input, { target: { files: [file] } });
33+
34+
const dialog = await screen.findByRole("dialog", { name: /permissions for todo/i });
35+
expect(dialog).toBeInTheDocument();
36+
expect(screen.getByText(/connect to the internet/i)).toBeInTheDocument();
37+
});
38+
39+
it("installing a package with no new permissions never shows the dialog", async () => {
40+
const mockFetch = mockFetchSequence([
41+
{
42+
url: "/api/userspace-apps/install",
43+
body: { app_id: "todo", permissions_requested: ["app.kv"], needs_consent: false, new_permissions: [] },
44+
},
45+
{ url: "/api/userspace-apps", body: [] },
46+
]);
47+
vi.stubGlobal("fetch", mockFetch);
48+
49+
render(<ImportAppButton />);
50+
const file = new File(["data"], "todo.taosapp");
51+
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
52+
fireEvent.change(input, { target: { files: [file] } });
53+
54+
await waitFor(() => expect(mockFetch).toHaveBeenCalled());
55+
expect(screen.queryByRole("dialog")).toBeNull();
56+
});
57+
58+
it("Deny in the dialog dismisses it without granting", async () => {
59+
const mockFetch = mockFetchSequence([
60+
{
61+
url: "/api/userspace-apps/install",
62+
body: { app_id: "todo", permissions_requested: ["app.net"], needs_consent: true, new_permissions: ["app.net"] },
63+
},
64+
{
65+
url: "/api/userspace-apps",
66+
body: [{ app_id: "todo", name: "Todo", icon: "", app_type: "web", version: "1", enabled: 1, permissions_requested: ["app.net"], permissions_granted: [] }],
67+
},
68+
]);
69+
vi.stubGlobal("fetch", mockFetch);
70+
71+
render(<ImportAppButton />);
72+
const file = new File(["data"], "todo.taosapp");
73+
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
74+
fireEvent.change(input, { target: { files: [file] } });
75+
76+
await screen.findByRole("dialog");
77+
fireEvent.click(screen.getByRole("button", { name: /deny/i }));
78+
expect(screen.queryByRole("dialog")).toBeNull();
79+
expect(mockFetch).not.toHaveBeenCalledWith(expect.stringContaining("/permissions"), expect.anything());
80+
});
81+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { useCallback, useRef, useState } from "react";
2+
import { Upload, Loader2 } from "lucide-react";
3+
import {
4+
installUserspaceApp,
5+
fetchUserspaceAppRow,
6+
USERSPACE_APPS_CHANGED,
7+
type InstallResult,
8+
} from "@/lib/userspace-apps";
9+
import { emitAppEvent } from "@/lib/app-event-bus";
10+
import { PermissionConsent } from "./PermissionConsent";
11+
12+
/** Import a .taosapp package from disk and install it as a userspace app.
13+
* When the install requests new permissions, the consent dialog shows before
14+
* anything sensitive is granted -- the app itself is already installed at
15+
* that point (with only its free capabilities usable) so it can never be
16+
* blocked on a decision the user hasn't made yet. */
17+
export function ImportAppButton() {
18+
const inputRef = useRef<HTMLInputElement>(null);
19+
const [busy, setBusy] = useState(false);
20+
const [error, setError] = useState<string | null>(null);
21+
const [consent, setConsent] = useState<{
22+
appId: string;
23+
appName: string;
24+
requested: string[];
25+
alreadyGranted: string[];
26+
} | null>(null);
27+
28+
const handleFile = useCallback(async (file: File) => {
29+
setBusy(true);
30+
setError(null);
31+
try {
32+
const result: InstallResult = await installUserspaceApp(file);
33+
const row = await fetchUserspaceAppRow(result.app_id);
34+
// The row is now enabled and visible regardless of consent -- only its
35+
// gated capabilities are withheld until the user answers below.
36+
emitAppEvent(USERSPACE_APPS_CHANGED);
37+
if (result.needs_consent) {
38+
setConsent({
39+
appId: result.app_id,
40+
appName: row?.name ?? result.app_id,
41+
requested: result.new_permissions,
42+
alreadyGranted: row?.permissions_granted ?? [],
43+
});
44+
}
45+
} catch (e) {
46+
setError(e instanceof Error ? e.message : "Install failed");
47+
} finally {
48+
setBusy(false);
49+
}
50+
}, []);
51+
52+
return (
53+
<>
54+
<input
55+
ref={inputRef}
56+
type="file"
57+
accept=".taosapp,.zip"
58+
className="hidden"
59+
onChange={(e) => {
60+
const file = e.target.files?.[0];
61+
e.target.value = "";
62+
if (file) void handleFile(file);
63+
}}
64+
/>
65+
<button
66+
type="button"
67+
onClick={() => inputRef.current?.click()}
68+
disabled={busy}
69+
className="flex items-center gap-1.5 h-8 px-3 rounded-lg text-[12px] font-semibold border border-shell-border text-shell-text-secondary hover:text-shell-text hover:bg-white/[0.04] transition-colors disabled:opacity-60"
70+
>
71+
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
72+
Import app package
73+
</button>
74+
{error && (
75+
<div role="alert" className="mt-2 text-[11px] text-red-300 bg-red-500/10 border border-red-500/20 rounded px-2 py-1">
76+
{error}
77+
</div>
78+
)}
79+
{consent && (
80+
<PermissionConsent
81+
appId={consent.appId}
82+
appName={consent.appName}
83+
requested={consent.requested}
84+
alreadyGranted={consent.alreadyGranted}
85+
onAllow={() => {
86+
emitAppEvent(USERSPACE_APPS_CHANGED);
87+
setConsent(null);
88+
}}
89+
onDeny={() => setConsent(null)}
90+
/>
91+
)}
92+
</>
93+
);
94+
}
95+
96+
export default ImportAppButton;
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { describe, it, expect, vi, afterEach } from "vitest";
2+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
3+
import { PermissionConsent, GATED_CAPS } from "./PermissionConsent";
4+
5+
describe("PermissionConsent", () => {
6+
afterEach(() => vi.unstubAllGlobals());
7+
8+
it("renders as a labelled dialog listing each requested capability", () => {
9+
render(
10+
<PermissionConsent
11+
appId="todo"
12+
appName="Todo"
13+
requested={["app.net", "app.kv"]}
14+
onAllow={() => {}}
15+
onDeny={() => {}}
16+
/>,
17+
);
18+
const dialog = screen.getByRole("dialog", { name: /permissions for todo/i });
19+
expect(dialog).toBeInTheDocument();
20+
expect(screen.getByText(/connect to the internet/i)).toBeInTheDocument();
21+
expect(screen.getByText(/store and read its own app data/i)).toBeInTheDocument();
22+
});
23+
24+
it("visually flags gated/sensitive capabilities but not free ones", () => {
25+
expect(GATED_CAPS.has("app.net")).toBe(true);
26+
expect(GATED_CAPS.has("app.kv")).toBe(false);
27+
28+
render(
29+
<PermissionConsent
30+
appId="todo"
31+
appName="Todo"
32+
requested={["app.net", "app.kv"]}
33+
onAllow={() => {}}
34+
onDeny={() => {}}
35+
/>,
36+
);
37+
const netRow = screen.getByText(/connect to the internet/i).closest("li")!;
38+
const kvRow = screen.getByText(/store and read its own app data/i).closest("li")!;
39+
expect(netRow.textContent).toContain("⚠");
40+
expect(kvRow.textContent).not.toContain("⚠");
41+
});
42+
43+
it("Allow POSTs the granted permissions (merged with any already granted) and calls onAllow", async () => {
44+
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
45+
vi.stubGlobal("fetch", mockFetch);
46+
const onAllow = vi.fn();
47+
48+
render(
49+
<PermissionConsent
50+
appId="todo"
51+
appName="Todo"
52+
requested={["app.net"]}
53+
alreadyGranted={["app.memory"]}
54+
onAllow={onAllow}
55+
onDeny={() => {}}
56+
/>,
57+
);
58+
fireEvent.click(screen.getByRole("button", { name: /allow/i }));
59+
60+
await waitFor(() => expect(onAllow).toHaveBeenCalled());
61+
expect(mockFetch).toHaveBeenCalledWith(
62+
"/api/userspace-apps/todo/permissions",
63+
expect.objectContaining({ method: "POST" }),
64+
);
65+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
66+
expect(new Set(body.granted)).toEqual(new Set(["app.net", "app.memory"]));
67+
expect(onAllow).toHaveBeenCalledWith(expect.arrayContaining(["app.net", "app.memory"]));
68+
});
69+
70+
it("Deny cancels without granting anything", () => {
71+
const mockFetch = vi.fn();
72+
vi.stubGlobal("fetch", mockFetch);
73+
const onDeny = vi.fn();
74+
75+
render(
76+
<PermissionConsent
77+
appId="todo"
78+
appName="Todo"
79+
requested={["app.net"]}
80+
onAllow={() => {}}
81+
onDeny={onDeny}
82+
/>,
83+
);
84+
fireEvent.click(screen.getByRole("button", { name: /deny/i }));
85+
86+
expect(onDeny).toHaveBeenCalledTimes(1);
87+
expect(mockFetch).not.toHaveBeenCalled();
88+
});
89+
90+
it("Escape key cancels", () => {
91+
const onDeny = vi.fn();
92+
render(
93+
<PermissionConsent
94+
appId="todo"
95+
appName="Todo"
96+
requested={["app.net"]}
97+
onAllow={() => {}}
98+
onDeny={onDeny}
99+
/>,
100+
);
101+
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
102+
expect(onDeny).toHaveBeenCalledTimes(1);
103+
});
104+
});

0 commit comments

Comments
 (0)