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

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

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

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";
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 (
<main className={styles.page}>
<section className={styles.hero}>
<p className={styles.eyebrow}>betterNAS monorepo</p>
<p className={styles.eyebrow}>betterNAS control plane</p>
<h1 className={styles.title}>
Contract-first scaffold for NAS mounts and cloud mode.
Mount exports from the live control-plane.
</h1>
<p className={styles.copy}>
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.
</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 className={styles.grid}>
{lanes.map((lane) => (
<Card
key={lane.title}
className={styles.card}
title={lane.title}
href="/#"
>
{lane.body}
</Card>
))}
<section className={styles.layout}>
<section className={styles.panel}>
<div className={styles.panelHeader}>
<div>
<p className={styles.sectionEyebrow}>Exports</p>
<h2 className={styles.sectionTitle}>
Registered storage exports
</h2>
</div>
<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>
</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(/\/+$/, "");
}