Add project scaffolding and documentation

Add .env.example with configuration for database, Redis, security, SMTP, workers, and plugins. Add .gitignore for Python, Node.js, Next.js, Docker volumes, and IDE files. Add MIT License. Update README.md with feature overview, quick start guide, architecture description, plugin system documentation, security details, backup/restore instructions, and developer setup. Add Alembic configuration files and placeholder directories for API, web, worker, and plugin components
This commit is contained in:
2026-06-21 09:31:47 +02:00
parent cfeeccbf53
commit d694c8b8e3
197 changed files with 8583 additions and 56 deletions
View File
+28
View File
@@ -0,0 +1,28 @@
const http = require("http");
const options = {
hostname: "localhost",
port: process.env.PORT || 3000,
path: "/",
method: "GET",
timeout: 2000,
};
const req = http.request(options, (res) => {
if (res.statusCode >= 200 && res.statusCode < 400) {
process.exit(0);
} else {
process.exit(1);
}
});
req.on("error", () => {
process.exit(1);
});
req.on("timeout", () => {
req.destroy();
process.exit(1);
});
req.end();
+18
View File
@@ -0,0 +1,18 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
transpilePackages: ["@nexadash/ui", "@nexadash/shared", "@nexadash/plugin-sdk"],
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000",
},
async rewrites() {
return [
{
source: "/api/:path*",
destination: `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/api/:path*`,
},
];
},
};
module.exports = nextConfig;
+48
View File
@@ -0,0 +1,48 @@
{
"name": "@nexadash/web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@nexadash/shared": "workspace:*",
"@nexadash/ui": "workspace:*",
"@nexadash/plugin-sdk": "workspace:*",
"@tanstack/react-query": "^5.59.0",
"@tanstack/react-query-devtools": "^5.59.0",
"axios": "^1.7.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"framer-motion": "^11.11.0",
"lucide-react": "^0.454.0",
"next": "^15.0.0",
"next-themes": "^0.3.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-grid-layout": "^1.5.0",
"react-hook-form": "^7.53.0",
"tailwind-merge": "^2.5.0",
"zod": "^3.23.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react-grid-layout": "^1.3.0",
"autoprefixer": "^10.4.0",
"eslint": "^8.57.0",
"eslint-config-next": "^15.0.0",
"jsdom": "^25.0.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, Switch } from "@nexadash/ui";
import { connectionApi, pluginApi } from "@/lib/api";
export default function NewConnectionPage() {
const router = useRouter();
const [pluginId, setPluginId] = useState("");
const [name, setName] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const [verifyTls, setVerifyTls] = useState(true);
const [credentials, setCredentials] = useState("");
const [testResult, setTestResult] = useState<any>(null);
const { data: plugins } = useQuery({
queryKey: ["plugins"],
queryFn: () => pluginApi.list().then((r) => r.data),
});
const createMutation = useMutation({
mutationFn: (payload: any) => connectionApi.create(payload),
onSuccess: () => router.push("/plugins"),
});
const testMutation = useMutation({
mutationFn: () =>
connectionApi.test({
plugin_id: pluginId,
base_url: baseUrl,
verify_tls: verifyTls,
credentials: credentials ? JSON.parse(credentials) : {},
}),
onSuccess: (res) => setTestResult(res.data),
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createMutation.mutate({
name,
plugin_id: pluginId,
base_url: baseUrl,
verify_tls: verifyTls,
credentials: credentials ? JSON.parse(credentials) : {},
});
};
return (
<div className="mx-auto max-w-xl p-6">
<Card>
<CardHeader>
<CardTitle>New Service Connection</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="plugin">Plugin</Label>
<select
id="plugin"
required
value={pluginId}
onChange={(e) => setPluginId(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
>
<option value="">Select plugin</option>
{plugins?.map((p: any) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" required value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="baseUrl">Base URL</Label>
<Input id="baseUrl" required value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} />
</div>
<div className="flex items-center gap-2">
<Switch id="verifyTls" checked={verifyTls} onCheckedChange={setVerifyTls} />
<Label htmlFor="verifyTls">Verify TLS</Label>
</div>
<div className="space-y-2">
<Label htmlFor="credentials">Credentials (JSON)</Label>
<textarea
id="credentials"
value={credentials}
onChange={(e) => setCredentials(e.target.value)}
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm"
placeholder='{"api_token": "..."}'
/>
</div>
{testResult && (
<div className="rounded-md border p-3 text-sm">
Test: {testResult.success ? "OK" : "Failed"} ({testResult.status_code || "-"})
</div>
)}
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={() => testMutation.mutate()}>
Test
</Button>
<Button type="submit" className="flex-1" disabled={createMutation.isPending}>
{createMutation.isPending ? "Saving..." : "Save"}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { Button, Card, CardContent, CardHeader, CardTitle, Skeleton } from "@nexadash/ui";
import { authApi, dashboardApi } from "@/lib/api";
import { useAuthStore } from "@/stores/auth";
export default function DashboardPage() {
const router = useRouter();
const logout = useAuthStore((s) => s.logout);
const { data: user, isLoading: userLoading } = useQuery({
queryKey: ["me"],
queryFn: () => authApi.me().then((r) => r.data),
retry: false,
});
const { data: dashboards, isLoading: dashboardsLoading } = useQuery({
queryKey: ["dashboards"],
queryFn: () => dashboardApi.list().then((r) => r.data),
enabled: !!user,
});
useEffect(() => {
if (!userLoading && !user) {
router.replace("/login");
}
}, [user, userLoading, router]);
if (userLoading || dashboardsLoading) {
return (
<div className="p-6">
<Skeleton className="h-8 w-48" />
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
</div>
);
}
return (
<div className="p-6">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Dashboards</h1>
<p className="text-muted-foreground">Welcome back, {user?.first_name || user?.email}</p>
</div>
<div className="flex items-center gap-2">
<Button onClick={() => router.push("/dashboards/new")}>Create Dashboard</Button>
<Button variant="outline" onClick={() => { logout(); router.replace("/login"); }}>
Logout
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{dashboards?.map((dashboard: any) => (
<Card
key={dashboard.id}
className="cursor-pointer transition hover:shadow-md"
onClick={() => router.push(`/dashboards/${dashboard.id}`)}
>
<CardHeader>
<CardTitle>{dashboard.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{dashboard.widgets?.length || 0} widgets
</p>
</CardContent>
</Card>
))}
{dashboards?.length === 0 && (
<div className="col-span-full text-center text-muted-foreground">
No dashboards yet. Create one to get started.
</div>
)}
</div>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import { Button, Skeleton } from "@nexadash/ui";
import { dashboardApi } from "@/lib/api";
export default function DashboardDetailPage() {
const params = useParams();
const router = useRouter();
const id = params.id as string;
const { data: dashboard, isLoading } = useQuery({
queryKey: ["dashboard", id],
queryFn: () => dashboardApi.get(id).then((r) => r.data),
});
if (isLoading) {
return (
<div className="p-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="mt-6 h-96" />
</div>
);
}
return (
<div className="p-6">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{dashboard?.title}</h1>
<p className="text-muted-foreground">{dashboard?.description}</p>
</div>
<Button onClick={() => router.push(`/dashboards/${id}/edit`)}>Edit Layout</Button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{dashboard?.widgets?.map((widget: any) => (
<div
key={widget.id}
className="rounded-xl border bg-card p-4 shadow backdrop-blur-sm"
>
<h3 className="font-semibold">{widget.title}</h3>
<p className="text-sm text-muted-foreground">{widget.widget_type}</p>
</div>
))}
{dashboard?.widgets?.length === 0 && (
<div className="col-span-full text-center text-muted-foreground">
No widgets. Edit the dashboard to add widgets.
</div>
)}
</div>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from "@nexadash/ui";
import { dashboardApi } from "@/lib/api";
export default function NewDashboardPage() {
const router = useRouter();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const mutation = useMutation({
mutationFn: (payload: any) => dashboardApi.create(payload),
onSuccess: (res) => router.push(`/dashboards/${res.data.id}`),
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate({ title, description, folder: "default" });
};
return (
<div className="mx-auto max-w-xl p-6">
<Card>
<CardHeader>
<CardTitle>Create Dashboard</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input id="title" required value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Input id="description" value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<Button type="submit" className="w-full" disabled={mutation.isPending}>
{mutation.isPending ? "Creating..." : "Create"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.75rem;
--status-ok: 142.1 76.2% 36.3%;
--status-warning: 37.7 92.1% 50.2%;
--status-error: 0 72.2% 50.6%;
--status-info: 217.2 91.2% 59.8%;
--status-unknown: 215.4 16.3% 46.9%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 7.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 7.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--status-ok: 142.1 70.6% 45.3%;
--status-warning: 37.7 92.1% 50.2%;
--status-error: 0 72.2% 50.6%;
--status-info: 217.2 91.2% 59.8%;
--status-unknown: 215.4 16.3% 46.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { Providers } from "@/components/providers";
import "./globals.css";
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
export const metadata: Metadata = {
title: "NexaDash",
description: "Modern plugin-based dashboard",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${inter.variable} font-sans antialiased`}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<Providers>{children}</Providers>
</ThemeProvider>
</body>
</html>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label } from "@nexadash/ui";
import { authApi } from "@/lib/api";
import { useAuthStore } from "@/stores/auth";
export default function LoginPage() {
const router = useRouter();
const setToken = useAuthStore((s) => s.setToken);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const mutation = useMutation({
mutationFn: (payload: any) => authApi.login(payload),
onSuccess: (res) => {
localStorage.setItem("access_token", res.data.access_token);
localStorage.setItem("refresh_token", res.data.refresh_token);
setToken(res.data.access_token);
router.push("/dashboard");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate({ email, password });
};
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background to-muted/30 p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Sign in to NexaDash</CardTitle>
<CardDescription>Enter your credentials to access the dashboard.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" required value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
{mutation.isError && (
<div className="text-sm text-destructive">Invalid credentials.</div>
)}
<Button type="submit" className="w-full" disabled={mutation.isPending}>
{mutation.isPending ? "Signing in..." : "Sign in"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { authApi } from "@/lib/api";
export default function HomePage() {
const router = useRouter();
const { data, isLoading } = useQuery({
queryKey: ["setup-status"],
queryFn: () => authApi.setupStatus().then((r) => r.data),
});
useEffect(() => {
if (!isLoading && data) {
if (data.setup_required) {
router.replace("/setup");
} else {
router.replace("/login");
}
}
}, [data, isLoading, router]);
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="text-center">
<h1 className="text-4xl font-bold tracking-tight">NexaDash</h1>
<p className="mt-2 text-muted-foreground">Loading...</p>
</div>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, Skeleton } from "@nexadash/ui";
import { pluginApi } from "@/lib/api";
export default function PluginDetailPage() {
const params = useParams();
const id = params.id as string;
const { data: plugin, isLoading } = useQuery({
queryKey: ["plugin", id],
queryFn: () => pluginApi.get(id).then((r) => r.data),
});
if (isLoading) {
return (
<div className="p-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="mt-4 h-32" />
</div>
);
}
return (
<div className="p-6">
<h1 className="text-2xl font-bold">{plugin?.name}</h1>
<p className="text-muted-foreground">{plugin?.description}</p>
<div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Settings Schema</CardTitle>
</CardHeader>
<CardContent>
<pre className="text-xs">{JSON.stringify(plugin?.settings_schema, null, 2)}</pre>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Credentials Schema</CardTitle>
</CardHeader>
<CardContent>
<pre className="text-xs">{JSON.stringify(plugin?.credentials_schema, null, 2)}</pre>
</CardContent>
</Card>
</div>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Switch } from "@nexadash/ui";
import { pluginApi } from "@/lib/api";
export default function PluginsPage() {
const router = useRouter();
const { data: plugins, isLoading, refetch } = useQuery({
queryKey: ["plugins"],
queryFn: () => pluginApi.list().then((r) => r.data),
});
const toggleMutation = useMutation({
mutationFn: ({ id, is_active }: { id: string; is_active: boolean }) =>
pluginApi.update(id, { is_active }),
onSuccess: () => refetch(),
});
if (isLoading) return <div className="p-6">Loading plugins...</div>;
return (
<div className="p-6">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Plugins</h1>
<p className="text-muted-foreground">Manage installed plugins and integrations.</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => router.push("/plugins/upload")}>
Upload Plugin
</Button>
<Button onClick={() => router.push("/connections/new")}>New Connection</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{plugins?.map((plugin: any) => (
<Card key={plugin.id}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{plugin.name}</CardTitle>
<Switch
checked={plugin.is_active}
onCheckedChange={(checked: boolean) =>
toggleMutation.mutate({ id: plugin.id, is_active: checked })
}
/>
</div>
<CardDescription>{plugin.description}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<Badge variant="outline">{plugin.category}</Badge>
<Badge>{plugin.version}</Badge>
{plugin.is_builtin && <Badge variant="secondary">built-in</Badge>}
</div>
<Button
className="mt-4 w-full"
variant="outline"
onClick={() => router.push(`/plugins/${plugin.id}`)}
>
Configure
</Button>
</CardContent>
</Card>
))}
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button, Card, CardContent, CardHeader, CardTitle } from "@nexadash/ui";
import { pluginApi } from "@/lib/api";
export default function UploadPluginPage() {
const router = useRouter();
const [file, setFile] = useState<File | null>(null);
const mutation = useMutation({
mutationFn: (f: File) => pluginApi.upload(f),
onSuccess: () => router.push("/plugins"),
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (file) mutation.mutate(file);
};
return (
<div className="mx-auto max-w-xl p-6">
<Card>
<CardHeader>
<CardTitle>Upload Plugin</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="file"
accept=".zip"
required
onChange={(e) => setFile(e.target.files?.[0] || null)}
className="block w-full text-sm text-muted-foreground file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-4 file:py-2 file:text-primary-foreground"
/>
{mutation.isError && (
<div className="text-sm text-destructive">Upload failed.</div>
)}
<Button type="submit" className="w-full" disabled={mutation.isPending || !file}>
{mutation.isPending ? "Uploading..." : "Upload"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label } from "@nexadash/ui";
import { authApi } from "@/lib/api";
import { useAuthStore } from "@/stores/auth";
export default function SetupPage() {
const router = useRouter();
const setToken = useAuthStore((s) => s.setToken);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const { data, isLoading } = useQuery({
queryKey: ["setup-status"],
queryFn: () => authApi.setupStatus().then((r) => r.data),
});
useEffect(() => {
if (data && !data.setup_required) {
router.replace("/login");
}
}, [data, router]);
const mutation = useMutation({
mutationFn: (payload: any) => authApi.setup(payload),
onSuccess: (res) => {
localStorage.setItem("access_token", res.data.access_token);
localStorage.setItem("refresh_token", res.data.refresh_token);
setToken(res.data.access_token);
router.push("/dashboard");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate({ email, password, first_name: firstName, last_name: lastName });
};
if (isLoading) return null;
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background to-muted/30 p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Welcome to NexaDash</CardTitle>
<CardDescription>Create the owner account to continue.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">First name</Label>
<Input id="firstName" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last name</Label>
<Input id="lastName" value={lastName} onChange={(e) => setLastName(e.target.value)} />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" required minLength={12} value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
{mutation.isError && (
<div className="text-sm text-destructive">Setup failed. Please try again.</div>
)}
<Button type="submit" className="w-full" disabled={mutation.isPending}>
{mutation.isPending ? "Creating..." : "Create owner account"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
+104
View File
@@ -0,0 +1,104 @@
import axios from "axios";
import { API_BASE_URL } from "@nexadash/shared";
export const api = axios.create({
baseURL: API_BASE_URL,
headers: {
"Content-Type": "application/json",
},
withCredentials: true,
});
api.interceptors.request.use((config) => {
if (typeof window !== "undefined") {
const token = localStorage.getItem("access_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
});
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refresh = localStorage.getItem("refresh_token");
if (!refresh) throw new Error("No refresh token");
const { data } = await axios.post(`${API_BASE_URL}/api/v1/auth/refresh`, {
refresh_token: refresh,
});
localStorage.setItem("access_token", data.access_token);
localStorage.setItem("refresh_token", data.refresh_token);
originalRequest.headers.Authorization = `Bearer ${data.access_token}`;
return api(originalRequest);
} catch {
if (typeof window !== "undefined") {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
window.location.href = "/login";
}
}
}
return Promise.reject(error);
}
);
export const authApi = {
setupStatus: () => api.get("/api/v1/auth/setup-status"),
setup: (data: any) => api.post("/api/v1/auth/setup", data),
login: (data: any) => api.post("/api/v1/auth/login", data),
logout: () => api.post("/api/v1/auth/logout"),
me: () => api.get("/api/v1/users/me"),
};
export const dashboardApi = {
list: () => api.get("/api/v1/dashboards"),
create: (data: any) => api.post("/api/v1/dashboards", data),
get: (id: string) => api.get(`/api/v1/dashboards/${id}`),
update: (id: string, data: any) => api.put(`/api/v1/dashboards/${id}`, data),
delete: (id: string) => api.delete(`/api/v1/dashboards/${id}`),
duplicate: (id: string) => api.post(`/api/v1/dashboards/${id}/duplicate`),
updateLayout: (id: string, data: any) => api.put(`/api/v1/dashboards/${id}/layout`, data),
};
export const widgetApi = {
create: (dashboardId: string, data: any) => api.post(`/api/v1/widgets/${dashboardId}`, data),
update: (id: string, data: any) => api.put(`/api/v1/widgets/${id}`, data),
delete: (id: string) => api.delete(`/api/v1/widgets/${id}`),
};
export const pluginApi = {
list: () => api.get("/api/v1/plugins"),
get: (id: string) => api.get(`/api/v1/plugins/${id}`),
update: (id: string, data: any) => api.put(`/api/v1/plugins/${id}`, data),
upload: (file: File) => {
const form = new FormData();
form.append("file", file);
return api.post("/api/v1/plugins/upload", form, {
headers: { "Content-Type": "multipart/form-data" },
});
},
createInstance: (pluginId: string, data: any) =>
api.post(`/api/v1/plugins/${pluginId}/instances`, data),
listInstances: (pluginId: string) => api.get(`/api/v1/plugins/${pluginId}/instances`),
updateInstance: (id: string, data: any) => api.put(`/api/v1/plugins/instances/${id}`, data),
deleteInstance: (id: string) => api.delete(`/api/v1/plugins/instances/${id}`),
};
export const connectionApi = {
list: () => api.get("/api/v1/connections"),
create: (data: any) => api.post("/api/v1/connections", data),
test: (data: any) => api.post("/api/v1/connections/test", data),
delete: (id: string) => api.delete(`/api/v1/connections/${id}`),
};
export const widgetDataApi = {
fetch: (data: any) => api.post("/api/v1/widget-data/widget-data", data),
healthcheck: (instanceId: string) =>
api.post(`/api/v1/widget-data/widget-data/${instanceId}/healthcheck`),
};
+29
View File
@@ -0,0 +1,29 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { User } from "@nexadash/shared";
interface AuthState {
user: User | null;
token: string | null;
setUser: (user: User | null) => void;
setToken: (token: string | null) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
setUser: (user) => set({ user }),
setToken: (token) => set({ token }),
logout: () => {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
set({ user: null, token: null });
},
}),
{ name: "nexadash-auth" }
)
);
+17
View File
@@ -0,0 +1,17 @@
import { create } from "zustand";
interface UIState {
sidebarOpen: boolean;
commandOpen: boolean;
setSidebarOpen: (open: boolean) => void;
setCommandOpen: (open: boolean) => void;
toggleSidebar: () => void;
}
export const useUIStore = create<UIState>((set) => ({
sidebarOpen: true,
commandOpen: false,
setSidebarOpen: (open) => set({ sidebarOpen: open }),
setCommandOpen: (open) => set({ commandOpen: open }),
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
}));
+69
View File
@@ -0,0 +1,69 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
content: [
"./src/**/*.{js,ts,jsx,tsx}",
"../../packages/ui/src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
status: {
ok: "hsl(var(--status-ok))",
warning: "hsl(var(--status-warning))",
error: "hsl(var(--status-error))",
info: "hsl(var(--status-info))",
unknown: "hsl(var(--status-unknown))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: ["var(--font-sans)", "system-ui", "sans-serif"],
},
backdropBlur: {
xs: "2px",
},
},
},
plugins: [require("tailwindcss-animate")],
};
export default config;
View File
+15
View File
@@ -0,0 +1,15 @@
import { describe, it, expect } from "vitest";
import { API_BASE_URL } from "@nexadash/shared";
import { authApi } from "@/lib/api";
describe("api client", () => {
it("exports API_BASE_URL from shared", () => {
expect(API_BASE_URL).toBeDefined();
});
it("authApi has required methods", () => {
expect(authApi.setupStatus).toBeInstanceOf(Function);
expect(authApi.setup).toBeInstanceOf(Function);
expect(authApi.login).toBeInstanceOf(Function);
});
});
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"],
"@nexadash/ui": ["../../packages/ui/src"],
"@nexadash/shared": ["../../packages/shared/src"],
"@nexadash/plugin-sdk": ["../../packages/plugin-sdk/src"]
},
"types": ["node", "react", "react-dom"]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
environment: "jsdom",
globals: true,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"@nexadash/ui": path.resolve(__dirname, "../../packages/ui/src"),
"@nexadash/shared": path.resolve(__dirname, "../../packages/shared/src"),
"@nexadash/plugin-sdk": path.resolve(__dirname, "../../packages/plugin-sdk/src"),
},
},
});