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/ apps/web/.next/
.env.agent .env.agent
.state/ .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 { import {
Globe, Globe,
HardDrives, HardDrives,
LinkSimple, LinkSimple,
SignOut,
Warning, Warning,
} from "@phosphor-icons/react/dist/ssr"; } from "@phosphor-icons/react";
import { import {
ControlPlaneConfigurationError, isAuthenticated,
ControlPlaneRequestError,
getControlPlaneConfig,
issueMountProfile,
listExports, listExports,
type MountProfile, issueMountProfile,
logout,
getMe,
type StorageExport, type StorageExport,
} from "@/lib/control-plane"; type MountProfile,
type User,
ApiError,
} from "@/lib/api";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
@ -23,79 +30,109 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CopyField } from "./copy-field"; 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 { useEffect(() => {
searchParams: Promise<{ exportId?: string | string[] }>; if (!isAuthenticated()) {
} router.replace("/login");
return;
}
export default async function Home({ searchParams }: PageProps) { async function load() {
const resolvedSearchParams = await searchParams; try {
const selectedExportId = readSearchParam(resolvedSearchParams.exportId); const [me, exps] = await Promise.all([getMe(), listExports()]);
const controlPlaneConfig = await getControlPlaneConfig(); setUser(me);
setExports(exps);
let exports: StorageExport[] = []; } catch (err) {
let mountProfile: MountProfile | null = null; if (err instanceof ApiError && err.status === 401) {
let feedback: string | null = null; router.replace("/login");
return;
try { }
exports = await listExports(); setFeedback(err instanceof Error ? err.message : "Failed to load");
} finally {
if (selectedExportId !== null) { setLoading(false);
if (exports.some((e) => e.id === selectedExportId)) {
mountProfile = await issueMountProfile(selectedExportId);
} else {
feedback = `Export "${selectedExportId}" was not found.`;
} }
} }
} catch (error) {
if ( load();
error instanceof ControlPlaneConfigurationError || }, [router]);
error instanceof ControlPlaneRequestError
) { async function handleSelectExport(exportId: string) {
feedback = error.message; setSelectedExportId(exportId);
} else { setMountProfile(null);
throw error; 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 = async function handleLogout() {
selectedExportId === null await logout();
? null router.replace("/login");
: (exports.find((e) => e.id === selectedExportId) ?? null); }
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 ( return (
<main className="min-h-screen bg-background"> <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="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-4">
<div className="flex flex-col gap-1"> <div className="flex items-start justify-between">
<p className="text-xs font-medium uppercase tracking-widest text-muted-foreground"> <div className="flex flex-col gap-1">
betterNAS <p className="text-xs font-medium uppercase tracking-widest text-muted-foreground">
</p> betterNAS
<h1 className="font-heading text-2xl font-semibold tracking-tight"> </p>
Control Plane <h1 className="font-heading text-2xl font-semibold tracking-tight">
</h1> 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>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge variant="outline"> <Badge variant="outline">
<Globe data-icon="inline-start" /> <Globe data-icon="inline-start" />
{controlPlaneConfig.baseUrl ?? "Not configured"} {process.env.NEXT_PUBLIC_BETTERNAS_API_URL || "local"}
</Badge>
<Badge
variant={
controlPlaneConfig.clientToken !== null
? "secondary"
: "destructive"
}
>
{controlPlaneConfig.clientToken !== null
? "Bearer auth"
: "No token"}
</Badge> </Badge>
<Badge variant="secondary"> <Badge variant="secondary">
{exports.length === 1 ? "1 export" : `${exports.length} exports`} {exports.length === 1 ? "1 export" : `${exports.length} exports`}
@ -106,7 +143,7 @@ export default async function Home({ searchParams }: PageProps) {
{feedback !== null && ( {feedback !== null && (
<Alert variant="destructive"> <Alert variant="destructive">
<Warning /> <Warning />
<AlertTitle>Configuration error</AlertTitle> <AlertTitle>Error</AlertTitle>
<AlertDescription>{feedback}</AlertDescription> <AlertDescription>{feedback}</AlertDescription>
</Alert> </Alert>
)} )}
@ -141,11 +178,11 @@ export default async function Home({ searchParams }: PageProps) {
const isSelected = storageExport.id === selectedExportId; const isSelected = storageExport.id === selectedExportId;
return ( return (
<a <button
key={storageExport.id} key={storageExport.id}
href={`/?exportId=${encodeURIComponent(storageExport.id)}`} onClick={() => handleSelectExport(storageExport.id)}
className={cn( 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 isSelected
? "border-primary/20 bg-primary/5" ? "border-primary/20 bg-primary/5"
: "border-border hover:bg-muted/50", : "border-border hover:bg-muted/50",
@ -191,7 +228,7 @@ export default async function Home({ searchParams }: PageProps) {
</dd> </dd>
</div> </div>
</dl> </dl>
</a> </button>
); );
})} })}
</div> </div>
@ -278,7 +315,7 @@ export default async function Home({ searchParams }: PageProps) {
<ol className="flex flex-col gap-2"> <ol className="flex flex-col gap-2">
{[ {[
"Open Finder and choose Go, then Connect to Server.", "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.", "Enter the issued username and password when prompted.",
"Save to Keychain only if the credential expiry suits your workflow.", "Save to Keychain only if the credential expiry suits your workflow.",
].map((step, index) => ( ].map((step, index) => (
@ -303,14 +340,3 @@ export default async function Home({ searchParams }: PageProps) {
</main> </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 }),
});
}