agnes-the-ai-analyst/app/web/templates/login_email.html
ZdenekSrotyr 7e4ddf0b01
feat(auth): password reset & invite flows for web + admin (#34) (#37)
* feat(auth): password reset & invite flows for web + admin (#34)

Wires end-to-end the previously orphaned password_reset.html and
password_setup.html templates, adds the missing POST /auth/password/reset
handler (closes #34), and restores the Reset action in the admin user UI
(which origin/main had removed precisely because the flow was broken).

Web flow
- GET  /auth/password/reset — renders the set-new-password form
- POST /auth/password/reset — 'Forgot Password?' request; emails link,
  anti-enumeration (same response for unknown email)
- POST /auth/password/reset/confirm — validates token + 24h TTL, sets new
  password, clears token, logs user in
- GET  /auth/password/setup — renders the setup form (invite link landing)
- POST /auth/password/setup/request — signup-tab 'Request Access' (email-only)
- POST /auth/password/setup/confirm — 7-day TTL, sets password + name, logs in
- Reuses LOCAL_DEV_MODE pattern from email.py: logs the link loudly so
  developers can use the flow without an SMTP/SendGrid transport

Admin flow
- POST /api/users accepts send_invite → returns invite_url + invite_email_sent
- POST /api/users/{id}/reset-password now returns a full reset_url pointing
  at the dedicated password-reset endpoint (NOT the magic-link verifier,
  which would log the user in without prompting for a new password)
- admin_users.html: restored Reset row action, copyable reset/invite link
  modals, invite checkbox on create, reworded 'magic-link not wired' notes

Backward compat
- JSON POST /auth/password/setup kept unchanged (existing tests pass)
- Active-account gate applied to reset/setup flows (matches password_login)

Tests: 21 new cases (tests/test_password_flows.py) covering GET renders,
request/confirm happy + error paths, TTLs, anti-enumeration, and admin
invite/reset URL responses. Full suite: 1309 passed.

Closes #34

* fix(admin-users): allow horizontal scroll when actions overflow

Four action buttons (Tokens, Reset, Set pwd, Delete) can exceed the
viewport on narrow screens. Switch .users-table-wrap from overflow: hidden
to overflow-x: auto so the table scrolls instead of clipping, and lock
row-actions buttons to a single nowrap line.

* fix(admin-users): override base 800px container so table can use full width

The base layout caps .container at 800px, so the table was always being
clipped regardless of viewport. Unclamp the container on this page and
widen the inner page cap to 1400px.

* fix(auth): address Devin review — harden JSON setup, anti-enumeration, preserve email case

Addresses findings from Devin review on PR #37:

1. JSON POST /auth/password/setup now enforces the same SETUP_TOKEN_TTL
   (7 days) and active-account check as the web flow. An expired token or
   a deactivated user can no longer bypass the gate by posting JSON.
   Existing test fixture seeds setup_token_created=now so backward-compat
   tests continue to pass.

2. GET /auth/password/setup no longer looks up the user to pre-fill name.
   The form renders identically regardless of whether the email exists,
   consistent with anti-enumeration in POST /setup/request.

3. reset_request / setup_request no longer lowercase the submitted email.
   The rest of the codebase (password_login, magic-link, admin create)
   uses case-sensitive lookups, so normalizing only here would silently
   fail for mixed-case accounts.

Tests: 6 new cases covering expired-JSON-setup, missing-created-timestamp,
deactivated-user-rejection, mixed-case email preservation, and the
anti-enumeration property of GET /setup.
2026-04-22 17:43:57 +02:00

131 lines
5.5 KiB
HTML

{% extends "base.html" %}
{% block title %}Sign in with Email - Data Analyst Portal{% endblock %}
{% block content %}
<div class="login-container">
<div class="login-card">
<h2>External User Access</h2>
<p class="login-description">
For partners, investors, and other external users.
</p>
<!-- Tab navigation -->
<div class="auth-tabs">
<button type="button" class="auth-tab active" data-tab="signin">Sign In</button>
<button type="button" class="auth-tab" data-tab="signup">First Time? Sign Up</button>
</div>
<!-- Sign In Tab -->
<div id="signin-tab" class="auth-tab-content active">
<form method="POST" action="/auth/password/login/web" class="login-form">
<input type="hidden" name="next" value="{{ next_path|default('', true) }}">
<div class="form-group">
<label for="email-signin">Email Address</label>
<input type="email"
id="email-signin"
name="email"
value="{{ email|default('', true) }}"
placeholder="your@email.com"
required
autocomplete="email">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password"
id="password"
name="password"
placeholder="Enter your password"
autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary btn-block">Sign In</button>
</form>
<div class="login-links">
<form method="POST" action="/auth/password/reset" class="reset-form">
<input type="hidden" name="email" value="{{ email|default('', true) }}" id="reset-email">
<button type="submit" class="btn btn-link">Forgot Password?</button>
</form>
</div>
</div>
<!-- Sign Up Tab -->
<div id="signup-tab" class="auth-tab-content">
<p class="tab-description">
Enter your email to receive a setup link. You must be pre-approved by an administrator.
</p>
<form method="POST" action="/auth/password/setup/request" class="login-form">
<div class="form-group">
<label for="email-signup">Email Address</label>
<input type="email"
id="email-signup"
name="email"
value="{{ email|default('', true) }}"
placeholder="your@email.com"
required
autocomplete="email">
</div>
<button type="submit" class="btn btn-primary btn-block">Request Access</button>
</form>
<p class="signup-note">
We'll send you an email with instructions to set up your password.
</p>
</div>
{% if google_available %}
<div class="divider">
<span>or</span>
</div>
<a href="/auth/google/login" class="btn btn-secondary btn-block">
<svg class="google-icon" viewBox="0 0 24 24" width="20" height="20">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Internal user? Sign in with Google
</a>
{% endif %}
</div>
</div>
<script>
// Tab switching
document.querySelectorAll('.auth-tab').forEach(tab => {
tab.addEventListener('click', function() {
const tabId = this.dataset.tab;
// Update tab buttons
document.querySelectorAll('.auth-tab').forEach(t => t.classList.remove('active'));
this.classList.add('active');
// Update tab content
document.querySelectorAll('.auth-tab-content').forEach(c => c.classList.remove('active'));
document.getElementById(tabId + '-tab').classList.add('active');
// Focus first input in active tab
const firstInput = document.getElementById(tabId + '-tab').querySelector('input');
if (firstInput) firstInput.focus();
});
});
// Keep reset email in sync with login email
document.getElementById('email-signin').addEventListener('input', function() {
document.getElementById('reset-email').value = this.value;
});
// Sync emails between tabs
document.getElementById('email-signin').addEventListener('input', function() {
document.getElementById('email-signup').value = this.value;
});
document.getElementById('email-signup').addEventListener('input', function() {
document.getElementById('email-signin').value = this.value;
});
</script>
{% endblock %}