Skip to main content

offline_intelligence/api/
auth_api.rs

1//! Authentication API - User registration, login, email verification, and Google OAuth
2use crate::memory_db::UsersStore;
3use crate::shared_state::UnifiedAppState;
4use argon2::{password_hash::SaltString, Argon2, PasswordHasher, PasswordVerifier};
5use axum::{
6    extract::{Query, State},
7    http::StatusCode,
8    response::{Html, IntoResponse, Response},
9    Json,
10};
11use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, TokenData, Validation};
12use rand::Rng;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::sync::{Arc, Mutex as StdMutex};
16use tracing::{error, info, warn};
17
18// ─── Google OAuth Pending State ────────────────────────────────────────────────
19
20/// Holds the Google OAuth client credentials and a map of in-flight auth states.
21/// Map key = `state` param; value = (redirect_uri, Option<result-when-done>).
22pub struct GoogleOAuthPending {
23    pub states: Arc<StdMutex<HashMap<String, (String, Option<GoogleOAuthResult>)>>>,
24    pub client_id: String,
25    pub client_secret: String,
26}
27
28impl Clone for GoogleOAuthPending {
29    fn clone(&self) -> Self {
30        Self {
31            states: Arc::clone(&self.states),
32            client_id: self.client_id.clone(),
33            client_secret: self.client_secret.clone(),
34        }
35    }
36}
37
38#[derive(Clone, Serialize, Debug)]
39pub struct GoogleOAuthResult {
40    pub token: String,
41    pub user: UserResponse,
42}
43
44// ─── Auth State ────────────────────────────────────────────────────────────────
45
46#[derive(Clone)]
47pub struct AuthState {
48    pub users: UsersStore,
49    pub jwt_secret: String,
50    /// Present only when GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET are set.
51    pub google: Option<GoogleOAuthPending>,
52}
53
54// ─── JWT Claims ────────────────────────────────────────────────────────────────
55
56#[derive(Debug, Serialize, Deserialize)]
57pub struct Claims {
58    pub sub: String,
59    pub email: String,
60    pub name: String,
61    pub exp: i64,
62    pub iat: i64,
63}
64
65// ─── Request / Response types ──────────────────────────────────────────────────
66
67#[derive(Debug, Deserialize)]
68pub struct SignupRequest {
69    pub name: String,
70    pub email: String,
71    pub password: String,
72}
73
74#[derive(Debug, Deserialize)]
75pub struct LoginRequest {
76    pub email: String,
77    pub password: String,
78}
79
80#[derive(Debug, Deserialize)]
81pub struct VerifyEmailRequest {
82    pub token: String,
83}
84
85#[derive(Debug, Deserialize)]
86pub struct MeRequest {
87    pub token: String,
88}
89
90#[derive(Debug, Serialize)]
91pub struct AuthResponse {
92    pub success: bool,
93    pub message: String,
94    pub user: Option<UserResponse>,
95    pub token: Option<String>,
96    pub requires_verification: bool,
97}
98
99#[derive(Debug, Serialize, Clone)]
100pub struct UserResponse {
101    pub id: i64,
102    pub name: String,
103    pub email: String,
104    pub email_verified: bool,
105    pub avatar_url: Option<String>,
106}
107
108// Google OAuth request/response types
109#[derive(Debug, Deserialize)]
110pub struct GoogleInitRequest {
111    pub port: u16,
112}
113
114#[derive(Debug, Deserialize)]
115pub struct GoogleCallbackQuery {
116    pub code: Option<String>,
117    pub state: Option<String>,
118    pub error: Option<String>,
119}
120
121#[derive(Debug, Deserialize)]
122pub struct GoogleStatusQuery {
123    pub state: String,
124}
125
126/// Google token-endpoint response (we only need access_token)
127#[derive(Deserialize)]
128struct GoogleTokenResponse {
129    access_token: String,
130}
131
132/// Google userinfo-endpoint response
133#[derive(Deserialize)]
134struct GoogleUserInfo {
135    id: String,
136    email: String,
137    name: String,
138    picture: Option<String>,
139}
140
141// ─── JWT helpers ───────────────────────────────────────────────────────────────
142
143const JWT_EXPIRY_HOURS: i64 = 24 * 7;
144
145fn create_jwt_token(email: &str, name: &str, secret: &str) -> Result<String, String> {
146    let now = chrono::Utc::now().timestamp();
147    let exp = now + (JWT_EXPIRY_HOURS * 3600);
148
149    let claims = Claims {
150        sub: email.to_string(),
151        email: email.to_string(),
152        name: name.to_string(),
153        exp,
154        iat: now,
155    };
156
157    encode(
158        &Header::default(),
159        &claims,
160        &EncodingKey::from_secret(secret.as_bytes()),
161    )
162    .map_err(|e| format!("Failed to create token: {}", e))
163}
164
165fn decode_jwt_token(token: &str, secret: &str) -> Result<TokenData<Claims>, String> {
166    decode::<Claims>(
167        token,
168        &DecodingKey::from_secret(secret.as_bytes()),
169        &Validation::default(),
170    )
171    .map_err(|e| format!("Invalid token: {}", e))
172}
173
174// ─── Password helpers ──────────────────────────────────────────────────────────
175
176fn hash_password(password: &str) -> Result<String, String> {
177    let salt = SaltString::generate(&mut rand::thread_rng());
178    let argon2 = Argon2::default();
179    let hash = argon2
180        .hash_password(password.as_bytes(), &salt)
181        .map_err(|e| format!("Failed to hash password: {}", e))?;
182    Ok(hash.to_string())
183}
184
185fn verify_password(password: &str, hash: &str) -> Result<bool, String> {
186    use argon2::PasswordHash;
187    let parsed_hash =
188        PasswordHash::new(hash).map_err(|e| format!("Invalid hash: {}", e))?;
189    match Argon2::default().verify_password(password.as_bytes(), &parsed_hash) {
190        Ok(_) => Ok(true),
191        Err(_) => Ok(false),
192    }
193}
194
195// ─── URL encode helper (for building Google OAuth URL) ─────────────────────────
196
197fn url_encode(s: &str) -> String {
198    let mut out = String::with_capacity(s.len() * 3);
199    for b in s.bytes() {
200        match b {
201            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
202                out.push(b as char);
203            }
204            _ => out.push_str(&format!("%{:02X}", b)),
205        }
206    }
207    out
208}
209
210// ─── HTML helpers for Google callback page (shown in system browser) ───────────
211
212fn success_html() -> String {
213    r#"<!DOCTYPE html>
214<html lang="en">
215<head>
216  <meta charset="utf-8">
217  <meta name="viewport" content="width=device-width, initial-scale=1.0">
218  <title>Aud.io — Authenticated</title>
219  <style>
220    * { box-sizing: border-box; margin: 0; padding: 0; }
221    body {
222      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
223      background: #0f1117;
224      color: #e2e8f0;
225      display: flex;
226      align-items: center;
227      justify-content: center;
228      min-height: 100vh;
229    }
230    .card {
231      text-align: center;
232      padding: 48px 40px;
233      background: #1a1d27;
234      border-radius: 16px;
235      border: 1px solid rgba(255,255,255,0.08);
236      max-width: 380px;
237      width: 90%;
238    }
239    .icon {
240      width: 72px; height: 72px;
241      background: linear-gradient(135deg, #00d4aa, #0099ff);
242      border-radius: 50%;
243      display: flex; align-items: center; justify-content: center;
244      margin: 0 auto 24px;
245      font-size: 36px;
246    }
247    h1 { font-size: 22px; font-weight: 600; color: #fff; margin-bottom: 12px; }
248    p { font-size: 14px; color: #94a3b8; line-height: 1.5; }
249    .brand { margin-top: 24px; font-size: 12px; color: #475569; }
250  </style>
251</head>
252<body>
253  <div class="card">
254    <div class="icon">✓</div>
255    <h1>Authentication Successful</h1>
256    <p>You're signed in! You can close this tab and return to Aud.io.</p>
257    <p class="brand">Aud.io · Offline Intelligence</p>
258  </div>
259  <script>
260    // Try to auto-close after 2 s; may not work in all browsers for security reasons
261    setTimeout(() => { try { window.close(); } catch(e) {} }, 2000);
262  </script>
263</body>
264</html>"#
265        .to_string()
266}
267
268fn error_html(msg: &str) -> String {
269    format!(
270        r#"<!DOCTYPE html>
271<html lang="en">
272<head>
273  <meta charset="utf-8">
274  <meta name="viewport" content="width=device-width, initial-scale=1.0">
275  <title>Aud.io — Authentication Error</title>
276  <style>
277    * {{ box-sizing: border-box; margin: 0; padding: 0; }}
278    body {{
279      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
280      background: #0f1117;
281      color: #e2e8f0;
282      display: flex;
283      align-items: center;
284      justify-content: center;
285      min-height: 100vh;
286    }}
287    .card {{
288      text-align: center;
289      padding: 48px 40px;
290      background: #1a1d27;
291      border-radius: 16px;
292      border: 1px solid rgba(255,255,255,0.08);
293      max-width: 380px;
294      width: 90%;
295    }}
296    .icon {{
297      width: 72px; height: 72px;
298      background: linear-gradient(135deg, #ef4444, #b91c1c);
299      border-radius: 50%;
300      display: flex; align-items: center; justify-content: center;
301      margin: 0 auto 24px;
302      font-size: 36px;
303    }}
304    h1 {{ font-size: 22px; font-weight: 600; color: #fff; margin-bottom: 12px; }}
305    p {{ font-size: 14px; color: #94a3b8; line-height: 1.5; }}
306  </style>
307</head>
308<body>
309  <div class="card">
310    <div class="icon">✗</div>
311    <h1>Authentication Failed</h1>
312    <p>{}</p>
313  </div>
314</body>
315</html>"#,
316        msg
317    )
318}
319
320// ─── State accessor ────────────────────────────────────────────────────────────
321
322fn get_auth_state(state: &UnifiedAppState) -> &AuthState {
323    state
324        .auth_state
325        .as_ref()
326        .expect("Auth state not initialized")
327        .as_ref()
328}
329
330// ═══════════════════════════════════════════════════════════════════════════════
331//  Email / password handlers (kept for backward compatibility)
332// ═══════════════════════════════════════════════════════════════════════════════
333
334pub async fn signup(
335    State(state): State<UnifiedAppState>,
336    Json(payload): Json<SignupRequest>,
337) -> impl IntoResponse {
338    let auth_state = get_auth_state(&state);
339    let name = payload.name.trim();
340    let email = payload.email.trim().to_lowercase();
341    let password = payload.password;
342
343    if name.is_empty() {
344        return (
345            StatusCode::BAD_REQUEST,
346            Json(AuthResponse {
347                success: false,
348                message: "Name is required".to_string(),
349                user: None,
350                token: None,
351                requires_verification: false,
352            }),
353        );
354    }
355
356    if email.is_empty() || !email.contains('@') {
357        return (
358            StatusCode::BAD_REQUEST,
359            Json(AuthResponse {
360                success: false,
361                message: "Valid email is required".to_string(),
362                user: None,
363                token: None,
364                requires_verification: false,
365            }),
366        );
367    }
368
369    if password.len() < 6 {
370        return (
371            StatusCode::BAD_REQUEST,
372            Json(AuthResponse {
373                success: false,
374                message: "Password must be at least 6 characters".to_string(),
375                user: None,
376                token: None,
377                requires_verification: false,
378            }),
379        );
380    }
381
382    if let Ok(true) = auth_state.users.email_exists(&email) {
383        return (
384            StatusCode::CONFLICT,
385            Json(AuthResponse {
386                success: false,
387                message: "An account with this email already exists".to_string(),
388                user: None,
389                token: None,
390                requires_verification: false,
391            }),
392        );
393    }
394
395    let password_hash = match hash_password(&password) {
396        Ok(hash) => hash,
397        Err(e) => {
398            error!("Failed to hash password: {}", e);
399            return (
400                StatusCode::INTERNAL_SERVER_ERROR,
401                Json(AuthResponse {
402                    success: false,
403                    message: "Failed to create account".to_string(),
404                    user: None,
405                    token: None,
406                    requires_verification: false,
407                }),
408            );
409        }
410    };
411
412    match auth_state
413        .users
414        .create_user(&email, name, &password_hash)
415    {
416        Ok(user_id) => {
417            info!("User created with id: {}", user_id);
418
419            // Issue a JWT immediately so the user is signed in right after signup.
420            let jwt = match create_jwt_token(&email, name, &auth_state.jwt_secret) {
421                Ok(t) => t,
422                Err(e) => {
423                    error!("Failed to create JWT after signup: {}", e);
424                    return (
425                        StatusCode::INTERNAL_SERVER_ERROR,
426                        Json(AuthResponse {
427                            success: false,
428                            message: "Account created but could not sign in automatically. Please log in.".to_string(),
429                            user: None,
430                            token: None,
431                            requires_verification: false,
432                        }),
433                    );
434                }
435            };
436
437            // Fire-and-forget welcome email — never blocks the response.
438            let welcome_name = name.to_string();
439            let welcome_email = email.clone();
440            tokio::spawn(async move {
441                if let Err(e) = send_welcome_email(&welcome_name, &welcome_email).await {
442                    warn!("Failed to send welcome email: {}", e);
443                }
444            });
445
446            (
447                StatusCode::CREATED,
448                Json(AuthResponse {
449                    success: true,
450                    message: "Account created! Welcome to _Aud.io Chat Interface.".to_string(),
451                    user: Some(UserResponse {
452                        id: user_id,
453                        name: name.to_string(),
454                        email: email.clone(),
455                        email_verified: true,
456                        avatar_url: None,
457                    }),
458                    token: Some(jwt),
459                    requires_verification: false,
460                }),
461            )
462        }
463        Err(e) => {
464            error!("Failed to create user: {}", e);
465            (
466                StatusCode::INTERNAL_SERVER_ERROR,
467                Json(AuthResponse {
468                    success: false,
469                    message: "Failed to create account".to_string(),
470                    user: None,
471                    token: None,
472                    requires_verification: false,
473                }),
474            )
475        }
476    }
477}
478
479pub async fn login(
480    State(state): State<UnifiedAppState>,
481    Json(payload): Json<LoginRequest>,
482) -> impl IntoResponse {
483    let auth_state = get_auth_state(&state);
484    let email = payload.email.trim().to_lowercase();
485    let password = payload.password;
486
487    if email.is_empty() || password.is_empty() {
488        return (
489            StatusCode::BAD_REQUEST,
490            Json(AuthResponse {
491                success: false,
492                message: "Email and password are required".to_string(),
493                user: None,
494                token: None,
495                requires_verification: false,
496            }),
497        );
498    }
499
500    let user = match auth_state.users.get_user_by_email(&email) {
501        Ok(Some(user)) => user,
502        Ok(None) => {
503            return (
504                StatusCode::UNAUTHORIZED,
505                Json(AuthResponse {
506                    success: false,
507                    message: "Invalid email or password".to_string(),
508                    user: None,
509                    token: None,
510                    requires_verification: false,
511                }),
512            )
513        }
514        Err(e) => {
515            error!("Failed to get user: {}", e);
516            return (
517                StatusCode::INTERNAL_SERVER_ERROR,
518                Json(AuthResponse {
519                    success: false,
520                    message: "Login failed".to_string(),
521                    user: None,
522                    token: None,
523                    requires_verification: false,
524                }),
525            );
526        }
527    };
528
529    // Google-only accounts cannot log in with email/password
530    if user.password_hash == "google-oauth-user" {
531        return (
532            StatusCode::UNAUTHORIZED,
533            Json(AuthResponse {
534                success: false,
535                message: "This account uses Google sign-in. Please use \"Sign in with Google\"."
536                    .to_string(),
537                user: None,
538                token: None,
539                requires_verification: false,
540            }),
541        );
542    }
543
544    let password_valid = match verify_password(&password, &user.password_hash) {
545        Ok(valid) => valid,
546        Err(e) => {
547            error!("Failed to verify password: {}", e);
548            return (
549                StatusCode::INTERNAL_SERVER_ERROR,
550                Json(AuthResponse {
551                    success: false,
552                    message: "Login failed".to_string(),
553                    user: None,
554                    token: None,
555                    requires_verification: false,
556                }),
557            );
558        }
559    };
560
561    if !password_valid {
562        return (
563            StatusCode::UNAUTHORIZED,
564            Json(AuthResponse {
565                success: false,
566                message: "Invalid email or password".to_string(),
567                user: None,
568                token: None,
569                requires_verification: false,
570            }),
571        );
572    }
573
574    let token = match create_jwt_token(&email, &user.name, &auth_state.jwt_secret) {
575        Ok(token) => token,
576        Err(e) => {
577            error!("Failed to create JWT token: {}", e);
578            return (
579                StatusCode::INTERNAL_SERVER_ERROR,
580                Json(AuthResponse {
581                    success: false,
582                    message: "Login failed".to_string(),
583                    user: None,
584                    token: None,
585                    requires_verification: false,
586                }),
587            );
588        }
589    };
590
591    info!("User logged in: {}", email);
592    (
593        StatusCode::OK,
594        Json(AuthResponse {
595            success: true,
596            message: "Login successful".to_string(),
597            user: Some(UserResponse {
598                id: user.id,
599                name: user.name,
600                email: user.email,
601                email_verified: user.email_verified,
602                avatar_url: user.avatar_url,
603            }),
604            token: Some(token),
605            requires_verification: false,
606        }),
607    )
608}
609
610pub async fn verify_email(
611    State(state): State<UnifiedAppState>,
612    Json(payload): Json<VerifyEmailRequest>,
613) -> impl IntoResponse {
614    let auth_state = get_auth_state(&state);
615    let token = payload.token.trim();
616
617    if token.is_empty() {
618        return (
619            StatusCode::BAD_REQUEST,
620            Json(AuthResponse {
621                success: false,
622                message: "Verification token is required".to_string(),
623                user: None,
624                token: None,
625                requires_verification: false,
626            }),
627        );
628    }
629
630    let user = match auth_state.users.verify_email(token) {
631        Ok(Some(user)) => user,
632        Ok(None) => {
633            return (
634                StatusCode::BAD_REQUEST,
635                Json(AuthResponse {
636                    success: false,
637                    message: "Invalid or expired verification token".to_string(),
638                    user: None,
639                    token: None,
640                    requires_verification: true,
641                }),
642            )
643        }
644        Err(e) => {
645            error!("Failed to verify email: {}", e);
646            return (
647                StatusCode::INTERNAL_SERVER_ERROR,
648                Json(AuthResponse {
649                    success: false,
650                    message: "Email verification failed".to_string(),
651                    user: None,
652                    token: None,
653                    requires_verification: false,
654                }),
655            );
656        }
657    };
658
659    let jwt = match create_jwt_token(&user.email, &user.name, &auth_state.jwt_secret) {
660        Ok(t) => t,
661        Err(e) => {
662            error!("Failed to create JWT token: {}", e);
663            return (
664                StatusCode::INTERNAL_SERVER_ERROR,
665                Json(AuthResponse {
666                    success: false,
667                    message: "Verification succeeded but login failed".to_string(),
668                    user: None,
669                    token: None,
670                    requires_verification: false,
671                }),
672            );
673        }
674    };
675
676    info!("Email verified for user: {}", user.email);
677    (
678        StatusCode::OK,
679        Json(AuthResponse {
680            success: true,
681            message: "Email verified successfully!".to_string(),
682            user: Some(UserResponse {
683                id: user.id,
684                name: user.name,
685                email: user.email,
686                email_verified: true,
687                avatar_url: user.avatar_url,
688            }),
689            token: Some(jwt),
690            requires_verification: false,
691        }),
692    )
693}
694
695pub async fn get_current_user(
696    State(state): State<UnifiedAppState>,
697    Json(payload): Json<MeRequest>,
698) -> impl IntoResponse {
699    let auth_state = get_auth_state(&state);
700    let token = payload.token;
701
702    if token.is_empty() {
703        return (
704            StatusCode::UNAUTHORIZED,
705            Json(AuthResponse {
706                success: false,
707                message: "No token provided".to_string(),
708                user: None,
709                token: None,
710                requires_verification: false,
711            }),
712        );
713    }
714
715    let token_data = match decode_jwt_token(&token, &auth_state.jwt_secret) {
716        Ok(data) => data,
717        Err(e) => {
718            return (
719                StatusCode::UNAUTHORIZED,
720                Json(AuthResponse {
721                    success: false,
722                    message: format!("Invalid token: {}", e),
723                    user: None,
724                    token: None,
725                    requires_verification: false,
726                }),
727            )
728        }
729    };
730
731    let user = match auth_state
732        .users
733        .get_user_by_email(&token_data.claims.email)
734    {
735        Ok(Some(user)) => user,
736        Ok(None) => {
737            return (
738                StatusCode::NOT_FOUND,
739                Json(AuthResponse {
740                    success: false,
741                    message: "User not found".to_string(),
742                    user: None,
743                    token: None,
744                    requires_verification: false,
745                }),
746            )
747        }
748        Err(e) => {
749            error!("Failed to get user: {}", e);
750            return (
751                StatusCode::INTERNAL_SERVER_ERROR,
752                Json(AuthResponse {
753                    success: false,
754                    message: "Failed to get user info".to_string(),
755                    user: None,
756                    token: None,
757                    requires_verification: false,
758                }),
759            );
760        }
761    };
762
763    (
764        StatusCode::OK,
765        Json(AuthResponse {
766            success: true,
767            message: "User found".to_string(),
768            user: Some(UserResponse {
769                id: user.id,
770                name: user.name,
771                email: user.email,
772                email_verified: user.email_verified,
773                avatar_url: user.avatar_url,
774            }),
775            token: Some(token),
776            requires_verification: false,
777        }),
778    )
779}
780
781// ═══════════════════════════════════════════════════════════════════════════════
782//  Google OAuth handlers
783// ═══════════════════════════════════════════════════════════════════════════════
784
785/// POST /auth/google/init
786/// Body: { "port": 8000 }
787/// Returns: { "auth_url": "https://accounts.google.com/...", "state": "hex-string" }
788pub async fn google_init(
789    State(state): State<UnifiedAppState>,
790    Json(payload): Json<GoogleInitRequest>,
791) -> (StatusCode, Json<serde_json::Value>) {
792    let auth_state = get_auth_state(&state);
793
794    let google = match &auth_state.google {
795        Some(g) => g,
796        None => {
797            return (
798                StatusCode::SERVICE_UNAVAILABLE,
799                Json(serde_json::json!({
800                    "success": false,
801                    "message": "Google OAuth not configured. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables."
802                })),
803            )
804        }
805    };
806
807    // Generate a random state parameter for CSRF protection
808    let state_param: String = hex::encode(rand::thread_rng().gen::<[u8; 16]>());
809    let redirect_uri = format!(
810        "http://127.0.0.1:{}/auth/google/callback",
811        payload.port
812    );
813
814    // Store the state in the pending map
815    {
816        let mut states = google.states.lock().unwrap();
817        states.insert(state_param.clone(), (redirect_uri.clone(), None));
818    }
819
820    let auth_url = format!(
821        "https://accounts.google.com/o/oauth2/v2/auth?client_id={}&redirect_uri={}&response_type=code&scope=openid%20email%20profile&state={}&access_type=online&prompt=select_account",
822        url_encode(&google.client_id),
823        url_encode(&redirect_uri),
824        state_param,
825    );
826
827    info!(
828        "Google OAuth init: state={}... port={}",
829        &state_param[..8],
830        payload.port
831    );
832
833    (
834        StatusCode::OK,
835        Json(serde_json::json!({
836            "auth_url": auth_url,
837            "state": state_param
838        })),
839    )
840}
841
842/// GET /auth/google/callback?code=&state=&error=
843/// This endpoint is hit by the system browser after Google redirects back.
844/// Returns an HTML page (shown in the system browser, not the Tauri WebView).
845pub async fn google_callback(
846    State(state): State<UnifiedAppState>,
847    Query(params): Query<GoogleCallbackQuery>,
848) -> Response {
849    let auth_state = get_auth_state(&state);
850
851    let google = match &auth_state.google {
852        Some(g) => g,
853        None => return Html(error_html("OAuth not configured on this server.")).into_response(),
854    };
855
856    // Handle Google-reported errors (e.g. user denied access)
857    if let Some(err) = params.error {
858        if let Some(ref s) = params.state {
859            google.states.lock().unwrap().remove(s);
860        }
861        return Html(error_html(&format!("Google sign-in was cancelled: {}", err))).into_response();
862    }
863
864    let code = match params.code {
865        Some(c) if !c.is_empty() => c,
866        _ => {
867            return Html(error_html(
868                "Missing authorization code. Please try signing in again.",
869            ))
870            .into_response()
871        }
872    };
873
874    let state_param = match params.state {
875        Some(s) if !s.is_empty() => s,
876        _ => return Html(error_html("Missing state parameter.")).into_response(),
877    };
878
879    // Retrieve the stored redirect_uri for this state (validates the state too)
880    let redirect_uri = {
881        let states = google.states.lock().unwrap();
882        match states.get(&state_param) {
883            Some((uri, _)) => uri.clone(),
884            None => {
885                return Html(error_html(
886                    "Invalid or expired sign-in session. Please try again.",
887                ))
888                .into_response()
889            }
890        }
891    };
892
893    // Exchange the authorization code for an access token
894    let http = reqwest::Client::new();
895    let token_resp = http
896        .post("https://oauth2.googleapis.com/token")
897        .form(&[
898            ("code", code.as_str()),
899            ("client_id", &google.client_id),
900            ("client_secret", &google.client_secret),
901            ("redirect_uri", &redirect_uri),
902            ("grant_type", "authorization_code"),
903        ])
904        .send()
905        .await;
906
907    let access_token = match token_resp {
908        Ok(resp) => match resp.json::<GoogleTokenResponse>().await {
909            Ok(t) => t.access_token,
910            Err(e) => {
911                error!("Failed to parse Google token response: {}", e);
912                return Html(error_html(
913                    "Failed to exchange authorization code with Google.",
914                ))
915                .into_response();
916            }
917        },
918        Err(e) => {
919            error!("Google token exchange failed: {}", e);
920            return Html(error_html(
921                "Could not contact Google servers. Please check your internet connection.",
922            ))
923            .into_response();
924        }
925    };
926
927    // Fetch the user's profile from Google
928    let user_info_resp = http
929        .get("https://www.googleapis.com/oauth2/v2/userinfo")
930        .bearer_auth(&access_token)
931        .send()
932        .await;
933
934    let google_user = match user_info_resp {
935        Ok(resp) => match resp.json::<GoogleUserInfo>().await {
936            Ok(u) => u,
937            Err(e) => {
938                error!("Failed to parse Google user info: {}", e);
939                return Html(error_html("Failed to retrieve your Google account info."))
940                    .into_response();
941            }
942        },
943        Err(e) => {
944            error!("Google userinfo request failed: {}", e);
945            return Html(error_html("Could not retrieve Google profile info.")).into_response();
946        }
947    };
948
949    // Create or update the user in the local database.
950    // Returns (user, is_new_user) — is_new_user=true means first-ever sign-in.
951    let (db_user, is_new_user) = match auth_state.users.upsert_google_user(
952        &google_user.email,
953        &google_user.name,
954        &google_user.id,
955        google_user.picture.as_deref(),
956    ) {
957        Ok(pair) => pair,
958        Err(e) => {
959            error!("Failed to upsert Google user: {}", e);
960            return Html(error_html(
961                "Failed to create or update your account. Please try again.",
962            ))
963            .into_response();
964        }
965    };
966
967    // Fire-and-forget emails when a brand-new user registers.
968    if is_new_user {
969        // Notify the product team
970        let name_notif = google_user.name.clone();
971        let email_notif = google_user.email.clone();
972        tokio::spawn(async move {
973            if let Err(e) = send_new_user_notification(&name_notif, &email_notif).await {
974                warn!("Failed to send new-user notification: {}", e);
975            }
976        });
977
978        // Send welcome email to the user
979        let name_welcome = google_user.name.clone();
980        let email_welcome = google_user.email.clone();
981        tokio::spawn(async move {
982            if let Err(e) = send_welcome_email(&name_welcome, &email_welcome).await {
983                warn!("Failed to send welcome email to new Google user: {}", e);
984            }
985        });
986    }
987
988    // Issue a local JWT
989    let jwt = match create_jwt_token(&db_user.email, &db_user.name, &auth_state.jwt_secret) {
990        Ok(t) => t,
991        Err(e) => {
992            error!("Failed to create JWT for Google user: {}", e);
993            return Html(error_html("Failed to generate authentication token.")).into_response();
994        }
995    };
996
997    let user_response = UserResponse {
998        id: db_user.id,
999        name: db_user.name.clone(),
1000        email: db_user.email.clone(),
1001        email_verified: true,
1002        avatar_url: db_user.avatar_url.clone(),
1003    };
1004
1005    // Store the result so the polling endpoint can pick it up
1006    {
1007        let mut states = google.states.lock().unwrap();
1008        if let Some(entry) = states.get_mut(&state_param) {
1009            entry.1 = Some(GoogleOAuthResult {
1010                token: jwt,
1011                user: user_response,
1012            });
1013        }
1014    }
1015
1016    // Fire-and-forget login confirmation email to the user's own inbox.
1017    // Sent on EVERY successful Google sign-in so the user always gets a receipt.
1018    {
1019        let name  = google_user.name.clone();
1020        let email = google_user.email.clone();
1021        tokio::spawn(async move {
1022            if let Err(e) = send_login_confirmation_email(&name, &email).await {
1023                warn!("Failed to send login confirmation email: {}", e);
1024            }
1025        });
1026    }
1027
1028    info!("Google OAuth complete for: {}", db_user.email);
1029    Html(success_html()).into_response()
1030}
1031
1032/// GET /auth/google/status?state=<hex>
1033/// Returns { "pending": true } while waiting, or full auth result when done.
1034pub async fn google_status(
1035    State(state): State<UnifiedAppState>,
1036    Query(params): Query<GoogleStatusQuery>,
1037) -> (StatusCode, Json<serde_json::Value>) {
1038    let auth_state = get_auth_state(&state);
1039
1040    let google = match &auth_state.google {
1041        Some(g) => g,
1042        None => {
1043            return (
1044                StatusCode::SERVICE_UNAVAILABLE,
1045                Json(serde_json::json!({
1046                    "pending": false,
1047                    "success": false,
1048                    "message": "Google OAuth not configured"
1049                })),
1050            )
1051        }
1052    };
1053
1054    let mut states = google.states.lock().unwrap();
1055
1056    match states.get(&params.state) {
1057        // State not found — either expired or never initiated
1058        None => (
1059            StatusCode::NOT_FOUND,
1060            Json(serde_json::json!({
1061                "pending": false,
1062                "success": false,
1063                "message": "Unknown or expired auth session"
1064            })),
1065        ),
1066
1067        // State found, result not yet available — still waiting for the browser
1068        Some((_, None)) => (
1069            StatusCode::OK,
1070            Json(serde_json::json!({ "pending": true })),
1071        ),
1072
1073        // Result available — return it and clean up
1074        Some((_, Some(_))) => {
1075            let result = states.remove(&params.state).unwrap().1.unwrap();
1076            (
1077                StatusCode::OK,
1078                Json(serde_json::json!({
1079                    "pending": false,
1080                    "success": true,
1081                    "message": "Authentication successful",
1082                    "token": result.token,
1083                    "user": {
1084                        "id": result.user.id,
1085                        "name": result.user.name,
1086                        "email": result.user.email,
1087                        "email_verified": result.user.email_verified,
1088                        "avatar_url": result.user.avatar_url,
1089                    }
1090                })),
1091            )
1092        }
1093    }
1094}
1095
1096// ─── New-user notification email ─────────────────────────────────────────────
1097
1098/// Sends a notification to the product team whenever a brand-new Google user
1099/// registers for the first time.  Non-critical — failures are only logged.
1100async fn send_new_user_notification(name: &str, email: &str) -> Result<(), String> {
1101    use lettre::{
1102        message::header::ContentType, transport::smtp::authentication::Credentials, Message,
1103        SmtpTransport, Transport,
1104    };
1105
1106    let smtp_user = std::env::var("SMTP_USER").unwrap_or_default();
1107    let smtp_pass = std::env::var("SMTP_PASS").unwrap_or_default();
1108
1109    if smtp_user.is_empty() || smtp_pass.is_empty() {
1110        return Err("SMTP credentials not configured — skipping new-user notification".into());
1111    }
1112
1113    let smtp_host =
1114        std::env::var("SMTP_HOST").unwrap_or_else(|_| "smtp.hostinger.com".to_string());
1115    let smtp_port: u16 = std::env::var("SMTP_PORT")
1116        .unwrap_or_else(|_| "587".to_string())
1117        .parse()
1118        .unwrap_or(587);
1119
1120    let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
1121
1122    let msg = Message::builder()
1123        .from(
1124            format!("Aud.io <{}>", smtp_user)
1125                .parse()
1126                .map_err(|e: lettre::address::AddressError| e.to_string())?,
1127        )
1128        .to("Product Team <product@offlineintelligence.io>"
1129            .parse()
1130            .map_err(|e: lettre::address::AddressError| e.to_string())?)
1131        .subject(format!("New User - {}", name))
1132        .header(ContentType::TEXT_HTML)
1133        .body(format!(
1134            r#"<!DOCTYPE html>
1135<html>
1136<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
1137    <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 24px 30px; border-radius: 10px 10px 0 0;">
1138        <h1 style="color: white; margin: 0; font-size: 22px;">New User Registered</h1>
1139    </div>
1140    <div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 10px 10px; border: 1px solid #e0e0e0;">
1141        <table style="width: 100%; border-collapse: collapse;">
1142            <tr><td style="padding: 8px 0; color: #666; width: 100px;">Name</td><td style="padding: 8px 0; font-weight: 600;">{}</td></tr>
1143            <tr><td style="padding: 8px 0; color: #666;">Email</td><td style="padding: 8px 0; font-weight: 600;">{}</td></tr>
1144            <tr><td style="padding: 8px 0; color: #666;">Sign-in</td><td style="padding: 8px 0;">Google OAuth</td></tr>
1145            <tr><td style="padding: 8px 0; color: #666;">Time</td><td style="padding: 8px 0;">{}</td></tr>
1146        </table>
1147    </div>
1148</body>
1149</html>"#,
1150            name, email, now
1151        ))
1152        .map_err(|e| e.to_string())?;
1153
1154    let creds = Credentials::new(smtp_user, smtp_pass);
1155    let mailer = SmtpTransport::starttls_relay(&smtp_host)
1156        .map_err(|e| format!("SMTP relay error: {}", e))?
1157        .port(smtp_port)
1158        .credentials(creds)
1159        .build();
1160
1161    mailer.send(&msg).map_err(|e| format!("Failed to send: {}", e))?;
1162    info!("New-user notification sent for: {}", email);
1163    Ok(())
1164}
1165
1166// ─── Login confirmation email sent to the user after every Google sign-in ─────
1167
1168/// Sends a sign-in confirmation / receipt email to the user's own inbox.
1169/// Non-critical — failures are only logged, never bubbled to the user.
1170async fn send_login_confirmation_email(name: &str, user_email: &str) -> Result<(), String> {
1171    use lettre::{
1172        message::header::ContentType, transport::smtp::authentication::Credentials, Message,
1173        SmtpTransport, Transport,
1174    };
1175
1176    let smtp_user = std::env::var("SMTP_USER").unwrap_or_default();
1177    let smtp_pass = std::env::var("SMTP_PASS").unwrap_or_default();
1178
1179    if smtp_user.is_empty() || smtp_pass.is_empty() {
1180        return Err("SMTP credentials not configured — skipping login confirmation".into());
1181    }
1182
1183    let smtp_host =
1184        std::env::var("SMTP_HOST").unwrap_or_else(|_| "smtp.hostinger.com".to_string());
1185    let smtp_port: u16 = std::env::var("SMTP_PORT")
1186        .unwrap_or_else(|_| "587".to_string())
1187        .parse()
1188        .unwrap_or(587);
1189
1190    let first_name = name.split_whitespace().next().unwrap_or(name);
1191    let now = chrono::Utc::now().format("%d %B %Y at %H:%M UTC").to_string();
1192
1193    let html_body = format!(
1194        r#"<!DOCTYPE html>
1195<html>
1196<body style="margin:0;padding:0;background:#0d0d0d;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
1197<div style="max-width:480px;margin:40px auto;background:#1a1a1a;border-radius:16px;overflow:hidden;border:1px solid #2a2a2a;">
1198
1199  <!-- Header -->
1200  <div style="background:linear-gradient(135deg,#00c9a7 0%,#0099ff 100%);padding:28px 32px 22px;text-align:center;">
1201    <div style="font-size:26px;font-weight:800;color:#fff;letter-spacing:-0.5px;">Aud.io</div>
1202    <div style="font-size:12px;color:rgba(255,255,255,0.75);margin-top:4px;">Offline-first AI Assistant</div>
1203  </div>
1204
1205  <!-- Body -->
1206  <div style="padding:32px;">
1207    <h2 style="margin:0 0 8px;font-size:20px;font-weight:700;color:#f0f0f0;">You're signed in, {}! 👋</h2>
1208    <p style="color:#a0a0a0;font-size:14px;line-height:1.6;margin:0 0 24px;">
1209      Your Google account was used to sign in to Aud.io. If this was you, no action is needed.
1210    </p>
1211
1212    <!-- Details card -->
1213    <div style="background:#242424;border-radius:10px;padding:16px 20px;border-left:3px solid #00c9a7;margin-bottom:24px;">
1214      <table style="width:100%;border-collapse:collapse;">
1215        <tr>
1216          <td style="padding:5px 0;color:#707070;font-size:12px;width:80px;">Account</td>
1217          <td style="padding:5px 0;color:#e0e0e0;font-size:13px;font-weight:500;">{}</td>
1218        </tr>
1219        <tr>
1220          <td style="padding:5px 0;color:#707070;font-size:12px;">Method</td>
1221          <td style="padding:5px 0;color:#e0e0e0;font-size:13px;">Google Sign-In</td>
1222        </tr>
1223        <tr>
1224          <td style="padding:5px 0;color:#707070;font-size:12px;">Time</td>
1225          <td style="padding:5px 0;color:#e0e0e0;font-size:13px;">{}</td>
1226        </tr>
1227      </table>
1228    </div>
1229
1230    <p style="color:#606060;font-size:12px;line-height:1.6;margin:0;">
1231      If you did not sign in to Aud.io, you can safely ignore this email.
1232      No one can access your account without your Google credentials.
1233    </p>
1234  </div>
1235
1236  <!-- Footer -->
1237  <div style="padding:14px 32px;border-top:1px solid #242424;text-align:center;">
1238    <span style="font-size:11px;color:#404040;">© 2025 Aud.io · Offline Intelligence</span>
1239  </div>
1240
1241</div>
1242</body>
1243</html>"#,
1244        first_name, user_email, now
1245    );
1246
1247    let msg = Message::builder()
1248        .from(
1249            format!("Aud.io <{}>", smtp_user)
1250                .parse()
1251                .map_err(|e: lettre::address::AddressError| e.to_string())?,
1252        )
1253        .to(format!("{} <{}>", name, user_email)
1254            .parse()
1255            .map_err(|e: lettre::address::AddressError| e.to_string())?)
1256        .subject("You're signed in to Aud.io")
1257        .header(ContentType::TEXT_HTML)
1258        .body(html_body)
1259        .map_err(|e| e.to_string())?;
1260
1261    let creds = Credentials::new(smtp_user, smtp_pass);
1262    let mailer = SmtpTransport::starttls_relay(&smtp_host)
1263        .map_err(|e| format!("SMTP relay error: {}", e))?
1264        .port(smtp_port)
1265        .credentials(creds)
1266        .build();
1267
1268    mailer.send(&msg).map_err(|e| format!("Failed to send login confirmation: {}", e))?;
1269    info!("Login confirmation email sent to: {}", user_email);
1270    Ok(())
1271}
1272
1273// ─── Welcome email sent once to every new user on first signup ─────────────────
1274
1275/// Sends a one-time welcome email after a new account is created (email/password
1276/// or Google OAuth first sign-in).  Non-critical — failures are only logged.
1277async fn send_welcome_email(name: &str, user_email: &str) -> Result<(), String> {
1278    use lettre::{
1279        message::header::ContentType, transport::smtp::authentication::Credentials, Message,
1280        SmtpTransport, Transport,
1281    };
1282
1283    let smtp_user = std::env::var("SMTP_USER").unwrap_or_default();
1284    let smtp_pass = std::env::var("SMTP_PASS").unwrap_or_default();
1285
1286    if smtp_user.is_empty() || smtp_pass.is_empty() {
1287        return Err("SMTP credentials not configured — skipping welcome email".into());
1288    }
1289
1290    let smtp_host =
1291        std::env::var("SMTP_HOST").unwrap_or_else(|_| "smtp.hostinger.com".to_string());
1292    let smtp_port: u16 = std::env::var("SMTP_PORT")
1293        .unwrap_or_else(|_| "587".to_string())
1294        .parse()
1295        .unwrap_or(587);
1296
1297    let first_name = name.split_whitespace().next().unwrap_or(name);
1298
1299    let html_body = format!(
1300        r#"<!DOCTYPE html>
1301<html>
1302<body style="margin:0;padding:0;background:#0d0d0d;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
1303<div style="max-width:520px;margin:40px auto;background:#1a1a1a;border-radius:16px;overflow:hidden;border:1px solid #2a2a2a;">
1304
1305  <!-- Header -->
1306  <div style="background:linear-gradient(135deg,#00c9a7 0%,#0099ff 100%);padding:32px 36px 26px;text-align:center;">
1307    <div style="font-size:28px;font-weight:800;color:#fff;letter-spacing:-0.5px;">_Aud.io</div>
1308    <div style="font-size:13px;color:rgba(255,255,255,0.8);margin-top:5px;">Chat Interface</div>
1309  </div>
1310
1311  <!-- Body -->
1312  <div style="padding:36px;">
1313    <h2 style="margin:0 0 10px;font-size:22px;font-weight:700;color:#f0f0f0;">Welcome, {}! 🎉</h2>
1314    <p style="color:#a0a0a0;font-size:15px;line-height:1.7;margin:0 0 20px;">
1315      Thank you for downloading <strong style="color:#e0e0e0;">_Aud.io Chat Interface</strong> —
1316      your privacy-first, offline AI assistant. We're thrilled to have you on board.
1317    </p>
1318
1319    <p style="color:#a0a0a0;font-size:15px;line-height:1.7;margin:0 0 28px;">
1320      Everything runs locally on your device, so your conversations stay completely private.
1321      No data leaves your machine unless you explicitly use an online model.
1322    </p>
1323
1324    <!-- Feedback callout -->
1325    <div style="background:#242424;border-radius:12px;padding:20px 24px;border-left:3px solid #00c9a7;margin-bottom:28px;">
1326      <p style="margin:0 0 8px;font-size:14px;font-weight:600;color:#e0e0e0;">Share your feedback 💬</p>
1327      <p style="margin:0;font-size:13px;color:#808080;line-height:1.6;">
1328        We read every message. If you have a suggestion, a feature request, or ran into
1329        something unexpected, simply reply to this email — we'd love to hear from you.
1330      </p>
1331    </div>
1332
1333    <!-- CTA button -->
1334    <div style="text-align:center;margin-bottom:28px;">
1335      <a href="https://www.offlineintelligence.io"
1336         style="display:inline-block;padding:13px 32px;background:linear-gradient(135deg,#00c9a7,#0099ff);color:#fff;
1337                text-decoration:none;border-radius:50px;font-weight:600;font-size:14px;letter-spacing:0.3px;">
1338        Explore Our Products
1339      </a>
1340    </div>
1341
1342    <p style="color:#606060;font-size:13px;line-height:1.6;margin:0;">
1343      You're receiving this because you just created an account on _Aud.io Chat Interface.
1344    </p>
1345  </div>
1346
1347  <!-- Footer -->
1348  <div style="padding:16px 36px;border-top:1px solid #242424;text-align:center;">
1349    <span style="font-size:11px;color:#404040;">
1350      © 2025 Offline Intelligence ·
1351      <a href="https://www.offlineintelligence.io" style="color:#505050;text-decoration:none;">offlineintelligence.io</a>
1352    </span>
1353  </div>
1354
1355</div>
1356</body>
1357</html>"#,
1358        first_name
1359    );
1360
1361    let msg = Message::builder()
1362        .from(
1363            format!("_Aud.io <{}>", smtp_user)
1364                .parse()
1365                .map_err(|e: lettre::address::AddressError| e.to_string())?,
1366        )
1367        .to(format!("{} <{}>", name, user_email)
1368            .parse()
1369            .map_err(|e: lettre::address::AddressError| e.to_string())?)
1370        .subject("Welcome to _Aud.io Chat Interface!")
1371        .header(ContentType::TEXT_HTML)
1372        .body(html_body)
1373        .map_err(|e| e.to_string())?;
1374
1375    let creds = Credentials::new(smtp_user, smtp_pass);
1376    let mailer = SmtpTransport::starttls_relay(&smtp_host)
1377        .map_err(|e| format!("SMTP relay error: {}", e))?
1378        .port(smtp_port)
1379        .credentials(creds)
1380        .build();
1381
1382    mailer.send(&msg).map_err(|e| format!("Failed to send welcome email: {}", e))?;
1383    info!("Welcome email sent to: {}", user_email);
1384    Ok(())
1385}