mirror of
https://github.com/harivansh-afk/betterNAS.git
synced 2026-04-15 01:00:28 +00:00
frontend deploy (#13)
This commit is contained in:
parent
c499e46a4d
commit
ca5014750b
4 changed files with 417 additions and 76 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,3 +8,4 @@ coverage/
|
|||
apps/web/.next/
|
||||
.env.agent
|
||||
.state/
|
||||
.vercel
|
||||
|
|
|
|||
154
apps/web/app/login/page.tsx
Normal file
154
apps/web/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
160
apps/web/lib/api.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue