From 7976aa7dda034d6fe767b4d8ac781594af8cd30e Mon Sep 17 00:00:00 2001
From: Brett Kolodny <brett.kolodny@coder.com>
Date: Thu, 3 Jul 2025 21:33:18 +0000
Subject: [PATCH 01/21] wip: basic user form UI

---
 biome.jsonc                         |  43 ++---
 package.json                        |   2 +
 pnpm-lock.yaml                      |  94 ++++++++++-
 src/client/App.tsx                  |  28 ++--
 src/client/components/Button.tsx    |   2 +-
 src/client/components/Form.tsx      |   1 +
 src/client/components/Markdown.tsx  |   2 +-
 src/client/components/Resizable.tsx |  67 ++++----
 src/client/components/Slider.tsx    |   8 +-
 src/client/components/Stack.tsx     |  20 +++
 src/client/components/Table.tsx     |   1 -
 src/client/components/Tabs.tsx      |  15 +-
 src/client/components/TagInput.tsx  |  11 +-
 src/client/diagnostics.ts           |   6 +-
 src/client/{ => editor}/Editor.tsx  |  54 +++---
 src/client/editor/Users.tsx         | 244 ++++++++++++++++++++++++++++
 src/client/hooks/debounce.tsx       |   2 +-
 src/client/index.css                | 196 +++++++++++-----------
 src/client/snippets.ts              |   2 +-
 src/owner.ts                        |  26 ++-
 20 files changed, 585 insertions(+), 239 deletions(-)
 create mode 100644 src/client/components/Form.tsx
 create mode 100644 src/client/components/Stack.tsx
 rename src/client/{ => editor}/Editor.tsx (91%)
 create mode 100644 src/client/editor/Users.tsx

diff --git a/biome.jsonc b/biome.jsonc
index 628ac8a..1b6c69b 100644
--- a/biome.jsonc
+++ b/biome.jsonc
@@ -2,13 +2,13 @@
 	"vcs": {
 		"enabled": true,
 		"useIgnoreFile": true,
-		"clientKind": "git",
-		"root": ".."
+		"clientKind": "git"
 	},
 	"files": {
-		"ignore": [
-			"e2e/**/*Generated.ts",
+		"experimentalScannerIgnores": [
+			"src/client/gen/types.ts",
 			"pnpm-lock.yaml"
+			
 		],
 		"ignoreUnknown": true
 	},
@@ -30,46 +30,35 @@
 					"level": "off"
 				},
 				"noParameterAssign": {
-					"level": "off"
+					"level": "off",
+					"options": {}
 				},
 				"useDefaultParameterLast": {
 					"level": "off"
 				},
 				"useSelfClosingElements": {
-					"level": "off"
+					"level": "off",
+					"options": {}
 				}
 			},
 			"suspicious": {
 				"noArrayIndexKey": {
 					"level": "off"
 				},
-				"noConsoleLog": {
-					"level": "error"
+				"noConsole": {
+					"level": "error",
+					"options": {
+						"allow": ["error"]
+					}
 				},
 				"noThenProperty": {
 					"level": "off"
 				}
 			},
 			"nursery": {
-				"useSortedClasses": "error",
-				"noRestrictedImports": {
-					"level": "error",
-					"options": {
-						"paths": {
-							"@mui/material": "Use @mui/material/<name> instead. See: https://material-ui.com/guides/minimizing-bundle-size/.",
-							"@mui/icons-material": "Use @mui/icons-material/<name> instead. See: https://material-ui.com/guides/minimizing-bundle-size/.",
-							"@mui/material/Avatar": "Use components/Avatar/Avatar instead.",
-							"@mui/material/Alert": "Use components/Alert/Alert instead.",
-							"@mui/material/Popover": "Use components/Popover/Popover instead.",
-							"@mui/material/Typography": "Use native HTML elements instead. Eg: <span>, <p>, <h1>, etc.",
-							"@mui/material/Box": "Use a <div> instead.",
-							"@mui/material/styles": "Import from @emotion/react instead.",
-							"lodash": "Use lodash/<name> instead."
-						}
-					}
-				}
+				"useSortedClasses": "error"
 			}
 		}
 	},
-	"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json"
-}
\ No newline at end of file
+	"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json"
+}
diff --git a/package.json b/package.json
index 1dde321..b5bc645 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,8 @@
     "@radix-ui/react-tabs": "^1.1.12",
     "@radix-ui/react-tooltip": "^1.2.7",
     "@tailwindcss/typography": "^0.5.16",
+    "@tanstack/react-form": "^1.12.4",
+    "@tanstack/valibot-form-adapter": "^0.42.1",
     "@universal-middleware/core": "^0.4.7",
     "@universal-middleware/hono": "^0.4.12",
     "@vercel/blob": "^1.1.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6c6340b..4555325 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -59,6 +59,12 @@ importers:
       '@tailwindcss/typography':
         specifier: ^0.5.16
         version: 0.5.16(tailwindcss@3.4.17(ts-node@10.9.1(@types/node@22.15.21)(typescript@5.8.3)))
+      '@tanstack/react-form':
+        specifier: ^1.12.4
+        version: 1.12.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      '@tanstack/valibot-form-adapter':
+        specifier: ^0.42.1
+        version: 0.42.1(valibot@1.1.0(typescript@5.8.3))
       '@universal-middleware/core':
         specifier: ^0.4.7
         version: 0.4.7(hono@4.7.11)
@@ -136,7 +142,7 @@ importers:
         version: 1.6.1
       zustand:
         specifier: ^5.0.5
-        version: 5.0.5(@types/react@19.1.4)(react@19.1.0)
+        version: 5.0.5(@types/react@19.1.4)(react@19.1.0)(use-sync-external-store@1.5.0(react@19.1.0))
     devDependencies:
       '@eslint/js':
         specifier: ^9.25.0
@@ -1333,6 +1339,38 @@ packages:
     peerDependencies:
       tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
 
+  '@tanstack/form-core@0.42.1':
+    resolution: {integrity: sha512-jTU0jyHqFceujdtPNv3jPVej1dTqBwa8TYdIyWB5BCwRVUBZEp1PiYEBkC9r92xu5fMpBiKc+JKud3eeVjuMiA==}
+
+  '@tanstack/form-core@1.12.4':
+    resolution: {integrity: sha512-BhfNI5sEjI68Im1Vqezf9w68fJL4EB80cqW5w0zb/MV1erHHsXNRwLGmljF88VnCx1t/xd4fmF0D08wNajBauQ==}
+
+  '@tanstack/react-form@1.12.4':
+    resolution: {integrity: sha512-MsWHTTUl1Db7tcawbREEMjUtnjK1wC9HnwEITFFhO6e9jN4vR8gb7qRM6TDKg0tkBf42fd5jhEI5qCYA8Sl2pQ==}
+    peerDependencies:
+      '@tanstack/react-start': ^1.112.0
+      react: ^17.0.0 || ^18.0.0 || ^19.0.0
+      vinxi: ^0.5.0
+    peerDependenciesMeta:
+      '@tanstack/react-start':
+        optional: true
+      vinxi:
+        optional: true
+
+  '@tanstack/react-store@0.7.1':
+    resolution: {integrity: sha512-qUTEKdId6QPWGiWyKAPf/gkN29scEsz6EUSJ0C3HgLMgaqTAyBsQ2sMCfGVcqb+kkhEXAdjleCgH6LAPD6f2sA==}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+      react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+  '@tanstack/store@0.7.1':
+    resolution: {integrity: sha512-PjUQKXEXhLYj2X5/6c1Xn/0/qKY0IVFxTJweopRfF26xfjVyb14yALydJrHupDh3/d+1WKmfEgZPBVCmDkzzwg==}
+
+  '@tanstack/valibot-form-adapter@0.42.1':
+    resolution: {integrity: sha512-MUWjxGm8ZVDEqW79UET9U0hh87r8hj1oM0dTjCUHnHjvyIHpItk3SDkv4OjBSBoy9Y4kWvCF+0NQ3KM/omdGqg==}
+    peerDependencies:
+      valibot: ^1.0.0 || ^1.0.0-beta.4 || ^1.0.0-rc
+
   '@tootallnate/once@2.0.0':
     resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
     engines: {node: '>= 10'}
@@ -1869,6 +1907,9 @@ packages:
       supports-color:
         optional: true
 
+  decode-formdata@0.9.0:
+    resolution: {integrity: sha512-q5uwOjR3Um5YD+ZWPOF/1sGHVW9A5rCrRwITQChRXlmPkxDFBqCm4jNTIVdGHNH9OnR+V9MoZVgRhsFb+ARbUw==}
+
   decode-named-character-reference@1.1.0:
     resolution: {integrity: sha512-Wy+JTSbFThEOXQIR2L6mxJvEs+veIzpmqD7ynWxMXGpnk3smkHQOp6forLdHsKpAMW9iJpaBBIxz285t1n1C3w==}
 
@@ -1890,6 +1931,9 @@ packages:
   detect-node-es@1.1.0:
     resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
 
+  devalue@5.1.1:
+    resolution: {integrity: sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==}
+
   devlop@1.1.0:
     resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
 
@@ -3501,6 +3545,11 @@ packages:
       '@types/react':
         optional: true
 
+  use-sync-external-store@1.5.0:
+    resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
   util-deprecate@1.0.2:
     resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
 
@@ -4657,6 +4706,38 @@ snapshots:
       postcss-selector-parser: 6.0.10
       tailwindcss: 3.4.17(ts-node@10.9.1(@types/node@22.15.21)(typescript@5.8.3))
 
+  '@tanstack/form-core@0.42.1':
+    dependencies:
+      '@tanstack/store': 0.7.1
+
+  '@tanstack/form-core@1.12.4':
+    dependencies:
+      '@tanstack/store': 0.7.1
+
+  '@tanstack/react-form@1.12.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+    dependencies:
+      '@tanstack/form-core': 1.12.4
+      '@tanstack/react-store': 0.7.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      decode-formdata: 0.9.0
+      devalue: 5.1.1
+      react: 19.1.0
+    transitivePeerDependencies:
+      - react-dom
+
+  '@tanstack/react-store@0.7.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+    dependencies:
+      '@tanstack/store': 0.7.1
+      react: 19.1.0
+      react-dom: 19.1.0(react@19.1.0)
+      use-sync-external-store: 1.5.0(react@19.1.0)
+
+  '@tanstack/store@0.7.1': {}
+
+  '@tanstack/valibot-form-adapter@0.42.1(valibot@1.1.0(typescript@5.8.3))':
+    dependencies:
+      '@tanstack/form-core': 0.42.1
+      valibot: 1.1.0(typescript@5.8.3)
+
   '@tootallnate/once@2.0.0': {}
 
   '@ts-morph/common@0.11.1':
@@ -5290,6 +5371,8 @@ snapshots:
     dependencies:
       ms: 2.1.3
 
+  decode-formdata@0.9.0: {}
+
   decode-named-character-reference@1.1.0:
     dependencies:
       character-entities: 2.0.2
@@ -5304,6 +5387,8 @@ snapshots:
 
   detect-node-es@1.1.0: {}
 
+  devalue@5.1.1: {}
+
   devlop@1.1.0:
     dependencies:
       dequal: 2.0.3
@@ -7127,6 +7212,10 @@ snapshots:
     optionalDependencies:
       '@types/react': 19.1.4
 
+  use-sync-external-store@1.5.0(react@19.1.0):
+    dependencies:
+      react: 19.1.0
+
   util-deprecate@1.0.2: {}
 
   v8-compile-cache-lib@3.0.1: {}
@@ -7272,9 +7361,10 @@ snapshots:
 
   zod@3.25.51: {}
 
-  zustand@5.0.5(@types/react@19.1.4)(react@19.1.0):
+  zustand@5.0.5(@types/react@19.1.4)(react@19.1.0)(use-sync-external-store@1.5.0(react@19.1.0)):
     optionalDependencies:
       '@types/react': 19.1.4
       react: 19.1.0
+      use-sync-external-store: 1.5.0(react@19.1.0)
 
   zwitch@2.0.4: {}
diff --git a/src/client/App.tsx b/src/client/App.tsx
index adf4330..ead94a9 100644
--- a/src/client/App.tsx
+++ b/src/client/App.tsx
@@ -1,5 +1,13 @@
-import { Editor } from "@/client/Editor";
-import { Preview } from "@/client/Preview";
+import isEqual from "lodash/isEqual";
+import {
+	ExternalLinkIcon,
+	MoonIcon,
+	ShareIcon,
+	SunIcon,
+	SunMoonIcon,
+} from "lucide-react";
+import { type FC, useEffect, useMemo, useRef, useState } from "react";
+import { useBeforeUnload, useSearchParams } from "react-router";
 import { Button } from "@/client/components/Button";
 import {
 	DropdownMenu,
@@ -19,6 +27,8 @@ import {
 	TooltipTrigger,
 } from "@/client/components/Tooltip";
 import { useTheme } from "@/client/contexts/theme";
+import { Editor } from "@/client/editor/Editor";
+import { Preview } from "@/client/Preview";
 import { defaultCode } from "@/client/snippets";
 import { examples } from "@/examples";
 import type {
@@ -29,21 +39,11 @@ import type {
 import { mockUsers } from "@/owner";
 import { rpc } from "@/utils/rpc";
 import {
-	type WasmLoadState,
 	getDynamicParametersOutput,
 	initWasm,
+	type WasmLoadState,
 } from "@/utils/wasm";
-import isEqual from "lodash/isEqual";
-import {
-	ExternalLinkIcon,
-	MoonIcon,
-	ShareIcon,
-	SunIcon,
-	SunMoonIcon,
-} from "lucide-react";
-import { type FC, useEffect, useMemo, useRef, useState } from "react";
 import { useDebouncedValue } from "./hooks/debounce";
-import { useBeforeUnload, useSearchParams } from "react-router";
 
 export const App = () => {
 	useBeforeUnload(
@@ -171,7 +171,7 @@ export const App = () => {
 				<div className="flex items-center justify-center gap-6">
 					<div className="flex items-center gap-2">
 						<Logo className="text-content-primary" height={24} />
-						<p className="font-semibold text-content-primary text-2xl">
+						<p className="font-semibold text-2xl text-content-primary">
 							Playground
 						</p>
 					</div>
diff --git a/src/client/components/Button.tsx b/src/client/components/Button.tsx
index 79441af..33af318 100644
--- a/src/client/components/Button.tsx
+++ b/src/client/components/Button.tsx
@@ -68,4 +68,4 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
 			/>
 		);
 	},
-)
+);
diff --git a/src/client/components/Form.tsx b/src/client/components/Form.tsx
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/client/components/Form.tsx
@@ -0,0 +1 @@
+
diff --git a/src/client/components/Markdown.tsx b/src/client/components/Markdown.tsx
index 28dd205..af8acd3 100644
--- a/src/client/components/Markdown.tsx
+++ b/src/client/components/Markdown.tsx
@@ -57,7 +57,7 @@ export const Markdown: FC<MarkdownProps> = (props) => {
 					);
 				},
 
-				hr: () => <hr className="my-3"/>,
+				hr: () => <hr className="my-3" />,
 
 				pre: ({ node, children }) => {
 					if (!node || !node.children) {
diff --git a/src/client/components/Resizable.tsx b/src/client/components/Resizable.tsx
index 37e0124..a7fa05f 100644
--- a/src/client/components/Resizable.tsx
+++ b/src/client/components/Resizable.tsx
@@ -1,43 +1,42 @@
-import { GripVertical } from "lucide-react"
-import * as ResizablePrimitive from "react-resizable-panels"
-import { cn } from "@/utils/cn"
-
+import { GripVertical } from "lucide-react";
+import * as ResizablePrimitive from "react-resizable-panels";
+import { cn } from "@/utils/cn";
 
 const ResizablePanelGroup = ({
-  className,
-  ...props
+	className,
+	...props
 }: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
-  <ResizablePrimitive.PanelGroup
-    className={cn(
-      "flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
-      className
-    )}
-    {...props}
-  />
-)
+	<ResizablePrimitive.PanelGroup
+		className={cn(
+			"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
+			className,
+		)}
+		{...props}
+	/>
+);
 
-const ResizablePanel = ResizablePrimitive.Panel
+const ResizablePanel = ResizablePrimitive.Panel;
 
 const ResizableHandle = ({
-  withHandle,
-  className,
-  ...props
+	withHandle,
+	className,
+	...props
 }: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
-  withHandle?: boolean
+	withHandle?: boolean;
 }) => (
-  <ResizablePrimitive.PanelResizeHandle
-    className={cn(
-      "after:-translate-x-1/2 data-[panel-group-direction=vertical]:after:-translate-y-1/2 relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
-      className
-    )}
-    {...props}
-  >
-    {withHandle && (
-      <div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm">
-        <GripVertical className="h-3 w-3" />
-      </div>
-    )}
-  </ResizablePrimitive.PanelResizeHandle>
-)
+	<ResizablePrimitive.PanelResizeHandle
+		className={cn(
+			"after:-translate-x-1/2 data-[panel-group-direction=vertical]:after:-translate-y-1/2 relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
+			className,
+		)}
+		{...props}
+	>
+		{withHandle && (
+			<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm">
+				<GripVertical className="h-3 w-3" />
+			</div>
+		)}
+	</ResizablePrimitive.PanelResizeHandle>
+);
 
-export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
+export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
diff --git a/src/client/components/Slider.tsx b/src/client/components/Slider.tsx
index 83fc202..5068ee9 100644
--- a/src/client/components/Slider.tsx
+++ b/src/client/components/Slider.tsx
@@ -23,11 +23,7 @@ export const Slider = React.forwardRef<
 		<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-surface-secondary data-[disabled]:opacity-40">
 			<SliderPrimitive.Range className="absolute h-full bg-content-primary" />
 		</SliderPrimitive.Track>
-		<SliderPrimitive.Thumb
-			className="block h-4 w-4 rounded-full border border-surface-invert-secondary border-solid bg-surface-primary shadow transition-colors hover:border-content-primary focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-content-primary focus-visible:ring-offset-surface-primary disabled:pointer-events-none data-[disabled]:border-border data-[disabled]:opacity-100"
-		/>
-		<SliderPrimitive.Thumb
-			className="block h-4 w-4 rounded-full border border-surface-invert-secondary border-solid bg-surface-primary shadow transition-colors hover:border-content-primary focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-content-primary focus-visible:ring-offset-surface-primary disabled:pointer-events-none data-[disabled]:border-border data-[disabled]:opacity-100"
-		/>
+		<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-surface-invert-secondary border-solid bg-surface-primary shadow transition-colors hover:border-content-primary focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-content-primary focus-visible:ring-offset-surface-primary disabled:pointer-events-none data-[disabled]:border-border data-[disabled]:opacity-100" />
+		<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-surface-invert-secondary border-solid bg-surface-primary shadow transition-colors hover:border-content-primary focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-content-primary focus-visible:ring-offset-surface-primary disabled:pointer-events-none data-[disabled]:border-border data-[disabled]:opacity-100" />
 	</SliderPrimitive.Root>
 ));
diff --git a/src/client/components/Stack.tsx b/src/client/components/Stack.tsx
new file mode 100644
index 0000000..1ec7164
--- /dev/null
+++ b/src/client/components/Stack.tsx
@@ -0,0 +1,20 @@
+import { cn } from "@/utils/cn";
+import { forwardRef } from "react";
+
+type StackProps = {
+	className?: string;
+} & React.HTMLProps<HTMLDivElement>;
+
+export const Stack = forwardRef<HTMLDivElement, StackProps>((props, ref) => {
+	const { className, children, ...divProps } = props;
+
+	return (
+		<div
+			{...divProps}
+			ref={ref}
+			className={cn("flex flex-col gap-4 flex-wrap max-w-full", className)}
+		>
+			{children}
+		</div>
+	);
+});
diff --git a/src/client/components/Table.tsx b/src/client/components/Table.tsx
index 3fe4ad2..1b07172 100644
--- a/src/client/components/Table.tsx
+++ b/src/client/components/Table.tsx
@@ -114,4 +114,3 @@ export const TableCell = React.forwardRef<
 		{...props}
 	/>
 ));
-
diff --git a/src/client/components/Tabs.tsx b/src/client/components/Tabs.tsx
index aecc01c..8ff5a0b 100644
--- a/src/client/components/Tabs.tsx
+++ b/src/client/components/Tabs.tsx
@@ -4,13 +4,7 @@ import type { LucideProps } from "lucide-react";
 import { cn } from "@/utils/cn";
 
 export const Root: FC<Tabs.TabsProps> = ({ children, ...rest }) => {
-	return (
-		<Tabs.Root
-			{...rest}
-		>
-			{children}
-		</Tabs.Root>
-	);
+	return <Tabs.Root {...rest}>{children}</Tabs.Root>;
 };
 
 export const List: FC<Tabs.TabsListProps> = ({
@@ -19,7 +13,10 @@ export const List: FC<Tabs.TabsListProps> = ({
 	...rest
 }) => {
 	return (
-		<Tabs.List {...rest} className={cn("flex h-12 max-h-12 w-full border-b", className)}>
+		<Tabs.List
+			{...rest}
+			className={cn("flex h-12 max-h-12 w-full border-b", className)}
+		>
 			{children}
 		</Tabs.List>
 	);
@@ -39,7 +36,7 @@ export const Trigger: FC<TriggerProps> = ({
 		<Tabs.Trigger
 			{...rest}
 			className={cn(
-				"flex min-w-[120px] items-center gap-2 border-x border-x-transparent px-4 py-3 text-center text-content-secondary text-sm transition-colors hover:bg-surface-secondary hover:text-content-primary data-[disabled]:cursor-not-allowed data-[state=active]:border-x-border data-[state=active]:bg-surface-secondary data-[state=active]:text-content-primary data-[disabled]:hover:bg-inherit data-[disabled]:hover:text-content-secondary data-[state=active]:first:border-l-transparent",
+				"flex min-w-[120px] items-center gap-2 border-x border-x-transparent px-4 py-3 text-center text-content-secondary text-sm transition-colors hover:bg-surface-secondary hover:text-content-primary data-[disabled]:cursor-not-allowed data-[state=active]:border-x-border data-[state=active]:bg-surface-secondary data-[state=active]:text-content-primary data-[disabled]:hover:bg-inherit data-[disabled]:hover:text-content-secondary data-[state=active]:first:border-l-transparent last:border-r last:border-r-border",
 				className,
 			)}
 		>
diff --git a/src/client/components/TagInput.tsx b/src/client/components/TagInput.tsx
index 43c1cb8..22af805 100644
--- a/src/client/components/TagInput.tsx
+++ b/src/client/components/TagInput.tsx
@@ -1,3 +1,4 @@
+import { XIcon } from "lucide-react";
 import { type FC, useId, useMemo } from "react";
 
 type TagInputProps = {
@@ -28,11 +29,15 @@ export const TagInput: FC<TagInputProps> = ({
 				{values.map((value, index) => (
 					<button
 						key={itemIds[index]}
-						className="h-7 rounded-md bg-surface-secondary text-content-secondary"
-						onClick={() => {
+						className="flex h-7 items-center gap-1 rounded-md bg-surface-secondary px-2 text-content-secondary"
+						onClick={(e) => {
+							e.preventDefault();
 							onChange(values.filter((oldValue) => oldValue !== value));
 						}}
-					/>
+					>
+						{value}
+						<XIcon size={16} />
+					</button>
 				))}
 				<input
 					id={id}
diff --git a/src/client/diagnostics.ts b/src/client/diagnostics.ts
index 25c158a..898af07 100644
--- a/src/client/diagnostics.ts
+++ b/src/client/diagnostics.ts
@@ -1,8 +1,4 @@
-import type {
-	FriendlyDiagnostic,
-	ParserLog,
-	PreviewOutput,
-} from "@/gen/types";
+import type { FriendlyDiagnostic, ParserLog, PreviewOutput } from "@/gen/types";
 
 type FriendlyDiagnosticWithoutKind = Omit<FriendlyDiagnostic, "extra">;
 
diff --git a/src/client/Editor.tsx b/src/client/editor/Editor.tsx
similarity index 91%
rename from src/client/Editor.tsx
rename to src/client/editor/Editor.tsx
index 87c5fc8..c46bbba 100644
--- a/src/client/Editor.tsx
+++ b/src/client/editor/Editor.tsx
@@ -1,22 +1,3 @@
-import { Button } from "@/client/components/Button";
-import {
-	DropdownMenu,
-	DropdownMenuContent,
-	DropdownMenuItem,
-	DropdownMenuPortal,
-	DropdownMenuTrigger,
-} from "@/client/components/DropdownMenu";
-import { ResizablePanel } from "@/client/components/Resizable";
-import * as Tabs from "@/client/components/Tabs";
-import {
-	Tooltip,
-	TooltipContent,
-	TooltipTrigger,
-} from "@/client/components/Tooltip";
-import { useTheme } from "@/client/contexts/theme";
-import { multiSelect, radio, switchInput, textInput } from "@/client/snippets";
-import type { ParameterFormType } from "@/gen/types";
-import { cn } from "@/utils/cn";
 import { Editor as MonacoEditor } from "@monaco-editor/react";
 import {
 	CheckIcon,
@@ -24,14 +5,29 @@ import {
 	CopyIcon,
 	FileJsonIcon,
 	RadioIcon,
-	SettingsIcon,
 	SquareMousePointerIcon,
 	TextCursorInputIcon,
 	ToggleLeftIcon,
+	UsersIcon,
 	ZapIcon,
 } from "lucide-react";
 import { type FC, useEffect, useRef, useState } from "react";
+import { Button } from "@/client/components/Button";
+import {
+	DropdownMenu,
+	DropdownMenuContent,
+	DropdownMenuItem,
+	DropdownMenuPortal,
+	DropdownMenuTrigger,
+} from "@/client/components/DropdownMenu";
+import { ResizablePanel } from "@/client/components/Resizable";
+import * as Tabs from "@/client/components/Tabs";
 import { useEditor } from "@/client/contexts/editor";
+import { useTheme } from "@/client/contexts/theme";
+import { Users } from "@/client/editor/Users";
+import { multiSelect, radio, switchInput, textInput } from "@/client/snippets";
+import type { ParameterFormType } from "@/gen/types";
+import { cn } from "@/utils/cn";
 
 type EditorProps = {
 	code: string;
@@ -47,7 +43,7 @@ export const Editor: FC<EditorProps> = ({ code, setCode }) => {
 		undefined,
 	);
 
-	const [tab, setTab] = useState(() => "code");
+	const [tab, setTab] = useState(() => "users");
 
 	const onCopy = () => {
 		navigator.clipboard.writeText(code);
@@ -92,17 +88,7 @@ export const Editor: FC<EditorProps> = ({ code, setCode }) => {
 					<div className="flex h-12 w-full items-center justify-between border-b border-b-surface-quaternary pr-3">
 						<div className="flex">
 							<Tabs.Trigger icon={FileJsonIcon} label="Code" value="code" />
-							<Tooltip>
-								<TooltipTrigger asChild={true} className="hidden">
-									<Tabs.Trigger
-										icon={SettingsIcon}
-										label="Variables"
-										value="variables"
-										disabled={true}
-									/>
-								</TooltipTrigger>
-								<TooltipContent>Coming soon</TooltipContent>
-							</Tooltip>
+							<Tabs.Trigger icon={UsersIcon} label="Users" value="users" />
 						</div>
 
 						<DropdownMenu>
@@ -187,6 +173,10 @@ export const Editor: FC<EditorProps> = ({ code, setCode }) => {
 						/>
 					</div>
 				</Tabs.Content>
+
+				<Tabs.Content value="users" asChild={true}>
+					<Users />
+				</Tabs.Content>
 			</ResizablePanel>
 		</Tabs.Root>
 	);
diff --git a/src/client/editor/Users.tsx b/src/client/editor/Users.tsx
new file mode 100644
index 0000000..707ef79
--- /dev/null
+++ b/src/client/editor/Users.tsx
@@ -0,0 +1,244 @@
+/** biome-ignore-all lint/correctness/noChildrenProp: below
+ * Tanstack Form uses the children prop which lets us keep the component flat
+ * rather than having to define separate wrappers using hooks.
+ */
+
+import {
+	DropdownMenu,
+	DropdownMenuContent,
+	DropdownMenuItem,
+	DropdownMenuPortal,
+	DropdownMenuTrigger,
+} from "@radix-ui/react-dropdown-menu";
+import { useForm } from "@tanstack/react-form";
+import {
+	DownloadIcon,
+	Ellipsis,
+	PlusIcon,
+	TrashIcon,
+	UploadIcon,
+} from "lucide-react";
+import type { FC } from "react";
+import type { InferInput } from "valibot";
+import { Button } from "@/client/components/Button";
+import { Input } from "@/client/components/Input";
+import { Label } from "@/client/components/Label";
+import { TagInput } from "@/client/components/TagInput";
+import { OwnerSchema } from "@/owner";
+
+const UserForm: FC = () => {
+	const defaultValues: InferInput<typeof OwnerSchema> = {
+		name: "",
+		email: "",
+		full_name: "",
+		id: "",
+		ssh_public_key: "",
+		rbac_roles: [{ name: "", org_id: "" }],
+		groups: [],
+		login_type: "password",
+	};
+	const form = useForm({
+		defaultValues,
+		validators: {
+			onChange: OwnerSchema,
+		},
+		onSubmitInvalid: () => {
+			// TODO
+		},
+		onSubmit: () => {
+			// TODO
+		},
+	});
+
+	return (
+		<div className="flex w-full flex-col gap-5 rounded-lg border p-5">
+			<div className="flex w-full items-center justify-between">
+				<h2 className="font-semibold text-content-primary text-xl">
+					User Data
+				</h2>
+				<Button size="icon" variant="outline">
+					<TrashIcon />
+				</Button>
+			</div>
+			<form
+				className="flex w-full flex-col gap-4"
+				onSubmit={(e) => {
+					e.preventDefault();
+					e.stopPropagation();
+					form.handleSubmit();
+				}}
+			>
+				<div className="flex gap-3">
+					<form.Field name="name">
+						{(field) => {
+							return (
+								<div className="flex w-full flex-col gap-2">
+									<Label>Username</Label>
+									<Input
+										id={field.name}
+										name={field.name}
+										value={field.state.value}
+										onBlur={field.handleBlur}
+										onChange={(e) => field.handleChange(e.target.value)}
+										placeholder="alice.coder"
+									/>
+									{field.state.meta.isTouched && !field.state.meta.isValid ? (
+										<em>{field.state.meta.errors.join(", ")}</em>
+									) : null}
+									{field.state.meta.isValidating ? "Validating..." : null}{" "}
+								</div>
+							);
+						}}
+					</form.Field>
+					<form.Field
+						name="full_name"
+						children={(field) => {
+							return (
+								<div className="flex w-full flex-col gap-2">
+									<Label>Full name</Label>
+									<Input
+										id={field.name}
+										name={field.name}
+										value={field.state.value}
+										onBlur={field.handleBlur}
+										onChange={(e) => field.handleChange(e.target.value)}
+										placeholder="Alice Coder"
+									/>
+								</div>
+							);
+						}}
+					/>
+				</div>
+				<form.Field
+					name="email"
+					children={(field) => {
+						return (
+							<div className="flex w-full flex-col gap-2">
+								<Label>Email</Label>
+								<Input
+									id={field.name}
+									name={field.name}
+									value={field.state.value}
+									onBlur={field.handleBlur}
+									onChange={(e) => field.handleChange(e.target.value)}
+									placeholder="alice@coder.com"
+								/>
+							</div>
+						);
+					}}
+				/>
+				<form.Field
+					name="groups"
+					children={(field) => {
+						return (
+							<div className="flex w-full flex-col gap-2">
+								<Label>Groups</Label>
+								<TagInput
+									label="groups"
+									values={field.state.value}
+									onChange={(v) => field.handleChange(v)}
+								/>
+							</div>
+						);
+					}}
+				/>
+				<div className="flex flex-col gap-3">
+					<p>RBAC Roles</p>
+					<form.Field name="rbac_roles">
+						{(field) => {
+							return field.state.value.map((_, index) => {
+								return (
+									<div key={index} className="flex items-center gap-4">
+										<form.Field name={`rbac_roles[${index}].name`}>
+											{(subField) => (
+												<Input
+													value={subField.state.value}
+													placeholder="Role name"
+													onChange={(e) => {
+														subField.handleChange(e.target.value);
+													}}
+												/>
+											)}
+										</form.Field>
+										<form.Field name={`rbac_roles[${index}].org_id`}>
+											{(subField) => (
+												<Input
+													value={subField.state.value}
+													placeholder="Organization ID"
+													onChange={(e) => {
+														subField.handleChange(e.target.value);
+													}}
+												/>
+											)}
+										</form.Field>
+										<Button
+											variant="outline"
+											size="icon"
+											onClick={() => {
+												field.removeValue(index);
+											}}
+										>
+											<TrashIcon />
+										</Button>
+									</div>
+								);
+							});
+						}}
+					</form.Field>
+					<Button
+						size="sm"
+						variant="outline"
+						onClick={() =>
+							form.setFieldValue("rbac_roles", (curr) => [
+								...curr,
+								{ name: "", org_id: "" },
+							])
+						}
+					>
+						<PlusIcon />
+						Add Roles
+					</Button>
+				</div>
+				<Button type="submit" onClick={() => form.handleSubmit()}>
+					Save
+				</Button>
+			</form>
+		</div>
+	);
+};
+
+export const Users: FC = () => {
+	return (
+		<div className="flex w-full flex-col gap-4 p-6">
+			<div className="flex items-center justify-between">
+				<h1 className="font-semibold text-3xl text-content-primary">Users</h1>
+				<DropdownMenu>
+					<DropdownMenuTrigger asChild={true}>
+						<Button variant="outline" size="icon-lg">
+							<Ellipsis />
+						</Button>
+					</DropdownMenuTrigger>
+					<DropdownMenuPortal>
+						<DropdownMenuContent align="end">
+							<DropdownMenuItem>
+								<UploadIcon />
+								Upload
+							</DropdownMenuItem>
+							<DropdownMenuItem>
+								<DownloadIcon />
+								Download
+							</DropdownMenuItem>
+						</DropdownMenuContent>
+					</DropdownMenuPortal>
+				</DropdownMenu>
+			</div>
+			<div className="">
+				<UserForm />
+			</div>
+			<Button variant="outline">
+				<PlusIcon />
+				Add a user
+			</Button>
+		</div>
+	);
+};
diff --git a/src/client/hooks/debounce.tsx b/src/client/hooks/debounce.tsx
index 42aa776..826baba 100644
--- a/src/client/hooks/debounce.tsx
+++ b/src/client/hooks/debounce.tsx
@@ -89,7 +89,7 @@ export function useDebouncedValue<T = unknown>(
 	const [isDebouncing, setIsDebouncing] = useState(false);
 
 	useEffect(() => {
-    setIsDebouncing(() => true);
+		setIsDebouncing(() => true);
 		const timeoutId = window.setTimeout(() => {
 			setDebouncedValue(value);
 			setIsDebouncing(() => false);
diff --git a/src/client/index.css b/src/client/index.css
index 73cd3ec..3e599fb 100644
--- a/src/client/index.css
+++ b/src/client/index.css
@@ -3,93 +3,93 @@
 	Related issue: https://github.com/shadcn-ui/ui/issues/805#issuecomment-1616021820
 */
 
-@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&display=swap');
-@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&display=swap');
+@import url("https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&display=swap");
+@import url("https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&display=swap");
 
 @tailwind base;
 @tailwind components;
 @tailwind utilities;
 
 @layer base {
-  :root {
-    --content-primary: 240 10% 4%;
-    --content-secondary: 240 5% 34%;
-    --content-link: 221 83% 53%;
-    --content-invert: 0 0% 98%;
-    --content-disabled: 240 5% 65%;
-    --content-success: 142 72% 29%;
-    --content-warning: 27 96% 61%;
-    --content-destructive: 0 84% 60%;
-    --surface-primary: 0 0% 98%;
-    --surface-secondary: 240 5% 96%;
-    --surface-tertiary: 240 6% 90%;
-    --surface-quaternary: 240 5% 84%;
-    --surface-invert-primary: 240 4% 16%;
-    --surface-invert-secondary: 240 5% 26%;
-    --surface-destructive: 0 93% 94%;
-    --surface-green: 141 79% 85%;
-    --surface-grey: 240 5% 96%;
-    --surface-orange: 34 100% 92%;
-    --surface-sky: 201 94% 86%;
-    --border-default: 240 6% 90%;
-    --border-success: 142 76% 36%;
-    --border-destructive: 0 84% 60%;
-    --border-hover: 240, 5%, 34%;
-    --overlay-default: 240 5% 84% / 80%;
-    --radius: 0.5rem;
-    --highlight-purple: 262 83% 58%;
-    --highlight-green: 143 64% 24%;
-    --highlight-grey: 240 5% 65%;
-    --highlight-sky: 201 90% 27%;
-    --border: 240 5.9% 90%;
-    --input: 240 5.9% 90%;
-    --ring: 240 10% 3.9%;
-    --avatar-lg: 2.5rem;
-    --avatar-default: 1.5rem;
-    --avatar-sm: 1.125rem;
-  }
+	:root {
+		--content-primary: 240 10% 4%;
+		--content-secondary: 240 5% 34%;
+		--content-link: 221 83% 53%;
+		--content-invert: 0 0% 98%;
+		--content-disabled: 240 5% 65%;
+		--content-success: 142 72% 29%;
+		--content-warning: 27 96% 61%;
+		--content-destructive: 0 84% 60%;
+		--surface-primary: 0 0% 98%;
+		--surface-secondary: 240 5% 96%;
+		--surface-tertiary: 240 6% 90%;
+		--surface-quaternary: 240 5% 84%;
+		--surface-invert-primary: 240 4% 16%;
+		--surface-invert-secondary: 240 5% 26%;
+		--surface-destructive: 0 93% 94%;
+		--surface-green: 141 79% 85%;
+		--surface-grey: 240 5% 96%;
+		--surface-orange: 34 100% 92%;
+		--surface-sky: 201 94% 86%;
+		--border-default: 240 6% 90%;
+		--border-success: 142 76% 36%;
+		--border-destructive: 0 84% 60%;
+		--border-hover: 240, 5%, 34%;
+		--overlay-default: 240 5% 84% / 80%;
+		--radius: 0.5rem;
+		--highlight-purple: 262 83% 58%;
+		--highlight-green: 143 64% 24%;
+		--highlight-grey: 240 5% 65%;
+		--highlight-sky: 201 90% 27%;
+		--border: 240 5.9% 90%;
+		--input: 240 5.9% 90%;
+		--ring: 240 10% 3.9%;
+		--avatar-lg: 2.5rem;
+		--avatar-default: 1.5rem;
+		--avatar-sm: 1.125rem;
+	}
 
-  .dark {
-    --content-primary: 0 0% 98%;
-    --content-secondary: 240 5% 65%;
-    --content-link: 213 94% 68%;
-    --content-invert: 240 10% 4%;
-    --content-disabled: 240 5% 26%;
-    --content-success: 142 76% 36%;
-    --content-warning: 31 97% 72%;
-    --content-destructive: 0 91% 71%;
-    --surface-primary: 240 10% 4%;
-    --surface-secondary: 240 6% 10%;
-    --surface-tertiary: 240 4% 16%;
-    --surface-quaternary: 240 5% 26%;
-    --surface-invert-primary: 240 6% 90%;
-    --surface-invert-secondary: 240 5% 65%;
-    --surface-destructive: 0 75% 15%;
-    --surface-green: 145 80% 10%;
-    --surface-grey: 240 6% 10%;
-    --surface-orange: 13 81% 15%;
-    --surface-sky: 204 80% 16%;
-    --border-default: 240 4% 16%;
-    --border-success: 142 76% 36%;
-    --border-destructive: 0 91% 71%;
-    --border-hover: 240, 5%, 34%;
-    --overlay-default: 240 10% 4% / 80%;
-    --highlight-purple: 252 95% 85%;
-    --highlight-green: 141 79% 85%;
-    --highlight-grey: 240 4% 46%;
-    --highlight-sky: 198 93% 60%;
-    --border: 240 3.7% 15.9%;
-    --input: 240 3.7% 15.9%;
-    --ring: 240 4.9% 83.9%;
-  }
+	.dark {
+		--content-primary: 0 0% 98%;
+		--content-secondary: 240 5% 65%;
+		--content-link: 213 94% 68%;
+		--content-invert: 240 10% 4%;
+		--content-disabled: 240 5% 26%;
+		--content-success: 142 76% 36%;
+		--content-warning: 31 97% 72%;
+		--content-destructive: 0 91% 71%;
+		--surface-primary: 240 10% 4%;
+		--surface-secondary: 240 6% 10%;
+		--surface-tertiary: 240 4% 16%;
+		--surface-quaternary: 240 5% 26%;
+		--surface-invert-primary: 240 6% 90%;
+		--surface-invert-secondary: 240 5% 65%;
+		--surface-destructive: 0 75% 15%;
+		--surface-green: 145 80% 10%;
+		--surface-grey: 240 6% 10%;
+		--surface-orange: 13 81% 15%;
+		--surface-sky: 204 80% 16%;
+		--border-default: 240 4% 16%;
+		--border-success: 142 76% 36%;
+		--border-destructive: 0 91% 71%;
+		--border-hover: 240, 5%, 34%;
+		--overlay-default: 240 10% 4% / 80%;
+		--highlight-purple: 252 95% 85%;
+		--highlight-green: 141 79% 85%;
+		--highlight-grey: 240 4% 46%;
+		--highlight-sky: 198 93% 60%;
+		--border: 240 3.7% 15.9%;
+		--input: 240 3.7% 15.9%;
+		--ring: 240 4.9% 83.9%;
+	}
 }
 
 @layer base {
-  * {
-    @apply border-border;
-  }
+	* {
+		@apply border-border;
+	}
 
-  /*
+	/*
 	By default, Radix adds a margin to the `body` element when a dropdown is displayed,
 	causing some shifting when the dropdown has a full-width size, as is the case with the mobile menu.
 	To prevent this, we need to apply the styles below.
@@ -97,10 +97,10 @@
 	There’s a related issue on GitHub: Radix UI Primitives Issue #3251
 	https://github.com/radix-ui/primitives/issues/3251
 	 */
-  html body[data-scroll-locked] {
-    --removed-body-scroll-bar-size: 0 !important;
-    margin-right: 0 !important;
-  }
+	html body[data-scroll-locked] {
+		--removed-body-scroll-bar-size: 0 !important;
+		margin-right: 0 !important;
+	}
 }
 
 /*
@@ -108,38 +108,38 @@
  */
 
 .editor {
-  color: hsl(var(--content-primary));
-  counter-reset: line;
-  padding-top: 12px !important;
-  min-height: 100%;
+	color: hsl(var(--content-primary));
+	counter-reset: line;
+	padding-top: 12px !important;
+	min-height: 100%;
 }
 
 .editor #codeArea {
-  outline: none;
-  padding-left: 60px !important;
-  padding-top: 12px !important;
+	outline: none;
+	padding-left: 60px !important;
+	padding-top: 12px !important;
 }
 
 .editor pre {
-  padding-left: 60px !important;
+	padding-left: 60px !important;
 }
 
 .editor .editorLineNumber {
-  position: absolute;
-  left: 0px;
-  color: hsl(var(--content-secondary));
-  text-align: right;
-  width: 40px;
-  font-weight: 100;
+	position: absolute;
+	left: 0px;
+	color: hsl(var(--content-secondary));
+	text-align: right;
+	width: 40px;
+	font-weight: 100;
 }
 
 /* Styles for the JSON viewer */
 
 .react-json-view {
-  font-family: "DM Mono", monospace !important;
-  background: hsl(var(--surface-primary)) !important;
+	font-family: "DM Mono", monospace !important;
+	background: hsl(var(--surface-primary)) !important;
 }
 
 .copy-to-clipboard-container span {
-  display: flex !important;
-}
\ No newline at end of file
+	display: flex !important;
+}
diff --git a/src/client/snippets.ts b/src/client/snippets.ts
index 26b8fef..e443981 100644
--- a/src/client/snippets.ts
+++ b/src/client/snippets.ts
@@ -93,7 +93,7 @@ export const switchInput = `data "coder_parameter" "switch" {
   form_type    = "switch"
   default      = true
   order        = 1
-}`
+}`;
 
 export const checkerModule = `
 variable "solutions" {
diff --git a/src/owner.ts b/src/owner.ts
index f6f0b2d..cb9cdea 100644
--- a/src/owner.ts
+++ b/src/owner.ts
@@ -1,6 +1,24 @@
-import type { WorkspaceOwner } from "@/gen/types";
+import * as v from "valibot";
 
-export const baseMockUser: WorkspaceOwner = {
+export const OwnerSchema = v.object({
+	id: v.nullish(v.pipe(v.string()), "cc915c5a-5709-4e32-b442-9000caabd9dd"),
+	name: v.string(),
+	full_name: v.string(),
+	email: v.string(),
+	groups: v.array(v.string()),
+	rbac_roles: v.array(
+		v.object({
+			name: v.string(),
+			org_id: v.string(),
+		}),
+	),
+	ssh_public_key: v.nullish(v.string(), ""),
+	login_type: v.nullish(v.string(), "password"),
+});
+
+export type Owner = v.InferOutput<typeof OwnerSchema>;
+
+export const baseMockUser: Owner = {
 	id: "8d36e355-e775-4c49-9b8d-ac042ed50440",
 	name: "coder",
 	full_name: "Coder",
@@ -15,7 +33,7 @@ export const baseMockUser: WorkspaceOwner = {
 			org_id: "09942665-ba1b-4661-be9f-36bf9f738c83",
 		},
 	],
-};
+} satisfies Owner;
 
 export type User =
 	| "admin"
@@ -24,7 +42,7 @@ export type User =
 	| "eu-developer"
 	| "sales";
 
-export const mockUsers: Record<User, WorkspaceOwner> = {
+export const mockUsers: Record<User, Owner> = {
 	admin: {
 		...baseMockUser,
 		name: "admin",

From 13f88f8c68a11d984cf934f54edd18b6d35e976b Mon Sep 17 00:00:00 2001
From: Brett Kolodny <brett.kolodny@coder.com>
Date: Mon, 7 Jul 2025 21:02:13 +0000
Subject: [PATCH 02/21] feat: users form implementation

---
 src/client/App.tsx           |  38 ++++------
 src/client/Preview.tsx       |  87 ++++++++++++++--------
 src/client/editor/Editor.tsx |  15 +++-
 src/client/editor/Users.tsx  | 139 +++++++++++++++++++++++++++--------
 src/client/utils.ts          |  21 ++++++
 src/owner.ts                 |  46 +++++++-----
 6 files changed, 244 insertions(+), 102 deletions(-)
 create mode 100644 src/client/utils.ts

diff --git a/src/client/App.tsx b/src/client/App.tsx
index ead94a9..beec723 100644
--- a/src/client/App.tsx
+++ b/src/client/App.tsx
@@ -36,7 +36,7 @@ import type {
 	PreviewOutput,
 	WorkspaceOwner,
 } from "@/gen/types";
-import { mockUsers } from "@/owner";
+import { baseMockUser, mockUsers } from "@/owner";
 import { rpc } from "@/utils/rpc";
 import {
 	getDynamicParametersOutput,
@@ -44,6 +44,7 @@ import {
 	type WasmLoadState,
 } from "@/utils/wasm";
 import { useDebouncedValue } from "./hooks/debounce";
+import { downloadData } from "./utils";
 
 export const App = () => {
 	useBeforeUnload(
@@ -67,28 +68,14 @@ export const App = () => {
 	>({});
 	const [output, setOutput] = useState<PreviewOutput | null>(null);
 	const [parameters, setParameters] = useState<ParameterWithSource[]>([]);
-	const [owner, setOwner] = useState<WorkspaceOwner>(mockUsers.admin);
 
-	const onDownloadOutput = () => {
-		const blob = new Blob([JSON.stringify(output, null, 2)], {
-			type: "application/json",
-		});
+	const [owner, setOwner] = useState<WorkspaceOwner>(
+		mockUsers[0] ?? baseMockUser,
+	);
+	const [owners, setOwners] = useState<WorkspaceOwner[]>(mockUsers);
 
-		const url = URL.createObjectURL(blob);
-
-		const link = document.createElement("a");
-		link.href = url;
-		link.download = "output.json";
-		document.body.appendChild(link);
-		link.click();
-		document.body.removeChild(link);
-
-		// Give the click event enough time to fire and then revoke the URL.
-		// This method of doing it doesn't seem great but I'm not sure if there is a
-		// better way.
-		setTimeout(() => {
-			URL.revokeObjectURL(url);
-		}, 100);
+	const onDownloadOutput = () => {
+		downloadData(output, "output.json");
 	};
 
 	const onReset = () => {
@@ -203,7 +190,12 @@ export const App = () => {
 
 			<ResizablePanelGroup direction={"horizontal"}>
 				{/* EDITOR */}
-				<Editor code={code} setCode={setCode} />
+				<Editor
+					code={code}
+					setCode={setCode}
+					owners={owners}
+					setOwners={setOwners}
+				/>
 
 				<ResizableHandle className="bg-surface-quaternary" />
 
@@ -217,6 +209,8 @@ export const App = () => {
 					setParameterValues={setParameterValues}
 					parameters={parameters}
 					onReset={onReset}
+					selectedOwner={owner}
+					owners={owners}
 					setOwner={(owner) => {
 						onReset();
 						setOwner(owner);
diff --git a/src/client/Preview.tsx b/src/client/Preview.tsx
index 9ca2d27..76e63ac 100644
--- a/src/client/Preview.tsx
+++ b/src/client/Preview.tsx
@@ -1,3 +1,24 @@
+import ReactJsonView from "@microlink/react-json-view";
+import * as Dialog from "@radix-ui/react-dialog";
+import {
+	ActivityIcon,
+	BugIcon,
+	DownloadIcon,
+	ExternalLinkIcon,
+	LoaderIcon,
+	PlayIcon,
+	ScrollTextIcon,
+	SearchCodeIcon,
+	XIcon,
+} from "lucide-react";
+import { AnimatePresence, motion } from "motion/react";
+import React, {
+	type FC,
+	type PropsWithChildren,
+	useMemo,
+	useState,
+} from "react";
+import { useSearchParams } from "react-router";
 import { Button } from "@/client/components/Button";
 import {
 	DynamicParameter,
@@ -17,33 +38,15 @@ import {
 } from "@/client/components/Select";
 import * as Tabs from "@/client/components/Tabs";
 import { useTheme } from "@/client/contexts/theme";
-import { outputToDiagnostics, type Diagnostic } from "@/client/diagnostics";
+import { type Diagnostic, outputToDiagnostics } from "@/client/diagnostics";
 import type {
 	ParameterWithSource,
 	ParserLog,
 	PreviewOutput,
 	WorkspaceOwner,
 } from "@/gen/types";
-import { mockUsers } from "@/owner";
 import { cn } from "@/utils/cn";
 import type { WasmLoadState } from "@/utils/wasm";
-import ReactJsonView from "@microlink/react-json-view";
-import * as Dialog from "@radix-ui/react-dialog";
-import {
-	ActivityIcon,
-	BugIcon,
-	DownloadIcon,
-	ExternalLinkIcon,
-	LoaderIcon,
-	PlayIcon,
-	ScrollTextIcon,
-	SearchCodeIcon,
-	XIcon,
-} from "lucide-react";
-import { AnimatePresence, motion } from "motion/react";
-import React from "react";
-import { type FC, type PropsWithChildren, useMemo, useState } from "react";
-import { useSearchParams } from "react-router";
 
 type PreviewProps = {
 	wasmLoadState: WasmLoadState;
@@ -56,6 +59,8 @@ type PreviewProps = {
 	>;
 	parameters: ParameterWithSource[];
 	onReset: () => void;
+	selectedOwner: WorkspaceOwner;
+	owners: WorkspaceOwner[];
 	setOwner: (owner: WorkspaceOwner) => void;
 };
 
@@ -67,6 +72,8 @@ export const Preview: FC<PreviewProps> = ({
 	setParameterValues,
 	parameters,
 	onReset,
+	selectedOwner,
+	owners,
 	setOwner,
 }) => {
 	const [params] = useSearchParams();
@@ -183,7 +190,11 @@ export const Preview: FC<PreviewProps> = ({
 										) : null}
 									</AnimatePresence>
 								</div>
-								<UserSelect setOwner={setOwner} />
+								<UserSelect
+									setOwner={setOwner}
+									owners={owners}
+									selectedOwner={selectedOwner}
+								/>
 							</div>
 						}
 						{parameters.length === 0 ? (
@@ -551,26 +562,43 @@ const FormElement: FC<FormElementProps> = React.memo(
 FormElement.displayName = "FormElement";
 
 type UserSelectProps = {
+	selectedOwner: WorkspaceOwner;
+	owners: WorkspaceOwner[];
 	setOwner: (owner: WorkspaceOwner) => void;
 };
-const UserSelect: FC<UserSelectProps> = ({ setOwner }) => {
+const UserSelect: FC<UserSelectProps> = ({
+	selectedOwner,
+	owners,
+	setOwner,
+}) => {
+	const selectedMissing = !owners.some(
+		(owner) => selectedOwner.id === owner.id,
+	);
+
 	return (
 		<Select
-			defaultValue="admin"
+			value={selectedOwner.id}
 			onValueChange={(value) => {
-				const users: Record<string, WorkspaceOwner | undefined> = mockUsers;
-				setOwner(users[value] ?? mockUsers.admin);
+				const owner = owners.find((owner) => owner.id === value);
+				if (owner) {
+					setOwner(owner);
+				}
 			}}
 		>
 			<SelectTrigger className="w-fit min-w-40">
 				<SelectValue />
 			</SelectTrigger>
 			<SelectContent>
-				<SelectItem value="admin">Administrator</SelectItem>
-				<SelectItem value="developer">Developer</SelectItem>
-				<SelectItem value="contractor">Contractor</SelectItem>
-				<SelectItem value="eu-developer">EU Developer</SelectItem>
-				<SelectItem value="sales">Sales</SelectItem>
+				{selectedMissing ? (
+					<SelectItem value={selectedOwner.id}>
+						{selectedOwner.full_name || selectedOwner.name}
+					</SelectItem>
+				) : null}
+				{owners.map((owner, index) => (
+					<SelectItem value={owner.id} key={index}>
+						{owner.full_name || owner.name}
+					</SelectItem>
+				))}
 			</SelectContent>
 		</Select>
 	);
@@ -690,6 +718,7 @@ const ViewOutput: FC<ViewOutputProps> = ({ parameters }) => {
 					return true;
 				}
 
+				// biome-ignore lint/correctness/useHookAtTopLevel: It's not a hook
 				const schema = useValidationSchemaForDynamicParameters([p]);
 				schema.validateSync([{ name: p.name, value: p.value.value }]);
 
diff --git a/src/client/editor/Editor.tsx b/src/client/editor/Editor.tsx
index c46bbba..1d3f6fe 100644
--- a/src/client/editor/Editor.tsx
+++ b/src/client/editor/Editor.tsx
@@ -26,15 +26,22 @@ import { useEditor } from "@/client/contexts/editor";
 import { useTheme } from "@/client/contexts/theme";
 import { Users } from "@/client/editor/Users";
 import { multiSelect, radio, switchInput, textInput } from "@/client/snippets";
-import type { ParameterFormType } from "@/gen/types";
+import type { ParameterFormType, WorkspaceOwner } from "@/gen/types";
 import { cn } from "@/utils/cn";
 
 type EditorProps = {
 	code: string;
 	setCode: React.Dispatch<React.SetStateAction<string>>;
+	owners: WorkspaceOwner[];
+	setOwners: (owners: WorkspaceOwner[]) => void;
 };
 
-export const Editor: FC<EditorProps> = ({ code, setCode }) => {
+export const Editor: FC<EditorProps> = ({
+	code,
+	setCode,
+	owners,
+	setOwners,
+}) => {
 	const { appliedTheme } = useTheme();
 	const editorRef = useEditor();
 
@@ -43,7 +50,7 @@ export const Editor: FC<EditorProps> = ({ code, setCode }) => {
 		undefined,
 	);
 
-	const [tab, setTab] = useState(() => "users");
+	const [tab, setTab] = useState(() => "code");
 
 	const onCopy = () => {
 		navigator.clipboard.writeText(code);
@@ -175,7 +182,7 @@ export const Editor: FC<EditorProps> = ({ code, setCode }) => {
 				</Tabs.Content>
 
 				<Tabs.Content value="users" asChild={true}>
-					<Users />
+					<Users setUsers={setOwners} users={owners} />
 				</Tabs.Content>
 			</ResizablePanel>
 		</Tabs.Root>
diff --git a/src/client/editor/Users.tsx b/src/client/editor/Users.tsx
index 707ef79..7c2b275 100644
--- a/src/client/editor/Users.tsx
+++ b/src/client/editor/Users.tsx
@@ -3,40 +3,47 @@
  * rather than having to define separate wrappers using hooks.
  */
 
-import {
-	DropdownMenu,
-	DropdownMenuContent,
-	DropdownMenuItem,
-	DropdownMenuPortal,
-	DropdownMenuTrigger,
-} from "@radix-ui/react-dropdown-menu";
 import { useForm } from "@tanstack/react-form";
 import {
 	DownloadIcon,
 	Ellipsis,
+	PencilIcon,
 	PlusIcon,
 	TrashIcon,
 	UploadIcon,
 } from "lucide-react";
-import type { FC } from "react";
+import { type FC, useState } from "react";
 import type { InferInput } from "valibot";
+import * as v from "valibot";
 import { Button } from "@/client/components/Button";
+import {
+	DropdownMenu,
+	DropdownMenuContent,
+	DropdownMenuItem,
+	DropdownMenuPortal,
+	DropdownMenuTrigger,
+} from "@/client/components/DropdownMenu";
 import { Input } from "@/client/components/Input";
 import { Label } from "@/client/components/Label";
 import { TagInput } from "@/client/components/TagInput";
-import { OwnerSchema } from "@/owner";
+import type { WorkspaceOwner } from "@/gen/types";
+import { emptyUser, type Owner, OwnerSchema } from "@/owner";
+import { downloadData } from "../utils";
 
-const UserForm: FC = () => {
-	const defaultValues: InferInput<typeof OwnerSchema> = {
-		name: "",
-		email: "",
-		full_name: "",
-		id: "",
-		ssh_public_key: "",
-		rbac_roles: [{ name: "", org_id: "" }],
-		groups: [],
-		login_type: "password",
-	};
+const UserFormSchema = v.object({
+	users: v.array(OwnerSchema),
+});
+type UserForm = v.InferInput<typeof UserFormSchema>;
+
+type UserFormProps = {
+	user: Owner;
+	onSave: (user: Owner) => void;
+	onDelete: () => void;
+};
+const UserForm: FC<UserFormProps> = ({ user, onSave, onDelete }) => {
+	const [isEditing, setIsEditing] = useState(user.name === "");
+
+	const defaultValues: InferInput<typeof OwnerSchema> = user;
 	const form = useForm({
 		defaultValues,
 		validators: {
@@ -45,18 +52,47 @@ const UserForm: FC = () => {
 		onSubmitInvalid: () => {
 			// TODO
 		},
-		onSubmit: () => {
-			// TODO
+		onSubmit: ({ value }) => {
+			setIsEditing(false);
+			const owner = v.parse(OwnerSchema, value);
+			onSave(owner);
 		},
 	});
 
+	if (!isEditing) {
+		return (
+			<div className="flex w-full items-center justify-between rounded-lg border px-4 py-3">
+				<div className="flex flex-col">
+					<p className="text-content-primary">{user.full_name}</p>
+					<p className="text-content-secondary text-xs">
+						{[
+							user.email,
+							...user.groups,
+							...user.rbac_roles.map(({ name }) => name),
+						].join(" • ")}
+					</p>
+				</div>
+				<Button variant="subtle" size="icon" onClick={() => setIsEditing(true)}>
+					<PencilIcon />
+				</Button>
+			</div>
+		);
+	}
+
 	return (
 		<div className="flex w-full flex-col gap-5 rounded-lg border p-5">
 			<div className="flex w-full items-center justify-between">
 				<h2 className="font-semibold text-content-primary text-xl">
 					User Data
 				</h2>
-				<Button size="icon" variant="outline">
+				<Button
+					size="icon"
+					variant="outline"
+					onClick={(e) => {
+						e.preventDefault();
+						onDelete();
+					}}
+				>
 					<TrashIcon />
 				</Button>
 			</div>
@@ -207,7 +243,24 @@ const UserForm: FC = () => {
 	);
 };
 
-export const Users: FC = () => {
+type UsersProps = {
+	users: WorkspaceOwner[];
+	setUsers: (owners: WorkspaceOwner[]) => void;
+};
+export const Users: FC<UsersProps> = ({ users, setUsers }) => {
+	const defaultValues: UserForm = {
+		users,
+	};
+	const form = useForm({
+		defaultValues,
+		validators: {
+			onChange: UserFormSchema,
+		},
+		onSubmit: ({ value }) => {
+			setUsers(value.users);
+		},
+	});
+
 	return (
 		<div className="flex w-full flex-col gap-4 p-6">
 			<div className="flex items-center justify-between">
@@ -224,7 +277,12 @@ export const Users: FC = () => {
 								<UploadIcon />
 								Upload
 							</DropdownMenuItem>
-							<DropdownMenuItem>
+							<DropdownMenuItem
+								disabled={form.state.errors.length > 0}
+								onClick={() => {
+									downloadData(form.state.values.users, "users.json");
+								}}
+							>
 								<DownloadIcon />
 								Download
 							</DropdownMenuItem>
@@ -232,10 +290,33 @@ export const Users: FC = () => {
 					</DropdownMenuPortal>
 				</DropdownMenu>
 			</div>
-			<div className="">
-				<UserForm />
-			</div>
-			<Button variant="outline">
+			<form.Field name="users" mode="array">
+				{(field) => {
+					return (
+						<div className=" flex flex-col gap-3">
+							{field.state.value.map((_, index) => (
+								<form.Field key={index} name={`users[${index}]`}>
+									{(subField) => (
+										<UserForm
+											user={subField.state.value}
+											key={window.crypto.randomUUID()}
+											onSave={(owner) => {
+												subField.handleChange(owner);
+												form.handleSubmit();
+											}}
+											onDelete={() => {
+												field.removeValue(index);
+												form.handleSubmit();
+											}}
+										/>
+									)}
+								</form.Field>
+							))}
+						</div>
+					);
+				}}
+			</form.Field>
+			<Button onClick={() => form.pushFieldValue("users", emptyUser)}>
 				<PlusIcon />
 				Add a user
 			</Button>
diff --git a/src/client/utils.ts b/src/client/utils.ts
new file mode 100644
index 0000000..d866451
--- /dev/null
+++ b/src/client/utils.ts
@@ -0,0 +1,21 @@
+export const downloadData = (data: unknown, fileName: string) => {
+	const blob = new Blob([JSON.stringify(data, null, 2)], {
+		type: "application/json",
+	});
+
+	const url = URL.createObjectURL(blob);
+
+	const link = document.createElement("a");
+	link.href = url;
+	link.download = fileName.endsWith(".json") ? fileName : `${fileName}.json`;
+	document.body.appendChild(link);
+	link.click();
+	document.body.removeChild(link);
+
+	// Give the click event enough time to fire and then revoke the URL.
+	// This method of doing it doesn't seem great but I'm not sure if there is a
+	// better way.
+	setTimeout(() => {
+		URL.revokeObjectURL(url);
+	}, 100);
+};
diff --git a/src/owner.ts b/src/owner.ts
index cb9cdea..cf0d0d4 100644
--- a/src/owner.ts
+++ b/src/owner.ts
@@ -1,7 +1,8 @@
 import * as v from "valibot";
+import type { WorkspaceOwner } from "@/gen/types";
 
 export const OwnerSchema = v.object({
-	id: v.nullish(v.pipe(v.string()), "cc915c5a-5709-4e32-b442-9000caabd9dd"),
+	id: v.string(),
 	name: v.string(),
 	full_name: v.string(),
 	email: v.string(),
@@ -12,12 +13,23 @@ export const OwnerSchema = v.object({
 			org_id: v.string(),
 		}),
 	),
-	ssh_public_key: v.nullish(v.string(), ""),
-	login_type: v.nullish(v.string(), "password"),
-});
+	ssh_public_key: v.string(),
+	login_type: v.string(),
+}) satisfies v.GenericSchema<WorkspaceOwner>;
 
 export type Owner = v.InferOutput<typeof OwnerSchema>;
 
+export const emptyUser: Owner = {
+	id: "54e265e8-43b2-46a7-9d1c-b612d63f57b7",
+	name: "",
+	full_name: "",
+	email: "",
+	ssh_public_key: "",
+	groups: [],
+	login_type: "",
+	rbac_roles: [],
+};
+
 export const baseMockUser: Owner = {
 	id: "8d36e355-e775-4c49-9b8d-ac042ed50440",
 	name: "coder",
@@ -35,16 +47,10 @@ export const baseMockUser: Owner = {
 	],
 } satisfies Owner;
 
-export type User =
-	| "admin"
-	| "developer"
-	| "contractor"
-	| "eu-developer"
-	| "sales";
-
-export const mockUsers: Record<User, Owner> = {
-	admin: {
+export const mockUsers: Owner[] = [
+	{
 		...baseMockUser,
+		id: "f7090396-a12b-4477-b56a-eeee60d7fffa",
 		name: "admin",
 		full_name: "Admin",
 		email: "admin@coder.com",
@@ -61,32 +67,36 @@ export const mockUsers: Record<User, Owner> = {
 			},
 		],
 	},
-	developer: {
+	{
 		...baseMockUser,
+		id: "7310a8dd-6919-43f1-a4e2-b5dd97a51a39",
 		name: "developer",
 		full_name: "Developer",
 		email: "dev@coder.com",
 		groups: ["developer"],
 	},
-	contractor: {
+	{
 		...baseMockUser,
+		id: "5d75db13-c70d-489c-b78d-aacee4fae043",
 		name: "contractor",
 		full_name: "Contractor",
 		email: "contractor@coder.com",
 		groups: ["contractor"],
 	},
-	"eu-developer": {
+	{
 		...baseMockUser,
+		id: "2f07b3a1-119f-4ed4-a50d-0055b4bf4fcd",
 		name: "eu-developer",
 		full_name: "EU Developer",
 		email: "eu.dev@coder.com",
 		groups: ["developer", "eu-helsinki"],
 	},
-	sales: {
+	{
 		...baseMockUser,
+		id: "23f45ca8-a2df-4ee5-801b-4cc8f1c0000f",
 		name: "sales",
 		full_name: "Sales",
 		email: "sales@coder.com",
 		groups: ["sales"],
 	},
-};
+];

From 49be0fedfe18ad88f8a058f1493d44f36aaaa3c3 Mon Sep 17 00:00:00 2001
From: Brett Kolodny <brett.kolodny@coder.com>
Date: Mon, 7 Jul 2025 21:04:41 +0000
Subject: [PATCH 03/21] feat: add close button to user form

---
 src/client/editor/Users.tsx | 33 +++++++++++++++++++++++----------
 1 file changed, 23 insertions(+), 10 deletions(-)

diff --git a/src/client/editor/Users.tsx b/src/client/editor/Users.tsx
index 7c2b275..1194e88 100644
--- a/src/client/editor/Users.tsx
+++ b/src/client/editor/Users.tsx
@@ -11,6 +11,7 @@ import {
 	PlusIcon,
 	TrashIcon,
 	UploadIcon,
+    XIcon,
 } from "lucide-react";
 import { type FC, useState } from "react";
 import type { InferInput } from "valibot";
@@ -85,16 +86,28 @@ const UserForm: FC<UserFormProps> = ({ user, onSave, onDelete }) => {
 				<h2 className="font-semibold text-content-primary text-xl">
 					User Data
 				</h2>
-				<Button
-					size="icon"
-					variant="outline"
-					onClick={(e) => {
-						e.preventDefault();
-						onDelete();
-					}}
-				>
-					<TrashIcon />
-				</Button>
+				<div className="flex gap-1">
+					<Button
+						size="icon"
+						variant="subtle"
+						onClick={(e) => {
+							e.preventDefault();
+							onDelete();
+						}}
+					>
+						<TrashIcon />
+					</Button>
+					<Button
+						size="icon"
+						variant="outline"
+						onClick={(e) => {
+							e.preventDefault();
+							setIsEditing(false);
+						}}
+					>
+						<XIcon />
+					</Button>
+				</div>
 			</div>
 			<form
 				className="flex w-full flex-col gap-4"

From 9a80a7eccf7fb2b0aab86c401329714117526567 Mon Sep 17 00:00:00 2001
From: Brett Kolodny <brett.kolodny@coder.com>
Date: Mon, 7 Jul 2025 21:30:56 +0000
Subject: [PATCH 04/21] feat: upload users

---
 src/client/editor/Users.tsx | 35 ++++++++++++++++++++++++++++++++---
 1 file changed, 32 insertions(+), 3 deletions(-)

diff --git a/src/client/editor/Users.tsx b/src/client/editor/Users.tsx
index 1194e88..bcc139f 100644
--- a/src/client/editor/Users.tsx
+++ b/src/client/editor/Users.tsx
@@ -11,9 +11,9 @@ import {
 	PlusIcon,
 	TrashIcon,
 	UploadIcon,
-    XIcon,
+	XIcon,
 } from "lucide-react";
-import { type FC, useState } from "react";
+import { type FC, useRef, useState } from "react";
 import type { InferInput } from "valibot";
 import * as v from "valibot";
 import { Button } from "@/client/components/Button";
@@ -261,6 +261,28 @@ type UsersProps = {
 	setUsers: (owners: WorkspaceOwner[]) => void;
 };
 export const Users: FC<UsersProps> = ({ users, setUsers }) => {
+	const uploadInputRef = useRef<HTMLInputElement | null>(null);
+
+	const onUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
+		const file = e.target.files?.[0];
+		e.target.value = "";
+		e.target.files = null;
+		if (!file) {
+			return;
+		}
+
+		const parsedUsers = v.safeParse(
+			v.array(OwnerSchema),
+			JSON.parse(new TextDecoder().decode(await file.bytes())),
+		);
+
+		if (parsedUsers.success) {
+			setUsers(parsedUsers.output);
+		} else {
+			// TODO: Show an error
+		}
+	};
+
 	const defaultValues: UserForm = {
 		users,
 	};
@@ -276,6 +298,13 @@ export const Users: FC<UsersProps> = ({ users, setUsers }) => {
 
 	return (
 		<div className="flex w-full flex-col gap-4 p-6">
+			<input
+				ref={uploadInputRef}
+				onChange={onUpload}
+				className="hidden"
+				type="file"
+				multiple={false}
+			/>
 			<div className="flex items-center justify-between">
 				<h1 className="font-semibold text-3xl text-content-primary">Users</h1>
 				<DropdownMenu>
@@ -286,7 +315,7 @@ export const Users: FC<UsersProps> = ({ users, setUsers }) => {
 					</DropdownMenuTrigger>
 					<DropdownMenuPortal>
 						<DropdownMenuContent align="end">
-							<DropdownMenuItem>
+							<DropdownMenuItem onClick={() => uploadInputRef.current?.click()}>
 								<UploadIcon />
 								Upload
 							</DropdownMenuItem>

From 42971de5a16ea15dc98102b703f0535803c14dc7 Mon Sep 17 00:00:00 2001
From: Brett Kolodny <brett.kolodny@coder.com>
Date: Tue, 8 Jul 2025 19:50:50 +0000
Subject: [PATCH 05/21] chore: refactor code to use consistent language of user
 vs. owner

---
 src/client/App.tsx           | 36 +++++++++++++++------------
 src/client/Preview.tsx       | 48 ++++++++++++++++++------------------
 src/client/editor/Editor.tsx | 11 +++++----
 src/client/editor/Users.tsx  | 21 ++++++++--------
 src/{owner.ts => user.ts}    | 22 +++++++----------
 src/utils/wasm.ts            | 12 ++++++---
 6 files changed, 78 insertions(+), 72 deletions(-)
 rename src/{owner.ts => user.ts} (81%)

diff --git a/src/client/App.tsx b/src/client/App.tsx
index beec723..1585f52 100644
--- a/src/client/App.tsx
+++ b/src/client/App.tsx
@@ -31,12 +31,8 @@ import { Editor } from "@/client/editor/Editor";
 import { Preview } from "@/client/Preview";
 import { defaultCode } from "@/client/snippets";
 import { examples } from "@/examples";
-import type {
-	ParameterWithSource,
-	PreviewOutput,
-	WorkspaceOwner,
-} from "@/gen/types";
-import { baseMockUser, mockUsers } from "@/owner";
+import type { ParameterWithSource, PreviewOutput } from "@/gen/types";
+import { baseMockUser, mockUsers, type User } from "@/user";
 import { rpc } from "@/utils/rpc";
 import {
 	getDynamicParametersOutput,
@@ -69,10 +65,10 @@ export const App = () => {
 	const [output, setOutput] = useState<PreviewOutput | null>(null);
 	const [parameters, setParameters] = useState<ParameterWithSource[]>([]);
 
-	const [owner, setOwner] = useState<WorkspaceOwner>(
+	const [currentUser, setCurrentUser] = useState<User>(
 		mockUsers[0] ?? baseMockUser,
 	);
-	const [owners, setOwners] = useState<WorkspaceOwner[]>(mockUsers);
+	const [users, setUsers] = useState<User[]>(mockUsers);
 
 	const onDownloadOutput = () => {
 		downloadData(output, "output.json");
@@ -88,6 +84,14 @@ export const App = () => {
 		);
 	};
 
+	useEffect(() => {
+		const newCurrentUser = users.find((u) => u.id === currentUser.id);
+
+		if (newCurrentUser) {
+			setCurrentUser(() => newCurrentUser);
+		}
+	}, [users, currentUser.id]);
+
 	useEffect(() => {
 		if (!window.go_preview) {
 			initWasm().then((loadState) => {
@@ -139,7 +143,7 @@ export const App = () => {
 			return;
 		}
 
-		getDynamicParametersOutput(debouncedCode, parameterValues, owner)
+		getDynamicParametersOutput(debouncedCode, parameterValues, currentUser)
 			.catch((e) => {
 				console.error(e);
 				setWasmLoadingState("error");
@@ -149,7 +153,7 @@ export const App = () => {
 			.then((output) => {
 				setOutput(output);
 			});
-	}, [debouncedCode, parameterValues, wasmLoadState, owner]);
+	}, [debouncedCode, parameterValues, wasmLoadState, currentUser]);
 
 	return (
 		<main className="flex h-dvh w-screen flex-col items-center bg-surface-primary">
@@ -193,8 +197,8 @@ export const App = () => {
 				<Editor
 					code={code}
 					setCode={setCode}
-					owners={owners}
-					setOwners={setOwners}
+					users={users}
+					setUsers={setUsers}
 				/>
 
 				<ResizableHandle className="bg-surface-quaternary" />
@@ -209,11 +213,11 @@ export const App = () => {
 					setParameterValues={setParameterValues}
 					parameters={parameters}
 					onReset={onReset}
-					selectedOwner={owner}
-					owners={owners}
-					setOwner={(owner) => {
+					currentUser={currentUser}
+					users={users}
+					setUsers={(owner) => {
 						onReset();
-						setOwner(owner);
+						setCurrentUser(owner);
 					}}
 				/>
 			</ResizablePanelGroup>
diff --git a/src/client/Preview.tsx b/src/client/Preview.tsx
index 76e63ac..637e67b 100644
--- a/src/client/Preview.tsx
+++ b/src/client/Preview.tsx
@@ -43,8 +43,8 @@ import type {
 	ParameterWithSource,
 	ParserLog,
 	PreviewOutput,
-	WorkspaceOwner,
 } from "@/gen/types";
+import type { User } from "@/user";
 import { cn } from "@/utils/cn";
 import type { WasmLoadState } from "@/utils/wasm";
 
@@ -59,9 +59,9 @@ type PreviewProps = {
 	>;
 	parameters: ParameterWithSource[];
 	onReset: () => void;
-	selectedOwner: WorkspaceOwner;
-	owners: WorkspaceOwner[];
-	setOwner: (owner: WorkspaceOwner) => void;
+	currentUser: User;
+	users: User[];
+	setUsers: (user: User) => void;
 };
 
 export const Preview: FC<PreviewProps> = ({
@@ -72,9 +72,9 @@ export const Preview: FC<PreviewProps> = ({
 	setParameterValues,
 	parameters,
 	onReset,
-	selectedOwner,
-	owners,
-	setOwner,
+	currentUser,
+	users,
+	setUsers,
 }) => {
 	const [params] = useSearchParams();
 	const isDebug = params.has("debug");
@@ -191,9 +191,9 @@ export const Preview: FC<PreviewProps> = ({
 									</AnimatePresence>
 								</div>
 								<UserSelect
-									setOwner={setOwner}
-									owners={owners}
-									selectedOwner={selectedOwner}
+									setUsers={setUsers}
+									users={users}
+									currentUser={currentUser}
 								/>
 							</div>
 						}
@@ -562,26 +562,26 @@ const FormElement: FC<FormElementProps> = React.memo(
 FormElement.displayName = "FormElement";
 
 type UserSelectProps = {
-	selectedOwner: WorkspaceOwner;
-	owners: WorkspaceOwner[];
-	setOwner: (owner: WorkspaceOwner) => void;
+	currentUser: User;
+	users: User[];
+	setUsers: (user: User) => void;
 };
 const UserSelect: FC<UserSelectProps> = ({
-	selectedOwner,
-	owners,
-	setOwner,
+	currentUser,
+	users,
+	setUsers,
 }) => {
-	const selectedMissing = !owners.some(
-		(owner) => selectedOwner.id === owner.id,
+	const selectedMissing = !users.some(
+		(owner) => currentUser.id === owner.id,
 	);
 
 	return (
 		<Select
-			value={selectedOwner.id}
+			value={currentUser.id}
 			onValueChange={(value) => {
-				const owner = owners.find((owner) => owner.id === value);
+				const owner = users.find((owner) => owner.id === value);
 				if (owner) {
-					setOwner(owner);
+					setUsers(owner);
 				}
 			}}
 		>
@@ -590,11 +590,11 @@ const UserSelect: FC<UserSelectProps> = ({
 			</SelectTrigger>
 			<SelectContent>
 				{selectedMissing ? (
-					<SelectItem value={selectedOwner.id}>
-						{selectedOwner.full_name || selectedOwner.name}
+					<SelectItem value={currentUser.id}>
+						{currentUser.full_name || currentUser.name}
 					</SelectItem>
 				) : null}
-				{owners.map((owner, index) => (
+				{users.map((owner, index) => (
 					<SelectItem value={owner.id} key={index}>
 						{owner.full_name || owner.name}
 					</SelectItem>
diff --git a/src/client/editor/Editor.tsx b/src/client/editor/Editor.tsx
index 1d3f6fe..43c6c5f 100644
--- a/src/client/editor/Editor.tsx
+++ b/src/client/editor/Editor.tsx
@@ -26,21 +26,22 @@ import { useEditor } from "@/client/contexts/editor";
 import { useTheme } from "@/client/contexts/theme";
 import { Users } from "@/client/editor/Users";
 import { multiSelect, radio, switchInput, textInput } from "@/client/snippets";
-import type { ParameterFormType, WorkspaceOwner } from "@/gen/types";
+import type { ParameterFormType } from "@/gen/types";
+import type { User } from "@/user";
 import { cn } from "@/utils/cn";
 
 type EditorProps = {
 	code: string;
 	setCode: React.Dispatch<React.SetStateAction<string>>;
-	owners: WorkspaceOwner[];
-	setOwners: (owners: WorkspaceOwner[]) => void;
+	users: User[];
+	setUsers: (owners: User[]) => void;
 };
 
 export const Editor: FC<EditorProps> = ({
 	code,
 	setCode,
-	owners,
-	setOwners,
+	users: owners,
+	setUsers: setOwners,
 }) => {
 	const { appliedTheme } = useTheme();
 	const editorRef = useEditor();
diff --git a/src/client/editor/Users.tsx b/src/client/editor/Users.tsx
index bcc139f..9822538 100644
--- a/src/client/editor/Users.tsx
+++ b/src/client/editor/Users.tsx
@@ -27,35 +27,34 @@ import {
 import { Input } from "@/client/components/Input";
 import { Label } from "@/client/components/Label";
 import { TagInput } from "@/client/components/TagInput";
-import type { WorkspaceOwner } from "@/gen/types";
-import { emptyUser, type Owner, OwnerSchema } from "@/owner";
+import { emptyUser, type User, UserSchema } from "@/user";
 import { downloadData } from "../utils";
 
 const UserFormSchema = v.object({
-	users: v.array(OwnerSchema),
+	users: v.array(UserSchema),
 });
 type UserForm = v.InferInput<typeof UserFormSchema>;
 
 type UserFormProps = {
-	user: Owner;
-	onSave: (user: Owner) => void;
+	user: User;
+	onSave: (user: User) => void;
 	onDelete: () => void;
 };
 const UserForm: FC<UserFormProps> = ({ user, onSave, onDelete }) => {
 	const [isEditing, setIsEditing] = useState(user.name === "");
 
-	const defaultValues: InferInput<typeof OwnerSchema> = user;
+	const defaultValues: InferInput<typeof UserSchema> = user;
 	const form = useForm({
 		defaultValues,
 		validators: {
-			onChange: OwnerSchema,
+			onChange: UserSchema,
 		},
 		onSubmitInvalid: () => {
 			// TODO
 		},
 		onSubmit: ({ value }) => {
 			setIsEditing(false);
-			const owner = v.parse(OwnerSchema, value);
+			const owner = v.parse(UserSchema, value);
 			onSave(owner);
 		},
 	});
@@ -257,8 +256,8 @@ const UserForm: FC<UserFormProps> = ({ user, onSave, onDelete }) => {
 };
 
 type UsersProps = {
-	users: WorkspaceOwner[];
-	setUsers: (owners: WorkspaceOwner[]) => void;
+	users: User[];
+	setUsers: (owners: User[]) => void;
 };
 export const Users: FC<UsersProps> = ({ users, setUsers }) => {
 	const uploadInputRef = useRef<HTMLInputElement | null>(null);
@@ -272,7 +271,7 @@ export const Users: FC<UsersProps> = ({ users, setUsers }) => {
 		}
 
 		const parsedUsers = v.safeParse(
-			v.array(OwnerSchema),
+			v.array(UserSchema),
 			JSON.parse(new TextDecoder().decode(await file.bytes())),
 		);
 
diff --git a/src/owner.ts b/src/user.ts
similarity index 81%
rename from src/owner.ts
rename to src/user.ts
index cf0d0d4..1338c6a 100644
--- a/src/owner.ts
+++ b/src/user.ts
@@ -1,7 +1,7 @@
 import * as v from "valibot";
 import type { WorkspaceOwner } from "@/gen/types";
 
-export const OwnerSchema = v.object({
+export const UserSchema = v.object({
 	id: v.string(),
 	name: v.string(),
 	full_name: v.string(),
@@ -13,31 +13,27 @@ export const OwnerSchema = v.object({
 			org_id: v.string(),
 		}),
 	),
-	ssh_public_key: v.string(),
-	login_type: v.string(),
-}) satisfies v.GenericSchema<WorkspaceOwner>;
+}) satisfies v.GenericSchema<
+	Omit<WorkspaceOwner, "ssh_public_key" | "login_type">
+>;
 
-export type Owner = v.InferOutput<typeof OwnerSchema>;
+export type User = v.InferOutput<typeof UserSchema>;
 
-export const emptyUser: Owner = {
+export const emptyUser: User = {
 	id: "54e265e8-43b2-46a7-9d1c-b612d63f57b7",
 	name: "",
 	full_name: "",
 	email: "",
-	ssh_public_key: "",
 	groups: [],
-	login_type: "",
 	rbac_roles: [],
 };
 
-export const baseMockUser: Owner = {
+export const baseMockUser: User = {
 	id: "8d36e355-e775-4c49-9b8d-ac042ed50440",
 	name: "coder",
 	full_name: "Coder",
 	email: "coder@coder.com",
-	ssh_public_key: "",
 	groups: ["Everyone"],
-	login_type: "password",
 	rbac_roles: [
 		{ name: "member", org_id: "" },
 		{
@@ -45,9 +41,9 @@ export const baseMockUser: Owner = {
 			org_id: "09942665-ba1b-4661-be9f-36bf9f738c83",
 		},
 	],
-} satisfies Owner;
+} satisfies User;
 
-export const mockUsers: Owner[] = [
+export const mockUsers: User[] = [
 	{
 		...baseMockUser,
 		id: "f7090396-a12b-4477-b56a-eeee60d7fffa",
diff --git a/src/utils/wasm.ts b/src/utils/wasm.ts
index 8166218..65becf1 100644
--- a/src/utils/wasm.ts
+++ b/src/utils/wasm.ts
@@ -1,6 +1,6 @@
 import { checkerModule } from "@/client/snippets";
 import type { PreviewOutput, WorkspaceOwner } from "@/gen/types";
-import { baseMockUser } from "@/owner";
+import { baseMockUser, type User } from "@/user";
 
 export type WasmLoadState = "loaded" | "loading" | "error";
 
@@ -58,12 +58,18 @@ export const initWasm = async (): Promise<WasmLoadState> => {
 export const getDynamicParametersOutput = async (
 	code: string,
 	parameterValues: Record<string, string>,
-	owner?: WorkspaceOwner,
+	user?: User,
 ): Promise<PreviewOutput | null> => {
 	if (!window.go_preview) {
 		return null;
 	}
 
+	const owner: WorkspaceOwner = {
+		...(user ?? baseMockUser),
+		ssh_public_key: "",
+		login_type: "",
+	};
+
 	const rawOutput = await window.go_preview(
 		{
 			"main.tf": code,
@@ -71,7 +77,7 @@ export const getDynamicParametersOutput = async (
 			"checker/main.tf": checkerModule,
 		},
 		parameterValues,
-		owner ?? baseMockUser,
+		owner,
 	);
 
 	if (rawOutput === undefined) {

From 897d21432012bdbc7e11d00fa5c32d9e08114679 Mon Sep 17 00:00:00 2001
From: Brett Kolodny <brett.kolodny@coder.com>
Date: Tue, 8 Jul 2025 20:13:24 +0000
Subject: [PATCH 06/21] feat: include user data in share

---
 src/client/App.tsx       | 11 ++++++-----
 src/server/blob.ts       | 12 +++++++++---
 src/server/index.tsx     | 33 ++++++++++++++++++++-------------
 src/server/routes/api.ts | 14 ++++----------
 src/utils/wasm.ts        |  1 +
 5 files changed, 40 insertions(+), 31 deletions(-)

diff --git a/src/client/App.tsx b/src/client/App.tsx
index 1585f52..1b9a3f5 100644
--- a/src/client/App.tsx
+++ b/src/client/App.tsx
@@ -65,10 +65,10 @@ export const App = () => {
 	const [output, setOutput] = useState<PreviewOutput | null>(null);
 	const [parameters, setParameters] = useState<ParameterWithSource[]>([]);
 
+	const [users, setUsers] = useState<User[]>(window.USERS ?? mockUsers);
 	const [currentUser, setCurrentUser] = useState<User>(
-		mockUsers[0] ?? baseMockUser,
+		users[0] ?? baseMockUser,
 	);
-	const [users, setUsers] = useState<User[]>(mockUsers);
 
 	const onDownloadOutput = () => {
 		downloadData(output, "output.json");
@@ -167,7 +167,7 @@ export const App = () => {
 						</p>
 					</div>
 
-					<ShareButton code={code} />
+					<ShareButton code={code} users={users} />
 				</div>
 
 				<div className="flex items-center gap-3">
@@ -266,15 +266,16 @@ const ThemeSelector: FC = () => {
 
 type ShareButtonProps = {
 	code: string;
+	users: User[];
 };
-const ShareButton: FC<ShareButtonProps> = ({ code }) => {
+const ShareButton: FC<ShareButtonProps> = ({ code, users }) => {
 	const [isCopied, setIsCopied] = useState(() => false);
 	const timeoutId = useRef<ReturnType<typeof setTimeout>>(undefined);
 
 	const onShare = async () => {
 		try {
 			const { id } = await rpc.parameters
-				.$post({ json: { code } })
+				.$post({ json: { code, users } })
 				.then((res) => res.json());
 
 			const { protocol, host } = window.location;
diff --git a/src/server/blob.ts b/src/server/blob.ts
index 6dcb893..dad1dfc 100644
--- a/src/server/blob.ts
+++ b/src/server/blob.ts
@@ -1,11 +1,15 @@
 import { head, put } from "@vercel/blob";
 import { nanoid } from "nanoid";
 import * as v from "valibot";
+import { UserSchema } from "@/user";
 
 export const BLOG_PATH = "parameters/share";
 
-export const ShareDataSchema = v.object({ code: v.string() });
-type ShareData = v.InferInput<typeof ShareDataSchema>;
+export const ShareDataSchema = v.object({
+	code: v.string(),
+	users: v.optional(v.array(UserSchema)),
+});
+export type ShareData = v.InferOutput<typeof ShareDataSchema>;
 
 export const putShareData = async (data: ShareData): Promise<string> => {
 	const id = nanoid(10);
@@ -17,7 +21,9 @@ export const putShareData = async (data: ShareData): Promise<string> => {
 	return id;
 };
 
-export const getShareData = async (id: string): Promise<{ code: string; } | null> => {
+export const getShareData = async (
+	id: string,
+): Promise<ShareData | null> => {
 	try {
 		const { url } = await head(`${BLOG_PATH}/${id}.json`);
 		const res = await fetch(url);
diff --git a/src/server/index.tsx b/src/server/index.tsx
index 6c15470..f2ee6fb 100644
--- a/src/server/index.tsx
+++ b/src/server/index.tsx
@@ -1,11 +1,11 @@
-import { examples } from "@/examples/code";
-import { api } from "@/server/routes/api";
 import { Hono } from "hono";
-import { renderToString } from "react-dom/server";
-import { getShareData } from "./blob";
 import { trimTrailingSlash } from "hono/trailing-slash";
-import { BaseHeader, defaultCode, getAssetPath, HmrScript } from "./utils";
+import { renderToString } from "react-dom/server";
+import { examples } from "@/examples/code";
+import { api } from "@/server/routes/api";
+import { getShareData, type ShareData } from "./blob";
 import { notFound } from "./routes/404";
+import { BaseHeader, defaultCode, getAssetPath, HmrScript } from "./utils";
 
 // This must be exported for the dev server to work
 export const app = new Hono();
@@ -26,23 +26,24 @@ app.get("/", (c) => c.redirect("/parameters"));
 
 // Serves the main web application. This must come after the API route.
 app.get("/parameters/:shareId?/:example?", async (c, next) => {
-	const getExampleCode = async (): Promise<string | null> => {
+	const getCodeAndUsers = async (): Promise<ShareData | null> => {
 		const { shareId, example } = c.req.param();
 
 		if (shareId && shareId !== "example") {
 			const shareData = await getShareData(shareId);
-			return shareData?.code ?? null;
+			return shareData;
 		}
 
 		if (example) {
-			return examples[example] ?? null;
+			const code = examples[example];
+			return code ? { code } : null;
 		}
 
-		return defaultCode;
+		return { code: defaultCode };
 	};
 
-	const exampleCode = await getExampleCode();
-	if (!exampleCode) {
+	const codeAndUsers = await getCodeAndUsers();
+	if (!codeAndUsers) {
 		return notFound(c, next);
 	}
 
@@ -59,8 +60,14 @@ app.get("/parameters/:shareId?/:example?", async (c, next) => {
 					</head>
 					<body>
 						<div id="root"></div>
-							<script type="module">{`window.CODE = ${JSON.stringify(exampleCode)}`}</script>
-						<script type="module" src={getAssetPath("/src/client/index.tsx", "client.js")}></script>
+						<script type="module">{`window.CODE = ${JSON.stringify(codeAndUsers.code)}`}</script>
+						{codeAndUsers.users ? (
+							<script type="module">{`window.USERS = ${JSON.stringify(codeAndUsers.users)}`}</script>
+						) : null}
+						<script
+							type="module"
+							src={getAssetPath("/src/client/index.tsx", "client.js")}
+						></script>
 					</body>
 				</html>,
 			),
diff --git a/src/server/routes/api.ts b/src/server/routes/api.ts
index 5a89005..cb1dcd9 100644
--- a/src/server/routes/api.ts
+++ b/src/server/routes/api.ts
@@ -1,25 +1,19 @@
 import { vValidator } from "@hono/valibot-validator";
 import { Hono } from "hono";
-import * as v from "valibot";
-import { putShareData } from "@/server/blob";
+import { putShareData, ShareDataSchema } from "@/server/blob";
 
 const MAX_CODE_SIZE = 1024 * 1000; // 1mb
 
 const parameters = new Hono().post(
 	"/",
-	vValidator(
-		"json",
-		v.object({
-			code: v.string(),
-		}),
-	),
+	vValidator("json", ShareDataSchema),
 	async (c) => {
 		const data = c.req.valid("json");
 		const bytes = new TextEncoder().encode(JSON.stringify(data));
 
 		if (bytes.length > MAX_CODE_SIZE) {
-			console.error("Data larger than 10kb");
-			return c.json({ id: "" }, 500);
+			console.error("Data larger than 1mb");
+			return c.json({ id: "" }, 400);
 		}
 
 		const id = await putShareData(data);
diff --git a/src/utils/wasm.ts b/src/utils/wasm.ts
index 65becf1..980f390 100644
--- a/src/utils/wasm.ts
+++ b/src/utils/wasm.ts
@@ -21,6 +21,7 @@ declare global {
 		go_preview?: GoPreviewDef;
 		Go: { new (): Go };
 		CODE?: string;
+		USERS?: User[];
 	}
 }
 

From dd49328a41c87f12405dfc23f52d82768cecc24c Mon Sep 17 00:00:00 2001
From: Brett Kolodny <brett.kolodny@coder.com>
Date: Tue, 8 Jul 2025 20:35:53 +0000
Subject: [PATCH 07/21] fix: use theme responsive color for form elements

---
 src/client/editor/Users.tsx | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/client/editor/Users.tsx b/src/client/editor/Users.tsx
index 9822538..b5852a4 100644
--- a/src/client/editor/Users.tsx
+++ b/src/client/editor/Users.tsx
@@ -41,7 +41,7 @@ type UserFormProps = {
 	onDelete: () => void;
 };
 const UserForm: FC<UserFormProps> = ({ user, onSave, onDelete }) => {
-	const [isEditing, setIsEditing] = useState(user.name === "");
+	const [isEditing, setIsEditing] = useState(true || user.name === "");
 
 	const defaultValues: InferInput<typeof UserSchema> = user;
 	const form = useForm({
@@ -80,7 +80,7 @@ const UserForm: FC<UserFormProps> = ({ user, onSave, onDelete }) => {
 	}
 
 	return (
-		<div className="flex w-full flex-col gap-5 rounded-lg border p-5">
+		<div className="flex w-full flex-col gap-5 rounded-lg border p-5 text-content-primary">
 			<div className="flex w-full items-center justify-between">
 				<h2 className="font-semibold text-content-primary text-xl">
 					User Data
@@ -116,7 +116,7 @@ const UserForm: FC<UserFormProps> = ({ user, onSave, onDelete }) => {
 					form.handleSubmit();
 				}}
 			>
-				<div className="flex gap-3">
+				<div className="flex gap-3 text-content-primary">
 					<form.Field name="name">
 						{(field) => {
 							return (

From 6b22290783c4eb1632ba856d57a80e529c9bd5fd Mon Sep 17 00:00:00 2001
From: Brett Kolodny <brett.kolodny@coder.com>
Date: Tue, 8 Jul 2025 20:36:29 +0000
Subject: [PATCH 08/21] fix: don't default user form to editing

---
 src/client/editor/Users.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/client/editor/Users.tsx b/src/client/editor/Users.tsx
index b5852a4..285422c 100644
--- a/src/client/editor/Users.tsx
+++ b/src/client/editor/Users.tsx
@@ -41,7 +41,7 @@ type UserFormProps = {
 	onDelete: () => void;
 };
 const UserForm: FC<UserFormProps> = ({ user, onSave, onDelete }) => {
-	const [isEditing, setIsEditing] = useState(true || user.name === "");
+	const [isEditing, setIsEditing] = useState(user.name === "");
 
 	const defaultValues: InferInput<typeof UserSchema> = user;
 	const form = useForm({

From ba5e9a42819eaedad92fec31ae62682652653c9c Mon Sep 17 00:00:00 2001
From: Brett Kolodny <brett.kolodny@coder.com>
Date: Wed, 9 Jul 2025 15:17:19 +0000
Subject: [PATCH 09/21] fix: remove unused code

---
 src/client/editor/Users.tsx | 5 +----
 1 file changed, 1 insertion(+), 4 deletions(-)

diff --git a/src/client/editor/Users.tsx b/src/client/editor/Users.tsx
index 285422c..6daba5e 100644
--- a/src/client/editor/Users.tsx
+++ b/src/client/editor/Users.tsx
@@ -49,9 +49,6 @@ const UserForm: FC<UserFormProps> = ({ user, onSave, onDelete }) => {
 		validators: {
 			onChange: UserSchema,
 		},
-		onSubmitInvalid: () => {
-			// TODO
-		},
 		onSubmit: ({ value }) => {
 			setIsEditing(false);
 			const owner = v.parse(UserSchema, value);
@@ -278,7 +275,7 @@ export const Users: FC<UsersProps> = ({ users, setUsers }) => {
 		if (parsedUsers.success) {
 			setUsers(parsedUsers.output);
 		} else {
-			// TODO: Show an error
+			console.error(parsedUsers.issues);
 		}
 	};
 

From e789bc20c239915fc50c045f9e41480fa4673b5b Mon Sep 17 00:00:00 2001
From: Brett Kolodny <brett.kolodny@coder.com>
Date: Wed, 9 Jul 2025 16:32:13 +0000
Subject: [PATCH 10/21] feat: show error dialog if users upload fails to parse

---
 src/client/editor/Users.tsx | 366 +++++++++++++++++++++---------------
 1 file changed, 219 insertions(+), 147 deletions(-)

diff --git a/src/client/editor/Users.tsx b/src/client/editor/Users.tsx
index 6daba5e..f275896 100644
--- a/src/client/editor/Users.tsx
+++ b/src/client/editor/Users.tsx
@@ -1,8 +1,4 @@
-/** biome-ignore-all lint/correctness/noChildrenProp: below
- * Tanstack Form uses the children prop which lets us keep the component flat
- * rather than having to define separate wrappers using hooks.
- */
-
+import { DialogDescription, DialogTitle } from "@radix-ui/react-dialog";
 import { useForm } from "@tanstack/react-form";
 import {
 	DownloadIcon,
@@ -17,6 +13,12 @@ import { type FC, useRef, useState } from "react";
 import type { InferInput } from "valibot";
 import * as v from "valibot";
 import { Button } from "@/client/components/Button";
+import {
+	Dialog,
+	DialogContent,
+	DialogFooter,
+	DialogHeader,
+} from "@/client/components/Dialog";
 import {
 	DropdownMenu,
 	DropdownMenuContent,
@@ -35,6 +37,152 @@ const UserFormSchema = v.object({
 });
 type UserForm = v.InferInput<typeof UserFormSchema>;
 
+type LoadUserIssues =
+	| {
+			kind: "json-parse";
+			issue: string;
+	  }
+	| {
+			kind: "valibot-parse";
+			issue: v.InferIssue<typeof UserSchema>[];
+	  };
+
+type UsersProps = {
+	users: User[];
+	setUsers: (owners: User[]) => void;
+};
+export const Users: FC<UsersProps> = ({ users, setUsers }) => {
+	const uploadInputRef = useRef<HTMLInputElement | null>(null);
+	const [loadUserIssues, setLoadUserIssues] = useState<LoadUserIssues | null>(
+		null,
+	);
+
+	const onUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
+		const file = e.target.files?.[0];
+		e.target.value = "";
+		e.target.files = null;
+		if (!file) {
+			return;
+		}
+
+		let data: unknown;
+		try {
+			data = JSON.parse(new TextDecoder().decode(await file.bytes()));
+		} catch (e) {
+			const errorMessage =
+				e instanceof Error
+					? e.message
+					: "Selected file does not contain valid JSON";
+
+			setLoadUserIssues({
+				kind: "json-parse",
+				issue: errorMessage,
+			});
+
+			return;
+		}
+
+		const parsedUsers = v.safeParse(v.array(UserSchema), data);
+
+		if (parsedUsers.success) {
+			setUsers(parsedUsers.output);
+		} else {
+			setLoadUserIssues({ kind: "valibot-parse", issue: parsedUsers.issues });
+			console.error(parsedUsers.issues);
+		}
+	};
+
+	const defaultValues: UserForm = {
+		users,
+	};
+	const form = useForm({
+		defaultValues,
+		validators: {
+			onChange: UserFormSchema,
+		},
+		onSubmit: ({ value }) => {
+			setUsers(value.users);
+		},
+	});
+
+	return (
+		<>
+			<LoadUserIssueModal
+				issues={loadUserIssues}
+				close={() => setLoadUserIssues(null)}
+			/>
+			<div className="flex w-full flex-col gap-4 p-6">
+				<input
+					ref={uploadInputRef}
+					onChange={onUpload}
+					className="hidden"
+					type="file"
+					multiple={false}
+				/>
+				<div className="flex items-center justify-between">
+					<h1 className="font-semibold text-3xl text-content-primary">Users</h1>
+					<DropdownMenu>
+						<DropdownMenuTrigger asChild={true}>
+							<Button variant="outline" size="icon-lg">
+								<Ellipsis />
+							</Button>
+						</DropdownMenuTrigger>
+						<DropdownMenuPortal>
+							<DropdownMenuContent align="end">
+								<DropdownMenuItem
+									onClick={() => uploadInputRef.current?.click()}
+								>
+									<UploadIcon />
+									Upload
+								</DropdownMenuItem>
+								<DropdownMenuItem
+									disabled={form.state.errors.length > 0}
+									onClick={() => {
+										downloadData(form.state.values.users, "users.json");
+									}}
+								>
+									<DownloadIcon />
+									Download
+								</DropdownMenuItem>
+							</DropdownMenuContent>
+						</DropdownMenuPortal>
+					</DropdownMenu>
+				</div>
+				<form.Field name="users" mode="array">
+					{(field) => {
+						return (
+							<div className=" flex flex-col gap-3">
+								{field.state.value.map((_, index) => (
+									<form.Field key={index} name={`users[${index}]`}>
+										{(subField) => (
+											<UserForm
+												user={subField.state.value}
+												key={window.crypto.randomUUID()}
+												onSave={(owner) => {
+													subField.handleChange(owner);
+													form.handleSubmit();
+												}}
+												onDelete={() => {
+													field.removeValue(index);
+													form.handleSubmit();
+												}}
+											/>
+										)}
+									</form.Field>
+								))}
+							</div>
+						);
+					}}
+				</form.Field>
+				<Button onClick={() => form.pushFieldValue("users", emptyUser)}>
+					<PlusIcon />
+					Add a user
+				</Button>
+			</div>
+		</>
+	);
+};
+
 type UserFormProps = {
 	user: User;
 	onSave: (user: User) => void;
@@ -135,58 +283,49 @@ const UserForm: FC<UserFormProps> = ({ user, onSave, onDelete }) => {
 							);
 						}}
 					</form.Field>
-					<form.Field
-						name="full_name"
-						children={(field) => {
-							return (
-								<div className="flex w-full flex-col gap-2">
-									<Label>Full name</Label>
-									<Input
-										id={field.name}
-										name={field.name}
-										value={field.state.value}
-										onBlur={field.handleBlur}
-										onChange={(e) => field.handleChange(e.target.value)}
-										placeholder="Alice Coder"
-									/>
-								</div>
-							);
-						}}
-					/>
-				</div>
-				<form.Field
-					name="email"
-					children={(field) => {
-						return (
+					<form.Field name="full_name">
+						{(field) => (
 							<div className="flex w-full flex-col gap-2">
-								<Label>Email</Label>
+								<Label>Full name</Label>
 								<Input
 									id={field.name}
 									name={field.name}
 									value={field.state.value}
 									onBlur={field.handleBlur}
 									onChange={(e) => field.handleChange(e.target.value)}
-									placeholder="alice@coder.com"
+									placeholder="Alice Coder"
 								/>
 							</div>
-						);
-					}}
-				/>
-				<form.Field
-					name="groups"
-					children={(field) => {
-						return (
-							<div className="flex w-full flex-col gap-2">
-								<Label>Groups</Label>
-								<TagInput
-									label="groups"
-									values={field.state.value}
-									onChange={(v) => field.handleChange(v)}
-								/>
-							</div>
-						);
-					}}
-				/>
+						)}
+					</form.Field>
+				</div>
+				<form.Field name="email">
+					{(field) => (
+						<div className="flex w-full flex-col gap-2">
+							<Label>Email</Label>
+							<Input
+								id={field.name}
+								name={field.name}
+								value={field.state.value}
+								onBlur={field.handleBlur}
+								onChange={(e) => field.handleChange(e.target.value)}
+								placeholder="alice@coder.com"
+							/>
+						</div>
+					)}
+				</form.Field>
+				<form.Field name="groups">
+					{(field) => (
+						<div className="flex w-full flex-col gap-2">
+							<Label>Groups</Label>
+							<TagInput
+								label="groups"
+								values={field.state.value}
+								onChange={(v) => field.handleChange(v)}
+							/>
+						</div>
+					)}
+				</form.Field>
 				<div className="flex flex-col gap-3">
 					<p>RBAC Roles</p>
 					<form.Field name="rbac_roles">
@@ -252,112 +391,45 @@ const UserForm: FC<UserFormProps> = ({ user, onSave, onDelete }) => {
 	);
 };
 
-type UsersProps = {
-	users: User[];
-	setUsers: (owners: User[]) => void;
+type LoadUserIssueModalProps = {
+	issues: LoadUserIssues | null;
+	close: () => void;
 };
-export const Users: FC<UsersProps> = ({ users, setUsers }) => {
-	const uploadInputRef = useRef<HTMLInputElement | null>(null);
-
-	const onUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
-		const file = e.target.files?.[0];
-		e.target.value = "";
-		e.target.files = null;
-		if (!file) {
-			return;
+const LoadUserIssueModal: FC<LoadUserIssueModalProps> = ({ issues, close }) => {
+	const getErrorMessages = () => {
+		if (!issues) {
+			return [];
 		}
 
-		const parsedUsers = v.safeParse(
-			v.array(UserSchema),
-			JSON.parse(new TextDecoder().decode(await file.bytes())),
-		);
-
-		if (parsedUsers.success) {
-			setUsers(parsedUsers.output);
-		} else {
-			console.error(parsedUsers.issues);
+		if (issues.kind === "json-parse") {
+			return [issues.issue];
 		}
-	};
 
-	const defaultValues: UserForm = {
-		users,
+		return issues.issue.map((v) => v.message);
 	};
-	const form = useForm({
-		defaultValues,
-		validators: {
-			onChange: UserFormSchema,
-		},
-		onSubmit: ({ value }) => {
-			setUsers(value.users);
-		},
-	});
 
 	return (
-		<div className="flex w-full flex-col gap-4 p-6">
-			<input
-				ref={uploadInputRef}
-				onChange={onUpload}
-				className="hidden"
-				type="file"
-				multiple={false}
-			/>
-			<div className="flex items-center justify-between">
-				<h1 className="font-semibold text-3xl text-content-primary">Users</h1>
-				<DropdownMenu>
-					<DropdownMenuTrigger asChild={true}>
-						<Button variant="outline" size="icon-lg">
-							<Ellipsis />
-						</Button>
-					</DropdownMenuTrigger>
-					<DropdownMenuPortal>
-						<DropdownMenuContent align="end">
-							<DropdownMenuItem onClick={() => uploadInputRef.current?.click()}>
-								<UploadIcon />
-								Upload
-							</DropdownMenuItem>
-							<DropdownMenuItem
-								disabled={form.state.errors.length > 0}
-								onClick={() => {
-									downloadData(form.state.values.users, "users.json");
-								}}
-							>
-								<DownloadIcon />
-								Download
-							</DropdownMenuItem>
-						</DropdownMenuContent>
-					</DropdownMenuPortal>
-				</DropdownMenu>
-			</div>
-			<form.Field name="users" mode="array">
-				{(field) => {
-					return (
-						<div className=" flex flex-col gap-3">
-							{field.state.value.map((_, index) => (
-								<form.Field key={index} name={`users[${index}]`}>
-									{(subField) => (
-										<UserForm
-											user={subField.state.value}
-											key={window.crypto.randomUUID()}
-											onSave={(owner) => {
-												subField.handleChange(owner);
-												form.handleSubmit();
-											}}
-											onDelete={() => {
-												field.removeValue(index);
-												form.handleSubmit();
-											}}
-										/>
-									)}
-								</form.Field>
-							))}
-						</div>
-					);
-				}}
-			</form.Field>
-			<Button onClick={() => form.pushFieldValue("users", emptyUser)}>
-				<PlusIcon />
-				Add a user
-			</Button>
-		</div>
+		<Dialog open={issues !== null}>
+			<DialogContent>
+				<DialogHeader>
+					<DialogTitle className="text-content-primary text-xl">
+						Could not load users from file
+					</DialogTitle>
+					<DialogDescription className="text-content-secondary">
+						Please check your file and try again.
+					</DialogDescription>
+				</DialogHeader>
+
+				<div className="flex max-h-64 flex-col gap-4 overflow-y-scroll whitespace-pre-wrap rounded-md bg-surface-secondary p-4 font-mono text-content-destructive">
+					{getErrorMessages().map((message, index) => (
+						<p key={index}>{message}</p>
+					))}
+				</div>
+
+				<DialogFooter>
+					<Button onClick={close}>Close</Button>
+				</DialogFooter>
+			</DialogContent>
+		</Dialog>
 	);
 };

From 874d5c4b1186d5e41236e3cd210fe3c15bb75096 Mon Sep 17 00:00:00 2001
From: Brett Kolodny <brett.kolodny@coder.com>
Date: Wed, 9 Jul 2025 16:33:18 +0000
Subject: [PATCH 11/21] chore: remove console.error

---
 src/client/editor/Users.tsx | 1 -
 1 file changed, 1 deletion(-)

diff --git a/src/client/editor/Users.tsx b/src/client/editor/Users.tsx
index f275896..ff82403 100644
--- a/src/client/editor/Users.tsx
+++ b/src/client/editor/Users.tsx
@@ -88,7 +88,6 @@ export const Users: FC<UsersProps> = ({ users, setUsers }) => {
 			setUsers(parsedUsers.output);
 		} else {
 			setLoadUserIssues({ kind: "valibot-parse", issue: parsedUsers.issues });
-			console.error(parsedUsers.issues);
 		}
 	};
 

From da6da72af99f404426640896e8cf472877139c88 Mon Sep 17 00:00:00 2001
From: Brett Kolodny <brett.kolodny@coder.com>
Date: Thu, 10 Jul 2025 15:49:35 +0000
Subject: [PATCH 12/21] fix: add prop

---
 src/client/App.tsx | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/client/App.tsx b/src/client/App.tsx
index ecd1fa9..ef4c923 100644
--- a/src/client/App.tsx
+++ b/src/client/App.tsx
@@ -200,6 +200,7 @@ export const App = () => {
 					setCode={setCode}
 					users={users}
 					setUsers={setUsers}
+					parameters={parameters}
 				/>
 
 				<ResizableHandle className="bg-surface-quaternary" />

From 1269bd9515bd692a8d2cf679811e41d28093e410 Mon Sep 17 00:00:00 2001
From: Brett Kolodny <brett.kolodny@coder.com>
Date: Thu, 10 Jul 2025 16:01:05 +0000
Subject: [PATCH 13/21] fix: add overflow

---
 src/client/editor/Users.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/client/editor/Users.tsx b/src/client/editor/Users.tsx
index ff82403..2cff501 100644
--- a/src/client/editor/Users.tsx
+++ b/src/client/editor/Users.tsx
@@ -110,7 +110,7 @@ export const Users: FC<UsersProps> = ({ users, setUsers }) => {
 				issues={loadUserIssues}
 				close={() => setLoadUserIssues(null)}
 			/>
-			<div className="flex w-full flex-col gap-4 p-6">
+			<div className="flex h-full w-full flex-col gap-4 overflow-y-scroll p-6">
 				<input
 					ref={uploadInputRef}
 					onChange={onUpload}

From 9f0cdfad57bf507588ff6415f484614288d57f55 Mon Sep 17 00:00:00 2001
From: Brett Kolodny <brett.kolodny@coder.com>
Date: Thu, 10 Jul 2025 16:14:57 +0000
Subject: [PATCH 14/21] fix: remove forgotten HEAD

---
 src/client/snippets.ts | 2 --
 1 file changed, 2 deletions(-)

diff --git a/src/client/snippets.ts b/src/client/snippets.ts
index 5ff3080..f32c7e9 100644
--- a/src/client/snippets.ts
+++ b/src/client/snippets.ts
@@ -205,8 +205,6 @@ export const switchInput: SnippetFunc = (
   type         = "bool"
   form_type    = "switch"
   default      = true
-<<<<<<< HEAD
-  order        = 1
 }`;
 
 export const slider: SnippetFunc = (

From bdb06e722aed1efa0107afa4d4ab5e686f0ddd5d Mon Sep 17 00:00:00 2001
From: Brett Kolodny <brett.kolodny@coder.com>
Date: Thu, 10 Jul 2025 17:36:16 +0000
Subject: [PATCH 15/21] fix: snippet name

---
 src/client/snippets.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/client/snippets.ts b/src/client/snippets.ts
index f32c7e9..6953e6f 100644
--- a/src/client/snippets.ts
+++ b/src/client/snippets.ts
@@ -245,7 +245,7 @@ export const snippets: Snippet[] = [
 		snippet: radio,
 	},
 	{
-		name: "switch",
+		name: "multi-select",
 		label: "Multi-select",
 		icon: SquareMousePointerIcon,
 		snippet: multiSelect,

From 24470f3a6fad7556b7e9ef606cbe121da24c26b1 Mon Sep 17 00:00:00 2001
From: Brett Kolodny <brett.kolodny@coder.com>
Date: Thu, 17 Jul 2025 13:51:51 +0000
Subject: [PATCH 16/21] chore: remove empty file

---
 src/client/components/Form.tsx | 1 -
 1 file changed, 1 deletion(-)
 delete mode 100644 src/client/components/Form.tsx

diff --git a/src/client/components/Form.tsx b/src/client/components/Form.tsx
deleted file mode 100644
index 8b13789..0000000
--- a/src/client/components/Form.tsx
+++ /dev/null
@@ -1 +0,0 @@
-

From 60c6a931b7f0f3273e00de971642b669ee6af1a7 Mon Sep 17 00:00:00 2001
From: Brett Kolodny <brett.kolodny@coder.com>
Date: Thu, 17 Jul 2025 13:55:34 +0000
Subject: [PATCH 17/21] chore: add biome as a dev dependency

---
 package.json   |  1 +
 pnpm-lock.yaml | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 92 insertions(+)

diff --git a/package.json b/package.json
index b5bc645..57cfed0 100644
--- a/package.json
+++ b/package.json
@@ -57,6 +57,7 @@
     "zustand": "^5.0.5"
   },
   "devDependencies": {
+    "@biomejs/biome": "2.1.1",
     "@eslint/js": "^9.25.0",
     "@hono/vite-dev-server": "^0.19.1",
     "@types/lodash": "^4.17.17",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4555325..d1b5c03 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -144,6 +144,9 @@ importers:
         specifier: ^5.0.5
         version: 5.0.5(@types/react@19.1.4)(react@19.1.0)(use-sync-external-store@1.5.0(react@19.1.0))
     devDependencies:
+      '@biomejs/biome':
+        specifier: 2.1.1
+        version: 2.1.1
       '@eslint/js':
         specifier: ^9.25.0
         version: 9.27.0
@@ -304,6 +307,59 @@ packages:
     resolution: {integrity: sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==}
     engines: {node: '>=6.9.0'}
 
+  '@biomejs/biome@2.1.1':
+    resolution: {integrity: sha512-HFGYkxG714KzG+8tvtXCJ1t1qXQMzgWzfvQaUjxN6UeKv+KvMEuliInnbZLJm6DXFXwqVi6446EGI0sGBLIYng==}
+    engines: {node: '>=14.21.3'}
+    hasBin: true
+
+  '@biomejs/cli-darwin-arm64@2.1.1':
+    resolution: {integrity: sha512-2Muinu5ok4tWxq4nu5l19el48cwCY/vzvI7Vjbkf3CYIQkjxZLyj0Ad37Jv2OtlXYaLvv+Sfu1hFeXt/JwRRXQ==}
+    engines: {node: '>=14.21.3'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@biomejs/cli-darwin-x64@2.1.1':
+    resolution: {integrity: sha512-cC8HM5lrgKQXLAK+6Iz2FrYW5A62pAAX6KAnRlEyLb+Q3+Kr6ur/sSuoIacqlp1yvmjHJqjYfZjPvHWnqxoEIA==}
+    engines: {node: '>=14.21.3'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@biomejs/cli-linux-arm64-musl@2.1.1':
+    resolution: {integrity: sha512-/7FBLnTswu4jgV9ttI3AMIdDGqVEPIZd8I5u2D4tfCoj8rl9dnjrEQbAIDlWhUXdyWlFSz8JypH3swU9h9P+2A==}
+    engines: {node: '>=14.21.3'}
+    cpu: [arm64]
+    os: [linux]
+
+  '@biomejs/cli-linux-arm64@2.1.1':
+    resolution: {integrity: sha512-tw4BEbhAUkWPe4WBr6IX04DJo+2jz5qpPzpW/SWvqMjb9QuHY8+J0M23V8EPY/zWU4IG8Ui0XESapR1CB49Q7g==}
+    engines: {node: '>=14.21.3'}
+    cpu: [arm64]
+    os: [linux]
+
+  '@biomejs/cli-linux-x64-musl@2.1.1':
+    resolution: {integrity: sha512-kUu+loNI3OCD2c12cUt7M5yaaSjDnGIksZwKnueubX6c/HWUyi/0mPbTBHR49Me3F0KKjWiKM+ZOjsmC+lUt9g==}
+    engines: {node: '>=14.21.3'}
+    cpu: [x64]
+    os: [linux]
+
+  '@biomejs/cli-linux-x64@2.1.1':
+    resolution: {integrity: sha512-3WJ1GKjU7NzZb6RTbwLB59v9cTIlzjbiFLDB0z4376TkDqoNYilJaC37IomCr/aXwuU8QKkrYoHrgpSq5ffJ4Q==}
+    engines: {node: '>=14.21.3'}
+    cpu: [x64]
+    os: [linux]
+
+  '@biomejs/cli-win32-arm64@2.1.1':
+    resolution: {integrity: sha512-vEHK0v0oW+E6RUWLoxb2isI3rZo57OX9ZNyyGH701fZPj6Il0Rn1f5DMNyCmyflMwTnIQstEbs7n2BxYSqQx4Q==}
+    engines: {node: '>=14.21.3'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@biomejs/cli-win32-x64@2.1.1':
+    resolution: {integrity: sha512-i2PKdn70kY++KEF/zkQFvQfX1e8SkA8hq4BgC+yE9dZqyLzB/XStY2MvwI3qswlRgnGpgncgqe0QYKVS1blksg==}
+    engines: {node: '>=14.21.3'}
+    cpu: [x64]
+    os: [win32]
+
   '@brillout/libassert@0.5.8':
     resolution: {integrity: sha512-u/fu+jTRUdNdbLONGq1plCfh+k2/XjSbGVTfnF3rHnSPZd+ABWp0XinR5ifrFkyGOzMbzv8IiQ44lZ4U6ZGrGA==}
 
@@ -3850,6 +3906,41 @@ snapshots:
       '@babel/helper-string-parser': 7.27.1
       '@babel/helper-validator-identifier': 7.27.1
 
+  '@biomejs/biome@2.1.1':
+    optionalDependencies:
+      '@biomejs/cli-darwin-arm64': 2.1.1
+      '@biomejs/cli-darwin-x64': 2.1.1
+      '@biomejs/cli-linux-arm64': 2.1.1
+      '@biomejs/cli-linux-arm64-musl': 2.1.1
+      '@biomejs/cli-linux-x64': 2.1.1
+      '@biomejs/cli-linux-x64-musl': 2.1.1
+      '@biomejs/cli-win32-arm64': 2.1.1
+      '@biomejs/cli-win32-x64': 2.1.1
+
+  '@biomejs/cli-darwin-arm64@2.1.1':
+    optional: true
+
+  '@biomejs/cli-darwin-x64@2.1.1':
+    optional: true
+
+  '@biomejs/cli-linux-arm64-musl@2.1.1':
+    optional: true
+
+  '@biomejs/cli-linux-arm64@2.1.1':
+    optional: true
+
+  '@biomejs/cli-linux-x64-musl@2.1.1':
+    optional: true
+
+  '@biomejs/cli-linux-x64@2.1.1':
+    optional: true
+
+  '@biomejs/cli-win32-arm64@2.1.1':
+    optional: true
+
+  '@biomejs/cli-win32-x64@2.1.1':
+    optional: true
+
   '@brillout/libassert@0.5.8': {}
 
   '@cspotcode/source-map-support@0.8.1':

From 7cea52c608aa759c545aed7e532eee5162a8c6a6 Mon Sep 17 00:00:00 2001
From: Brett Kolodny <brett.kolodny@coder.com>
Date: Thu, 17 Jul 2025 13:58:03 +0000
Subject: [PATCH 18/21] chore: update biome config to match saved version

---
 biome.jsonc | 24 ++++++++++++++++--------
 1 file changed, 16 insertions(+), 8 deletions(-)

diff --git a/biome.jsonc b/biome.jsonc
index 1b6c69b..1cceb1e 100644
--- a/biome.jsonc
+++ b/biome.jsonc
@@ -5,6 +5,7 @@
 		"clientKind": "git"
 	},
 	"files": {
+		"includes": ["!src/client/gen/types.ts","!pnpm-lock.yaml"],
 		"experimentalScannerIgnores": [
 			"src/client/gen/types.ts",
 			"pnpm-lock.yaml"
@@ -16,25 +17,30 @@
 		"rules": {
 			"a11y": {
 				"noSvgWithoutTitle": {
-					"level": "off"
+					"level": "off",
+					"options": {}
 				},
 				"useButtonType": {
-					"level": "off"
+					"level": "off",
+					"options": {}
 				},
 				"useSemanticElements": {
-					"level": "off"
+					"level": "off",
+					"options": {}
 				}
 			},
 			"style": {
 				"noNonNullAssertion": {
-					"level": "off"
+					"level": "off",
+					"options": {}
 				},
 				"noParameterAssign": {
 					"level": "off",
 					"options": {}
 				},
 				"useDefaultParameterLast": {
-					"level": "off"
+					"level": "off",
+					"options": {}
 				},
 				"useSelfClosingElements": {
 					"level": "off",
@@ -43,7 +49,8 @@
 			},
 			"suspicious": {
 				"noArrayIndexKey": {
-					"level": "off"
+					"level": "off",
+					"options": {}
 				},
 				"noConsole": {
 					"level": "error",
@@ -52,7 +59,8 @@
 					}
 				},
 				"noThenProperty": {
-					"level": "off"
+					"level": "off",
+					"options": {}
 				}
 			},
 			"nursery": {
@@ -60,5 +68,5 @@
 			}
 		}
 	},
-	"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json"
+	"$schema": "https://biomejs.dev/schemas/2.1.1/schema.json"
 }

From d282a7082d2671828b6daca255a3fee1c92530b1 Mon Sep 17 00:00:00 2001
From: Brett Kolodny <brett.kolodny@coder.com>
Date: Thu, 17 Jul 2025 13:58:55 +0000
Subject: [PATCH 19/21] chore: remove newline

---
 src/client/App.tsx | 1 -
 1 file changed, 1 deletion(-)

diff --git a/src/client/App.tsx b/src/client/App.tsx
index ef4c923..0f0916b 100644
--- a/src/client/App.tsx
+++ b/src/client/App.tsx
@@ -86,7 +86,6 @@ export const App = () => {
 
 	useEffect(() => {
 		const newCurrentUser = users.find((u) => u.id === currentUser.id);
-
 		if (newCurrentUser) {
 			setCurrentUser(() => newCurrentUser);
 		}

From c1725f5c1f51506935d6c3ef370a85779bfb354e Mon Sep 17 00:00:00 2001
From: Brett Kolodny <brett.kolodny@coder.com>
Date: Thu, 17 Jul 2025 14:03:28 +0000
Subject: [PATCH 20/21] chore: reverse logic of user search

---
 src/client/Preview.tsx | 10 ++--------
 1 file changed, 2 insertions(+), 8 deletions(-)

diff --git a/src/client/Preview.tsx b/src/client/Preview.tsx
index 8199a64..99e5c88 100644
--- a/src/client/Preview.tsx
+++ b/src/client/Preview.tsx
@@ -569,14 +569,8 @@ type UserSelectProps = {
 	users: User[];
 	setUsers: (user: User) => void;
 };
-const UserSelect: FC<UserSelectProps> = ({
-	currentUser,
-	users,
-	setUsers,
-}) => {
-	const selectedMissing = !users.some(
-		(owner) => currentUser.id === owner.id,
-	);
+const UserSelect: FC<UserSelectProps> = ({ currentUser, users, setUsers }) => {
+	const selectedMissing = users.every((user) => currentUser.id !== user.id);
 
 	return (
 		<Select

From 522964526fee2a9bfc48861f9df1bbf77d2ffdf5 Mon Sep 17 00:00:00 2001
From: Brett Kolodny <brett.kolodny@coder.com>
Date: Thu, 17 Jul 2025 20:30:47 +0000
Subject: [PATCH 21/21] chore: remove unused biome config

---
 biome.jsonc | 5 -----
 1 file changed, 5 deletions(-)

diff --git a/biome.jsonc b/biome.jsonc
index 1cceb1e..85d0c2e 100644
--- a/biome.jsonc
+++ b/biome.jsonc
@@ -6,11 +6,6 @@
 	},
 	"files": {
 		"includes": ["!src/client/gen/types.ts","!pnpm-lock.yaml"],
-		"experimentalScannerIgnores": [
-			"src/client/gen/types.ts",
-			"pnpm-lock.yaml"
-			
-		],
 		"ignoreUnknown": true
 	},
 	"linter": {
