feat(auth): add local user signup, email verification, and Stripe billing
- Add LocalUser model with bcrypt password hashing, email verification tokens, and password reset tokens - Add ALLOW_LOCAL_SIGNUP config flag (requires SMTP to be configured) - Add Stripe billing config fields (STRIPE_SECRET_KEY, etc.) - Add stripe_customer_id to UserProfile and stripe_price_id_monthly/ stripe_price_id_yearly to SubscriptionPlan - Create migration 018_add_local_users_and_billing - Add app/utils/local_auth.py: hash_password, verify_password, generate_token, is_token_expired, send_verification_email, send_password_reset_email, build_session_user - Add app/api/local_auth.py: signup, email verification, password reset endpoints plus signup/verify-email-sent/reset-password page routes - Add app/api/billing.py: Stripe Checkout, Customer Portal, and webhook endpoints; syncs subscription tier from webhook events - Update auth() to check LocalUser table before admin credentials fallback - Update login() to pass allow_signup context variable to template - Add signup.html, verify_email_sent.html, password_reset_form.html, billing_success.html templates (Alpine.js, Tailwind, WCAG 2.1 AA) - Update login.html to show 'Create account' link when signup enabled - Update pricing.html CTA buttons to use Stripe Checkout for paid tiers - Add docs/BillingSetup.md with setup guide, webhook config, compliance - Add tests/test_local_auth.py (42 tests) and tests/test_billing.py (31 tests); all 106 tests in the modified test suite pass - Add stripe>=7.0.0,<15.0.0 to requirements.txt Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DocuElevate - Subscription Activated</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
</head>
|
||||
<body class="bg-gray-100 min-h-screen flex items-center justify-center py-8">
|
||||
<main class="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center" role="main">
|
||||
<div class="flex justify-center mb-6">
|
||||
<img src="/static/images/logo_writing.svg" alt="DocuElevate Logo" class="h-16">
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="bg-green-100 rounded-full p-4">
|
||||
<i class="fas fa-circle-check text-green-600 text-5xl" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-2">You're all set!</h1>
|
||||
<p class="text-gray-600 mb-8">
|
||||
Your subscription has been activated. Thank you for choosing DocuElevate!
|
||||
</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<a href="/subscription"
|
||||
class="block w-full py-3 px-4 rounded-lg border-2 border-indigo-600 text-indigo-600 font-semibold hover:bg-indigo-50 transition focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
style="min-height:44px;display:flex;align-items:center;justify-content:center;"
|
||||
>
|
||||
<i class="fas fa-credit-card mr-2" aria-hidden="true"></i>Manage subscription
|
||||
</a>
|
||||
<a href="/upload"
|
||||
class="block w-full py-3 px-4 rounded-lg bg-indigo-600 text-white font-semibold hover:bg-indigo-700 transition focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
style="min-height:44px;display:flex;align-items:center;justify-content:center;"
|
||||
>
|
||||
<i class="fas fa-arrow-right mr-2" aria-hidden="true"></i>Go to dashboard
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -78,6 +78,15 @@
|
||||
Return to Home
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if allow_signup %}
|
||||
<div class="mt-4 text-center">
|
||||
<span class="text-sm text-gray-600">Don't have an account?</span>
|
||||
<a href="/signup" class="ml-1 text-sm font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Create account
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
<div class="fixed bottom-4 text-center w-full text-xs text-gray-500">
|
||||
DocuElevate {{ app_version|default('', true) }}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DocuElevate - Reset Password</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
<body class="bg-gray-100 min-h-screen flex items-center justify-center py-8">
|
||||
<main class="bg-white rounded-lg shadow-lg p-8 max-w-md w-full" role="main">
|
||||
<div class="flex justify-center mb-6">
|
||||
<img src="/static/images/logo_writing.svg" alt="DocuElevate Logo" class="h-16">
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-bold text-center text-gray-800 mb-2">Set a new password</h1>
|
||||
<p class="text-center text-gray-500 text-sm mb-6">Enter your new password below.</p>
|
||||
|
||||
<div
|
||||
x-data="{
|
||||
token: '{{ token | default('', true) }}',
|
||||
new_password: '',
|
||||
new_password_confirm: '',
|
||||
loading: false,
|
||||
error: '',
|
||||
success: false,
|
||||
async submit() {
|
||||
this.error = '';
|
||||
if (this.new_password !== this.new_password_confirm) {
|
||||
this.error = 'Passwords do not match.';
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
try {
|
||||
const resp = await fetch('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrf_token }}' },
|
||||
body: JSON.stringify({
|
||||
token: this.token,
|
||||
new_password: this.new_password,
|
||||
new_password_confirm: this.new_password_confirm
|
||||
})
|
||||
});
|
||||
if (resp.ok) {
|
||||
this.success = true;
|
||||
} else {
|
||||
const data = await resp.json();
|
||||
this.error = data.detail || 'Password reset failed. Please try again.';
|
||||
}
|
||||
} catch(e) {
|
||||
this.error = 'Network error. Please try again.';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div x-show="success" x-cloak class="text-center py-4">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="bg-green-100 rounded-full p-4">
|
||||
<i class="fas fa-check-circle text-green-600 text-4xl" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-green-700 font-semibold mb-4">Password updated successfully!</p>
|
||||
<a href="/login"
|
||||
class="inline-block py-2 px-6 rounded-md bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
style="min-height:44px;display:flex;align-items:center;justify-content:center;"
|
||||
>Sign in</a>
|
||||
</div>
|
||||
|
||||
<form x-show="!success" @submit.prevent="submit" class="space-y-4" novalidate>
|
||||
<div x-show="error" x-cloak
|
||||
class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded"
|
||||
role="alert" aria-live="polite">
|
||||
<p x-text="error"></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new_password" class="block text-sm font-medium text-gray-700">New password <span aria-hidden="true" class="text-red-500">*</span></label>
|
||||
<input
|
||||
type="password" id="new_password" name="new_password" required
|
||||
x-model="new_password"
|
||||
autocomplete="new-password"
|
||||
minlength="8" maxlength="128"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-500 focus:ring-opacity-50"
|
||||
aria-required="true"
|
||||
aria-describedby="pw-hint"
|
||||
>
|
||||
<p id="pw-hint" class="mt-1 text-xs text-gray-500">Minimum 8 characters.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new_password_confirm" class="block text-sm font-medium text-gray-700">Confirm new password <span aria-hidden="true" class="text-red-500">*</span></label>
|
||||
<input
|
||||
type="password" id="new_password_confirm" name="new_password_confirm" required
|
||||
x-model="new_password_confirm"
|
||||
autocomplete="new-password"
|
||||
minlength="8" maxlength="128"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-500 focus:ring-opacity-50"
|
||||
aria-required="true"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<span x-show="!loading">Update password</span>
|
||||
<span x-show="loading" x-cloak>
|
||||
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>Updating…
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<a href="/login" class="text-sm font-medium text-blue-600 hover:text-blue-500">
|
||||
Back to sign in
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<div class="fixed bottom-4 text-center w-full text-xs text-gray-500">
|
||||
DocuElevate {{ app_version|default('', true) }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -117,6 +117,13 @@
|
||||
<a href="/login"
|
||||
class="block w-full text-center py-3 px-4 rounded-lg border-2 border-blue-600 text-blue-600 font-semibold hover:bg-blue-50 transition"
|
||||
>{{ tier.cta }}</a>
|
||||
{% elif tier.stripe_price_id_monthly or tier.stripe_price_id_yearly %}
|
||||
<button
|
||||
type="button"
|
||||
onclick="startCheckout('{{ tier.id }}')"
|
||||
class="block w-full text-center py-3 px-4 rounded-lg {% if tier.highlight %}bg-indigo-600 text-white font-semibold hover:bg-indigo-700 transition shadow-md{% else %}bg-blue-600 text-white font-semibold hover:bg-blue-700 transition{% endif %}"
|
||||
style="min-height:44px;"
|
||||
>{{ tier.cta }}</button>
|
||||
{% elif tier.highlight %}
|
||||
<a href="/login"
|
||||
class="block w-full text-center py-3 px-4 rounded-lg bg-indigo-600 text-white font-semibold hover:bg-indigo-700 transition shadow-md"
|
||||
@@ -377,3 +384,41 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
/**
|
||||
* Initiate a Stripe Checkout session for the given plan.
|
||||
* Uses the currently-selected billing cycle toggle (monthly/yearly).
|
||||
*
|
||||
* @param {string} planId - The plan identifier (e.g. 'starter', 'professional').
|
||||
*/
|
||||
async function startCheckout(planId) {
|
||||
const cycleEl = document.querySelector('[data-billing-cycle]');
|
||||
const billingCycle = (cycleEl && cycleEl.dataset.billingCycle) ? cycleEl.dataset.billingCycle : 'monthly';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/billing/create-checkout-session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ plan_id: planId, billing_cycle: billingCycle })
|
||||
});
|
||||
if (resp.status === 401) {
|
||||
window.location.href = '/login?message=Please+sign+in+to+subscribe';
|
||||
return;
|
||||
}
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
if (data.checkout_url) {
|
||||
window.location.href = data.checkout_url;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
alert(data.detail || 'Unable to start checkout. Please try again.');
|
||||
} catch (e) {
|
||||
alert('Network error. Please try again.');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DocuElevate - Create Account</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
<body class="bg-gray-100 min-h-screen flex items-center justify-center py-8">
|
||||
<main class="bg-white rounded-lg shadow-lg p-8 max-w-md w-full" role="main">
|
||||
<div class="flex justify-center mb-6">
|
||||
<img src="/static/images/logo_writing.svg" alt="DocuElevate Logo" class="h-16">
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-bold text-center text-gray-800 mb-2">Create your account</h1>
|
||||
<p class="text-center text-gray-500 text-sm mb-6">Already have an account?
|
||||
<a href="/login" class="text-blue-600 hover:text-blue-500 font-medium">Sign in</a>
|
||||
</p>
|
||||
|
||||
<div
|
||||
x-data="{
|
||||
email: '',
|
||||
username: '',
|
||||
display_name: '',
|
||||
password: '',
|
||||
password_confirm: '',
|
||||
loading: false,
|
||||
error: '',
|
||||
async submit() {
|
||||
this.error = '';
|
||||
if (this.password !== this.password_confirm) {
|
||||
this.error = 'Passwords do not match.';
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
try {
|
||||
const resp = await fetch('/api/auth/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrf_token }}' },
|
||||
body: JSON.stringify({
|
||||
email: this.email,
|
||||
username: this.username,
|
||||
display_name: this.display_name || null,
|
||||
password: this.password,
|
||||
password_confirm: this.password_confirm
|
||||
})
|
||||
});
|
||||
if (resp.ok) {
|
||||
window.location.href = '/verify-email-sent';
|
||||
} else {
|
||||
const data = await resp.json();
|
||||
this.error = data.detail || 'Registration failed. Please try again.';
|
||||
}
|
||||
} catch(e) {
|
||||
this.error = 'Network error. Please try again.';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div x-show="error" x-cloak
|
||||
class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 rounded"
|
||||
role="alert"
|
||||
aria-live="polite">
|
||||
<p x-text="error"></p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="space-y-4" novalidate>
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700">Email address <span aria-hidden="true" class="text-red-500">*</span></label>
|
||||
<input
|
||||
type="email" id="email" name="email" required autocomplete="email"
|
||||
x-model="email"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50"
|
||||
aria-required="true"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700">Username <span aria-hidden="true" class="text-red-500">*</span></label>
|
||||
<input
|
||||
type="text" id="username" name="username" required autocomplete="username"
|
||||
x-model="username"
|
||||
pattern="^[a-zA-Z0-9_-]+$"
|
||||
minlength="3" maxlength="64"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50"
|
||||
aria-required="true"
|
||||
aria-describedby="username-hint"
|
||||
>
|
||||
<p id="username-hint" class="mt-1 text-xs text-gray-500">3–64 characters. Letters, numbers, hyphens and underscores only.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="display_name" class="block text-sm font-medium text-gray-700">Display name <span class="text-gray-400">(optional)</span></label>
|
||||
<input
|
||||
type="text" id="display_name" name="display_name" autocomplete="name"
|
||||
x-model="display_name"
|
||||
maxlength="255"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700">Password <span aria-hidden="true" class="text-red-500">*</span></label>
|
||||
<input
|
||||
type="password" id="password" name="password" required autocomplete="new-password"
|
||||
x-model="password"
|
||||
minlength="8" maxlength="128"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50"
|
||||
aria-required="true"
|
||||
aria-describedby="password-hint"
|
||||
>
|
||||
<p id="password-hint" class="mt-1 text-xs text-gray-500">Minimum 8 characters.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password_confirm" class="block text-sm font-medium text-gray-700">Confirm password <span aria-hidden="true" class="text-red-500">*</span></label>
|
||||
<input
|
||||
type="password" id="password_confirm" name="password_confirm" required autocomplete="new-password"
|
||||
x-model="password_confirm"
|
||||
minlength="8" maxlength="128"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50"
|
||||
aria-required="true"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<span x-show="!loading">Create account</span>
|
||||
<span x-show="loading" x-cloak>
|
||||
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>Creating account…
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<a href="/" class="text-sm font-medium text-blue-600 hover:text-blue-500">
|
||||
Return to Home
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<div class="fixed bottom-4 text-center w-full text-xs text-gray-500">
|
||||
DocuElevate {{ app_version|default('', true) }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,108 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DocuElevate - Verify Your Email</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
<body class="bg-gray-100 min-h-screen flex items-center justify-center py-8">
|
||||
<main class="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center" role="main">
|
||||
<div class="flex justify-center mb-6">
|
||||
<img src="/static/images/logo_writing.svg" alt="DocuElevate Logo" class="h-16">
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="bg-indigo-100 rounded-full p-4">
|
||||
<i class="fas fa-envelope-circle-check text-indigo-600 text-4xl" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-2">Check your inbox</h1>
|
||||
<p class="text-gray-600 mb-6">
|
||||
We've sent you a verification email. Please click the link in the email to activate your account.
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mb-8">
|
||||
The link expires in 24 hours. If you don't see the email, check your spam folder.
|
||||
</p>
|
||||
|
||||
<div
|
||||
x-data="{ email: '', loading: false, message: '', error: '' }"
|
||||
class="border-t border-gray-100 pt-6"
|
||||
>
|
||||
<p class="text-sm text-gray-600 mb-3">Didn't receive it?</p>
|
||||
|
||||
<div x-show="message" x-cloak
|
||||
class="bg-green-100 border-l-4 border-green-500 text-green-700 p-3 mb-4 rounded text-sm"
|
||||
role="status" aria-live="polite">
|
||||
<p x-text="message"></p>
|
||||
</div>
|
||||
<div x-show="error" x-cloak
|
||||
class="bg-red-100 border-l-4 border-red-500 text-red-700 p-3 mb-4 rounded text-sm"
|
||||
role="alert" aria-live="polite">
|
||||
<p x-text="error"></p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
@submit.prevent="async () => {
|
||||
error = ''; message = '';
|
||||
if (!email) { error = 'Please enter your email address.'; return; }
|
||||
loading = true;
|
||||
try {
|
||||
const resp = await fetch('/api/auth/resend-verification', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
if (resp.ok) {
|
||||
message = 'Verification email resent. Please check your inbox.';
|
||||
} else {
|
||||
error = 'Something went wrong. Please try again.';
|
||||
}
|
||||
} catch(e) {
|
||||
error = 'Network error. Please try again.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}"
|
||||
class="space-y-3"
|
||||
novalidate
|
||||
>
|
||||
<div>
|
||||
<label for="resend-email" class="sr-only">Email address</label>
|
||||
<input
|
||||
type="email" id="resend-email" name="email"
|
||||
x-model="email"
|
||||
placeholder="Enter your email address"
|
||||
autocomplete="email"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring focus:ring-indigo-500 focus:ring-opacity-50 text-sm"
|
||||
aria-label="Email address for resend"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full flex justify-center py-2 px-4 border border-indigo-600 rounded-md text-sm font-medium text-indigo-600 hover:bg-indigo-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<span x-show="!loading">Resend verification email</span>
|
||||
<span x-show="loading" x-cloak>
|
||||
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>Sending…
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<a href="/login" class="text-sm font-medium text-blue-600 hover:text-blue-500">
|
||||
Back to sign in
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user