mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
feat(admin): magic link login — ask your agent for the URL
New flow: 1. User opens /admin → sees 'This is a protected dashboard' 2. UI tells them: 'Ask your AI agent for the admin login link' 3. Agent generates: https://host:port/admin/auth/<token> 4. User clicks the link → auto-authenticates → redirects to dashboard 5. Session lasts 7 days (magic link) vs 24h (manual token paste) Server: GET /admin/auth/:token validates the bootstrap token, sets HttpOnly cookie, redirects to /admin/. Invalid tokens get a plain text error telling them to ask their agent for a fresh link. Login page: primary UX is the 'ask your agent' prompt with example. Manual token paste collapsed under a <details> disclosure.
This commit is contained in:
+53
-17
@@ -14,7 +14,6 @@ export function LoginPage({ onLogin }: { onLogin: () => void }) {
|
||||
api.login(saved).then(() => {
|
||||
onLogin();
|
||||
}).catch(() => {
|
||||
// Saved token expired or server restarted with new token
|
||||
localStorage.removeItem('gbrain_admin_token');
|
||||
setAutoLogging(false);
|
||||
});
|
||||
@@ -32,7 +31,7 @@ export function LoginPage({ onLogin }: { onLogin: () => void }) {
|
||||
localStorage.setItem('gbrain_admin_token', token);
|
||||
onLogin();
|
||||
} catch (err) {
|
||||
setError('Invalid token. Check your terminal output.');
|
||||
setError('Invalid token.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -51,23 +50,60 @@ export function LoginPage({ onLogin }: { onLogin: () => void }) {
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<form className="login-box" onSubmit={handleSubmit}>
|
||||
<div className="login-box">
|
||||
<div className="login-logo">GBrain</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Admin Token"
|
||||
value={token}
|
||||
onChange={e => setToken(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div style={{
|
||||
background: 'rgba(136, 170, 255, 0.08)',
|
||||
border: '1px solid rgba(136, 170, 255, 0.2)',
|
||||
borderRadius: 8,
|
||||
padding: '14px 16px',
|
||||
marginBottom: 20,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
color: 'var(--text-secondary)',
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, color: 'var(--text-primary)', marginBottom: 6 }}>
|
||||
🔒 This is a protected dashboard
|
||||
</div>
|
||||
Ask your AI agent for the admin login link:
|
||||
<div style={{
|
||||
background: 'rgba(0,0,0,0.3)',
|
||||
borderRadius: 6,
|
||||
padding: '8px 12px',
|
||||
marginTop: 8,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 12,
|
||||
color: '#88aaff',
|
||||
wordBreak: 'break-all',
|
||||
}}>
|
||||
"Give me the GBrain admin login link"
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
Your agent will generate a secure one-click URL that logs you in automatically.
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||
{loading ? 'Authenticating...' : 'Submit'}
|
||||
</button>
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
<div className="login-hint">Find this token in your terminal output.</div>
|
||||
</form>
|
||||
|
||||
<details style={{ marginBottom: 16 }}>
|
||||
<summary style={{ cursor: 'pointer', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
Or paste token manually
|
||||
</summary>
|
||||
<form onSubmit={handleSubmit} style={{ marginTop: 12 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Admin Token"
|
||||
value={token}
|
||||
onChange={e => setToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||
{loading ? 'Authenticating...' : 'Submit'}
|
||||
</button>
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,6 +191,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin authentication (cookie-based)
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /admin/login — JSON body with token (for programmatic/UI login)
|
||||
app.post('/admin/login', express.json(), (req, res) => {
|
||||
const token = req.body?.token;
|
||||
if (!token) {
|
||||
@@ -217,6 +218,30 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
res.json({ status: 'authenticated' });
|
||||
});
|
||||
|
||||
// GET /admin/auth/:token — magic link login (one-click from agent)
|
||||
// The agent generates: https://host:port/admin/auth/<bootstrapToken>
|
||||
// Browser hits it, sets cookie, redirects to dashboard.
|
||||
app.get('/admin/auth/:token', (req, res) => {
|
||||
const token = req.params.token;
|
||||
const tokenHash = createHash('sha256').update(token).digest('hex');
|
||||
if (tokenHash !== bootstrapHash) {
|
||||
res.status(401).send('Invalid admin link. Ask your agent for a fresh one.');
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = randomBytes(32).toString('hex');
|
||||
const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days for magic link
|
||||
adminSessions.set(sessionId, expiresAt);
|
||||
|
||||
res.cookie('gbrain_admin', sessionId, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
path: '/admin',
|
||||
});
|
||||
res.redirect('/admin/');
|
||||
});
|
||||
|
||||
// Admin auth middleware
|
||||
function requireAdmin(req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
const sessionId = (req.cookies as Record<string, string>)?.gbrain_admin;
|
||||
|
||||
Reference in New Issue
Block a user