diff --git a/.gitignore b/.gitignore index adb3e87..790a711 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ coverage/ apps/web/.next/ .env.agent .state/ +.vercel diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx new file mode 100644 index 0000000..2df62d4 --- /dev/null +++ b/apps/web/app/login/page.tsx @@ -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(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 ( +
+ + +

+ betterNAS +

+ + {mode === "login" ? "Sign in" : "Create account"} + + + {mode === "login" + ? "Sign in to your betterNAS control plane." + : "Create your betterNAS account."} + +
+ +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + {error && ( +

{error}

+ )} + + + +

+ {mode === "login" ? ( + <> + No account?{" "} + + + ) : ( + <> + Already have an account?{" "} + + + )} +

+
+
+
+
+ ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 257b318..3c17e01 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -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(null); + const [exports, setExports] = useState([]); + const [selectedExportId, setSelectedExportId] = useState(null); + const [mountProfile, setMountProfile] = useState(null); + const [feedback, setFeedback] = useState(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 ( +
+

Loading...

+
+ ); + } + + const selectedExport = selectedExportId + ? exports.find((e) => e.id === selectedExportId) ?? null + : null; return (
-
-

- betterNAS -

-

- Control Plane -

+
+
+

+ betterNAS +

+

+ Control Plane +

+
+ {user && ( +
+ + {user.username} + + +
+ )}
- {controlPlaneConfig.baseUrl ?? "Not configured"} - - - {controlPlaneConfig.clientToken !== null - ? "Bearer auth" - : "No token"} + {process.env.NEXT_PUBLIC_BETTERNAS_API_URL || "local"} {exports.length === 1 ? "1 export" : `${exports.length} exports`} @@ -106,7 +143,7 @@ export default async function Home({ searchParams }: PageProps) { {feedback !== null && ( - Configuration error + Error {feedback} )} @@ -141,11 +178,11 @@ export default async function Home({ searchParams }: PageProps) { const isSelected = storageExport.id === selectedExportId; return ( - 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) {
- + ); })}
@@ -278,7 +315,7 @@ export default async function Home({ searchParams }: PageProps) {
    {[ "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) {
); } - -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; -} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts new file mode 100644 index 0000000..009ded1 --- /dev/null +++ b/apps/web/lib/api.ts @@ -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( + path: string, + options?: RequestInit, +): Promise { + const headers: Record = {}; + 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; +} + +export async function register( + username: string, + password: string, +): Promise { + 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 { + 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 { + try { + await apiFetch("/api/v1/auth/logout", { method: "POST" }); + } finally { + clearToken(); + } +} + +export async function getMe(): Promise { + return apiFetch("/api/v1/auth/me"); +} + +export async function listExports(): Promise { + return apiFetch("/api/v1/exports"); +} + +export async function issueMountProfile( + exportId: string, +): Promise { + return apiFetch("/api/v1/mount-profiles/issue", { + method: "POST", + body: JSON.stringify({ exportId }), + }); +}