feat(device-manager): add standalone project workspace
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# NODE.DC Device Manager
|
||||
|
||||
Standalone Device Core application shell for Hub-authenticated device administration.
|
||||
It is intentionally vendor-neutral: adapters and model profiles describe protocol-specific
|
||||
behavior; projects, inventory, collections and access remain shared Device Core concepts.
|
||||
|
||||
## Runtime boundary
|
||||
|
||||
- The browser talks only to the Device Manager BFF under `/api/device-manager/*`.
|
||||
- Launcher consumes the one-time handoff and periodically revalidates the process-local,
|
||||
opaque Device Manager cookie.
|
||||
- The BFF derives the Core actor from that trusted Hub identity. Browser-supplied role,
|
||||
group or owner headers are ignored.
|
||||
- The BFF reads the Core bearer token from `NODEDC_DEVICE_CORE_TOKEN_FILE`; the token is
|
||||
never embedded into client assets or accepted as a raw environment value.
|
||||
- Device Control Core owns authorization, lifecycle validation, idempotency and persistence.
|
||||
- Query responses contain masked identifiers only. Digests and credential references stay
|
||||
inside Device Control Core.
|
||||
|
||||
Hub currently supplies identity and groups but no signed company-membership/owner-scope
|
||||
claim. Therefore an admin may create projects in their personal scope. Existing company
|
||||
projects remain visible through explicit project grants, but company project creation stays
|
||||
closed until Hub extends the handoff contract.
|
||||
|
||||
## Local source preview
|
||||
|
||||
The preview store starts empty and exists only to exercise the shell without a deployed Core.
|
||||
All visible resources must still be created through the same command-shaped BFF endpoints.
|
||||
It is forbidden when `NODE_ENV=production`.
|
||||
|
||||
```sh
|
||||
NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW=1 \
|
||||
NODEDC_DEVICE_MANAGER_AUTH_REQUIRED=0 \
|
||||
npm run build --workspace @nodedc/device-manager
|
||||
|
||||
NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW=1 \
|
||||
NODEDC_DEVICE_MANAGER_AUTH_REQUIRED=0 \
|
||||
npm run serve --workspace @nodedc/device-manager
|
||||
```
|
||||
|
||||
Production additionally requires:
|
||||
|
||||
- `NODEDC_LAUNCHER_BASE_URL`
|
||||
- `NODEDC_LAUNCHER_INTERNAL_URL`
|
||||
- `NODEDC_INTERNAL_ACCESS_TOKEN` or `NODEDC_PLATFORM_SERVICE_TOKEN`
|
||||
- `NODEDC_DEVICE_CORE_INTERNAL_URL`
|
||||
- `NODEDC_DEVICE_CORE_TOKEN_FILE`
|
||||
|
||||
The application source does not create a Hub service entry, DNS record, reverse proxy,
|
||||
database or deployment artifact. Those remain explicit infrastructure phases.
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#101114" />
|
||||
<title>NODE.DC Device Core</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@nodedc/device-manager",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc -b --pretty false",
|
||||
"test": "node --test server/*.test.mjs",
|
||||
"serve": "node server/device-manager-server.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nodedc/ui-core": "0.7.0",
|
||||
"@nodedc/ui-react": "0.7.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const commandRoutes = new Map([
|
||||
["owner-scopes:ensure", "/internal/v1/management/owner-scopes:ensure"],
|
||||
["projects:ensure", "/internal/v1/management/projects:ensure"],
|
||||
["collections:ensure", "/internal/v1/management/collections:ensure"],
|
||||
["devices:claim", "/internal/v1/management/devices:claim"],
|
||||
]);
|
||||
|
||||
export function createDeviceCoreClient({ baseUrl, token, fetchImpl = fetch } = {}) {
|
||||
const endpoint = normalizeBaseUrl(baseUrl);
|
||||
if (typeof token !== "string" || token.length < 32) {
|
||||
throw serviceError("device_core_token_invalid", 503);
|
||||
}
|
||||
|
||||
async function request(pathname, actor, init = {}) {
|
||||
const response = await fetchImpl(new URL(pathname, endpoint), {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...actorHeaders(actor),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok || body?.ok !== true) {
|
||||
throw serviceError(
|
||||
safeCoreError(body?.error),
|
||||
response.status >= 400 && response.status < 600 ? response.status : 502,
|
||||
);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
async listProjects(actor) {
|
||||
return request("/internal/v1/query/projects", actor)
|
||||
.then((body) => body.projects);
|
||||
},
|
||||
async getWorkspace(actor, projectRef) {
|
||||
const projectId = entityId(projectRef, "project");
|
||||
return request(`/internal/v1/query/projects/${projectId}/workspace`, actor)
|
||||
.then((body) => body.workspace);
|
||||
},
|
||||
async execute(command, actor, input, idempotencyKey) {
|
||||
const pathname = commandRoutes.get(command);
|
||||
if (!pathname) throw serviceError("device_manager_command_invalid", 404);
|
||||
if (!/^[\x21-\x7e]{8,256}$/.test(idempotencyKey || "")) {
|
||||
throw serviceError("device_idempotency_key_invalid", 400);
|
||||
}
|
||||
return request(pathname, actor, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": idempotencyKey,
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
}).then(({ replayed, result }) => ({ replayed, result }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createLocalPreviewDeviceCore() {
|
||||
const ownerScopes = new Map();
|
||||
const projects = new Map();
|
||||
const collections = new Map();
|
||||
|
||||
function projectSummary(project) {
|
||||
const projectCollections = [...collections.values()]
|
||||
.filter((collection) => collection.projectRef === project.projectRef);
|
||||
return {
|
||||
...project,
|
||||
counts: { devices: 0, collections: projectCollections.length, discoveries: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
async listProjects() {
|
||||
return [...projects.values()].map(projectSummary);
|
||||
},
|
||||
async getWorkspace(_actor, projectRef) {
|
||||
const project = projects.get(projectRef);
|
||||
if (!project) throw serviceError("device_project_not_found", 404);
|
||||
return {
|
||||
project: projectSummary(project),
|
||||
devices: [],
|
||||
discoveries: [],
|
||||
enrollments: [],
|
||||
collections: [...collections.values()]
|
||||
.filter((collection) => collection.projectRef === projectRef)
|
||||
.map(({ projectRef: _projectRef, ...collection }) => collection),
|
||||
};
|
||||
},
|
||||
async execute(command, actor, input) {
|
||||
if (command === "owner-scopes:ensure") {
|
||||
const key = `${input.scopeKind}:${input.ownerRef}`;
|
||||
const created = !ownerScopes.has(key);
|
||||
const scope = {
|
||||
ownerScopeRef: ownerScopes.get(key)?.ownerScopeRef || `owner-scope:${randomUUID()}`,
|
||||
scopeKind: input.scopeKind,
|
||||
ownerRef: input.ownerRef,
|
||||
displayName: input.displayName,
|
||||
lifecycleState: "active",
|
||||
};
|
||||
ownerScopes.set(key, scope);
|
||||
return { replayed: false, result: { created, ownerScope: scope } };
|
||||
}
|
||||
if (command === "projects:ensure") {
|
||||
const scope = ownerScopes.get(`${input.scopeKind}:${input.ownerRef}`);
|
||||
if (!scope) throw serviceError("device_owner_scope_not_found", 404);
|
||||
const existing = [...projects.values()].find((project) =>
|
||||
project.ownerScope.ownerRef === input.ownerRef
|
||||
&& project.projectKey === input.projectKey
|
||||
);
|
||||
const projectRef = existing?.projectRef || `project:${randomUUID()}`;
|
||||
const project = {
|
||||
projectRef,
|
||||
projectKey: input.projectKey,
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
lifecycleState: "active",
|
||||
ownerScope: scope,
|
||||
access: { projectRole: "owner", capabilities: ownerCapabilities },
|
||||
counts: { devices: 0, collections: 0, discoveries: 0 },
|
||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
projects.set(projectRef, project);
|
||||
return { replayed: false, result: { created: !existing, project } };
|
||||
}
|
||||
if (command === "collections:ensure") {
|
||||
const projectRef = input.projectRef;
|
||||
if (!projects.has(projectRef)) throw serviceError("device_project_not_found", 404);
|
||||
const existing = [...collections.values()].find((collection) =>
|
||||
collection.projectRef === projectRef
|
||||
&& collection.collectionKey === input.collectionKey
|
||||
);
|
||||
const collectionRef = existing?.collectionRef || `collection:${randomUUID()}`;
|
||||
const collection = {
|
||||
collectionRef,
|
||||
projectRef,
|
||||
collectionKey: input.collectionKey,
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
lifecycleState: "active",
|
||||
memberCount: existing?.memberCount || 0,
|
||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
collections.set(collectionRef, collection);
|
||||
return { replayed: false, result: { created: !existing, collection } };
|
||||
}
|
||||
if (command === "devices:claim") {
|
||||
throw serviceError("device_discovery_not_found", 404);
|
||||
}
|
||||
throw serviceError("device_manager_command_invalid", 404);
|
||||
},
|
||||
snapshot() {
|
||||
return { ownerScopes, projects, collections };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ownerCapabilities = Object.freeze([
|
||||
"project.read",
|
||||
"project.manage",
|
||||
"access.manage",
|
||||
"inventory.read",
|
||||
"device.enroll",
|
||||
"device.claim",
|
||||
"device.transfer",
|
||||
"collection.manage",
|
||||
"route.manage",
|
||||
"binding.manage",
|
||||
"telemetry.observe",
|
||||
"configuration.read",
|
||||
"configuration.manage",
|
||||
"command.plan",
|
||||
"command.confirm",
|
||||
"command.dispatch",
|
||||
"credential.manage",
|
||||
"audit.read",
|
||||
]);
|
||||
|
||||
function actorHeaders(actor) {
|
||||
if (!actor || typeof actor !== "object") throw serviceError("device_actor_required", 401);
|
||||
return {
|
||||
"X-NODEDC-User-Ref": actor.userRef,
|
||||
"X-NODEDC-Hub-Role": actor.hubRole,
|
||||
"X-NODEDC-Group-Refs": (actor.groupRefs ?? []).join(","),
|
||||
"X-NODEDC-Owner-Scopes": (actor.ownerScopes ?? [])
|
||||
.map((scope) => `${scope.scopeKind}=${scope.ownerRef}`)
|
||||
.join(","),
|
||||
};
|
||||
}
|
||||
|
||||
function entityId(value, prefix) {
|
||||
const match = String(value || "").match(new RegExp(
|
||||
`^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`,
|
||||
"i",
|
||||
));
|
||||
if (!match) throw serviceError(`device_${prefix}_ref_invalid`, 400);
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw serviceError("device_core_url_required", 503);
|
||||
}
|
||||
const url = new URL(value);
|
||||
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
|
||||
throw serviceError("device_core_url_invalid", 503);
|
||||
}
|
||||
url.pathname = url.pathname.replace(/\/$/, "") || "/";
|
||||
return url;
|
||||
}
|
||||
|
||||
function safeCoreError(value) {
|
||||
return typeof value === "string" && /^device_[a-z0-9._:-]{2,120}$/.test(value)
|
||||
? value
|
||||
: "device_core_unavailable";
|
||||
}
|
||||
|
||||
function serviceError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createDeviceCoreClient,
|
||||
createLocalPreviewDeviceCore,
|
||||
} from "./device-core-client.mjs";
|
||||
|
||||
const token = "device-core-test-token-that-is-never-exposed";
|
||||
const actor = Object.freeze({
|
||||
userRef: "user:device-admin",
|
||||
hubRole: "admin",
|
||||
groupRefs: ["group:device-engineers"],
|
||||
ownerScopes: [{ scopeKind: "personal", ownerRef: "user:device-admin" }],
|
||||
});
|
||||
|
||||
test("Device Core client creates trusted actor headers and keeps its token server-side", async () => {
|
||||
const calls = [];
|
||||
const client = createDeviceCoreClient({
|
||||
baseUrl: "http://device-control-core:3210",
|
||||
token,
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return jsonResponse(200, { ok: true, projects: [] });
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(await client.listProjects(actor), []);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].init.headers.Authorization, `Bearer ${token}`);
|
||||
assert.equal(calls[0].init.headers["X-NODEDC-User-Ref"], actor.userRef);
|
||||
assert.equal(calls[0].init.headers["X-NODEDC-Hub-Role"], "admin");
|
||||
assert.equal(calls[0].init.headers["X-NODEDC-Group-Refs"], "group:device-engineers");
|
||||
assert.equal(
|
||||
calls[0].init.headers["X-NODEDC-Owner-Scopes"],
|
||||
"personal=user:device-admin",
|
||||
);
|
||||
assert.equal(JSON.stringify(await client.listProjects(actor)).includes(token), false);
|
||||
});
|
||||
|
||||
test("Device Core client accepts only canonical commands and entity refs", async () => {
|
||||
const client = createDeviceCoreClient({
|
||||
baseUrl: "http://127.0.0.1:3210",
|
||||
token,
|
||||
fetchImpl: async () => jsonResponse(200, { ok: true, replayed: false, result: {} }),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
client.execute("raw:proxy", actor, {}, "device-manager-12345678"),
|
||||
/device_manager_command_invalid/,
|
||||
);
|
||||
await assert.rejects(
|
||||
client.getWorkspace(actor, "project:not-a-uuid"),
|
||||
/device_project_ref_invalid/,
|
||||
);
|
||||
await assert.rejects(
|
||||
client.execute("projects:ensure", actor, {}, "short"),
|
||||
/device_idempotency_key_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test("local preview is empty and creates resources only through canonical commands", async () => {
|
||||
const client = createLocalPreviewDeviceCore();
|
||||
assert.deepEqual(await client.listProjects(actor), []);
|
||||
|
||||
const owner = await client.execute("owner-scopes:ensure", actor, {
|
||||
scopeKind: "personal",
|
||||
ownerRef: actor.userRef,
|
||||
displayName: "Device Admin",
|
||||
});
|
||||
assert.equal(owner.result.created, true);
|
||||
|
||||
const created = await client.execute("projects:ensure", actor, {
|
||||
scopeKind: "personal",
|
||||
ownerRef: actor.userRef,
|
||||
projectKey: "sandbox",
|
||||
name: "Device sandbox",
|
||||
description: null,
|
||||
});
|
||||
assert.equal(created.result.created, true);
|
||||
const projectRef = created.result.project.projectRef;
|
||||
|
||||
await client.execute("collections:ensure", actor, {
|
||||
projectRef,
|
||||
collectionKey: "field-devices",
|
||||
name: "Field devices",
|
||||
description: null,
|
||||
});
|
||||
|
||||
const projects = await client.listProjects(actor);
|
||||
assert.equal(projects.length, 1);
|
||||
assert.equal(projects[0].counts.collections, 1);
|
||||
const workspace = await client.getWorkspace(actor, projectRef);
|
||||
assert.equal(workspace.collections[0].collectionKey, "field-devices");
|
||||
assert.deepEqual(workspace.devices, []);
|
||||
});
|
||||
|
||||
function jsonResponse(status, body) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
const DEFAULT_VALIDATION_TTL_MS = 20_000;
|
||||
const DEFAULT_VALIDATION_GRACE_MS = 30_000;
|
||||
|
||||
export function createDeviceManagerAuth({
|
||||
env = process.env,
|
||||
fetchImpl = fetch,
|
||||
now = Date.now,
|
||||
} = {}) {
|
||||
const authRequired = booleanValue(
|
||||
env.NODEDC_DEVICE_MANAGER_AUTH_REQUIRED,
|
||||
env.NODE_ENV === "production",
|
||||
);
|
||||
const serviceSlug = textValue(env.NODEDC_DEVICE_MANAGER_SERVICE_SLUG, "device-core");
|
||||
const launcherBaseUrl = baseUrl(env.NODEDC_LAUNCHER_BASE_URL, "http://127.0.0.1:5173");
|
||||
const launcherInternalUrl = baseUrl(env.NODEDC_LAUNCHER_INTERNAL_URL, launcherBaseUrl);
|
||||
const internalToken = textValue(
|
||||
env.NODEDC_INTERNAL_ACCESS_TOKEN || env.NODEDC_PLATFORM_SERVICE_TOKEN,
|
||||
"",
|
||||
);
|
||||
const sessionCookie = textValue(
|
||||
env.NODEDC_DEVICE_MANAGER_SESSION_COOKIE,
|
||||
"nodedc_device_manager_session",
|
||||
);
|
||||
const sessionTtlMs = boundedInteger(
|
||||
env.NODEDC_DEVICE_MANAGER_SESSION_TTL_MS,
|
||||
DEFAULT_SESSION_TTL_MS,
|
||||
60_000,
|
||||
24 * 60 * 60 * 1000,
|
||||
);
|
||||
const validationTtlMs = boundedInteger(
|
||||
env.NODEDC_DEVICE_MANAGER_SESSION_VALIDATION_TTL_MS,
|
||||
DEFAULT_VALIDATION_TTL_MS,
|
||||
15_000,
|
||||
30_000,
|
||||
);
|
||||
const validationGraceMs = boundedInteger(
|
||||
env.NODEDC_DEVICE_MANAGER_SESSION_VALIDATION_GRACE_MS,
|
||||
DEFAULT_VALIDATION_GRACE_MS,
|
||||
0,
|
||||
60_000,
|
||||
);
|
||||
const secureCookie = booleanValue(
|
||||
env.NODEDC_DEVICE_MANAGER_COOKIE_SECURE,
|
||||
authRequired,
|
||||
);
|
||||
const sessions = new Map();
|
||||
|
||||
function buildCookie(value, maxAgeSeconds) {
|
||||
return [
|
||||
`${sessionCookie}=${encodeURIComponent(value)}`,
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
`Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
|
||||
...(secureCookie ? ["Secure"] : []),
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
function createSession(response, handoff) {
|
||||
pruneSessions();
|
||||
const id = randomBytes(32).toString("base64url");
|
||||
const createdAt = now();
|
||||
sessions.set(id, {
|
||||
id,
|
||||
user: handoff.user,
|
||||
launcherSessionId: handoff.launcherSessionId,
|
||||
expiresAt: createdAt + sessionTtlMs,
|
||||
validatedAt: createdAt,
|
||||
validationInFlight: null,
|
||||
});
|
||||
appendCookie(response, buildCookie(id, sessionTtlMs / 1000));
|
||||
}
|
||||
|
||||
function currentSession(request) {
|
||||
const id = parseCookies(request.headers.cookie)[sessionCookie];
|
||||
const session = id ? sessions.get(id) : null;
|
||||
if (!session || session.expiresAt <= now()) {
|
||||
if (id) sessions.delete(id);
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
async function launcherRequest(pathname, payload) {
|
||||
if (!internalToken) throw serviceError("device_manager_auth_not_configured", 503);
|
||||
const response = await fetchImpl(new URL(pathname, launcherInternalUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${internalToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
return { response, body };
|
||||
}
|
||||
|
||||
async function handleHandoff(request, response, url) {
|
||||
const nextPath = safeReturnTo(
|
||||
url.searchParams.get("next_path") || url.searchParams.get("returnTo"),
|
||||
);
|
||||
if (!authRequired) return redirect(response, nextPath);
|
||||
const token = String(url.searchParams.get("token") || "");
|
||||
if (!token) return sendText(response, 400, "Missing Launcher handoff token.");
|
||||
try {
|
||||
const result = await launcherRequest("/api/internal/handoff/consume", {
|
||||
token,
|
||||
serviceSlug,
|
||||
});
|
||||
if (!result.response.ok || result.body?.ok !== true || !result.body?.user) {
|
||||
return sendText(response, 401, "Launcher handoff rejected.");
|
||||
}
|
||||
createSession(response, {
|
||||
user: result.body.user,
|
||||
launcherSessionId: result.body.launcherSessionId ?? null,
|
||||
});
|
||||
return redirect(response, nextPath);
|
||||
} catch {
|
||||
return sendText(response, 401, "Launcher handoff failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function validatedSession(request, response) {
|
||||
if (!authRequired) {
|
||||
return attachSession(request, {
|
||||
user: {
|
||||
id: "local-device-admin",
|
||||
email: "local-device-admin@nodedc.local",
|
||||
name: "Local Device Admin",
|
||||
avatarUrl: null,
|
||||
groups: ["nodedc:superadmin"],
|
||||
},
|
||||
});
|
||||
}
|
||||
const session = currentSession(request);
|
||||
if (!session) {
|
||||
clearCookie(response);
|
||||
return null;
|
||||
}
|
||||
if (now() - session.validatedAt <= validationTtlMs) {
|
||||
return attachSession(request, session);
|
||||
}
|
||||
if (!session.validationInFlight) {
|
||||
session.validationInFlight = launcherRequest("/api/internal/session/validate", {
|
||||
serviceSlug,
|
||||
launcherSessionId: session.launcherSessionId,
|
||||
}).finally(() => {
|
||||
session.validationInFlight = null;
|
||||
});
|
||||
}
|
||||
try {
|
||||
const { response: upstream, body } = await session.validationInFlight;
|
||||
if (upstream.ok && body?.ok === true && body.active === true) {
|
||||
session.user = body.user || session.user;
|
||||
session.validatedAt = now();
|
||||
return attachSession(request, session);
|
||||
}
|
||||
if (upstream.ok && body?.ok === true && body.active === false) {
|
||||
sessions.delete(session.id);
|
||||
clearCookie(response);
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
// Read-only grace is resolved below; mutations always fail closed.
|
||||
}
|
||||
const readOnly = request.method === "GET" || request.method === "HEAD";
|
||||
if (readOnly && now() - session.validatedAt <= validationTtlMs + validationGraceMs) {
|
||||
return attachSession(request, session);
|
||||
}
|
||||
request.nodedcDeviceManagerAuthUnavailable = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function authorize(request, response, url) {
|
||||
if (
|
||||
url.pathname === "/healthz"
|
||||
|| url.pathname === "/auth/nodedc/handoff"
|
||||
|| url.pathname === "/auth/logout"
|
||||
) return false;
|
||||
const session = await validatedSession(request, response);
|
||||
if (!session) {
|
||||
if (request.nodedcDeviceManagerAuthUnavailable) {
|
||||
sendJson(response, 503, { ok: false, error: "device_manager_auth_unavailable" });
|
||||
return true;
|
||||
}
|
||||
const loginUrl = new URL("/auth/login", launcherBaseUrl);
|
||||
const launch = new URL(`/api/services/${encodeURIComponent(serviceSlug)}/launch`, launcherBaseUrl);
|
||||
launch.searchParams.set("returnTo", safeReturnTo(`${url.pathname}${url.search}`));
|
||||
loginUrl.searchParams.set("returnTo", `${launch.pathname}${launch.search}`);
|
||||
if (isHtmlRequest(request, url)) return redirect(response, loginUrl.toString());
|
||||
sendJson(response, 401, {
|
||||
ok: false,
|
||||
error: "device_manager_auth_required",
|
||||
loginUrl: loginUrl.toString(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const access = resolveAccess(session.user);
|
||||
if (!access.allowed) {
|
||||
sendJson(response, 403, {
|
||||
ok: false,
|
||||
error: access.blocked
|
||||
? "device_manager_access_blocked"
|
||||
: "device_manager_access_denied",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
request.nodedcDeviceManagerAccess = access;
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleLogout(request, response) {
|
||||
const id = parseCookies(request.headers.cookie)[sessionCookie];
|
||||
if (id) sessions.delete(id);
|
||||
clearCookie(response);
|
||||
redirect(response, "/");
|
||||
}
|
||||
|
||||
function currentContext(request) {
|
||||
const user = request.nodedcDeviceManagerSession?.user;
|
||||
const access = request.nodedcDeviceManagerAccess ?? resolveAccess(user);
|
||||
if (!user || !access.allowed) return null;
|
||||
const id = cleanOpaque(user.id || user.subject || user.sub);
|
||||
if (!id) return null;
|
||||
const email = String(user.email || "").trim().slice(0, 240);
|
||||
const displayName = String(user.name || user.displayName || email || "NODE.DC")
|
||||
.trim()
|
||||
.slice(0, 240);
|
||||
const avatar = String(user.avatarUrl || user.avatar_url || user.picture || "").trim();
|
||||
const userRef = `user:${id}`;
|
||||
const ownerScopes = ["admin", "owner"].includes(access.hubRole)
|
||||
? [{ scopeKind: "personal", ownerRef: userRef, displayName }]
|
||||
: [];
|
||||
return {
|
||||
user: {
|
||||
id,
|
||||
email,
|
||||
displayName,
|
||||
avatarUrl: /^https:\/\//i.test(avatar) || avatar.startsWith("/") ? avatar : null,
|
||||
initials: initials(displayName),
|
||||
},
|
||||
actor: {
|
||||
userRef,
|
||||
hubRole: access.hubRole,
|
||||
groupRefs: access.groups.map((group) => `group:${group}`),
|
||||
ownerScopes,
|
||||
},
|
||||
profileUrl: new URL("/profile", launcherBaseUrl).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
function clearCookie(response) {
|
||||
appendCookie(response, buildCookie("", 0));
|
||||
}
|
||||
|
||||
function pruneSessions() {
|
||||
const current = now();
|
||||
for (const [id, session] of sessions) {
|
||||
if (session.expiresAt <= current) sessions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authRequired,
|
||||
serviceSlug,
|
||||
authorize,
|
||||
currentContext,
|
||||
handleHandoff,
|
||||
handleLogout,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAccess(user) {
|
||||
if (!user || typeof user !== "object") {
|
||||
return { allowed: false, blocked: false, hubRole: "viewer", groups: [] };
|
||||
}
|
||||
const groups = normalizedGroups(user);
|
||||
if (groups.includes("nodedc:device-core:blocked")) {
|
||||
return { allowed: false, blocked: true, hubRole: "viewer", groups };
|
||||
}
|
||||
const id = cleanOpaque(user.id || user.subject || user.sub);
|
||||
if (!id) return { allowed: false, blocked: false, hubRole: "viewer", groups };
|
||||
const hubRole = id === "user_root" || groups.includes("nodedc:superadmin")
|
||||
? "owner"
|
||||
: groups.includes("nodedc:device-core:admin") || groups.includes("nodedc:launcher:admin")
|
||||
? "admin"
|
||||
: groups.includes("nodedc:device-core:viewer")
|
||||
? "viewer"
|
||||
: "member";
|
||||
return { allowed: true, blocked: false, hubRole, groups };
|
||||
}
|
||||
|
||||
function normalizedGroups(user) {
|
||||
const values = [user.groups, user.roles, user.roleKeys, user.permissions];
|
||||
const groups = [];
|
||||
for (const value of values) {
|
||||
const items = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
||||
for (const item of items) {
|
||||
const raw = typeof item === "string" ? item : item?.name || item?.key || item?.slug;
|
||||
const normalized = String(raw || "").trim().toLowerCase();
|
||||
if (/^[a-z0-9][a-z0-9._:-]{1,127}$/.test(normalized)) groups.push(normalized);
|
||||
}
|
||||
}
|
||||
return [...new Set(groups)].sort();
|
||||
}
|
||||
|
||||
function attachSession(request, session) {
|
||||
request.nodedcDeviceManagerSession = session;
|
||||
return session;
|
||||
}
|
||||
|
||||
function cleanOpaque(value) {
|
||||
const normalized = String(value || "").trim();
|
||||
return /^[A-Za-z0-9][A-Za-z0-9._:-]{2,255}$/.test(normalized)
|
||||
? normalized
|
||||
: null;
|
||||
}
|
||||
|
||||
function parseCookies(header = "") {
|
||||
const values = {};
|
||||
for (const part of String(header).split(";")) {
|
||||
const index = part.indexOf("=");
|
||||
if (index < 1) continue;
|
||||
const key = part.slice(0, index).trim();
|
||||
try {
|
||||
values[key] = decodeURIComponent(part.slice(index + 1).trim());
|
||||
} catch {
|
||||
values[key] = part.slice(index + 1).trim();
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function appendCookie(response, value) {
|
||||
const current = response.getHeader("Set-Cookie");
|
||||
response.setHeader("Set-Cookie", current ? [current, value].flat() : value);
|
||||
}
|
||||
|
||||
function safeReturnTo(value) {
|
||||
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//")
|
||||
? value
|
||||
: "/";
|
||||
}
|
||||
|
||||
function isHtmlRequest(request, url) {
|
||||
return request.method === "GET"
|
||||
&& !url.pathname.startsWith("/api/")
|
||||
&& (url.pathname === "/" || String(request.headers.accept || "").includes("text/html"));
|
||||
}
|
||||
|
||||
function redirect(response, location) {
|
||||
response.statusCode = 302;
|
||||
response.setHeader("Location", location);
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end();
|
||||
}
|
||||
|
||||
function sendJson(response, status, body) {
|
||||
response.statusCode = status;
|
||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function sendText(response, status, body) {
|
||||
response.statusCode = status;
|
||||
response.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function initials(value) {
|
||||
return value.split(/\s+/).filter(Boolean).slice(0, 2)
|
||||
.map((part) => part[0]).join("").toUpperCase() || "DC";
|
||||
}
|
||||
|
||||
function booleanValue(value, fallback) {
|
||||
if (value == null || value === "") return fallback;
|
||||
return ["1", "true", "yes", "on"].includes(String(value).toLowerCase());
|
||||
}
|
||||
|
||||
function boundedInteger(value, fallback, min, max) {
|
||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
|
||||
}
|
||||
|
||||
function textValue(value, fallback) {
|
||||
return String(value || fallback).trim();
|
||||
}
|
||||
|
||||
function baseUrl(value, fallback) {
|
||||
return textValue(value, fallback).replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function serviceError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createDeviceManagerAuth } from "./device-manager-auth.mjs";
|
||||
|
||||
const internalToken = "launcher-internal-token-must-stay-server-side";
|
||||
const launcherSessionId = "launcher-session-id-must-stay-server-side";
|
||||
|
||||
test("Launcher handoff becomes an opaque Device Manager session and trusted actor", async () => {
|
||||
const calls = [];
|
||||
const auth = createDeviceManagerAuth({
|
||||
env: productionEnv(),
|
||||
fetchImpl: async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return jsonResponse(200, {
|
||||
ok: true,
|
||||
launcherSessionId,
|
||||
user: {
|
||||
id: "user_root",
|
||||
email: "root@example.test",
|
||||
name: "DC SUDO",
|
||||
groups: ["nodedc:superadmin", "nodedc:device-core:admin"],
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const response = mockResponse();
|
||||
await auth.handleHandoff(
|
||||
{ method: "GET", headers: {} },
|
||||
response,
|
||||
new URL("https://device.example.test/auth/nodedc/handoff?token=handoff-secret&next_path=%2F"),
|
||||
);
|
||||
|
||||
assert.equal(response.statusCode, 302);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].init.headers.Authorization, `Bearer ${internalToken}`);
|
||||
assert.deepEqual(JSON.parse(calls[0].init.body), {
|
||||
token: "handoff-secret",
|
||||
serviceSlug: "device-core",
|
||||
});
|
||||
const cookie = String(response.getHeader("set-cookie")).split(";", 1)[0];
|
||||
assert.match(cookie, /^nodedc_device_manager_session=[A-Za-z0-9_-]{40,}$/);
|
||||
assert.equal(cookie.includes("user_root"), false);
|
||||
assert.equal(cookie.includes(launcherSessionId), false);
|
||||
|
||||
const request = { method: "GET", headers: { cookie, accept: "application/json" } };
|
||||
const authorized = await auth.authorize(
|
||||
request,
|
||||
mockResponse(),
|
||||
new URL("https://device.example.test/api/device-manager/session"),
|
||||
);
|
||||
assert.equal(authorized, false);
|
||||
const context = auth.currentContext(request);
|
||||
assert.equal(context.actor.userRef, "user:user_root");
|
||||
assert.equal(context.actor.hubRole, "owner");
|
||||
assert.deepEqual(context.actor.ownerScopes, [{
|
||||
scopeKind: "personal",
|
||||
ownerRef: "user:user_root",
|
||||
displayName: "DC SUDO",
|
||||
}]);
|
||||
assert.deepEqual(context.actor.groupRefs, [
|
||||
"group:nodedc:device-core:admin",
|
||||
"group:nodedc:superadmin",
|
||||
]);
|
||||
assert.equal(JSON.stringify(context).includes(launcherSessionId), false);
|
||||
assert.equal(JSON.stringify(context).includes(internalToken), false);
|
||||
});
|
||||
|
||||
test("invalid identity and explicit Device Core block never produce an actor", async () => {
|
||||
for (const user of [
|
||||
{ id: "?", groups: ["nodedc:device-core:admin"] },
|
||||
{ id: "valid-user", groups: ["nodedc:superadmin", "nodedc:device-core:blocked"] },
|
||||
]) {
|
||||
const auth = createDeviceManagerAuth({
|
||||
env: productionEnv(),
|
||||
fetchImpl: async () => jsonResponse(200, {
|
||||
ok: true,
|
||||
launcherSessionId,
|
||||
user,
|
||||
}),
|
||||
});
|
||||
const handoff = mockResponse();
|
||||
await auth.handleHandoff(
|
||||
{ method: "GET", headers: {} },
|
||||
handoff,
|
||||
new URL("https://device.example.test/auth/nodedc/handoff?token=handoff-secret"),
|
||||
);
|
||||
const cookie = String(handoff.getHeader("set-cookie")).split(";", 1)[0];
|
||||
const request = { method: "GET", headers: { cookie, accept: "application/json" } };
|
||||
const response = mockResponse();
|
||||
assert.equal(await auth.authorize(
|
||||
request,
|
||||
response,
|
||||
new URL("https://device.example.test/api/device-manager/session"),
|
||||
), true);
|
||||
assert.equal(response.statusCode, 403);
|
||||
}
|
||||
});
|
||||
|
||||
function productionEnv() {
|
||||
return {
|
||||
NODE_ENV: "production",
|
||||
NODEDC_DEVICE_MANAGER_AUTH_REQUIRED: "true",
|
||||
NODEDC_DEVICE_MANAGER_COOKIE_SECURE: "false",
|
||||
NODEDC_LAUNCHER_BASE_URL: "https://launcher.example.test",
|
||||
NODEDC_LAUNCHER_INTERNAL_URL: "http://launcher.internal.test",
|
||||
NODEDC_INTERNAL_ACCESS_TOKEN: internalToken,
|
||||
};
|
||||
}
|
||||
|
||||
function mockResponse() {
|
||||
const headers = new Map();
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: "",
|
||||
setHeader(name, value) { headers.set(String(name).toLowerCase(), value); },
|
||||
getHeader(name) { return headers.get(String(name).toLowerCase()); },
|
||||
end(body = "") { this.body = String(body); },
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(status, body) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { dirname, extname, resolve, sep } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
import { createDeviceManagerAuth } from "./device-manager-auth.mjs";
|
||||
import {
|
||||
createDeviceCoreClient,
|
||||
createLocalPreviewDeviceCore,
|
||||
} from "./device-core-client.mjs";
|
||||
|
||||
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const mutationRoutes = new Map([
|
||||
["/api/device-manager/owner-scopes:ensure", "owner-scopes:ensure"],
|
||||
["/api/device-manager/projects:ensure", "projects:ensure"],
|
||||
["/api/device-manager/collections:ensure", "collections:ensure"],
|
||||
["/api/device-manager/devices:claim", "devices:claim"],
|
||||
]);
|
||||
|
||||
export function createDeviceManagerServer({
|
||||
auth,
|
||||
coreClient,
|
||||
distRoot = resolve(appRoot, "dist"),
|
||||
} = {}) {
|
||||
if (!auth || typeof auth.authorize !== "function") {
|
||||
throw new TypeError("device_manager_auth_required");
|
||||
}
|
||||
if (!coreClient || typeof coreClient.listProjects !== "function") {
|
||||
throw new TypeError("device_manager_core_client_required");
|
||||
}
|
||||
|
||||
return createServer(async (request, response) => {
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
response.setHeader("Referrer-Policy", "same-origin");
|
||||
response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||
try {
|
||||
const url = new URL(
|
||||
request.url || "/",
|
||||
`http://${request.headers.host || "127.0.0.1"}`,
|
||||
);
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/healthz") {
|
||||
return sendJson(response, 200, {
|
||||
ok: true,
|
||||
service: "nodedc-device-manager",
|
||||
authRequired: auth.authRequired,
|
||||
deviceCoreConfigured: coreClient.configured === true,
|
||||
});
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/auth/nodedc/handoff") {
|
||||
return auth.handleHandoff(request, response, url);
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/auth/logout") {
|
||||
return auth.handleLogout(request, response);
|
||||
}
|
||||
if (await auth.authorize(request, response, url)) return;
|
||||
const context = auth.currentContext(request);
|
||||
if (!context) return sendJson(response, 401, {
|
||||
ok: false,
|
||||
error: "device_manager_auth_required",
|
||||
});
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/api/device-manager/session") {
|
||||
return sendJson(response, 200, { ok: true, session: context });
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/api/device-manager/projects") {
|
||||
const projects = await coreClient.listProjects(context.actor);
|
||||
return sendJson(response, 200, { ok: true, projects });
|
||||
}
|
||||
const projectRef = workspaceProjectRef(url.pathname);
|
||||
if (request.method === "GET" && projectRef) {
|
||||
const workspace = await coreClient.getWorkspace(context.actor, projectRef);
|
||||
return sendJson(response, 200, { ok: true, workspace });
|
||||
}
|
||||
const command = mutationRoutes.get(url.pathname);
|
||||
if (request.method === "POST" && command) {
|
||||
const idempotencyKey = singleHeader(request.headers["idempotency-key"]);
|
||||
const input = await readJsonBody(request, 64 * 1024);
|
||||
const execution = await coreClient.execute(
|
||||
command,
|
||||
context.actor,
|
||||
input,
|
||||
idempotencyKey,
|
||||
);
|
||||
response.setHeader("Idempotency-Key", idempotencyKey);
|
||||
response.setHeader(
|
||||
"Idempotency-Replayed",
|
||||
execution.replayed ? "true" : "false",
|
||||
);
|
||||
return sendJson(response, 200, { ok: true, ...execution });
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
return sendJson(response, 404, { ok: false, error: "device_manager_route_not_found" });
|
||||
}
|
||||
return serveStatic(request, response, url, distRoot);
|
||||
} catch (error) {
|
||||
const statusCode = normalizeStatus(error?.statusCode);
|
||||
return sendJson(response, statusCode, {
|
||||
ok: false,
|
||||
error: safeError(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function createConfiguredDeviceManagerServer({ env = process.env } = {}) {
|
||||
const localPreview = booleanValue(env.NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW, false);
|
||||
const auth = createDeviceManagerAuth({ env });
|
||||
let coreClient;
|
||||
if (localPreview) {
|
||||
if (String(env.NODE_ENV || "").toLowerCase() === "production") {
|
||||
throw new Error("device_manager_local_preview_forbidden");
|
||||
}
|
||||
coreClient = createLocalPreviewDeviceCore();
|
||||
} else {
|
||||
const tokenFile = String(env.NODEDC_DEVICE_CORE_TOKEN_FILE || "").trim();
|
||||
if (!tokenFile) throw new Error("device_core_token_file_required");
|
||||
const token = (await readFile(tokenFile, "utf8")).trim();
|
||||
coreClient = createDeviceCoreClient({
|
||||
baseUrl: env.NODEDC_DEVICE_CORE_INTERNAL_URL,
|
||||
token,
|
||||
});
|
||||
}
|
||||
return createDeviceManagerServer({ auth, coreClient });
|
||||
}
|
||||
|
||||
async function serveStatic(request, response, url, root) {
|
||||
const requestedPath = url.pathname === "/" ? "/index.html" : url.pathname;
|
||||
const candidate = resolve(root, `.${decodeURIComponent(requestedPath)}`);
|
||||
const normalizedRoot = resolve(root);
|
||||
if (candidate !== normalizedRoot && !candidate.startsWith(`${normalizedRoot}${sep}`)) {
|
||||
return sendJson(response, 404, { ok: false, error: "device_manager_asset_not_found" });
|
||||
}
|
||||
let filePath = candidate;
|
||||
let info = await stat(filePath).catch(() => null);
|
||||
if ((!info || !info.isFile()) && !extname(requestedPath)) {
|
||||
filePath = resolve(root, "index.html");
|
||||
info = await stat(filePath).catch(() => null);
|
||||
}
|
||||
if (!info?.isFile()) {
|
||||
return sendJson(response, 404, { ok: false, error: "device_manager_asset_not_found" });
|
||||
}
|
||||
response.statusCode = 200;
|
||||
response.setHeader("Content-Type", contentType(filePath));
|
||||
response.setHeader(
|
||||
"Cache-Control",
|
||||
filePath.endsWith("index.html") ? "no-store" : "private, max-age=300",
|
||||
);
|
||||
response.setHeader("Content-Length", info.size);
|
||||
if (request.method === "HEAD") return response.end();
|
||||
createReadStream(filePath).pipe(response);
|
||||
}
|
||||
|
||||
function workspaceProjectRef(pathname) {
|
||||
const match = pathname.match(/^\/api\/device-manager\/projects\/([^/]+)\/workspace$/);
|
||||
if (!match) return null;
|
||||
const projectRef = decodeURIComponent(match[1]);
|
||||
return /^project:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(projectRef)
|
||||
? projectRef.toLowerCase()
|
||||
: null;
|
||||
}
|
||||
|
||||
async function readJsonBody(request, maxBytes) {
|
||||
let size = 0;
|
||||
const chunks = [];
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) throw serviceError("device_manager_request_too_large", 413);
|
||||
chunks.push(chunk);
|
||||
}
|
||||
try {
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error();
|
||||
return body;
|
||||
} catch {
|
||||
throw serviceError("device_manager_json_invalid", 400);
|
||||
}
|
||||
}
|
||||
|
||||
function singleHeader(value) {
|
||||
if (Array.isArray(value) || typeof value !== "string") {
|
||||
throw serviceError("device_idempotency_key_invalid", 400);
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (!/^[\x21-\x7e]{8,256}$/.test(normalized)) {
|
||||
throw serviceError("device_idempotency_key_invalid", 400);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sendJson(response, statusCode, body) {
|
||||
response.statusCode = statusCode;
|
||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function contentType(pathname) {
|
||||
return ({
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".ico": "image/x-icon",
|
||||
})[extname(pathname).toLowerCase()] || "application/octet-stream";
|
||||
}
|
||||
|
||||
function normalizeStatus(value) {
|
||||
const status = Number(value || 500);
|
||||
return Number.isInteger(status) && status >= 400 && status < 600 ? status : 500;
|
||||
}
|
||||
|
||||
function safeError(error) {
|
||||
const value = String(error?.message || "");
|
||||
return /^(?:device|nodedc)_[a-z0-9._:-]{2,160}$/.test(value)
|
||||
? value
|
||||
: "device_manager_internal_error";
|
||||
}
|
||||
|
||||
function booleanValue(value, fallback) {
|
||||
if (value == null || value === "") return fallback;
|
||||
return ["1", "true", "yes", "on"].includes(String(value).toLowerCase());
|
||||
}
|
||||
|
||||
function serviceError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const server = await createConfiguredDeviceManagerServer();
|
||||
const port = Number.parseInt(process.env.PORT || "3335", 10);
|
||||
const host = String(process.env.HOST || "127.0.0.1");
|
||||
server.listen(port, host, () => {
|
||||
console.log(JSON.stringify({
|
||||
event: "device_manager_started",
|
||||
host,
|
||||
port,
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createLocalPreviewDeviceCore } from "./device-core-client.mjs";
|
||||
import { createDeviceManagerAuth } from "./device-manager-auth.mjs";
|
||||
import { createDeviceManagerServer } from "./device-manager-server.mjs";
|
||||
|
||||
test("Device Manager BFF exposes an empty, mutation-driven project workspace", async (t) => {
|
||||
const auth = createDeviceManagerAuth({
|
||||
env: { NODEDC_DEVICE_MANAGER_AUTH_REQUIRED: "false" },
|
||||
});
|
||||
const coreClient = createLocalPreviewDeviceCore();
|
||||
const server = createDeviceManagerServer({ auth, coreClient });
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
const address = server.address();
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
const session = await getJson(`${baseUrl}/api/device-manager/session`);
|
||||
assert.equal(session.session.actor.hubRole, "owner");
|
||||
assert.equal(session.session.actor.userRef, "user:local-device-admin");
|
||||
assert.deepEqual((await getJson(`${baseUrl}/api/device-manager/projects`)).projects, []);
|
||||
|
||||
await postJson(`${baseUrl}/api/device-manager/owner-scopes:ensure`, {
|
||||
scopeKind: "personal",
|
||||
ownerRef: "user:local-device-admin",
|
||||
displayName: "Local Device Admin",
|
||||
}, {
|
||||
"X-NODEDC-Hub-Role": "viewer",
|
||||
"X-NODEDC-User-Ref": "user:spoofed-browser",
|
||||
});
|
||||
const created = await postJson(`${baseUrl}/api/device-manager/projects:ensure`, {
|
||||
scopeKind: "personal",
|
||||
ownerRef: "user:local-device-admin",
|
||||
projectKey: "device-sandbox",
|
||||
name: "Device sandbox",
|
||||
description: "Created only through the canonical command path",
|
||||
});
|
||||
const projectRef = created.result.project.projectRef;
|
||||
|
||||
const projects = (await getJson(`${baseUrl}/api/device-manager/projects`)).projects;
|
||||
assert.equal(projects.length, 1);
|
||||
assert.equal(projects[0].projectKey, "device-sandbox");
|
||||
assert.equal(projects[0].ownerScope.ownerRef, "user:local-device-admin");
|
||||
|
||||
await postJson(`${baseUrl}/api/device-manager/collections:ensure`, {
|
||||
projectRef,
|
||||
collectionKey: "pilot-devices",
|
||||
name: "Pilot devices",
|
||||
description: null,
|
||||
});
|
||||
const workspace = await getJson(
|
||||
`${baseUrl}/api/device-manager/projects/${encodeURIComponent(projectRef)}/workspace`,
|
||||
);
|
||||
assert.equal(workspace.workspace.project.projectRef, projectRef);
|
||||
assert.equal(workspace.workspace.collections[0].collectionKey, "pilot-devices");
|
||||
assert.deepEqual(workspace.workspace.devices, []);
|
||||
|
||||
const missingKey = await fetch(`${baseUrl}/api/device-manager/projects:ensure`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
assert.equal(missingKey.status, 400);
|
||||
assert.equal((await missingKey.json()).error, "device_idempotency_key_invalid");
|
||||
});
|
||||
|
||||
async function getJson(url) {
|
||||
const response = await fetch(url, { headers: { accept: "application/json" } });
|
||||
const body = await response.json();
|
||||
assert.equal(response.status, 200, JSON.stringify(body));
|
||||
assert.equal(body.ok, true);
|
||||
return body;
|
||||
}
|
||||
|
||||
async function postJson(url, body, headers = {}) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/json",
|
||||
"idempotency-key": `device-manager-test-${crypto.randomUUID()}`,
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await response.json();
|
||||
assert.equal(response.status, 200, JSON.stringify(payload));
|
||||
assert.equal(payload.ok, true);
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import {
|
||||
AdminNavigationPanel,
|
||||
AppHeader,
|
||||
ApplicationPanel,
|
||||
ApplicationShell,
|
||||
Button,
|
||||
GlassSurface,
|
||||
HeaderNavigation,
|
||||
HeaderProfile,
|
||||
HeaderWorkspace,
|
||||
Icon,
|
||||
Select,
|
||||
SettingsCard,
|
||||
StatusBadge,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
UserProfileMenu,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
useApplicationWorkspace,
|
||||
} from "@nodedc/ui-react";
|
||||
import {
|
||||
claimDevice,
|
||||
ensureCollection,
|
||||
ensureOwnerScope,
|
||||
ensureProject,
|
||||
loadProjects,
|
||||
loadSession,
|
||||
loadWorkspace,
|
||||
} from "./api";
|
||||
import type {
|
||||
DeviceManagerSession,
|
||||
EnrollmentView,
|
||||
OwnerScopeClaim,
|
||||
ProjectSummary,
|
||||
ProjectWorkspace,
|
||||
} from "./types";
|
||||
|
||||
type ViewId = "overview" | "inventory" | "discovery" | "collections";
|
||||
|
||||
const navigationItems = [
|
||||
{ id: "overview", label: "Обзор", icon: "grid" },
|
||||
{ id: "inventory", label: "Устройства", icon: "apps" },
|
||||
{ id: "discovery", label: "Подключение", icon: "network" },
|
||||
{ id: "collections", label: "Коллекции", icon: "folder" },
|
||||
] as const;
|
||||
|
||||
export function DeviceManagerApp() {
|
||||
const shell = useApplicationWorkspace<ViewId>({ navigationOpen: true });
|
||||
const [session, setSession] = useState<DeviceManagerSession | null>(null);
|
||||
const [projects, setProjects] = useState<ProjectSummary[]>([]);
|
||||
const [workspace, setWorkspace] = useState<ProjectWorkspace | null>(null);
|
||||
const [activeOwnerRef, setActiveOwnerRef] = useState("");
|
||||
const [activeProjectRef, setActiveProjectRef] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
|
||||
const [collectionDialogOpen, setCollectionDialogOpen] = useState(false);
|
||||
const [claimEnrollment, setClaimEnrollment] = useState<EnrollmentView | null>(null);
|
||||
|
||||
const refreshProjects = async () => {
|
||||
const next = await loadProjects();
|
||||
setProjects(next);
|
||||
setActiveProjectRef((current) =>
|
||||
next.some((project) => project.projectRef === current)
|
||||
? current
|
||||
: next[0]?.projectRef ?? ""
|
||||
);
|
||||
return next;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
Promise.all([loadSession(), loadProjects()])
|
||||
.then(([nextSession, nextProjects]) => {
|
||||
if (!active) return;
|
||||
setSession(nextSession);
|
||||
setProjects(nextProjects);
|
||||
const ownerRef = nextProjects[0]?.ownerScope.ownerRef
|
||||
|| nextSession.actor.ownerScopes[0]?.ownerRef
|
||||
|| "";
|
||||
setActiveOwnerRef(ownerRef);
|
||||
setActiveProjectRef(nextProjects[0]?.projectRef ?? "");
|
||||
})
|
||||
.catch((reason) => active && setError(errorText(reason)))
|
||||
.finally(() => active && setLoading(false));
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeProjectRef) {
|
||||
setWorkspace(null);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
loadWorkspace(activeProjectRef)
|
||||
.then((next) => active && setWorkspace(next))
|
||||
.catch((reason) => active && setError(errorText(reason)));
|
||||
return () => { active = false; };
|
||||
}, [activeProjectRef]);
|
||||
|
||||
const ownerScopes = useMemo(
|
||||
() => mergeOwnerScopes(session?.actor.ownerScopes ?? [], projects),
|
||||
[projects, session],
|
||||
);
|
||||
const visibleProjects = useMemo(
|
||||
() => projects.filter((project) => !activeOwnerRef || project.ownerScope.ownerRef === activeOwnerRef),
|
||||
[activeOwnerRef, projects],
|
||||
);
|
||||
const activeProject = projects.find((project) => project.projectRef === activeProjectRef) ?? null;
|
||||
const capabilities = new Set(activeProject?.access.capabilities ?? []);
|
||||
const canCreateProject = Boolean(
|
||||
session?.actor.ownerScopes.some((scope) => scope.ownerRef === activeOwnerRef),
|
||||
);
|
||||
const canManageCollections = capabilities.has("collection.manage");
|
||||
const canClaim = capabilities.has("device.claim");
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeOwnerRef && ownerScopes[0]) setActiveOwnerRef(ownerScopes[0].ownerRef);
|
||||
}, [activeOwnerRef, ownerScopes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeProjectRef
|
||||
&& !visibleProjects.some((project) => project.projectRef === activeProjectRef)
|
||||
) {
|
||||
setActiveProjectRef(visibleProjects[0]?.projectRef ?? "");
|
||||
setWorkspace(null);
|
||||
}
|
||||
}, [activeOwnerRef, activeProjectRef, visibleProjects]);
|
||||
|
||||
const openProject = (projectRef: string, view: ViewId = "overview") => {
|
||||
setActiveProjectRef(projectRef);
|
||||
shell.openView(view);
|
||||
};
|
||||
|
||||
const refreshWorkspace = async () => {
|
||||
if (!activeProjectRef) return;
|
||||
const next = await loadWorkspace(activeProjectRef);
|
||||
setWorkspace(next);
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
if (loading || !session) {
|
||||
return <div className="device-manager-boot">Подключаем Device Core…</div>;
|
||||
}
|
||||
|
||||
const activeView = shell.activeView ?? "overview";
|
||||
return (
|
||||
<>
|
||||
<ApplicationShell
|
||||
header={
|
||||
<AppHeader
|
||||
brand={<span className="device-manager-brand">NODE.DC</span>}
|
||||
brandHref="/"
|
||||
left={
|
||||
<button
|
||||
className="device-manager-workspace-trigger"
|
||||
type="button"
|
||||
aria-label="Открыть Device Core"
|
||||
onClick={shell.toggleNavigation}
|
||||
>
|
||||
<HeaderWorkspace label="Device Core" />
|
||||
</button>
|
||||
}
|
||||
center={
|
||||
<HeaderNavigation
|
||||
label="Device Core"
|
||||
value="devices"
|
||||
items={[{ value: "devices", label: "Device Core" }]}
|
||||
onChange={() => shell.openNavigation()}
|
||||
/>
|
||||
}
|
||||
right={
|
||||
<HeaderProfile>
|
||||
<UserProfileMenu
|
||||
displayName={session.user.displayName}
|
||||
subtitle={session.user.email}
|
||||
avatarUrl={session.user.avatarUrl ?? undefined}
|
||||
actions={[
|
||||
{ id: "profile", label: "Профиль", icon: "profile", href: session.profileUrl },
|
||||
{ id: "logout", label: "Выйти", icon: "external", href: "/auth/logout" },
|
||||
]}
|
||||
/>
|
||||
</HeaderProfile>
|
||||
}
|
||||
/>
|
||||
}
|
||||
stage={
|
||||
<DeviceStage
|
||||
ownerScopes={ownerScopes}
|
||||
activeOwnerRef={activeOwnerRef}
|
||||
onOwnerChange={setActiveOwnerRef}
|
||||
projects={visibleProjects}
|
||||
canCreateProject={canCreateProject}
|
||||
onCreateProject={() => setProjectDialogOpen(true)}
|
||||
onOpenProject={openProject}
|
||||
error={error}
|
||||
onDismissError={() => setError(null)}
|
||||
/>
|
||||
}
|
||||
navigationOpen={shell.navigationOpen}
|
||||
navigation={
|
||||
<AdminNavigationPanel
|
||||
eyebrow="DEVICE CORE"
|
||||
title="Устройства"
|
||||
contexts={activeProject ? [{
|
||||
id: activeProject.projectRef,
|
||||
label: activeProject.name,
|
||||
description: activeProject.ownerScope.displayName,
|
||||
active: true,
|
||||
icon: <Icon name="apps" />,
|
||||
}] : []}
|
||||
contextSlot={
|
||||
ownerScopes.length ? (
|
||||
<Select
|
||||
value={activeOwnerRef}
|
||||
label="Контур владельца"
|
||||
options={ownerScopes.map((scope) => ({
|
||||
value: scope.ownerRef,
|
||||
label: scope.displayName,
|
||||
description: scope.scopeKind === "company" ? "Компания" : "Личный контур",
|
||||
}))}
|
||||
onChange={setActiveOwnerRef}
|
||||
searchable={ownerScopes.length > 6}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
items={activeProject ? navigationItems.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
icon: <Icon name={item.icon} />,
|
||||
})) : []}
|
||||
activeId={shell.activeView ?? undefined}
|
||||
footer={<span>{session.actor.hubRole} · {projects.length} проектов</span>}
|
||||
onClose={shell.closeNavigation}
|
||||
onItemChange={(id) => shell.openView(id as ViewId)}
|
||||
/>
|
||||
}
|
||||
contentOpen={shell.contentOpen}
|
||||
contentExpanded={shell.contentExpanded}
|
||||
content={
|
||||
activeProject ? (
|
||||
<ApplicationPanel
|
||||
eyebrow={activeProject.ownerScope.displayName}
|
||||
title={viewTitle(activeView)}
|
||||
description={activeProject.name}
|
||||
expanded={shell.contentExpanded}
|
||||
onExpandedChange={shell.setContentExpanded}
|
||||
onClose={shell.closeView}
|
||||
utilityActions={[{
|
||||
label: "Обновить данные",
|
||||
icon: "refresh",
|
||||
onClick: () => refreshWorkspace().catch((reason) => setError(errorText(reason))),
|
||||
}]}
|
||||
>
|
||||
<ProjectView
|
||||
view={activeView}
|
||||
workspace={workspace}
|
||||
canManageCollections={canManageCollections}
|
||||
canClaim={canClaim}
|
||||
onCreateCollection={() => setCollectionDialogOpen(true)}
|
||||
onClaim={setClaimEnrollment}
|
||||
/>
|
||||
</ApplicationPanel>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<ProjectDialog
|
||||
open={projectDialogOpen}
|
||||
ownerScope={ownerScopes.find((scope) => scope.ownerRef === activeOwnerRef) ?? null}
|
||||
onClose={() => setProjectDialogOpen(false)}
|
||||
onCreated={async () => {
|
||||
setProjectDialogOpen(false);
|
||||
await refreshProjects();
|
||||
}}
|
||||
onError={(reason) => setError(errorText(reason))}
|
||||
/>
|
||||
<CollectionDialog
|
||||
open={collectionDialogOpen}
|
||||
project={activeProject}
|
||||
onClose={() => setCollectionDialogOpen(false)}
|
||||
onCreated={async () => {
|
||||
setCollectionDialogOpen(false);
|
||||
await refreshWorkspace();
|
||||
}}
|
||||
onError={(reason) => setError(errorText(reason))}
|
||||
/>
|
||||
<ClaimDialog
|
||||
enrollment={claimEnrollment}
|
||||
project={activeProject}
|
||||
onClose={() => setClaimEnrollment(null)}
|
||||
onClaimed={async () => {
|
||||
setClaimEnrollment(null);
|
||||
await refreshWorkspace();
|
||||
}}
|
||||
onError={(reason) => setError(errorText(reason))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceStage({
|
||||
ownerScopes,
|
||||
activeOwnerRef,
|
||||
onOwnerChange,
|
||||
projects,
|
||||
canCreateProject,
|
||||
onCreateProject,
|
||||
onOpenProject,
|
||||
error,
|
||||
onDismissError,
|
||||
}: {
|
||||
ownerScopes: OwnerScopeClaim[];
|
||||
activeOwnerRef: string;
|
||||
onOwnerChange: (value: string) => void;
|
||||
projects: ProjectSummary[];
|
||||
canCreateProject: boolean;
|
||||
onCreateProject: () => void;
|
||||
onOpenProject: (projectRef: string, view?: ViewId) => void;
|
||||
error: string | null;
|
||||
onDismissError: () => void;
|
||||
}) {
|
||||
const totals = projects.reduce(
|
||||
(sum, project) => ({
|
||||
devices: sum.devices + project.counts.devices,
|
||||
collections: sum.collections + project.counts.collections,
|
||||
discoveries: sum.discoveries + project.counts.discoveries,
|
||||
}),
|
||||
{ devices: 0, collections: 0, discoveries: 0 },
|
||||
);
|
||||
return (
|
||||
<div className="device-manager-stage">
|
||||
<section className="device-manager-hero">
|
||||
<div>
|
||||
<span>UNIVERSAL DEVICE PLANE</span>
|
||||
<h1>Все устройства — в одном управляемом контуре.</h1>
|
||||
<p>Проекты, безопасное подключение, инвентарь и коллекции без привязки интерфейса к конкретному производителю.</p>
|
||||
</div>
|
||||
<div className="device-manager-hero__actions">
|
||||
{ownerScopes.length ? (
|
||||
<Select
|
||||
value={activeOwnerRef}
|
||||
label="Контур владельца"
|
||||
options={ownerScopes.map((scope) => ({
|
||||
value: scope.ownerRef,
|
||||
label: scope.displayName,
|
||||
description: scope.scopeKind === "company" ? "Компания" : "Личный контур",
|
||||
}))}
|
||||
onChange={onOwnerChange}
|
||||
/>
|
||||
) : null}
|
||||
<Button variant="primary" icon={<Icon name="plus" />} disabled={!canCreateProject} onClick={onCreateProject}>
|
||||
Новый проект
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<div className="device-manager-alert" role="alert">
|
||||
<Icon name="alert" />
|
||||
<span>{error}</span>
|
||||
<Button variant="ghost" size="compact" onClick={onDismissError}>Закрыть</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="device-manager-metrics" aria-label="Состояние контура">
|
||||
<Metric label="Проекты" value={projects.length} detail="доступно пользователю" />
|
||||
<Metric label="Устройства" value={totals.devices} detail="в выбранном контуре" />
|
||||
<Metric label="Подключение" value={totals.discoveries} detail="ожидают решения" tone={totals.discoveries ? "warning" : "success"} />
|
||||
<Metric label="Коллекции" value={totals.collections} detail="логических групп" />
|
||||
</section>
|
||||
|
||||
<section className="device-manager-projects">
|
||||
<div className="device-manager-section-title">
|
||||
<div><span>DEVICE PROJECTS</span><h2>Рабочие проекты</h2></div>
|
||||
<small>Доступ определяется Hub и project grants</small>
|
||||
</div>
|
||||
{projects.length ? (
|
||||
<div className="device-manager-project-grid">
|
||||
{projects.map((project) => (
|
||||
<button key={project.projectRef} type="button" className="device-project-card" onClick={() => onOpenProject(project.projectRef)}>
|
||||
<span className="device-project-card__icon"><Icon name="apps" size={20} /></span>
|
||||
<span className="device-project-card__body">
|
||||
<small>{project.ownerScope.displayName}</small>
|
||||
<strong>{project.name}</strong>
|
||||
<span>{project.description || "Универсальный проект устройств"}</span>
|
||||
</span>
|
||||
<span className="device-project-card__stats">
|
||||
<b>{project.counts.devices}</b> устройств
|
||||
<b>{project.counts.collections}</b> коллекций
|
||||
</span>
|
||||
<StatusBadge tone={project.lifecycleState === "active" ? "success" : "warning"}>
|
||||
{project.access.projectRole || "read"}
|
||||
</StatusBadge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<GlassSurface className="device-manager-empty" padding="lg" tone="soft">
|
||||
<Icon name="inbox" size={24} />
|
||||
<h3>В этом контуре пока нет проектов</h3>
|
||||
<p>Создание доступно только в owner scope, подтверждённом Hub.</p>
|
||||
</GlassSurface>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value, detail, tone = "neutral" }: { label: string; value: number; detail: string; tone?: "neutral" | "success" | "warning" }) {
|
||||
return (
|
||||
<GlassSurface className="device-manager-metric" padding="md" tone="soft">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<StatusBadge tone={tone}>{detail}</StatusBadge>
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectView({ view, workspace, canManageCollections, canClaim, onCreateCollection, onClaim }: {
|
||||
view: ViewId;
|
||||
workspace: ProjectWorkspace | null;
|
||||
canManageCollections: boolean;
|
||||
canClaim: boolean;
|
||||
onCreateCollection: () => void;
|
||||
onClaim: (enrollment: EnrollmentView) => void;
|
||||
}) {
|
||||
if (!workspace) return <div className="device-manager-panel-empty">Загружаем проект…</div>;
|
||||
if (view === "inventory") return (
|
||||
<EntityList
|
||||
empty="В проекте ещё нет зарегистрированных устройств."
|
||||
items={workspace.devices.map((device) => ({
|
||||
id: device.deviceRef,
|
||||
title: device.displayName,
|
||||
subtitle: `${device.modelProfileRef} · ${device.identifier?.masked ?? "идентификатор не назначен"}`,
|
||||
status: device.session?.state || device.lifecycleState,
|
||||
tone: device.session?.state === "online" ? "success" : "neutral",
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
if (view === "discovery") return (
|
||||
<div className="device-manager-stack">
|
||||
{workspace.enrollments.map((enrollment) => (
|
||||
<SettingsCard
|
||||
key={enrollment.enrollmentIntentRef}
|
||||
eyebrow={enrollment.lifecycleState}
|
||||
title={enrollment.displayName}
|
||||
description={`${enrollment.modelProfileRef} · ${enrollment.expectedIdentifier.masked}`}
|
||||
actions={enrollment.lifecycleState === "observed" && enrollment.observedDiscoveryRef ? (
|
||||
<Button size="compact" variant="primary" disabled={!canClaim} onClick={() => onClaim(enrollment)}>
|
||||
Принять устройство
|
||||
</Button>
|
||||
) : <StatusBadge>{enrollment.lifecycleState}</StatusBadge>}
|
||||
>
|
||||
<p className="device-manager-card-copy">Enrollment и quarantine evidence остаются связанными; raw identifier в интерфейс не передаётся.</p>
|
||||
</SettingsCard>
|
||||
))}
|
||||
{!workspace.enrollments.length ? <div className="device-manager-panel-empty">Нет ожидающих подключений.</div> : null}
|
||||
</div>
|
||||
);
|
||||
if (view === "collections") return (
|
||||
<div className="device-manager-stack">
|
||||
<div className="device-manager-panel-toolbar">
|
||||
<p>Коллекции группируют устройства для последующих bindings и задач.</p>
|
||||
<Button icon={<Icon name="plus" />} disabled={!canManageCollections} onClick={onCreateCollection}>Новая коллекция</Button>
|
||||
</div>
|
||||
<EntityList
|
||||
empty="Коллекций пока нет."
|
||||
items={workspace.collections.map((collection) => ({
|
||||
id: collection.collectionRef,
|
||||
title: collection.name,
|
||||
subtitle: collection.description || collection.collectionKey,
|
||||
status: `${collection.memberCount} устройств`,
|
||||
tone: "neutral",
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="device-manager-overview-grid">
|
||||
<Metric label="Устройства" value={workspace.devices.length} detail="inventory" />
|
||||
<Metric label="Quarantine" value={workspace.discoveries.filter((item) => item.lifecycleState === "quarantine").length} detail="safe projection" />
|
||||
<Metric label="Коллекции" value={workspace.collections.length} detail="bindings ready" />
|
||||
<SettingsCard title="Права проекта" description={workspace.project.access.projectRole || "read"}>
|
||||
<div className="device-manager-capabilities">
|
||||
{workspace.project.access.capabilities.map((capability) => <span key={capability}>{capability}</span>)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityList({ items, empty }: { items: Array<{ id: string; title: string; subtitle: string; status: string; tone: "neutral" | "success" | "warning" }>; empty: string }) {
|
||||
if (!items.length) return <div className="device-manager-panel-empty">{empty}</div>;
|
||||
return <div className="device-manager-entity-list">{items.map((item) => (
|
||||
<GlassSurface key={item.id} className="device-manager-entity" padding="md" tone="soft">
|
||||
<span className="device-manager-entity__icon"><Icon name="circle" /></span>
|
||||
<span className="device-manager-entity__body"><strong>{item.title}</strong><small>{item.subtitle}</small></span>
|
||||
<StatusBadge tone={item.tone}>{item.status}</StatusBadge>
|
||||
</GlassSurface>
|
||||
))}</div>;
|
||||
}
|
||||
|
||||
function ProjectDialog({ open, ownerScope, onClose, onCreated, onError }: {
|
||||
open: boolean;
|
||||
ownerScope: OwnerScopeClaim | null;
|
||||
onClose: () => void;
|
||||
onCreated: () => Promise<void>;
|
||||
onError: (reason: unknown) => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [key, setKey] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!ownerScope) return;
|
||||
setPending(true);
|
||||
try {
|
||||
await ensureOwnerScope(ownerScope);
|
||||
await ensureProject({
|
||||
scopeKind: ownerScope.scopeKind,
|
||||
ownerRef: ownerScope.ownerRef,
|
||||
projectKey: key,
|
||||
name,
|
||||
description: description.trim() || null,
|
||||
});
|
||||
setName(""); setKey(""); setDescription("");
|
||||
await onCreated();
|
||||
} catch (reason) { onError(reason); } finally { setPending(false); }
|
||||
};
|
||||
return <Window open={open} title="Новый Device Project" subtitle={ownerScope?.displayName || "Owner scope недоступен"} onClose={onClose} footer={
|
||||
<WindowFooterActions><Button variant="ghost" onClick={onClose}>Отмена</Button><Button type="submit" form="device-project-form" variant="primary" disabled={pending || !ownerScope}>{pending ? "Создаём…" : "Создать"}</Button></WindowFooterActions>
|
||||
}>
|
||||
<form id="device-project-form" className="device-manager-form" onSubmit={submit}>
|
||||
<TextField label="Название" value={name} onChange={(event) => setName(event.target.value)} required maxLength={160} />
|
||||
<TextField label="Ключ проекта" value={key} onChange={(event) => setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" description="Стабильный ключ: латиница, цифры и дефис." />
|
||||
<TextAreaField label="Описание" value={description} onChange={(event) => setDescription(event.target.value)} maxLength={2000} />
|
||||
</form>
|
||||
</Window>;
|
||||
}
|
||||
|
||||
function CollectionDialog({ open, project, onClose, onCreated, onError }: {
|
||||
open: boolean; project: ProjectSummary | null; onClose: () => void; onCreated: () => Promise<void>; onError: (reason: unknown) => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [key, setKey] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault(); if (!project) return; setPending(true);
|
||||
try {
|
||||
await ensureCollection({ projectRef: project.projectRef, collectionKey: key, name, description: description.trim() || null });
|
||||
setName(""); setKey(""); setDescription(""); await onCreated();
|
||||
} catch (reason) { onError(reason); } finally { setPending(false); }
|
||||
};
|
||||
return <Window open={open} title="Новая коллекция" subtitle={project?.name} onClose={onClose} footer={
|
||||
<WindowFooterActions><Button variant="ghost" onClick={onClose}>Отмена</Button><Button type="submit" form="device-collection-form" variant="primary" disabled={pending || !project}>{pending ? "Сохраняем…" : "Создать"}</Button></WindowFooterActions>
|
||||
}>
|
||||
<form id="device-collection-form" className="device-manager-form" onSubmit={submit}>
|
||||
<TextField label="Название" value={name} onChange={(event) => setName(event.target.value)} required maxLength={160} />
|
||||
<TextField label="Ключ коллекции" value={key} onChange={(event) => setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" />
|
||||
<TextAreaField label="Описание" value={description} onChange={(event) => setDescription(event.target.value)} maxLength={2000} />
|
||||
</form>
|
||||
</Window>;
|
||||
}
|
||||
|
||||
function ClaimDialog({ enrollment, project, onClose, onClaimed, onError }: {
|
||||
enrollment: EnrollmentView | null; project: ProjectSummary | null; onClose: () => void; onClaimed: () => Promise<void>; onError: (reason: unknown) => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [key, setKey] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
useEffect(() => { if (enrollment) setName(enrollment.displayName); }, [enrollment]);
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!project || !enrollment?.observedDiscoveryRef) return;
|
||||
setPending(true);
|
||||
try {
|
||||
await claimDevice({
|
||||
projectRef: project.projectRef,
|
||||
enrollmentIntentRef: enrollment.enrollmentIntentRef,
|
||||
discoveryRef: enrollment.observedDiscoveryRef,
|
||||
deviceKey: key,
|
||||
displayName: name,
|
||||
});
|
||||
setName(""); setKey(""); await onClaimed();
|
||||
} catch (reason) { onError(reason); } finally { setPending(false); }
|
||||
};
|
||||
return <Window open={Boolean(enrollment)} title="Принять устройство" subtitle={enrollment?.expectedIdentifier.masked} onClose={onClose} footer={
|
||||
<WindowFooterActions><Button variant="ghost" onClick={onClose}>Отмена</Button><Button type="submit" form="device-claim-form" variant="primary" disabled={pending || !enrollment}>{pending ? "Проверяем…" : "Принять"}</Button></WindowFooterActions>
|
||||
}>
|
||||
<form id="device-claim-form" className="device-manager-form" onSubmit={submit}>
|
||||
<TextField label="Название устройства" value={name} onChange={(event) => setName(event.target.value)} required maxLength={160} />
|
||||
<TextField label="Ключ устройства" value={key} onChange={(event) => setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" />
|
||||
<p className="device-manager-card-copy">Claim использует только ссылки на enrollment и discovery. Исходный идентификатор не запрашивается повторно.</p>
|
||||
</form>
|
||||
</Window>;
|
||||
}
|
||||
|
||||
function mergeOwnerScopes(claims: OwnerScopeClaim[], projects: ProjectSummary[]) {
|
||||
const scopes = new Map(claims.map((scope) => [scope.ownerRef, scope]));
|
||||
for (const project of projects) {
|
||||
if (!scopes.has(project.ownerScope.ownerRef)) {
|
||||
scopes.set(project.ownerScope.ownerRef, {
|
||||
scopeKind: project.ownerScope.scopeKind,
|
||||
ownerRef: project.ownerScope.ownerRef,
|
||||
displayName: project.ownerScope.displayName,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...scopes.values()];
|
||||
}
|
||||
|
||||
function viewTitle(view: ViewId) {
|
||||
return ({ overview: "Обзор проекта", inventory: "Устройства", discovery: "Подключение", collections: "Коллекции" })[view];
|
||||
}
|
||||
|
||||
function errorText(reason: unknown) {
|
||||
const value = reason instanceof Error ? reason.message : String(reason || "device_manager_error");
|
||||
const labels: Record<string, string> = {
|
||||
device_project_capability_denied: "Недостаточно прав в выбранном проекте.",
|
||||
device_owner_scope_access_denied: "Hub не подтвердил право управлять этим контуром.",
|
||||
device_manager_auth_unavailable: "Проверка Hub-сессии временно недоступна.",
|
||||
};
|
||||
return labels[value] || value;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type {
|
||||
DeviceManagerSession,
|
||||
ProjectSummary,
|
||||
ProjectWorkspace,
|
||||
ScopeKind,
|
||||
} from "./types";
|
||||
|
||||
export async function loadSession(): Promise<DeviceManagerSession> {
|
||||
return requestJson<{ ok: true; session: DeviceManagerSession }>(
|
||||
"/api/device-manager/session",
|
||||
).then((value) => value.session);
|
||||
}
|
||||
|
||||
export async function loadProjects(): Promise<ProjectSummary[]> {
|
||||
return requestJson<{ ok: true; projects: ProjectSummary[] }>(
|
||||
"/api/device-manager/projects",
|
||||
).then((value) => value.projects);
|
||||
}
|
||||
|
||||
export async function loadWorkspace(projectRef: string): Promise<ProjectWorkspace> {
|
||||
return requestJson<{ ok: true; workspace: ProjectWorkspace }>(
|
||||
`/api/device-manager/projects/${encodeURIComponent(projectRef)}/workspace`,
|
||||
).then((value) => value.workspace);
|
||||
}
|
||||
|
||||
export async function ensureOwnerScope(input: {
|
||||
scopeKind: ScopeKind;
|
||||
ownerRef: string;
|
||||
displayName: string;
|
||||
}) {
|
||||
return mutate("/api/device-manager/owner-scopes:ensure", input);
|
||||
}
|
||||
|
||||
export async function ensureProject(input: {
|
||||
scopeKind: ScopeKind;
|
||||
ownerRef: string;
|
||||
projectKey: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
}) {
|
||||
return mutate("/api/device-manager/projects:ensure", input);
|
||||
}
|
||||
|
||||
export async function ensureCollection(input: {
|
||||
projectRef: string;
|
||||
collectionKey: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
}) {
|
||||
return mutate("/api/device-manager/collections:ensure", input);
|
||||
}
|
||||
|
||||
export async function claimDevice(input: {
|
||||
projectRef: string;
|
||||
enrollmentIntentRef: string;
|
||||
discoveryRef: string;
|
||||
deviceKey: string;
|
||||
displayName: string;
|
||||
}) {
|
||||
return mutate("/api/device-manager/devices:claim", input);
|
||||
}
|
||||
|
||||
async function mutate(path: string, input: unknown) {
|
||||
return requestJson<{ ok: true; replayed: boolean; result: unknown }>(path, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": `device-manager-${crypto.randomUUID()}`,
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json", ...(init?.headers ?? {}) },
|
||||
...init,
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok || !body?.ok) {
|
||||
const error = new Error(body?.error || `device_manager_request_failed:${response.status}`);
|
||||
Object.assign(error, { status: response.status });
|
||||
throw error;
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { applyNodedcTheme } from "@nodedc/ui-core";
|
||||
import "@nodedc/ui-core/styles.css";
|
||||
import "./styles.css";
|
||||
import { DeviceManagerApp } from "./DeviceManagerApp";
|
||||
|
||||
applyNodedcTheme(document.documentElement, {
|
||||
theme: "dark",
|
||||
accent: [185, 255, 74],
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<DeviceManagerApp />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,418 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
background: #0b0d0f;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
min-width: 320px;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 76% 18%, rgb(185 255 74 / 0.09), transparent 34%),
|
||||
radial-gradient(circle at 12% 84%, rgb(79 127 255 / 0.08), transparent 31%),
|
||||
#0b0d0f;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.device-manager-boot {
|
||||
display: grid;
|
||||
height: 100%;
|
||||
place-items: center;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font: 500 0.9rem/1.4 var(--nodedc-font-family);
|
||||
}
|
||||
|
||||
.device-manager-brand {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
.device-manager-workspace-trigger {
|
||||
display: inline-grid;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.device-manager-stage {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: clamp(2.25rem, 5vw, 5.5rem);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.device-manager-hero {
|
||||
display: flex;
|
||||
max-width: 1420px;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
margin: 0 auto 2.5rem;
|
||||
}
|
||||
|
||||
.device-manager-hero > div:first-child {
|
||||
max-width: 780px;
|
||||
}
|
||||
|
||||
.device-manager-hero span,
|
||||
.device-manager-section-title span {
|
||||
color: rgb(var(--nodedc-accent-rgb));
|
||||
font-size: 0.68rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.device-manager-hero h1 {
|
||||
max-width: 760px;
|
||||
margin: 0.7rem 0 0.9rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: clamp(2.25rem, 5vw, 5.4rem);
|
||||
font-weight: 520;
|
||||
letter-spacing: -0.065em;
|
||||
line-height: 0.98;
|
||||
}
|
||||
|
||||
.device-manager-hero p {
|
||||
max-width: 680px;
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: clamp(0.96rem, 1.4vw, 1.14rem);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.device-manager-hero__actions {
|
||||
display: flex;
|
||||
min-width: min(100%, 390px);
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.device-manager-hero__actions .nodedc-select-anchor {
|
||||
min-width: 210px;
|
||||
}
|
||||
|
||||
.device-manager-alert {
|
||||
display: flex;
|
||||
max-width: 1420px;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0 auto 1.25rem;
|
||||
border-radius: 18px;
|
||||
background: rgb(var(--nodedc-danger-rgb) / 0.11);
|
||||
color: var(--nodedc-text-primary);
|
||||
padding: 0.7rem 0.8rem 0.7rem 1rem;
|
||||
}
|
||||
|
||||
.device-manager-alert span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.device-manager-metrics {
|
||||
display: grid;
|
||||
max-width: 1420px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 0 auto 3.5rem;
|
||||
}
|
||||
|
||||
.device-manager-metric {
|
||||
display: grid;
|
||||
min-height: 142px;
|
||||
align-content: space-between;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.device-manager-metric > span:first-child {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.device-manager-metric > strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: clamp(2rem, 3vw, 3.25rem);
|
||||
font-weight: 520;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.device-manager-metric .nodedc-status {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.device-manager-projects {
|
||||
max-width: 1420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.device-manager-section-title {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.15rem;
|
||||
}
|
||||
|
||||
.device-manager-section-title h2 {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 1.45rem;
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
.device-manager-section-title small {
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.device-manager-project-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.device-project-card {
|
||||
display: grid;
|
||||
min-height: 210px;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-rows: 1fr auto;
|
||||
gap: 1rem;
|
||||
border: 0;
|
||||
border-radius: var(--nodedc-radius-card);
|
||||
background: var(--nodedc-glass-panel-bg-soft);
|
||||
color: var(--nodedc-text-primary);
|
||||
padding: 1.25rem;
|
||||
text-align: left;
|
||||
box-shadow: var(--nodedc-glass-panel-shadow);
|
||||
cursor: pointer;
|
||||
transition: background 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.device-project-card:hover {
|
||||
background: var(--nodedc-glass-control-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.device-project-card__icon,
|
||||
.device-manager-entity__icon {
|
||||
display: grid;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: rgb(var(--nodedc-accent-rgb) / 0.12);
|
||||
color: rgb(var(--nodedc-accent-rgb));
|
||||
}
|
||||
|
||||
.device-project-card__body {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.device-project-card__body small,
|
||||
.device-project-card__body span,
|
||||
.device-project-card__stats {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.device-project-card__body strong {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.device-project-card__stats {
|
||||
display: flex;
|
||||
grid-column: 1 / 3;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.device-project-card__stats b {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.device-project-card > .nodedc-status {
|
||||
grid-column: 3;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.device-manager-empty {
|
||||
display: grid;
|
||||
min-height: 220px;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.65rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.device-manager-empty h3,
|
||||
.device-manager-empty p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.device-manager-empty p,
|
||||
.device-manager-card-copy {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.device-manager-panel-empty {
|
||||
display: grid;
|
||||
min-height: 220px;
|
||||
place-items: center;
|
||||
color: var(--nodedc-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.device-manager-stack,
|
||||
.device-manager-entity-list {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.device-manager-panel-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.device-manager-panel-toolbar p {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.device-manager-entity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.device-manager-entity__icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.device-manager-entity__body {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.device-manager-entity__body small {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.74rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-manager-overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.device-manager-overview-grid .nodedc-settings-card {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.device-manager-capabilities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.device-manager-capabilities span {
|
||||
border-radius: 999px;
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
color: var(--nodedc-text-secondary);
|
||||
padding: 0.45rem 0.7rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.device-manager-form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.device-manager-form textarea {
|
||||
min-height: 110px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.device-manager-hero {
|
||||
align-items: start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.device-manager-hero__actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.device-manager-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
body { overflow: auto; }
|
||||
|
||||
.device-manager-stage {
|
||||
padding: 2rem 1rem 4rem;
|
||||
}
|
||||
|
||||
.device-manager-hero h1 {
|
||||
font-size: clamp(2.25rem, 14vw, 3.5rem);
|
||||
}
|
||||
|
||||
.device-manager-hero__actions,
|
||||
.device-manager-panel-toolbar,
|
||||
.device-manager-section-title {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.device-manager-hero__actions > *,
|
||||
.device-manager-hero__actions .nodedc-select-anchor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.device-manager-metrics,
|
||||
.device-manager-overview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.device-manager-overview-grid .nodedc-settings-card {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.device-project-card {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.device-project-card > .nodedc-status {
|
||||
grid-column: 2;
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
export type HubRole = "viewer" | "member" | "admin" | "owner";
|
||||
export type ScopeKind = "company" | "personal";
|
||||
|
||||
export interface OwnerScopeClaim {
|
||||
scopeKind: ScopeKind;
|
||||
ownerRef: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface DeviceManagerSession {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
initials: string;
|
||||
};
|
||||
actor: {
|
||||
userRef: string;
|
||||
hubRole: HubRole;
|
||||
groupRefs: string[];
|
||||
ownerScopes: OwnerScopeClaim[];
|
||||
};
|
||||
profileUrl: string;
|
||||
}
|
||||
|
||||
export interface ProjectSummary {
|
||||
projectRef: string;
|
||||
projectKey: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
lifecycleState: string;
|
||||
ownerScope: {
|
||||
ownerScopeRef: string;
|
||||
scopeKind: ScopeKind;
|
||||
ownerRef: string;
|
||||
displayName: string;
|
||||
};
|
||||
access: {
|
||||
projectRole: string | null;
|
||||
capabilities: string[];
|
||||
};
|
||||
counts: { devices: number; collections: number; discoveries: number };
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceView {
|
||||
deviceRef: string;
|
||||
deviceKey: string | null;
|
||||
displayName: string;
|
||||
modelProfileRef: string;
|
||||
lifecycleState: string;
|
||||
identifier: { kind: string; masked: string } | null;
|
||||
session: { state: string; lastSeenAt: string | null } | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CollectionView {
|
||||
collectionRef: string;
|
||||
collectionKey: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
lifecycleState: string;
|
||||
memberCount: number;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface DiscoveryView {
|
||||
discoveryRef: string;
|
||||
identifier: { kind: string; masked: string };
|
||||
modelProfileRef: string;
|
||||
protocol: string;
|
||||
lifecycleState: string;
|
||||
enrollmentIntentRef: string | null;
|
||||
claimedDeviceRef: string | null;
|
||||
firstObservedAt: string | null;
|
||||
lastObservedAt: string | null;
|
||||
}
|
||||
|
||||
export interface EnrollmentView {
|
||||
enrollmentIntentRef: string;
|
||||
enrollmentKey: string;
|
||||
displayName: string;
|
||||
modelProfileRef: string;
|
||||
expectedIdentifier: { kind: string; masked: string };
|
||||
lifecycleState: string;
|
||||
observedDiscoveryRef: string | null;
|
||||
claimedDeviceRef: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectWorkspace {
|
||||
project: ProjectSummary;
|
||||
devices: DeviceView[];
|
||||
collections: CollectionView[];
|
||||
discoveries: DiscoveryView[];
|
||||
enrollments: EnrollmentView[];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: { sourcemap: true },
|
||||
});
|
||||
Generated
+21
@@ -43,6 +43,23 @@
|
||||
"vite-plugin-static-copy": "^4.1.1"
|
||||
}
|
||||
},
|
||||
"apps/device-manager": {
|
||||
"name": "@nodedc/device-manager",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@nodedc/ui-core": "0.7.0",
|
||||
"@nodedc/ui-react": "0.7.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
@@ -921,6 +938,10 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodedc/device-manager": {
|
||||
"resolved": "apps/device-manager",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@nodedc/map-cesium-react": {
|
||||
"resolved": "packages/map-cesium-react",
|
||||
"link": true
|
||||
|
||||
Reference in New Issue
Block a user