From 4116f5ba9594a7a3872558be8e432ea25bf7071e Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 10 Aug 2026 20:10:43 +0300 Subject: [PATCH] feat(device-manager): add standalone project workspace --- apps/device-manager/README.md | 50 ++ apps/device-manager/index.html | 13 + apps/device-manager/package.json | 26 + .../server/device-core-client.mjs | 232 +++++++ .../server/device-core-client.test.mjs | 103 +++ .../server/device-manager-auth.mjs | 403 +++++++++++ .../server/device-manager-auth.test.mjs | 127 ++++ .../server/device-manager-server.mjs | 244 +++++++ .../server/device-manager-server.test.mjs | 94 +++ apps/device-manager/src/DeviceManagerApp.tsx | 630 ++++++++++++++++++ apps/device-manager/src/api.ts | 87 +++ apps/device-manager/src/main.tsx | 17 + apps/device-manager/src/styles.css | 418 ++++++++++++ apps/device-manager/src/types.ts | 103 +++ apps/device-manager/tsconfig.json | 9 + apps/device-manager/vite.config.ts | 7 + package-lock.json | 21 + 17 files changed, 2584 insertions(+) create mode 100644 apps/device-manager/README.md create mode 100644 apps/device-manager/index.html create mode 100644 apps/device-manager/package.json create mode 100644 apps/device-manager/server/device-core-client.mjs create mode 100644 apps/device-manager/server/device-core-client.test.mjs create mode 100644 apps/device-manager/server/device-manager-auth.mjs create mode 100644 apps/device-manager/server/device-manager-auth.test.mjs create mode 100644 apps/device-manager/server/device-manager-server.mjs create mode 100644 apps/device-manager/server/device-manager-server.test.mjs create mode 100644 apps/device-manager/src/DeviceManagerApp.tsx create mode 100644 apps/device-manager/src/api.ts create mode 100644 apps/device-manager/src/main.tsx create mode 100644 apps/device-manager/src/styles.css create mode 100644 apps/device-manager/src/types.ts create mode 100644 apps/device-manager/tsconfig.json create mode 100644 apps/device-manager/vite.config.ts diff --git a/apps/device-manager/README.md b/apps/device-manager/README.md new file mode 100644 index 0000000..8f81539 --- /dev/null +++ b/apps/device-manager/README.md @@ -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. diff --git a/apps/device-manager/index.html b/apps/device-manager/index.html new file mode 100644 index 0000000..30163c1 --- /dev/null +++ b/apps/device-manager/index.html @@ -0,0 +1,13 @@ + + + + + + + NODE.DC Device Core + + +
+ + + diff --git a/apps/device-manager/package.json b/apps/device-manager/package.json new file mode 100644 index 0000000..84885dd --- /dev/null +++ b/apps/device-manager/package.json @@ -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" + } +} diff --git a/apps/device-manager/server/device-core-client.mjs b/apps/device-manager/server/device-core-client.mjs new file mode 100644 index 0000000..cc94cb6 --- /dev/null +++ b/apps/device-manager/server/device-core-client.mjs @@ -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; +} diff --git a/apps/device-manager/server/device-core-client.test.mjs b/apps/device-manager/server/device-core-client.test.mjs new file mode 100644 index 0000000..d71c0c7 --- /dev/null +++ b/apps/device-manager/server/device-core-client.test.mjs @@ -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" }, + }); +} diff --git a/apps/device-manager/server/device-manager-auth.mjs b/apps/device-manager/server/device-manager-auth.mjs new file mode 100644 index 0000000..3b46aa2 --- /dev/null +++ b/apps/device-manager/server/device-manager-auth.mjs @@ -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; +} diff --git a/apps/device-manager/server/device-manager-auth.test.mjs b/apps/device-manager/server/device-manager-auth.test.mjs new file mode 100644 index 0000000..f22434c --- /dev/null +++ b/apps/device-manager/server/device-manager-auth.test.mjs @@ -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" }, + }); +} diff --git a/apps/device-manager/server/device-manager-server.mjs b/apps/device-manager/server/device-manager-server.mjs new file mode 100644 index 0000000..0a5f8c7 --- /dev/null +++ b/apps/device-manager/server/device-manager-server.mjs @@ -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, + })); + }); +} diff --git a/apps/device-manager/server/device-manager-server.test.mjs b/apps/device-manager/server/device-manager-server.test.mjs new file mode 100644 index 0000000..8cee497 --- /dev/null +++ b/apps/device-manager/server/device-manager-server.test.mjs @@ -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; +} diff --git a/apps/device-manager/src/DeviceManagerApp.tsx b/apps/device-manager/src/DeviceManagerApp.tsx new file mode 100644 index 0000000..d6babc8 --- /dev/null +++ b/apps/device-manager/src/DeviceManagerApp.tsx @@ -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({ navigationOpen: true }); + const [session, setSession] = useState(null); + const [projects, setProjects] = useState([]); + const [workspace, setWorkspace] = useState(null); + const [activeOwnerRef, setActiveOwnerRef] = useState(""); + const [activeProjectRef, setActiveProjectRef] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [projectDialogOpen, setProjectDialogOpen] = useState(false); + const [collectionDialogOpen, setCollectionDialogOpen] = useState(false); + const [claimEnrollment, setClaimEnrollment] = useState(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
Подключаем Device Core…
; + } + + const activeView = shell.activeView ?? "overview"; + return ( + <> + NODE.DC} + brandHref="/" + left={ + + } + center={ + shell.openNavigation()} + /> + } + right={ + + + + } + /> + } + stage={ + setProjectDialogOpen(true)} + onOpenProject={openProject} + error={error} + onDismissError={() => setError(null)} + /> + } + navigationOpen={shell.navigationOpen} + navigation={ + , + }] : []} + contextSlot={ + ownerScopes.length ? ( + ({ + value: scope.ownerRef, + label: scope.displayName, + description: scope.scopeKind === "company" ? "Компания" : "Личный контур", + }))} + onChange={onOwnerChange} + /> + ) : null} + + + + + {error ? ( +
+ + {error} + +
+ ) : null} + +
+ + + + +
+ +
+
+
DEVICE PROJECTS

Рабочие проекты

+ Доступ определяется Hub и project grants +
+ {projects.length ? ( +
+ {projects.map((project) => ( + + ))} +
+ ) : ( + + +

В этом контуре пока нет проектов

+

Создание доступно только в owner scope, подтверждённом Hub.

+
+ )} +
+ + ); +} + +function Metric({ label, value, detail, tone = "neutral" }: { label: string; value: number; detail: string; tone?: "neutral" | "success" | "warning" }) { + return ( + + {label} + {value} + {detail} + + ); +} + +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
Загружаем проект…
; + if (view === "inventory") return ( + ({ + 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 ( +
+ {workspace.enrollments.map((enrollment) => ( + onClaim(enrollment)}> + Принять устройство + + ) : {enrollment.lifecycleState}} + > +

Enrollment и quarantine evidence остаются связанными; raw identifier в интерфейс не передаётся.

+
+ ))} + {!workspace.enrollments.length ?
Нет ожидающих подключений.
: null} +
+ ); + if (view === "collections") return ( +
+
+

Коллекции группируют устройства для последующих bindings и задач.

+ +
+ ({ + id: collection.collectionRef, + title: collection.name, + subtitle: collection.description || collection.collectionKey, + status: `${collection.memberCount} устройств`, + tone: "neutral", + }))} + /> +
+ ); + return ( +
+ + item.lifecycleState === "quarantine").length} detail="safe projection" /> + + +
+ {workspace.project.access.capabilities.map((capability) => {capability})} +
+
+
+ ); +} + +function EntityList({ items, empty }: { items: Array<{ id: string; title: string; subtitle: string; status: string; tone: "neutral" | "success" | "warning" }>; empty: string }) { + if (!items.length) return
{empty}
; + return
{items.map((item) => ( + + + {item.title}{item.subtitle} + {item.status} + + ))}
; +} + +function ProjectDialog({ open, ownerScope, onClose, onCreated, onError }: { + open: boolean; + ownerScope: OwnerScopeClaim | null; + onClose: () => void; + onCreated: () => Promise; + 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 + }> +
+ setName(event.target.value)} required maxLength={160} /> + setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" description="Стабильный ключ: латиница, цифры и дефис." /> + setDescription(event.target.value)} maxLength={2000} /> + +
; +} + +function CollectionDialog({ open, project, onClose, onCreated, onError }: { + open: boolean; project: ProjectSummary | null; onClose: () => void; onCreated: () => Promise; 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 + }> +
+ setName(event.target.value)} required maxLength={160} /> + setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" /> + setDescription(event.target.value)} maxLength={2000} /> + +
; +} + +function ClaimDialog({ enrollment, project, onClose, onClaimed, onError }: { + enrollment: EnrollmentView | null; project: ProjectSummary | null; onClose: () => void; onClaimed: () => Promise; 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 + }> +
+ setName(event.target.value)} required maxLength={160} /> + setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" /> +

Claim использует только ссылки на enrollment и discovery. Исходный идентификатор не запрашивается повторно.

+ +
; +} + +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 = { + device_project_capability_denied: "Недостаточно прав в выбранном проекте.", + device_owner_scope_access_denied: "Hub не подтвердил право управлять этим контуром.", + device_manager_auth_unavailable: "Проверка Hub-сессии временно недоступна.", + }; + return labels[value] || value; +} diff --git a/apps/device-manager/src/api.ts b/apps/device-manager/src/api.ts new file mode 100644 index 0000000..f9ded11 --- /dev/null +++ b/apps/device-manager/src/api.ts @@ -0,0 +1,87 @@ +import type { + DeviceManagerSession, + ProjectSummary, + ProjectWorkspace, + ScopeKind, +} from "./types"; + +export async function loadSession(): Promise { + return requestJson<{ ok: true; session: DeviceManagerSession }>( + "/api/device-manager/session", + ).then((value) => value.session); +} + +export async function loadProjects(): Promise { + return requestJson<{ ok: true; projects: ProjectSummary[] }>( + "/api/device-manager/projects", + ).then((value) => value.projects); +} + +export async function loadWorkspace(projectRef: string): Promise { + 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(path: string, init?: RequestInit): Promise { + 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; +} diff --git a/apps/device-manager/src/main.tsx b/apps/device-manager/src/main.tsx new file mode 100644 index 0000000..c7e1457 --- /dev/null +++ b/apps/device-manager/src/main.tsx @@ -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( + + + , +); diff --git a/apps/device-manager/src/styles.css b/apps/device-manager/src/styles.css new file mode 100644 index 0000000..45975a9 --- /dev/null +++ b/apps/device-manager/src/styles.css @@ -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; + } +} diff --git a/apps/device-manager/src/types.ts b/apps/device-manager/src/types.ts new file mode 100644 index 0000000..b86ed62 --- /dev/null +++ b/apps/device-manager/src/types.ts @@ -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[]; +} diff --git a/apps/device-manager/tsconfig.json b/apps/device-manager/tsconfig.json new file mode 100644 index 0000000..fd471c7 --- /dev/null +++ b/apps/device-manager/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/device-manager/vite.config.ts b/apps/device-manager/vite.config.ts new file mode 100644 index 0000000..b2f1a56 --- /dev/null +++ b/apps/device-manager/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 }, +}); diff --git a/package-lock.json b/package-lock.json index e58a60b..2c6f06d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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