From f6069a024ad209b0908995d39e3540a3cc6cbebc Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Wed, 1 Apr 2026 21:18:08 -0400 Subject: [PATCH] ui --- .../control-plane/runtime_integration_test.go | 15 +- .../control-plane/cmd/control-plane/server.go | 10 +- .../cmd/control-plane/server_test.go | 20 +- .../cmd/control-plane/sqlite_store_test.go | 4 +- apps/node-agent/cmd/node-agent/app.go | 14 +- apps/web/app/landing/page.tsx | 527 ++++++++++++++++++ apps/web/app/login/page.tsx | 205 +++---- 7 files changed, 675 insertions(+), 120 deletions(-) create mode 100644 apps/web/app/landing/page.tsx diff --git a/apps/control-plane/cmd/control-plane/runtime_integration_test.go b/apps/control-plane/cmd/control-plane/runtime_integration_test.go index 0fb13ac..2e93f6e 100644 --- a/apps/control-plane/cmd/control-plane/runtime_integration_test.go +++ b/apps/control-plane/cmd/control-plane/runtime_integration_test.go @@ -56,8 +56,9 @@ func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) { mount := postJSONAuth[mountProfile](t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{ ExportID: export.ID, }) - if mount.MountURL != nodeAgent.baseURL+defaultWebDAVPath { - t.Fatalf("expected runtime mount URL %q, got %q", nodeAgent.baseURL+defaultWebDAVPath, mount.MountURL) + expectedMountURL := nodeAgent.baseURL + defaultWebDAVPath + runtimeUsername + "/" + if mount.MountURL != expectedMountURL { + t.Fatalf("expected runtime mount URL %q, got %q", expectedMountURL, mount.MountURL) } if mount.Credential.Mode != mountCredentialModeBasicAuth { t.Fatalf("expected mount credential mode %q, got %q", mountCredentialModeBasicAuth, mount.Credential.Mode) @@ -103,11 +104,13 @@ func TestControlPlaneBinaryMultiExportProfilesStayDistinct(t *testing.T) { if firstMount.MountURL == secondMount.MountURL { t.Fatalf("expected distinct runtime mount URLs, got %q", firstMount.MountURL) } - if firstMount.MountURL != nodeAgent.baseURL+firstMountPath { - t.Fatalf("expected first runtime mount URL %q, got %q", nodeAgent.baseURL+firstMountPath, firstMount.MountURL) + expectedFirstMountURL := nodeAgent.baseURL + firstMountPath + runtimeUsername + "/" + expectedSecondMountURL := nodeAgent.baseURL + secondMountPath + runtimeUsername + "/" + if firstMount.MountURL != expectedFirstMountURL { + t.Fatalf("expected first runtime mount URL %q, got %q", expectedFirstMountURL, firstMount.MountURL) } - if secondMount.MountURL != nodeAgent.baseURL+secondMountPath { - t.Fatalf("expected second runtime mount URL %q, got %q", nodeAgent.baseURL+secondMountPath, secondMount.MountURL) + if secondMount.MountURL != expectedSecondMountURL { + t.Fatalf("expected second runtime mount URL %q, got %q", expectedSecondMountURL, secondMount.MountURL) } assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", firstMount.MountURL, controlPlane.username, controlPlane.password, http.StatusMultiStatus) diff --git a/apps/control-plane/cmd/control-plane/server.go b/apps/control-plane/cmd/control-plane/server.go index e4057f9..092bedc 100644 --- a/apps/control-plane/cmd/control-plane/server.go +++ b/apps/control-plane/cmd/control-plane/server.go @@ -218,7 +218,7 @@ func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) { return } - mountURL, err := buildMountURL(context) + mountURL, err := buildMountURL(context, currentUser.Username) if err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) return @@ -720,13 +720,17 @@ func hasConfiguredNextcloudBaseURL(baseURL string) bool { return err == nil } -func buildMountURL(context exportContext) (string, error) { +func buildMountURL(context exportContext, username string) (string, error) { address, ok := firstAddress(context.node.DirectAddress, context.node.RelayAddress) if !ok { return "", errMountTargetUnavailable } - mountURL, err := buildAbsoluteHTTPURLWithPath(address, mountProfilePathForExport(context.export.MountPath)) + basePath := mountProfilePathForExport(context.export.MountPath) + // Append the username so Finder uses it as the volume name in the sidebar. + userScopedPath := path.Join(basePath, username) + "/" + + mountURL, err := buildAbsoluteHTTPURLWithPath(address, userScopedPath) if err != nil { return "", errMountTargetUnavailable } diff --git a/apps/control-plane/cmd/control-plane/server_test.go b/apps/control-plane/cmd/control-plane/server_test.go index 7456a8b..c714a89 100644 --- a/apps/control-plane/cmd/control-plane/server_test.go +++ b/apps/control-plane/cmd/control-plane/server_test.go @@ -162,8 +162,8 @@ func TestControlPlaneRegistrationProfilesAndHeartbeat(t *testing.T) { if mount.DisplayName != "Photos" { t.Fatalf("expected mount display name Photos, got %q", mount.DisplayName) } - if mount.MountURL != "http://nas.local:8090/dav/" { - t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/", mount.MountURL) + if mount.MountURL != "http://nas.local:8090/dav/fixture/" { + t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/fixture/", mount.MountURL) } if mount.Readonly { t.Fatal("expected mount profile to be read-write") @@ -415,11 +415,11 @@ func TestControlPlaneProfilesRemainExportSpecificForConfiguredMountPaths(t *test if docsMount.MountURL == mediaMount.MountURL { t.Fatalf("expected distinct mount URLs for configured export paths, got %q", docsMount.MountURL) } - if docsMount.MountURL != "http://nas.local:8090/dav/exports/docs/" { - t.Fatalf("expected docs mount URL %q, got %q", "http://nas.local:8090/dav/exports/docs/", docsMount.MountURL) + if docsMount.MountURL != "http://nas.local:8090/dav/exports/docs/fixture/" { + t.Fatalf("expected docs mount URL %q, got %q", "http://nas.local:8090/dav/exports/docs/fixture/", docsMount.MountURL) } - if mediaMount.MountURL != "http://nas.local:8090/dav/exports/media/" { - t.Fatalf("expected media mount URL %q, got %q", "http://nas.local:8090/dav/exports/media/", mediaMount.MountURL) + if mediaMount.MountURL != "http://nas.local:8090/dav/exports/media/fixture/" { + t.Fatalf("expected media mount URL %q, got %q", "http://nas.local:8090/dav/exports/media/fixture/", mediaMount.MountURL) } docsCloud := postJSONAuth[cloudProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{ @@ -469,8 +469,8 @@ func TestControlPlaneMountProfilesUseRelayAndPreserveBasePath(t *testing.T) { }) mount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: "dev-export"}) - if mount.MountURL != "https://nas.example.test/control/dav/relay/" { - t.Fatalf("expected relay mount URL %q, got %q", "https://nas.example.test/control/dav/relay/", mount.MountURL) + if mount.MountURL != "https://nas.example.test/control/dav/relay/fixture/" { + t.Fatalf("expected relay mount URL %q, got %q", "https://nas.example.test/control/dav/relay/fixture/", mount.MountURL) } registration = registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{ @@ -648,8 +648,8 @@ func TestControlPlanePersistsRegistryAcrossAppRestart(t *testing.T) { } mount := postJSONAuth[mountProfile](t, secondServer.Client(), testClientToken, secondServer.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: exports[0].ID}) - if mount.MountURL != "http://nas.local:8090/dav/persisted/" { - t.Fatalf("expected persisted mount URL %q, got %q", "http://nas.local:8090/dav/persisted/", mount.MountURL) + if mount.MountURL != "http://nas.local:8090/dav/persisted/fixture/" { + t.Fatalf("expected persisted mount URL %q, got %q", "http://nas.local:8090/dav/persisted/fixture/", mount.MountURL) } reRegistration := registerNode(t, secondServer.Client(), secondServer.URL+"/api/v1/nodes/register", registration.NodeToken, nodeRegistrationRequest{ diff --git a/apps/control-plane/cmd/control-plane/sqlite_store_test.go b/apps/control-plane/cmd/control-plane/sqlite_store_test.go index 1dda815..0be0d53 100644 --- a/apps/control-plane/cmd/control-plane/sqlite_store_test.go +++ b/apps/control-plane/cmd/control-plane/sqlite_store_test.go @@ -102,8 +102,8 @@ func TestSQLiteRegistrationAndExports(t *testing.T) { } mount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: "dev-export"}) - if mount.MountURL != "http://nas.local:8090/dav/docs/" { - t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/docs/", mount.MountURL) + if mount.MountURL != "http://nas.local:8090/dav/docs/fixture/" { + t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/docs/fixture/", mount.MountURL) } } diff --git a/apps/node-agent/cmd/node-agent/app.go b/apps/node-agent/cmd/node-agent/app.go index 469663d..1a87468 100644 --- a/apps/node-agent/cmd/node-agent/app.go +++ b/apps/node-agent/cmd/node-agent/app.go @@ -234,12 +234,24 @@ func (a *app) handler() http.Handler { for _, mount := range a.exportMounts { mountPathPrefix := strings.TrimSuffix(mount.mountPath, "/") fs := webdav.Dir(mount.exportPath) + lockSystem := webdav.NewMemLS() dav := &webdav.Handler{ Prefix: mountPathPrefix, FileSystem: fs, - LockSystem: webdav.NewMemLS(), + LockSystem: lockSystem, } mux.Handle(mount.mountPath, a.requireDAVAuth(mount, finderCompatible(dav, fs, mountPathPrefix))) + + // Register a username-scoped handler at {mountPath}{username}/ so + // Finder shows the username as the volume name in the sidebar. + userScopedPath := mount.mountPath + a.authUsername + "/" + userScopedPrefix := strings.TrimSuffix(userScopedPath, "/") + userDav := &webdav.Handler{ + Prefix: userScopedPrefix, + FileSystem: fs, + LockSystem: lockSystem, + } + mux.Handle(userScopedPath, a.requireDAVAuth(mount, finderCompatible(userDav, fs, userScopedPrefix))) } return mux diff --git a/apps/web/app/landing/page.tsx b/apps/web/app/landing/page.tsx new file mode 100644 index 0000000..3dba059 --- /dev/null +++ b/apps/web/app/landing/page.tsx @@ -0,0 +1,527 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; + +/* ------------------------------------------------------------------ */ +/* README content (rendered as simple markdown-ish HTML) */ +/* ------------------------------------------------------------------ */ + +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", + ], + }, + { + tag: "p", + text: "For now, the whole stack should be able to run on the user's NAS device.", + }, + { 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.", + }, +] as const; + +/* ------------------------------------------------------------------ */ +/* Icons */ +/* ------------------------------------------------------------------ */ + +function GithubIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function ClockIcon() { + return ( + + + + + ); +} + +function SharedIcon() { + return ( + + + + + + ); +} + +function LibraryIcon() { + return ( + + + + ); +} + +function AppIcon() { + return ( + + + + ); +} + +function DesktopIcon() { + return ( + + + + + ); +} + +function DownloadIcon() { + return ( + + + + ); +} + +function DocumentsIcon() { + return ( + + + + + ); +} + +function FolderIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function CloudIcon() { + return ( + + + + ); +} + +function HomeIcon() { + return ( + + + + ); +} + +function NetworkIcon() { + return ( + + + + + ); +} + +function AirdropIcon() { + return ( + + + + + ); +} + +/* ------------------------------------------------------------------ */ +/* README modal (Quick Look style) */ +/* ------------------------------------------------------------------ */ + +function ReadmeModal({ onClose }: { onClose: () => void }) { + return ( +
+
e.stopPropagation()} + > + {/* titlebar */} +
+
+
+ + README.md + + +
+ + {/* body */} +
+
+ {README_LINES.map((block, i) => { + if (block.tag === "h1") + return ( +

+ {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; + })} +
+
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Finder sidebar item */ +/* ------------------------------------------------------------------ */ + +function SidebarItem({ + icon, + label, + active, + accent, + onClick, +}: { + icon: React.ReactNode; + label: string; + active?: boolean; + accent?: string; + onClick?: () => void; +}) { + return ( + + ); +} + +/* ------------------------------------------------------------------ */ +/* Finder file grid item (folder) */ +/* ------------------------------------------------------------------ */ + +function GridFolder({ + name, + itemCount, + onClick, +}: { + name: string; + itemCount?: number; + onClick?: () => void; +}) { + return ( + + ); +} + +/* ------------------------------------------------------------------ */ +/* Finder file grid item (file) */ +/* ------------------------------------------------------------------ */ + +function GridFile({ + name, + meta, + onClick, +}: { + name: string; + meta?: string; + onClick?: () => void; +}) { + return ( + + ); +} + +/* ------------------------------------------------------------------ */ +/* Main page */ +/* ------------------------------------------------------------------ */ + +export default function LandingPage() { + const [readmeOpen, setReadmeOpen] = useState(false); + const [selectedSidebar, setSelectedSidebar] = useState("DAV"); + + return ( +
+ {/* ---- header ---- */} +
+
+ + Sign in + + + + +
+
+ + {/* ---- finder ---- */} +
+
+ {/* titlebar */} +
+
+ + + +
+ +
+ DAV +
+ + {/* forward/back placeholders */} +
+ + + + + + +
+
+ + {/* content area */} +
+ {/* ---- sidebar ---- */} +
+ {/* Favorites */} +

+ Favorites +

+ } label="Recents" /> + } label="Shared" /> + } label="Library" /> + } label="Applications" /> + } label="Desktop" /> + } label="Downloads" /> + } label="Documents" /> + } label="GitHub" /> + + {/* Locations */} +

+ Locations +

+ } label="rathi" /> + } label="hari-macbook-pro" /> + } + label="DAV" + active={selectedSidebar === "DAV"} + accent="text-[#65a2f8]" + onClick={() => setSelectedSidebar("DAV")} + /> + } label="AirDrop" /> +
+ + {/* ---- file grid ---- */} +
+ {/* toolbar */} +
+
+ + DAV + / + exports +
+
+ + + + + + + + + +
+
+ + {/* files */} +
+
+ + + + + + setReadmeOpen(true)} + /> +
+
+ + {/* statusbar */} +
+ 5 folders, 1 file + 847 GB available +
+
+
+
+
+ + {/* ---- readme modal ---- */} + {readmeOpen && setReadmeOpen(false)} />} +
+ ); +} diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index 73eb23d..23d117e 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; +import Link from "next/link"; import { login, register, ApiError } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { @@ -45,108 +46,116 @@ export default function LoginPage() { return (
- - -

- betterNAS -

- - {mode === "login" ? "Sign in" : "Create account"} - - - {mode === "login" - ? "Sign in to your betterNAS control plane with the same credentials you use for the node agent and Finder." - : "Create your betterNAS account. You will use the same username and password for the web app, node agent, and Finder."} - -
- -
-
- - setUsername(e.target.value)} - className="rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none ring-ring focus:ring-2" - placeholder="admin" - /> -
+
+ + + + + -
- - setPassword(e.target.value)} - className="rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none ring-ring focus:ring-2" - /> -
+ + + + {mode === "login" ? "Sign in" : "Create account"} + + + {mode === "login" + ? "Use the same credentials as your node agent and Finder." + : "This account works across the web UI, node agent, and Finder."} + + + + +
+ + setUsername(e.target.value)} + className="rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none ring-ring focus:ring-2" + placeholder="admin" + /> +
- {error &&

{error}

} +
+ + setPassword(e.target.value)} + className="rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none ring-ring focus:ring-2" + /> +
- + {error &&

{error}

} -

- {mode === "login" ? ( - <> - No account?{" "} - - - ) : ( - <> - Already have an account?{" "} - - - )} -

- -
-
+ + +

+ {mode === "login" ? ( + <> + No account?{" "} + + + ) : ( + <> + Already have an account?{" "} + + + )} +

+ + + +
); }