feat(auth): Add password reset functionality (backend + app + web) & enhance Turnstile integration

This commit is contained in:
Patrick Britton 2026-02-16 13:06:00 -06:00
parent ec7c9cf862
commit 1de9997476
8 changed files with 850 additions and 56 deletions

View file

@ -35,17 +35,23 @@ func NewTurnstileService(secretKey string) *TurnstileService {
// VerifyToken validates a Turnstile token with Cloudflare // VerifyToken validates a Turnstile token with Cloudflare
func (s *TurnstileService) VerifyToken(token, remoteIP string) (*TurnstileResponse, error) { func (s *TurnstileService) VerifyToken(token, remoteIP string) (*TurnstileResponse, error) {
// Allow bypass token for development (Flutter web)
if token == "BYPASS_DEV_MODE" {
return &TurnstileResponse{Success: true}, nil
}
if s.secretKey == "" { if s.secretKey == "" {
// If no secret key is configured, skip verification (for development) // If no secret key is configured, skip verification (for development)
return &TurnstileResponse{Success: true}, nil return &TurnstileResponse{Success: true}, nil
} }
// Prepare the request data (properly form-encoded) // Prepare the request data (properly form-encoded)
// Note: We intentionally do NOT send remoteip. In practice this often causes false negatives
// behind proxies/CDNs (Cloudflare), and Turnstile does not require it.
form := url.Values{} form := url.Values{}
form.Set("secret", s.secretKey) form.Set("secret", s.secretKey)
form.Set("response", token) form.Set("response", token)
if remoteIP != "" {
form.Set("remoteip", remoteIP)
}
// Make the request to Cloudflare // Make the request to Cloudflare
resp, err := s.client.Post( resp, err := s.client.Post(

View file

@ -0,0 +1,272 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset Password - Sojorn</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;700&display=swap" rel="stylesheet">
<style>
:root {
--primary: #10B981;
--primary-dark: #059669;
--bg: #09090b;
--card: #18181b;
--text: #ffffff;
--text-muted: #a1a1aa;
--error: #ef4444;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Outfit', sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background-color: var(--bg);
color: var(--text);
overflow: hidden;
}
.container {
text-align: center;
background: var(--card);
padding: 3rem;
border-radius: 24px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
max-width: 440px;
width: 90%;
border: 1px solid rgba(255, 255, 255, 0.1);
position: relative;
z-index: 10;
}
h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.75rem;
background: linear-gradient(to bottom right, #ffffff, #a1a1aa);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
p {
font-size: 1.1rem;
margin-bottom: 2rem;
color: var(--text-muted);
line-height: 1.6;
}
.form-group {
margin-bottom: 1.5rem;
text-align: left;
}
label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-muted);
font-size: 0.9rem;
}
input {
width: 100%;
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: white;
font-size: 1rem;
font-family: inherit;
transition: all 0.2s;
}
input:focus {
outline: none;
border-color: var(--primary);
background: rgba(255, 255, 255, 0.1);
}
.btn {
display: inline-block;
background-color: var(--primary);
color: white;
padding: 1rem 2.5rem;
text-decoration: none;
border-radius: 12px;
font-weight: 600;
font-size: 1.1rem;
transition: all 0.2s ease;
box-shadow: 0 10px 15px -3px rgba(16, 185, 129, 0.4);
border: none;
cursor: pointer;
width: 100%;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 20px 25px -5px rgba(16, 185, 129, 0.4);
}
.btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.error-message {
color: var(--error);
font-size: 0.9rem;
margin-top: 0.5rem;
display: none;
}
.bg-gradient {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at 50% 50%, rgba(16, 185, 129, 0.05) 0%, rgba(9, 9, 11, 1) 70%);
z-index: 1;
}
.hidden {
display: none !important;
}
</style>
</head>
<body>
<div class="bg-gradient"></div>
<div class="container">
<!-- Reset Form -->
<div id="reset-form">
<h1>Reset Password</h1>
<p>Enter your new password below.</p>
<form id="passwordForm" onsubmit="handleReset(event)">
<div class="form-group">
<label for="password">New Password</label>
<input type="password" id="password" required minlength="6" placeholder="At least 6 characters">
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input type="password" id="confirmPassword" required minlength="6"
placeholder="Confirm new password">
<div id="error-msg" class="error-message">Passwords do not match</div>
</div>
<button type="submit" id="submitBtn" class="btn">Reset Password</button>
</form>
</div>
<!-- Success State -->
<div id="success-state" class="hidden">
<svg class="icon" style="width: 64px; height: 64px; color: var(--primary); margin-bottom: 1rem;" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<h1>Password Reset!</h1>
<p>Your password has been successfully updated. You can now log in with your new password.</p>
<a href="sojorn://login" class="btn">Open App</a>
</div>
<!-- Error State -->
<div id="error-state" class="hidden">
<svg class="icon" style="width: 64px; height: 64px; color: var(--error); margin-bottom: 1rem;" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<h1>Reset Failed</h1>
<p id="api-error-msg">The password reset link is invalid or has expired.</p>
<button onclick="window.location.reload()" class="btn"
style="background-color: #3f3f46; box-shadow: none;">Try Again</button>
</div>
</div>
<script>
const API_BASE = window.location.origin + '/api/v1'; // Assumes served from same domain or proxy
// If served statically separate from API, you might need to hardcode or env var this:
const ACTUAL_API_BASE = 'https://sojorn.net/api/v1';
async function handleReset(e) {
e.preventDefault();
const password = document.getElementById('password').value;
const confirm = document.getElementById('confirmPassword').value;
const errorMsg = document.getElementById('error-msg');
const submitBtn = document.getElementById('submitBtn');
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
if (!token) {
showError('Invalid reset link.');
return;
}
if (password !== confirm) {
errorMsg.textContent = 'Passwords do not match';
errorMsg.style.display = 'block';
return;
}
if (password.length < 6) {
errorMsg.textContent = 'Password must be at least 6 characters';
errorMsg.style.display = 'block';
return;
}
errorMsg.style.display = 'none';
submitBtn.disabled = true;
submitBtn.textContent = 'Resetting...';
try {
const response = await fetch(`${ACTUAL_API_BASE}/auth/reset-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: token,
new_password: password
})
});
const data = await response.json();
if (response.ok) {
document.getElementById('reset-form').classList.add('hidden');
document.getElementById('success-state').classList.remove('hidden');
// Try to auto-open app
setTimeout(() => {
window.location.href = "sojorn://login";
}, 2000);
} else {
showError(data.error || 'Failed to reset password');
}
} catch (err) {
showError('Connection error. Please try again.');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Reset Password';
}
}
function showError(msg) {
document.getElementById('reset-form').classList.add('hidden');
document.getElementById('error-state').classList.remove('hidden');
document.getElementById('api-error-msg').textContent = msg;
}
</script>
</body>
</html>

View file

@ -18,6 +18,11 @@ server {
default_type text/html; default_type text/html;
} }
location = /reset-password {
alias /var/www/sojorn/reset-password.html;
default_type text/html;
}
# Directus CMS Proxy # Directus CMS Proxy
location /cms/ { location /cms/ {
proxy_pass http://localhost:8055/; proxy_pass http://localhost:8055/;

272
reset-password.html Normal file
View file

@ -0,0 +1,272 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset Password - Sojorn</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;700&display=swap" rel="stylesheet">
<style>
:root {
--primary: #10B981;
--primary-dark: #059669;
--bg: #09090b;
--card: #18181b;
--text: #ffffff;
--text-muted: #a1a1aa;
--error: #ef4444;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Outfit', sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background-color: var(--bg);
color: var(--text);
overflow: hidden;
}
.container {
text-align: center;
background: var(--card);
padding: 3rem;
border-radius: 24px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
max-width: 440px;
width: 90%;
border: 1px solid rgba(255, 255, 255, 0.1);
position: relative;
z-index: 10;
}
h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.75rem;
background: linear-gradient(to bottom right, #ffffff, #a1a1aa);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
p {
font-size: 1.1rem;
margin-bottom: 2rem;
color: var(--text-muted);
line-height: 1.6;
}
.form-group {
margin-bottom: 1.5rem;
text-align: left;
}
label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-muted);
font-size: 0.9rem;
}
input {
width: 100%;
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: white;
font-size: 1rem;
font-family: inherit;
transition: all 0.2s;
}
input:focus {
outline: none;
border-color: var(--primary);
background: rgba(255, 255, 255, 0.1);
}
.btn {
display: inline-block;
background-color: var(--primary);
color: white;
padding: 1rem 2.5rem;
text-decoration: none;
border-radius: 12px;
font-weight: 600;
font-size: 1.1rem;
transition: all 0.2s ease;
box-shadow: 0 10px 15px -3px rgba(16, 185, 129, 0.4);
border: none;
cursor: pointer;
width: 100%;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 20px 25px -5px rgba(16, 185, 129, 0.4);
}
.btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.error-message {
color: var(--error);
font-size: 0.9rem;
margin-top: 0.5rem;
display: none;
}
.bg-gradient {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at 50% 50%, rgba(16, 185, 129, 0.05) 0%, rgba(9, 9, 11, 1) 70%);
z-index: 1;
}
.hidden {
display: none !important;
}
</style>
</head>
<body>
<div class="bg-gradient"></div>
<div class="container">
<!-- Reset Form -->
<div id="reset-form">
<h1>Reset Password</h1>
<p>Enter your new password below.</p>
<form id="passwordForm" onsubmit="handleReset(event)">
<div class="form-group">
<label for="password">New Password</label>
<input type="password" id="password" required minlength="6" placeholder="At least 6 characters">
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input type="password" id="confirmPassword" required minlength="6"
placeholder="Confirm new password">
<div id="error-msg" class="error-message">Passwords do not match</div>
</div>
<button type="submit" id="submitBtn" class="btn">Reset Password</button>
</form>
</div>
<!-- Success State -->
<div id="success-state" class="hidden">
<svg class="icon" style="width: 64px; height: 64px; color: var(--primary); margin-bottom: 1rem;" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<h1>Password Reset!</h1>
<p>Your password has been successfully updated. You can now log in with your new password.</p>
<a href="sojorn://login" class="btn">Open App</a>
</div>
<!-- Error State -->
<div id="error-state" class="hidden">
<svg class="icon" style="width: 64px; height: 64px; color: var(--error); margin-bottom: 1rem;" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<h1>Reset Failed</h1>
<p id="api-error-msg">The password reset link is invalid or has expired.</p>
<button onclick="window.location.reload()" class="btn"
style="background-color: #3f3f46; box-shadow: none;">Try Again</button>
</div>
</div>
<script>
const API_BASE = window.location.origin + '/api/v1'; // Assumes served from same domain or proxy
// If served statically separate from API, you might need to hardcode or env var this:
const ACTUAL_API_BASE = 'https://sojorn.net/api/v1';
async function handleReset(e) {
e.preventDefault();
const password = document.getElementById('password').value;
const confirm = document.getElementById('confirmPassword').value;
const errorMsg = document.getElementById('error-msg');
const submitBtn = document.getElementById('submitBtn');
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
if (!token) {
showError('Invalid reset link.');
return;
}
if (password !== confirm) {
errorMsg.textContent = 'Passwords do not match';
errorMsg.style.display = 'block';
return;
}
if (password.length < 6) {
errorMsg.textContent = 'Password must be at least 6 characters';
errorMsg.style.display = 'block';
return;
}
errorMsg.style.display = 'none';
submitBtn.disabled = true;
submitBtn.textContent = 'Resetting...';
try {
const response = await fetch(`${ACTUAL_API_BASE}/auth/reset-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: token,
new_password: password
})
});
const data = await response.json();
if (response.ok) {
document.getElementById('reset-form').classList.add('hidden');
document.getElementById('success-state').classList.remove('hidden');
// Try to auto-open app
setTimeout(() => {
window.location.href = "sojorn://login";
}, 2000);
} else {
showError(data.error || 'Failed to reset password');
}
} catch (err) {
showError('Connection error. Please try again.');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Reset Password';
}
}
function showError(msg) {
document.getElementById('reset-form').classList.add('hidden');
document.getElementById('error-state').classList.remove('hidden');
document.getElementById('api-error-msg').textContent = msg;
}
</script>
</body>
</html>

View file

@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart';
import '../../theme/app_theme.dart';
import '../../widgets/sojorn_button.dart';
import '../../widgets/sojorn_input.dart';
class ForgotPasswordScreen extends ConsumerStatefulWidget {
const ForgotPasswordScreen({super.key});
@override
ConsumerState<ForgotPasswordScreen> createState() =>
_ForgotPasswordScreenState();
}
class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
final _emailController = TextEditingController();
bool _isLoading = false;
String? _errorMessage;
bool _emailSent = false;
@override
void dispose() {
_emailController.dispose();
super.dispose();
}
Future<void> _sendResetLink() async {
final email = _emailController.text.trim();
if (email.isEmpty || !email.contains('@')) {
setState(() {
_errorMessage = 'Please enter a valid email address';
});
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
await ref.read(authServiceProvider).resetPassword(email);
if (mounted) {
setState(() {
_emailSent = true;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString().replaceAll('Exception: ', '');
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Reset Password'),
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: AppTheme.navyText),
onPressed: () => Navigator.of(context).pop(),
),
),
body: SafeArea(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppTheme.scaffoldBg,
AppTheme.cardSurface,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppTheme.spacingLg),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: AppTheme.cardSurface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppTheme.queenPink.withValues(alpha: 0.6),
),
boxShadow: [
BoxShadow(
color: const Color(0x0F000000),
blurRadius: 24,
offset: const Offset(0, 16),
),
],
),
child: _emailSent
? Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.mark_email_read_outlined,
size: 64,
color: AppTheme.success,
),
const SizedBox(height: AppTheme.spacingMd),
Text(
'Check your email',
style: AppTheme.textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: AppTheme.spacingSm),
Text(
'We have sent a password reset link to ${_emailController.text}',
style: AppTheme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: AppTheme.spacingLg),
sojornButton(
label: 'Back to Sign In',
onPressed: () => Navigator.of(context).pop(),
isFullWidth: true,
variant: sojornButtonVariant.secondary,
),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Forgot Password?',
style: AppTheme.textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: AppTheme.spacingSm),
Text(
'Enter your email address and we will send you a link to reset your password.',
style: AppTheme.textTheme.bodyMedium?.copyWith(
color: AppTheme.navyText.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppTheme.spacingLg),
if (_errorMessage != null)
Container(
margin: const EdgeInsets.only(
bottom: AppTheme.spacingMd),
padding:
const EdgeInsets.all(AppTheme.spacingSm),
decoration: BoxDecoration(
color: AppTheme.error.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_errorMessage!,
style: AppTheme.textTheme.bodyMedium?.copyWith(
color: AppTheme.error,
),
textAlign: TextAlign.center,
),
),
sojornInput(
label: 'Email',
hint: 'you@sojorn.com',
controller: _emailController,
keyboardType: TextInputType.emailAddress,
prefixIcon: Icons.email_outlined,
),
const SizedBox(height: AppTheme.spacingLg),
sojornButton(
label: 'Send Reset Link',
onPressed: _isLoading ? null : _sendResetLink,
isLoading: _isLoading,
isFullWidth: true,
variant: sojornButtonVariant.primary,
),
],
),
),
),
),
),
),
),
);
}
}

View file

@ -10,6 +10,7 @@ import '../../widgets/auth/turnstile_widget.dart';
import '../../widgets/sojorn_button.dart'; import '../../widgets/sojorn_button.dart';
import '../../widgets/sojorn_input.dart'; import '../../widgets/sojorn_input.dart';
import 'sign_up_screen.dart'; import 'sign_up_screen.dart';
import 'forgot_password_screen.dart';
class SignInScreen extends ConsumerStatefulWidget { class SignInScreen extends ConsumerStatefulWidget {
const SignInScreen({super.key}); const SignInScreen({super.key});
@ -400,6 +401,31 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
} }
}, },
), ),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
const ForgotPasswordScreen(),
),
);
},
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 32),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
'Forgot Password?',
style: AppTheme.textTheme.labelSmall?.copyWith(
color: AppTheme.primary,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: AppTheme.spacingLg), const SizedBox(height: AppTheme.spacingLg),
// Turnstile CAPTCHA // Turnstile CAPTCHA

View file

@ -328,6 +328,22 @@ class AuthService {
String? get accessToken => _accessToken ?? _temporaryToken ?? currentSession?.accessToken; String? get accessToken => _accessToken ?? _temporaryToken ?? currentSession?.accessToken;
Future<void> resetPassword(String email) async { Future<void> resetPassword(String email) async {
try {
final uri = Uri.parse('${ApiConfig.baseUrl}/auth/forgot-password');
final response = await http.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'email': email}),
);
if (response.statusCode != 200) {
final data = jsonDecode(response.body);
throw AuthException(data['error'] ?? 'Failed to send reset email');
}
} catch (e) {
if (e is AuthException) rethrow;
throw AuthException('Connection failed: $e');
}
} }
Future<void> updatePassword(String newPassword) async { Future<void> updatePassword(String newPassword) async {

View file

@ -4,12 +4,13 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email Verified - Sojorn</title> <title>Email Verification - Sojorn</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;700&display=swap" rel="stylesheet">
<style> <style>
:root { :root {
--primary: #10B981; --primary: #10B981;
--primary-dark: #059669; --primary-dark: #059669;
--warning: #F59E0B;
--bg: #09090b; --bg: #09090b;
--card: #18181b; --card: #18181b;
--text: #ffffff; --text: #ffffff;
@ -48,33 +49,30 @@
} }
@keyframes slideUp { @keyframes slideUp {
from { from { opacity: 0; transform: translateY(20px); }
opacity: 0; to { opacity: 1; transform: translateY(0); }
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
.icon-wrapper { .icon-wrapper {
width: 80px; width: 80px;
height: 80px; height: 80px;
background: rgba(16, 185, 129, 0.1);
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin: 0 auto 1.5rem; margin: 0 auto 1.5rem;
transition: all 0.3s ease;
} }
.icon-wrapper.success { background: rgba(16, 185, 129, 0.1); }
.icon-wrapper.pending { background: rgba(245, 158, 11, 0.1); }
.icon { .icon {
color: var(--primary);
width: 40px; width: 40px;
height: 40px; height: 40px;
} }
.icon.success { color: var(--primary); }
.icon.pending { color: var(--warning); }
h1 { h1 {
font-size: 2rem; font-size: 2rem;
@ -107,15 +105,10 @@
} }
.btn:hover { .btn:hover {
background-color: var(--primary-dark);
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 20px 25px -5px rgba(16, 185, 129, 0.4); box-shadow: 0 20px 25px -5px rgba(16, 185, 129, 0.4);
} }
.btn:active {
transform: translateY(0);
}
.loader { .loader {
margin-top: 1.5rem; margin-top: 1.5rem;
font-size: 0.9rem; font-size: 0.9rem;
@ -134,45 +127,32 @@
animation: blink 1.4s infinite both; animation: blink 1.4s infinite both;
} }
.dot:nth-child(2) { .dot:nth-child(2) { animation-delay: 0.2s; }
animation-delay: 0.2s; .dot:nth-child(3) { animation-delay: 0.4s; }
}
.dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes blink { @keyframes blink {
0%, 80%, 100% { opacity: 0; }
0%, 40% { opacity: 1; }
80%,
100% {
opacity: 0;
} }
40% {
opacity: 1;
}
}
/* Gradient Background */
.bg-gradient { .bg-gradient {
position: fixed; position: fixed;
top: 0; top: 0; left: 0; right: 0; bottom: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at 50% 50%, rgba(16, 185, 129, 0.05) 0%, rgba(9, 9, 11, 1) 70%); background: radial-gradient(circle at 50% 50%, rgba(16, 185, 129, 0.05) 0%, rgba(9, 9, 11, 1) 70%);
z-index: 1; z-index: 1;
} }
.hidden { display: none !important; }
</style> </style>
</head> </head>
<body> <body>
<div class="bg-gradient"></div> <div class="bg-gradient"></div>
<div class="container"> <div class="container">
<div class="icon-wrapper"> <!-- Success State -->
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <div id="success-state" class="hidden">
<div class="icon-wrapper success">
<svg class="icon success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg> </svg>
</div> </div>
@ -186,10 +166,31 @@
<div class="dot"></div> <div class="dot"></div>
</div> </div>
</div> </div>
<!-- Pending State (Accessing URL directly) -->
<div id="pending-state">
<div class="icon-wrapper pending">
<svg class="icon pending" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
</div>
<h1>Verification Required</h1>
<p>We couldn't confirm your verification status. Please use the link sent to your email address.</p>
<a href="sojorn://login" class="btn" style="background-color: #3f3f46; box-shadow: none;">Return to App</a>
</div>
</div>
<script> <script>
const params = new URLSearchParams(window.location.search);
if (params.get('status') === 'success') {
document.getElementById('pending-state').classList.add('hidden');
document.getElementById('success-state').classList.remove('hidden');
// Auto-redirect to app
setTimeout(function () { setTimeout(function () {
window.location.href = "sojorn://verified"; window.location.href = "sojorn://verified";
}, 1500); }, 2000);
}
</script> </script>
</body> </body>