Magic link auth via Gmail SMTP + password fallback
This commit is contained in:
+131
-122
@@ -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">✉</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>
|
||||
|
||||
Reference in New Issue
Block a user