Make the web app consume the live mount contract

Add the first control-plane UI over the verified backend seam so exports,
issued DAV credentials, and Finder instructions can be exercised from the
running stack.
This commit is contained in:
Harivansh Rathi 2026-04-01 17:56:05 +00:00
parent b5f8ea9c52
commit 87de69520c
8 changed files with 773 additions and 51 deletions

View file

@ -1,7 +1,9 @@
.git .git
.agents
.next .next
.turbo .turbo
coverage coverage
dist dist
node_modules node_modules
apps/web/.next apps/web/.next
skills-lock.json

View file

@ -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 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. 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.

View file

@ -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;
}

View file

@ -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 (
<div className={styles.field}>
<div className={styles.header}>
<span className={styles.label}>{label}</span>
<Button
className={styles.button}
onClick={async () => {
await navigator.clipboard.writeText(value);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
}}
>
{copied ? "Copied" : "Copy"}
</Button>
</div>
<Code className={styles.value}>{value}</Code>
</div>
);
}

View file

@ -12,7 +12,7 @@
} }
.hero { .hero {
max-width: 860px; max-width: 1200px;
margin: 0 auto 32px; margin: 0 auto 32px;
padding: 32px; padding: 32px;
border-radius: 28px; border-radius: 28px;
@ -42,32 +42,252 @@
line-height: 1.7; line-height: 1.7;
} }
.grid { .heroMeta {
max-width: 860px; 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; margin: 0 auto;
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr);
gap: 18px; gap: 22px;
} }
.card { .panel {
display: block; display: grid;
padding: 22px; align-content: start;
border-radius: 20px; gap: 18px;
padding: 24px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.88); 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); border: 1px solid rgba(18, 48, 67, 0.08);
box-shadow: 0 12px 30px rgba(18, 48, 67, 0.08); background: #fbfdfd;
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
transition:
transform 140ms ease,
box-shadow 140ms ease,
border-color 140ms ease;
} }
.card h2 { .exportCard:hover,
margin: 0 0 10px; .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; 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) { @media (max-width: 640px) {
@ -75,7 +295,19 @@
padding-inline: 16px; padding-inline: 16px;
} }
.hero { .hero,
.panel {
padding: 24px; padding: 24px;
} }
.panelHeader,
.mountStatus,
.exportCardTop {
flex-direction: column;
}
.exportFacts,
.mountMeta {
grid-template-columns: 1fr;
}
} }

View file

@ -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"; import styles from "./page.module.css";
const lanes = [ export const dynamic = "force-dynamic";
{
title: "NAS node", interface PageProps {
body: "Runs on the storage machine. Exposes WebDAV, reports exports, and stays close to the bytes.", searchParams: Promise<{
}, exportId?: string | string[];
{ }>;
title: "Control plane", }
body: "Owns users, devices, nodes, grants, mount profiles, and cloud profiles.",
}, export default async function Home({ searchParams }: PageProps) {
{ const resolvedSearchParams = await searchParams;
title: "Local device", const selectedExportId = readSearchParam(resolvedSearchParams.exportId);
body: "Consumes mount profiles and uses Finder WebDAV flows before we ship a helper app.", const controlPlaneConfig = await getControlPlaneConfig();
},
{ let exports: StorageExport[] = [];
title: "Cloud layer", let mountProfile: MountProfile | null = null;
body: "Keeps Nextcloud optional and thin for browser, mobile, and sharing flows.", 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 ( return (
<main className={styles.page}> <main className={styles.page}>
<section className={styles.hero}> <section className={styles.hero}>
<p className={styles.eyebrow}>betterNAS monorepo</p> <p className={styles.eyebrow}>betterNAS control plane</p>
<h1 className={styles.title}> <h1 className={styles.title}>
Contract-first scaffold for NAS mounts and cloud mode. Mount exports from the live control-plane.
</h1> </h1>
<p className={styles.copy}> <p className={styles.copy}>
The repo is organized so each system part can be built in parallel This page reads the running control-plane, lists available exports,
without inventing new interfaces. The source of truth is the root and issues Finder-friendly WebDAV mount credentials for the export you
contract plus the shared contracts package. select.
</p> </p>
<dl className={styles.heroMeta}>
<div>
<dt>Control-plane URL</dt>
<dd>{controlPlaneConfig.baseUrl ?? "Not configured"}</dd>
</div>
<div>
<dt>Auth mode</dt>
<dd>
{controlPlaneConfig.clientToken === null
? "Missing client token"
: "Server-side bearer token"}
</dd>
</div>
<div>
<dt>Exports discovered</dt>
<dd>{exports.length}</dd>
</div>
</dl>
</section> </section>
<section className={styles.grid}> <section className={styles.layout}>
{lanes.map((lane) => ( <section className={styles.panel}>
<Card <div className={styles.panelHeader}>
key={lane.title} <div>
className={styles.card} <p className={styles.sectionEyebrow}>Exports</p>
title={lane.title} <h2 className={styles.sectionTitle}>
href="/#" Registered storage exports
> </h2>
{lane.body} </div>
</Card> <span className={styles.sectionMeta}>
))} {exports.length === 1 ? "1 export" : `${exports.length} exports`}
</span>
</div>
{feedback !== null ? (
<div className={styles.notice}>{feedback}</div>
) : null}
{exports.length === 0 ? (
<div className={styles.emptyState}>
No exports are registered yet. Start the node agent and verify the
control-plane connection first.
</div>
) : (
<div className={styles.exportList}>
{exports.map((storageExport) => {
const isSelected = storageExport.id === selectedExportId;
return (
<a
key={storageExport.id}
className={
isSelected ? styles.exportCardSelected : styles.exportCard
}
href={`/?exportId=${encodeURIComponent(storageExport.id)}`}
>
<div className={styles.exportCardTop}>
<div>
<h3 className={styles.exportTitle}>
{storageExport.label}
</h3>
<p className={styles.exportId}>{storageExport.id}</p>
</div>
<span className={styles.exportProtocol}>
{storageExport.protocols.join(", ")}
</span>
</div>
<dl className={styles.exportFacts}>
<div>
<dt>Node</dt>
<dd>{storageExport.nasNodeId}</dd>
</div>
<div>
<dt>Mount path</dt>
<dd>{storageExport.mountPath ?? "/dav/"}</dd>
</div>
<div className={styles.exportFactWide}>
<dt>Export path</dt>
<dd>{storageExport.path}</dd>
</div>
</dl>
</a>
);
})}
</div>
)}
</section>
<aside className={styles.panel}>
<div className={styles.panelHeader}>
<div>
<p className={styles.sectionEyebrow}>Mount instructions</p>
<h2 className={styles.sectionTitle}>
{selectedExport === null
? "Select an export"
: `Mount ${selectedExport.label}`}
</h2>
</div>
</div>
{mountProfile === null ? (
<div className={styles.emptyState}>
Pick an export to issue a WebDAV mount profile and reveal the URL,
username, password, and expiry.
</div>
) : (
<div className={styles.mountPanel}>
<div className={styles.mountStatus}>
<div>
<p className={styles.sectionEyebrow}>Issued profile</p>
<h3 className={styles.mountTitle}>
{mountProfile.displayName}
</h3>
</div>
<span className={styles.mountBadge}>
{mountProfile.readonly ? "Read-only" : "Read-write"}
</span>
</div>
<div className={styles.copyFields}>
<CopyField label="Mount URL" value={mountProfile.mountUrl} />
<CopyField
label="Username"
value={mountProfile.credential.username}
/>
<CopyField
label="Password"
value={mountProfile.credential.password}
/>
</div>
<dl className={styles.mountMeta}>
<div>
<dt>Credential mode</dt>
<dd>{mountProfile.credential.mode}</dd>
</div>
<div>
<dt>Expires at</dt>
<dd>{mountProfile.credential.expiresAt}</dd>
</div>
</dl>
<div className={styles.instructions}>
<h3 className={styles.instructionsTitle}>Finder steps</h3>
<ol className={styles.instructionsList}>
<li>Open Finder and choose Go, then Connect to Server.</li>
<li>
Paste{" "}
<Code className={styles.inlineCode}>
{mountProfile.mountUrl}
</Code>
.
</li>
<li>When prompted, use the issued username and password.</li>
<li>
Save credentials in Keychain only if the expiry fits your
workflow.
</li>
</ol>
</div>
</div>
)}
</aside>
</section> </section>
</main> </main>
); );
} }
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;
}

View file

@ -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<Record<string, string>> => {
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<string, string> {
return raw.split(/\r?\n/).reduce<Record<string, string>>((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<ControlPlaneConfig> {
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<StorageExport[]> {
return controlPlaneRequest<StorageExport[]>("/api/v1/exports");
}
export async function issueMountProfile(
exportId: string,
): Promise<MountProfile> {
return controlPlaneRequest<MountProfile>("/api/v1/mount-profiles/issue", {
method: "POST",
body: JSON.stringify({ exportId }),
});
}
async function controlPlaneRequest<T>(
requestPath: string,
init?: RequestInit,
): Promise<T> {
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 | undefined>
): 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(/\/+$/, "");
}

View file

@ -1,6 +1,11 @@
{ {
"$schema": "https://turborepo.dev/schema.json", "$schema": "https://turborepo.dev/schema.json",
"ui": "tui", "ui": "tui",
"globalEnv": [
"BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN",
"BETTERNAS_CONTROL_PLANE_PORT",
"BETTERNAS_CONTROL_PLANE_URL"
],
"tasks": { "tasks": {
"build": { "build": {
"dependsOn": ["^build"], "dependsOn": ["^build"],