Magic link auth via Gmail SMTP + password fallback

This commit is contained in:
2026-04-13 14:08:16 +00:00
parent 00e76e412d
commit f57dd9621f
2 changed files with 219 additions and 123 deletions
+131 -122
View File
@@ -5,94 +5,30 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login — Agent Command Center</title>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2e3345;
--text: #e4e6ed;
--text-dim: #8b8fa3;
--accent: #6c5ce7;
--accent-hover: #7c6ef0;
--red: #e17055;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 2.5rem;
width: 100%;
max-width: 380px;
}
.login-card h1 {
font-size: 1.3rem;
font-weight: 600;
margin-bottom: 0.4rem;
text-align: center;
}
.login-card .subtitle {
color: var(--text-dim);
font-size: 0.85rem;
text-align: center;
margin-bottom: 2rem;
}
.field {
margin-bottom: 1.25rem;
}
.field label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-dim);
margin-bottom: 0.4rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.field input {
width: 100%;
padding: 0.65rem 0.85rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s;
}
.field input:focus {
border-color: var(--accent);
}
.btn {
width: 100%;
padding: 0.7rem;
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
margin-top: 0.5rem;
}
.btn:hover { background: var(--accent-hover); }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.error {
color: var(--red);
font-size: 0.85rem;
text-align: center;
margin-top: 1rem;
display: none;
}
:root {--bg:#0f1117;--surface:#1a1d27;--border:#2e3345;--text:#e4e6ed;--text-dim:#8b8fa3;--accent:#6c5ce7;--accent-hover:#7c6ef0;--red:#e17055;--green:#00b894}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center}
.login-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:2.5rem;width:100%;max-width:400px}
.login-card h1{font-size:1.3rem;font-weight:600;margin-bottom:.4rem;text-align:center}
.login-card .subtitle{color:var(--text-dim);font-size:.85rem;text-align:center;margin-bottom:2rem}
.field{margin-bottom:1.25rem}
.field label{display:block;font-size:.8rem;font-weight:500;color:var(--text-dim);margin-bottom:.4rem;text-transform:uppercase;letter-spacing:.05em}
.field input{width:100%;padding:.65rem .85rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:.95rem;outline:none;transition:border-color .2s}
.field input:focus{border-color:var(--accent)}
.btn{width:100%;padding:.7rem;background:var(--accent);color:#fff;border:none;border-radius:6px;font-size:.95rem;font-weight:500;cursor:pointer;transition:background .2s;margin-top:.5rem}
.btn:hover{background:var(--accent-hover)}.btn:disabled{opacity:.6;cursor:not-allowed}
.btn-ghost{background:none;color:var(--text-dim);border:1px solid var(--border)}.btn-ghost:hover{border-color:var(--text-dim);color:var(--text)}
.msg{font-size:.85rem;text-align:center;margin-top:1rem;display:none}
.msg.error{color:var(--red)}.msg.success{color:var(--green)}
.divider{display:flex;align-items:center;gap:1rem;margin:1.5rem 0;color:var(--text-dim);font-size:.8rem}
.divider::before,.divider::after{content:'';flex:1;border-top:1px solid var(--border)}
.tab-row{display:flex;margin-bottom:1.5rem;border-bottom:1px solid var(--border)}
.tab{flex:1;text-align:center;padding:.6rem;font-size:.85rem;cursor:pointer;color:var(--text-dim);border-bottom:2px solid transparent;transition:all .2s}
.tab:hover{color:var(--text)}.tab.active{color:var(--accent);border-bottom-color:var(--accent)}
.panel{display:none}.panel.active{display:block}
.sent-state{text-align:center;padding:1rem 0}
.sent-state .icon{font-size:2rem;margin-bottom:.75rem}
.sent-state p{color:var(--text-dim);font-size:.85rem;line-height:1.5}
</style>
</head>
<body>
@@ -100,49 +36,122 @@
<div class="login-card">
<h1>Agent Command Center</h1>
<p class="subtitle">Sign in to continue</p>
<form id="login-form">
<div class="field">
<label>Username</label>
<input type="text" id="username" autocomplete="username" autofocus required>
<div class="tab-row">
<div class="tab active" onclick="switchTab('magic')">Magic Link</div>
<div class="tab" onclick="switchTab('password')">Password</div>
</div>
<!-- Magic Link -->
<div class="panel active" id="panel-magic">
<div id="magic-form-state">
<div class="field">
<label>Email Address</label>
<input type="email" id="magic-email" autocomplete="email" autofocus placeholder="you@example.com" required>
</div>
<button class="btn" id="magic-btn" onclick="sendMagicLink()">Send Sign-In Link</button>
<p class="msg" id="magic-msg"></p>
</div>
<div class="field">
<label>Password</label>
<input type="password" id="password" autocomplete="current-password" required>
<div id="magic-sent-state" style="display:none">
<div class="sent-state">
<div class="icon">&#9993;</div>
<h3 style="margin-bottom:.5rem">Check your email</h3>
<p>We sent a sign-in link to <strong id="sent-email"></strong>.<br>Click the link in the email to sign in.<br>The link expires in 15 minutes.</p>
<button class="btn btn-ghost" style="margin-top:1.5rem" onclick="resetMagic()">Send another</button>
</div>
</div>
<button type="submit" class="btn" id="submit-btn">Sign In</button>
<p class="error" id="error-msg"></p>
</form>
</div>
<!-- Password -->
<div class="panel" id="panel-password">
<form id="login-form">
<div class="field">
<label>Username or Email</label>
<input type="text" id="username" autocomplete="username" required>
</div>
<div class="field">
<label>Password</label>
<input type="password" id="password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn" id="submit-btn">Sign In</button>
<p class="msg error" id="error-msg"></p>
</form>
</div>
</div>
<script>
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('submit-btn');
const err = document.getElementById('error-msg');
btn.disabled = true;
err.style.display = 'none';
function switchTab(name){
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
document.querySelector(`.tab[onclick*="${name}"]`).classList.add('active');
document.getElementById('panel-'+name).classList.add('active');
}
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
// Check URL params for errors from magic link
const params = new URLSearchParams(window.location.search);
if(params.get('error')){
const msg = document.getElementById('magic-msg');
msg.textContent = params.get('error') === 'expired' ? 'Link expired. Please request a new one.' : 'Invalid link. Please try again.';
msg.className = 'msg error';
msg.style.display = 'block';
}
async function sendMagicLink(){
const btn = document.getElementById('magic-btn');
const msg = document.getElementById('magic-msg');
const email = document.getElementById('magic-email').value.trim();
if(!email){msg.textContent='Enter your email';msg.className='msg error';msg.style.display='block';return}
btn.disabled=true;msg.style.display='none';
try{
const res = await fetch('/api/auth/magic-link',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({email}),
});
if(res.ok){
document.getElementById('magic-form-state').style.display='none';
document.getElementById('magic-sent-state').style.display='block';
document.getElementById('sent-email').textContent=email;
} else {
const data = await res.json();
msg.textContent = data.detail || 'Failed to send link';
msg.className='msg error';msg.style.display='block';
}
}catch(e){
msg.textContent='Connection error';msg.className='msg error';msg.style.display='block';
}
btn.disabled=false;
}
function resetMagic(){
document.getElementById('magic-form-state').style.display='block';
document.getElementById('magic-sent-state').style.display='none';
document.getElementById('magic-email').value='';
}
document.getElementById('login-form').addEventListener('submit', async(e)=>{
e.preventDefault();
const btn=document.getElementById('submit-btn');
const err=document.getElementById('error-msg');
btn.disabled=true;err.style.display='none';
try{
const res=await fetch('/api/login',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({
username:document.getElementById('username').value,
password:document.getElementById('password').value,
}),
});
if (res.ok) {
window.location.href = '/';
} else {
err.textContent = 'Invalid username or password';
err.style.display = 'block';
}
} catch (ex) {
err.textContent = 'Connection error';
err.style.display = 'block';
}
btn.disabled = false;
if(res.ok){window.location.href='/'}
else{err.textContent='Invalid username or password';err.style.display='block'}
}catch(ex){err.textContent='Connection error';err.style.display='block'}
btn.disabled=false;
});
// Enter key on magic email field
document.getElementById('magic-email').addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();sendMagicLink()}});
</script>
</body>
</html>