frontend deploy (#13)

This commit is contained in:
Hari 2026-04-01 19:26:28 -04:00 committed by GitHub
parent c499e46a4d
commit ca5014750b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 417 additions and 76 deletions

1
.gitignore vendored
View file

@ -8,3 +8,4 @@ coverage/
apps/web/.next/
.env.agent
.state/
.vercel

154
apps/web/app/login/page.tsx Normal file
View file

@ -0,0 +1,154 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { login, register, ApiError } from "@/lib/api";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default function LoginPage() {
const router = useRouter();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [mode, setMode] = useState<"login" | "register">("login");
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
setError(null);
setLoading(true);
try {
if (mode === "login") {
await login(username, password);
} else {
await register(username, password);
}
router.push("/");
} catch (err) {
if (err instanceof ApiError) {
setError(err.message);
} else {
setError("Something went wrong");
}
} finally {
setLoading(false);
}
}
return (
<main className="flex min-h-screen items-center justify-center bg-background px-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<p className="text-xs font-medium uppercase tracking-widest text-muted-foreground">
betterNAS
</p>
<CardTitle className="text-xl">
{mode === "login" ? "Sign in" : "Create account"}
</CardTitle>
<CardDescription>
{mode === "login"
? "Sign in to your betterNAS control plane."
: "Create your betterNAS account."}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label
htmlFor="username"
className="text-sm font-medium text-foreground"
>
Username
</label>
<input
id="username"
type="text"
autoComplete="username"
required
minLength={3}
maxLength={64}
value={username}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="password"
className="text-sm font-medium text-foreground"
>
Password
</label>
<input
id="password"
type="password"
autoComplete={
mode === "login" ? "current-password" : "new-password"
}
required
minLength={8}
value={password}
onChange={(e) => 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"
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<Button type="submit" disabled={loading} className="w-full">
{loading
? "..."
: mode === "login"
? "Sign in"
: "Create account"}
</Button>
<p className="text-center text-sm text-muted-foreground">
{mode === "login" ? (
<>
No account?{" "}
<button
type="button"
onClick={() => {
setMode("register");
setError(null);
}}
className="text-foreground underline underline-offset-2"
>
Create one
</button>
</>
) : (
<>
Already have an account?{" "}
<button
type="button"
onClick={() => {
setMode("login");
setError(null);
}}
className="text-foreground underline underline-offset-2"
>
Sign in
</button>
</>
)}
</p>
</form>
</CardContent>
</Card>
</main>
);
}

View file

@ -1,18 +1,25 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import {
Globe,
HardDrives,
LinkSimple,
SignOut,
Warning,
} from "@phosphor-icons/react/dist/ssr";
} from "@phosphor-icons/react";
import {
ControlPlaneConfigurationError,
ControlPlaneRequestError,
getControlPlaneConfig,
issueMountProfile,
isAuthenticated,
listExports,
type MountProfile,
issueMountProfile,
logout,
getMe,
type StorageExport,
} from "@/lib/control-plane";
type MountProfile,
type User,
ApiError,
} from "@/lib/api";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import {
@ -23,79 +30,109 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { CopyField } from "./copy-field";
export const dynamic = "force-dynamic";
export default function Home() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [exports, setExports] = useState<StorageExport[]>([]);
const [selectedExportId, setSelectedExportId] = useState<string | null>(null);
const [mountProfile, setMountProfile] = useState<MountProfile | null>(null);
const [feedback, setFeedback] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
interface PageProps {
searchParams: Promise<{ exportId?: string | string[] }>;
}
useEffect(() => {
if (!isAuthenticated()) {
router.replace("/login");
return;
}
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((e) => e.id === selectedExportId)) {
mountProfile = await issueMountProfile(selectedExportId);
} else {
feedback = `Export "${selectedExportId}" was not found.`;
async function load() {
try {
const [me, exps] = await Promise.all([getMe(), listExports()]);
setUser(me);
setExports(exps);
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
router.replace("/login");
return;
}
setFeedback(err instanceof Error ? err.message : "Failed to load");
} finally {
setLoading(false);
}
}
} catch (error) {
if (
error instanceof ControlPlaneConfigurationError ||
error instanceof ControlPlaneRequestError
) {
feedback = error.message;
} else {
throw error;
load();
}, [router]);
async function handleSelectExport(exportId: string) {
setSelectedExportId(exportId);
setMountProfile(null);
setFeedback(null);
try {
const profile = await issueMountProfile(exportId);
setMountProfile(profile);
} catch (err) {
setFeedback(err instanceof Error ? err.message : "Failed to issue mount profile");
}
}
const selectedExport =
selectedExportId === null
? null
: (exports.find((e) => e.id === selectedExportId) ?? null);
async function handleLogout() {
await logout();
router.replace("/login");
}
if (loading) {
return (
<main className="flex min-h-screen items-center justify-center bg-background">
<p className="text-sm text-muted-foreground">Loading...</p>
</main>
);
}
const selectedExport = selectedExportId
? exports.find((e) => e.id === selectedExportId) ?? null
: null;
return (
<main className="min-h-screen bg-background">
<div className="mx-auto flex max-w-6xl flex-col gap-8 px-4 py-8 sm:px-6">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<p className="text-xs font-medium uppercase tracking-widest text-muted-foreground">
betterNAS
</p>
<h1 className="font-heading text-2xl font-semibold tracking-tight">
Control Plane
</h1>
<div className="flex items-start justify-between">
<div className="flex flex-col gap-1">
<p className="text-xs font-medium uppercase tracking-widest text-muted-foreground">
betterNAS
</p>
<h1 className="font-heading text-2xl font-semibold tracking-tight">
Control Plane
</h1>
</div>
{user && (
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
{user.username}
</span>
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
>
<SignOut className="mr-1 size-4" />
Sign out
</Button>
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">
<Globe data-icon="inline-start" />
{controlPlaneConfig.baseUrl ?? "Not configured"}
</Badge>
<Badge
variant={
controlPlaneConfig.clientToken !== null
? "secondary"
: "destructive"
}
>
{controlPlaneConfig.clientToken !== null
? "Bearer auth"
: "No token"}
{process.env.NEXT_PUBLIC_BETTERNAS_API_URL || "local"}
</Badge>
<Badge variant="secondary">
{exports.length === 1 ? "1 export" : `${exports.length} exports`}
@ -106,7 +143,7 @@ export default async function Home({ searchParams }: PageProps) {
{feedback !== null && (
<Alert variant="destructive">
<Warning />
<AlertTitle>Configuration error</AlertTitle>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{feedback}</AlertDescription>
</Alert>
)}
@ -141,11 +178,11 @@ export default async function Home({ searchParams }: PageProps) {
const isSelected = storageExport.id === selectedExportId;
return (
<a
<button
key={storageExport.id}
href={`/?exportId=${encodeURIComponent(storageExport.id)}`}
onClick={() => handleSelectExport(storageExport.id)}
className={cn(
"flex flex-col gap-3 rounded-2xl border p-4 text-sm transition-colors",
"flex flex-col gap-3 rounded-2xl border p-4 text-left text-sm transition-colors",
isSelected
? "border-primary/20 bg-primary/5"
: "border-border hover:bg-muted/50",
@ -191,7 +228,7 @@ export default async function Home({ searchParams }: PageProps) {
</dd>
</div>
</dl>
</a>
</button>
);
})}
</div>
@ -278,7 +315,7 @@ export default async function Home({ searchParams }: PageProps) {
<ol className="flex flex-col gap-2">
{[
"Open Finder and choose Go, then Connect to Server.",
`Paste the mount URL into the server address field.`,
"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.",
].map((step, index) => (
@ -303,14 +340,3 @@ export default async function Home({ searchParams }: PageProps) {
</main>
);
}
function readSearchParam(value: string | string[] | undefined): string | null {
if (typeof value === "string" && value.trim() !== "") {
return value.trim();
}
if (Array.isArray(value)) {
const first = value.find((v) => v.trim() !== "");
return first?.trim() ?? null;
}
return null;
}

160
apps/web/lib/api.ts Normal file
View file

@ -0,0 +1,160 @@
const API_URL = process.env.NEXT_PUBLIC_BETTERNAS_API_URL || "";
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 User {
id: string;
username: string;
createdAt: string;
}
export interface AuthResponse {
token: string;
user: User;
}
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
function getToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("betternas_session");
}
export function setToken(token: string): void {
localStorage.setItem("betternas_session", token);
}
export function clearToken(): void {
localStorage.removeItem("betternas_session");
}
export function isAuthenticated(): boolean {
return getToken() !== null;
}
async function apiFetch<T>(
path: string,
options?: RequestInit,
): Promise<T> {
const headers: Record<string, string> = {};
const token = getToken();
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
if (options?.body) {
headers["Content-Type"] = "application/json";
}
const response = await fetch(`${API_URL}${path}`, {
...options,
headers: { ...headers, ...Object.fromEntries(new Headers(options?.headers).entries()) },
});
if (!response.ok) {
const text = await response.text();
throw new ApiError(text || response.statusText, response.status);
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
export async function register(
username: string,
password: string,
): Promise<AuthResponse> {
const res = await fetch(`${API_URL}/api/v1/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const text = await res.text();
throw new ApiError(text || res.statusText, res.status);
}
const data = (await res.json()) as AuthResponse;
setToken(data.token);
return data;
}
export async function login(
username: string,
password: string,
): Promise<AuthResponse> {
const res = await fetch(`${API_URL}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const text = await res.text();
throw new ApiError(text || res.statusText, res.status);
}
const data = (await res.json()) as AuthResponse;
setToken(data.token);
return data;
}
export async function logout(): Promise<void> {
try {
await apiFetch("/api/v1/auth/logout", { method: "POST" });
} finally {
clearToken();
}
}
export async function getMe(): Promise<User> {
return apiFetch<User>("/api/v1/auth/me");
}
export async function listExports(): Promise<StorageExport[]> {
return apiFetch<StorageExport[]>("/api/v1/exports");
}
export async function issueMountProfile(
exportId: string,
): Promise<MountProfile> {
return apiFetch<MountProfile>("/api/v1/mount-profiles/issue", {
method: "POST",
body: JSON.stringify({ exportId }),
});
}