From 43ef276976ee8c75ee4ef1db0314962294e99275 Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Wed, 1 Apr 2026 22:10:39 -0400 Subject: [PATCH] web repo --- CLAUDE.md | 9 + README.md | 120 +------- apps/web/app/app/page.tsx | 283 +++++++++++++++++++ apps/web/app/docs/page.tsx | 181 ++++++++++++ apps/web/app/landing/page.tsx | 117 ++------ apps/web/app/login/page.tsx | 4 +- apps/web/app/page.tsx | 502 +--------------------------------- 7 files changed, 508 insertions(+), 708 deletions(-) create mode 100644 apps/web/app/app/page.tsx create mode 100644 apps/web/app/docs/page.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 67e34d8..fcce24d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,8 +65,17 @@ ## Live operations - If modifying the live Netcup deployment, only stop the `betternas` node process unless the user explicitly asks to modify the deployed backend service. +- When setting up a separate machine as a node in this session, access it through `computer ssh hiromi`. ## Node availability UX - Prefer default UI behavior that does not present disconnected nodes as mountable. - Surface connected and disconnected node state in the product when node availability is exposed. + +## Product docs UX + +- Remove operational setup instructions from the main control-plane page when they are better represented as dedicated end-to-end product docs. +- Prefer a separate clean docs page for simple end-to-end usage instructions. +- Keep the root `README.md` extremely minimal: no headings, just 5-6 plain lines that explain the architecture simply and cleanly. +- Make the default root route land on the main landing page instead of the auth-gated app. +- Add a plain `Docs` button to the left of the sign-in button on the main landing page. diff --git a/README.md b/README.md index 3c7c647..d5c9453 100644 --- a/README.md +++ b/README.md @@ -1,114 +1,6 @@ -# betterNAS - -betterNAS is a self-hostable WebDAV stack for mounting NAS exports in Finder. - -The default product shape is: - -- `node-service` serves the real files from the NAS over WebDAV -- `control-server` owns auth, nodes, exports, grants, and mount profile issuance -- `web control plane` lets the user manage the NAS and get mount instructions -- `macOS client` starts as native Finder WebDAV mounting, with a thin helper later - -For now, the whole stack should be able to run on the user's NAS device. - -## Current repo shape - -- `apps/node-agent` - - NAS-side Go runtime and WebDAV server -- `apps/control-plane` - - Go backend for auth, registry, and mount profile issuance -- `apps/web` - - Next.js web control plane -- `apps/nextcloud-app` - - optional Nextcloud adapter, not the product center -- `packages/contracts` - - canonical shared contracts -- `infra/docker` - - self-hosted local stack - -The main planning docs are: - -- [docs/architecture.md](./docs/architecture.md) -- [skeleton.md](./skeleton.md) -- [docs/05-build-plan.md](./docs/05-build-plan.md) - -## Default runtime model - -```text - self-hosted betterNAS on the user's NAS - - +------------------------------+ - | web control plane | - | Next.js UI | - +--------------+---------------+ - | - v - +------------------------------+ - | control-server | - | auth / nodes / exports | - | grants / mount profiles | - +--------------+---------------+ - | - v - +------------------------------+ - | node-service | - | WebDAV + export runtime | - | real NAS bytes | - +------------------------------+ - - user Mac - | - +--> browser -> web control plane - | - +--> Finder -> WebDAV mount URL from control-server -``` - -## Verify - -Static verification: - -```bash -pnpm verify -``` - -Bootstrap clone-local runtime settings: - -```bash -pnpm agent:bootstrap -``` - -Bring the self-hosted stack up, verify it, and tear it down: - -```bash -pnpm stack:up -pnpm stack:verify -pnpm stack:down --volumes -``` - -Run the full loop: - -```bash -pnpm agent:verify -``` - -## Current end-to-end slice - -The first proven slice is: - -1. boot the stack with `pnpm stack:up` -2. verify it with `pnpm stack:verify` -3. get the WebDAV mount profile from the control plane -4. mount it in Finder with the issued credentials - -If the stack is running on a remote machine, tunnel the WebDAV port first, then -use Finder `Connect to Server` with the tunneled URL. - -## Product boundary - -The default betterNAS product is self-hosted and WebDAV-first. - -Nextcloud remains optional and secondary: - -- useful later for browser/mobile/share surfaces -- not required for the core mount flow -- not the system of record +betterNAS is a hosted control plane with a user-run node agent. +The control plane owns user auth, node enrollment, heartbeats, export state, and mount issuance. +The node agent runs on the machine that owns the files and serves them over WebDAV. +The web app reads from the control plane and shows nodes, exports, and mount details. +Finder mounts the export from the node's public WebDAV URL using the same betterNAS username and password. +File traffic goes directly between the client and the node, not through the control plane. diff --git a/apps/web/app/app/page.tsx b/apps/web/app/app/page.tsx new file mode 100644 index 0000000..2e3dc8a --- /dev/null +++ b/apps/web/app/app/page.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { SignOut } from "@phosphor-icons/react"; +import { + isAuthenticated, + listExports, + listNodes, + issueMountProfile, + logout, + getMe, + type StorageExport, + type MountProfile, + type NasNode, + type User, + ApiError, +} from "@/lib/api"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { CopyField } from "../copy-field"; + +export default function Home() { + const router = useRouter(); + const [user, setUser] = useState(null); + const [nodes, setNodes] = useState([]); + const [exports, setExports] = useState([]); + const [selectedExportId, setSelectedExportId] = useState(null); + const [mountProfile, setMountProfile] = useState(null); + const [feedback, setFeedback] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!isAuthenticated()) { + router.replace("/login"); + return; + } + + async function load() { + try { + const [me, registeredNodes, exps] = await Promise.all([ + getMe(), + listNodes(), + listExports(), + ]); + setUser(me); + setNodes(registeredNodes); + setExports(exps); + } catch (err) { + if (err instanceof ApiError && err.status === 401) { + router.replace("/login"); + return; + } + setFeedback(err instanceof Error ? err.message : "Failed to load"); + } finally { + setLoading(false); + } + } + + load(); + }, [router]); + + async function handleSelectExport(exportId: string) { + setSelectedExportId(exportId); + setMountProfile(null); + setFeedback(null); + + try { + const profile = await issueMountProfile(exportId); + setMountProfile(profile); + } catch (err) { + setFeedback( + err instanceof Error ? err.message : "Failed to issue mount profile", + ); + } + } + + async function handleLogout() { + await logout(); + router.replace("/login"); + } + + if (loading) { + return ( +
+

Loading...

+
+ ); + } + + const selectedExport = selectedExportId + ? (exports.find((e) => e.id === selectedExportId) ?? null) + : null; + + return ( +
+
+ {/* header */} +
+
+ + betterNAS + +

+ Control Plane +

+
+ {user && ( +
+ + Docs + + + {user.username} + + +
+ )} +
+ + {feedback !== null && ( +
+ {feedback} +
+ )} + + {/* nodes */} +
+
+

Nodes

+ + {nodes.filter((n) => n.status === "online").length} online + {nodes.filter((n) => n.status === "offline").length > 0 && + `, ${nodes.filter((n) => n.status === "offline").length} offline`} + +
+ + {nodes.length === 0 ? ( +

+ No nodes registered yet. Install and start the node agent on the + machine that owns your files. +

+ ) : ( +
+ {nodes.map((node) => ( +
+
+ + {node.displayName} + + + {node.directAddress ?? node.relayAddress ?? node.machineId} + + + Last seen {formatTimestamp(node.lastSeenAt)} + +
+ + {node.status} + +
+ ))} +
+ )} +
+ + {/* exports + mount */} +
+ {/* exports list */} +
+

Exports

+ + {exports.length === 0 ? ( +

+ {nodes.length === 0 + ? "No exports yet. Start the node agent to register one." + : "No connected exports. Start the node agent or wait for reconnection."} +

+ ) : ( +
+ {exports.map((exp) => { + const isSelected = exp.id === selectedExportId; + + return ( + + ); + })} +
+ )} +
+ + {/* mount profile */} +
+

+ {selectedExport ? `Mount ${selectedExport.label}` : "Mount"} +

+ + {mountProfile === null ? ( +

+ Select an export to see the mount URL and credentials. +

+ ) : ( +
+ + + +

+ Use your betterNAS account password when Finder prompts. v1 + does not issue a separate WebDAV password. +

+ +
+

Finder steps

+
    +
  1. 1. Go > Connect to Server in Finder.
  2. +
  3. 2. Paste the mount URL.
  4. +
  5. 3. Enter your betterNAS username and password.
  6. +
  7. 4. Optionally save to Keychain.
  8. +
+
+
+ )} +
+
+
+
+ ); +} + +function formatTimestamp(value: string): string { + const trimmedValue = value.trim(); + if (trimmedValue === "") return "Never"; + + const parsed = new Date(trimmedValue); + if (Number.isNaN(parsed.getTime())) return trimmedValue; + + return parsed.toLocaleString(); +} diff --git a/apps/web/app/docs/page.tsx b/apps/web/app/docs/page.tsx new file mode 100644 index 0000000..60568a6 --- /dev/null +++ b/apps/web/app/docs/page.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Check, Copy } from "@phosphor-icons/react"; + +function CodeBlock({ children, label }: { children: string; label?: string }) { + const [copied, setCopied] = useState(false); + + return ( +
+ {label && ( + + {label} + + )} +
+        {children}
+      
+ +
+ ); +} + +export default function DocsPage() { + return ( +
+
+ {/* header */} +
+
+ + + + + + + Sign in + +
+
+

+ Getting started +

+

+ One account works everywhere: the web app, the node agent, and + Finder. Set up the node, confirm it is online, then mount your + export. +

+
+
+ + {/* prerequisites */} +
+

Prerequisites

+
    +
  • - A betterNAS account
  • +
  • - A machine with the files you want to expose
  • +
  • - An export folder on that machine
  • +
  • + - A public HTTPS URL that reaches your node directly (for Finder + mounting) +
  • +
+
+ + {/* step 1 */} +
+

1. Install the node binary

+

+ Run this on the machine that owns the files. +

+ + {`curl -fsSL https://raw.githubusercontent.com/harivansh-afk/betterNAS/main/scripts/install-betternas-node.sh | sh`} + +
+ + {/* step 2 */} +
+

2. Start the node

+

+ Replace the placeholders with your account, export path, and public + node URL. +

+ + {`BETTERNAS_CONTROL_PLANE_URL=https://api.betternas.com \\ +BETTERNAS_USERNAME=your-username \\ +BETTERNAS_PASSWORD='your-password' \\ +BETTERNAS_EXPORT_PATH=/absolute/path/to/export \\ +BETTERNAS_NODE_DIRECT_ADDRESS=https://your-public-node-url \\ +betternas-node`} + +
+

+ Export path{" "} + - the directory you want to expose through betterNAS. +

+

+ + Direct address + {" "} + - the real public HTTPS base URL that reaches your node directly. +

+
+
+ + {/* step 3 */} +
+

3. Confirm the node is online

+

+ Open the control plane after the node starts. You should see: +

+
    +
  • - Your node appears as online
  • +
  • - Your export appears in the exports list
  • +
  • + - Issuing a mount profile gives you a WebDAV URL, not an HTML + login page +
  • +
+
+ + {/* step 4 */} +
+

4. Mount in Finder

+
    +
  1. 1. Open Finder, then Go > Connect to Server.
  2. +
  3. + 2. Copy the mount URL from the control plane and paste it in. +
  4. +
  5. + 3. Sign in with the same username and password you used for the + web app and node agent. +
  6. +
  7. + 4. Save to Keychain only if you want Finder to remember the + password. +
  8. +
+
+ + {/* note about public urls */} +
+

A note on public URLs

+

+ Finder mounting only works when the node URL is directly reachable + over HTTPS. Avoid gateways that show their own login page before + forwarding traffic. A good check: load{" "} + /dav/{" "} + on your node URL. A working node responds with WebDAV headers, not + HTML. +

+
+
+
+ ); +} diff --git a/apps/web/app/landing/page.tsx b/apps/web/app/landing/page.tsx index 3dba059..79ee795 100644 --- a/apps/web/app/landing/page.tsx +++ b/apps/web/app/landing/page.tsx @@ -11,50 +11,27 @@ const README_LINES = [ { tag: "h1", text: "betterNAS" }, { tag: "p", - text: "betterNAS is a self-hostable WebDAV stack for mounting NAS exports in Finder.", - }, - { tag: "p", text: "The default product shape is:" }, - { - tag: "ul", - items: [ - "node-service serves the real files from the NAS over WebDAV", - "control-server owns auth, nodes, exports, grants, and mount profile issuance", - "web control plane lets the user manage the NAS and get mount instructions", - "macOS client starts as native Finder WebDAV mounting, with a thin helper later", - ], + text: "betterNAS is a hosted control plane with a user-run node agent.", }, { tag: "p", - text: "For now, the whole stack should be able to run on the user's NAS device.", + text: "The control plane owns user auth, node enrollment, heartbeats, export state, and mount issuance.", }, - { tag: "h2", text: "Current repo shape" }, - { - tag: "ul", - items: [ - "apps/node-agent - NAS-side Go runtime and WebDAV server", - "apps/control-plane - Go backend for auth, registry, and mount profile issuance", - "apps/web - Next.js web control plane", - "apps/nextcloud-app - optional Nextcloud adapter, not the product center", - "packages/contracts - canonical shared contracts", - "infra/docker - self-hosted local stack", - ], - }, - { tag: "h2", text: "Verify" }, - { tag: "code", text: "pnpm verify" }, - { tag: "h2", text: "Current end-to-end slice" }, - { - tag: "ol", - items: [ - "Boot the stack with pnpm stack:up", - "Verify it with pnpm stack:verify", - "Get the WebDAV mount profile from the control plane", - "Mount it in Finder with the issued credentials", - ], - }, - { tag: "h2", text: "Product boundary" }, { tag: "p", - text: "The default betterNAS product is self-hosted and WebDAV-first. Nextcloud remains optional and secondary.", + text: "The node agent runs on the machine that owns the files and serves them over WebDAV.", + }, + { + tag: "p", + text: "The web app reads from the control plane and shows nodes, exports, and mount details.", + }, + { + tag: "p", + text: "Finder mounts the export from the node's public WebDAV URL using the same betterNAS username and password.", + }, + { + tag: "p", + text: "File traffic goes directly between the client and the node, not through the control plane.", }, ] as const; @@ -217,59 +194,11 @@ function ReadmeModal({ onClose }: { onClose: () => void }) { {block.text} ); - if (block.tag === "h2") - return ( -

- {block.text} -

- ); - if (block.tag === "p") - return ( -

- {block.text} -

- ); - if (block.tag === "code") - return ( -
-                    {block.text}
-                  
- ); - if (block.tag === "ul") - return ( -
    - {block.items.map((item, j) => ( -
  • - - {item.split(" - ")[0]} - - {item.includes(" - ") && ( - - {" "} - - {item.split(" - ").slice(1).join(" - ")} - - )} -
  • - ))} -
- ); - if (block.tag === "ol") - return ( -
    - {block.items.map((item, j) => ( -
  1. - {item} -
  2. - ))} -
- ); - return null; + return ( +

+ {block.text} +

+ ); })} @@ -394,6 +323,12 @@ export default function LandingPage() { {/* ---- header ---- */}
+ + Docs +
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 44bff34..d6fc8bf 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,501 +1 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { - Globe, - HardDrives, - LinkSimple, - SignOut, - Warning, -} from "@phosphor-icons/react"; -import { - isAuthenticated, - listExports, - listNodes, - issueMountProfile, - logout, - getMe, - type StorageExport, - type MountProfile, - type NasNode, - type User, - ApiError, -} from "@/lib/api"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Separator } from "@/components/ui/separator"; -import { cn } from "@/lib/utils"; -import { CopyField } from "./copy-field"; - -export default function Home() { - const router = useRouter(); - const [user, setUser] = useState(null); - const [nodes, setNodes] = useState([]); - const [exports, setExports] = useState([]); - const [selectedExportId, setSelectedExportId] = useState(null); - const [mountProfile, setMountProfile] = useState(null); - const [feedback, setFeedback] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - if (!isAuthenticated()) { - router.replace("/login"); - return; - } - - async function load() { - try { - const [me, registeredNodes, exps] = await Promise.all([ - getMe(), - listNodes(), - listExports(), - ]); - setUser(me); - setNodes(registeredNodes); - setExports(exps); - } catch (err) { - if (err instanceof ApiError && err.status === 401) { - router.replace("/login"); - return; - } - setFeedback(err instanceof Error ? err.message : "Failed to load"); - } finally { - setLoading(false); - } - } - - load(); - }, [router]); - - async function handleSelectExport(exportId: string) { - setSelectedExportId(exportId); - setMountProfile(null); - setFeedback(null); - - try { - const profile = await issueMountProfile(exportId); - setMountProfile(profile); - } catch (err) { - setFeedback( - err instanceof Error ? err.message : "Failed to issue mount profile", - ); - } - } - - async function handleLogout() { - await logout(); - router.replace("/login"); - } - - if (loading) { - return ( -
-

Loading...

- - ); - } - - const selectedExport = selectedExportId - ? (exports.find((e) => e.id === selectedExportId) ?? null) - : null; - const onlineNodeCount = nodes.filter((node) => node.status === "online").length; - const degradedNodeCount = nodes.filter( - (node) => node.status === "degraded", - ).length; - const offlineNodeCount = nodes.filter( - (node) => node.status === "offline", - ).length; - - return ( -
-
-
-
-
-

- betterNAS -

-

- Control Plane -

-
- {user && ( -
- - {user.username} - - -
- )} -
- -
- - - {process.env.NEXT_PUBLIC_BETTERNAS_API_URL || "local"} - - - {exports.length === 1 ? "1 export" : `${exports.length} exports`} - - - {nodes.length === 1 ? "1 node" : `${nodes.length} nodes`} - -
- - {user && ( - - - Node agent setup - - Run the node binary on the machine that owns the files with - the same account credentials you use here and in Finder. - - - -
-
-                    
-                      {`curl -fsSL https://raw.githubusercontent.com/harivansh-afk/betterNAS/main/scripts/install-betternas-node.sh | sh`}
-                    
-                  
-
-                    
-                      {`BETTERNAS_USERNAME=${user.username} BETTERNAS_PASSWORD=... BETTERNAS_EXPORT_PATH=/path/to/export BETTERNAS_NODE_DIRECT_ADDRESS=https://your-public-node-url betternas-node`}
-                    
-                  
-
-
-
- )} -
- - {feedback !== null && ( - - - Error - {feedback} - - )} - - - - Nodes - - Machines registered to your account and their current connection - state. - - -
- - {onlineNodeCount} online - - {degradedNodeCount > 0 && ( - - {degradedNodeCount} degraded - - )} - {offlineNodeCount > 0 && ( - {offlineNodeCount} offline - )} -
-
-
- - {nodes.length === 0 ? ( -
- -

- No nodes registered yet. Install and start the node agent on - the machine that owns your files. -

-
- ) : ( -
- {nodes.map((node) => ( -
-
-
- - {node.displayName} - - - {node.machineId} - -
- - {node.status} - -
- -
-
-
- Node ID -
-
- {node.id} -
-
-
-
- Last seen -
-
- {formatTimestamp(node.lastSeenAt)} -
-
-
-
- Address -
-
- {node.directAddress ?? node.relayAddress ?? "Unavailable"} -
-
-
-
- ))} -
- )} -
-
- -
- - - Exports - - Connected storage exports that are currently mountable. - - - - {exports.length === 1 - ? "1 export" - : `${exports.length} exports`} - - - - - {exports.length === 0 ? ( -
- -

- {nodes.length === 0 - ? "No exports registered yet. Start the node agent and connect it to this control plane." - : "No connected exports right now. Start the node agent or wait for a disconnected node to reconnect."} -

-
- ) : ( -
- {exports.map((storageExport) => { - const isSelected = storageExport.id === selectedExportId; - - return ( - - ); - })} -
- )} -
-
- - - - - {selectedExport !== null - ? `Mount ${selectedExport.label}` - : "Mount instructions"} - - - {selectedExport !== null - ? "WebDAV mount details for Finder." - : "Select an export to see the mount URL and account login details."} - - - - {mountProfile === null ? ( -
- -

- Pick an export to see the Finder mount URL and the username - to use with your betterNAS account password. -

-
- ) : ( -
-
- - Issued profile - - - {mountProfile.readonly ? "Read-only" : "Read-write"} - -
- - - -
- - - - - Use your betterNAS account password - - - Enter the same password you use to sign in to betterNAS - and run the node agent. v1 does not issue a separate - WebDAV password. - - -
- - - -
-
-
- Mode -
-
- {mountProfile.credential.mode} -
-
-
-
- Password source -
-
- Your betterNAS account password -
-
-
- - - -
-

Finder steps

-
    - {[ - "Open Finder and choose Go, then Connect to Server.", - "Paste the mount URL into the server address field.", - "Enter your betterNAS username and account password when prompted.", - "Save to Keychain only if you want Finder to reuse that same account password.", - ].map((step, index) => ( -
  1. - - {index + 1} - - {step} -
  2. - ))} -
-
-
- )} -
-
-
-
-
- ); -} - -function formatTimestamp(value: string): string { - const trimmedValue = value.trim(); - if (trimmedValue === "") { - return "Never"; - } - - const parsed = new Date(trimmedValue); - if (Number.isNaN(parsed.getTime())) { - return trimmedValue; - } - - return parsed.toLocaleString(); -} +export { default } from "./landing/page";