feat(device-manager): add safe device enrollment

This commit is contained in:
Codex
2026-08-13 11:37:36 +03:00
parent a3385e83c4
commit d6c62da470
6 changed files with 396 additions and 14 deletions
@@ -89,6 +89,7 @@ export function createLocalPreviewDeviceCore() {
const modelProfiles = new Map();
const edges = new Map();
const routes = new Map();
const enrollments = new Map();
const bindings = new Map();
const grants = new Map();
const configurationRevisions = new Map();
@@ -131,7 +132,7 @@ export function createLocalPreviewDeviceCore() {
project: projectSummary(project),
devices: [],
discoveries: [],
enrollments: [],
enrollments: projectValues(enrollments, projectRef),
collections: projectValues(collections, projectRef)
.map(({ projectRef: _projectRef, ...collection }) => collection),
adapterPackages: [...adapterPackages.values()],
@@ -268,6 +269,12 @@ export function createLocalPreviewDeviceCore() {
);
const adapterPackageRef = existing?.adapterPackageRef
|| `adapter-package:${randomUUID()}`;
assertPreviewTransition(
existing?.lifecycleState,
input.lifecycleState ?? "active",
previewTransitions.adapterPackage,
"device_adapter_package_transition_invalid",
);
const adapterPackage = {
adapterPackageRef,
packageKey: input.packageKey,
@@ -282,15 +289,25 @@ export function createLocalPreviewDeviceCore() {
}
if (command === "adapter-versions:register") {
requirePlatformOwner(actor);
if (!adapterPackages.has(input.adapterPackageRef)) {
const adapterPackage = adapterPackages.get(input.adapterPackageRef);
if (!adapterPackage) {
throw serviceError("device_adapter_package_not_found", 404);
}
if (adapterPackage.lifecycleState !== "active") {
throw serviceError("device_adapter_package_inactive", 409);
}
const existing = [...adapterVersions.values()].find((entry) =>
entry.adapterPackageRef === input.adapterPackageRef
&& entry.version === input.version
);
const adapterVersionRef = existing?.adapterVersionRef
|| `adapter-version:${randomUUID()}`;
assertPreviewTransition(
existing?.lifecycleState,
input.lifecycleState ?? "draft",
previewTransitions.catalogVersion,
"device_adapter_version_transition_invalid",
);
const adapterVersion = {
adapterVersionRef,
adapterPackageRef: input.adapterPackageRef,
@@ -308,10 +325,21 @@ export function createLocalPreviewDeviceCore() {
}
if (command === "model-profiles:register") {
requirePlatformOwner(actor);
if (!adapterVersions.has(input.adapterVersionRef)) {
const adapterVersion = adapterVersions.get(input.adapterVersionRef);
if (!adapterVersion) {
throw serviceError("device_adapter_version_not_found", 404);
}
const existing = modelProfiles.get(input.profileRef);
const lifecycleState = input.lifecycleState ?? "draft";
if (lifecycleState === "active" && adapterVersion.lifecycleState !== "active") {
throw serviceError("device_model_profile_adapter_not_active", 409);
}
assertPreviewTransition(
existing?.lifecycleState,
lifecycleState,
previewTransitions.catalogVersion,
"device_model_profile_transition_invalid",
);
const modelProfile = {
modelProfileRef: input.profileRef,
adapterVersionRef: input.adapterVersionRef,
@@ -323,7 +351,7 @@ export function createLocalPreviewDeviceCore() {
schemaArtifactRef: input.schemaArtifactRef,
profileDigest: input.profileDigest,
capabilities: input.capabilities ?? [],
lifecycleState: input.lifecycleState ?? "draft",
lifecycleState,
createdAt: existing?.createdAt || now(),
updatedAt: now(),
};
@@ -336,6 +364,12 @@ export function createLocalPreviewDeviceCore() {
(entry) => entry.edgeKey === input.edgeKey,
);
const edgeRef = existing?.edgeRef || `edge:${randomUUID()}`;
assertPreviewTransition(
existing?.lifecycleState,
input.lifecycleState ?? "provisioning",
previewTransitions.edge,
"device_edge_transition_invalid",
);
const edge = {
edgeRef,
edgeKey: input.edgeKey,
@@ -360,6 +394,19 @@ export function createLocalPreviewDeviceCore() {
(entry) => entry.routeKey === input.routeKey,
);
const routeRef = existing?.routeRef || `route:${randomUUID()}`;
const lifecycleState = input.lifecycleState ?? "draft";
if (
lifecycleState === "active"
&& (edge.lifecycleState !== "active" || profile.lifecycleState !== "active")
) {
throw serviceError("device_route_dependency_not_active", 409);
}
assertPreviewTransition(
existing?.lifecycleState,
lifecycleState,
previewTransitions.route,
"device_route_transition_invalid",
);
const route = {
routeRef,
projectRef: input.projectRef,
@@ -372,7 +419,7 @@ export function createLocalPreviewDeviceCore() {
listenerRef: input.listenerRef,
protocol: input.protocol,
direction: input.direction ?? "telemetry",
lifecycleState: input.lifecycleState ?? "draft",
lifecycleState,
sessionCount: 0,
activeSessionCount: 0,
createdAt: existing?.createdAt || now(),
@@ -432,7 +479,62 @@ export function createLocalPreviewDeviceCore() {
throw serviceError("device_configuration_revision_not_found", 404);
}
if (command === "enrollment-intents:ensure") {
throw serviceError("device_enrollment_secure_input_required", 409);
if (!projects.has(input.projectRef)) {
throw serviceError("device_project_not_found", 404);
}
const route = routes.get(input.routeRef);
if (!route || route.projectRef !== input.projectRef) {
throw serviceError("device_route_not_found", 404);
}
if (route.lifecycleState !== "active") {
throw serviceError("device_enrollment_route_inactive", 409);
}
if (route.modelProfileRef !== input.modelProfileRef) {
throw serviceError("device_enrollment_profile_mismatch", 409);
}
if (
input.identifier?.kind !== "imei"
|| typeof input.identifier.value !== "string"
|| !/^\d{15}$/.test(input.identifier.value)
) {
throw serviceError("restricted_identifier_imei_invalid", 400);
}
const existing = projectValues(enrollments, input.projectRef).find(
(entry) => entry.enrollmentKey === input.enrollmentKey,
);
const enrollmentIntentRef = existing?.enrollmentIntentRef
|| `enrollment-intent:${randomUUID()}`;
const enrollment = {
enrollmentIntentRef,
projectRef: input.projectRef,
enrollmentKey: input.enrollmentKey,
displayName: input.displayName,
routeRef: input.routeRef,
modelProfileRef: input.modelProfileRef,
expectedIdentifier: {
kind: "imei",
masked: `***********${input.identifier.value.slice(-4)}`,
},
lifecycleState: "pending",
observedDiscoveryRef: null,
claimedDeviceRef: null,
expiresAt: input.expiresAt ?? null,
createdAt: existing?.createdAt || now(),
updatedAt: now(),
};
enrollments.set(enrollmentIntentRef, enrollment);
audit(actor, input.projectRef, createdEvent(existing, "enrollment_intent"));
const { expectedIdentifier, ...safeEnrollment } = enrollment;
return {
replayed: false,
result: {
created: !existing,
enrollmentIntent: {
...safeEnrollment,
identifier: expectedIdentifier,
},
},
};
}
if (command === "devices:claim") {
throw serviceError("device_discovery_not_found", 404);
@@ -449,6 +551,7 @@ export function createLocalPreviewDeviceCore() {
modelProfiles,
edges,
routes,
enrollments,
bindings,
grants,
configurationRevisions,
@@ -469,6 +572,37 @@ function requirePlatformOwner(actor) {
}
}
const previewTransitions = Object.freeze({
adapterPackage: Object.freeze({
active: Object.freeze(["active", "retired"]),
retired: Object.freeze(["retired"]),
}),
catalogVersion: Object.freeze({
draft: Object.freeze(["draft", "active", "retired"]),
active: Object.freeze(["active", "retired"]),
retired: Object.freeze(["retired"]),
}),
edge: Object.freeze({
provisioning: Object.freeze(["provisioning", "active", "retired"]),
active: Object.freeze(["active", "suspended", "retired"]),
suspended: Object.freeze(["suspended", "active", "retired"]),
retired: Object.freeze(["retired"]),
}),
route: Object.freeze({
draft: Object.freeze(["draft", "active", "retired"]),
active: Object.freeze(["active", "suspended", "retired"]),
suspended: Object.freeze(["suspended", "active", "retired"]),
retired: Object.freeze(["retired"]),
}),
});
function assertPreviewTransition(previous, next, transitions, code) {
if (!previous) return;
if (!transitions[previous]?.includes(next)) {
throw serviceError(code, 409);
}
}
const ownerCapabilities = Object.freeze([
"project.read",
"project.manage",
@@ -122,7 +122,7 @@ test("local preview is empty and creates resources only through canonical comman
deploymentRef: "deployment:preview-edge",
lifecycleState: "provisioning",
});
await client.execute("routes:ensure", actor, {
const route = await client.execute("routes:ensure", actor, {
projectRef,
routeKey: "preview-route",
displayName: "Preview route",
@@ -133,6 +133,56 @@ test("local preview is empty and creates resources only through canonical comman
direction: "telemetry",
lifecycleState: "draft",
});
await client.execute("adapter-versions:register", platformActor, {
adapterPackageRef: adapterPackage.result.adapterPackage.adapterPackageRef,
version: "1.0.0",
runtimePackageRef: "artifact:generic-tracker:1.0.0",
contentDigest: `sha256:${"a".repeat(64)}`,
contractVersion: "device-adapter.v1",
capabilities: ["telemetry"],
lifecycleState: "active",
});
await client.execute("model-profiles:register", platformActor, {
adapterVersionRef: adapterVersion.result.adapterVersion.adapterVersionRef,
profileRef: "generic.tracker.v1",
schemaVersion: "1.0.0",
vendor: "Generic",
model: "Tracker",
deviceType: "tracker",
protocol: "INTERNAL",
schemaArtifactRef: "schema:generic.tracker.v1",
profileDigest: `sha256:${"b".repeat(64)}`,
capabilities: ["telemetry"],
lifecycleState: "active",
});
await client.execute("edges:ensure", platformActor, {
edgeKey: "preview-edge",
displayName: "Preview Edge",
deploymentRef: "deployment:preview-edge",
lifecycleState: "active",
});
await client.execute("routes:ensure", actor, {
projectRef,
routeKey: "preview-route",
displayName: "Preview route",
edgeRef: edge.result.edge.edgeRef,
modelProfileRef: modelProfile.result.modelProfile.modelProfileRef,
listenerRef: "listener:preview",
protocol: "INTERNAL",
direction: "telemetry",
lifecycleState: "active",
});
const enrollment = await client.execute("enrollment-intents:ensure", actor, {
projectRef,
enrollmentKey: "preview-device",
routeRef: route.result.route.routeRef,
modelProfileRef: modelProfile.result.modelProfile.modelProfileRef,
displayName: "Preview device",
identifier: { kind: "imei", value: "123456789012345" },
expiresAt: null,
});
assert.equal(enrollment.result.enrollmentIntent.identifier.masked, "***********2345");
assert.equal(JSON.stringify(enrollment).includes("123456789012345"), false);
const collectionRef = (await client.getWorkspace(actor, projectRef))
.collections[0].collectionRef;
await client.execute("device-bindings:ensure", actor, {
@@ -161,6 +211,8 @@ test("local preview is empty and creates resources only through canonical comman
assert.equal(workspace.collections[0].collectionKey, "field-devices");
assert.equal(workspace.adapterPackages[0].packageKey, "generic-tracker");
assert.equal(workspace.routes[0].routeKey, "preview-route");
assert.equal(workspace.routes[0].lifecycleState, "active");
assert.equal(workspace.enrollments[0].expectedIdentifier.masked, "***********2345");
assert.equal(workspace.bindings[0].lifecycleState, "pending_external_approval");
assert.equal(workspace.grants.length, 2);
assert.ok(workspace.auditEvents.some((event) => event.eventType === "device_binding.created"));
+81 -4
View File
@@ -76,6 +76,14 @@ export function DeviceControlView({
close();
await onRefresh();
};
const mutateAndRefresh = async (mutation: () => Promise<unknown>) => {
try {
await mutation();
await onRefresh();
} catch (reason) {
onError(reason);
}
};
return (
<>
@@ -86,6 +94,28 @@ export function DeviceControlView({
onCreatePackage={() => setDialog("adapter-package")}
onCreateVersion={() => setDialog("adapter-version")}
onCreateProfile={() => setDialog("model-profile")}
onActivateVersion={(version) => mutateAndRefresh(() => registerAdapterVersion({
adapterPackageRef: version.adapterPackageRef,
version: version.version,
runtimePackageRef: version.runtimePackageRef,
contentDigest: version.contentDigest,
contractVersion: version.contractVersion,
capabilities: version.capabilities,
lifecycleState: "active",
}))}
onActivateProfile={(profile) => mutateAndRefresh(() => registerModelProfile({
adapterVersionRef: profile.adapterVersionRef || "",
profileRef: profile.modelProfileRef,
schemaVersion: profile.schemaVersion,
vendor: profile.vendor,
model: profile.model,
deviceType: profile.deviceType,
protocol: profile.protocol,
schemaArtifactRef: profile.schemaArtifactRef || "",
profileDigest: profile.profileDigest || "",
capabilities: profile.capabilities,
lifecycleState: "active",
}))}
/>
) : null}
{view === "infrastructure" ? (
@@ -95,6 +125,23 @@ export function DeviceControlView({
canManageRoutes={capabilities.has("route.manage")}
onCreateEdge={() => setDialog("edge")}
onCreateRoute={() => setDialog("route")}
onActivateEdge={(edge) => mutateAndRefresh(() => ensureEdge({
edgeKey: edge.edgeKey,
displayName: edge.displayName,
deploymentRef: edge.deploymentRef,
lifecycleState: "active",
}))}
onActivateRoute={(route) => mutateAndRefresh(() => ensureRoute({
projectRef: workspace.project.projectRef,
routeKey: route.routeKey,
displayName: route.displayName,
edgeRef: route.edgeRef,
modelProfileRef: route.modelProfileRef,
listenerRef: route.listenerRef,
protocol: route.protocol,
direction: route.direction,
lifecycleState: "active",
}))}
/>
) : null}
{view === "sessions" ? <SessionsView workspace={workspace} /> : null}
@@ -185,12 +232,14 @@ export function DeviceControlView({
);
}
function CatalogView({ workspace, canManage, onCreatePackage, onCreateVersion, onCreateProfile }: {
function CatalogView({ workspace, canManage, onCreatePackage, onCreateVersion, onCreateProfile, onActivateVersion, onActivateProfile }: {
workspace: ProjectWorkspace;
canManage: boolean;
onCreatePackage: () => void;
onCreateVersion: () => void;
onCreateProfile: () => void;
onActivateVersion: (version: AdapterVersionView) => void;
onActivateProfile: (profile: ModelProfileView) => void;
}) {
return (
<ControlStack>
@@ -212,6 +261,26 @@ function CatalogView({ workspace, canManage, onCreatePackage, onCreateVersion, o
description={`${profile.protocol} · ${profile.modelProfileRef}`}
status={profile.lifecycleState}
meta={profile.capabilities}
action={canManage && profile.lifecycleState === "draft" && profile.adapterVersionRef && profile.schemaArtifactRef && profile.profileDigest ? (
<Button size="compact" variant="primary" onClick={() => onActivateProfile(profile)}>Активировать</Button>
) : null}
/>
))}
</ResourceGrid>
</ControlSection>
<ControlSection title="Adapter versions" count={workspace.adapterVersions.length}>
<ResourceGrid empty="Версии адаптеров не зарегистрированы.">
{workspace.adapterVersions.map((version) => (
<ResourceCard
key={version.adapterVersionRef}
eyebrow={version.contractVersion}
title={version.version}
description={version.runtimePackageRef}
status={version.lifecycleState}
meta={[shortDigest(version.contentDigest), ...version.capabilities]}
action={canManage && version.lifecycleState === "draft" ? (
<Button size="compact" variant="primary" onClick={() => onActivateVersion(version)}>Активировать</Button>
) : null}
/>
))}
</ResourceGrid>
@@ -236,12 +305,14 @@ function CatalogView({ workspace, canManage, onCreatePackage, onCreateVersion, o
);
}
function InfrastructureView({ workspace, canManageCatalog, canManageRoutes, onCreateEdge, onCreateRoute }: {
function InfrastructureView({ workspace, canManageCatalog, canManageRoutes, onCreateEdge, onCreateRoute, onActivateEdge, onActivateRoute }: {
workspace: ProjectWorkspace;
canManageCatalog: boolean;
canManageRoutes: boolean;
onCreateEdge: () => void;
onCreateRoute: () => void;
onActivateEdge: (edge: EdgeView) => void;
onActivateRoute: (route: ProjectWorkspace["routes"][number]) => void;
}) {
return (
<ControlStack>
@@ -265,6 +336,9 @@ function InfrastructureView({ workspace, canManageCatalog, canManageRoutes, onCr
route.listenerRef,
`${route.activeSessionCount}/${route.sessionCount} активных сессий`,
]}
action={canManageRoutes && ["draft", "suspended"].includes(route.lifecycleState) ? (
<Button size="compact" variant="primary" onClick={() => onActivateRoute(route)}>Активировать</Button>
) : null}
/>
))}
</ResourceGrid>
@@ -279,6 +353,9 @@ function InfrastructureView({ workspace, canManageCatalog, canManageRoutes, onCr
description={edge.edgeKey}
status={edge.lifecycleState}
meta={edge.deploymentRef ? [edge.deploymentRef] : []}
action={canManageCatalog && ["provisioning", "suspended"].includes(edge.lifecycleState) ? (
<Button size="compact" variant="primary" onClick={() => onActivateEdge(edge)}>Активировать</Button>
) : null}
/>
))}
</ResourceGrid>
@@ -741,8 +818,8 @@ function ResourceGrid({ children, empty }: { children: ReactNode; empty: string
return hasChildren ? <div className="device-control-resource-grid">{children}</div> : <div className="device-manager-panel-empty">{empty}</div>;
}
function ResourceCard({ eyebrow, title, description, status, meta }: { eyebrow: string; title: string; description: string; status: string; meta: string[] }) {
return <SettingsCard eyebrow={eyebrow} title={title} description={description} actions={<StatusBadge tone={statusTone(status)}>{status}</StatusBadge>}>
function ResourceCard({ eyebrow, title, description, status, meta, action = null }: { eyebrow: string; title: string; description: string; status: string; meta: string[]; action?: ReactNode }) {
return <SettingsCard eyebrow={eyebrow} title={title} description={description} actions={<><StatusBadge tone={statusTone(status)}>{status}</StatusBadge>{action}</>}>
{meta.length ? <div className="device-manager-capabilities">{meta.map((item) => <span key={item}>{item}</span>)}</div> : <p className="device-manager-card-copy">Metadata-only projection</p>}
</SettingsCard>;
}
+108 -1
View File
@@ -23,6 +23,7 @@ import {
import {
claimDevice,
ensureCollection,
ensureEnrollmentIntent,
ensureOwnerScope,
ensureProject,
loadProjects,
@@ -74,6 +75,7 @@ export function DeviceManagerApp() {
const [error, setError] = useState<string | null>(null);
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
const [collectionDialogOpen, setCollectionDialogOpen] = useState(false);
const [enrollmentDialogOpen, setEnrollmentDialogOpen] = useState(false);
const [claimEnrollment, setClaimEnrollment] = useState<EnrollmentView | null>(null);
const refreshProjects = async () => {
@@ -131,6 +133,7 @@ export function DeviceManagerApp() {
session?.actor.ownerScopes.some((scope) => scope.ownerRef === activeOwnerRef),
);
const canManageCollections = capabilities.has("collection.manage");
const canEnroll = capabilities.has("device.enroll");
const canClaim = capabilities.has("device.claim");
const visibleNavigationItems = navigationItems.filter(
(item) => item.capability === null || capabilities.has(item.capability),
@@ -279,11 +282,13 @@ export function DeviceManagerApp() {
view={activeView}
workspace={workspace}
canManageCollections={canManageCollections}
canEnroll={canEnroll}
canClaim={canClaim}
session={session}
onRefresh={refreshWorkspace}
onError={(reason) => setError(errorText(reason))}
onCreateCollection={() => setCollectionDialogOpen(true)}
onCreateEnrollment={() => setEnrollmentDialogOpen(true)}
onClaim={setClaimEnrollment}
/>
</ApplicationPanel>
@@ -321,6 +326,16 @@ export function DeviceManagerApp() {
}}
onError={(reason) => setError(errorText(reason))}
/>
<EnrollmentDialog
open={enrollmentDialogOpen}
workspace={workspace}
onClose={() => setEnrollmentDialogOpen(false)}
onCreated={async () => {
setEnrollmentDialogOpen(false);
await refreshWorkspace();
}}
onError={(reason) => setError(errorText(reason))}
/>
</>
);
}
@@ -443,15 +458,17 @@ function Metric({ label, value, detail, tone = "neutral" }: { label: string; val
);
}
function ProjectView({ view, workspace, canManageCollections, canClaim, session, onRefresh, onError, onCreateCollection, onClaim }: {
function ProjectView({ view, workspace, canManageCollections, canEnroll, canClaim, session, onRefresh, onError, onCreateCollection, onCreateEnrollment, onClaim }: {
view: ViewId;
workspace: ProjectWorkspace | null;
canManageCollections: boolean;
canEnroll: boolean;
canClaim: boolean;
session: DeviceManagerSession;
onRefresh: () => Promise<void>;
onError: (reason: unknown) => void;
onCreateCollection: () => void;
onCreateEnrollment: () => void;
onClaim: (enrollment: EnrollmentView) => void;
}) {
if (!workspace) return <div className="device-manager-panel-empty">Загружаем проект</div>;
@@ -478,6 +495,17 @@ function ProjectView({ view, workspace, canManageCollections, canClaim, session,
);
if (view === "discovery") return (
<div className="device-manager-stack">
<div className="device-manager-panel-toolbar">
<p>Заранее разрешите конкретный идентификатор на активном маршруте. Core сохранит только HMAC и маску.</p>
<Button
icon={<Icon name="plus" />}
variant="primary"
disabled={!canEnroll || !workspace.routes.some((route) => route.lifecycleState === "active")}
onClick={onCreateEnrollment}
>
Подключить устройство
</Button>
</div>
{workspace.enrollments.map((enrollment) => (
<SettingsCard
key={enrollment.enrollmentIntentRef}
@@ -636,6 +664,85 @@ function ClaimDialog({ enrollment, project, onClose, onClaimed, onError }: {
</Window>;
}
function EnrollmentDialog({ open, workspace, onClose, onCreated, onError }: {
open: boolean;
workspace: ProjectWorkspace | null;
onClose: () => void;
onCreated: () => Promise<void>;
onError: (reason: unknown) => void;
}) {
const activeRoutes = useMemo(
() => workspace?.routes.filter((route) => route.lifecycleState === "active") ?? [],
[workspace],
);
const [routeRef, setRouteRef] = useState("");
const [name, setName] = useState("");
const [key, setKey] = useState("");
const [imei, setImei] = useState("");
const [expiresAt, setExpiresAt] = useState("");
const [pending, setPending] = useState(false);
useEffect(() => {
if (!activeRoutes.some((route) => route.routeRef === routeRef)) {
setRouteRef(activeRoutes[0]?.routeRef ?? "");
}
}, [activeRoutes, routeRef]);
useEffect(() => {
if (!open) setImei("");
}, [open]);
const submit = async (event: FormEvent) => {
event.preventDefault();
const route = activeRoutes.find((item) => item.routeRef === routeRef);
if (!workspace || !route) return;
setPending(true);
try {
await ensureEnrollmentIntent({
projectRef: workspace.project.projectRef,
enrollmentKey: key,
routeRef: route.routeRef,
modelProfileRef: route.modelProfileRef,
displayName: name,
identifier: { kind: "imei", value: imei },
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
});
setImei("");
setName("");
setKey("");
setExpiresAt("");
await onCreated();
} catch (reason) {
onError(reason);
} finally {
setPending(false);
}
};
return <Window open={open} title="Подключить устройство" subtitle={workspace?.project.name} onClose={onClose} footer={
<WindowFooterActions>
<Button variant="ghost" onClick={onClose}>Отмена</Button>
<Button type="submit" form="device-enrollment-form" variant="primary" disabled={pending || !workspace || !routeRef}>
{pending ? "Защищаем идентификатор…" : "Создать enrollment"}
</Button>
</WindowFooterActions>
}>
<form id="device-enrollment-form" className="device-manager-form" onSubmit={submit}>
<Select
label="Активный маршрут"
value={routeRef}
onChange={setRouteRef}
options={activeRoutes.map((route) => ({
value: route.routeRef,
label: route.displayName,
description: `${route.protocol} · ${route.modelProfileRef}`,
}))}
/>
<TextField label="Название устройства" value={name} onChange={(event) => setName(event.target.value)} required maxLength={160} />
<TextField label="Ключ enrollment" value={key} onChange={(event) => setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" />
<TextField label="IMEI" value={imei} onChange={(event) => setImei(event.target.value.replace(/\D/g, "").slice(0, 15))} required pattern="[0-9]{15}" inputMode="numeric" autoComplete="off" />
<TextField label="Истекает" type="datetime-local" value={expiresAt} onChange={(event) => setExpiresAt(event.target.value)} description="Опционально. Время будет сохранено в UTC." />
<p className="device-manager-card-copy">IMEI передаётся один раз по HTTPS в Device Control Core. В базе, audit и ответе останутся только HMAC и маска.</p>
</form>
</Window>;
}
function mergeOwnerScopes(claims: OwnerScopeClaim[], projects: ProjectSummary[]) {
const scopes = new Map(claims.map((scope) => [scope.ownerRef, scope]));
for (const project of projects) {
+12
View File
@@ -68,6 +68,18 @@ export async function claimDevice(input: {
return mutate("/api/device-manager/devices:claim", input);
}
export async function ensureEnrollmentIntent(input: {
projectRef: string;
enrollmentKey: string;
routeRef: string;
modelProfileRef: string;
displayName: string;
identifier: { kind: "imei"; value: string };
expiresAt: string | null;
}) {
return mutate("/api/device-manager/enrollment-intents:ensure", input);
}
export async function upsertProjectGrant(input: {
projectRef: string;
principalKind: "user" | "group";
+2 -2
View File
@@ -153,8 +153,8 @@ export interface RouteView {
profileName: string;
listenerRef: string;
protocol: string;
direction: string;
lifecycleState: string;
direction: "telemetry" | "bidirectional";
lifecycleState: "draft" | "active" | "suspended" | "retired";
sessionCount: number;
activeSessionCount: number;
createdAt: string | null;