@axium/sysadmin 0.1.3 → 0.2.0

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/db.json CHANGED
@@ -68,6 +68,19 @@
68
68
  }
69
69
  }
70
70
  }
71
+ },
72
+ {
73
+ "delta": true,
74
+ "alter_tables": {
75
+ "systems": {
76
+ "add_columns": {
77
+ "connectedUserId": { "type": "uuid", "references": "system_users.id", "onDelete": "set null" }
78
+ }
79
+ }
80
+ },
81
+ "add_indexes": {
82
+ "systems_connectedUserId": { "on": "systems", "columns": ["connectedUserId"] }
83
+ }
71
84
  }
72
85
  ],
73
86
  "wipe": ["systems", "system_users", "acl.systems"],
@@ -179,5 +179,6 @@ export function systemInfo() {
179
179
  uptime: os.uptime(),
180
180
  version: os.version(),
181
181
  hostname: os.hostname(),
182
+ user: os.userInfo(),
182
183
  };
183
184
  }
package/dist/common.d.ts CHANGED
@@ -6,6 +6,7 @@ export declare const SystemInit: z.ZodObject<{
6
6
  name: z.ZodString;
7
7
  hostname: z.ZodString;
8
8
  type: z.ZodDefault<z.ZodLiteral<"server" | "pc" | "laptop">>;
9
+ connectedUserId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
9
10
  }, z.core.$strip>;
10
11
  export interface SystemInit extends z.infer<typeof SystemInit> {
11
12
  }
@@ -15,6 +16,7 @@ export declare const System: z.ZodObject<{
15
16
  type: z.ZodDefault<z.ZodLiteral<"server" | "pc" | "laptop">>;
16
17
  id: z.ZodUUID;
17
18
  userId: z.ZodUUID;
19
+ connectedUserId: z.ZodNullable<z.ZodUUID>;
18
20
  isShared: z.ZodBoolean;
19
21
  acl: z.ZodArray<z.ZodObject<{
20
22
  itemId: z.ZodUUID;
@@ -68,6 +70,7 @@ declare const SysadminAPI: {
68
70
  type: z.ZodDefault<z.ZodLiteral<"server" | "pc" | "laptop">>;
69
71
  id: z.ZodUUID;
70
72
  userId: z.ZodUUID;
73
+ connectedUserId: z.ZodNullable<z.ZodUUID>;
71
74
  isShared: z.ZodBoolean;
72
75
  acl: z.ZodArray<z.ZodObject<{
73
76
  itemId: z.ZodUUID;
@@ -95,12 +98,14 @@ declare const SysadminAPI: {
95
98
  name: z.ZodString;
96
99
  hostname: z.ZodString;
97
100
  type: z.ZodDefault<z.ZodLiteral<"server" | "pc" | "laptop">>;
101
+ connectedUserId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
98
102
  }, z.core.$strip>, z.ZodObject<{
99
103
  name: z.ZodString;
100
104
  hostname: z.ZodString;
101
105
  type: z.ZodDefault<z.ZodLiteral<"server" | "pc" | "laptop">>;
102
106
  id: z.ZodUUID;
103
107
  userId: z.ZodUUID;
108
+ connectedUserId: z.ZodNullable<z.ZodUUID>;
104
109
  isShared: z.ZodBoolean;
105
110
  acl: z.ZodArray<z.ZodObject<{
106
111
  itemId: z.ZodUUID;
@@ -149,6 +154,7 @@ declare const SysadminAPI: {
149
154
  type: z.ZodDefault<z.ZodLiteral<"server" | "pc" | "laptop">>;
150
155
  id: z.ZodUUID;
151
156
  userId: z.ZodUUID;
157
+ connectedUserId: z.ZodNullable<z.ZodUUID>;
152
158
  isShared: z.ZodBoolean;
153
159
  acl: z.ZodArray<z.ZodObject<{
154
160
  itemId: z.ZodUUID;
@@ -176,12 +182,14 @@ declare const SysadminAPI: {
176
182
  name: z.ZodString;
177
183
  hostname: z.ZodString;
178
184
  type: z.ZodDefault<z.ZodLiteral<"server" | "pc" | "laptop">>;
185
+ connectedUserId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
179
186
  }, z.core.$strip>, z.ZodObject<{
180
187
  name: z.ZodString;
181
188
  hostname: z.ZodString;
182
189
  type: z.ZodDefault<z.ZodLiteral<"server" | "pc" | "laptop">>;
183
190
  id: z.ZodUUID;
184
191
  userId: z.ZodUUID;
192
+ connectedUserId: z.ZodNullable<z.ZodUUID>;
185
193
  isShared: z.ZodBoolean;
186
194
  acl: z.ZodArray<z.ZodObject<{
187
195
  itemId: z.ZodUUID;
@@ -211,6 +219,7 @@ declare const SysadminAPI: {
211
219
  type: z.ZodDefault<z.ZodLiteral<"server" | "pc" | "laptop">>;
212
220
  id: z.ZodUUID;
213
221
  userId: z.ZodUUID;
222
+ connectedUserId: z.ZodNullable<z.ZodUUID>;
214
223
  isShared: z.ZodBoolean;
215
224
  acl: z.ZodArray<z.ZodObject<{
216
225
  itemId: z.ZodUUID;
@@ -258,6 +267,38 @@ declare const SysadminAPI: {
258
267
  userId: z.ZodUUID;
259
268
  }, z.core.$strip>;
260
269
  };
270
+ readonly 'sysadmin/users/:id/systems': {
271
+ readonly GET: z.ZodArray<z.ZodObject<{
272
+ name: z.ZodString;
273
+ hostname: z.ZodString;
274
+ type: z.ZodDefault<z.ZodLiteral<"server" | "pc" | "laptop">>;
275
+ id: z.ZodUUID;
276
+ userId: z.ZodUUID;
277
+ connectedUserId: z.ZodNullable<z.ZodUUID>;
278
+ isShared: z.ZodBoolean;
279
+ acl: z.ZodArray<z.ZodObject<{
280
+ itemId: z.ZodUUID;
281
+ userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
282
+ role: z.ZodOptional<z.ZodNullable<z.ZodString>>;
283
+ tag: z.ZodOptional<z.ZodNullable<z.ZodString>>;
284
+ user: z.ZodOptional<z.ZodNullable<z.ZodObject<{
285
+ id: z.ZodUUID;
286
+ name: z.ZodString;
287
+ email: z.ZodOptional<z.ZodEmail>;
288
+ emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
289
+ preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
290
+ debug: z.ZodDefault<z.ZodBoolean>;
291
+ }, z.core.$strip>>>;
292
+ roles: z.ZodArray<z.ZodString>;
293
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
294
+ registeredAt: z.ZodCoercedDate<unknown>;
295
+ isAdmin: z.ZodOptional<z.ZodBoolean>;
296
+ isSuspended: z.ZodOptional<z.ZodBoolean>;
297
+ }, z.core.$strip>>>;
298
+ createdAt: z.ZodCoercedDate<unknown>;
299
+ }, z.core.$catchall<z.ZodBoolean>>>;
300
+ }, z.core.$strip>>;
301
+ };
261
302
  };
262
303
  type SysadminAPI = typeof SysadminAPI;
263
304
  declare module '@axium/core/api' {
@@ -307,6 +348,13 @@ declare const SysadminClientToServer: {
307
348
  uptime: z.ZodNumber;
308
349
  version: z.ZodString;
309
350
  hostname: z.ZodString;
351
+ user: z.ZodObject<{
352
+ username: z.ZodString;
353
+ uid: z.ZodInt;
354
+ gid: z.ZodInt;
355
+ shell: z.ZodNullable<z.ZodString>;
356
+ homedir: z.ZodString;
357
+ }, z.core.$strip>;
310
358
  }, z.core.$strip>>]];
311
359
  readonly 'sysadmin:ping': readonly [readonly [z.ZodArray<z.ZodObject<{
312
360
  hostname: z.ZodString;
@@ -356,6 +404,13 @@ declare const SysadminServerToClient: {
356
404
  uptime: z.ZodNumber;
357
405
  version: z.ZodString;
358
406
  hostname: z.ZodString;
407
+ user: z.ZodObject<{
408
+ username: z.ZodString;
409
+ uid: z.ZodInt;
410
+ gid: z.ZodInt;
411
+ shell: z.ZodNullable<z.ZodString>;
412
+ homedir: z.ZodString;
413
+ }, z.core.$strip>;
359
414
  }, z.core.$strip>]];
360
415
  readonly 'sysadmin:ping': readonly [readonly [z.ZodObject<{
361
416
  hostname: z.ZodString;
package/dist/common.js CHANGED
@@ -13,10 +13,13 @@ export const SystemInit = z.object({
13
13
  name: z.string().nonempty().max(250),
14
14
  hostname: z.string().nonempty().max(250),
15
15
  type: SystemType.default('server'),
16
+ /** The connected system user, if any. `null` disconnects. */
17
+ connectedUserId: z.uuid().nullish(),
16
18
  });
17
19
  export const System = SystemInit.extend({
18
20
  id: z.uuid(),
19
21
  userId: z.uuid(),
22
+ connectedUserId: z.uuid().nullable(),
20
23
  isShared: z.boolean(),
21
24
  acl: AccessControl.array(),
22
25
  });
@@ -51,6 +54,9 @@ const SysadminAPI = {
51
54
  PATCH: [SystemUserInit, SystemUser],
52
55
  DELETE: SystemUser,
53
56
  },
57
+ 'sysadmin/users/:id/systems': {
58
+ GET: System.array(),
59
+ },
54
60
  };
55
61
  Object.assign($API, SysadminAPI);
56
62
  const SysadminClientToServer = {
package/dist/info.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { UserInfo } from 'node:os';
1
2
  import * as z from 'zod';
2
3
  export declare const TotalUsed: z.ZodObject<{
3
4
  total: z.ZodCoercedBigInt<unknown>;
@@ -48,6 +49,15 @@ export declare const NetworkInterface: z.ZodObject<{
48
49
  }, z.core.$strip>;
49
50
  export interface NetworkInterface extends z.infer<typeof NetworkInterface> {
50
51
  }
52
+ export declare const SystemInfoUser: z.ZodObject<{
53
+ username: z.ZodString;
54
+ uid: z.ZodInt;
55
+ gid: z.ZodInt;
56
+ shell: z.ZodNullable<z.ZodString>;
57
+ homedir: z.ZodString;
58
+ }, z.core.$strip>;
59
+ export interface SystemInfoUser extends z.infer<typeof SystemInfoUser>, UserInfo<string> {
60
+ }
51
61
  export declare const SystemInfo: z.ZodObject<{
52
62
  cpus: z.ZodArray<z.ZodObject<{
53
63
  model: z.ZodString;
@@ -90,6 +100,13 @@ export declare const SystemInfo: z.ZodObject<{
90
100
  uptime: z.ZodNumber;
91
101
  version: z.ZodString;
92
102
  hostname: z.ZodString;
103
+ user: z.ZodObject<{
104
+ username: z.ZodString;
105
+ uid: z.ZodInt;
106
+ gid: z.ZodInt;
107
+ shell: z.ZodNullable<z.ZodString>;
108
+ homedir: z.ZodString;
109
+ }, z.core.$strip>;
93
110
  }, z.core.$strip>;
94
111
  export interface SystemInfo extends z.infer<typeof SystemInfo> {
95
112
  }
package/dist/info.js CHANGED
@@ -32,6 +32,13 @@ export const NetworkInterface = z.object({
32
32
  connection: z.string().optional(),
33
33
  speed: z.number().nonnegative().optional(),
34
34
  });
35
+ export const SystemInfoUser = z.object({
36
+ username: z.string(),
37
+ uid: z.int(),
38
+ gid: z.int(),
39
+ shell: z.string().nullable(),
40
+ homedir: z.string(),
41
+ });
35
42
  export const SystemInfo = z.object({
36
43
  cpus: CPU.array(),
37
44
  gpus: GPU.array(),
@@ -53,6 +60,7 @@ export const SystemInfo = z.object({
53
60
  /** Kernel version, e.g. `#1 SMP PREEMPT_DYNAMIC ...` */
54
61
  version: z.string(),
55
62
  hostname: z.string(),
63
+ user: SystemInfoUser,
56
64
  });
57
65
  // OS //
58
66
  // User //
@@ -8,6 +8,21 @@ import { SystemInit, SystemUserInit } from '../common.js';
8
8
  function parseSystem(system, isShared) {
9
9
  return Object.assign({ acl: [] }, system, { isShared });
10
10
  }
11
+ /**
12
+ * Ensure the requester owns the system user they are connecting a system to.
13
+ * No-op when `connectedUserId` is unset (`undefined` leaves it unchanged, `null` disconnects).
14
+ */
15
+ async function checkConnectedUser(request, connectedUserId) {
16
+ if (!connectedUserId)
17
+ return;
18
+ const systemUser = await database
19
+ .selectFrom('system_users')
20
+ .select('userId')
21
+ .where('id', '=', connectedUserId)
22
+ .executeTakeFirstOrThrow()
23
+ .catch(withError('Could not get system user', 404));
24
+ await checkAuthForUser(request, systemUser.userId);
25
+ }
11
26
  addRoute({
12
27
  path: '/api/users/:id/sysadmin/systems',
13
28
  params: { id: z.uuid() },
@@ -25,6 +40,7 @@ addRoute({
25
40
  async PUT(request, { id: userId }) {
26
41
  const init = await parseBody(request, SystemInit);
27
42
  await checkAuthForUser(request, userId);
43
+ await checkConnectedUser(request, init.connectedUserId);
28
44
  return parseSystem(await database
29
45
  .insertInto('systems')
30
46
  .values({ ...init, userId })
@@ -43,6 +59,7 @@ addRoute({
43
59
  async PATCH(request, { id }) {
44
60
  const init = await parseBody(request, SystemInit);
45
61
  const { fromACL } = await authRequestForItem(request, 'systems', id, { edit: true });
62
+ await checkConnectedUser(request, init.connectedUserId);
46
63
  const system = await database
47
64
  .updateTable('systems')
48
65
  .set(init)
@@ -134,3 +151,24 @@ addRoute({
134
151
  .catch(withError('Could not delete system user'));
135
152
  },
136
153
  });
154
+ addRoute({
155
+ path: '/api/sysadmin/users/:id/systems',
156
+ params: { id: z.uuid() },
157
+ async GET(request, { id }) {
158
+ const user = await database
159
+ .selectFrom('system_users')
160
+ .select('userId')
161
+ .where('id', '=', id)
162
+ .executeTakeFirstOrThrow()
163
+ .catch(withError('Could not get system user', 404));
164
+ await checkAuthForUser(request, user.userId);
165
+ const systems = await database
166
+ .selectFrom('systems')
167
+ .selectAll()
168
+ .select(acl.from('systems'))
169
+ .where('connectedUserId', '=', id)
170
+ .execute()
171
+ .catch(withError('Could not get systems'));
172
+ return systems.map(system => parseSystem(system, false));
173
+ },
174
+ });
package/locales/en.json CHANGED
@@ -42,7 +42,9 @@
42
42
  "toast_deleted": "User deleted"
43
43
  },
44
44
  "user": {
45
- "page_title": "{name} — System Users"
45
+ "page_title": "{name} — System Users",
46
+ "systems": "Connected Systems",
47
+ "no_systems": "No systems are connected to this user."
46
48
  },
47
49
  "system_type": {
48
50
  "server": "Server",
@@ -74,7 +76,21 @@
74
76
  "kernel": "Kernel",
75
77
  "arch": "Architecture",
76
78
  "uptime": "Uptime",
77
- "usage": "{used} / {total}"
79
+ "usage": "{used} / {total}",
80
+ "user": {
81
+ "title": "User",
82
+ "username": "Username",
83
+ "uid": "UID / GID",
84
+ "home": "Home Directory",
85
+ "shell": "Shell",
86
+ "match_found": "This user matches '{name}'.",
87
+ "no_match": "No matching system user. Add one with the live username to connect it.",
88
+ "connect": "Connect User",
89
+ "connected_to": "Connected to {name}",
90
+ "disconnect": "Disconnect",
91
+ "disconnected_from": "Disconnected from {name}",
92
+ "username_mismatch": "The connected user “{connected}” no longer matches the live username “{live}”."
93
+ }
78
94
  }
79
95
  }
80
96
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/sysadmin",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "author": "James Prevett <axium@jamespre.dev>",
5
5
  "description": "System administration for Axium",
6
6
  "funding": {
@@ -1,18 +1,29 @@
1
1
  <script lang="ts">
2
- import { text } from '@axium/client';
3
- import { formatBytes, formatDuration } from '@axium/core';
2
+ import { fetchAPI, text } from '@axium/client';
4
3
  import { Icon, NumberBar } from '@axium/client/components';
5
4
  import { connect } from '@axium/client/socket';
5
+ import { toastStatus } from '@axium/client/toast';
6
+ import { formatBytes, formatDuration } from '@axium/core';
6
7
  import { systemTypeIcons, type SystemInfo } from '@axium/sysadmin';
7
8
  import '@axium/sysadmin/common';
8
9
 
9
10
  const { data } = $props();
10
- const system = data.system;
11
+
12
+ let system = $state(data.system);
13
+ const systemUsers = data.systemUsers;
11
14
 
12
15
  let info = $state<SystemInfo>();
13
16
  let error = $state(false);
14
17
  let loading = $state(true);
15
18
 
19
+ const matchingUser = $derived(info && systemUsers.find(u => u.username === info!.user.username));
20
+ const connectedUser = $derived(system.connectedUserId ? systemUsers.find(u => u.id === system.connectedUserId) : undefined);
21
+
22
+ async function setConnectedUser(connectedUserId: string | null) {
23
+ const updated = await fetchAPI('PATCH', 'sysadmin/systems/:id', { ...system, connectedUserId }, system.id);
24
+ system = updated;
25
+ }
26
+
16
27
  /** The fraction used, for a NumberBar, clamped to [0, 1]. */
17
28
  function fraction(used: bigint, total: bigint): number {
18
29
  if (total <= 0n) return 0;
@@ -182,6 +193,68 @@
182
193
  </dl>
183
194
  </section>
184
195
  {/if}
196
+
197
+ {#if info || connectedUser}
198
+ <section>
199
+ {const userIcon = !info ? 'user' : info.user.uid === 0 ? 'user-crown' : info.user.uid < 1000 ? 'user-gear' : 'user'}
200
+ <h2><Icon i={userIcon} /> {text('sysadmin.system.user.title')}</h2>
201
+
202
+ {#if info && connectedUser && connectedUser.username !== info.user.username}
203
+ <div class="warning icon-text">
204
+ <Icon i="triangle-exclamation" />
205
+ <span>
206
+ {text('sysadmin.system.user.username_mismatch', {
207
+ connected: connectedUser.username,
208
+ live: info.user.username,
209
+ })}
210
+ </span>
211
+ </div>
212
+ {/if}
213
+
214
+ {#if info}
215
+ <dl>
216
+ <dt>{text('sysadmin.system.user.username')}</dt>
217
+ <dd>{info.user.username}</dd>
218
+ <dt>{text('sysadmin.system.user.uid')}</dt>
219
+ <dd>{info.user.uid} <span class="subtle">/ {info.user.gid}</span></dd>
220
+ <dt>{text('sysadmin.system.user.home')}</dt>
221
+ <dd>{info.user.homedir}</dd>
222
+ {#if info.user.shell}
223
+ <dt>{text('sysadmin.system.user.shell')}</dt>
224
+ <dd>{info.user.shell}</dd>
225
+ {/if}
226
+ </dl>
227
+ {/if}
228
+
229
+ <div class="user-connection">
230
+ {#if connectedUser}
231
+ <a class="icon-text subtle" href="/sysadmin/users/{connectedUser.id}">
232
+ <Icon i="link" />
233
+ <span>{text('sysadmin.system.user.connected_to', connectedUser)}</span>
234
+ </a>
235
+ <button
236
+ class="icon-text"
237
+ onclick={() => toastStatus(setConnectedUser(null), text('sysadmin.system.user.disconnected_from', connectedUser))}
238
+ >
239
+ <Icon i="link-slash" />
240
+ <span>{text('sysadmin.system.user.disconnect')}</span>
241
+ </button>
242
+ {:else if matchingUser}
243
+ <span class="subtle">{text('sysadmin.system.user.match_found', matchingUser)}</span>
244
+ <button
245
+ class="icon-text"
246
+ onclick={() =>
247
+ toastStatus(setConnectedUser(matchingUser.id), text('sysadmin.system.user.connected_to', matchingUser))}
248
+ >
249
+ <Icon i="link" />
250
+ <span>{text('sysadmin.system.user.connect')}</span>
251
+ </button>
252
+ {:else}
253
+ <span class="subtle">{text('sysadmin.system.user.no_match')}</span>
254
+ {/if}
255
+ </div>
256
+ </section>
257
+ {/if}
185
258
  </div>
186
259
 
187
260
  <style>
@@ -315,6 +388,17 @@
315
388
  margin: 0;
316
389
  }
317
390
 
391
+ .user-connection {
392
+ display: flex;
393
+ align-items: center;
394
+ gap: 1em;
395
+ flex-wrap: wrap;
396
+
397
+ button {
398
+ margin-left: auto;
399
+ }
400
+ }
401
+
318
402
  @media (width < 700px) {
319
403
  .system-page {
320
404
  padding: 1em;
@@ -5,7 +5,10 @@ export const ssr = false;
5
5
  export async function load({ parent, params }) {
6
6
  const { session } = await parent();
7
7
 
8
- const system = await fetchAPI('GET', 'sysadmin/systems/:id', {}, params.id);
8
+ const [system, systemUsers] = await Promise.all([
9
+ fetchAPI('GET', 'sysadmin/systems/:id', {}, params.id),
10
+ fetchAPI('GET', 'users/:id/sysadmin/users', {}, session.userId),
11
+ ]);
9
12
 
10
- return { session, system };
13
+ return { session, system, systemUsers };
11
14
  }
@@ -1,15 +1,19 @@
1
1
  <script lang="ts">
2
+ import { goto } from '$app/navigation';
2
3
  import { fetchAPI, text } from '@axium/client';
3
4
  import { Icon } from '@axium/client/components';
4
- import { UserInitDialog } from '@axium/sysadmin/components';
5
+ import { socket } from '@axium/client/socket';
5
6
  import { toastStatus } from '@axium/client/toast';
6
- import { goto } from '$app/navigation';
7
+ import { SystemCard, UserInitDialog } from '@axium/sysadmin/components';
7
8
 
8
9
  const { data } = $props();
9
10
 
10
11
  let user = $state(data.user);
12
+ let systems = $state(data.systems);
11
13
  let editDialog = $state<HTMLDialogElement>();
12
14
 
15
+ const onlineHosts = $derived(await socket?.emitWithAck('sysadmin:ping').then(systems => systems.map(s => s.hostname)));
16
+
13
17
  function remove() {
14
18
  toastStatus(
15
19
  fetchAPI('DELETE', 'sysadmin/users/:id', {}, user.id).then(() => goto('/sysadmin/users')),
@@ -43,6 +47,23 @@
43
47
  <span>{text('generic.delete')}</span>
44
48
  </button>
45
49
  </div>
50
+
51
+ <section>
52
+ <h2><Icon i="server" /> {text('sysadmin.user.systems')}</h2>
53
+ {#if systems.length}
54
+ <div class="cards">
55
+ {#each systems as system, i (system.id)}
56
+ <SystemCard
57
+ bind:system={systems[i]}
58
+ online={!!onlineHosts?.includes(system.hostname)}
59
+ deleted={() => systems.splice(i, 1)}
60
+ />
61
+ {/each}
62
+ </div>
63
+ {:else}
64
+ <p class="subtle">{text('sysadmin.user.no_systems')}</p>
65
+ {/if}
66
+ </section>
46
67
  </div>
47
68
 
48
69
  <UserInitDialog bind:dialog={editDialog} {user} edited={updated => (user = updated)} />
@@ -73,10 +94,33 @@
73
94
  }
74
95
  }
75
96
 
97
+ section {
98
+ display: flex;
99
+ flex-direction: column;
100
+ gap: 1em;
101
+
102
+ h2 {
103
+ margin: 0;
104
+ display: flex;
105
+ align-items: center;
106
+ gap: 0.5em;
107
+ }
108
+ }
109
+
110
+ .cards {
111
+ display: grid;
112
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
113
+ gap: 1em;
114
+ }
115
+
76
116
  @media (width < 700px) {
77
117
  .user-page {
78
118
  padding: 1em;
79
119
  padding-bottom: 5em;
80
120
  }
121
+
122
+ .cards {
123
+ grid-template-columns: 1fr;
124
+ }
81
125
  }
82
126
  </style>
@@ -5,7 +5,10 @@ export const ssr = false;
5
5
  export async function load({ parent, params }) {
6
6
  const { session } = await parent();
7
7
 
8
- const user = await fetchAPI('GET', 'sysadmin/users/:id', {}, params.id);
8
+ const [user, systems] = await Promise.all([
9
+ fetchAPI('GET', 'sysadmin/users/:id', {}, params.id),
10
+ fetchAPI('GET', 'sysadmin/users/:id/systems', {}, params.id),
11
+ ]);
9
12
 
10
- return { session, user };
13
+ return { session, user, systems };
11
14
  }