Add first-run setup flow for superadmin creation
Some checks failed
Deploy / deploy (push) Failing after 9s

When no users exist in the database, the login page shows a setup
form to create the initial superadmin account. The /api/setup
endpoint is locked once the first user is created.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-02-23 14:50:18 +03:00
parent 76290d9f7e
commit 8d53524e41
2 changed files with 221 additions and 66 deletions

View File

@@ -1,8 +1,9 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
import { Megaphone, Lock, Mail, AlertCircle } from 'lucide-react' import { Megaphone, Lock, Mail, AlertCircle, User, CheckCircle } from 'lucide-react'
import api from '../utils/api'
export default function Login() { export default function Login() {
const navigate = useNavigate() const navigate = useNavigate()
@@ -13,6 +14,16 @@ export default function Login() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [needsSetup, setNeedsSetup] = useState(null)
const [setupName, setSetupName] = useState('')
const [setupEmail, setSetupEmail] = useState('')
const [setupPassword, setSetupPassword] = useState('')
const [setupDone, setSetupDone] = useState(false)
useEffect(() => {
api.get('/setup/status').then(data => setNeedsSetup(data.needsSetup)).catch(() => setNeedsSetup(false))
}, [])
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault()
setError('') setError('')
@@ -28,6 +39,31 @@ export default function Login() {
} }
} }
const handleSetup = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await api.post('/setup', { name: setupName, email: setupEmail, password: setupPassword })
setSetupDone(true)
setNeedsSetup(false)
setEmail(setupEmail)
} catch (err) {
setError(err.message || 'Setup failed')
} finally {
setLoading(false)
}
}
if (needsSetup === null) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-white/30 border-t-white rounded-full animate-spin" />
</div>
)
}
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4"> <div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
@@ -36,82 +72,175 @@ export default function Login() {
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg"> <div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
<Megaphone className="w-8 h-8 text-white" /> <Megaphone className="w-8 h-8 text-white" />
</div> </div>
<h1 className="text-3xl font-bold text-white mb-2">{t('login.title')}</h1> <h1 className="text-3xl font-bold text-white mb-2">
<p className="text-slate-400">{t('login.subtitle')}</p> {needsSetup ? 'Initial Setup' : t('login.title')}
</h1>
<p className="text-slate-400">
{needsSetup ? 'Create your superadmin account to get started' : t('login.subtitle')}
</p>
</div> </div>
{/* Login Card */} {/* Success Message */}
{setupDone && (
<div className="flex items-center gap-2 p-3 mb-4 bg-green-500/10 border border-green-500/30 rounded-lg">
<CheckCircle className="w-5 h-5 text-green-400 shrink-0" />
<p className="text-sm text-green-400">Account created. You can now log in.</p>
</div>
)}
{/* Card */}
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl"> <div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl">
<form onSubmit={handleSubmit} className="space-y-5"> {needsSetup ? (
{/* Email */} <form onSubmit={handleSetup} className="space-y-5">
<div> {/* Name */}
<label className="block text-sm font-medium text-slate-300 mb-2"> <div>
{t('auth.email')} <label className="block text-sm font-medium text-slate-300 mb-2">Name</label>
</label> <div className="relative">
<div className="relative"> <User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" /> <input
<input type="text"
type="email" value={setupName}
value={email} onChange={(e) => setSetupName(e.target.value)}
onChange={(e) => setEmail(e.target.value)} className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
dir="auto" placeholder="Your name"
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" required
placeholder="user@company.com" autoFocus
required />
autoFocus </div>
/>
</div> </div>
</div>
{/* Password */} {/* Email */}
<div> <div>
<label className="block text-sm font-medium text-slate-300 mb-2"> <label className="block text-sm font-medium text-slate-300 mb-2">Email</label>
{t('auth.password')} <div className="relative">
</label> <Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<div className="relative"> <input
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" /> type="email"
<input value={setupEmail}
type="password" onChange={(e) => setSetupEmail(e.target.value)}
value={password} dir="auto"
onChange={(e) => setPassword(e.target.value)} className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" placeholder="admin@company.com"
placeholder="••••••••" required
required />
/> </div>
</div> </div>
</div>
{/* Error Message */} {/* Password */}
{error && ( <div>
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg"> <label className="block text-sm font-medium text-slate-300 mb-2">Password</label>
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" /> <div className="relative">
<p className="text-sm text-red-400">{error}</p> <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="password"
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="Choose a strong password"
required
minLength={6}
/>
</div>
</div> </div>
)}
{/* Submit Button */} {/* Error */}
<button {error && (
type="submit" <div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
disabled={loading} <AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed" <p className="text-sm text-red-400">{error}</p>
> </div>
{loading ? (
<span className="flex items-center justify-center gap-2">
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{t('auth.signingIn')}
</span>
) : (
t('auth.loginBtn')
)} )}
</button>
</form> {/* Submit */}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Creating account...
</span>
) : (
'Create Superadmin Account'
)}
</button>
</form>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
{/* Email */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
{t('auth.email')}
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
dir="auto"
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="user@company.com"
required
autoFocus
/>
</div>
</div>
{/* Password */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
{t('auth.password')}
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="••••••••"
required
/>
</div>
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
<p className="text-sm text-red-400">{error}</p>
</div>
)}
{/* Submit */}
<button
type="submit"
disabled={loading}
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{t('auth.signingIn')}
</span>
) : (
t('auth.loginBtn')
)}
</button>
</form>
)}
{/* Footer */} {/* Footer */}
<div className="mt-6 pt-6 border-t border-slate-700/50"> {!needsSetup && (
<p className="text-xs text-slate-500 text-center"> <div className="mt-6 pt-6 border-t border-slate-700/50">
{t('login.forgotPassword')} <p className="text-xs text-slate-500 text-center">
</p> {t('login.forgotPassword')}
</div> </p>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -468,6 +468,32 @@ async function getRecordName(table, id) {
// Clear name cache periodically (every 60s) // Clear name cache periodically (every 60s)
setInterval(() => { Object.keys(_nameCache).forEach(k => delete _nameCache[k]); }, 60000); setInterval(() => { Object.keys(_nameCache).forEach(k => delete _nameCache[k]); }, 60000);
// ─── SETUP ROUTES ───────────────────────────────────────────────
app.get('/api/setup/status', (req, res) => {
const count = authDb.prepare('SELECT COUNT(*) as cnt FROM auth_credentials').get().cnt;
res.json({ needsSetup: count === 0 });
});
app.post('/api/setup', async (req, res) => {
const count = authDb.prepare('SELECT COUNT(*) as cnt FROM auth_credentials').get().cnt;
if (count > 0) return res.status(403).json({ error: 'Setup already completed' });
const { name, email, password } = req.body;
if (!name || !email || !password) return res.status(400).json({ error: 'Name, email, and password are required' });
try {
const created = await nocodb.create('Users', { name, email, role: 'superadmin' });
const passwordHash = await bcrypt.hash(password, 10);
authDb.prepare('INSERT INTO auth_credentials (email, password_hash, nocodb_user_id) VALUES (?, ?, ?)').run(email, passwordHash, created.Id);
console.log(`[SETUP] Superadmin created: ${email} (NocoDB Id: ${created.Id})`);
res.status(201).json({ message: 'Superadmin account created. You can now log in.' });
} catch (err) {
console.error('Setup error:', err);
res.status(500).json({ error: 'Failed to create superadmin account' });
}
});
// ─── AUTH ROUTES ──────────────────────────────────────────────── // ─── AUTH ROUTES ────────────────────────────────────────────────
app.post('/api/auth/login', async (req, res) => { app.post('/api/auth/login', async (req, res) => {