Add dd0c Console — modular React dashboard with drift module
- Vite + React + TypeScript + Tailwind CSS - Shell: auth provider, entitlement gate, dynamic sidebar - Shared components: Button, Card, Table, Badge, Modal, EmptyState, PageHeader - Drift module: dashboard, detail view, report viewer - Module manifest pattern for pluggable product UIs - Dockerfile: multi-stage node:22-slim → nginx:alpine - 189KB JS + 17KB CSS (65KB gzipped)
This commit is contained in:
63
products/console/src/shell/App.tsx
Normal file
63
products/console/src/shell/App.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from './AuthProvider';
|
||||
import { LoginPage } from './LoginPage';
|
||||
import { Layout } from './Layout';
|
||||
import { driftManifest } from '../modules/drift/manifest.js';
|
||||
import type { ModuleManifest } from '../modules/drift/manifest.js';
|
||||
|
||||
const allModules: ModuleManifest[] = [driftManifest];
|
||||
|
||||
function OverviewPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Welcome to dd0c Console</h1>
|
||||
<p className="text-gray-500 text-sm">Select a module from the sidebar to get started.</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-6">
|
||||
{allModules.map((mod) => (
|
||||
<a
|
||||
key={mod.id}
|
||||
href={mod.path}
|
||||
className="bg-gray-900 border border-gray-800 rounded-xl p-5 hover:border-indigo-500/50 transition-colors group"
|
||||
>
|
||||
<span className="text-2xl">{mod.icon}</span>
|
||||
<h3 className="text-white font-semibold mt-3 group-hover:text-indigo-400 transition-colors">
|
||||
{mod.name}
|
||||
</h3>
|
||||
<p className="text-gray-500 text-sm mt-1">Manage {mod.name.toLowerCase()}</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-950">
|
||||
<div className="animate-pulse text-indigo-500 text-lg font-semibold">Loading…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Layout modules={allModules} />}>
|
||||
<Route index element={<OverviewPage />} />
|
||||
{allModules.map((mod) =>
|
||||
mod.routes.map((route) => (
|
||||
<Route key={route.path} path={route.path} element={route.element} />
|
||||
))
|
||||
)}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
86
products/console/src/shell/AuthProvider.tsx
Normal file
86
products/console/src/shell/AuthProvider.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
|
||||
import { apiFetch } from './api';
|
||||
|
||||
interface User {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
entitlements?: string[];
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isLoading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
signup: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
function decodeJwtPayload(token: string): User {
|
||||
const base64 = token.split('.')[1];
|
||||
const json = atob(base64.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
return JSON.parse(json);
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('dd0c_token');
|
||||
if (stored) {
|
||||
try {
|
||||
const payload = decodeJwtPayload(stored);
|
||||
setUser(payload);
|
||||
setToken(stored);
|
||||
} catch {
|
||||
localStorage.removeItem('dd0c_token');
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const res = await apiFetch<{ token: string }>('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
skipAuth: true,
|
||||
});
|
||||
localStorage.setItem('dd0c_token', res.token);
|
||||
setToken(res.token);
|
||||
setUser(decodeJwtPayload(res.token));
|
||||
}, []);
|
||||
|
||||
const signup = useCallback(async (email: string, password: string) => {
|
||||
const res = await apiFetch<{ token: string }>('/api/v1/auth/signup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
skipAuth: true,
|
||||
});
|
||||
localStorage.setItem('dd0c_token', res.token);
|
||||
setToken(res.token);
|
||||
setUser(decodeJwtPayload(res.token));
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem('dd0c_token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, isLoading, login, signup, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextType {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
27
products/console/src/shell/EntitlementGate.tsx
Normal file
27
products/console/src/shell/EntitlementGate.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from './AuthProvider';
|
||||
|
||||
interface EntitlementGateProps {
|
||||
entitlement: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EntitlementGate({ entitlement, children }: EntitlementGateProps) {
|
||||
const { user } = useAuth();
|
||||
|
||||
// Dev mode: if no entitlements array, show everything
|
||||
if (!user?.entitlements || user.entitlements.length === 0) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (!user.entitlements.includes(entitlement)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function hasEntitlement(entitlements: string[] | undefined, key: string): boolean {
|
||||
if (!entitlements || entitlements.length === 0) return true; // dev mode
|
||||
return entitlements.includes(key);
|
||||
}
|
||||
52
products/console/src/shell/Layout.tsx
Normal file
52
products/console/src/shell/Layout.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import type { ModuleManifest } from '../modules/drift/manifest.js';
|
||||
|
||||
interface LayoutProps {
|
||||
modules: ModuleManifest[];
|
||||
}
|
||||
|
||||
export function Layout({ modules }: LayoutProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950">
|
||||
<Sidebar
|
||||
modules={modules}
|
||||
collapsed={collapsed}
|
||||
onToggle={() => setCollapsed(!collapsed)}
|
||||
/>
|
||||
|
||||
{/* Topbar */}
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-14 z-20 flex items-center px-4 border-b border-gray-800 bg-gray-950/80 backdrop-blur-sm transition-all duration-200 ${
|
||||
collapsed ? 'left-0 lg:left-16' : 'left-0 lg:left-60'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="text-gray-400 hover:text-white transition-colors mr-4"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-gray-600">v0.1.0</span>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<main
|
||||
className={`pt-14 min-h-screen transition-all duration-200 ${
|
||||
collapsed ? 'lg:pl-16' : 'lg:pl-60'
|
||||
}`}
|
||||
>
|
||||
<div className="p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
products/console/src/shell/LoginPage.tsx
Normal file
103
products/console/src/shell/LoginPage.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from './AuthProvider';
|
||||
import { Button } from '../shared/Button';
|
||||
|
||||
export function LoginPage() {
|
||||
const { login, signup } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isSignup, setIsSignup] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isSignup) {
|
||||
await signup(email, password);
|
||||
} else {
|
||||
await login(email, password);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Authentication failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-950 px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-white tracking-tight">
|
||||
<span className="text-indigo-500">dd0c</span> Console
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-2 text-sm">DevOps intelligence platform</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-800 rounded-xl p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{isSignup ? 'Create account' : 'Sign in'}
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-400 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-sm"
|
||||
placeholder="you@company.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-400 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-sm"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="primary" className="w-full" disabled={loading}>
|
||||
{loading ? 'Loading…' : isSignup ? 'Create account' : 'Sign in'}
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
{isSignup ? 'Already have an account?' : "Don't have an account?"}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setIsSignup(!isSignup); setError(null); }}
|
||||
className="text-indigo-400 hover:text-indigo-300 font-medium"
|
||||
>
|
||||
{isSignup ? 'Sign in' : 'Sign up'}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-xs text-gray-600 mt-6">
|
||||
© {new Date().getFullYear()} dd0c · All rights reserved
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
products/console/src/shell/Sidebar.tsx
Normal file
121
products/console/src/shell/Sidebar.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React, { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useAuth } from './AuthProvider';
|
||||
import { hasEntitlement } from './EntitlementGate';
|
||||
import type { ModuleManifest } from '../modules/drift/manifest.js';
|
||||
|
||||
interface SidebarProps {
|
||||
modules: ModuleManifest[];
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ modules, collapsed, onToggle }: SidebarProps) {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const visibleModules = modules.filter((m) =>
|
||||
hasEntitlement(user?.entitlements, m.entitlement)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
{!collapsed && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-30 lg:hidden"
|
||||
onClick={onToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={`fixed top-0 left-0 h-full z-40 bg-gray-950 border-r border-gray-800 flex flex-col transition-all duration-200 ${
|
||||
collapsed ? '-translate-x-full lg:translate-x-0 lg:w-16' : 'w-60'
|
||||
}`}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center h-14 px-4 border-b border-gray-800 shrink-0">
|
||||
{!collapsed && (
|
||||
<span className="text-lg font-bold tracking-tight">
|
||||
<span className="text-indigo-500">dd0c</span>
|
||||
<span className="text-gray-400 ml-1 text-sm font-normal">console</span>
|
||||
</span>
|
||||
)}
|
||||
{collapsed && (
|
||||
<span className="text-lg font-bold text-indigo-500 mx-auto">d</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 py-3 overflow-y-auto">
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-2 mx-2 rounded-lg text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-indigo-500/10 text-indigo-400'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-800/50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="text-base">🏠</span>
|
||||
{!collapsed && <span>Overview</span>}
|
||||
</NavLink>
|
||||
|
||||
{visibleModules.length > 0 && (
|
||||
<div className="mt-4">
|
||||
{!collapsed && (
|
||||
<p className="px-4 mx-2 mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-600">
|
||||
Modules
|
||||
</p>
|
||||
)}
|
||||
{visibleModules.map((mod) => (
|
||||
<NavLink
|
||||
key={mod.id}
|
||||
to={mod.path}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-2 mx-2 rounded-lg text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-indigo-500/10 text-indigo-400'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-800/50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="text-base">{mod.icon}</span>
|
||||
{!collapsed && <span>{mod.name}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* User footer */}
|
||||
<div className="border-t border-gray-800 p-3 shrink-0">
|
||||
{!collapsed ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-white truncate">{user?.email}</p>
|
||||
<p className="text-[10px] text-gray-600 truncate">{user?.tenantId}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-gray-500 hover:text-red-400 transition-colors text-xs shrink-0 ml-2"
|
||||
title="Sign out"
|
||||
>
|
||||
↪
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-gray-500 hover:text-red-400 transition-colors text-xs w-full text-center"
|
||||
title="Sign out"
|
||||
>
|
||||
↪
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
40
products/console/src/shell/api.ts
Normal file
40
products/console/src/shell/api.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
|
||||
interface FetchOptions extends RequestInit {
|
||||
skipAuth?: boolean;
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, options: FetchOptions = {}): Promise<T> {
|
||||
const { skipAuth, ...fetchOptions } = options;
|
||||
const headers = new Headers(fetchOptions.headers);
|
||||
|
||||
if (!skipAuth) {
|
||||
const token = localStorage.getItem('dd0c_token');
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!headers.has('Content-Type') && fetchOptions.body) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...fetchOptions,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new ApiError(res.status, body);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, public body: string) {
|
||||
super(`API Error ${status}: ${body}`);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user