From 5ba6a3434afa49cae28ef6eea1c4052e98f2f295 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 7 Feb 2026 08:03:17 +0100 Subject: [PATCH] feat: Initial tenant Control Panel Privacy-first settings dashboard for LetsBe Cloud tenants. - Next.js 15 (App Router) with Keycloak SSO via NextAuth.js v5 - Tool status grid with health indicators - Settings pages (email, domain, server info) - Post-provisioning setup checklist - Orchestrator API proxy for server management - Docker deployment ready Co-Authored-By: Claude Opus 4.6 --- .env.example | 14 ++ .gitignore | 34 +++ Dockerfile | 44 ++++ docker-compose.yml | 22 ++ next.config.ts | 46 ++++ package.json | 44 ++++ postcss.config.js | 9 + src/app/api/auth/[...nextauth]/route.ts | 2 + src/app/api/proxy/[...path]/route.ts | 72 +++++++ src/app/error.tsx | 26 +++ src/app/globals.css | 59 ++++++ src/app/layout.tsx | 45 ++++ src/app/loading.tsx | 26 +++ src/app/login/page.tsx | 39 ++++ src/app/not-found.tsx | 18 ++ src/app/page.tsx | 187 ++++++++++++++++ src/app/settings/domain/page.tsx | 130 ++++++++++++ src/app/settings/email/page.tsx | 168 +++++++++++++++ src/app/settings/page.tsx | 66 ++++++ src/app/settings/server/page.tsx | 196 +++++++++++++++++ src/app/setup/page.tsx | 199 ++++++++++++++++++ src/app/tools/page.tsx | 70 ++++++ src/auth.ts | 45 ++++ src/components/dashboard/sidebar.tsx | 187 ++++++++++++++++ src/components/dashboard/tool-status-grid.tsx | 136 ++++++++++++ src/components/ui/alert.tsx | 59 ++++++ src/components/ui/avatar.tsx | 48 +++++ src/components/ui/badge.tsx | 40 ++++ src/components/ui/button.tsx | 56 +++++ src/components/ui/card.tsx | 79 +++++++ src/components/ui/checkbox.tsx | 28 +++ src/components/ui/dropdown-menu.tsx | 84 ++++++++ src/components/ui/input.tsx | 22 ++ src/components/ui/label.tsx | 24 +++ src/components/ui/progress.tsx | 26 +++ src/components/ui/separator.tsx | 29 +++ src/components/ui/sheet.tsx | 90 ++++++++ src/components/ui/skeleton.tsx | 15 ++ src/components/ui/tooltip.tsx | 28 +++ src/lib/orchestrator.ts | 67 ++++++ src/lib/tools.ts | 168 +++++++++++++++ src/lib/utils.ts | 6 + src/middleware.ts | 7 + src/types/index.ts | 45 ++++ tailwind.config.ts | 57 +++++ tsconfig.json | 28 +++ 46 files changed, 2890 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/app/api/proxy/[...path]/route.ts create mode 100644 src/app/error.tsx create mode 100644 src/app/globals.css create mode 100644 src/app/layout.tsx create mode 100644 src/app/loading.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/app/not-found.tsx create mode 100644 src/app/page.tsx create mode 100644 src/app/settings/domain/page.tsx create mode 100644 src/app/settings/email/page.tsx create mode 100644 src/app/settings/page.tsx create mode 100644 src/app/settings/server/page.tsx create mode 100644 src/app/setup/page.tsx create mode 100644 src/app/tools/page.tsx create mode 100644 src/auth.ts create mode 100644 src/components/dashboard/sidebar.tsx create mode 100644 src/components/dashboard/tool-status-grid.tsx create mode 100644 src/components/ui/alert.tsx create mode 100644 src/components/ui/avatar.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/progress.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/components/ui/sheet.tsx create mode 100644 src/components/ui/skeleton.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/lib/orchestrator.ts create mode 100644 src/lib/tools.ts create mode 100644 src/lib/utils.ts create mode 100644 src/middleware.ts create mode 100644 src/types/index.ts create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..effecc8 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Authentication +NEXTAUTH_URL=https://panel.example.com +NEXTAUTH_SECRET=generate-a-secret-here + +# Keycloak SSO +KEYCLOAK_CLIENT_ID=control-panel +KEYCLOAK_CLIENT_SECRET=your-client-secret +KEYCLOAK_ISSUER=https://auth.example.com/realms/letsbe + +# Orchestrator API (same server) +ORCHESTRATOR_URL=http://orchestrator:8100 + +# Tenant domain (set during provisioning) +TENANT_DOMAIN=example.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8ccc874 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7147153 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# Stage 1: Install dependencies +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci --only=production + +# Stage 2: Build +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN npm run build + +# Stage 3: Production +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Set the correct permissions for prerender cache +RUN mkdir .next +RUN chown nextjs:nodejs .next + +# Automatically leverage output traces to reduce image size +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3001 + +ENV PORT=3001 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c1922f1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + control-panel: + build: . + container_name: letsbe-control-panel + restart: unless-stopped + ports: + - "3001:3001" + environment: + - NEXTAUTH_URL=${NEXTAUTH_URL} + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET} + - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-control-panel} + - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET} + - KEYCLOAK_ISSUER=${KEYCLOAK_ISSUER} + - ORCHESTRATOR_URL=${ORCHESTRATOR_URL:-http://orchestrator:8100} + - TENANT_DOMAIN=${TENANT_DOMAIN} + - SERVER_IP=${SERVER_IP} + networks: + - letsbe + +networks: + letsbe: + external: true diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..8388335 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,46 @@ +import type { NextConfig } from 'next' + +const securityHeaders = [ + { + key: 'X-DNS-Prefetch-Control', + value: 'on', + }, + { + key: 'Strict-Transport-Security', + value: 'max-age=63072000; includeSubDomains; preload', + }, + { + key: 'X-Content-Type-Options', + value: 'nosniff', + }, + { + key: 'X-Frame-Options', + value: 'SAMEORIGIN', + }, + { + key: 'X-XSS-Protection', + value: '1; mode=block', + }, + { + key: 'Referrer-Policy', + value: 'strict-origin-when-cross-origin', + }, + { + key: 'Permissions-Policy', + value: 'camera=(), microphone=(), geolocation=()', + }, +] + +const nextConfig: NextConfig = { + output: 'standalone', + async headers() { + return [ + { + source: '/(.*)', + headers: securityHeaders, + }, + ] + }, +} + +export default nextConfig diff --git a/package.json b/package.json new file mode 100644 index 0000000..99d451c --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "letsbe-control-panel", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev --port 3001", + "build": "next build", + "start": "next start --port 3001", + "lint": "eslint .", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-dropdown-menu": "^2.1.4", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-progress": "^1.1.4", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-tooltip": "^1.1.6", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.469.0", + "next": "15.1.8", + "next-auth": "5.0.0-beta.30", + "react": "19.0.0", + "react-dom": "19.0.0", + "tailwind-merge": "^2.6.0", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "@types/react": "^19.0.4", + "@types/react-dom": "^19.0.2", + "autoprefixer": "^10.4.20", + "eslint": "^9.17.0", + "eslint-config-next": "15.1.8", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.3" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..b90a152 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,9 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + +module.exports = config diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..c865f77 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,2 @@ +import { handlers } from '@/auth' +export const { GET, POST } = handlers diff --git a/src/app/api/proxy/[...path]/route.ts b/src/app/api/proxy/[...path]/route.ts new file mode 100644 index 0000000..0b13770 --- /dev/null +++ b/src/app/api/proxy/[...path]/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/auth' + +const ORCHESTRATOR_URL = process.env.ORCHESTRATOR_URL || 'http://orchestrator:8100' + +async function proxyRequest( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> } +) { + const session = await auth() + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { path } = await params + const targetPath = path.join('/') + const url = new URL(`/api/v1/${targetPath}`, ORCHESTRATOR_URL) + + // Forward query parameters + request.nextUrl.searchParams.forEach((value, key) => { + url.searchParams.set(key, value) + }) + + const headers: Record = { + 'Content-Type': 'application/json', + } + + // Forward the access token if available + if (session.accessToken) { + headers['Authorization'] = `Bearer ${session.accessToken}` + } + + const fetchOptions: RequestInit = { + method: request.method, + headers, + } + + // Forward body for non-GET requests + if (request.method !== 'GET' && request.method !== 'HEAD') { + try { + const body = await request.text() + if (body) { + fetchOptions.body = body + } + } catch { + // No body to forward + } + } + + try { + const response = await fetch(url.toString(), fetchOptions) + const data = await response.text() + + return new NextResponse(data, { + status: response.status, + headers: { + 'Content-Type': response.headers.get('Content-Type') || 'application/json', + }, + }) + } catch (error) { + return NextResponse.json( + { error: 'Failed to connect to orchestrator' }, + { status: 502 } + ) + } +} + +export const GET = proxyRequest +export const POST = proxyRequest +export const PUT = proxyRequest +export const PATCH = proxyRequest +export const DELETE = proxyRequest diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..64281dd --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,26 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { AlertCircle, RotateCcw } from 'lucide-react' + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + return ( +
+ +

Something went wrong

+

+ {error.message || 'An unexpected error occurred. Please try again.'} +

+ +
+ ) +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..00b08e3 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,59 @@ +@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: 221.2 83.2% 53.3%; + --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: 221.2 83.2% 53.3%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.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: 224.3 76.3% 48%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..2827b1f --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,45 @@ +import type { Metadata } from 'next' +import { Inter } from 'next/font/google' +import './globals.css' +import { auth } from '@/auth' +import { Sidebar } from '@/components/dashboard/sidebar' +import { TooltipProvider } from '@/components/ui/tooltip' + +const inter = Inter({ subsets: ['latin'] }) + +export const metadata: Metadata = { + title: 'LetsBe Control Panel', + description: 'Manage your LetsBe server and tools', +} + +export default async function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + const session = await auth() + + return ( + + + + {session?.user ? ( +
+ +
+
+
{children}
+
+
+
+ ) : ( + children + )} +
+ + + ) +} diff --git a/src/app/loading.tsx b/src/app/loading.tsx new file mode 100644 index 0000000..65f6eeb --- /dev/null +++ b/src/app/loading.tsx @@ -0,0 +1,26 @@ +import { Skeleton } from '@/components/ui/skeleton' + +export default function Loading() { + return ( +
+
+ + +
+
+ + + + +
+
+ + + + + + +
+
+ ) +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..f027486 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,39 @@ +import { signIn } from '@/auth' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Shield } from 'lucide-react' + +export default function LoginPage() { + return ( +
+ + +
+ LB +
+ LetsBe Control Panel + + Sign in to manage your server and tools + +
+ +
{ + 'use server' + await signIn('keycloak', { redirectTo: '/' }) + }} + > + +
+

+ Your identity is managed through Keycloak on this server. + Contact your administrator if you need access. +

+
+
+
+ ) +} diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..ab6484e --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,18 @@ +import Link from 'next/link' +import { Button } from '@/components/ui/button' +import { ArrowLeft } from 'lucide-react' + +export default function NotFound() { + return ( +
+

404

+

Page not found

+ +
+ ) +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..03816e9 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,187 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Progress } from '@/components/ui/progress' +import { Button } from '@/components/ui/button' +import { + Cpu, + HardDrive, + MemoryStick, + Clock, + ExternalLink, + Activity, + CheckCircle2, + XCircle, + ArrowRight, +} from 'lucide-react' +import Link from 'next/link' +import { getHealth } from '@/lib/orchestrator' +import { AVAILABLE_TOOLS } from '@/lib/tools' +import { ToolStatusGrid } from '@/components/dashboard/tool-status-grid' + +function formatUptime(seconds: number): string { + const days = Math.floor(seconds / 86400) + const hours = Math.floor((seconds % 86400) / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + if (days > 0) return `${days}d ${hours}h ${minutes}m` + if (hours > 0) return `${hours}h ${minutes}m` + return `${minutes}m` +} + +function getUsageColor(percent: number): string { + if (percent < 60) return 'text-green-600' + if (percent < 80) return 'text-yellow-600' + return 'text-red-600' +} + +export default async function DashboardPage() { + let health = null + let healthError = null + + try { + health = await getHealth() + } catch (e) { + healthError = e instanceof Error ? e.message : 'Failed to fetch server health' + } + + return ( +
+
+

Dashboard

+

+ Monitor your server health and manage your tools +

+
+ + {/* Server Health */} +
+

Server Health

+ {healthError ? ( + + +
+ +
+

Unable to connect to server

+

{healthError}

+
+
+
+
+ ) : health ? ( +
+ + + CPU Usage + + + +
+ {health.cpu_percent.toFixed(1)}% +
+ +
+
+ + + Memory + + + +
+ {health.memory_percent.toFixed(1)}% +
+ +
+
+ + + Disk Usage + + + +
+ {health.disk_percent.toFixed(1)}% +
+

+ {health.disk_used_gb.toFixed(1)} / {health.disk_total_gb.toFixed(1)} GB +

+ +
+
+ + + Uptime + + + +
+ {formatUptime(health.uptime_seconds)} +
+
+ + + Online + + + {health.agent_count} agent{health.agent_count !== 1 ? 's' : ''} + +
+
+
+
+ ) : null} +
+ + {/* Tool Status Grid */} +
+
+

Deployed Tools

+ +
+ +
+ + {/* Quick Actions */} +
+

Quick Actions

+
+ + + + Setup Checklist + + Complete post-provisioning steps + + + + + + + + Configure Email + + Set up SMTP for outbound emails + + + + + + + + Domain Settings + + View DNS records and domain info + + + + +
+
+
+ ) +} diff --git a/src/app/settings/domain/page.tsx b/src/app/settings/domain/page.tsx new file mode 100644 index 0000000..40e3d41 --- /dev/null +++ b/src/app/settings/domain/page.tsx @@ -0,0 +1,130 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Globe, CheckCircle2, XCircle, AlertCircle } from 'lucide-react' +import { AVAILABLE_TOOLS } from '@/lib/tools' + +const domain = process.env.TENANT_DOMAIN || 'example.com' + +function DnsStatusIcon({ status }: { status: string }) { + switch (status) { + case 'active': + return + case 'pending': + return + default: + return + } +} + +function DnsStatusBadge({ status }: { status: string }) { + switch (status) { + case 'active': + return Active + case 'pending': + return Pending + default: + return Error + } +} + +export default function DomainSettingsPage() { + // Generate subdomain list from available tools + const subdomains = [ + { name: 'panel', fullDomain: `panel.${domain}`, purpose: 'Control Panel', status: 'active' as const }, + ...AVAILABLE_TOOLS.map((tool) => ({ + name: tool.subdomain, + fullDomain: `${tool.subdomain}.${domain}`, + purpose: tool.name, + status: 'active' as const, + })), + ] + + return ( +
+
+

Domain & DNS

+

+ View your domain configuration and DNS record status +

+
+ + {/* Domain Overview */} + + + + + Domain Information + + Your primary domain and configuration + + +
+
+

Primary Domain

+

{domain}

+
+
+

Wildcard DNS

+
+ Configured + *.{domain} +
+
+
+
+
+ + {/* Subdomain Records */} + + + Subdomain Records + + All subdomains pointing to your server ({subdomains.length} total) + + + +
+ {subdomains.map((sub) => ( +
+
+ +
+

{sub.fullDomain}

+

{sub.purpose}

+
+
+ +
+ ))} +
+
+
+ + {/* DNS Instructions */} + + + DNS Configuration + Required DNS records for your domain + + +
+

; A record for root domain

+

{domain}. IN A YOUR_SERVER_IP

+

+

; Wildcard A record for all subdomains

+

*.{domain}. IN A YOUR_SERVER_IP

+

+

; MX record for email (if using Poste.io)

+

{domain}. IN MX 10 mail.{domain}.

+

+

; SPF record

+

{domain}. IN TXT "v=spf1 a mx ip4:YOUR_SERVER_IP ~all"

+
+
+
+
+ ) +} diff --git a/src/app/settings/email/page.tsx b/src/app/settings/email/page.tsx new file mode 100644 index 0000000..6937815 --- /dev/null +++ b/src/app/settings/email/page.tsx @@ -0,0 +1,168 @@ +'use client' + +import { useState } from 'react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Save, Mail, AlertCircle, CheckCircle2 } from 'lucide-react' + +export default function EmailSettingsPage() { + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [error, setError] = useState(null) + const [config, setConfig] = useState({ + host: '', + port: '587', + username: '', + password: '', + fromEmail: '', + fromName: '', + }) + + const handleSave = async () => { + setSaving(true) + setError(null) + setSaved(false) + + try { + const res = await fetch('/api/proxy/playbooks/poste-smtp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + smtp_host: config.host, + smtp_port: parseInt(config.port), + smtp_user: config.username, + smtp_pass: config.password, + from_email: config.fromEmail, + from_name: config.fromName, + }), + }) + + if (!res.ok) { + throw new Error('Failed to save SMTP configuration') + } + + setSaved(true) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to save') + } finally { + setSaving(false) + } + } + + return ( +
+
+

Email Configuration

+

+ Configure SMTP settings for outbound email from your tools +

+
+ + {saved && ( + + + Saved + + SMTP configuration has been updated. Email will be reconfigured shortly. + + + )} + + {error && ( + + + Error + {error} + + )} + + + + + + SMTP Settings + + + These settings are used by Poste.io and other tools to send outbound email. + Configure your noreply address and relay settings. + + + +
+
+ + setConfig({ ...config, host: e.target.value })} + /> +
+
+ + setConfig({ ...config, port: e.target.value })} + /> +
+
+ +
+
+ + setConfig({ ...config, username: e.target.value })} + /> +
+
+ + setConfig({ ...config, password: e.target.value })} + /> +
+
+ +
+
+ + setConfig({ ...config, fromEmail: e.target.value })} + /> +
+
+ + setConfig({ ...config, fromName: e.target.value })} + /> +
+
+ + +
+
+
+ ) +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx new file mode 100644 index 0000000..b3b8479 --- /dev/null +++ b/src/app/settings/page.tsx @@ -0,0 +1,66 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Mail, Globe, Server, ArrowRight } from 'lucide-react' +import Link from 'next/link' + +const settingsSections = [ + { + title: 'Email Configuration', + description: 'Configure SMTP settings for outbound email notifications from your tools', + icon: Mail, + href: '/settings/email', + }, + { + title: 'Domain & DNS', + description: 'View your domain configuration and DNS record status for all subdomains', + icon: Globe, + href: '/settings/domain', + }, + { + title: 'Server Information', + description: 'View SSH credentials, server IP, and system information', + icon: Server, + href: '/settings/server', + }, +] + +export default function SettingsPage() { + return ( +
+
+

Settings

+

+ Manage your server configuration and preferences +

+
+ +
+ {settingsSections.map((section) => ( + + +
+
+ +
+
+ {section.title} +
+
+ + {section.description} + +
+ + + +
+ ))} +
+
+ ) +} diff --git a/src/app/settings/server/page.tsx b/src/app/settings/server/page.tsx new file mode 100644 index 0000000..ddb3488 --- /dev/null +++ b/src/app/settings/server/page.tsx @@ -0,0 +1,196 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Progress } from '@/components/ui/progress' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { + Server, + Cpu, + HardDrive, + MemoryStick, + Clock, + Network, + Terminal, + AlertCircle, + CheckCircle2, +} from 'lucide-react' +import { getHealth } from '@/lib/orchestrator' + +function formatUptime(seconds: number): string { + const days = Math.floor(seconds / 86400) + const hours = Math.floor((seconds % 86400) / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + if (days > 0) return `${days} days, ${hours} hours, ${minutes} minutes` + if (hours > 0) return `${hours} hours, ${minutes} minutes` + return `${minutes} minutes` +} + +export default async function ServerSettingsPage() { + let health = null + let healthError = null + + try { + health = await getHealth() + } catch (e) { + healthError = e instanceof Error ? e.message : 'Failed to connect' + } + + const serverIp = process.env.SERVER_IP || 'Not configured' + const domain = process.env.TENANT_DOMAIN || 'example.com' + + return ( +
+
+

Server Information

+

+ View server details, resource usage, and connection information +

+
+ + {healthError && ( + + + Connection Error + {healthError} + + )} + + {/* Server Details */} + + + + + Server Details + + Connection and identification information + + +
+
+

Server IP

+

{serverIp}

+
+
+

Domain

+

{domain}

+
+
+

Status

+ + {health ? 'Online' : 'Unreachable'} + +
+
+

Uptime

+

+ {health ? formatUptime(health.uptime_seconds) : 'N/A'} +

+
+
+
+
+ + {/* Resource Usage */} + {health && ( + + + Resource Usage + Current server resource utilization + + +
+
+
+ + CPU +
+ {health.cpu_percent.toFixed(1)}% +
+ +
+ +
+
+
+ + Memory +
+ {health.memory_percent.toFixed(1)}% +
+ +
+ +
+
+
+ + Disk +
+ + {health.disk_used_gb.toFixed(1)} / {health.disk_total_gb.toFixed(1)} GB ({health.disk_percent.toFixed(1)}%) + +
+ +
+
+
+ )} + + {/* SSH Access */} + + + + + SSH Access + + + Connect to your server via SSH for advanced administration + + + +
+

# Connect via SSH

+

ssh root@{serverIp}

+

# Application directory

+

cd /opt/letsbe/

+
+

+ SSH credentials were provided during server provisioning. + Contact your administrator if you need access. +

+
+
+ + {/* Orchestrator Info */} + {health && ( + + + + + Orchestrator + + Automation agent status + + +
+
+

Status

+
+ + Connected +
+
+
+

Active Agents

+

{health.agent_count}

+
+
+

Total Tasks

+

{health.task_count}

+
+
+
+
+ )} +
+ ) +} diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx new file mode 100644 index 0000000..bf2cd85 --- /dev/null +++ b/src/app/setup/page.tsx @@ -0,0 +1,199 @@ +'use client' + +import { useState } from 'react' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Progress } from '@/components/ui/progress' +import { + CheckCircle2, + Circle, + ExternalLink, + ArrowRight, + Shield, + Mail, + Key, + Users, + Globe, + Database, + Bell, +} from 'lucide-react' +import Link from 'next/link' + +interface SetupStep { + id: string + title: string + description: string + icon: React.ComponentType<{ className?: string }> + href?: string + externalHref?: string + category: 'security' | 'communication' | 'tools' +} + +const setupSteps: SetupStep[] = [ + { + id: 'change-keycloak-password', + title: 'Change Keycloak Admin Password', + description: 'Update the default admin password for your Keycloak identity server', + icon: Shield, + externalHref: '/realms/master/account', + category: 'security', + }, + { + id: 'change-portainer-password', + title: 'Change Portainer Password', + description: 'Update the default admin password for the container management dashboard', + icon: Key, + category: 'security', + }, + { + id: 'configure-email', + title: 'Configure Outbound Email (SMTP)', + description: 'Set up your noreply email address for system notifications', + icon: Mail, + href: '/settings/email', + category: 'communication', + }, + { + id: 'setup-sso', + title: 'Configure SSO for Tools', + description: 'Link your deployed tools to Keycloak for single sign-on access', + icon: Users, + category: 'security', + }, + { + id: 'verify-dns', + title: 'Verify DNS Configuration', + description: 'Confirm all subdomains are resolving correctly to your server', + icon: Globe, + href: '/settings/domain', + category: 'communication', + }, + { + id: 'nextcloud-setup', + title: 'Complete Nextcloud Setup', + description: 'Set admin password and configure your file storage', + icon: Database, + category: 'tools', + }, + { + id: 'setup-monitoring', + title: 'Set Up Uptime Monitoring', + description: 'Configure Uptime Kuma to monitor your tools and get alerts', + icon: Bell, + category: 'tools', + }, +] + +export default function SetupPage() { + const [completed, setCompleted] = useState>(new Set()) + + const toggleStep = (id: string) => { + setCompleted((prev) => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } + + const completedCount = completed.size + const totalSteps = setupSteps.length + const progressPercent = Math.round((completedCount / totalSteps) * 100) + + const categories = { + security: { label: 'Security', steps: setupSteps.filter((s) => s.category === 'security') }, + communication: { label: 'Communication', steps: setupSteps.filter((s) => s.category === 'communication') }, + tools: { label: 'Tools', steps: setupSteps.filter((s) => s.category === 'tools') }, + } + + return ( +
+
+

Setup Checklist

+

+ Complete these steps to finish setting up your server +

+
+ + {/* Progress Overview */} + + +
+ + {completedCount} of {totalSteps} steps completed + + + {progressPercent}% + +
+ +
+
+ + {/* Steps by Category */} + {Object.entries(categories).map(([key, { label, steps }]) => ( +
+

{label}

+
+ {steps.map((step) => { + const isCompleted = completed.has(step.id) + return ( + + + +
+

+ {step.title} +

+

+ {step.description} +

+
+ {step.href && ( + + )} + {step.externalHref && ( + + )} +
+
+ ) + })} +
+
+ ))} +
+ ) +} diff --git a/src/app/tools/page.tsx b/src/app/tools/page.tsx new file mode 100644 index 0000000..f1ce500 --- /dev/null +++ b/src/app/tools/page.tsx @@ -0,0 +1,70 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { ExternalLink } from 'lucide-react' +import { AVAILABLE_TOOLS } from '@/lib/tools' + +const categoryLabels: Record = { + communication: 'Communication', + productivity: 'Productivity', + development: 'Development', + monitoring: 'Monitoring', + storage: 'Storage', + marketing: 'Marketing', + security: 'Security', + other: 'Other', +} + +export default function ToolsPage() { + const grouped = AVAILABLE_TOOLS.reduce( + (acc, tool) => { + const cat = tool.category + if (!acc[cat]) acc[cat] = [] + acc[cat].push(tool) + return acc + }, + {} as Record + ) + + return ( +
+
+

Tools

+

+ Access all your deployed tools. Click "Open" to go to each tool's web interface. +

+
+ + {Object.entries(grouped).map(([category, tools]) => ( +
+

+ {categoryLabels[category] || category} +

+
+ {tools.map((tool) => ( + + +
+ {tool.name} + + {tool.subdomain} + +
+ {tool.description} +
+ + + +
+ ))} +
+
+ ))} +
+ ) +} diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..167d2d7 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,45 @@ +import NextAuth from 'next-auth' +import Keycloak from 'next-auth/providers/keycloak' + +export const { handlers, auth, signIn, signOut } = NextAuth({ + providers: [ + Keycloak({ + clientId: process.env.KEYCLOAK_CLIENT_ID!, + clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!, + issuer: process.env.KEYCLOAK_ISSUER!, + }), + ], + pages: { + signIn: '/login', + }, + callbacks: { + authorized({ auth }) { + return !!auth?.user + }, + async jwt({ token, account, profile }) { + if (account) { + token.accessToken = account.access_token + token.idToken = account.id_token + token.expiresAt = account.expires_at + token.refreshToken = account.refresh_token + } + if (profile) { + token.name = profile.name + token.email = profile.email + } + return token + }, + async session({ session, token }) { + if (token.accessToken) { + session.accessToken = token.accessToken as string + } + return session + }, + }, +}) + +declare module 'next-auth' { + interface Session { + accessToken?: string + } +} diff --git a/src/components/dashboard/sidebar.tsx b/src/components/dashboard/sidebar.tsx new file mode 100644 index 0000000..e065a02 --- /dev/null +++ b/src/components/dashboard/sidebar.tsx @@ -0,0 +1,187 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { + LayoutDashboard, + Grid3X3, + ClipboardCheck, + Settings, + Mail, + Globe, + Server, + LogOut, + Menu, + X, +} from 'lucide-react' +import { useState } from 'react' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' + +const navigation = [ + { name: 'Dashboard', href: '/', icon: LayoutDashboard }, + { name: 'Tools', href: '/tools', icon: Grid3X3 }, + { name: 'Setup', href: '/setup', icon: ClipboardCheck }, + { + name: 'Settings', + href: '/settings', + icon: Settings, + children: [ + { name: 'Overview', href: '/settings', icon: Settings }, + { name: 'Email', href: '/settings/email', icon: Mail }, + { name: 'Domain', href: '/settings/domain', icon: Globe }, + { name: 'Server', href: '/settings/server', icon: Server }, + ], + }, +] + +interface SidebarProps { + userName?: string | null + userEmail?: string | null +} + +export function Sidebar({ userName, userEmail }: SidebarProps) { + const pathname = usePathname() + const [mobileOpen, setMobileOpen] = useState(false) + + const isActive = (href: string) => { + if (href === '/') return pathname === '/' + return pathname.startsWith(href) + } + + const navContent = ( + <> +
+
+ LB +
+
+

LetsBe

+

Control Panel

+
+
+ + + +
+
+
+ {userName?.charAt(0)?.toUpperCase() || 'U'} +
+
+

{userName || 'User'}

+

{userEmail || ''}

+
+
+ +
+
+
+ + ) + + return ( + <> + {/* Mobile menu button */} +
+ +
+
+ LB +
+ LetsBe Control Panel +
+
+ + {/* Mobile sidebar overlay */} + {mobileOpen && ( +
setMobileOpen(false)} + /> + )} + + {/* Mobile sidebar */} + + + {/* Desktop sidebar */} + + + ) +} diff --git a/src/components/dashboard/tool-status-grid.tsx b/src/components/dashboard/tool-status-grid.tsx new file mode 100644 index 0000000..6facbdf --- /dev/null +++ b/src/components/dashboard/tool-status-grid.tsx @@ -0,0 +1,136 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Card, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + ExternalLink, + CheckCircle2, + XCircle, + AlertCircle, + Cloud, + Shield, + MessageSquare, + Mail, + Container, + Workflow, + Database, + BarChart3, + Calendar, + FileText, + Globe, + Lock, + GitBranch, + Activity, + Send, + Table, + AlertTriangle, + Paintbrush, +} from 'lucide-react' +import type { ToolInfo } from '@/types' + +const iconMap: Record> = { + Cloud, + Shield, + MessageSquare, + Mail, + Container, + Workflow, + Database, + BarChart3, + Calendar, + FileText, + Globe, + Lock, + GitBranch, + Activity, + Send, + Table, + AlertTriangle, + Paintbrush, +} + +function StatusIcon({ status }: { status: string }) { + switch (status) { + case 'running': + return + case 'stopped': + return + case 'error': + return + default: + return + } +} + +function StatusBadge({ status }: { status: string }) { + switch (status) { + case 'running': + return Running + case 'stopped': + return Stopped + case 'error': + return Error + default: + return Unknown + } +} + +interface ToolStatusGridProps { + tools: Omit[] +} + +export function ToolStatusGrid({ tools }: ToolStatusGridProps) { + const [toolStatuses, setToolStatuses] = useState>({}) + + useEffect(() => { + // In production, this would check each tool's health via the orchestrator proxy. + // For now, default all to 'running' as we can't check without the orchestrator. + const statuses: Record = {} + tools.forEach((tool) => { + statuses[tool.id] = 'running' + }) + setToolStatuses(statuses) + }, [tools]) + + return ( +
+ {tools.map((tool) => { + const Icon = iconMap[tool.icon] || Globe + const status = toolStatuses[tool.id] || 'unknown' + + return ( + + +
+
+
+ +
+
+

{tool.name}

+

{tool.subdomain}.domain

+
+
+ +
+

+ {tool.description} +

+
+ + +
+
+
+ ) + })} +
+ ) +} diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..45b0e7c --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const alertVariants = cva( + 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', + { + variants: { + variant: { + default: 'bg-background text-foreground', + destructive: + 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = 'Alert' + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = 'AlertTitle' + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = 'AlertDescription' + +export { Alert, AlertTitle, AlertDescription } diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..7bedca6 --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from 'react' +import * as AvatarPrimitive from '@radix-ui/react-avatar' + +import { cn } from '@/lib/utils' + +const Avatar = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..50882d8 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,40 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const badgeVariants = cva( + 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', + secondary: + 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', + outline: 'text-foreground', + success: + 'border-transparent bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100', + warning: + 'border-transparent bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..5f2702f --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: + 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + secondary: + 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8', + icon: 'h-10 w-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + return ( + + ) + } +) +Button.displayName = 'Button' + +export { Button, buttonVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..fa27798 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = 'Card' + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = 'CardHeader' + +const CardTitle = React.forwardRef< + HTMLHeadingElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = 'CardTitle' + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = 'CardDescription' + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = 'CardContent' + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = 'CardFooter' + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..7d461dc --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as React from 'react' +import * as CheckboxPrimitive from '@radix-ui/react-checkbox' +import { Check } from 'lucide-react' + +import { cn } from '@/lib/utils' + +const Checkbox = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..85a0da5 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,84 @@ +import * as React from 'react' +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' + +import { cn } from '@/lib/utils' + +const DropdownMenu = DropdownMenuPrimitive.Root +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuLabel, + DropdownMenuGroup, +} diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..bee17ea --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = 'Input' + +export { Input } diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..fd1fded --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +import * as React from 'react' +import * as LabelPrimitive from '@radix-ui/react-label' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const labelVariants = cva( + 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' +) + +const Label = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000..035c70c --- /dev/null +++ b/src/components/ui/progress.tsx @@ -0,0 +1,26 @@ +import * as React from 'react' +import * as ProgressPrimitive from '@radix-ui/react-progress' + +import { cn } from '@/lib/utils' + +const Progress = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 0000000..ba29d6a --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,29 @@ +import * as React from 'react' +import * as SeparatorPrimitive from '@radix-ui/react-separator' + +import { cn } from '@/lib/utils' + +const Separator = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = 'horizontal', decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx new file mode 100644 index 0000000..fa2be3a --- /dev/null +++ b/src/components/ui/sheet.tsx @@ -0,0 +1,90 @@ +import * as React from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { X } from 'lucide-react' + +import { cn } from '@/lib/utils' + +const Sheet = DialogPrimitive.Root +const SheetTrigger = DialogPrimitive.Trigger +const SheetClose = DialogPrimitive.Close +const SheetPortal = DialogPrimitive.Portal + +const SheetOverlay = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = DialogPrimitive.Overlay.displayName + +const SheetContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + side?: 'top' | 'bottom' | 'left' | 'right' + } +>(({ side = 'right', className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +SheetContent.displayName = DialogPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetHeader.displayName = 'SheetHeader' + +const SheetTitle = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = DialogPrimitive.Title.displayName + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetTitle, +} diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..c23a30d --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from '@/lib/utils' + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..a21cefe --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,28 @@ +import * as React from 'react' +import * as TooltipPrimitive from '@radix-ui/react-tooltip' + +import { cn } from '@/lib/utils' + +const TooltipProvider = TooltipPrimitive.Provider + +const Tooltip = TooltipPrimitive.Root + +const TooltipTrigger = TooltipPrimitive.Trigger + +const TooltipContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/src/lib/orchestrator.ts b/src/lib/orchestrator.ts new file mode 100644 index 0000000..9e5fb91 --- /dev/null +++ b/src/lib/orchestrator.ts @@ -0,0 +1,67 @@ +const ORCHESTRATOR_URL = process.env.ORCHESTRATOR_URL || 'http://orchestrator:8100' + +export interface OrchestratorHealth { + status: string + uptime_seconds: number + cpu_percent: number + memory_percent: number + disk_percent: number + disk_used_gb: number + disk_total_gb: number + agent_count: number + task_count: number +} + +export interface Agent { + id: string + name: string + status: string + last_heartbeat: string + capabilities: string[] +} + +export interface Task { + id: string + type: string + status: string + created_at: string + completed_at: string | null + result: string | null + error: string | null +} + +async function orchestratorFetch(path: string, options?: RequestInit) { + const url = `${ORCHESTRATOR_URL}${path}` + const res = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + }) + + if (!res.ok) { + throw new Error(`Orchestrator API error: ${res.status} ${res.statusText}`) + } + + return res.json() +} + +export async function getHealth(): Promise { + return orchestratorFetch('/api/v1/health') +} + +export async function getAgents(): Promise { + return orchestratorFetch('/api/v1/agents') +} + +export async function getTasks(limit = 20): Promise { + return orchestratorFetch(`/api/v1/tasks?limit=${limit}`) +} + +export async function triggerPlaybook(playbook: string, params?: Record) { + return orchestratorFetch(`/api/v1/playbooks/${playbook}`, { + method: 'POST', + body: JSON.stringify(params || {}), + }) +} diff --git a/src/lib/tools.ts b/src/lib/tools.ts new file mode 100644 index 0000000..394047d --- /dev/null +++ b/src/lib/tools.ts @@ -0,0 +1,168 @@ +import type { ToolInfo } from '@/types' + +const domain = process.env.TENANT_DOMAIN || 'example.com' + +export const AVAILABLE_TOOLS: Omit[] = [ + { + id: 'nextcloud', + name: 'Nextcloud', + description: 'File sync, sharing, and collaboration platform', + subdomain: 'cloud', + icon: 'Cloud', + category: 'productivity', + url: `https://cloud.${domain}`, + }, + { + id: 'keycloak', + name: 'Keycloak', + description: 'Identity and access management (SSO)', + subdomain: 'auth', + icon: 'Shield', + category: 'security', + url: `https://auth.${domain}`, + }, + { + id: 'chatwoot', + name: 'Chatwoot', + description: 'Customer engagement and live chat platform', + subdomain: 'chat', + icon: 'MessageSquare', + category: 'communication', + url: `https://chat.${domain}`, + }, + { + id: 'poste', + name: 'Poste.io', + description: 'Full-featured email server', + subdomain: 'mail', + icon: 'Mail', + category: 'communication', + url: `https://mail.${domain}`, + }, + { + id: 'portainer', + name: 'Portainer', + description: 'Container management dashboard', + subdomain: 'portainer', + icon: 'Container', + category: 'monitoring', + url: `https://portainer.${domain}`, + }, + { + id: 'n8n', + name: 'n8n', + description: 'Workflow automation tool', + subdomain: 'n8n', + icon: 'Workflow', + category: 'productivity', + url: `https://n8n.${domain}`, + }, + { + id: 'minio', + name: 'MinIO', + description: 'S3-compatible object storage', + subdomain: 'storage', + icon: 'Database', + category: 'storage', + url: `https://storage.${domain}`, + }, + { + id: 'umami', + name: 'Umami', + description: 'Privacy-focused web analytics', + subdomain: 'analytics', + icon: 'BarChart3', + category: 'monitoring', + url: `https://analytics.${domain}`, + }, + { + id: 'calcom', + name: 'Cal.com', + description: 'Scheduling and appointment booking', + subdomain: 'cal', + icon: 'Calendar', + category: 'productivity', + url: `https://cal.${domain}`, + }, + { + id: 'ghost', + name: 'Ghost', + description: 'Publishing and newsletter platform', + subdomain: 'blog', + icon: 'FileText', + category: 'marketing', + url: `https://blog.${domain}`, + }, + { + id: 'wordpress', + name: 'WordPress', + description: 'Content management system', + subdomain: 'www', + icon: 'Globe', + category: 'marketing', + url: `https://www.${domain}`, + }, + { + id: 'vaultwarden', + name: 'Vaultwarden', + description: 'Password manager (Bitwarden compatible)', + subdomain: 'vault', + icon: 'Lock', + category: 'security', + url: `https://vault.${domain}`, + }, + { + id: 'gitea', + name: 'Gitea', + description: 'Self-hosted Git service', + subdomain: 'git', + icon: 'GitBranch', + category: 'development', + url: `https://git.${domain}`, + }, + { + id: 'uptime-kuma', + name: 'Uptime Kuma', + description: 'Uptime monitoring tool', + subdomain: 'status', + icon: 'Activity', + category: 'monitoring', + url: `https://status.${domain}`, + }, + { + id: 'listmonk', + name: 'Listmonk', + description: 'Newsletter and mailing list manager', + subdomain: 'newsletter', + icon: 'Send', + category: 'marketing', + url: `https://newsletter.${domain}`, + }, + { + id: 'nocodb', + name: 'NocoDB', + description: 'Open-source Airtable alternative', + subdomain: 'db', + icon: 'Table', + category: 'productivity', + url: `https://db.${domain}`, + }, + { + id: 'glitchtip', + name: 'GlitchTip', + description: 'Error tracking and monitoring', + subdomain: 'errors', + icon: 'AlertTriangle', + category: 'monitoring', + url: `https://errors.${domain}`, + }, + { + id: 'penpot', + name: 'Penpot', + description: 'Design and prototyping platform', + subdomain: 'design', + icon: 'Paintbrush', + category: 'productivity', + url: `https://design.${domain}`, + }, +] diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..d32b0fe --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..b4e8e14 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,7 @@ +export { auth as middleware } from '@/auth' + +export const config = { + matcher: [ + '/((?!api/auth|login|_next/static|_next/image|favicon.ico).*)', + ], +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..99d688a --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,45 @@ +export interface ToolInfo { + id: string + name: string + description: string + subdomain: string + icon: string + category: 'communication' | 'productivity' | 'development' | 'monitoring' | 'storage' | 'marketing' | 'security' | 'other' + status: 'running' | 'stopped' | 'error' | 'unknown' + url: string +} + +export interface SetupStep { + id: string + title: string + description: string + completed: boolean + href?: string + action?: string +} + +export interface ServerInfo { + hostname: string + ip: string + os: string + uptime: string + cpuUsage: number + memoryUsage: number + diskUsage: number + diskUsedGb: number + diskTotalGb: number +} + +export interface SmtpConfig { + host: string + port: number + username: string + password: string + fromEmail: string + fromName: string +} + +export interface DomainInfo { + domain: string + subdomains: { name: string; target: string; status: 'active' | 'pending' | 'error' }[] +} diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..ade7f8b --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,57 @@ +import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ['class'], + content: [ + './src/pages/**/*.{js,ts,jsx,tsx,mdx}', + './src/components/**/*.{js,ts,jsx,tsx,mdx}', + './src/app/**/*.{js,ts,jsx,tsx,mdx}', + ], + 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))', + }, + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + }, + }, + plugins: [require('tailwindcss-animate')], +} + +export default config diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..288c835 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + }, + "target": "ES2022" + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": ["node_modules"] +}