mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
feat: let users choose what their API token can read over the REST API
The API token read every GET endpoint. It now reads only the scopes its user allows (search, models, dashboard, activity, collections, tags, categories, authors, images, model files, imports, everything else), set beside the MCP tools in a sectioned Token permissions dialog. Existing and new tokens get every scope, so integrations such as Homarr and holm keep working; a refused request names the scope it needs.
This commit is contained in:
1 parent
0d0eeaf7a8
commit
88949d9d8c
23 files changed
+944
-108
No files matched your search
@@ -11,6 +11,17 @@ Read [CONTRIBUTING.md](../CONTRIBUTING.md) for the workflow, commit style and co
|
||||
pull request or update one when the user explicitly asks for that, each time -- approval for one push isn't
|
||||
approval for the next.
|
||||
|
||||
## API token permissions
|
||||
|
||||
Third-party apps (dashboards, launchers such as Homarr and holm) read the REST API with a user's API token, and the
|
||||
user picks what it may read by scope in `backend/src/services/apiTokenScopes.ts`. A GET path no scope lists falls
|
||||
under "Everything else", so when you add or rename a GET endpoint that such an app would plausibly read (models,
|
||||
collections, search, dashboard data, images, files), add its path to the fitting scope, or add a scope for it with
|
||||
its translations under `profile.apiToken.rest.scopes` in every locale, a picture in `PermissionTile` (reuse the MCP
|
||||
tool's when one reads the same thing), and a row in the table in `docs/guides/api-token.md`. Scope ids are stored per
|
||||
user: never rename one, and when a new scope takes paths from an existing one, add a migration granting it to tokens
|
||||
that hold that one, so apps using them don't lose access. A new MCP tool that reads the same data should match its scope.
|
||||
|
||||
## UI
|
||||
|
||||
The UI is what Thingport is judged on, so treat it as a first-class part of every frontend change, not a
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- What the user's API token may read over the REST API, by scope. Existing tokens get every scope, so
|
||||
-- the apps using them keep reading what they could before.
|
||||
ALTER TABLE "User" ADD COLUMN "apiTokenRestScopes" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
UPDATE "User"
|
||||
SET "apiTokenRestScopes" = ARRAY[
|
||||
'search', 'models', 'dashboard', 'activity', 'collections', 'tags', 'categories', 'authors', 'images',
|
||||
'files', 'imports', 'other'
|
||||
]
|
||||
WHERE "apiTokenHash" IS NOT NULL;
|
||||
@@ -55,6 +55,8 @@ model User {
|
||||
// The MCP tools the token may call at /api/mcp (see mcp/tools.ts). Chosen per token, so a tool
|
||||
// that writes is only ever reachable if the user ticked it.
|
||||
apiTokenMcpTools String[] @default([])
|
||||
// What the token may read over the REST API, by scope (see services/apiTokenScopes.ts).
|
||||
apiTokenRestScopes String[] @default([])
|
||||
// Per-user, unlike the instance-wide Thingiverse token.
|
||||
makerworldCookie String?
|
||||
// When MakerWorld last turned the cookie down. Scheduled MakerWorld syncs wait until it changes.
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Role } from "@prisma/client";
|
||||
import { AUTH_ALGO, AUTH_SECRET } from "./config";
|
||||
import { getAuthTokenTtl } from "./services/settingsService";
|
||||
import { findApiTokenUser, isApiToken } from "./services/apiTokenService";
|
||||
import { scopeForPath } from "./services/apiTokenScopes";
|
||||
import { asyncHandler } from "./utils/asyncHandler";
|
||||
import { prisma } from "./db";
|
||||
|
||||
@@ -74,6 +75,11 @@ export const requireAuth = asyncHandler(async (req, res, next) => {
|
||||
res.status(403).json({ detail: "API tokens are read-only" });
|
||||
return;
|
||||
}
|
||||
const scope = scopeForPath(req.originalUrl.split("?")[0].replace(/^\/api(?=\/)/, ""));
|
||||
if (!user.apiTokenRestScopes.includes(scope)) {
|
||||
res.status(403).json({ detail: `This API token isn't allowed to read ${scope}`, scope });
|
||||
return;
|
||||
}
|
||||
req.userId = user.id;
|
||||
req.userRole = user.role;
|
||||
req.viaApiToken = true;
|
||||
|
||||
@@ -13,7 +13,14 @@ import { findValidInvitation } from "../services/invitationService";
|
||||
import { checkCaptcha } from "../services/captchaService";
|
||||
import { createLog } from "../services/auditLog";
|
||||
import { seedDefaultCategories } from "../services/categoryService";
|
||||
import { generateApiToken, getApiToken, revokeApiToken, setApiTokenMcpTools } from "../services/apiTokenService";
|
||||
import {
|
||||
generateApiToken,
|
||||
getApiToken,
|
||||
revokeApiToken,
|
||||
setApiTokenMcpTools,
|
||||
setApiTokenRestScopes,
|
||||
} from "../services/apiTokenService";
|
||||
import { API_TOKEN_SCOPE_IDS, apiTokenScopeCatalog } from "../services/apiTokenScopes";
|
||||
import { DEFAULT_MCP_TOOL_NAMES, MCP_TOOL_NAMES, mcpToolCatalog, mcpToolsFor } from "../mcp/tools";
|
||||
import { getMcpAccess, type McpAccess } from "../services/settingsService";
|
||||
import { toUserOut } from "../dto";
|
||||
@@ -395,9 +402,14 @@ router.patch(
|
||||
|
||||
// The API token itself is only in the response that generates it; this shows which one is in use.
|
||||
// Generating and revoking can't be done with the token itself: requireAuth lets it only read.
|
||||
/** The token with what the instance's MCP access lets it choose from. */
|
||||
/** The token with what it can be allowed: the REST scopes, and what the instance's MCP access offers. */
|
||||
function withMcpCatalog<T>(token: T, access: McpAccess) {
|
||||
return { ...token, mcp_access: access, available_mcp_tools: mcpToolCatalog(access) };
|
||||
return {
|
||||
...token,
|
||||
available_rest_scopes: apiTokenScopeCatalog(),
|
||||
mcp_access: access,
|
||||
available_mcp_tools: mcpToolCatalog(access),
|
||||
};
|
||||
}
|
||||
|
||||
/** Refuses a tool the instance's MCP access doesn't offer, rather than storing a choice that can't apply. */
|
||||
@@ -420,8 +432,14 @@ router.get(
|
||||
const mcpToolsSchema = z
|
||||
.array(z.enum(MCP_TOOL_NAMES as [string, ...string[]]))
|
||||
.transform((names) => [...new Set(names)]);
|
||||
// No body (older clients) gives the tools that only read.
|
||||
const generateApiTokenSchema = z.object({ mcp_tools: mcpToolsSchema.optional() }).default({});
|
||||
const restScopesSchema = z
|
||||
.array(z.enum(API_TOKEN_SCOPE_IDS as [string, ...string[]]))
|
||||
.transform((ids) => [...new Set(ids)]);
|
||||
// No body (older clients) gives the tools that only read, and every REST scope: a token reads all it
|
||||
// can see until its user narrows it.
|
||||
const generateApiTokenSchema = z
|
||||
.object({ mcp_tools: mcpToolsSchema.optional(), rest_scopes: restScopesSchema.optional() })
|
||||
.default({});
|
||||
|
||||
router.post(
|
||||
"/profile/api-token",
|
||||
@@ -430,9 +448,17 @@ router.post(
|
||||
const body = parseBody(generateApiTokenSchema, req.body ?? {});
|
||||
const access = await getMcpAccess();
|
||||
if (body.mcp_tools) assertMcpToolsOffered(body.mcp_tools, access);
|
||||
const token = await generateApiToken(req.userId!, body.mcp_tools ?? DEFAULT_MCP_TOOL_NAMES);
|
||||
const token = await generateApiToken(
|
||||
req.userId!,
|
||||
body.mcp_tools ?? DEFAULT_MCP_TOOL_NAMES,
|
||||
body.rest_scopes ?? API_TOKEN_SCOPE_IDS,
|
||||
);
|
||||
res.json(withMcpCatalog(token, access));
|
||||
void createLog({ userId: req.userId!, action: "api_token_generated", details: { mcp_tools: token.mcp_tools } });
|
||||
void createLog({
|
||||
userId: req.userId!,
|
||||
action: "api_token_generated",
|
||||
details: { mcp_tools: token.mcp_tools, rest_scopes: token.rest_scopes },
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -457,6 +483,21 @@ router.put(
|
||||
}),
|
||||
);
|
||||
|
||||
router.put(
|
||||
"/profile/api-token/rest-scopes",
|
||||
requireAuth,
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(z.object({ rest_scopes: restScopesSchema }), req.body);
|
||||
const token = await setApiTokenRestScopes(req.userId!, body.rest_scopes);
|
||||
res.json(withMcpCatalog(token, await getMcpAccess()));
|
||||
void createLog({
|
||||
userId: req.userId!,
|
||||
action: "api_token_rest_scopes_changed",
|
||||
details: { rest_scopes: token.rest_scopes },
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/profile/api-token",
|
||||
requireAuth,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// What an API token may read over the REST API, a scope per kind of data. A token reaches only the
|
||||
// scopes its user allowed; a path no scope lists falls under "other". Ids are stored on the user, so
|
||||
// don't rename one; split a new scope out of an existing one with a migration that grants it to
|
||||
// tokens holding that one, or the apps using them lose access.
|
||||
//
|
||||
// Keep this up to date when adding an important GET endpoint (see .claude/CLAUDE.md).
|
||||
|
||||
export type ApiTokenScope = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Paths under /api; `:name` matches one segment. */
|
||||
paths: string[];
|
||||
};
|
||||
|
||||
export const OTHER_SCOPE = "other";
|
||||
|
||||
export const API_TOKEN_SCOPES: readonly ApiTokenScope[] = [
|
||||
{
|
||||
id: "search",
|
||||
title: "Search",
|
||||
description: "Search models, collections and tags.",
|
||||
paths: ["/search"],
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
title: "Models",
|
||||
description: "List models and read their details.",
|
||||
paths: ["/prints", "/print/:id", "/print/:id/quality"],
|
||||
},
|
||||
{
|
||||
id: "dashboard",
|
||||
title: "Dashboard",
|
||||
description: "Library totals, most viewed and printed, the featured model and widgets.",
|
||||
paths: [
|
||||
"/dashboard/summary",
|
||||
"/dashboard/top-viewed",
|
||||
"/dashboard/top-printed",
|
||||
"/dashboard/top-authors",
|
||||
"/dashboard/featured",
|
||||
"/dashboard/weather",
|
||||
"/dashboard/weather/places",
|
||||
"/dashboard/weather/place-name",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "activity",
|
||||
title: "Activity",
|
||||
description: "Your activity heatmap and feed.",
|
||||
paths: ["/activity", "/activity/feed"],
|
||||
},
|
||||
{
|
||||
id: "collections",
|
||||
title: "Collections",
|
||||
description: "Your collections and what's in them.",
|
||||
paths: ["/collections", "/collection/:id", "/print/:id/collections", "/bookmarks"],
|
||||
},
|
||||
{
|
||||
id: "tags",
|
||||
title: "Tags",
|
||||
description: "Your tags and how many models have each.",
|
||||
paths: ["/tags", "/tags/summary", "/tags/bookmarked"],
|
||||
},
|
||||
{
|
||||
id: "categories",
|
||||
title: "Categories",
|
||||
description: "Your category tree.",
|
||||
paths: ["/categories"],
|
||||
},
|
||||
{
|
||||
id: "authors",
|
||||
title: "Authors",
|
||||
description: "Authors' profiles.",
|
||||
paths: ["/author/:id", "/me/author-links"],
|
||||
},
|
||||
{
|
||||
id: "images",
|
||||
title: "Images",
|
||||
description: "Thumbnails, renders and preview pictures.",
|
||||
paths: [
|
||||
"/plate/:id/thumb.jpg",
|
||||
"/plate/:id/render.png",
|
||||
"/print/:id/thumb.jpg",
|
||||
"/preview-image/:id/file.jpg",
|
||||
"/description-image/:id",
|
||||
"/tags/:tag/mosaic.webp",
|
||||
"/tags/:tag/render-mosaic.webp",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "files",
|
||||
title: "Model files",
|
||||
description: "Download model files and 3D previews.",
|
||||
paths: [
|
||||
"/print/:id/plate/:plateId/file/:filename",
|
||||
"/plate/:id/preview.glb",
|
||||
"/print/:id/files",
|
||||
"/print/:id/files/:fileId",
|
||||
"/print/:id/prepared-print",
|
||||
"/category/:id/download",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "imports",
|
||||
title: "Imports",
|
||||
description: "Progress of running imports.",
|
||||
paths: [
|
||||
"/import/status",
|
||||
"/import/tasks/:id",
|
||||
"/import/jobs/active",
|
||||
"/import/jobs/:id",
|
||||
"/import/jobs/:id/items",
|
||||
"/import/makeronline/waiting",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: OTHER_SCOPE,
|
||||
title: "Everything else",
|
||||
description: "Your settings, notifications, AI suggestions and anything not listed above.",
|
||||
paths: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const API_TOKEN_SCOPE_IDS = API_TOKEN_SCOPES.map((scope) => scope.id);
|
||||
|
||||
function pathPattern(path: string): RegExp {
|
||||
const segments = path
|
||||
.split("/")
|
||||
.map((segment) => (segment.startsWith(":") ? "[^/]+" : segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
// Case-insensitive and with an optional trailing slash, as Express routes match.
|
||||
return new RegExp(`^${segments.join("/")}/?$`, "i");
|
||||
}
|
||||
|
||||
const PATTERNS = API_TOKEN_SCOPES.flatMap((scope) =>
|
||||
scope.paths.map((path) => ({ id: scope.id, re: pathPattern(path) })),
|
||||
);
|
||||
|
||||
/** The scope a path under /api needs. */
|
||||
export function scopeForPath(path: string): string {
|
||||
return PATTERNS.find((pattern) => pattern.re.test(path))?.id ?? OTHER_SCOPE;
|
||||
}
|
||||
|
||||
/** What the profile page offers to allow. */
|
||||
export function apiTokenScopeCatalog() {
|
||||
return API_TOKEN_SCOPES.map(({ id, title, description }) => ({ id, title, description }));
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export type ApiTokenOut = {
|
||||
last_used_at: Date | null;
|
||||
/** The MCP tools the token may call; empty without a token. */
|
||||
mcp_tools: string[];
|
||||
/** What the token may read over the REST API (see apiTokenScopes.ts); empty without a token. */
|
||||
rest_scopes: string[];
|
||||
};
|
||||
|
||||
const hashApiToken = (token: string) => crypto.createHash("sha256").update(token).digest("hex");
|
||||
@@ -30,6 +32,7 @@ export async function getApiToken(userId: string): Promise<ApiTokenOut> {
|
||||
apiTokenCreatedAt: true,
|
||||
apiTokenLastUsedAt: true,
|
||||
apiTokenMcpTools: true,
|
||||
apiTokenRestScopes: true,
|
||||
},
|
||||
});
|
||||
return {
|
||||
@@ -38,12 +41,17 @@ export async function getApiToken(userId: string): Promise<ApiTokenOut> {
|
||||
created_at: user.apiTokenCreatedAt,
|
||||
last_used_at: user.apiTokenLastUsedAt,
|
||||
mcp_tools: user.apiTokenHash ? user.apiTokenMcpTools : [],
|
||||
rest_scopes: user.apiTokenHash ? user.apiTokenRestScopes : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Replaces any existing token, which stops working at once. The token is only ever returned here.
|
||||
* `mcpTools` must already be known tool names. */
|
||||
export async function generateApiToken(userId: string, mcpTools: string[]): Promise<ApiTokenOut & { token: string }> {
|
||||
* `mcpTools` and `restScopes` must already be known names. */
|
||||
export async function generateApiToken(
|
||||
userId: string,
|
||||
mcpTools: string[],
|
||||
restScopes: string[],
|
||||
): Promise<ApiTokenOut & { token: string }> {
|
||||
const token = API_TOKEN_PREFIX + crypto.randomBytes(32).toString("base64url");
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
@@ -53,6 +61,7 @@ export async function generateApiToken(userId: string, mcpTools: string[]): Prom
|
||||
apiTokenCreatedAt: new Date(),
|
||||
apiTokenLastUsedAt: null,
|
||||
apiTokenMcpTools: mcpTools,
|
||||
apiTokenRestScopes: restScopes,
|
||||
},
|
||||
});
|
||||
return {
|
||||
@@ -62,6 +71,7 @@ export async function generateApiToken(userId: string, mcpTools: string[]): Prom
|
||||
created_at: user.apiTokenCreatedAt,
|
||||
last_used_at: null,
|
||||
mcp_tools: user.apiTokenMcpTools,
|
||||
rest_scopes: user.apiTokenRestScopes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +85,16 @@ export async function setApiTokenMcpTools(userId: string, mcpTools: string[]): P
|
||||
return getApiToken(userId);
|
||||
}
|
||||
|
||||
/** Changes what the current token may read over the REST API, without replacing the token. */
|
||||
export async function setApiTokenRestScopes(userId: string, restScopes: string[]): Promise<ApiTokenOut> {
|
||||
const { count } = await prisma.user.updateMany({
|
||||
where: { id: userId, apiTokenHash: { not: null } },
|
||||
data: { apiTokenRestScopes: restScopes },
|
||||
});
|
||||
if (!count) throw new HttpError(409, "Generate an API token first");
|
||||
return getApiToken(userId);
|
||||
}
|
||||
|
||||
export async function revokeApiToken(userId: string): Promise<void> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
@@ -84,6 +104,7 @@ export async function revokeApiToken(userId: string): Promise<void> {
|
||||
apiTokenCreatedAt: null,
|
||||
apiTokenLastUsedAt: null,
|
||||
apiTokenMcpTools: [],
|
||||
apiTokenRestScopes: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -93,7 +114,7 @@ export async function findApiTokenUser(token: string) {
|
||||
const apiTokenHash = hashApiToken(token);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { apiTokenHash },
|
||||
select: { id: true, role: true, apiTokenLastUsedAt: true, apiTokenMcpTools: true },
|
||||
select: { id: true, role: true, apiTokenLastUsedAt: true, apiTokenMcpTools: true, apiTokenRestScopes: true },
|
||||
});
|
||||
if (!user) return null;
|
||||
const now = Date.now();
|
||||
|
||||
@@ -9,6 +9,7 @@ export type LogAction =
|
||||
| "api_token_generated"
|
||||
| "api_token_revoked"
|
||||
| "api_token_mcp_tools_changed"
|
||||
| "api_token_rest_scopes_changed"
|
||||
| "mcp_access_changed"
|
||||
| "user_invited"
|
||||
| "authors_linked"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeAll, describe, expect, it } from "vitest";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/app";
|
||||
import { prisma } from "../src/db";
|
||||
import { API_TOKEN_SCOPE_IDS, scopeForPath } from "../src/services/apiTokenScopes";
|
||||
|
||||
const app = createApp();
|
||||
let session: string;
|
||||
@@ -28,8 +29,16 @@ describe("API token", () => {
|
||||
it("reports no token for a new account", async () => {
|
||||
const res = await request(app).get("/api/profile/api-token").set(auth(session));
|
||||
expect(res.status).toBe(200);
|
||||
const { available_mcp_tools, mcp_access, ...token } = res.body;
|
||||
expect(token).toEqual({ configured: false, hint: null, created_at: null, last_used_at: null, mcp_tools: [] });
|
||||
const { available_mcp_tools, available_rest_scopes, mcp_access, ...token } = res.body;
|
||||
expect(token).toEqual({
|
||||
configured: false,
|
||||
hint: null,
|
||||
created_at: null,
|
||||
last_used_at: null,
|
||||
mcp_tools: [],
|
||||
rest_scopes: [],
|
||||
});
|
||||
expect(available_rest_scopes.map((scope: { id: string }) => scope.id)).toContain("search");
|
||||
expect(mcp_access).toBe("write");
|
||||
// What the profile page offers to tick, token or not.
|
||||
expect(available_mcp_tools.map((tool: { name: string }) => tool.name)).toContain("search_models");
|
||||
@@ -109,4 +118,63 @@ describe("API token", () => {
|
||||
await prisma.user.update({ where: { id: userId }, data: { role: "MEMBER" } });
|
||||
}
|
||||
});
|
||||
|
||||
it("reads every REST scope when new, and only the allowed ones once narrowed", async () => {
|
||||
const { body } = await request(app).post("/api/profile/api-token").set(auth(session));
|
||||
expect(body.rest_scopes).toEqual(API_TOKEN_SCOPE_IDS);
|
||||
|
||||
const narrowed = await request(app)
|
||||
.put("/api/profile/api-token/rest-scopes")
|
||||
.set(auth(session))
|
||||
.send({ rest_scopes: ["search"] });
|
||||
expect(narrowed.status).toBe(200);
|
||||
expect(narrowed.body.rest_scopes).toEqual(["search"]);
|
||||
|
||||
expect((await request(app).get("/api/search").query({ q: "x" }).set(auth(body.token))).status).toBe(200);
|
||||
const summary = await request(app).get("/api/dashboard/summary").set(auth(body.token));
|
||||
expect(summary.status).toBe(403);
|
||||
expect(summary.body.scope).toBe("dashboard");
|
||||
// A path no scope lists needs "everything else".
|
||||
expect((await request(app).get("/api/notifications").set(auth(body.token))).status).toBe(403);
|
||||
// The token's session-signed-in owner is unaffected.
|
||||
expect((await request(app).get("/api/dashboard/summary").set(auth(session))).status).toBe(200);
|
||||
});
|
||||
|
||||
it("refuses an unknown REST scope, and a change made with the token itself", async () => {
|
||||
const { body } = await request(app).post("/api/profile/api-token").set(auth(session));
|
||||
const unknown = await request(app)
|
||||
.put("/api/profile/api-token/rest-scopes")
|
||||
.set(auth(session))
|
||||
.send({ rest_scopes: ["everything"] });
|
||||
expect(unknown.status).toBe(400);
|
||||
const viaToken = await request(app)
|
||||
.put("/api/profile/api-token/rest-scopes")
|
||||
.set(auth(body.token))
|
||||
.send({ rest_scopes: API_TOKEN_SCOPE_IDS });
|
||||
expect(viaToken.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scopeForPath", () => {
|
||||
it("maps the paths integrations read to their scopes", () => {
|
||||
expect(scopeForPath("/search")).toBe("search");
|
||||
expect(scopeForPath("/prints")).toBe("models");
|
||||
expect(scopeForPath("/print/abc")).toBe("models");
|
||||
expect(scopeForPath("/print/abc/collections")).toBe("collections");
|
||||
expect(scopeForPath("/dashboard/featured")).toBe("dashboard");
|
||||
expect(scopeForPath("/activity")).toBe("activity");
|
||||
expect(scopeForPath("/plate/p1/thumb.jpg")).toBe("images");
|
||||
expect(scopeForPath("/preview-image/i1/file.jpg")).toBe("images");
|
||||
expect(scopeForPath("/print/m1/plate/p1/file/Benchy%20v2.stl")).toBe("files");
|
||||
expect(scopeForPath("/plate/p1/preview.glb")).toBe("files");
|
||||
expect(scopeForPath("/tags/summary")).toBe("tags");
|
||||
expect(scopeForPath("/tags/benchy/mosaic.webp")).toBe("images");
|
||||
});
|
||||
|
||||
it("puts anything unlisted under other", () => {
|
||||
expect(scopeForPath("/settings/theme")).toBe("other");
|
||||
expect(scopeForPath("/profile/api-token")).toBe("other");
|
||||
expect(scopeForPath("/searchx")).toBe("other");
|
||||
expect(scopeForPath("/plate/p1/thumbxjpg")).toBe("other");
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,30 @@ curl -H "Authorization: Bearer tp_your_token_here" "https://thingport.example.co
|
||||
The token works only in the header. Putting it in the address as `?token=...` is refused, because addresses end up in
|
||||
browser history and proxy logs.
|
||||
|
||||
## Choose what the token can read
|
||||
|
||||
A new token can read everything you can. To narrow it, click the cog next to the token to open **Token permissions**,
|
||||
and under **Permissions** set each kind of data to **Allow** or **Deny**:
|
||||
|
||||
| Permission | What it reads |
|
||||
| --------------- | --------------------------------------------------------------------------------- |
|
||||
| Search | `/api/search` |
|
||||
| Models | `/api/prints` and `/api/print/{id}` |
|
||||
| Dashboard | `/api/dashboard/...`: library totals, most viewed and printed, the featured model |
|
||||
| Activity | `/api/activity` and `/api/activity/feed` |
|
||||
| Collections | `/api/collections`, `/api/collection/{id}` and a model's collections |
|
||||
| Tags | `/api/tags` and `/api/tags/summary` |
|
||||
| Categories | `/api/categories` |
|
||||
| Authors | `/api/author/{id}` |
|
||||
| Images | Thumbnails, renders and preview pictures |
|
||||
| Model files | Model files and 3D previews (`.glb`) |
|
||||
| Imports | The progress of running imports |
|
||||
| Everything else | Any other read: your settings, notifications, AI suggestions and so on |
|
||||
|
||||
Changes apply straight away, without a new token. A request the token isn't allowed is refused with `403` and a
|
||||
`scope` field naming the permission it needs. A search widget, for example, needs only **Search**, plus **Images** if
|
||||
it shows thumbnails.
|
||||
|
||||
## Connect an AI assistant
|
||||
|
||||
The same token connects an AI assistant to your library over MCP, with the tools you allow in **Token permissions** (the cog next to the token). The tools that
|
||||
@@ -48,7 +72,7 @@ on. See [Connect an AI assistant with MCP](mcp.md).
|
||||
## What a token can and can't do
|
||||
|
||||
- **Read:** anything you can see in Thingport yourself, such as models, collections, tags, search, the dashboard and
|
||||
your activity.
|
||||
your activity, less whatever you deny it under [Permissions](#choose-what-the-token-can-read).
|
||||
- **Not write over the REST API:** any request that would add, change or delete something is refused with
|
||||
`403 API tokens are read-only`. Only the [MCP tools](mcp.md) you turn on can change your library, and none of them
|
||||
deletes or overwrites anything.
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ you open Thingport at, and the assistant signs in with your [API token](api-toke
|
||||
|
||||
1. Open the user menu (your avatar, top right) and choose **Profile**.
|
||||
2. Next to **API token**, click **Generate**, and copy the token straight away: it's shown only once.
|
||||
3. Click the cog next to the token to open **Token permissions**, and under **MCP** set each tool to **Allow** or
|
||||
3. Click the cog next to the token to open **Token permissions**, and under **MCP permissions** set each tool to **Allow** or
|
||||
**Deny**. Changes apply at once, to the token you already have.
|
||||
|
||||
A new token gets every tool that only reads. The tools that change your library are marked **Changes your library**,
|
||||
|
||||
@@ -60,6 +60,7 @@ export type LogAction =
|
||||
| "api_token_generated"
|
||||
| "api_token_revoked"
|
||||
| "api_token_mcp_tools_changed"
|
||||
| "api_token_rest_scopes_changed"
|
||||
| "mcp_access_changed"
|
||||
| "user_invited"
|
||||
| "authors_linked"
|
||||
|
||||
@@ -25,6 +25,10 @@ export type ApiTokenInfo = {
|
||||
last_used_at: string | null;
|
||||
/** The MCP tools the token may call at /api/mcp. Empty without a token. */
|
||||
mcp_tools: string[];
|
||||
/** What the token may read over the REST API, by scope id. Empty without a token. */
|
||||
rest_scopes: string[];
|
||||
/** Every REST scope, to choose from. */
|
||||
available_rest_scopes: RestScopeInfo[];
|
||||
/** Every tool the instance's MCP access offers, to choose from. */
|
||||
available_mcp_tools: McpToolInfo[];
|
||||
/** What an admin lets API tokens do over MCP. */
|
||||
@@ -32,6 +36,13 @@ export type ApiTokenInfo = {
|
||||
};
|
||||
export type GeneratedApiToken = ApiTokenInfo & { token: string };
|
||||
|
||||
export type RestScopeInfo = {
|
||||
id: string;
|
||||
/** English; the profile page shows its own translation when it has one. */
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type McpToolInfo = {
|
||||
name: string;
|
||||
/** English; the profile page shows its own translation when it has one. */
|
||||
@@ -151,6 +162,17 @@ export const authApi = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
setApiTokenRestScopes: async (restScopes: string[]): Promise<ApiTokenInfo> => {
|
||||
const res = await fetch(`${apiBase()}/profile/api-token/rest-scopes`, {
|
||||
method: "PUT",
|
||||
headers: authHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ rest_scopes: restScopes }),
|
||||
});
|
||||
if (res.status === 401) throw new UnauthorizedError();
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to update the token's permissions"));
|
||||
return res.json();
|
||||
},
|
||||
|
||||
revokeApiToken: async (): Promise<ApiTokenInfo> => {
|
||||
const res = await fetch(`${apiBase()}/profile/api-token`, { method: "DELETE", headers: authHeaders() });
|
||||
assertOk(res, "Failed to revoke API token");
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Segmented from "./controls/Segmented";
|
||||
import PermissionTile from "./permissionAnimations/PermissionTile";
|
||||
|
||||
type Props = {
|
||||
/** Picks the tile's picture: an MCP tool's name or a REST scope's id. */
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
allowed: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (allowed: boolean) => void;
|
||||
};
|
||||
|
||||
/** One thing an API token may or may not do: its picture, what it is, and Deny / Allow. */
|
||||
export default function PermissionRow({ name, title, description, allowed, disabled, onChange }: Props) {
|
||||
const { t } = useTranslation("app");
|
||||
const value = allowed ? "allow" : "deny";
|
||||
return (
|
||||
<Box
|
||||
className="permission-row"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
py: 0.75,
|
||||
"& + &": { borderTop: 1, borderColor: "divider" },
|
||||
}}
|
||||
>
|
||||
<PermissionTile name={name} allowed={allowed} />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2">{title}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" component="div">
|
||||
{description}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ flexShrink: 0 }}>
|
||||
<Segmented
|
||||
label={title}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(next) => next !== value && onChange(next === "allow")}
|
||||
options={[
|
||||
{ value: "deny", label: t("profile.apiToken.permissions.deny") },
|
||||
{ value: "allow", label: t("profile.apiToken.permissions.allow") },
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+99
-6
@@ -3,7 +3,7 @@ import { keyframes } from "@emotion/react";
|
||||
import Box from "@mui/material/Box";
|
||||
import type { SxProps, Theme } from "@mui/material/styles";
|
||||
|
||||
// A small picture of what each MCP tool does, sized to a permission row. Still until its row is hovered
|
||||
// A small picture of what each API token permission (an MCP tool or a REST scope) gives, sized to a permission row. Still until its row is hovered
|
||||
// or focused (a `.permission-row` ancestor), so a list of them doesn't all move at once; every
|
||||
// animation's 0% is its resting pose.
|
||||
|
||||
@@ -65,6 +65,20 @@ const pulse = keyframes`
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.3); }
|
||||
`;
|
||||
const sweep = keyframes`
|
||||
0%, 100% { opacity: 0.25; }
|
||||
40% { opacity: 1; }
|
||||
`;
|
||||
const sunrise = keyframes`
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
`;
|
||||
const download = keyframes`
|
||||
0% { transform: translateY(-3px); opacity: 0; }
|
||||
30% { opacity: 1; }
|
||||
70% { transform: translateY(3px); opacity: 1; }
|
||||
100% { transform: translateY(5px); opacity: 0; }
|
||||
`;
|
||||
const fileIn = keyframes`
|
||||
0% { transform: translateY(0); opacity: 1; }
|
||||
65% { transform: translateY(13px); opacity: 1; }
|
||||
@@ -276,9 +290,87 @@ const pictures: Record<string, () => React.ReactNode> = {
|
||||
<Card sx={{ ...at(14, 3, 12, 13), animation: run(fileIn, 2) }} />
|
||||
</Folder>
|
||||
),
|
||||
activity: () => (
|
||||
<>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
...at(9 + (i % 4) * 6, 11 + Math.floor(i / 4) * 6, 4, 4),
|
||||
borderRadius: "1px",
|
||||
bgcolor: "primary.main",
|
||||
opacity: [0.25, 0.6, 1, 0.4][(i * 7) % 4],
|
||||
animation: run(sweep, 2.4, `${(i % 4) * 0.2 + Math.floor(i / 4) * 0.1}s`),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
images: () => (
|
||||
<Box
|
||||
sx={{ ...at(8, 10, 24, 20), borderRadius: "3px", border: 1.5, borderColor: "text.disabled", overflow: "hidden" }}
|
||||
>
|
||||
<Box sx={{ ...at(14, 3, 5, 5), borderRadius: "50%", bgcolor: "warning.main", animation: run(sunrise, 2.2) }} />
|
||||
<Box sx={{ ...at(-2, 7, 18, 12), bgcolor: "primary.main", clipPath: "polygon(50% 0, 100% 100%, 0 100%)" }} />
|
||||
<Box
|
||||
sx={{
|
||||
...at(9, 10, 14, 9),
|
||||
bgcolor: "primary.dark",
|
||||
opacity: 0.8,
|
||||
clipPath: "polygon(50% 0, 100% 100%, 0 100%)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
),
|
||||
files: () => (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
...at(11, 7, 18, 24),
|
||||
bgcolor: "background.paper",
|
||||
border: 1.5,
|
||||
borderColor: "text.disabled",
|
||||
borderRadius: "2px",
|
||||
clipPath: "polygon(0 0, 65% 0, 100% 27%, 100% 100%, 0 100%)",
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ ...at(22, 7, 7, 7), bgcolor: "text.disabled", clipPath: "polygon(0 0, 100% 100%, 0 100%)" }} />
|
||||
<Box sx={{ ...at(14, 14, 12, 12), animation: run(download, 1.6) }}>
|
||||
<Box sx={{ ...at(4.5, 0, 3, 6), bgcolor: "primary.main" }} />
|
||||
<Box sx={{ ...at(1, 5, 10, 6), bgcolor: "primary.main", clipPath: "polygon(0 0, 100% 0, 50% 100%)" }} />
|
||||
</Box>
|
||||
</>
|
||||
),
|
||||
other: () => (
|
||||
<>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
...at(10 + i * 8, 17, 5, 5),
|
||||
borderRadius: "50%",
|
||||
bgcolor: "primary.main",
|
||||
animation: run(bob, 1.2, `${i * 0.15}s`),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
/** A plug, for a tool the frontend doesn't have a picture of yet. */
|
||||
// REST scopes that read the same thing as an MCP tool share its picture.
|
||||
const aliases: Record<string, string> = {
|
||||
search: "search_models",
|
||||
models: "list_models",
|
||||
collections: "list_collections",
|
||||
tags: "list_tags",
|
||||
categories: "list_categories",
|
||||
authors: "get_author",
|
||||
dashboard: "library_stats",
|
||||
imports: "get_import_status",
|
||||
};
|
||||
|
||||
/** A plug, for a permission the frontend doesn't have a picture of yet. */
|
||||
function Fallback() {
|
||||
return (
|
||||
<>
|
||||
@@ -291,13 +383,14 @@ function Fallback() {
|
||||
}
|
||||
|
||||
type Props = {
|
||||
tool: string;
|
||||
/** Off: greyed out, as the tool can't be called. */
|
||||
/** An MCP tool's name or a REST scope's id. */
|
||||
name: string;
|
||||
/** Off: greyed out, as the token can't use it. */
|
||||
allowed: boolean;
|
||||
};
|
||||
|
||||
export default function McpToolTile({ tool, allowed }: Props) {
|
||||
const picture = pictures[tool];
|
||||
export default function PermissionTile({ name, allowed }: Props) {
|
||||
const picture = pictures[aliases[name] ?? name];
|
||||
return (
|
||||
<Box
|
||||
aria-hidden
|
||||
@@ -206,6 +206,7 @@
|
||||
"failed": "Something went wrong with your API token. Please try again.",
|
||||
"mcp": {
|
||||
"heading": "MCP",
|
||||
"subtitle": "Tools an AI assistant may use",
|
||||
"description": "What an AI assistant connected with this token (over MCP, at /api/mcp) may use. Changes apply straight away, without a new token.",
|
||||
"connect": "To connect an AI assistant, for example Claude Code:",
|
||||
"writes": "Changes your library",
|
||||
@@ -270,12 +271,75 @@
|
||||
"adminOff": "An admin has turned MCP off on this Thingport.",
|
||||
"adminReadOnly": "An admin allows only the tools that read.",
|
||||
"copyCommand": "Copy command",
|
||||
"reads": "Reads your library",
|
||||
"allow": "Allow",
|
||||
"deny": "Deny"
|
||||
"reads": "Reads your library"
|
||||
},
|
||||
"rest": {
|
||||
"heading": "Permissions",
|
||||
"subtitle": "What apps using this token may read",
|
||||
"description": "What other apps using this token, such as dashboards and launchers, may read over the REST API. Changes apply straight away, without a new token. Denying something an app reads stops that part of it working.",
|
||||
"failed": "Couldn't save the token's permissions. Please try again.",
|
||||
"groups": {
|
||||
"library": "Your library",
|
||||
"media": "Images and files",
|
||||
"other": "Other"
|
||||
},
|
||||
"scopes": {
|
||||
"search": {
|
||||
"title": "Search",
|
||||
"description": "Search models, collections and tags."
|
||||
},
|
||||
"models": {
|
||||
"title": "Models",
|
||||
"description": "List models and read their details."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"description": "Library totals, most viewed and printed, the featured model and widgets."
|
||||
},
|
||||
"activity": {
|
||||
"title": "Activity",
|
||||
"description": "Your activity heatmap and feed."
|
||||
},
|
||||
"collections": {
|
||||
"title": "Collections",
|
||||
"description": "Your collections and what's in them."
|
||||
},
|
||||
"tags": {
|
||||
"title": "Tags",
|
||||
"description": "Your tags and how many models have each."
|
||||
},
|
||||
"categories": {
|
||||
"title": "Categories",
|
||||
"description": "Your category tree."
|
||||
},
|
||||
"authors": {
|
||||
"title": "Authors",
|
||||
"description": "Authors' profiles."
|
||||
},
|
||||
"images": {
|
||||
"title": "Images",
|
||||
"description": "Thumbnails, renders and preview pictures."
|
||||
},
|
||||
"files": {
|
||||
"title": "Model files",
|
||||
"description": "Download model files and 3D previews."
|
||||
},
|
||||
"imports": {
|
||||
"title": "Imports",
|
||||
"description": "Progress of running imports."
|
||||
},
|
||||
"other": {
|
||||
"title": "Everything else",
|
||||
"description": "Your settings, notifications, AI suggestions and anything not listed above."
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Token permissions"
|
||||
"title": "Token permissions",
|
||||
"rest": "Permissions",
|
||||
"mcp": "MCP permissions",
|
||||
"allow": "Allow",
|
||||
"deny": "Deny"
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
@@ -1300,6 +1364,7 @@
|
||||
"api_token_generated": "Generated an API token",
|
||||
"api_token_revoked": "Revoked their API token",
|
||||
"api_token_mcp_tools_changed": "Changed their API token's MCP tools",
|
||||
"api_token_rest_scopes_changed": "Changed their API token's permissions",
|
||||
"user_invited": "Invited a user",
|
||||
"model_uploaded": "Model uploaded",
|
||||
"model_imported": "Model imported",
|
||||
|
||||
@@ -209,6 +209,7 @@
|
||||
"adminReadOnly": "Un administrador solo permite las herramientas de lectura.",
|
||||
"copyCommand": "Copiar comando",
|
||||
"heading": "MCP",
|
||||
"subtitle": "Herramientas que puede usar un asistente de IA",
|
||||
"description": "Lo que puede usar un asistente de IA conectado con este token (por MCP, en /api/mcp). Los cambios se aplican al instante, sin un token nuevo.",
|
||||
"connect": "Para conectar un asistente de IA, por ejemplo Claude Code:",
|
||||
"writes": "Cambia tu biblioteca",
|
||||
@@ -270,12 +271,75 @@
|
||||
"writeConfirmTitle": "¿Dejar que el asistente use «{{tool}}»?",
|
||||
"writeConfirmMessage": "Esta herramienta cambia tu biblioteca. Un asistente conectado con este token podrá usarla sin preguntarte cada vez. Nada de lo que haga elimina modelos.",
|
||||
"writeConfirm": "Activar",
|
||||
"reads": "Lee tu biblioteca",
|
||||
"allow": "Permitir",
|
||||
"deny": "Denegar"
|
||||
"reads": "Lee tu biblioteca"
|
||||
},
|
||||
"rest": {
|
||||
"heading": "Permisos",
|
||||
"subtitle": "Lo que pueden leer las apps que usan este token",
|
||||
"description": "Lo que otras apps que usan este token, como paneles y lanzadores, pueden leer a través de la API REST. Los cambios se aplican al instante, sin un token nuevo. Denegar algo que una app lee hace que esa parte deje de funcionar.",
|
||||
"failed": "No se pudieron guardar los permisos del token. Inténtalo de nuevo.",
|
||||
"groups": {
|
||||
"library": "Tu biblioteca",
|
||||
"media": "Imágenes y archivos",
|
||||
"other": "Otros"
|
||||
},
|
||||
"scopes": {
|
||||
"search": {
|
||||
"title": "Búsqueda",
|
||||
"description": "Buscar modelos, colecciones y etiquetas."
|
||||
},
|
||||
"models": {
|
||||
"title": "Modelos",
|
||||
"description": "Listar modelos y leer sus detalles."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panel",
|
||||
"description": "Totales de la biblioteca, los más vistos e impresos, el modelo destacado y los widgets."
|
||||
},
|
||||
"activity": {
|
||||
"title": "Actividad",
|
||||
"description": "Tu mapa de actividad y tu historial."
|
||||
},
|
||||
"collections": {
|
||||
"title": "Colecciones",
|
||||
"description": "Tus colecciones y lo que contienen."
|
||||
},
|
||||
"tags": {
|
||||
"title": "Etiquetas",
|
||||
"description": "Tus etiquetas y cuántos modelos tiene cada una."
|
||||
},
|
||||
"categories": {
|
||||
"title": "Categorías",
|
||||
"description": "Tu árbol de categorías."
|
||||
},
|
||||
"authors": {
|
||||
"title": "Autores",
|
||||
"description": "Perfiles de autores."
|
||||
},
|
||||
"images": {
|
||||
"title": "Imágenes",
|
||||
"description": "Miniaturas, renders e imágenes de vista previa."
|
||||
},
|
||||
"files": {
|
||||
"title": "Archivos de modelos",
|
||||
"description": "Descargar archivos de modelos y vistas previas 3D."
|
||||
},
|
||||
"imports": {
|
||||
"title": "Importaciones",
|
||||
"description": "Progreso de las importaciones en curso."
|
||||
},
|
||||
"other": {
|
||||
"title": "Todo lo demás",
|
||||
"description": "Tus ajustes, notificaciones, sugerencias de IA y todo lo que no aparece arriba."
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Permisos del token"
|
||||
"title": "Permisos del token",
|
||||
"rest": "Permisos",
|
||||
"mcp": "Permisos MCP",
|
||||
"allow": "Permitir",
|
||||
"deny": "Denegar"
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
@@ -1328,7 +1392,8 @@
|
||||
"collections_merged": "Colecciones fusionadas",
|
||||
"tags_merged": "Etiquetas fusionadas",
|
||||
"mcp_access_changed": "Cambió el acceso MCP",
|
||||
"api_token_mcp_tools_changed": "Cambió las herramientas MCP de su token de API"
|
||||
"api_token_mcp_tools_changed": "Cambió las herramientas MCP de su token de API",
|
||||
"api_token_rest_scopes_changed": "Cambió los permisos de su token de API"
|
||||
},
|
||||
"authorsLinked": "{{count}} modelo(s) vinculado(s)",
|
||||
"descriptionsRefetched": "{{email}} — {{updated}} de {{total}} descripción(es) modificada(s)",
|
||||
|
||||
@@ -209,6 +209,7 @@
|
||||
"adminReadOnly": "Un administrateur n'autorise que les outils de lecture.",
|
||||
"copyCommand": "Copier la commande",
|
||||
"heading": "MCP",
|
||||
"subtitle": "Les outils qu'un assistant IA peut utiliser",
|
||||
"description": "Ce qu'un assistant IA connecté avec ce jeton (via MCP, sur /api/mcp) peut utiliser. Les changements s'appliquent aussitôt, sans nouveau jeton.",
|
||||
"connect": "Pour connecter un assistant IA, par exemple Claude Code :",
|
||||
"writes": "Modifie votre bibliothèque",
|
||||
@@ -270,12 +271,75 @@
|
||||
"writeConfirmTitle": "Laisser l'assistant utiliser « {{tool}} » ?",
|
||||
"writeConfirmMessage": "Cet outil modifie votre bibliothèque. Un assistant connecté avec ce jeton pourra l'utiliser sans vous demander à chaque fois. Rien de ce qu'il fait ne supprime de modèles.",
|
||||
"writeConfirm": "Activer",
|
||||
"reads": "Lit votre bibliothèque",
|
||||
"allow": "Autoriser",
|
||||
"deny": "Refuser"
|
||||
"reads": "Lit votre bibliothèque"
|
||||
},
|
||||
"rest": {
|
||||
"heading": "Autorisations",
|
||||
"subtitle": "Ce que les applis utilisant ce jeton peuvent lire",
|
||||
"description": "Ce que les autres applis utilisant ce jeton, comme les tableaux de bord et les lanceurs, peuvent lire via l'API REST. Les changements s'appliquent immédiatement, sans nouveau jeton. Refuser ce qu'une appli lit empêche cette partie de fonctionner.",
|
||||
"failed": "Impossible d'enregistrer les autorisations du jeton. Veuillez réessayer.",
|
||||
"groups": {
|
||||
"library": "Votre bibliothèque",
|
||||
"media": "Images et fichiers",
|
||||
"other": "Autre"
|
||||
},
|
||||
"scopes": {
|
||||
"search": {
|
||||
"title": "Recherche",
|
||||
"description": "Rechercher des modèles, collections et étiquettes."
|
||||
},
|
||||
"models": {
|
||||
"title": "Modèles",
|
||||
"description": "Lister les modèles et lire leurs détails."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
"description": "Totaux de la bibliothèque, les plus vus et imprimés, le modèle à la une et les widgets."
|
||||
},
|
||||
"activity": {
|
||||
"title": "Activité",
|
||||
"description": "Votre carte d'activité et votre fil."
|
||||
},
|
||||
"collections": {
|
||||
"title": "Collections",
|
||||
"description": "Vos collections et leur contenu."
|
||||
},
|
||||
"tags": {
|
||||
"title": "Étiquettes",
|
||||
"description": "Vos étiquettes et le nombre de modèles pour chacune."
|
||||
},
|
||||
"categories": {
|
||||
"title": "Catégories",
|
||||
"description": "Votre arborescence de catégories."
|
||||
},
|
||||
"authors": {
|
||||
"title": "Auteurs",
|
||||
"description": "Profils des auteurs."
|
||||
},
|
||||
"images": {
|
||||
"title": "Images",
|
||||
"description": "Miniatures, rendus et images d'aperçu."
|
||||
},
|
||||
"files": {
|
||||
"title": "Fichiers des modèles",
|
||||
"description": "Télécharger les fichiers des modèles et les aperçus 3D."
|
||||
},
|
||||
"imports": {
|
||||
"title": "Imports",
|
||||
"description": "Progression des imports en cours."
|
||||
},
|
||||
"other": {
|
||||
"title": "Tout le reste",
|
||||
"description": "Vos réglages, notifications, suggestions IA et tout ce qui n'est pas listé ci-dessus."
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Autorisations du jeton"
|
||||
"title": "Autorisations du jeton",
|
||||
"rest": "Autorisations",
|
||||
"mcp": "Autorisations MCP",
|
||||
"allow": "Autoriser",
|
||||
"deny": "Refuser"
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
@@ -1328,7 +1392,8 @@
|
||||
"collections_merged": "Collections fusionnées",
|
||||
"tags_merged": "Étiquettes fusionnées",
|
||||
"mcp_access_changed": "A modifié l'accès MCP",
|
||||
"api_token_mcp_tools_changed": "A modifié les outils MCP de son jeton d'API"
|
||||
"api_token_mcp_tools_changed": "A modifié les outils MCP de son jeton d'API",
|
||||
"api_token_rest_scopes_changed": "A modifié les autorisations de son jeton d'API"
|
||||
},
|
||||
"authorsLinked": "{{count}} modèle(s) relié(s)",
|
||||
"descriptionsRefetched": "{{email}} — {{updated}} description(s) sur {{total}} modifiée(s)",
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
"failed": "Kažkas nepavyko su jūsų API raktu. Bandykite dar kartą.",
|
||||
"mcp": {
|
||||
"heading": "MCP",
|
||||
"subtitle": "Įrankiai, kuriuos gali naudoti DI asistentas",
|
||||
"description": "Ką gali naudoti DI asistentas, prijungtas su šiuo raktu (per MCP, adresu /api/mcp). Pakeitimai įsigalioja iš karto, naujo rakto nereikia.",
|
||||
"connect": "Kad prijungtumėte DI asistentą, pavyzdžiui Claude Code:",
|
||||
"writes": "Keičia biblioteką",
|
||||
@@ -271,12 +272,75 @@
|
||||
"adminOff": "Administratorius šiame Thingport išjungė MCP.",
|
||||
"adminReadOnly": "Administratorius leidžia tik skaitančius įrankius.",
|
||||
"copyCommand": "Kopijuoti komandą",
|
||||
"reads": "Skaito biblioteką",
|
||||
"allow": "Leisti",
|
||||
"deny": "Drausti"
|
||||
"reads": "Skaito biblioteką"
|
||||
},
|
||||
"rest": {
|
||||
"heading": "Leidimai",
|
||||
"subtitle": "Ką gali skaityti šį raktą naudojančios programos",
|
||||
"description": "Ką kitos šį raktą naudojančios programos, pavyzdžiui, valdymo skydai ir paleidikliai, gali skaityti per REST API. Pakeitimai įsigalioja iškart, naujo rakto nereikia. Uždraudus tai, ką programa skaito, ta jos dalis nustos veikti.",
|
||||
"failed": "Nepavyko išsaugoti rakto leidimų. Bandykite dar kartą.",
|
||||
"groups": {
|
||||
"library": "Jūsų biblioteka",
|
||||
"media": "Paveikslėliai ir failai",
|
||||
"other": "Kita"
|
||||
},
|
||||
"scopes": {
|
||||
"search": {
|
||||
"title": "Paieška",
|
||||
"description": "Ieškoti modelių, kolekcijų ir žymų."
|
||||
},
|
||||
"models": {
|
||||
"title": "Modeliai",
|
||||
"description": "Modelių sąrašas ir jų informacija."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Valdymo skydas",
|
||||
"description": "Bibliotekos suvestinė, populiariausi ir dažniausiai spausdinti, dienos modelis ir valdikliai."
|
||||
},
|
||||
"activity": {
|
||||
"title": "Veikla",
|
||||
"description": "Jūsų veiklos žemėlapis ir srautas."
|
||||
},
|
||||
"collections": {
|
||||
"title": "Kolekcijos",
|
||||
"description": "Jūsų kolekcijos ir kas jose yra."
|
||||
},
|
||||
"tags": {
|
||||
"title": "Žymos",
|
||||
"description": "Jūsų žymos ir kiek modelių turi kiekvieną."
|
||||
},
|
||||
"categories": {
|
||||
"title": "Kategorijos",
|
||||
"description": "Jūsų kategorijų medis."
|
||||
},
|
||||
"authors": {
|
||||
"title": "Autoriai",
|
||||
"description": "Autorių profiliai."
|
||||
},
|
||||
"images": {
|
||||
"title": "Paveikslėliai",
|
||||
"description": "Miniatiūros, atvaizdai ir peržiūros paveikslėliai."
|
||||
},
|
||||
"files": {
|
||||
"title": "Modelių failai",
|
||||
"description": "Atsisiųsti modelių failus ir 3D peržiūras."
|
||||
},
|
||||
"imports": {
|
||||
"title": "Importai",
|
||||
"description": "Vykdomų importų eiga."
|
||||
},
|
||||
"other": {
|
||||
"title": "Visa kita",
|
||||
"description": "Jūsų nustatymai, pranešimai, DI pasiūlymai ir visa, kas neišvardyta aukščiau."
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"title": "Rakto leidimai"
|
||||
"title": "Rakto leidimai",
|
||||
"rest": "Leidimai",
|
||||
"mcp": "MCP leidimai",
|
||||
"allow": "Leisti",
|
||||
"deny": "Drausti"
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
@@ -1329,6 +1393,7 @@
|
||||
"api_token_generated": "Sugeneravo API raktą",
|
||||
"api_token_revoked": "Atšaukė savo API raktą",
|
||||
"api_token_mcp_tools_changed": "Pakeitė savo API rakto MCP įrankius",
|
||||
"api_token_rest_scopes_changed": "Pakeitė savo API rakto leidimus",
|
||||
"user_invited": "Pakvietė naudotoją",
|
||||
"model_uploaded": "Modelis įkeltas",
|
||||
"model_imported": "Modelis importuotas",
|
||||
|
||||
@@ -36,6 +36,7 @@ const ACTION_COLORS: Record<LogAction, ActionColor> = {
|
||||
api_token_revoked: "default",
|
||||
// Can turn on a tool that writes.
|
||||
api_token_mcp_tools_changed: "warning",
|
||||
api_token_rest_scopes_changed: "warning",
|
||||
mcp_access_changed: "warning",
|
||||
user_invited: "info",
|
||||
authors_linked: "success",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Button from "@mui/material/Button";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import ApiOutlinedIcon from "@mui/icons-material/ApiOutlined";
|
||||
import SmartToyOutlinedIcon from "@mui/icons-material/SmartToyOutlined";
|
||||
import type { ApiTokenInfo } from "../../api/auth";
|
||||
import SectionedDialog from "../../components/SectionedDialog";
|
||||
import McpPermissionsSection from "./McpPermissionsSection";
|
||||
import RestPermissionsSection from "./RestPermissionsSection";
|
||||
|
||||
type Section = "rest" | "mcp";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
@@ -16,21 +17,31 @@ type Props = {
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
/** What the API token may do, a section per way of using it. Only MCP for now; the REST API's
|
||||
* permissions are meant to join it here. */
|
||||
/** What the API token may do, a section per way of using it: reading the REST API, and MCP. */
|
||||
export default function ApiTokenPermissionsDialog({ open, onClose, info, onChange, onUnauthorized }: Props) {
|
||||
const { t } = useTranslation(["app", "common"]);
|
||||
const { t } = useTranslation("app");
|
||||
const [section, setSection] = React.useState<Section>("rest");
|
||||
|
||||
const sections = [
|
||||
{ id: "rest" as const, label: t("profile.apiToken.permissions.rest"), icon: <ApiOutlinedIcon fontSize="small" /> },
|
||||
{
|
||||
id: "mcp" as const,
|
||||
label: t("profile.apiToken.permissions.mcp"),
|
||||
icon: <SmartToyOutlinedIcon fontSize="small" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{t("profile.apiToken.permissions.title")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={3}>
|
||||
<McpPermissionsSection info={info} onChange={onChange} onUnauthorized={onUnauthorized} />
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>{t("common:close")}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<SectionedDialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t("profile.apiToken.permissions.title")}
|
||||
sections={sections}
|
||||
section={section}
|
||||
onSectionChange={setSection}
|
||||
>
|
||||
{section === "rest" && <RestPermissionsSection info={info} onChange={onChange} onUnauthorized={onUnauthorized} />}
|
||||
{section === "mcp" && <McpPermissionsSection info={info} onChange={onChange} onUnauthorized={onUnauthorized} />}
|
||||
</SectionedDialog>
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import { authApi, type ApiTokenInfo, type McpToolInfo } from "../../api/auth";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { useConfirm } from "../../components/ConfirmProvider";
|
||||
import HelpTip from "../../components/HelpTip";
|
||||
import Segmented from "../../components/controls/Segmented";
|
||||
import McpToolTile from "../../components/permissionAnimations/McpToolTile";
|
||||
import { PanelHeader, SectionLabel } from "../../components/Layout/ConfigurationDialog/parts";
|
||||
import PermissionRow from "../../components/PermissionRow";
|
||||
import { useToast } from "../../components/ToastProvider";
|
||||
|
||||
type Props = {
|
||||
@@ -17,7 +17,7 @@ type Props = {
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
/** Token permissions' MCP section: which tools the token may call at /api/mcp. Each choice saves on its own, so there's no
|
||||
/** Token permissions' MCP panel: which tools the token may call at /api/mcp. Each choice saves on its own, so there's no
|
||||
* unsaved state to lose; a refused save puts it back. */
|
||||
export default function McpPermissionsSection({ info, onChange, onUnauthorized }: Props) {
|
||||
const { t, i18n } = useTranslation(["app", "common"]);
|
||||
@@ -69,59 +69,35 @@ export default function McpPermissionsSection({ info, onChange, onUnauthorized }
|
||||
].filter((group) => group.tools.length);
|
||||
|
||||
return (
|
||||
<Stack component="section" aria-labelledby="token-permissions-mcp" spacing={1}>
|
||||
<Typography id="token-permissions-mcp" variant="subtitle1" component="h3" fontWeight={600}>
|
||||
{t("profile.apiToken.mcp.heading")}
|
||||
<HelpTip text={t("profile.apiToken.mcp.description")} />
|
||||
</Typography>
|
||||
<>
|
||||
<PanelHeader
|
||||
title={t("profile.apiToken.mcp.heading")}
|
||||
subtitle={t("profile.apiToken.mcp.subtitle")}
|
||||
action={<HelpTip text={t("profile.apiToken.mcp.description")} />}
|
||||
/>
|
||||
{info.mcp_access === "off" ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("profile.apiToken.mcp.adminOff")}
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
<Stack spacing={2.5}>
|
||||
{groups.map((group) => (
|
||||
<Stack key={group.key}>
|
||||
<Typography variant="overline" color="text.secondary" sx={{ lineHeight: 2 }}>
|
||||
<Box key={group.key}>
|
||||
<SectionLabel>
|
||||
{group.key === "reads" ? t("profile.apiToken.mcp.reads") : t("profile.apiToken.mcp.writes")}
|
||||
</Typography>
|
||||
{group.tools.map((tool) => {
|
||||
const value = enabled.has(tool.name) ? "allow" : "deny";
|
||||
return (
|
||||
<Box
|
||||
key={tool.name}
|
||||
className="permission-row"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
py: 0.75,
|
||||
"& + &": { borderTop: 1, borderColor: "divider" },
|
||||
}}
|
||||
>
|
||||
<McpToolTile tool={tool.name} allowed={value === "allow"} />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2">{label(tool, "title")}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" component="div">
|
||||
{label(tool, "description")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ flexShrink: 0 }}>
|
||||
<Segmented
|
||||
label={label(tool, "title")}
|
||||
value={value}
|
||||
disabled={saving !== null}
|
||||
onChange={(next) => next !== value && void toggle(tool, next === "allow")}
|
||||
options={[
|
||||
{ value: "deny", label: t("profile.apiToken.mcp.deny") },
|
||||
{ value: "allow", label: t("profile.apiToken.mcp.allow") },
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</SectionLabel>
|
||||
{group.tools.map((tool) => (
|
||||
<PermissionRow
|
||||
key={tool.name}
|
||||
name={tool.name}
|
||||
title={label(tool, "title")}
|
||||
description={label(tool, "description")}
|
||||
allowed={enabled.has(tool.name)}
|
||||
disabled={saving !== null}
|
||||
onChange={(on) => void toggle(tool, on)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
{info.mcp_access === "read" && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
@@ -130,6 +106,6 @@ export default function McpPermissionsSection({ info, onChange, onUnauthorized }
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { authApi, type ApiTokenInfo, type RestScopeInfo } from "../../api/auth";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import HelpTip from "../../components/HelpTip";
|
||||
import { PanelHeader, SectionLabel } from "../../components/Layout/ConfigurationDialog/parts";
|
||||
import PermissionRow from "../../components/PermissionRow";
|
||||
import { useToast } from "../../components/ToastProvider";
|
||||
|
||||
type Props = {
|
||||
info: ApiTokenInfo;
|
||||
onChange: (info: ApiTokenInfo) => void;
|
||||
onUnauthorized?: () => void;
|
||||
};
|
||||
|
||||
const MEDIA = new Set(["images", "files"]);
|
||||
const OTHER = "other";
|
||||
|
||||
/** Token permissions' REST panel: what other apps using the token may read under /api. Each choice saves
|
||||
* on its own, as in the MCP panel; a refused save puts it back. */
|
||||
export default function RestPermissionsSection({ info, onChange, onUnauthorized }: Props) {
|
||||
const { t, i18n } = useTranslation(["app", "common"]);
|
||||
const toast = useToast();
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
// Ahead of the server while a save is in flight; back in step with it once the save answers.
|
||||
const [selected, setSelected] = React.useState<string[]>(info.rest_scopes);
|
||||
React.useEffect(() => setSelected(info.rest_scopes), [info.rest_scopes]);
|
||||
const enabled = new Set(selected);
|
||||
|
||||
// A scope added on the server before its translation falls back to the server's English text.
|
||||
const label = (scope: RestScopeInfo, part: "title" | "description") => {
|
||||
const key = `profile.apiToken.rest.scopes.${scope.id}.${part}`;
|
||||
return i18n.exists(key, { ns: "app" }) ? t(key) : scope[part];
|
||||
};
|
||||
|
||||
const toggle = async (id: string, on: boolean) => {
|
||||
const next = info.available_rest_scopes
|
||||
.map((scope) => scope.id)
|
||||
.filter((other) => (other === id ? on : enabled.has(other)));
|
||||
setSelected(next);
|
||||
setSaving(true);
|
||||
try {
|
||||
onChange(await authApi.setApiTokenRestScopes(next));
|
||||
} catch (err) {
|
||||
setSelected(info.rest_scopes);
|
||||
if (err instanceof UnauthorizedError) onUnauthorized?.();
|
||||
else toast({ message: t("profile.apiToken.rest.failed"), severity: "error" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const scopes = info.available_rest_scopes;
|
||||
const groups = [
|
||||
{ key: "library", scopes: scopes.filter((scope) => !MEDIA.has(scope.id) && scope.id !== OTHER) },
|
||||
{ key: "media", scopes: scopes.filter((scope) => MEDIA.has(scope.id)) },
|
||||
{ key: "other", scopes: scopes.filter((scope) => scope.id === OTHER) },
|
||||
].filter((group) => group.scopes.length);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelHeader
|
||||
title={t("profile.apiToken.rest.heading")}
|
||||
subtitle={t("profile.apiToken.rest.subtitle")}
|
||||
action={<HelpTip text={t("profile.apiToken.rest.description")} />}
|
||||
/>
|
||||
<Stack spacing={2.5}>
|
||||
{groups.map((group) => (
|
||||
<Box key={group.key}>
|
||||
<SectionLabel>{t(`profile.apiToken.rest.groups.${group.key}`)}</SectionLabel>
|
||||
{group.scopes.map((scope) => (
|
||||
<PermissionRow
|
||||
key={scope.id}
|
||||
name={scope.id}
|
||||
title={label(scope, "title")}
|
||||
description={label(scope, "description")}
|
||||
allowed={enabled.has(scope.id)}
|
||||
disabled={saving}
|
||||
onChange={(on) => void toggle(scope.id, on)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user