From 908e2257e467531373837997b74fc1861afe60e6 Mon Sep 17 00:00:00 2001
From: Jeff Emmett
Date: Tue, 24 Feb 2026 21:42:16 -0800
Subject: [PATCH] feat: add Phase 3 provision UI and dashboard pages
Wallet-connected provisioning wizard at /provision with SIWE auth,
subdomain picker, and multi-step deploy flow. Dashboard at /dashboard
shows instance list with status and management controls. Adds wagmi +
viem + react-query for wallet integration.
Co-Authored-By: Claude Opus 4.6
---
package-lock.json | 396 +++++++++++++++++++++++++-
package.json | 3 +
src/app/dashboard/page.tsx | 200 ++++++++++++++
src/app/page.tsx | 12 +-
src/app/provision/page.tsx | 44 +++
src/components/InstanceCard.tsx | 137 +++++++++
src/components/Navbar.tsx | 8 +-
src/components/Providers.tsx | 15 +-
src/components/ProvisionForm.tsx | 459 +++++++++++++++++++++++++++++++
src/lib/api.ts | 24 ++
src/lib/siwe.ts | 29 ++
src/lib/wagmi-config.ts | 17 ++
tsconfig.json | 2 +-
13 files changed, 1333 insertions(+), 13 deletions(-)
create mode 100644 src/app/dashboard/page.tsx
create mode 100644 src/app/provision/page.tsx
create mode 100644 src/components/InstanceCard.tsx
create mode 100644 src/components/ProvisionForm.tsx
create mode 100644 src/lib/api.ts
create mode 100644 src/lib/siwe.ts
create mode 100644 src/lib/wagmi-config.ts
diff --git a/package-lock.json b/package-lock.json
index baf63c9..c44e073 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@google/generative-ai": "^0.24.1",
+ "@tanstack/react-query": "^5.90.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
@@ -21,6 +22,8 @@
"sharp": "^0.34.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
+ "viem": "^2.46.3",
+ "wagmi": "^3.5.0",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -35,6 +38,12 @@
"typescript": "^5"
}
},
+ "node_modules/@adraffy/ens-normalize": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz",
+ "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==",
+ "license": "MIT"
+ },
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -1236,6 +1245,45 @@
"node": ">= 10"
}
},
+ "node_modules/@noble/ciphers": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
+ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
+ "license": "MIT",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/curves": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz",
+ "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "1.8.0"
+ },
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
+ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
+ "license": "MIT",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2789,6 +2837,42 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@scure/base": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
+ "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@scure/bip32": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz",
+ "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/curves": "~1.9.0",
+ "@noble/hashes": "~1.8.0",
+ "@scure/base": "~1.2.5"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@scure/bip39": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz",
+ "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "~1.8.0",
+ "@scure/base": "~1.2.5"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -3069,6 +3153,32 @@
"tailwindcss": "4.2.0"
}
},
+ "node_modules/@tanstack/query-core": {
+ "version": "5.90.20",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
+ "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.90.21",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
+ "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.90.20"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -3682,6 +3792,58 @@
"win32"
]
},
+ "node_modules/@wagmi/core": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/@wagmi/core/-/core-3.4.0.tgz",
+ "integrity": "sha512-EU5gDsUp5t7+cuLv12/L8hfyWfCIKsBNiiBqpOqxZJxvAcAiQk4xFe2jMgaQPqApc3Omvxrk032M8AQ4N0cQeg==",
+ "license": "MIT",
+ "dependencies": {
+ "eventemitter3": "5.0.1",
+ "mipd": "0.0.7",
+ "zustand": "5.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/wevm"
+ },
+ "peerDependencies": {
+ "@tanstack/query-core": ">=5.0.0",
+ "ox": ">=0.11.1",
+ "typescript": ">=5.7.3",
+ "viem": "2.x"
+ },
+ "peerDependenciesMeta": {
+ "@tanstack/query-core": {
+ "optional": true
+ },
+ "ox": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/abitype": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz",
+ "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/wevm"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.0.4",
+ "zod": "^3.22.0 || ^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ },
+ "zod": {
+ "optional": true
+ }
+ }
+ },
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -5060,6 +5222,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/eventemitter3": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
+ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
+ "license": "MIT"
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -5996,6 +6164,21 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/isows": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz",
+ "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/wevm"
+ }
+ ],
+ "license": "MIT",
+ "peerDependencies": {
+ "ws": "*"
+ }
+ },
"node_modules/iterator.prototype": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
@@ -6534,6 +6717,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/mipd": {
+ "version": "0.0.7",
+ "resolved": "https://registry.npmjs.org/mipd/-/mipd-0.0.7.tgz",
+ "integrity": "sha512-aAPZPNDQ3uMTdKbuO2YmAw2TxLHO0moa4YKAyETM/DTj5FloZo+a+8tU+iv4GmW+sOxKLSRwcSFuczk+Cpt6fg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/wagmi-dev"
+ }
+ ],
+ "license": "MIT",
+ "peerDependencies": {
+ "typescript": ">=5.0.4"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -6876,6 +7079,36 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/ox": {
+ "version": "0.12.4",
+ "resolved": "https://registry.npmjs.org/ox/-/ox-0.12.4.tgz",
+ "integrity": "sha512-+P+C7QzuwPV8lu79dOwjBKfB2CbnbEXe/hfyyrff1drrO1nOOj3Hc87svHfcW1yneRr3WXaKr6nz11nq+/DF9Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/wevm"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@adraffy/ens-normalize": "^1.11.0",
+ "@noble/ciphers": "^1.3.0",
+ "@noble/curves": "1.9.1",
+ "@noble/hashes": "^1.8.0",
+ "@scure/bip32": "^1.7.0",
+ "@scure/bip39": "^1.6.0",
+ "abitype": "^1.2.3",
+ "eventemitter3": "5.0.1"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -8114,7 +8347,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -8302,6 +8535,117 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/viem": {
+ "version": "2.46.3",
+ "resolved": "https://registry.npmjs.org/viem/-/viem-2.46.3.tgz",
+ "integrity": "sha512-2LJS+Hyh2sYjHXQtzfv1kU9pZx9dxFzvoU/ZKIcn0FNtOU0HQuIICuYdWtUDFHaGXbAdVo8J1eCvmjkL9JVGwg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/wevm"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@noble/curves": "1.9.1",
+ "@noble/hashes": "1.8.0",
+ "@scure/bip32": "1.7.0",
+ "@scure/bip39": "1.6.0",
+ "abitype": "1.2.3",
+ "isows": "1.0.7",
+ "ox": "0.12.4",
+ "ws": "8.18.3"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.0.4"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/wagmi": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/wagmi/-/wagmi-3.5.0.tgz",
+ "integrity": "sha512-39uiY6Vkc28NiAHrxJzVTodoRgSVGG97EewwUxRf+jcFMTe8toAnaM8pJZA3Zw/6snMg4tSgWLJAtMnOacLe7w==",
+ "license": "MIT",
+ "dependencies": {
+ "@wagmi/connectors": "7.2.1",
+ "@wagmi/core": "3.4.0",
+ "use-sync-external-store": "1.4.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/wevm"
+ },
+ "peerDependencies": {
+ "@tanstack/react-query": ">=5.0.0",
+ "react": ">=18",
+ "typescript": ">=5.7.3",
+ "viem": "2.x"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/wagmi/node_modules/@wagmi/connectors": {
+ "version": "7.2.1",
+ "resolved": "https://registry.npmjs.org/@wagmi/connectors/-/connectors-7.2.1.tgz",
+ "integrity": "sha512-/tyDepUMDM8eNzNX3ofjqHNRFZ6XcZ3u0+cQp5x0/LHCpMA8tRh7A1/e7dTrYiIJeL7iLgHzfHUXCsU02OKMLQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/wevm"
+ },
+ "peerDependencies": {
+ "@base-org/account": "^2.5.1",
+ "@coinbase/wallet-sdk": "^4.3.6",
+ "@metamask/sdk": "~0.33.1",
+ "@safe-global/safe-apps-provider": "~0.18.6",
+ "@safe-global/safe-apps-sdk": "^9.1.0",
+ "@wagmi/core": "3.4.0",
+ "@walletconnect/ethereum-provider": "^2.21.1",
+ "porto": "~0.2.35",
+ "typescript": ">=5.7.3",
+ "viem": "2.x"
+ },
+ "peerDependenciesMeta": {
+ "@base-org/account": {
+ "optional": true
+ },
+ "@coinbase/wallet-sdk": {
+ "optional": true
+ },
+ "@metamask/sdk": {
+ "optional": true
+ },
+ "@safe-global/safe-apps-provider": {
+ "optional": true
+ },
+ "@safe-global/safe-apps-sdk": {
+ "optional": true
+ },
+ "@walletconnect/ethereum-provider": {
+ "optional": true
+ },
+ "porto": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/wagmi/node_modules/use-sync-external-store": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz",
+ "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -8417,6 +8761,27 @@
"node": ">=0.10.0"
}
},
+ "node_modules/ws": {
+ "version": "8.18.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
+ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -8458,6 +8823,35 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
+ },
+ "node_modules/zustand": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.0.tgz",
+ "integrity": "sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index 97fbb01..bdb8b9c 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
},
"dependencies": {
"@google/generative-ai": "^0.24.1",
+ "@tanstack/react-query": "^5.90.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
@@ -22,6 +23,8 @@
"sharp": "^0.34.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
+ "viem": "^2.46.3",
+ "wagmi": "^3.5.0",
"zod": "^4.3.6"
},
"devDependencies": {
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
new file mode 100644
index 0000000..306d61d
--- /dev/null
+++ b/src/app/dashboard/page.tsx
@@ -0,0 +1,200 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { useAccount, useConnect } from "wagmi";
+import { injected } from "wagmi/connectors";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { InstanceCard, type Instance } from "@/components/InstanceCard";
+import {
+ Wallet,
+ Plus,
+ Loader2,
+ ServerOff,
+} from "lucide-react";
+import { apiFetch, apiHeaders } from "@/lib/api";
+import { getNonce, createSiweMessage } from "@/lib/siwe";
+import { useSignMessage } from "wagmi";
+import { toast } from "sonner";
+
+export default function DashboardPage() {
+ const { address, isConnected, chain } = useAccount();
+ const { connect } = useConnect();
+ const { signMessageAsync } = useSignMessage();
+
+ const [siweToken, setSiweToken] = useState("");
+ const [instances, setInstances] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [signingIn, setSigningIn] = useState(false);
+
+ const signIn = useCallback(async () => {
+ if (!address || !chain) return;
+ setSigningIn(true);
+ try {
+ const nonce = await getNonce();
+ const message = createSiweMessage(address, nonce, chain.id);
+ const signature = await signMessageAsync({ message });
+ const token = btoa(JSON.stringify({ message, signature }));
+ setSiweToken(token);
+ } catch (e) {
+ toast.error(
+ `Sign-in failed: ${e instanceof Error ? e.message : "Unknown error"}`
+ );
+ } finally {
+ setSigningIn(false);
+ }
+ }, [address, chain, signMessageAsync]);
+
+ const fetchInstances = useCallback(async () => {
+ if (!siweToken) return;
+ setLoading(true);
+ try {
+ const data = await apiFetch<{ instances: Instance[] }>("/v1/spaces", {
+ headers: apiHeaders(siweToken),
+ });
+ setInstances(data.instances);
+ } catch (e) {
+ toast.error(
+ `Failed to load spaces: ${e instanceof Error ? e.message : "Unknown"}`
+ );
+ } finally {
+ setLoading(false);
+ }
+ }, [siweToken]);
+
+ useEffect(() => {
+ if (siweToken) fetchInstances();
+ }, [siweToken, fetchInstances]);
+
+ const handleDelete = useCallback(
+ async (slug: string) => {
+ if (!confirm(`Remove space "${slug}"? This will destroy all data.`))
+ return;
+ try {
+ await apiFetch(`/v1/spaces/${slug}`, {
+ method: "DELETE",
+ headers: apiHeaders(siweToken),
+ });
+ toast.success(`Space "${slug}" is being removed`);
+ fetchInstances();
+ } catch (e) {
+ toast.error(
+ `Delete failed: ${e instanceof Error ? e.message : "Unknown"}`
+ );
+ }
+ },
+ [siweToken, fetchInstances]
+ );
+
+ // Not connected
+ if (!isConnected) {
+ return (
+
+
+
+
+
Dashboard
+
+ Connect your wallet to view and manage your Postiz spaces.
+
+
+
+ );
+ }
+
+ // Connected but not signed in
+ if (!siweToken) {
+ return (
+
+
+
+
+
Dashboard
+
+ Sign in with your wallet to access your spaces.
+
+
+
+ {address?.slice(0, 6)}...{address?.slice(-4)}
+
+ {chain?.name}
+
+
+
+ );
+ }
+
+ // Signed in
+ return (
+
+
+
+
Your Spaces
+
+ Manage your deployed Postiz instances
+
+
+
+
+ {address?.slice(0, 6)}...{address?.slice(-4)}
+
+
+
+
+
+ {loading ? (
+
+
+
+ Loading your spaces...
+
+
+ ) : instances.length === 0 ? (
+
+
+
+
+
No spaces yet
+
+ Deploy your first Postiz instance to get started with social
+ media scheduling.
+
+
+
+
+
+ ) : (
+
+ {instances.map((instance) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 272996c..2188c1e 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -45,7 +45,7 @@ export default function HomePage() {
@@ -315,7 +315,7 @@ export default function HomePage() {
Postiz handles scheduling, Temporal handles workflows, Traefik handles routing.
-
# Clone and deploy
+
# Or self-host manually
git clone https://gitea.jeffemmett.com/jeffemmett/rsocials-online
cd rsocials-online/postiz
cp .env.example .env
@@ -324,14 +324,14 @@ export default function HomePage() {
diff --git a/src/app/provision/page.tsx b/src/app/provision/page.tsx
new file mode 100644
index 0000000..e1e1a5c
--- /dev/null
+++ b/src/app/provision/page.tsx
@@ -0,0 +1,44 @@
+import { Badge } from "@/components/ui/badge";
+import { ProvisionForm } from "@/components/ProvisionForm";
+
+export const metadata = {
+ title: "Provision a Space - rSocials",
+ description:
+ "Deploy your own Postiz social media scheduling instance for your community.",
+};
+
+export default function ProvisionPage() {
+ return (
+
+
+
+ Self-Service Provisioning
+
+
Deploy Your Postiz Space
+
+ Connect your wallet, choose a subdomain, and get a fully managed
+ Postiz instance in under 2 minutes.
+
+
+
+
+
+
+
+ Each space includes Postiz, PostgreSQL, Redis, and Temporal — all
+ managed for you.
+
+
+ Your data stays on our EU-based server. Cancel anytime from the{" "}
+
+ dashboard
+
+ .
+
+
+
+ );
+}
diff --git a/src/components/InstanceCard.tsx b/src/components/InstanceCard.tsx
new file mode 100644
index 0000000..feb8cef
--- /dev/null
+++ b/src/components/InstanceCard.tsx
@@ -0,0 +1,137 @@
+"use client";
+
+import { Card, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Globe,
+ ExternalLink,
+ Trash2,
+ Clock,
+ CheckCircle2,
+ AlertCircle,
+ Loader2,
+} from "lucide-react";
+
+export interface Instance {
+ id: string;
+ slug: string;
+ displayName: string;
+ primaryDomain: string;
+ fallbackDomain: string;
+ owner: string;
+ status: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+const statusConfig: Record<
+ string,
+ { label: string; color: string; icon: React.ReactNode }
+> = {
+ active: {
+ label: "Active",
+ color: "bg-green-500/10 text-green-600",
+ icon: ,
+ },
+ provisioning: {
+ label: "Deploying",
+ color: "bg-yellow-500/10 text-yellow-600",
+ icon: ,
+ },
+ failed: {
+ label: "Failed",
+ color: "bg-destructive/10 text-destructive",
+ icon: ,
+ },
+ destroying: {
+ label: "Removing",
+ color: "bg-orange-500/10 text-orange-600",
+ icon: ,
+ },
+ destroyed: {
+ label: "Removed",
+ color: "bg-muted text-muted-foreground",
+ icon: ,
+ },
+};
+
+function formatDate(dateStr: string) {
+ return new Date(dateStr).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+}
+
+export function InstanceCard({
+ instance,
+ onDelete,
+}: {
+ instance: Instance;
+ onDelete?: (slug: string) => void;
+}) {
+ const status = statusConfig[instance.status] ?? statusConfig.active;
+
+ return (
+
+
+
+
+
+
+
+
+
{instance.displayName}
+
+ {instance.fallbackDomain}
+
+
+
+
+ {status.icon}
+ {status.label}
+
+
+
+
+
+
+ Created
+
+ {formatDate(instance.createdAt)}
+
+
+
+ Primary Domain
+
+ {instance.primaryDomain}
+
+
+
+
+
+ {onDelete && instance.status === "active" && (
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx
index ce544f7..deca085 100644
--- a/src/components/Navbar.tsx
+++ b/src/components/Navbar.tsx
@@ -28,11 +28,17 @@ export function Navbar() {
Platforms
Deploy
+
+ Dashboard
+
- {children}
-
- >
+
+
+ {children}
+
+
+
);
}
diff --git a/src/components/ProvisionForm.tsx b/src/components/ProvisionForm.tsx
new file mode 100644
index 0000000..aedc94f
--- /dev/null
+++ b/src/components/ProvisionForm.tsx
@@ -0,0 +1,459 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import { useAccount, useConnect, useDisconnect, useSignMessage } from "wagmi";
+import { injected } from "wagmi/connectors";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Progress } from "@/components/ui/progress";
+import {
+ Wallet,
+ CheckCircle2,
+ Loader2,
+ AlertCircle,
+ Globe,
+ ArrowRight,
+ ArrowLeft,
+} from "lucide-react";
+import { apiFetch, apiHeaders } from "@/lib/api";
+import { getNonce, createSiweMessage } from "@/lib/siwe";
+import { toast } from "sonner";
+
+type Step = "connect" | "configure" | "confirm" | "provisioning" | "done";
+
+interface ProvisionResult {
+ instance: {
+ slug: string;
+ primaryDomain: string;
+ fallbackDomain: string;
+ status: string;
+ };
+}
+
+const RESERVED_SLUGS = new Set([
+ "www",
+ "api",
+ "admin",
+ "mail",
+ "app",
+ "staging",
+ "test",
+ "dev",
+ "socials",
+ "cc",
+ "crypto-commons",
+ "votc",
+ "p2pf",
+ "bcrg",
+]);
+
+const SLUG_RE = /^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/;
+
+export function ProvisionForm() {
+ const { address, isConnected, chain } = useAccount();
+ const { connect } = useConnect();
+ const { disconnect } = useDisconnect();
+ const { signMessageAsync } = useSignMessage();
+
+ const [step, setStep] = useState("connect");
+ const [slug, setSlug] = useState("");
+ const [displayName, setDisplayName] = useState("");
+ const [slugError, setSlugError] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [result, setResult] = useState(null);
+ const [provisionError, setProvisionError] = useState("");
+ const [siweToken, setSiweToken] = useState("");
+
+ const validateSlug = useCallback((value: string) => {
+ const v = value.toLowerCase();
+ if (!v) {
+ setSlugError("");
+ return false;
+ }
+ if (v.length < 3) {
+ setSlugError("Must be at least 3 characters");
+ return false;
+ }
+ if (!SLUG_RE.test(v)) {
+ setSlugError(
+ "Only lowercase letters, numbers, and hyphens. Must start/end with alphanumeric."
+ );
+ return false;
+ }
+ if (RESERVED_SLUGS.has(v)) {
+ setSlugError("This subdomain is reserved");
+ return false;
+ }
+ setSlugError("");
+ return true;
+ }, []);
+
+ const handleConnect = useCallback(() => {
+ connect({ connector: injected() });
+ }, [connect]);
+
+ const handleSignIn = useCallback(async () => {
+ if (!address || !chain) return;
+ setLoading(true);
+ try {
+ const nonce = await getNonce();
+ const message = createSiweMessage(address, nonce, chain.id);
+ const signature = await signMessageAsync({ message });
+ // The signature + message together serve as the bearer token
+ const token = btoa(JSON.stringify({ message, signature }));
+ setSiweToken(token);
+ setStep("configure");
+ toast.success("Wallet connected and signed in");
+ } catch (e) {
+ toast.error(
+ `Sign-in failed: ${e instanceof Error ? e.message : "Unknown error"}`
+ );
+ } finally {
+ setLoading(false);
+ }
+ }, [address, chain, signMessageAsync]);
+
+ const handleProvision = useCallback(async () => {
+ if (!validateSlug(slug) || !displayName.trim()) return;
+ setLoading(true);
+ setProvisionError("");
+ setStep("provisioning");
+
+ try {
+ const res = await apiFetch("/v1/spaces", {
+ method: "POST",
+ headers: apiHeaders(siweToken),
+ body: JSON.stringify({
+ slug: slug.toLowerCase(),
+ displayName: displayName.trim(),
+ }),
+ });
+ setResult(res);
+ setStep("done");
+ toast.success("Your space is live!");
+ } catch (e) {
+ setProvisionError(
+ e instanceof Error ? e.message : "Provisioning failed"
+ );
+ setStep("confirm");
+ toast.error("Provisioning failed");
+ } finally {
+ setLoading(false);
+ }
+ }, [slug, displayName, siweToken, validateSlug]);
+
+ const progress =
+ step === "connect"
+ ? 0
+ : step === "configure"
+ ? 33
+ : step === "confirm"
+ ? 66
+ : step === "provisioning"
+ ? 80
+ : 100;
+
+ return (
+
+
+
+ {/* Step 1: Connect Wallet */}
+ {step === "connect" && (
+
+
+
+
+
+
+
Connect Your Wallet
+
+ Sign in with your Ethereum wallet to provision a Postiz instance
+ for your community.
+
+
+
+ {!isConnected ? (
+
+ ) : (
+
+
+
+
Connected
+
+ {address?.slice(0, 6)}...{address?.slice(-4)}
+
+
+
+ {chain?.name ?? "Unknown"}
+
+
+
+
+
+
+
+ )}
+
+
+ )}
+
+ {/* Step 2: Configure Space */}
+ {step === "configure" && (
+
+
+
+
+
+
+
Configure Your Space
+
+ Choose a name and subdomain for your Postiz instance.
+
+
+
+
+
+
+ setDisplayName(e.target.value)}
+ placeholder="My Community"
+ className="w-full px-3 py-2 rounded-md border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
+ maxLength={50}
+ />
+
+
+
+
+
+ {
+ const v = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "");
+ setSlug(v);
+ validateSlug(v);
+ }}
+ placeholder="my-community"
+ className="flex-1 px-3 py-2 rounded-l-md border border-r-0 bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
+ maxLength={30}
+ />
+
+ .rsocials.online
+
+
+ {slugError && (
+
{slugError}
+ )}
+ {slug && !slugError && (
+
+ Your space will be at{" "}
+
+ {slug}.rsocials.online
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Step 3: Confirm */}
+ {step === "confirm" && (
+
+
+
+
+
+
+
Confirm Provisioning
+
+
+
+
+ Name
+ {displayName}
+
+
+ Subdomain
+ {slug}.rsocials.online
+
+
+ Owner
+
+ {address?.slice(0, 6)}...{address?.slice(-4)}
+
+
+
+
+
+ This will deploy a new Postiz instance with its own database,
+ scheduler, and domain routing. Takes about 2 minutes.
+
+
+ {provisionError && (
+
+ )}
+
+
+
+
+
+
+
+ )}
+
+ {/* Step 4: Provisioning */}
+ {step === "provisioning" && (
+
+
+
+
+
Deploying Your Space
+
+ Setting up containers, database, and routing...
+
+
+
+
+
+
+ Generating compose configuration...
+
+
+
+ Starting Postiz containers...
+
+
+
+ Configuring domain routing...
+
+
+
+
+ )}
+
+ {/* Step 5: Done */}
+ {step === "done" && result && (
+
+
+
+
+
+
+
Your Space is Live!
+
+ {displayName} is now deployed and ready to use.
+
+
+
+
+
+
+ Status
+
+ {result.instance.status}
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/lib/api.ts b/src/lib/api.ts
new file mode 100644
index 0000000..724e3a4
--- /dev/null
+++ b/src/lib/api.ts
@@ -0,0 +1,24 @@
+const API_BASE =
+ process.env.NEXT_PUBLIC_API_URL ?? "https://api.rsocials.online";
+
+export async function apiFetch(
+ path: string,
+ opts?: RequestInit
+): Promise {
+ const res = await fetch(`${API_BASE}${path}`, {
+ headers: {
+ "Content-Type": "application/json",
+ ...opts?.headers,
+ },
+ ...opts,
+ });
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({ error: res.statusText }));
+ throw new Error(body.error ?? `API error ${res.status}`);
+ }
+ return res.json();
+}
+
+export function apiHeaders(token: string) {
+ return { Authorization: `Bearer ${token}` };
+}
diff --git a/src/lib/siwe.ts b/src/lib/siwe.ts
new file mode 100644
index 0000000..211c67d
--- /dev/null
+++ b/src/lib/siwe.ts
@@ -0,0 +1,29 @@
+import { apiFetch } from "./api";
+
+export async function getNonce(): Promise {
+ const data = await apiFetch<{ nonce: string }>("/v1/auth/nonce");
+ return data.nonce;
+}
+
+export function createSiweMessage(
+ address: string,
+ nonce: string,
+ chainId: number
+): string {
+ const domain = window.location.host;
+ const origin = window.location.origin;
+ const issuedAt = new Date().toISOString();
+
+ return [
+ `${domain} wants you to sign in with your Ethereum account:`,
+ address,
+ "",
+ "Sign in to rSocials to provision your social media instance.",
+ "",
+ `URI: ${origin}`,
+ `Version: 1`,
+ `Chain ID: ${chainId}`,
+ `Nonce: ${nonce}`,
+ `Issued At: ${issuedAt}`,
+ ].join("\n");
+}
diff --git a/src/lib/wagmi-config.ts b/src/lib/wagmi-config.ts
new file mode 100644
index 0000000..715f892
--- /dev/null
+++ b/src/lib/wagmi-config.ts
@@ -0,0 +1,17 @@
+import { http, createConfig } from "wagmi";
+import { base, baseSepolia } from "wagmi/chains";
+import { injected, walletConnect } from "wagmi/connectors";
+
+const projectId = process.env.NEXT_PUBLIC_WC_PROJECT_ID ?? "";
+
+export const wagmiConfig = createConfig({
+ chains: [base, baseSepolia],
+ connectors: [
+ injected(),
+ ...(projectId ? [walletConnect({ projectId })] : []),
+ ],
+ transports: {
+ [base.id]: http(),
+ [baseSepolia.id]: http(),
+ },
+});
diff --git a/tsconfig.json b/tsconfig.json
index cf9c65d..863936a 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -30,5 +30,5 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
- "exclude": ["node_modules"]
+ "exclude": ["node_modules", "api"]
}