@frockbot/plugin-user-machine 0.0.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/frockbot.json +66 -0
- package/package.json +54 -6
- package/src/agent.test.ts +509 -0
- package/src/agent.ts +733 -0
- package/src/approval.test.ts +323 -0
- package/src/approval.ts +148 -0
- package/src/backend.test.ts +370 -0
- package/src/backend.ts +370 -0
- package/src/client/MachineSection.vue +97 -0
- package/src/client/MachineSurface.vue +308 -0
- package/src/client/index.test.ts +244 -0
- package/src/client/index.ts +180 -0
- package/src/client/state.ts +49 -0
- package/src/delivery.ts +145 -0
- package/src/desktop.test.ts +233 -0
- package/src/desktop.ts +238 -0
- package/src/device-runner.test.ts +326 -0
- package/src/device-runner.ts +205 -0
- package/src/device.test.ts +418 -0
- package/src/device.ts +707 -0
- package/src/env.d.ts +6 -0
- package/src/index.ts +13 -0
- package/src/intent.ts +325 -0
- package/src/manifest.ts +3 -0
- package/src/pairing.test.ts +85 -0
- package/src/pairing.ts +182 -0
- package/src/storage-keys.ts +102 -0
- package/src/store.test.ts +507 -0
- package/src/store.ts +638 -0
- package/src/target.ts +86 -0
- package/src/testing.ts +352 -0
- package/src/user.test.ts +182 -0
- package/src/user.ts +442 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/backend.ts
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// The registered-machine gateway Contribution: seven routes, on two doors.
|
|
2
|
+
//
|
|
3
|
+
// Three are ordinary authenticated routes beside `/api/settings` — the browser
|
|
4
|
+
// asks for a pairing code, reads the registry, and revokes a machine:
|
|
5
|
+
//
|
|
6
|
+
// POST /api/machines/pair mint a one-time, five-minute code
|
|
7
|
+
// GET /api/machines the `ListMachines` projection
|
|
8
|
+
// POST /api/machines/:id/revoke kill every token this machine holds
|
|
9
|
+
//
|
|
10
|
+
// Four are not authenticated at all, because the caller is a program on
|
|
11
|
+
// somebody's laptop and has no session:
|
|
12
|
+
//
|
|
13
|
+
// POST /api/machines/enroll bearer: pairing code
|
|
14
|
+
// GET /api/machines/:id/poll?wait=25 bearer: machine token
|
|
15
|
+
// POST /api/machines/:id/commands/:commandId/claim bearer: machine token
|
|
16
|
+
// POST /api/machines/:id/commands/:commandId/result bearer: machine token
|
|
17
|
+
//
|
|
18
|
+
// Those four are `publicRoute`s: they run at the seam in
|
|
19
|
+
// `apps/cloudflare/src/gateway.ts` that executes *before* session
|
|
20
|
+
// authentication, exactly where `plugin-routines`' webhook runs. Public means
|
|
21
|
+
// "no session", never "no authority" — and the order of the checks is the
|
|
22
|
+
// whole design, port for port from that webhook:
|
|
23
|
+
//
|
|
24
|
+
// 1. The presented bearer is verified against the deployment secret, in
|
|
25
|
+
// constant time. The gateway is stateless: without claims it could not
|
|
26
|
+
// address a Durable Object at all without creating one on an anonymous
|
|
27
|
+
// caller's word.
|
|
28
|
+
// 2. Only then is the object addressed, and only with the claims a token that
|
|
29
|
+
// was minted here carries. Inside, the digest is checked against the
|
|
30
|
+
// machine record — the authority — so revocation is effective on the very
|
|
31
|
+
// next call.
|
|
32
|
+
//
|
|
33
|
+
// Nothing here holds state, and nothing here decides who owns a machine. The
|
|
34
|
+
// User Durable Object refuses any RPC naming a User it is not.
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
MACHINE_POLL_WAIT_PARAM_V1,
|
|
38
|
+
MACHINE_ROUTE_PREFIX_V1,
|
|
39
|
+
MachineDecodeError,
|
|
40
|
+
MachineTokenError,
|
|
41
|
+
decodeMachineClaimReceiptV1,
|
|
42
|
+
decodeMachineEnrollmentReceiptV1,
|
|
43
|
+
decodeMachineIdV1,
|
|
44
|
+
decodeMachineListViewV1,
|
|
45
|
+
decodeMachinePairingOfferV1,
|
|
46
|
+
decodeMachinePairingRequestV1,
|
|
47
|
+
decodeMachinePollResultV1,
|
|
48
|
+
decodeMachinePollWaitV1,
|
|
49
|
+
decodeMachineResultReceiptV1,
|
|
50
|
+
machineBearerTokenV1,
|
|
51
|
+
machineTokenDigestV1,
|
|
52
|
+
verifyMachineTokenV1,
|
|
53
|
+
type MachineClaimReceiptV1,
|
|
54
|
+
type MachineEnrollmentReceiptV1,
|
|
55
|
+
type MachineListViewV1,
|
|
56
|
+
type MachinePairingOfferV1,
|
|
57
|
+
type MachinePollResultV1,
|
|
58
|
+
type MachineResultReceiptV1,
|
|
59
|
+
type MachineTokenClaimsV1,
|
|
60
|
+
} from "@frockbot/machine-protocol";
|
|
61
|
+
import type { Plugin } from "cordis";
|
|
62
|
+
import { verifyMachinePairingCodeV1 } from "./pairing.js";
|
|
63
|
+
|
|
64
|
+
/** What one machine call carries into the User Durable Object. */
|
|
65
|
+
export interface MachineCallV1 {
|
|
66
|
+
machineId: string;
|
|
67
|
+
/** The token's own claims, verified at the edge. */
|
|
68
|
+
claims: MachineTokenClaimsV1;
|
|
69
|
+
/**
|
|
70
|
+
* `SHA-256(token)`, hex. The token itself never crosses this seam: the
|
|
71
|
+
* authority compares digests, so nothing downstream is handed a key.
|
|
72
|
+
*/
|
|
73
|
+
tokenDigest: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface MachineGatewayHostV1 {
|
|
77
|
+
/**
|
|
78
|
+
* The HMAC secret every machine token and pairing code is signed with, or
|
|
79
|
+
* nothing. Absent means the door is closed: enrollment and every machine
|
|
80
|
+
* route answer 503 rather than admitting an unverified caller.
|
|
81
|
+
*/
|
|
82
|
+
machineTokenSecret?: string;
|
|
83
|
+
createMachinePairing(
|
|
84
|
+
userId: string,
|
|
85
|
+
request: { label?: string },
|
|
86
|
+
): Promise<MachinePairingOfferV1>;
|
|
87
|
+
enrollMachine(
|
|
88
|
+
userId: string,
|
|
89
|
+
input: { machineId: string; enrollment: unknown },
|
|
90
|
+
): Promise<MachineEnrollmentReceiptV1>;
|
|
91
|
+
pollMachine(
|
|
92
|
+
userId: string,
|
|
93
|
+
call: MachineCallV1 & { waitSeconds: number },
|
|
94
|
+
): Promise<MachinePollResultV1>;
|
|
95
|
+
claimMachineCommand(
|
|
96
|
+
userId: string,
|
|
97
|
+
call: MachineCallV1 & { commandId: string },
|
|
98
|
+
): Promise<MachineClaimReceiptV1>;
|
|
99
|
+
recordMachineResult(
|
|
100
|
+
userId: string,
|
|
101
|
+
call: MachineCallV1 & { commandId: string; result: unknown },
|
|
102
|
+
): Promise<MachineResultReceiptV1>;
|
|
103
|
+
listMachines(userId: string): Promise<MachineListViewV1>;
|
|
104
|
+
revokeMachine(userId: string, machineId: string): Promise<MachineListViewV1>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface MachineBackendRouteContribution {
|
|
108
|
+
packageId: string;
|
|
109
|
+
publicRoute?(
|
|
110
|
+
request: Request,
|
|
111
|
+
url: URL,
|
|
112
|
+
context: { userId?: string; client: "browser" | "desktop" },
|
|
113
|
+
): Promise<Response | undefined>;
|
|
114
|
+
route(
|
|
115
|
+
request: Request,
|
|
116
|
+
url: URL,
|
|
117
|
+
context: { userId?: string; client: "browser" | "desktop" },
|
|
118
|
+
): Promise<Response | undefined>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const PAIR = new RegExp(`^${MACHINE_ROUTE_PREFIX_V1}/pair$`);
|
|
122
|
+
const ENROLL = new RegExp(`^${MACHINE_ROUTE_PREFIX_V1}/enroll$`);
|
|
123
|
+
const LIST = new RegExp(`^${MACHINE_ROUTE_PREFIX_V1}$`);
|
|
124
|
+
const REVOKE = new RegExp(`^${MACHINE_ROUTE_PREFIX_V1}/([^/]+)/revoke$`);
|
|
125
|
+
const POLL = new RegExp(`^${MACHINE_ROUTE_PREFIX_V1}/([^/]+)/poll$`);
|
|
126
|
+
const CLAIM = new RegExp(
|
|
127
|
+
`^${MACHINE_ROUTE_PREFIX_V1}/([^/]+)/commands/([^/]+)/claim$`,
|
|
128
|
+
);
|
|
129
|
+
const RESULT = new RegExp(
|
|
130
|
+
`^${MACHINE_ROUTE_PREFIX_V1}/([^/]+)/commands/([^/]+)/result$`,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
function jsonError(status: number, message: string): Response {
|
|
134
|
+
return Response.json({ error: message }, { status });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function pathSegment(value: string, label: string): string {
|
|
138
|
+
try {
|
|
139
|
+
return decodeURIComponent(value);
|
|
140
|
+
} catch {
|
|
141
|
+
throw new MachineDecodeError(`${label} is invalid`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A machine the caller does not own, one that does not exist, and one whose
|
|
147
|
+
* key has been revoked are the same answer. Which it was is not a prober's.
|
|
148
|
+
*/
|
|
149
|
+
function errorResponse(error: unknown): Response {
|
|
150
|
+
if (
|
|
151
|
+
typeof error === "object" &&
|
|
152
|
+
error !== null &&
|
|
153
|
+
"name" in error &&
|
|
154
|
+
(error.name === "MachineTokenError" ||
|
|
155
|
+
error.name === "MachineRegistryError") &&
|
|
156
|
+
"status" in error &&
|
|
157
|
+
typeof error.status === "number"
|
|
158
|
+
) {
|
|
159
|
+
return jsonError(
|
|
160
|
+
error.status,
|
|
161
|
+
error instanceof Error ? error.message : "machine request failed",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (
|
|
165
|
+
error instanceof MachineDecodeError ||
|
|
166
|
+
(typeof error === "object" &&
|
|
167
|
+
error !== null &&
|
|
168
|
+
"name" in error &&
|
|
169
|
+
error.name === "MachineDecodeError")
|
|
170
|
+
) {
|
|
171
|
+
return jsonError(
|
|
172
|
+
400,
|
|
173
|
+
error instanceof Error ? error.message : "machine request is invalid",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return jsonError(
|
|
177
|
+
500,
|
|
178
|
+
error instanceof Error ? error.message : "machine request failed",
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The bearer a machine route presents, or a refusal. */
|
|
183
|
+
function bearer(request: Request): string {
|
|
184
|
+
const token = machineBearerTokenV1(request.headers.get("authorization"));
|
|
185
|
+
if (!token) {
|
|
186
|
+
throw new MachineTokenError(401, "machine token is required");
|
|
187
|
+
}
|
|
188
|
+
return token;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function machineCall(
|
|
192
|
+
secret: string,
|
|
193
|
+
request: Request,
|
|
194
|
+
machineIdSegment: string,
|
|
195
|
+
): Promise<MachineCallV1> {
|
|
196
|
+
const token = bearer(request);
|
|
197
|
+
const claims = await verifyMachineTokenV1(secret, token);
|
|
198
|
+
const machineId = decodeMachineIdV1(
|
|
199
|
+
pathSegment(machineIdSegment, "machineId"),
|
|
200
|
+
);
|
|
201
|
+
// The path and the key must agree. A token for one machine presented at
|
|
202
|
+
// another's door is as good as forged.
|
|
203
|
+
if (claims.m !== machineId) {
|
|
204
|
+
throw new MachineTokenError(401, "machine token is invalid");
|
|
205
|
+
}
|
|
206
|
+
return { machineId, claims, tokenDigest: await machineTokenDigestV1(token) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function readJsonBody(request: Request): Promise<unknown> {
|
|
210
|
+
try {
|
|
211
|
+
return (await request.json()) as unknown;
|
|
212
|
+
} catch {
|
|
213
|
+
throw new MachineDecodeError("machine request body is not JSON");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function createMachineBackendContribution(
|
|
218
|
+
host: MachineGatewayHostV1,
|
|
219
|
+
): MachineBackendRouteContribution {
|
|
220
|
+
const secretOrRefuse = (): string => {
|
|
221
|
+
const secret = host.machineTokenSecret;
|
|
222
|
+
if (!secret) {
|
|
223
|
+
throw new MachineTokenError(
|
|
224
|
+
503,
|
|
225
|
+
"machine registration is not configured",
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return secret;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const contribution: MachineBackendRouteContribution = {
|
|
232
|
+
packageId: "user-machine",
|
|
233
|
+
async route(request, url, context) {
|
|
234
|
+
if (!context.userId) return undefined;
|
|
235
|
+
const userId = context.userId;
|
|
236
|
+
const revoke = REVOKE.exec(url.pathname);
|
|
237
|
+
const isPair = PAIR.test(url.pathname);
|
|
238
|
+
const isList = LIST.test(url.pathname);
|
|
239
|
+
if (!revoke && !isPair && !isList) return undefined;
|
|
240
|
+
if ([...url.searchParams.keys()].length > 0) {
|
|
241
|
+
return jsonError(400, "machine routes take no query parameters");
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
if (isPair) {
|
|
245
|
+
if (request.method !== "POST") {
|
|
246
|
+
return jsonError(405, "method not allowed");
|
|
247
|
+
}
|
|
248
|
+
const requested = decodeMachinePairingRequestV1(
|
|
249
|
+
await readJsonBody(request),
|
|
250
|
+
);
|
|
251
|
+
return Response.json(
|
|
252
|
+
decodeMachinePairingOfferV1(
|
|
253
|
+
await host.createMachinePairing(userId, requested),
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
if (revoke) {
|
|
258
|
+
if (request.method !== "POST") {
|
|
259
|
+
return jsonError(405, "method not allowed");
|
|
260
|
+
}
|
|
261
|
+
const machineId = decodeMachineIdV1(
|
|
262
|
+
pathSegment(revoke[1]!, "machineId"),
|
|
263
|
+
);
|
|
264
|
+
return Response.json(
|
|
265
|
+
decodeMachineListViewV1(
|
|
266
|
+
await host.revokeMachine(userId, machineId),
|
|
267
|
+
),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (request.method !== "GET") {
|
|
271
|
+
return jsonError(405, "method not allowed");
|
|
272
|
+
}
|
|
273
|
+
return Response.json(
|
|
274
|
+
decodeMachineListViewV1(await host.listMachines(userId)),
|
|
275
|
+
);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
return errorResponse(error);
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
contribution.publicRoute = async (request, url) => {
|
|
283
|
+
const enroll = ENROLL.test(url.pathname);
|
|
284
|
+
const poll = POLL.exec(url.pathname);
|
|
285
|
+
const claim = CLAIM.exec(url.pathname);
|
|
286
|
+
const result = RESULT.exec(url.pathname);
|
|
287
|
+
if (!enroll && !poll && !claim && !result) return undefined;
|
|
288
|
+
try {
|
|
289
|
+
const secret = secretOrRefuse();
|
|
290
|
+
if (enroll) {
|
|
291
|
+
if (request.method !== "POST") {
|
|
292
|
+
return jsonError(405, "method not allowed");
|
|
293
|
+
}
|
|
294
|
+
// The pairing code is both the bearer and a field of the body: the
|
|
295
|
+
// header is what the edge verifies, and the body is what the authority
|
|
296
|
+
// hashes against the offer it stored. They must be the same code.
|
|
297
|
+
const code = bearer(request);
|
|
298
|
+
const claims = await verifyMachinePairingCodeV1(secret, code);
|
|
299
|
+
const body = await readJsonBody(request);
|
|
300
|
+
const presented = (body as { code?: unknown }).code;
|
|
301
|
+
if (presented !== code) {
|
|
302
|
+
throw new MachineTokenError(401, "machine pairing code is invalid");
|
|
303
|
+
}
|
|
304
|
+
return Response.json(
|
|
305
|
+
decodeMachineEnrollmentReceiptV1(
|
|
306
|
+
await host.enrollMachine(claims.userId, {
|
|
307
|
+
machineId: claims.machineId,
|
|
308
|
+
enrollment: body,
|
|
309
|
+
}),
|
|
310
|
+
),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
if (poll) {
|
|
314
|
+
if (request.method !== "GET") {
|
|
315
|
+
return jsonError(405, "method not allowed");
|
|
316
|
+
}
|
|
317
|
+
for (const key of url.searchParams.keys()) {
|
|
318
|
+
if (key !== MACHINE_POLL_WAIT_PARAM_V1) {
|
|
319
|
+
return jsonError(400, `machine poll query.${key} is not allowed`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const call = await machineCall(secret, request, poll[1]!);
|
|
323
|
+
const raw = url.searchParams.get(MACHINE_POLL_WAIT_PARAM_V1);
|
|
324
|
+
const waitSeconds = raw === null ? 0 : decodeMachinePollWaitV1(raw);
|
|
325
|
+
return Response.json(
|
|
326
|
+
decodeMachinePollResultV1(
|
|
327
|
+
await host.pollMachine(call.claims.u, { ...call, waitSeconds }),
|
|
328
|
+
),
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
const matched = (claim ?? result)!;
|
|
332
|
+
if (request.method !== "POST") {
|
|
333
|
+
return jsonError(405, "method not allowed");
|
|
334
|
+
}
|
|
335
|
+
const call = await machineCall(secret, request, matched[1]!);
|
|
336
|
+
const commandId = pathSegment(matched[2]!, "commandId");
|
|
337
|
+
if (claim) {
|
|
338
|
+
return Response.json(
|
|
339
|
+
decodeMachineClaimReceiptV1(
|
|
340
|
+
await host.claimMachineCommand(call.claims.u, {
|
|
341
|
+
...call,
|
|
342
|
+
commandId,
|
|
343
|
+
}),
|
|
344
|
+
),
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
return Response.json(
|
|
348
|
+
decodeMachineResultReceiptV1(
|
|
349
|
+
await host.recordMachineResult(call.claims.u, {
|
|
350
|
+
...call,
|
|
351
|
+
commandId,
|
|
352
|
+
result: await readJsonBody(request),
|
|
353
|
+
}),
|
|
354
|
+
),
|
|
355
|
+
);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
return errorResponse(error);
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
return contribution;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export namespace createMachineBackendContribution {
|
|
364
|
+
export function plugin(
|
|
365
|
+
host: MachineGatewayHostV1,
|
|
366
|
+
lifecycle: { mount(value: MachineBackendRouteContribution): () => void },
|
|
367
|
+
): Plugin {
|
|
368
|
+
return () => lifecycle.mount(createMachineBackendContribution(host));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The row in Application settings that names the Computer section.
|
|
3
|
+
//
|
|
4
|
+
// It renders one fact — how many machines are registered and how many are
|
|
5
|
+
// connected right now — and hands the rest to a surface, because a registry
|
|
6
|
+
// with pairing codes and revocation needs more room than the advanced block
|
|
7
|
+
// has. `connected` is the backend's arithmetic over `lastSeenAt`, never this
|
|
8
|
+
// component's: a laptop that stopped polling goes offline on its own, and a UI
|
|
9
|
+
// that guessed would be guessing about somebody's own computer.
|
|
10
|
+
import { clientSurfaceRegistryKey } from "@frockbot/client-core";
|
|
11
|
+
import { UiAnchor, UiButton, UiIcon } from "@frockbot/client-ui";
|
|
12
|
+
import { settingsLinkV1 } from "@frockbot/plugin-shell/settings-links";
|
|
13
|
+
import { computed, inject, onMounted } from "vue";
|
|
14
|
+
import { MACHINE_SURFACE_ID_V1 } from "./index.js";
|
|
15
|
+
import { machinesStateKey } from "./state.js";
|
|
16
|
+
|
|
17
|
+
const surfaces = inject(clientSurfaceRegistryKey);
|
|
18
|
+
const providedState = inject(machinesStateKey);
|
|
19
|
+
if (!surfaces || !providedState) {
|
|
20
|
+
throw new Error("Registered machine client services were not provided");
|
|
21
|
+
}
|
|
22
|
+
const machines = providedState;
|
|
23
|
+
const anchorHref = settingsLinkV1({ anchor: "user-machines" });
|
|
24
|
+
|
|
25
|
+
const summary = computed(() => {
|
|
26
|
+
const view = machines.value.view;
|
|
27
|
+
if (!view) return "Not loaded yet";
|
|
28
|
+
const live = view.machines.filter((machine) => machine.connected).length;
|
|
29
|
+
if (view.machines.length === 0) return "No machines registered";
|
|
30
|
+
return `${view.machines.length} registered · ${live} connected`;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
onMounted(() => machines.value.load());
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<UiAnchor
|
|
38
|
+
as="section"
|
|
39
|
+
anchor="user-machines"
|
|
40
|
+
label="Registered machines"
|
|
41
|
+
:href="anchorHref"
|
|
42
|
+
class="machines-section"
|
|
43
|
+
>
|
|
44
|
+
<span class="machines-section__icon" aria-hidden="true"
|
|
45
|
+
><UiIcon name="gear"
|
|
46
|
+
/></span>
|
|
47
|
+
<span class="machines-section__text">
|
|
48
|
+
<strong>Registered machines</strong>
|
|
49
|
+
<small>{{ summary }}</small>
|
|
50
|
+
</span>
|
|
51
|
+
<UiButton type="button" @click="surfaces.open(MACHINE_SURFACE_ID_V1)">
|
|
52
|
+
Open
|
|
53
|
+
</UiButton>
|
|
54
|
+
</UiAnchor>
|
|
55
|
+
</template>
|
|
56
|
+
|
|
57
|
+
<style scoped>
|
|
58
|
+
.machines-section {
|
|
59
|
+
display: flex;
|
|
60
|
+
align-items: center;
|
|
61
|
+
gap: 10px;
|
|
62
|
+
padding: 12px;
|
|
63
|
+
border: 1px solid var(--frock-border);
|
|
64
|
+
border-radius: var(--frock-radius-card);
|
|
65
|
+
background: var(--frock-surface-subtle);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.machines-section__icon {
|
|
69
|
+
display: grid;
|
|
70
|
+
width: var(--frock-avatar-sm);
|
|
71
|
+
height: var(--frock-avatar-sm);
|
|
72
|
+
flex: 0 0 auto;
|
|
73
|
+
place-items: center;
|
|
74
|
+
border-radius: 8px;
|
|
75
|
+
color: var(--frock-action-primary);
|
|
76
|
+
background: var(--frock-surface);
|
|
77
|
+
box-shadow: inset 0 0 0 1px var(--frock-border);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.machines-section__text {
|
|
81
|
+
display: flex;
|
|
82
|
+
min-width: 0;
|
|
83
|
+
flex: 1 1 auto;
|
|
84
|
+
flex-direction: column;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.machines-section__text strong {
|
|
88
|
+
color: var(--frock-text);
|
|
89
|
+
font-size: var(--frock-text-md);
|
|
90
|
+
font-weight: 600;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.machines-section__text small {
|
|
94
|
+
color: var(--frock-text-muted);
|
|
95
|
+
font-size: var(--frock-text-sm);
|
|
96
|
+
}
|
|
97
|
+
</style>
|