diff --git a/.prettierignore b/.prettierignore
index b658a0a..9742925 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,7 +1,9 @@
.git
+.agents
.next
.turbo
coverage
dist
node_modules
apps/web/.next
+skills-lock.json
diff --git a/apps/web/README.md b/apps/web/README.md
index 28d6986..b8acc89 100644
--- a/apps/web/README.md
+++ b/apps/web/README.md
@@ -11,3 +11,11 @@ Use this app for:
Do not move the product system of record into this app. It should stay a UI and
thin BFF layer over the Go control plane.
+
+The current page reads control-plane config from:
+
+- `BETTERNAS_CONTROL_PLANE_URL` and `BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN`, or
+- the repo-local `.env.agent` file
+
+That keeps the page aligned with the running self-hosted stack during local
+development.
diff --git a/apps/web/app/copy-field.module.css b/apps/web/app/copy-field.module.css
new file mode 100644
index 0000000..99101e8
--- /dev/null
+++ b/apps/web/app/copy-field.module.css
@@ -0,0 +1,42 @@
+.field {
+ display: grid;
+ gap: 10px;
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.label {
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: #527082;
+}
+
+.button {
+ border: 0;
+ border-radius: 999px;
+ padding: 9px 12px;
+ background: #123043;
+ color: #f7fbfc;
+ font: inherit;
+ font-size: 0.86rem;
+ cursor: pointer;
+}
+
+.value {
+ display: block;
+ overflow-wrap: anywhere;
+ padding: 12px 14px;
+ border-radius: 14px;
+ background: #f3f8f7;
+ border: 1px solid rgba(18, 48, 67, 0.08);
+ color: #123043;
+ font-size: 0.92rem;
+ line-height: 1.5;
+}
diff --git a/apps/web/app/copy-field.tsx b/apps/web/app/copy-field.tsx
new file mode 100644
index 0000000..2f621ac
--- /dev/null
+++ b/apps/web/app/copy-field.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import { Button } from "@betternas/ui/button";
+import { Code } from "@betternas/ui/code";
+import { useState } from "react";
+import styles from "./copy-field.module.css";
+
+export function CopyField({ label, value }: { label: string; value: string }) {
+ const [copied, setCopied] = useState(false);
+
+ return (
+
+
+ {label}
+ {
+ await navigator.clipboard.writeText(value);
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1500);
+ }}
+ >
+ {copied ? "Copied" : "Copy"}
+
+
+
{value}
+
+ );
+}
diff --git a/apps/web/app/page.module.css b/apps/web/app/page.module.css
index 3f2ae04..f0d38e3 100644
--- a/apps/web/app/page.module.css
+++ b/apps/web/app/page.module.css
@@ -12,7 +12,7 @@
}
.hero {
- max-width: 860px;
+ max-width: 1200px;
margin: 0 auto 32px;
padding: 32px;
border-radius: 28px;
@@ -42,32 +42,252 @@
line-height: 1.7;
}
-.grid {
- max-width: 860px;
+.heroMeta {
+ margin-top: 22px;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 14px;
+}
+
+.heroMeta div {
+ padding: 16px;
+ border-radius: 18px;
+ background: rgba(247, 251, 252, 0.12);
+ border: 1px solid rgba(247, 251, 252, 0.14);
+}
+
+.heroMeta dt {
+ margin: 0 0 8px;
+ font-size: 0.76rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ opacity: 0.76;
+}
+
+.heroMeta dd {
+ margin: 0;
+ font-size: 0.98rem;
+ line-height: 1.5;
+}
+
+.layout {
+ max-width: 1200px;
margin: 0 auto;
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
- gap: 18px;
+ grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr);
+ gap: 22px;
}
-.card {
- display: block;
- padding: 22px;
- border-radius: 20px;
+.panel {
+ display: grid;
+ align-content: start;
+ gap: 18px;
+ padding: 24px;
+ border-radius: 28px;
background: rgba(255, 255, 255, 0.88);
+ border: 1px solid rgba(18, 48, 67, 0.1);
+ box-shadow: 0 18px 50px rgba(18, 48, 67, 0.1);
+}
+
+.panelHeader {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.sectionEyebrow {
+ margin: 0;
+ font-size: 0.76rem;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: #527082;
+}
+
+.sectionTitle {
+ margin: 8px 0 0;
+ font-size: clamp(1.4rem, 2vw, 1.85rem);
+ color: #10212d;
+}
+
+.sectionMeta {
+ align-self: center;
+ padding: 8px 12px;
+ border-radius: 999px;
+ background: #eff6f5;
+ color: #26485b;
+ font-size: 0.88rem;
+}
+
+.notice {
+ padding: 14px 16px;
+ border-radius: 18px;
+ background: #fff1de;
+ color: #7a4a12;
+ line-height: 1.6;
+}
+
+.emptyState {
+ padding: 22px;
+ border-radius: 22px;
+ background: #f3f8f7;
+ border: 1px dashed rgba(18, 48, 67, 0.16);
+ color: #395667;
+ line-height: 1.7;
+}
+
+.exportList {
+ display: grid;
+ gap: 14px;
+}
+
+.exportCard,
+.exportCardSelected {
+ display: grid;
+ gap: 16px;
+ padding: 20px;
+ border-radius: 22px;
border: 1px solid rgba(18, 48, 67, 0.08);
- box-shadow: 0 12px 30px rgba(18, 48, 67, 0.08);
+ background: #fbfdfd;
color: inherit;
text-decoration: none;
+ transition:
+ transform 140ms ease,
+ box-shadow 140ms ease,
+ border-color 140ms ease;
}
-.card h2 {
- margin: 0 0 10px;
+.exportCard:hover,
+.exportCardSelected:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 12px 26px rgba(18, 48, 67, 0.08);
}
-.card p {
+.exportCardSelected {
+ border-color: rgba(29, 84, 102, 0.28);
+ background: linear-gradient(180deg, #f4fbfa 0%, #eef8f6 100%);
+}
+
+.exportCardTop {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.exportTitle {
margin: 0;
- line-height: 1.6;
+ font-size: 1.1rem;
+ color: #123043;
+}
+
+.exportId {
+ margin: 6px 0 0;
+ color: #527082;
+ font-size: 0.92rem;
+}
+
+.exportProtocol {
+ flex-shrink: 0;
+ padding: 8px 10px;
+ border-radius: 999px;
+ background: #eaf3f1;
+ color: #1d5466;
+ font-size: 0.82rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.exportFacts,
+.mountMeta {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 14px;
+}
+
+.exportFactWide {
+ grid-column: 1 / -1;
+}
+
+.exportFacts dt,
+.mountMeta dt {
+ margin: 0 0 6px;
+ font-size: 0.76rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: #5d7888;
+}
+
+.exportFacts dd,
+.mountMeta dd {
+ margin: 0;
+ overflow-wrap: anywhere;
+ color: #123043;
+ line-height: 1.55;
+}
+
+.mountPanel {
+ display: grid;
+ gap: 20px;
+}
+
+.mountStatus {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.mountTitle {
+ margin: 8px 0 0;
+ font-size: 1.3rem;
+ color: #123043;
+}
+
+.mountBadge {
+ flex-shrink: 0;
+ padding: 8px 12px;
+ border-radius: 999px;
+ background: #123043;
+ color: #f7fbfc;
+ font-size: 0.84rem;
+}
+
+.copyFields {
+ display: grid;
+ gap: 14px;
+}
+
+.instructions {
+ padding: 18px 20px;
+ border-radius: 22px;
+ background: #f4f8fa;
+ border: 1px solid rgba(18, 48, 67, 0.08);
+}
+
+.instructionsTitle {
+ margin: 0 0 12px;
+ color: #123043;
+ font-size: 1rem;
+}
+
+.instructionsList {
+ padding-left: 20px;
+ color: #304e60;
+ line-height: 1.7;
+}
+
+.inlineCode {
+ padding: 2px 6px;
+ border-radius: 8px;
+ background: rgba(18, 48, 67, 0.08);
+ color: #123043;
+}
+
+@media (max-width: 980px) {
+ .layout {
+ grid-template-columns: 1fr;
+ }
}
@media (max-width: 640px) {
@@ -75,7 +295,19 @@
padding-inline: 16px;
}
- .hero {
+ .hero,
+ .panel {
padding: 24px;
}
+
+ .panelHeader,
+ .mountStatus,
+ .exportCardTop {
+ flex-direction: column;
+ }
+
+ .exportFacts,
+ .mountMeta {
+ grid-template-columns: 1fr;
+ }
}
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index b474e5b..1cceabb 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -1,52 +1,254 @@
-import { Card } from "@betternas/ui/card";
+import { Code } from "@betternas/ui/code";
+import { CopyField } from "./copy-field";
+import {
+ ControlPlaneConfigurationError,
+ ControlPlaneRequestError,
+ getControlPlaneConfig,
+ issueMountProfile,
+ listExports,
+ type MountProfile,
+ type StorageExport,
+} from "../lib/control-plane";
import styles from "./page.module.css";
-const lanes = [
- {
- title: "NAS node",
- body: "Runs on the storage machine. Exposes WebDAV, reports exports, and stays close to the bytes.",
- },
- {
- title: "Control plane",
- body: "Owns users, devices, nodes, grants, mount profiles, and cloud profiles.",
- },
- {
- title: "Local device",
- body: "Consumes mount profiles and uses Finder WebDAV flows before we ship a helper app.",
- },
- {
- title: "Cloud layer",
- body: "Keeps Nextcloud optional and thin for browser, mobile, and sharing flows.",
- },
-];
+export const dynamic = "force-dynamic";
+
+interface PageProps {
+ searchParams: Promise<{
+ exportId?: string | string[];
+ }>;
+}
+
+export default async function Home({ searchParams }: PageProps) {
+ const resolvedSearchParams = await searchParams;
+ const selectedExportId = readSearchParam(resolvedSearchParams.exportId);
+ const controlPlaneConfig = await getControlPlaneConfig();
+
+ let exports: StorageExport[] = [];
+ let mountProfile: MountProfile | null = null;
+ let feedback: string | null = null;
+
+ try {
+ exports = await listExports();
+
+ if (selectedExportId !== null) {
+ if (
+ exports.some((storageExport) => storageExport.id === selectedExportId)
+ ) {
+ mountProfile = await issueMountProfile(selectedExportId);
+ } else {
+ feedback = `Export ${selectedExportId} was not found in the current control-plane response.`;
+ }
+ }
+ } catch (error) {
+ if (
+ error instanceof ControlPlaneConfigurationError ||
+ error instanceof ControlPlaneRequestError
+ ) {
+ feedback = error.message;
+ } else {
+ throw error;
+ }
+ }
+
+ const selectedExport =
+ selectedExportId === null
+ ? null
+ : (exports.find(
+ (storageExport) => storageExport.id === selectedExportId,
+ ) ?? null);
-export default function Home() {
return (
- betterNAS monorepo
+ betterNAS control plane
- Contract-first scaffold for NAS mounts and cloud mode.
+ Mount exports from the live control-plane.
- The repo is organized so each system part can be built in parallel
- without inventing new interfaces. The source of truth is the root
- contract plus the shared contracts package.
+ This page reads the running control-plane, lists available exports,
+ and issues Finder-friendly WebDAV mount credentials for the export you
+ select.
+
+
+
Control-plane URL
+ {controlPlaneConfig.baseUrl ?? "Not configured"}
+
+
+
Auth mode
+
+ {controlPlaneConfig.clientToken === null
+ ? "Missing client token"
+ : "Server-side bearer token"}
+
+
+
+
Exports discovered
+ {exports.length}
+
+
-
- {lanes.map((lane) => (
-
- {lane.body}
-
- ))}
+
+
+
+
+
Exports
+
+ Registered storage exports
+
+
+
+ {exports.length === 1 ? "1 export" : `${exports.length} exports`}
+
+
+
+ {feedback !== null ? (
+ {feedback}
+ ) : null}
+
+ {exports.length === 0 ? (
+
+ No exports are registered yet. Start the node agent and verify the
+ control-plane connection first.
+
+ ) : (
+
+ )}
+
+
+
+
+
+
Mount instructions
+
+ {selectedExport === null
+ ? "Select an export"
+ : `Mount ${selectedExport.label}`}
+
+
+
+
+ {mountProfile === null ? (
+
+ Pick an export to issue a WebDAV mount profile and reveal the URL,
+ username, password, and expiry.
+
+ ) : (
+
+
+
+
Issued profile
+
+ {mountProfile.displayName}
+
+
+
+ {mountProfile.readonly ? "Read-only" : "Read-write"}
+
+
+
+
+
+
+
+
+
+
+
+
Credential mode
+ {mountProfile.credential.mode}
+
+
+
Expires at
+ {mountProfile.credential.expiresAt}
+
+
+
+
+
Finder steps
+
+ Open Finder and choose Go, then Connect to Server.
+
+ Paste{" "}
+
+ {mountProfile.mountUrl}
+
+ .
+
+ When prompted, use the issued username and password.
+
+ Save credentials in Keychain only if the expiry fits your
+ workflow.
+
+
+
+
+ )}
+
);
}
+
+function readSearchParam(value: string | string[] | undefined): string | null {
+ if (typeof value === "string" && value.trim() !== "") {
+ return value.trim();
+ }
+ if (Array.isArray(value)) {
+ const firstValue = value.find(
+ (candidateValue) => candidateValue.trim() !== "",
+ );
+ return firstValue?.trim() ?? null;
+ }
+
+ return null;
+}
diff --git a/apps/web/lib/control-plane.ts b/apps/web/lib/control-plane.ts
new file mode 100644
index 0000000..e5fe97c
--- /dev/null
+++ b/apps/web/lib/control-plane.ts
@@ -0,0 +1,202 @@
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+import { cache } from "react";
+
+export interface StorageExport {
+ id: string;
+ nasNodeId: string;
+ label: string;
+ path: string;
+ mountPath?: string;
+ protocols: string[];
+ capacityBytes: number | null;
+ tags: string[];
+}
+
+export interface MountCredential {
+ mode: "basic-auth";
+ username: string;
+ password: string;
+ expiresAt: string;
+}
+
+export interface MountProfile {
+ id: string;
+ exportId: string;
+ protocol: "webdav";
+ displayName: string;
+ mountUrl: string;
+ readonly: boolean;
+ credential: MountCredential;
+}
+
+export interface ControlPlaneConfig {
+ baseUrl: string | null;
+ clientToken: string | null;
+}
+
+export class ControlPlaneConfigurationError extends Error {
+ constructor() {
+ super(
+ "Control-plane configuration is missing. Set BETTERNAS_CONTROL_PLANE_URL and BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN, or provide them through .env.agent.",
+ );
+ }
+}
+
+export class ControlPlaneRequestError extends Error {
+ constructor(message: string) {
+ super(message);
+ }
+}
+
+const readAgentEnvFile = cache(async (): Promise> => {
+ const candidatePaths = [
+ path.resolve(/* turbopackIgnore: true */ process.cwd(), ".env.agent"),
+ path.resolve(/* turbopackIgnore: true */ process.cwd(), "../../.env.agent"),
+ ];
+
+ for (const candidatePath of candidatePaths) {
+ try {
+ const raw = await readFile(candidatePath, "utf8");
+ return parseEnvLikeFile(raw);
+ } catch (error) {
+ const nodeError = error as NodeJS.ErrnoException;
+ if (nodeError.code === "ENOENT") {
+ continue;
+ }
+
+ throw error;
+ }
+ }
+
+ return {};
+});
+
+function parseEnvLikeFile(raw: string): Record {
+ return raw.split(/\r?\n/).reduce>((env, line) => {
+ const trimmedLine = line.trim();
+ if (trimmedLine === "" || trimmedLine.startsWith("#")) {
+ return env;
+ }
+
+ const separatorIndex = trimmedLine.indexOf("=");
+ if (separatorIndex === -1) {
+ return env;
+ }
+
+ const key = trimmedLine.slice(0, separatorIndex).trim();
+ const value = trimmedLine.slice(separatorIndex + 1).trim();
+ env[key] = unwrapEnvValue(value);
+ return env;
+ }, {});
+}
+
+function unwrapEnvValue(value: string): string {
+ if (
+ (value.startsWith('"') && value.endsWith('"')) ||
+ (value.startsWith("'") && value.endsWith("'"))
+ ) {
+ return value.slice(1, -1);
+ }
+
+ return value;
+}
+
+export async function getControlPlaneConfig(): Promise {
+ const agentEnv = await readAgentEnvFile();
+ const cloneName = firstDefinedValue(
+ process.env.BETTERNAS_CLONE_NAME,
+ agentEnv.BETTERNAS_CLONE_NAME,
+ );
+
+ const clientToken = firstDefinedValue(
+ process.env.BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN,
+ agentEnv.BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN,
+ cloneName === null ? undefined : `${cloneName}-local-client-token`,
+ );
+
+ const directBaseUrl = firstDefinedValue(
+ process.env.BETTERNAS_CONTROL_PLANE_URL,
+ agentEnv.BETTERNAS_CONTROL_PLANE_URL,
+ );
+ if (directBaseUrl !== null) {
+ return {
+ baseUrl: trimTrailingSlash(directBaseUrl),
+ clientToken,
+ };
+ }
+
+ const controlPlanePort = firstDefinedValue(
+ process.env.BETTERNAS_CONTROL_PLANE_PORT,
+ agentEnv.BETTERNAS_CONTROL_PLANE_PORT,
+ );
+
+ return {
+ baseUrl:
+ controlPlanePort === null
+ ? null
+ : trimTrailingSlash(`http://localhost:${controlPlanePort}`),
+ clientToken,
+ };
+}
+
+export async function listExports(): Promise {
+ return controlPlaneRequest("/api/v1/exports");
+}
+
+export async function issueMountProfile(
+ exportId: string,
+): Promise {
+ return controlPlaneRequest("/api/v1/mount-profiles/issue", {
+ method: "POST",
+ body: JSON.stringify({ exportId }),
+ });
+}
+
+async function controlPlaneRequest(
+ requestPath: string,
+ init?: RequestInit,
+): Promise {
+ const config = await getControlPlaneConfig();
+ if (config.baseUrl === null || config.clientToken === null) {
+ throw new ControlPlaneConfigurationError();
+ }
+
+ const headers = new Headers(init?.headers);
+ headers.set("Authorization", `Bearer ${config.clientToken}`);
+ if (init?.body !== undefined) {
+ headers.set("Content-Type", "application/json");
+ }
+
+ const response = await fetch(`${config.baseUrl}${requestPath}`, {
+ ...init,
+ headers,
+ cache: "no-store",
+ });
+
+ if (!response.ok) {
+ const responseBody = await response.text();
+ throw new ControlPlaneRequestError(
+ `Control-plane request failed for ${requestPath} with status ${response.status}: ${responseBody || response.statusText}`,
+ );
+ }
+
+ return (await response.json()) as T;
+}
+
+function firstDefinedValue(
+ ...values: Array
+): string | null {
+ for (const value of values) {
+ const trimmedValue = value?.trim();
+ if (trimmedValue) {
+ return trimmedValue;
+ }
+ }
+
+ return null;
+}
+
+function trimTrailingSlash(value: string): string {
+ return value.replace(/\/+$/, "");
+}
diff --git a/turbo.json b/turbo.json
index a223f00..fe81974 100644
--- a/turbo.json
+++ b/turbo.json
@@ -1,6 +1,11 @@
{
"$schema": "https://turborepo.dev/schema.json",
"ui": "tui",
+ "globalEnv": [
+ "BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN",
+ "BETTERNAS_CONTROL_PLANE_PORT",
+ "BETTERNAS_CONTROL_PLANE_URL"
+ ],
"tasks": {
"build": {
"dependsOn": ["^build"],