@programinglive/dev-workflow-mcp-server 1.7.11 → 1.7.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +60 -0
- package/.env.example +1 -0
- package/.env.production +16 -0
- package/CHANGELOG.md +28 -0
- package/Dockerfile +32 -0
- package/README.md +20 -0
- package/db/migrations/002_create_users_table.sql +22 -0
- package/db/migrations/003_create_workflow_history.sql +14 -0
- package/db/migrations/004_fix_workflow_history.sql +3 -0
- package/docker-compose.yml +24 -0
- package/docs/GCP_DEPLOYMENT.md +298 -0
- package/docs/product/PRD.md +1 -1
- package/docs/release-notes/RELEASE_NOTES.md +17 -0
- package/package.json +7 -2
- package/scripts/add-user.js +52 -0
- package/scripts/create-admin.js +47 -0
- package/scripts/deploy-gcloud.ps1 +160 -0
- package/scripts/deploy-gcloud.sh +154 -0
- package/web/__tests__/api/workflow-history.test.js +61 -0
- package/web/app/docs/page.tsx +1 -1
- package/web/app/history/page.tsx +213 -0
- package/web/app/layout.tsx +1 -1
- package/web/app/login/page.tsx +144 -0
- package/web/app/page.tsx +0 -2
- package/web/components/Hero.tsx +1 -1
- package/web/components/History.tsx +63 -16
- package/web/components/Navbar.tsx +111 -9
- package/web/lib/auth.js +75 -0
- package/web/middleware/auth.js +26 -0
- package/web/next.config.ts +4 -0
- package/web/package-lock.json +40 -40
- package/web/package.json +6 -5
- package/web/scripts/smoke-test.js +30 -0
- package/web/server.js +163 -18
- package/verify-db-mysql.js +0 -92
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import Image from 'next/image';
|
|
6
|
+
import { Lock, User } from 'lucide-react';
|
|
7
|
+
|
|
8
|
+
export default function LoginPage() {
|
|
9
|
+
const [username, setUsername] = useState('');
|
|
10
|
+
const [password, setPassword] = useState('');
|
|
11
|
+
const [error, setError] = useState('');
|
|
12
|
+
const [loading, setLoading] = useState(false);
|
|
13
|
+
const [authChecking, setAuthChecking] = useState(true);
|
|
14
|
+
const router = useRouter();
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
// Check if already logged in
|
|
18
|
+
const mightBeLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
|
|
19
|
+
if (!mightBeLoggedIn) {
|
|
20
|
+
setAuthChecking(false);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
fetch('/api/auth/me')
|
|
25
|
+
.then(res => {
|
|
26
|
+
if (res.ok) {
|
|
27
|
+
router.push('/history');
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
.catch(() => { })
|
|
31
|
+
.finally(() => setAuthChecking(false));
|
|
32
|
+
}, [router]);
|
|
33
|
+
|
|
34
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
setError('');
|
|
37
|
+
setLoading(true);
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const response = await fetch('/api/auth/login', {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({ username, password }),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const data = await response.json();
|
|
47
|
+
|
|
48
|
+
if (response.ok) {
|
|
49
|
+
localStorage.setItem('isLoggedIn', 'true');
|
|
50
|
+
window.dispatchEvent(new Event('authUpdate'));
|
|
51
|
+
router.push('/history');
|
|
52
|
+
} else {
|
|
53
|
+
setError(data.error || 'Login failed');
|
|
54
|
+
}
|
|
55
|
+
} catch (err) {
|
|
56
|
+
setError('Network error. Please try again.');
|
|
57
|
+
} finally {
|
|
58
|
+
setLoading(false);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
if (authChecking) {
|
|
63
|
+
return null; // Or a loading spinner
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-[#0a0a0f] px-4 transition-colors duration-300">
|
|
68
|
+
<div className="max-w-md w-full space-y-8 bg-white dark:bg-white/5 p-8 rounded-2xl shadow-xl border border-gray-100 dark:border-white/10">
|
|
69
|
+
<div className="text-center">
|
|
70
|
+
<div className="mx-auto h-12 w-12 relative mb-4">
|
|
71
|
+
<div className="absolute inset-0 bg-blue-500 rounded-xl blur opacity-20 dark:opacity-40"></div>
|
|
72
|
+
<Image src="/assets/logo.png" alt="Logo" width={48} height={48} className="relative rounded-xl" />
|
|
73
|
+
</div>
|
|
74
|
+
<h2 className="text-3xl font-bold text-gray-900 dark:text-white tracking-tight">
|
|
75
|
+
Welcome Back
|
|
76
|
+
</h2>
|
|
77
|
+
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
|
78
|
+
Sign in to access your workflow history
|
|
79
|
+
</p>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
|
83
|
+
{error && (
|
|
84
|
+
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 border border-red-200 dark:border-red-900/50">
|
|
85
|
+
<p className="text-sm text-red-600 dark:text-red-400 text-center font-medium">{error}</p>
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
|
|
89
|
+
<div className="space-y-4">
|
|
90
|
+
<div className="relative">
|
|
91
|
+
<label htmlFor="username" className="sr-only">Username</label>
|
|
92
|
+
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
93
|
+
<User className="h-5 w-5 text-gray-400" />
|
|
94
|
+
</div>
|
|
95
|
+
<input
|
|
96
|
+
id="username"
|
|
97
|
+
name="username"
|
|
98
|
+
type="text"
|
|
99
|
+
required
|
|
100
|
+
className="appearance-none block w-full pl-10 pr-3 py-3 border border-gray-300 dark:border-white/10 rounded-xl placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-black/20 text-gray-900 dark:text-white sm:text-sm transition-all"
|
|
101
|
+
placeholder="Username"
|
|
102
|
+
value={username}
|
|
103
|
+
onChange={(e) => setUsername(e.target.value)}
|
|
104
|
+
disabled={loading}
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
107
|
+
<div className="relative">
|
|
108
|
+
<label htmlFor="password" className="sr-only">Password</label>
|
|
109
|
+
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
110
|
+
<Lock className="h-5 w-5 text-gray-400" />
|
|
111
|
+
</div>
|
|
112
|
+
<input
|
|
113
|
+
id="password"
|
|
114
|
+
name="password"
|
|
115
|
+
type="password"
|
|
116
|
+
required
|
|
117
|
+
className="appearance-none block w-full pl-10 pr-3 py-3 border border-gray-300 dark:border-white/10 rounded-xl placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-black/20 text-gray-900 dark:text-white sm:text-sm transition-all"
|
|
118
|
+
placeholder="Password"
|
|
119
|
+
value={password}
|
|
120
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
121
|
+
disabled={loading}
|
|
122
|
+
/>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<div>
|
|
127
|
+
<button
|
|
128
|
+
type="submit"
|
|
129
|
+
disabled={loading}
|
|
130
|
+
className="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg shadow-blue-500/30 hover:shadow-blue-500/40"
|
|
131
|
+
>
|
|
132
|
+
{loading ? (
|
|
133
|
+
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
134
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
135
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
136
|
+
</svg>
|
|
137
|
+
) : 'Sign in'}
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
</form>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
package/web/app/page.tsx
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import Hero from "@/components/Hero";
|
|
2
2
|
import Features from "@/components/Features";
|
|
3
|
-
import History from "@/components/History";
|
|
4
3
|
import ApiReference from "@/components/ApiReference";
|
|
5
4
|
|
|
6
5
|
export default function Home() {
|
|
@@ -8,7 +7,6 @@ export default function Home() {
|
|
|
8
7
|
<main>
|
|
9
8
|
<Hero />
|
|
10
9
|
<Features />
|
|
11
|
-
<History />
|
|
12
10
|
<ApiReference />
|
|
13
11
|
</main>
|
|
14
12
|
);
|
package/web/components/Hero.tsx
CHANGED
|
@@ -3,8 +3,13 @@
|
|
|
3
3
|
import { useState, useEffect } from "react";
|
|
4
4
|
|
|
5
5
|
type HistoryItem = {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
id: number;
|
|
7
|
+
task_type: string;
|
|
8
|
+
description: string;
|
|
9
|
+
commit_message: string;
|
|
10
|
+
completed_at: string;
|
|
11
|
+
tests_passed: boolean;
|
|
12
|
+
documentation_type: string;
|
|
8
13
|
};
|
|
9
14
|
|
|
10
15
|
type SummaryData = {
|
|
@@ -25,19 +30,47 @@ export default function History() {
|
|
|
25
30
|
});
|
|
26
31
|
|
|
27
32
|
const loadData = async () => {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
try {
|
|
34
|
+
// Fetch workflow history from API
|
|
35
|
+
const response = await fetch('/api/workflow/history');
|
|
36
|
+
|
|
37
|
+
if (response.ok) {
|
|
38
|
+
const data = await response.json();
|
|
39
|
+
setHistory(data.history || []);
|
|
40
|
+
setTotalPages(Math.ceil((data.history?.length || 0) / 10));
|
|
41
|
+
setSummary({
|
|
42
|
+
summary: {
|
|
43
|
+
totalTasks: data.history?.length || 0,
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
} else {
|
|
47
|
+
// Not authenticated or error - show empty state
|
|
48
|
+
setHistory([]);
|
|
49
|
+
setTotalPages(1);
|
|
50
|
+
setSummary({
|
|
51
|
+
summary: {
|
|
52
|
+
totalTasks: 0,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
// Silently handle errors - show empty state
|
|
58
|
+
setHistory([]);
|
|
59
|
+
setTotalPages(1);
|
|
60
|
+
setSummary({
|
|
61
|
+
summary: {
|
|
62
|
+
totalTasks: 0,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
37
66
|
};
|
|
38
67
|
|
|
39
68
|
useEffect(() => {
|
|
40
|
-
|
|
69
|
+
// Only load if we're likely authenticated (reduces 401 errors)
|
|
70
|
+
// History component should only be on authenticated pages anyway
|
|
71
|
+
if (typeof window !== 'undefined' && window.location.pathname === '/history') {
|
|
72
|
+
loadData();
|
|
73
|
+
}
|
|
41
74
|
}, [page]); // Reload when page changes
|
|
42
75
|
|
|
43
76
|
const handleApplyFilters = () => {
|
|
@@ -92,10 +125,24 @@ export default function History() {
|
|
|
92
125
|
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Recent History</h3>
|
|
93
126
|
<div className="space-y-3">
|
|
94
127
|
{history.length > 0 ? (
|
|
95
|
-
history.map((row
|
|
96
|
-
<div key={
|
|
97
|
-
<div className="
|
|
98
|
-
|
|
128
|
+
history.map((row) => (
|
|
129
|
+
<div key={row.id} className="p-4 rounded-lg bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10">
|
|
130
|
+
<div className="flex items-center gap-2 mb-2">
|
|
131
|
+
<span className={`px-2 py-1 rounded text-xs font-medium ${row.task_type === 'feature' ? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200' :
|
|
132
|
+
row.task_type === 'bugfix' ? 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200' :
|
|
133
|
+
'bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200'
|
|
134
|
+
}`}>
|
|
135
|
+
{row.task_type}
|
|
136
|
+
</span>
|
|
137
|
+
<span className={`px-2 py-1 rounded text-xs font-medium ${row.tests_passed ? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200' :
|
|
138
|
+
'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200'
|
|
139
|
+
}`}>
|
|
140
|
+
{row.tests_passed ? '✓ Tests' : '⚠ Skipped'}
|
|
141
|
+
</span>
|
|
142
|
+
</div>
|
|
143
|
+
<div className="font-semibold text-gray-900 dark:text-white">{row.description}</div>
|
|
144
|
+
<div className="text-sm text-gray-700 dark:text-gray-300 mt-1">{row.commit_message}</div>
|
|
145
|
+
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">{new Date(row.completed_at).toLocaleString()}</div>
|
|
99
146
|
</div>
|
|
100
147
|
))
|
|
101
148
|
) : (
|
|
@@ -2,22 +2,67 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect } from "react";
|
|
4
4
|
import Link from "next/link";
|
|
5
|
-
import { Github, Menu, X, Moon, Sun } from "lucide-react";
|
|
5
|
+
import { Github, Menu, X, Moon, Sun, LogOut } from "lucide-react";
|
|
6
6
|
import Image from "next/image";
|
|
7
7
|
|
|
8
8
|
export default function Navbar() {
|
|
9
9
|
const [isOpen, setIsOpen] = useState(false);
|
|
10
10
|
const [theme, setTheme] = useState<"light" | "dark">("dark");
|
|
11
|
+
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
|
12
|
+
const [username, setUsername] = useState<string | null>(null);
|
|
11
13
|
|
|
12
14
|
useEffect(() => {
|
|
13
15
|
// Get theme from localStorage or system preference
|
|
14
16
|
const savedTheme = localStorage.getItem("theme") as "light" | "dark" | null;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
18
|
+
|
|
19
|
+
const initialTheme = savedTheme || (prefersDark ? "dark" : "light");
|
|
20
|
+
setTheme(initialTheme);
|
|
21
|
+
|
|
22
|
+
// Apply the class immediately
|
|
23
|
+
document.documentElement.classList.toggle("dark", initialTheme === "dark");
|
|
24
|
+
|
|
25
|
+
// Check authentication status
|
|
26
|
+
const checkAuth = () => {
|
|
27
|
+
// Check authentication status ONLY if we think we're logged in OR if we just received an update event
|
|
28
|
+
// This prevents 401 errors from cluttering the console for guests
|
|
29
|
+
const mightBeLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
|
|
30
|
+
|
|
31
|
+
if (mightBeLoggedIn) {
|
|
32
|
+
fetch('/api/auth/me')
|
|
33
|
+
.then(res => {
|
|
34
|
+
if (res.ok) {
|
|
35
|
+
return res.json();
|
|
36
|
+
}
|
|
37
|
+
throw new Error('Not authenticated');
|
|
38
|
+
})
|
|
39
|
+
.then(data => {
|
|
40
|
+
setIsAuthenticated(true);
|
|
41
|
+
setUsername(data.user?.username || 'User');
|
|
42
|
+
// Ensure local storage is in sync
|
|
43
|
+
localStorage.setItem('isLoggedIn', 'true');
|
|
44
|
+
})
|
|
45
|
+
.catch(() => {
|
|
46
|
+
// Session invalid/expired
|
|
47
|
+
localStorage.removeItem('isLoggedIn');
|
|
48
|
+
setIsAuthenticated(false);
|
|
49
|
+
setUsername(null);
|
|
50
|
+
});
|
|
51
|
+
} else {
|
|
52
|
+
setIsAuthenticated(false);
|
|
53
|
+
setUsername(null);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Initial check
|
|
58
|
+
checkAuth();
|
|
59
|
+
|
|
60
|
+
// Listen for auth updates (login/logout/validated session)
|
|
61
|
+
window.addEventListener('authUpdate', checkAuth);
|
|
62
|
+
|
|
63
|
+
return () => {
|
|
64
|
+
window.removeEventListener('authUpdate', checkAuth);
|
|
65
|
+
};
|
|
21
66
|
}, []);
|
|
22
67
|
|
|
23
68
|
const toggleTheme = () => {
|
|
@@ -27,6 +72,19 @@ export default function Navbar() {
|
|
|
27
72
|
document.documentElement.classList.toggle("dark", newTheme === "dark");
|
|
28
73
|
};
|
|
29
74
|
|
|
75
|
+
const handleLogout = async () => {
|
|
76
|
+
try {
|
|
77
|
+
await fetch('/api/auth/logout', { method: 'POST' });
|
|
78
|
+
localStorage.removeItem('isLoggedIn');
|
|
79
|
+
setIsAuthenticated(false);
|
|
80
|
+
setUsername(null);
|
|
81
|
+
window.dispatchEvent(new Event('authUpdate'));
|
|
82
|
+
window.location.href = '/login';
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error('Logout error:', error);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
30
88
|
return (
|
|
31
89
|
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/80 dark:bg-[#0a0a0f]/80 backdrop-blur-lg border-b border-gray-200 dark:border-white/10 transition-colors duration-300">
|
|
32
90
|
<div className="max-w-7xl mx-auto px-3 sm:px-4 lg:px-8">
|
|
@@ -37,7 +95,7 @@ export default function Navbar() {
|
|
|
37
95
|
</Link>
|
|
38
96
|
<div className="hidden md:flex items-center gap-3 lg:gap-6">
|
|
39
97
|
<Link href="/#features" className="text-xs lg:text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition">Features</Link>
|
|
40
|
-
<Link href="
|
|
98
|
+
<Link href="/history" className="text-xs lg:text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition">History</Link>
|
|
41
99
|
<Link href="/#api" className="text-xs lg:text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition">API</Link>
|
|
42
100
|
<Link href="/docs" className="text-xs lg:text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition">Docs</Link>
|
|
43
101
|
<button
|
|
@@ -60,6 +118,28 @@ export default function Navbar() {
|
|
|
60
118
|
<Github className="w-3.5 h-3.5 lg:w-4 lg:h-4" />
|
|
61
119
|
GitHub
|
|
62
120
|
</a>
|
|
121
|
+
{isAuthenticated && (
|
|
122
|
+
<div className="flex items-center gap-4 pl-4 border-l border-gray-200 dark:border-white/10">
|
|
123
|
+
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
|
124
|
+
{username}
|
|
125
|
+
</span>
|
|
126
|
+
<button
|
|
127
|
+
onClick={handleLogout}
|
|
128
|
+
className="flex items-center gap-1.5 px-3 py-1.5 lg:px-4 lg:py-2 bg-red-100 dark:bg-red-900/50 hover:bg-red-200 dark:hover:bg-red-800/50 rounded-lg transition text-xs lg:text-sm text-red-700 dark:text-red-300"
|
|
129
|
+
>
|
|
130
|
+
<LogOut className="w-3.5 h-3.5 lg:w-4 lg:h-4" />
|
|
131
|
+
Logout
|
|
132
|
+
</button>
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
{!isAuthenticated && (
|
|
136
|
+
<Link
|
|
137
|
+
href="/login"
|
|
138
|
+
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition"
|
|
139
|
+
>
|
|
140
|
+
Login
|
|
141
|
+
</Link>
|
|
142
|
+
)}
|
|
63
143
|
</div>
|
|
64
144
|
<div className="md:hidden flex items-center gap-2">
|
|
65
145
|
<button
|
|
@@ -86,8 +166,13 @@ export default function Navbar() {
|
|
|
86
166
|
{isOpen && (
|
|
87
167
|
<div className="md:hidden border-t border-gray-200 dark:border-white/10 bg-white dark:bg-[#0a0a0f] transition-colors duration-300">
|
|
88
168
|
<div className="px-4 py-4 space-y-3">
|
|
169
|
+
{isAuthenticated && (
|
|
170
|
+
<div className="px-3 py-2 text-sm font-medium text-gray-900 dark:text-white border-b border-gray-200 dark:border-white/10 mb-2">
|
|
171
|
+
Signed in as {username}
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
89
174
|
<Link href="/#features" className="block text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition" onClick={() => setIsOpen(false)}>Features</Link>
|
|
90
|
-
<Link href="
|
|
175
|
+
<Link href="/history" className="block text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition" onClick={() => setIsOpen(false)}>History</Link>
|
|
91
176
|
<Link href="/#api" className="block text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition" onClick={() => setIsOpen(false)}>API</Link>
|
|
92
177
|
<Link href="/docs" className="block text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition" onClick={() => setIsOpen(false)}>Docs</Link>
|
|
93
178
|
<a
|
|
@@ -99,6 +184,23 @@ export default function Navbar() {
|
|
|
99
184
|
<Github className="w-4 h-4" />
|
|
100
185
|
GitHub
|
|
101
186
|
</a>
|
|
187
|
+
{isAuthenticated ? (
|
|
188
|
+
<button
|
|
189
|
+
onClick={() => { setIsOpen(false); handleLogout(); }}
|
|
190
|
+
className="w-full text-left flex items-center gap-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 px-2 py-1.5 rounded transition"
|
|
191
|
+
>
|
|
192
|
+
<LogOut className="w-4 h-4" />
|
|
193
|
+
Logout
|
|
194
|
+
</button>
|
|
195
|
+
) : (
|
|
196
|
+
<Link
|
|
197
|
+
href="/login"
|
|
198
|
+
className="block w-full text-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition"
|
|
199
|
+
onClick={() => setIsOpen(false)}
|
|
200
|
+
>
|
|
201
|
+
Login
|
|
202
|
+
</Link>
|
|
203
|
+
)}
|
|
102
204
|
</div>
|
|
103
205
|
</div>
|
|
104
206
|
)}
|
package/web/lib/auth.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import bcrypt from 'bcrypt';
|
|
2
|
+
import pg from 'pg';
|
|
3
|
+
|
|
4
|
+
const SALT_ROUNDS = 10;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Hash a password using bcrypt
|
|
8
|
+
*/
|
|
9
|
+
export async function hashPassword(password) {
|
|
10
|
+
return bcrypt.hash(password, SALT_ROUNDS);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Verify a password against a hash
|
|
15
|
+
*/
|
|
16
|
+
export async function verifyPassword(password, hash) {
|
|
17
|
+
return bcrypt.compare(password, hash);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create a new user
|
|
22
|
+
*/
|
|
23
|
+
export async function createUser(pool, username, password, email = null) {
|
|
24
|
+
const passwordHash = await hashPassword(password);
|
|
25
|
+
|
|
26
|
+
const result = await pool.query(
|
|
27
|
+
'INSERT INTO users (username, password_hash, email) VALUES ($1, $2, $3) RETURNING id, username, email, created_at',
|
|
28
|
+
[username, passwordHash, email]
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return result.rows[0];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Authenticate a user
|
|
36
|
+
*/
|
|
37
|
+
export async function authenticateUser(pool, username, password) {
|
|
38
|
+
const result = await pool.query(
|
|
39
|
+
'SELECT id, username, password_hash, email FROM users WHERE username = $1',
|
|
40
|
+
[username]
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
if (result.rows.length === 0) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const user = result.rows[0];
|
|
48
|
+
const isValid = await verifyPassword(password, user.password_hash);
|
|
49
|
+
|
|
50
|
+
if (!isValid) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Update last login
|
|
55
|
+
await pool.query(
|
|
56
|
+
'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = $1',
|
|
57
|
+
[user.id]
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Return user without password hash
|
|
61
|
+
const { password_hash, ...userWithoutPassword } = user;
|
|
62
|
+
return userWithoutPassword;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get user by ID
|
|
67
|
+
*/
|
|
68
|
+
export async function getUserById(pool, id) {
|
|
69
|
+
const result = await pool.query(
|
|
70
|
+
'SELECT id, username, email, created_at, last_login FROM users WHERE id = $1',
|
|
71
|
+
[id]
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return result.rows[0] || null;
|
|
75
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middleware to require authentication
|
|
3
|
+
*/
|
|
4
|
+
export function requireAuth(req, res, next) {
|
|
5
|
+
if (req.session && req.session.userId) {
|
|
6
|
+
return next();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// If it's an API request, return 401
|
|
10
|
+
if (req.path.startsWith('/api/')) {
|
|
11
|
+
return res.status(401).json({ error: 'Authentication required' });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Otherwise redirect to login
|
|
15
|
+
res.redirect('/login');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Middleware to redirect if already authenticated
|
|
20
|
+
*/
|
|
21
|
+
export function redirectIfAuthenticated(req, res, next) {
|
|
22
|
+
if (req.session && req.session.userId) {
|
|
23
|
+
return res.redirect('/');
|
|
24
|
+
}
|
|
25
|
+
next();
|
|
26
|
+
}
|
package/web/next.config.ts
CHANGED
|
@@ -3,6 +3,10 @@ import type { NextConfig } from "next";
|
|
|
3
3
|
const nextConfig: NextConfig = {
|
|
4
4
|
// Static export for Netlify deployment
|
|
5
5
|
output: 'export',
|
|
6
|
+
// Set turbopack root to silence multi-lockfile warning
|
|
7
|
+
turbopack: {
|
|
8
|
+
root: __dirname,
|
|
9
|
+
},
|
|
6
10
|
// Disable image optimization for static export
|
|
7
11
|
images: {
|
|
8
12
|
unoptimized: true,
|