mirror of
https://github.com/harivansh-afk/betterNAS.git
synced 2026-04-15 05:02:07 +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/
|
apps/web/.next/
|
||||||
.env.agent
|
.env.agent
|
||||||
.state/
|
.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 {
|
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
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