user-owned DAVs (#14)

This commit is contained in:
Hari 2026-04-01 20:26:44 -04:00 committed by GitHub
parent ca5014750b
commit 1bbfb6647d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 732 additions and 777 deletions

View file

@ -5,17 +5,9 @@ Next.js control-plane UI for betterNAS.
Use this app for:
- admin and operator workflows
- node and export visibility
- issuing mount profiles
- user-scoped node and export visibility
- issuing mount profiles that reuse the same betterNAS account credentials
- later cloud-mode management
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

@ -55,8 +55,8 @@ export default function LoginPage() {
</CardTitle>
<CardDescription>
{mode === "login"
? "Sign in to your betterNAS control plane."
: "Create your betterNAS account."}
? "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."}
</CardDescription>
</CardHeader>
<CardContent>
@ -103,9 +103,7 @@ export default function LoginPage() {
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={loading} className="w-full">
{loading

View file

@ -78,7 +78,9 @@ export default function Home() {
const profile = await issueMountProfile(exportId);
setMountProfile(profile);
} catch (err) {
setFeedback(err instanceof Error ? err.message : "Failed to issue mount profile");
setFeedback(
err instanceof Error ? err.message : "Failed to issue mount profile",
);
}
}
@ -96,7 +98,7 @@ export default function Home() {
}
const selectedExport = selectedExportId
? exports.find((e) => e.id === selectedExportId) ?? null
? (exports.find((e) => e.id === selectedExportId) ?? null)
: null;
return (
@ -117,11 +119,7 @@ export default function Home() {
<span className="text-sm text-muted-foreground">
{user.username}
</span>
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
>
<Button variant="ghost" size="sm" onClick={handleLogout}>
<SignOut className="mr-1 size-4" />
Sign out
</Button>
@ -138,6 +136,25 @@ export default function Home() {
{exports.length === 1 ? "1 export" : `${exports.length} exports`}
</Badge>
</div>
{user && (
<Card>
<CardHeader>
<CardTitle>Node agent setup</CardTitle>
<CardDescription>
Run the node binary on the machine that owns the files with
the same account credentials you use here and in Finder.
</CardDescription>
</CardHeader>
<CardContent>
<pre className="overflow-x-auto rounded-xl border bg-muted/40 p-4 text-xs text-foreground">
<code>
{`BETTERNAS_USERNAME=${user.username} BETTERNAS_PASSWORD=... BETTERNAS_EXPORT_PATH=/path/to/export betternas-node`}
</code>
</pre>
</CardContent>
</Card>
)}
</div>
{feedback !== null && (
@ -245,8 +262,8 @@ export default function Home() {
</CardTitle>
<CardDescription>
{selectedExport !== null
? "Issued WebDAV credentials for Finder."
: "Select an export to issue mount credentials."}
? "WebDAV mount details for Finder."
: "Select an export to see the mount URL and account login details."}
</CardDescription>
</CardHeader>
<CardContent>
@ -254,7 +271,8 @@ export default function Home() {
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-10 text-center">
<LinkSimple size={32} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">
Pick an export to issue WebDAV credentials for Finder.
Pick an export to see the Finder mount URL and the username
to use with your betterNAS account password.
</p>
</div>
) : (
@ -281,10 +299,16 @@ export default function Home() {
label="Username"
value={mountProfile.credential.username}
/>
<CopyField
label="Password"
value={mountProfile.credential.password}
/>
<Alert>
<AlertTitle>
Use your betterNAS account password
</AlertTitle>
<AlertDescription>
Enter the same password you use to sign in to betterNAS
and run the node agent. v1 does not issue a separate
WebDAV password.
</AlertDescription>
</Alert>
</div>
<Separator />
@ -300,10 +324,10 @@ export default function Home() {
</div>
<div>
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
Expires
Password source
</dt>
<dd className="text-xs text-foreground">
{mountProfile.credential.expiresAt}
Your betterNAS account password
</dd>
</div>
</dl>
@ -316,8 +340,8 @@ export default function Home() {
{[
"Open Finder and choose Go, then Connect to Server.",
"Paste the mount URL into the server address field.",
"Enter the issued username and password when prompted.",
"Save to Keychain only if the credential expiry suits your workflow.",
"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) => (
<li
key={index}

View file

@ -64,10 +64,7 @@ export function isAuthenticated(): boolean {
return getToken() !== null;
}
async function apiFetch<T>(
path: string,
options?: RequestInit,
): Promise<T> {
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const headers: Record<string, string> = {};
const token = getToken();
if (token) {
@ -79,7 +76,10 @@ async function apiFetch<T>(
const response = await fetch(`${API_URL}${path}`, {
...options,
headers: { ...headers, ...Object.fromEntries(new Headers(options?.headers).entries()) },
headers: {
...headers,
...Object.fromEntries(new Headers(options?.headers).entries()),
},
});
if (!response.ok) {