SSO auth (1)

This commit is contained in:
2026-03-17 15:08:39 +08:00
parent 905ba5abc0
commit 3cc4f5366d
15 changed files with 923 additions and 379 deletions

View File

@@ -0,0 +1,51 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Friendolls Sign-in Cancelled</title>
<style>
:root {
color-scheme: light;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(circle at top, #fff0cf, transparent 45%),
linear-gradient(180deg, #fffdf7 0%, #f8f2e6 100%);
font-family: "Avenir Next", "Segoe UI", sans-serif;
color: #55411d;
}
.card {
width: min(420px, calc(100vw - 32px));
padding: 32px 28px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 24px 80px rgba(120, 95, 35, 0.14);
text-align: center;
}
h1 {
margin: 0 0 10px;
font-size: 28px;
}
p {
margin: 0;
line-height: 1.5;
color: #7a6237;
}
</style>
</head>
<body>
<div class="card">
<h1>Sign-in cancelled</h1>
<p>You can close this tab and return to Friendolls to try again.</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,51 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Friendolls Sign-in Failed</title>
<style>
:root {
color-scheme: light;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(circle at top, #ffd8d8, transparent 45%),
linear-gradient(180deg, #fff8f8 0%, #fceeee 100%);
font-family: "Avenir Next", "Segoe UI", sans-serif;
color: #4d2323;
}
.card {
width: min(420px, calc(100vw - 32px));
padding: 32px 28px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 24px 80px rgba(135, 57, 57, 0.14);
text-align: center;
}
h1 {
margin: 0 0 10px;
font-size: 28px;
}
p {
margin: 0;
line-height: 1.5;
color: #7c4a4a;
}
</style>
</head>
<body>
<div class="card">
<h1>Sign-in failed</h1>
<p>Friendolls could not complete the browser handshake. Return to the app and try again.</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,51 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Friendolls Sign-in Complete</title>
<style>
:root {
color-scheme: light;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(circle at top, #d8f8ff, transparent 45%),
linear-gradient(180deg, #f8feff 0%, #eef8fb 100%);
font-family: "Avenir Next", "Segoe UI", sans-serif;
color: #24414b;
}
.card {
width: min(420px, calc(100vw - 32px));
padding: 32px 28px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 24px 80px rgba(55, 113, 130, 0.18);
text-align: center;
}
h1 {
margin: 0 0 10px;
font-size: 28px;
}
p {
margin: 0;
line-height: 1.5;
color: #4b6973;
}
</style>
</head>
<body>
<div class="card">
<h1>Signed in</h1>
<p>You can close this browser tab and return to Friendolls.</p>
</div>
</body>
</html>

View File

@@ -8,45 +8,16 @@ pub async fn logout_and_restart() -> Result<(), String> {
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn login(email: String, password: String) -> Result<(), String> { pub async fn start_google_auth() -> Result<(), String> {
auth::login_and_init_session(&email, &password) auth::start_browser_login("google")
.await .await
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn register( pub async fn start_discord_auth() -> Result<(), String> {
email: String, auth::start_browser_login("discord")
password: String,
name: Option<String>,
username: Option<String>,
) -> Result<String, String> {
auth::register(
&email,
&password,
name.as_deref(),
username.as_deref(),
)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
#[specta::specta]
pub async fn change_password(
current_password: String,
new_password: String,
) -> Result<(), String> {
auth::change_password(&current_password, &new_password)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
#[specta::specta]
pub async fn reset_password(old_password: String, new_password: String) -> Result<(), String> {
auth::reset_password(&old_password, &new_password)
.await .await
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }

View File

@@ -10,7 +10,7 @@ use commands::app_state::{
get_active_doll_sprite_base64, get_app_data, get_friend_active_doll_sprites_base64, get_active_doll_sprite_base64, get_app_data, get_friend_active_doll_sprites_base64,
refresh_app_data, refresh_app_data,
}; };
use commands::auth::{change_password, login, logout_and_restart, register, reset_password}; use commands::auth::{logout_and_restart, start_discord_auth, start_google_auth};
use commands::config::{get_client_config, open_client_config, save_client_config}; use commands::config::{get_client_config, open_client_config, save_client_config};
use commands::dolls::{ use commands::dolls::{
create_doll, delete_doll, get_doll, get_dolls, remove_active_doll, set_active_doll, update_doll, create_doll, delete_doll, get_doll, get_dolls, remove_active_doll, set_active_doll, update_doll,
@@ -27,9 +27,9 @@ use tauri::async_runtime;
use tauri_specta::{collect_commands, collect_events, Builder as SpectaBuilder, ErrorHandlingMode}; use tauri_specta::{collect_commands, collect_events, Builder as SpectaBuilder, ErrorHandlingMode};
use crate::services::app_events::{ use crate::services::app_events::{
ActiveDollSpriteChanged, AppDataRefreshed, CreateDoll, CursorMoved, EditDoll, ActiveDollSpriteChanged, AppDataRefreshed, AuthFlowUpdated, CreateDoll, CursorMoved,
FriendActiveDollChanged, FriendActiveDollSpritesUpdated, FriendCursorPositionsUpdated, EditDoll, FriendActiveDollChanged, FriendActiveDollSpritesUpdated,
FriendDisconnected, FriendCursorPositionsUpdated, FriendDisconnected,
FriendRequestAccepted, FriendRequestDenied, FriendRequestReceived, FriendUserStatusChanged, FriendRequestAccepted, FriendRequestDenied, FriendRequestReceived, FriendUserStatusChanged,
InteractionDeliveryFailed, InteractionReceived, SceneInteractiveChanged, SetInteractionOverlay, InteractionDeliveryFailed, InteractionReceived, SceneInteractiveChanged, SetInteractionOverlay,
Unfriended, UserStatusChanged, Unfriended, UserStatusChanged,
@@ -99,10 +99,8 @@ pub fn run() {
get_scene_interactive, get_scene_interactive,
set_scene_interactive, set_scene_interactive,
set_pet_menu_state, set_pet_menu_state,
login, start_google_auth,
register, start_discord_auth,
change_password,
reset_password,
logout_and_restart, logout_and_restart,
send_interaction_cmd, send_interaction_cmd,
get_modules get_modules
@@ -126,7 +124,8 @@ pub fn run() {
FriendRequestReceived, FriendRequestReceived,
FriendRequestAccepted, FriendRequestAccepted,
FriendRequestDenied, FriendRequestDenied,
Unfriended Unfriended,
AuthFlowUpdated
]); ]);
#[cfg(debug_assertions)] #[cfg(debug_assertions)]

View File

@@ -18,6 +18,23 @@ use crate::{
}, },
}; };
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "snake_case")]
pub enum AuthFlowStatus {
Started,
Succeeded,
Failed,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AuthFlowUpdatedPayload {
pub provider: String,
pub status: AuthFlowStatus,
pub message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "cursor-position")] #[tauri_specta(event_name = "cursor-position")]
pub struct CursorMoved(pub CursorPositions); pub struct CursorMoved(pub CursorPositions);
@@ -93,3 +110,7 @@ pub struct FriendRequestDenied(pub FriendRequestDeniedPayload);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "unfriended")] #[tauri_specta(event_name = "unfriended")]
pub struct Unfriended(pub UnfriendedPayload); pub struct Unfriended(pub UnfriendedPayload);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "auth-flow-updated")]
pub struct AuthFlowUpdated(pub AuthFlowUpdatedPayload);

View File

@@ -6,48 +6,46 @@ use crate::{lock_r, lock_w, state::FDOLL};
use super::storage::{build_auth_pass, save_auth_pass, AuthError, AuthPass}; use super::storage::{build_auth_pass, save_auth_pass, AuthError, AuthPass};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct LoginResponse { pub struct StartSsoResponse {
pub state: String,
#[serde(rename = "authorizeUrl")]
pub authorize_url: Option<String>,
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
#[serde(rename = "accessToken")] #[serde(rename = "accessToken")]
access_token: String, access_token: String,
#[serde(rename = "expiresIn")] #[serde(rename = "expiresIn")]
expires_in: u64, expires_in: u64,
} #[serde(rename = "refreshToken")]
refresh_token: String,
#[derive(Debug, Deserialize)] #[serde(rename = "refreshExpiresIn")]
struct RegisterResponse { refresh_expires_in: u64,
id: String,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct LoginRequest<'a> { struct StartSsoRequest<'a> {
email: &'a str, provider: &'a str,
password: &'a str, #[serde(rename = "redirectUri")]
redirect_uri: &'a str,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct RegisterRequest<'a> { struct ExchangeSsoCodeRequest<'a> {
email: &'a str, code: &'a str,
password: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
username: Option<&'a str>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct ChangePasswordRequest<'a> { struct RefreshTokenRequest<'a> {
#[serde(rename = "currentPassword")] #[serde(rename = "refreshToken")]
current_password: &'a str, refresh_token: &'a str,
#[serde(rename = "newPassword")]
new_password: &'a str,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct ResetPasswordRequest<'a> { struct LogoutRequest<'a> {
#[serde(rename = "oldPassword")] #[serde(rename = "refreshToken")]
old_password: &'a str, refresh_token: &'a str,
#[serde(rename = "newPassword")]
new_password: &'a str,
} }
fn auth_http_context() -> Result<(String, reqwest::Client), AuthError> { fn auth_http_context() -> Result<(String, reqwest::Client), AuthError> {
@@ -87,96 +85,72 @@ pub async fn with_auth(request: reqwest::RequestBuilder) -> reqwest::RequestBuil
} }
} }
pub async fn login(email: &str, password: &str) -> Result<AuthPass, AuthError> { pub async fn start_sso(provider: &str, redirect_uri: &str) -> Result<StartSsoResponse, AuthError> {
let (base_url, http_client) = auth_http_context()?; let (base_url, http_client) = auth_http_context()?;
let response = http_client let response = http_client
.post(format!("{}/auth/login", base_url)) .post(format!("{}/auth/sso/start", base_url))
.json(&LoginRequest { email, password }) .json(&StartSsoRequest {
.send() provider,
.await?; redirect_uri,
let login_response: LoginResponse = ensure_success(response).await?.json().await?;
let auth_pass = build_auth_pass(login_response.access_token, login_response.expires_in)?;
lock_w!(FDOLL).auth.auth_pass = Some(auth_pass.clone());
save_auth_pass(&auth_pass)?;
Ok(auth_pass)
}
pub async fn register(
email: &str,
password: &str,
name: Option<&str>,
username: Option<&str>,
) -> Result<String, AuthError> {
let (base_url, http_client) = auth_http_context()?;
let response = http_client
.post(format!("{}/auth/register", base_url))
.json(&RegisterRequest {
email,
password,
name,
username,
}) })
.send() .send()
.await?; .await?;
let register_response: RegisterResponse = ensure_success(response).await?.json().await?; ensure_success(response).await?.json().await.map_err(AuthError::from)
Ok(register_response.id)
} }
pub async fn change_password( pub async fn exchange_sso_code(code: &str) -> Result<AuthPass, AuthError> {
current_password: &str,
new_password: &str,
) -> Result<(), AuthError> {
let (base_url, http_client) = auth_http_context()?; let (base_url, http_client) = auth_http_context()?;
let response = with_auth(http_client.post(format!("{}/auth/change-password", base_url)).json( let response = http_client
&ChangePasswordRequest { .post(format!("{}/auth/sso/exchange", base_url))
current_password, .json(&ExchangeSsoCodeRequest { code })
new_password,
},
))
.await
.send() .send()
.await?; .await?;
ensure_success(response).await?; let token_response: TokenResponse = ensure_success(response).await?.json().await?;
Ok(()) build_auth_pass(
token_response.access_token,
token_response.expires_in,
token_response.refresh_token,
token_response.refresh_expires_in,
)
} }
pub async fn reset_password(old_password: &str, new_password: &str) -> Result<(), AuthError> { pub async fn refresh_token(refresh_token: &str) -> Result<AuthPass, AuthError> {
let (base_url, http_client) = auth_http_context()?;
let response = with_auth(http_client.post(format!("{}/auth/reset-password", base_url)).json(
&ResetPasswordRequest {
old_password,
new_password,
},
))
.await
.send()
.await?;
ensure_success(response).await?;
Ok(())
}
pub async fn refresh_token(access_token: &str) -> Result<AuthPass, AuthError> {
let (base_url, http_client) = auth_http_context()?; let (base_url, http_client) = auth_http_context()?;
let response = http_client let response = http_client
.post(format!("{}/auth/refresh", base_url)) .post(format!("{}/auth/refresh", base_url))
.header("Authorization", format!("Bearer {}", access_token)) .json(&RefreshTokenRequest { refresh_token })
.send() .send()
.await?; .await?;
if !response.status().is_success() { let token_response: TokenResponse = ensure_success(response).await?.json().await?;
let status = response.status(); let auth_pass = build_auth_pass(
let error_text = response.text().await.unwrap_or_default(); token_response.access_token,
error!("Token refresh failed with status {}: {}", status, error_text); token_response.expires_in,
return Err(AuthError::RefreshFailed); token_response.refresh_token,
} token_response.refresh_expires_in,
)?;
let refresh_response: LoginResponse = response.json().await?;
let auth_pass = build_auth_pass(refresh_response.access_token, refresh_response.expires_in)?;
lock_w!(FDOLL).auth.auth_pass = Some(auth_pass.clone()); lock_w!(FDOLL).auth.auth_pass = Some(auth_pass.clone());
save_auth_pass(&auth_pass)?; save_auth_pass(&auth_pass)?;
Ok(auth_pass) Ok(auth_pass)
} }
pub async fn logout_remote(refresh_token: &str) -> Result<(), AuthError> {
let (base_url, http_client) = auth_http_context()?;
let response = http_client
.post(format!("{}/auth/logout", base_url))
.json(&LogoutRequest { refresh_token })
.send()
.await?;
ensure_success(response).await?;
Ok(())
}
pub fn persist_auth_pass(auth_pass: &AuthPass) -> Result<(), AuthError> {
lock_w!(FDOLL).auth.auth_pass = Some(auth_pass.clone());
save_auth_pass(auth_pass)?;
Ok(())
}

View File

@@ -0,0 +1,387 @@
use tauri_specta::Event as _;
use tauri_plugin_opener::OpenerExt;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
use crate::get_app_handle;
use crate::services::app_events::{AuthFlowStatus, AuthFlowUpdated, AuthFlowUpdatedPayload};
use crate::state::{begin_auth_flow, clear_auth_flow_state, is_auth_flow_active};
use crate::{lock_r, state::FDOLL};
use super::api::{exchange_sso_code, persist_auth_pass, start_sso};
use super::storage::AuthError;
static AUTH_SUCCESS_HTML: &str = include_str!("../../assets/auth-success.html");
static AUTH_CANCELLED_HTML: &str = include_str!("../../assets/auth-cancelled.html");
static AUTH_FAILED_HTML: &str = include_str!("../../assets/auth-failed.html");
pub struct OAuthCallbackParams {
pub state: String,
pub result: OAuthCallbackResult,
}
pub enum OAuthCallbackResult {
Code(String),
Error { message: String, cancelled: bool },
}
struct PendingOAuthCallback {
stream: TcpStream,
params: OAuthCallbackParams,
}
pub async fn start_browser_auth_flow(provider: &str) -> Result<(), AuthError> {
let (flow_id, cancel_token) = begin_auth_flow();
let bind_addr = "127.0.0.1:0";
let std_listener = std::net::TcpListener::bind(bind_addr)
.map_err(|e| AuthError::ServerBindError(e.to_string()))?;
std_listener
.set_nonblocking(true)
.map_err(|e| AuthError::ServerBindError(e.to_string()))?;
let local_addr = std_listener
.local_addr()
.map_err(|e| AuthError::ServerBindError(e.to_string()))?;
let redirect_uri = format!("http://127.0.0.1:{}/callback", local_addr.port());
let start_response = match start_sso(provider, &redirect_uri).await {
Ok(response) => response,
Err(err) => {
clear_auth_flow_state(flow_id);
return Err(err);
}
};
let listener = TcpListener::from_std(std_listener)
.map_err(|e| AuthError::ServerBindError(e.to_string()))?;
let expected_state = start_response.state.clone();
let auth_url = match start_response.authorize_url.clone() {
Some(authorize_url) => authorize_url,
None => match build_authorize_url(provider, &start_response.state) {
Ok(url) => url,
Err(err) => {
clear_auth_flow_state(flow_id);
return Err(err);
}
},
};
let provider_name = provider.to_string();
if let Err(err) = get_app_handle().opener().open_url(auth_url, None::<&str>) {
clear_auth_flow_state(flow_id);
emit_auth_flow_event(
provider,
AuthFlowStatus::Failed,
Some("Friendolls could not open your browser for sign-in.".to_string()),
);
return Err(err.into());
}
emit_auth_flow_event(provider, AuthFlowStatus::Started, None);
tauri::async_runtime::spawn(async move {
match listen_for_callback(listener, cancel_token.clone()).await {
Ok(mut callback) => {
if !is_auth_flow_active(flow_id) {
let _ = write_html_response(&mut callback.stream, AUTH_CANCELLED_HTML).await;
return;
}
if callback.params.state != expected_state {
error!("SSO state mismatch");
if let Err(err) = write_html_response(&mut callback.stream, AUTH_FAILED_HTML).await {
warn!("Failed to write auth failure response: {}", err);
}
emit_auth_flow_event(
&provider_name,
AuthFlowStatus::Failed,
Some("Sign-in verification failed. Please try again.".to_string()),
);
clear_auth_flow_state(flow_id);
return;
}
match callback.params.result {
OAuthCallbackResult::Code(code) => {
let auth_pass = match exchange_sso_code(&code).await {
Ok(auth_pass) => auth_pass,
Err(err) => {
error!("Failed to exchange SSO code: {}", err);
if let Err(write_err) =
write_html_response(&mut callback.stream, AUTH_FAILED_HTML).await
{
warn!("Failed to write auth failure response: {}", write_err);
}
emit_auth_flow_event(
&provider_name,
AuthFlowStatus::Failed,
Some(
"Friendolls could not complete sign-in. Please try again."
.to_string(),
),
);
clear_auth_flow_state(flow_id);
return;
}
};
if !is_auth_flow_active(flow_id) {
let _ = write_html_response(&mut callback.stream, AUTH_CANCELLED_HTML).await;
return;
}
if let Err(err) = persist_auth_pass(&auth_pass) {
error!("Failed to persist SSO auth pass: {}", err);
if let Err(write_err) =
write_html_response(&mut callback.stream, AUTH_FAILED_HTML).await
{
warn!("Failed to write auth failure response: {}", write_err);
}
emit_auth_flow_event(
&provider_name,
AuthFlowStatus::Failed,
Some(
"Friendolls could not complete sign-in. Please try again."
.to_string(),
),
);
clear_auth_flow_state(flow_id);
return;
}
if let Err(err) = super::session::finish_login_session().await {
error!("Failed to finalize desktop login session: {}", err);
if let Err(write_err) =
write_html_response(&mut callback.stream, AUTH_FAILED_HTML).await
{
warn!("Failed to write auth failure response: {}", write_err);
}
emit_auth_flow_event(
&provider_name,
AuthFlowStatus::Failed,
Some(
"Signed in, but Friendolls could not open your session."
.to_string(),
),
);
clear_auth_flow_state(flow_id);
} else {
if let Err(err) =
write_html_response(&mut callback.stream, AUTH_SUCCESS_HTML).await
{
warn!("Failed to write auth success response: {}", err);
}
emit_auth_flow_event(&provider_name, AuthFlowStatus::Succeeded, None);
clear_auth_flow_state(flow_id);
}
}
OAuthCallbackResult::Error { message, cancelled } => {
let response_html = if cancelled {
AUTH_CANCELLED_HTML
} else {
AUTH_FAILED_HTML
};
if let Err(err) = write_html_response(&mut callback.stream, response_html).await {
warn!("Failed to write auth callback response: {}", err);
}
emit_auth_flow_event(
&provider_name,
if cancelled {
AuthFlowStatus::Cancelled
} else {
AuthFlowStatus::Failed
},
Some(message),
);
clear_auth_flow_state(flow_id);
}
}
}
Err(AuthError::Cancelled) => {
info!("Auth flow cancelled");
if is_auth_flow_active(flow_id) {
emit_auth_flow_event(
&provider_name,
AuthFlowStatus::Cancelled,
Some("Sign-in was cancelled.".to_string()),
);
clear_auth_flow_state(flow_id);
}
}
Err(err) => {
error!("Auth callback listener failed: {}", err);
if is_auth_flow_active(flow_id) {
emit_auth_flow_event(
&provider_name,
AuthFlowStatus::Failed,
Some(auth_flow_error_message(&err)),
);
clear_auth_flow_state(flow_id);
}
}
}
});
Ok(())
}
fn build_authorize_url(provider: &str, state: &str) -> Result<String, AuthError> {
let base_url = lock_r!(FDOLL)
.app_config
.api_base_url
.clone()
.ok_or(AuthError::InvalidConfig)?;
let mut parsed = url::Url::parse(&base_url)
.map_err(|e| AuthError::RequestFailed(format!("Invalid API base URL: {}", e)))?;
let existing_path = parsed.path().trim_end_matches('/');
parsed.set_path(&format!("{}/auth/sso/{}", existing_path, provider));
let query = url::form_urlencoded::Serializer::new(String::new())
.append_pair("state", state)
.finish();
parsed.set_query(Some(&query));
Ok(parsed.to_string())
}
async fn listen_for_callback(
listener: TcpListener,
cancel_token: CancellationToken,
) -> Result<PendingOAuthCallback, AuthError> {
let timeout = tokio::time::Duration::from_secs(300);
let start = tokio::time::Instant::now();
loop {
let elapsed = start.elapsed();
if elapsed >= timeout {
return Err(AuthError::CallbackTimeout);
}
let remaining = timeout - elapsed;
let accepted = tokio::select! {
_ = cancel_token.cancelled() => return Err(AuthError::Cancelled),
result = tokio::time::timeout(remaining, listener.accept()) => result,
};
let (mut stream, _) = match accepted {
Ok(Ok(value)) => value,
Ok(Err(err)) => {
warn!("Accept error in auth callback listener: {}", err);
continue;
}
Err(_) => return Err(AuthError::CallbackTimeout),
};
if let Some(params) = parse_callback(&mut stream).await? {
return Ok(PendingOAuthCallback { stream, params });
}
}
}
async fn parse_callback(stream: &mut TcpStream) -> Result<Option<OAuthCallbackParams>, AuthError> {
let mut buffer = [0; 4096];
let bytes_read = match stream.read(&mut buffer).await {
Ok(value) if value > 0 => value,
_ => return Ok(None),
};
let request = String::from_utf8_lossy(&buffer[..bytes_read]);
let first_line = request.lines().next().unwrap_or_default();
let mut parts = first_line.split_whitespace();
match (parts.next(), parts.next()) {
(Some("GET"), Some(path)) if path.starts_with("/callback") => {
let parsed = url::Url::parse(&format!("http://localhost{}", path))
.map_err(|e| AuthError::RequestFailed(e.to_string()))?;
let params: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect();
let state = params
.get("state")
.cloned()
.ok_or_else(|| AuthError::MissingParameter("state".to_string()))?;
if let Some(error_code) = params.get("error") {
let message = oauth_error_message(error_code, params.get("error_description"));
return Ok(Some(OAuthCallbackParams {
state,
result: OAuthCallbackResult::Error {
message,
cancelled: is_oauth_cancellation(error_code),
},
}));
}
let code = params
.get("code")
.cloned()
.ok_or_else(|| AuthError::MissingParameter("code".to_string()))?;
Ok(Some(OAuthCallbackParams {
state,
result: OAuthCallbackResult::Code(code),
}))
}
(Some("GET"), Some("/health")) => {
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK")
.await?;
Ok(None)
}
_ => {
stream
.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n")
.await?;
Ok(None)
}
}
}
fn emit_auth_flow_event(provider: &str, status: AuthFlowStatus, message: Option<String>) {
if let Err(err) = AuthFlowUpdated(AuthFlowUpdatedPayload {
provider: provider.to_string(),
status,
message,
})
.emit(get_app_handle())
{
warn!("Failed to emit auth flow event: {}", err);
}
}
fn auth_flow_error_message(err: &AuthError) -> String {
match err {
AuthError::Cancelled => "Sign-in was cancelled.".to_string(),
AuthError::CallbackTimeout => "Sign-in timed out. Please try again.".to_string(),
AuthError::MissingParameter(_) => {
"Friendolls did not receive a complete sign-in response. Please try again.".to_string()
}
_ => "Friendolls could not complete sign-in. Please try again.".to_string(),
}
}
fn oauth_error_message(error_code: &str, description: Option<&String>) -> String {
if let Some(description) = description.filter(|description| !description.is_empty()) {
return description.clone();
}
if is_oauth_cancellation(error_code) {
"Sign-in was cancelled.".to_string()
} else {
"The sign-in provider reported an error. Please try again.".to_string()
}
}
fn is_oauth_cancellation(error_code: &str) -> bool {
matches!(error_code, "access_denied" | "user_cancelled" | "authorization_cancelled")
}
async fn write_html_response(stream: &mut TcpStream, html: &str) -> Result<(), AuthError> {
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\n\r\n{}",
html.len(),
html
);
stream.write_all(response.as_bytes()).await?;
stream.flush().await?;
Ok(())
}

View File

@@ -1,7 +1,8 @@
mod api; mod api;
mod flow;
mod session; mod session;
mod storage; mod storage;
pub use api::{change_password, refresh_token, register, reset_password, with_auth}; pub use api::{refresh_token, with_auth};
pub use session::{get_access_token, get_session_token, login_and_init_session, logout_and_restart}; pub use session::{get_access_token, get_session_token, logout_and_restart, start_browser_login};
pub use storage::{clear_auth_pass, load_auth_pass, AuthPass}; pub use storage::{clear_auth_pass, load_auth_pass, AuthPass};

View File

@@ -1,4 +1,5 @@
use tracing::info; use tracing::info;
use tokio::time::{timeout, Duration};
use crate::get_app_handle; use crate::get_app_handle;
use crate::services::{scene::close_splash_window, session::construct_user_session, welcome::close_welcome_window}; use crate::services::{scene::close_splash_window, session::construct_user_session, welcome::close_welcome_window};
@@ -15,21 +16,33 @@ pub async fn get_access_token() -> Option<String> {
get_session_token().await.map(|pass| pass.access_token) get_session_token().await.map(|pass| pass.access_token)
} }
pub fn logout() -> Result<(), AuthError> { pub async fn logout() -> Result<(), AuthError> {
info!("Logging out user"); info!("Logging out user");
lock_w!(FDOLL).auth.auth_pass = None; let refresh_token = lock_w!(FDOLL)
.auth
.auth_pass
.take()
.and_then(|pass| pass.refresh_token);
clear_auth_pass()?; clear_auth_pass()?;
if let Some(refresh_token) = refresh_token {
match timeout(Duration::from_secs(5), super::api::logout_remote(&refresh_token)).await {
Ok(Ok(())) => {}
Ok(Err(err)) => info!("Failed to revoke refresh token on server: {}", err),
Err(_) => info!("Timed out while revoking refresh token on server"),
}
}
Ok(()) Ok(())
} }
pub async fn logout_and_restart() -> Result<(), AuthError> { pub async fn logout_and_restart() -> Result<(), AuthError> {
logout()?; logout().await?;
let app_handle = get_app_handle(); let app_handle = get_app_handle();
app_handle.restart(); app_handle.restart();
} }
pub async fn login_and_init_session(email: &str, password: &str) -> Result<(), AuthError> { pub async fn finish_login_session() -> Result<(), AuthError> {
super::api::login(email, password).await?;
close_welcome_window(); close_welcome_window();
tauri::async_runtime::spawn(async { tauri::async_runtime::spawn(async {
construct_user_session().await; construct_user_session().await;
@@ -37,3 +50,7 @@ pub async fn login_and_init_session(email: &str, password: &str) -> Result<(), A
}); });
Ok(()) Ok(())
} }
pub async fn start_browser_login(provider: &str) -> Result<(), AuthError> {
super::flow::start_browser_auth_flow(provider).await
}

View File

@@ -29,6 +29,21 @@ pub enum AuthError {
#[error("Request failed: {0}")] #[error("Request failed: {0}")]
RequestFailed(String), RequestFailed(String),
#[error("Missing callback parameter: {0}")]
MissingParameter(String),
#[error("Authentication flow cancelled")]
Cancelled,
#[error("Callback timeout - no response received")]
CallbackTimeout,
#[error("Server binding failed: {0}")]
ServerBindError(String),
#[error("Failed to open auth portal: {0}")]
OpenPortalFailed(#[from] tauri_plugin_opener::Error),
#[error("IO error: {0}")] #[error("IO error: {0}")]
IoError(#[from] std::io::Error), IoError(#[from] std::io::Error),
} }
@@ -37,12 +52,18 @@ pub enum AuthError {
pub struct AuthPass { pub struct AuthPass {
pub access_token: String, pub access_token: String,
pub expires_in: u64, pub expires_in: u64,
#[serde(default)]
pub refresh_token: Option<String>,
#[serde(default)]
pub refresh_expires_in: Option<u64>,
pub issued_at: Option<u64>, pub issued_at: Option<u64>,
} }
pub(crate) fn build_auth_pass( pub(crate) fn build_auth_pass(
access_token: String, access_token: String,
expires_in: u64, expires_in: u64,
refresh_token: String,
refresh_expires_in: u64,
) -> Result<AuthPass, AuthError> { ) -> Result<AuthPass, AuthError> {
let issued_at = SystemTime::now() let issued_at = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
@@ -51,6 +72,8 @@ pub(crate) fn build_auth_pass(
Ok(AuthPass { Ok(AuthPass {
access_token, access_token,
expires_in, expires_in,
refresh_token: Some(refresh_token),
refresh_expires_in: Some(refresh_expires_in),
issued_at: Some(issued_at), issued_at: Some(issued_at),
}) })
} }
@@ -168,6 +191,10 @@ pub fn load_auth_pass() -> Result<Option<AuthPass>, AuthError> {
} }
}; };
if auth_pass.refresh_token.is_none() || auth_pass.refresh_expires_in.is_none() {
info!("Loaded legacy auth pass without refresh token support");
}
Ok(Some(auth_pass)) Ok(Some(auth_pass))
} }

View File

@@ -11,15 +11,30 @@ use tracing::{error, info, warn};
static REFRESH_LOCK: once_cell::sync::Lazy<Mutex<()>> = static REFRESH_LOCK: once_cell::sync::Lazy<Mutex<()>> =
once_cell::sync::Lazy::new(|| Mutex::new(())); once_cell::sync::Lazy::new(|| Mutex::new(()));
#[derive(Default, Clone)]
pub struct OAuthFlowTracker {
pub active_flow_id: u64,
pub background_auth_token: Option<CancellationToken>,
}
#[derive(Default)] #[derive(Default)]
pub struct AuthState { pub struct AuthState {
pub auth_pass: Option<AuthPass>, pub auth_pass: Option<AuthPass>,
pub background_refresh_token: Option<tokio_util::sync::CancellationToken>, pub oauth_flow: OAuthFlowTracker,
pub background_refresh_token: Option<CancellationToken>,
} }
pub fn init_auth_state() -> AuthState { pub fn init_auth_state() -> AuthState {
let auth_pass = match load_auth_pass() { let auth_pass = match load_auth_pass() {
Ok(pass) => pass, Ok(Some(pass)) if has_supported_auth_pass(&pass) => Some(pass),
Ok(Some(_)) => {
warn!("Discarding stored auth pass from unsupported auth format");
if let Err(err) = clear_auth_pass() {
error!("Failed to clear unsupported auth pass: {}", err);
}
None
}
Ok(None) => None,
Err(e) => { Err(e) => {
warn!("Failed to load auth pass from keyring: {e}"); warn!("Failed to load auth pass from keyring: {e}");
None None
@@ -29,10 +44,50 @@ pub fn init_auth_state() -> AuthState {
AuthState { AuthState {
auth_pass, auth_pass,
oauth_flow: OAuthFlowTracker::default(),
background_refresh_token: None, background_refresh_token: None,
} }
} }
fn has_supported_auth_pass(auth_pass: &AuthPass) -> bool {
auth_pass.issued_at.is_some()
&& auth_pass.refresh_token.is_some()
&& auth_pass.refresh_expires_in.is_some()
}
pub fn begin_auth_flow() -> (u64, CancellationToken) {
let mut guard = lock_w!(FDOLL);
if let Some(cancel_token) = guard.auth.oauth_flow.background_auth_token.take() {
cancel_token.cancel();
}
guard.auth.oauth_flow.active_flow_id = guard.auth.oauth_flow.active_flow_id.saturating_add(1);
let flow_id = guard.auth.oauth_flow.active_flow_id;
let cancel_token = CancellationToken::new();
guard.auth.oauth_flow.background_auth_token = Some(cancel_token.clone());
(flow_id, cancel_token)
}
pub fn clear_auth_flow_state(flow_id: u64) -> bool {
let mut guard = lock_w!(FDOLL);
if guard.auth.oauth_flow.active_flow_id != flow_id {
return false;
}
if let Some(cancel_token) = guard.auth.oauth_flow.background_auth_token.take() {
cancel_token.cancel();
}
true
}
pub fn is_auth_flow_active(flow_id: u64) -> bool {
let guard = lock_r!(FDOLL);
guard.auth.oauth_flow.active_flow_id == flow_id
&& guard.auth.oauth_flow.background_auth_token.is_some()
}
/// Returns the auth pass object, including access token and metadata. /// Returns the auth pass object, including access token and metadata.
/// Automatically refreshes if expired and clears session on refresh failure. /// Automatically refreshes if expired and clears session on refresh failure.
pub async fn get_auth_pass_with_refresh() -> Option<AuthPass> { pub async fn get_auth_pass_with_refresh() -> Option<AuthPass> {
@@ -41,14 +96,22 @@ pub async fn get_auth_pass_with_refresh() -> Option<AuthPass> {
let Some(issued_at) = auth_pass.issued_at else { let Some(issued_at) = auth_pass.issued_at else {
warn!("Auth pass missing issued_at timestamp, clearing"); warn!("Auth pass missing issued_at timestamp, clearing");
lock_w!(FDOLL).auth.auth_pass = None; clear_invalid_auth().await;
return None; return None;
}; };
let current_time = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); let current_time = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
let expires_at = issued_at.saturating_add(auth_pass.expires_in); let access_expires_at = issued_at.saturating_add(auth_pass.expires_in);
let expired = current_time >= expires_at; let refresh_expires_at = auth_pass
if !expired { .refresh_expires_in
.map(|refresh_expires_in| issued_at.saturating_add(refresh_expires_in));
if refresh_expires_at.is_some_and(|refresh_expires_at| current_time >= refresh_expires_at) {
clear_expired_auth().await;
return None;
}
if current_time < access_expires_at {
return Some(auth_pass); return Some(auth_pass);
} }
@@ -58,29 +121,52 @@ pub async fn get_auth_pass_with_refresh() -> Option<AuthPass> {
let current_time = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); let current_time = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
let Some(issued_at) = auth_pass.issued_at else { let Some(issued_at) = auth_pass.issued_at else {
warn!("Auth pass missing issued_at timestamp after refresh lock, clearing"); warn!("Auth pass missing issued_at timestamp after refresh lock, clearing");
lock_w!(FDOLL).auth.auth_pass = None; clear_invalid_auth().await;
return None; return None;
}; };
let expires_at = issued_at.saturating_add(auth_pass.expires_in);
let expired = current_time >= expires_at; let access_expires_at = issued_at.saturating_add(auth_pass.expires_in);
if !expired { let refresh_expires_at = auth_pass
.refresh_expires_in
.map(|refresh_expires_in| issued_at.saturating_add(refresh_expires_in));
if refresh_expires_at.is_some_and(|refresh_expires_at| current_time >= refresh_expires_at) {
clear_expired_auth().await;
return None;
}
if current_time < access_expires_at {
return Some(auth_pass); return Some(auth_pass);
} }
info!("Access token expired, attempting refresh"); info!("Access token expired, attempting refresh");
match refresh_token(&auth_pass.access_token).await { let Some(refresh_token_value) = auth_pass.refresh_token.as_deref() else {
warn!("Auth pass missing refresh token, clearing session");
clear_invalid_auth().await;
return None;
};
match refresh_token(refresh_token_value).await {
Ok(new_pass) => Some(new_pass), Ok(new_pass) => Some(new_pass),
Err(e) => { Err(e) => {
error!("Failed to refresh token: {}", e); error!("Failed to refresh token: {}", e);
clear_expired_auth().await;
None
}
}
}
async fn clear_expired_auth() {
lock_w!(FDOLL).auth.auth_pass = None; lock_w!(FDOLL).auth.auth_pass = None;
if let Err(e) = clear_auth_pass() { if let Err(e) = clear_auth_pass() {
error!("Failed to clear expired auth pass: {}", e); error!("Failed to clear expired auth pass: {}", e);
} }
destruct_user_session().await; destruct_user_session().await;
open_welcome_window(); open_welcome_window();
None }
}
} async fn clear_invalid_auth() {
clear_expired_auth().await;
} }
async fn refresh_if_expiring_soon() { async fn refresh_if_expiring_soon() {
@@ -98,6 +184,19 @@ async fn refresh_if_expiring_soon() {
}; };
let access_expires_at = issued_at.saturating_add(auth_pass.expires_in); let access_expires_at = issued_at.saturating_add(auth_pass.expires_in);
if current_time >= access_expires_at && auth_pass.refresh_token.is_none() {
clear_expired_auth().await;
return;
}
let refresh_expires_at = auth_pass
.refresh_expires_in
.map(|refresh_expires_in| issued_at.saturating_add(refresh_expires_in));
if refresh_expires_at.is_some_and(|refresh_expires_at| current_time >= refresh_expires_at) {
clear_expired_auth().await;
return;
}
if access_expires_at.saturating_sub(current_time) >= 60 { if access_expires_at.saturating_sub(current_time) >= 60 {
return; return;
} }
@@ -118,18 +217,30 @@ async fn refresh_if_expiring_soon() {
}; };
let access_expires_at = latest_issued_at.saturating_add(latest_pass.expires_in); let access_expires_at = latest_issued_at.saturating_add(latest_pass.expires_in);
if current_time >= access_expires_at && latest_pass.refresh_token.is_none() {
clear_expired_auth().await;
return;
}
let refresh_expires_at = latest_pass
.refresh_expires_in
.map(|refresh_expires_in| latest_issued_at.saturating_add(refresh_expires_in));
if refresh_expires_at.is_some_and(|refresh_expires_at| current_time >= refresh_expires_at) {
clear_expired_auth().await;
return;
}
if access_expires_at.saturating_sub(current_time) >= 60 { if access_expires_at.saturating_sub(current_time) >= 60 {
return; return;
} }
if let Err(e) = refresh_token(&latest_pass.access_token).await { let Some(refresh_token_value) = latest_pass.refresh_token.as_deref() else {
return;
};
if let Err(e) = refresh_token(refresh_token_value).await {
warn!("Background refresh failed: {}", e); warn!("Background refresh failed: {}", e);
lock_w!(FDOLL).auth.auth_pass = None; clear_expired_auth().await;
if let Err(e) = clear_auth_pass() {
error!("Failed to clear auth pass after refresh failure: {}", e);
}
destruct_user_session().await;
open_welcome_window();
} }
} }

View File

@@ -104,17 +104,11 @@ async setSceneInteractive(interactive: boolean, shouldClick: boolean) : Promise<
async setPetMenuState(id: string, open: boolean) : Promise<void> { async setPetMenuState(id: string, open: boolean) : Promise<void> {
await TAURI_INVOKE("set_pet_menu_state", { id, open }); await TAURI_INVOKE("set_pet_menu_state", { id, open });
}, },
async login(email: string, password: string) : Promise<null> { async startGoogleAuth() : Promise<null> {
return await TAURI_INVOKE("login", { email, password }); return await TAURI_INVOKE("start_google_auth");
}, },
async register(email: string, password: string, name: string | null, username: string | null) : Promise<string> { async startDiscordAuth() : Promise<null> {
return await TAURI_INVOKE("register", { email, password, name, username }); return await TAURI_INVOKE("start_discord_auth");
},
async changePassword(currentPassword: string, newPassword: string) : Promise<null> {
return await TAURI_INVOKE("change_password", { currentPassword, newPassword });
},
async resetPassword(oldPassword: string, newPassword: string) : Promise<null> {
return await TAURI_INVOKE("reset_password", { oldPassword, newPassword });
}, },
async logoutAndRestart() : Promise<null> { async logoutAndRestart() : Promise<null> {
return await TAURI_INVOKE("logout_and_restart"); return await TAURI_INVOKE("logout_and_restart");
@@ -133,6 +127,7 @@ async getModules() : Promise<ModuleMetadata[]> {
export const events = __makeEvents__<{ export const events = __makeEvents__<{
activeDollSpriteChanged: ActiveDollSpriteChanged, activeDollSpriteChanged: ActiveDollSpriteChanged,
appDataRefreshed: AppDataRefreshed, appDataRefreshed: AppDataRefreshed,
authFlowUpdated: AuthFlowUpdated,
createDoll: CreateDoll, createDoll: CreateDoll,
cursorMoved: CursorMoved, cursorMoved: CursorMoved,
editDoll: EditDoll, editDoll: EditDoll,
@@ -153,6 +148,7 @@ userStatusChanged: UserStatusChanged
}>({ }>({
activeDollSpriteChanged: "active-doll-sprite-changed", activeDollSpriteChanged: "active-doll-sprite-changed",
appDataRefreshed: "app-data-refreshed", appDataRefreshed: "app-data-refreshed",
authFlowUpdated: "auth-flow-updated",
createDoll: "create-doll", createDoll: "create-doll",
cursorMoved: "cursor-moved", cursorMoved: "cursor-moved",
editDoll: "edit-doll", editDoll: "edit-doll",
@@ -181,6 +177,9 @@ userStatusChanged: "user-status-changed"
export type ActiveDollSpriteChanged = string | null export type ActiveDollSpriteChanged = string | null
export type AppConfig = { api_base_url: string | null } export type AppConfig = { api_base_url: string | null }
export type AppDataRefreshed = UserData export type AppDataRefreshed = UserData
export type AuthFlowStatus = "started" | "succeeded" | "failed" | "cancelled"
export type AuthFlowUpdated = AuthFlowUpdatedPayload
export type AuthFlowUpdatedPayload = { provider: string; status: AuthFlowStatus; message: string | null }
export type CreateDoll = null export type CreateDoll = null
export type CreateDollDto = { name: string; configuration: DollConfigurationDto | null } export type CreateDollDto = { name: string; configuration: DollConfigurationDto | null }
export type CursorMoved = CursorPositions export type CursorMoved = CursorPositions

View File

@@ -4,14 +4,6 @@
import Power from "../../../assets/icons/power.svelte"; import Power from "../../../assets/icons/power.svelte";
let signingOut = false; let signingOut = false;
let isChangingPassword = false;
let passwordError = "";
let passwordSuccess = "";
let passwordForm = {
currentPassword: "",
newPassword: "",
confirmPassword: "",
};
async function handleSignOut() { async function handleSignOut() {
if (signingOut) return; if (signingOut) return;
@@ -31,99 +23,23 @@
console.error("Failed to open client config", error); console.error("Failed to open client config", error);
} }
}; };
const handleChangePassword = async () => {
if (isChangingPassword) return;
passwordError = "";
passwordSuccess = "";
if (!passwordForm.currentPassword || !passwordForm.newPassword) {
passwordError = "Current and new password are required";
return;
}
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
passwordError = "New password confirmation does not match";
return;
}
isChangingPassword = true;
try {
await commands.changePassword(
passwordForm.currentPassword,
passwordForm.newPassword,
);
passwordSuccess = "Password updated";
passwordForm.currentPassword = "";
passwordForm.newPassword = "";
passwordForm.confirmPassword = "";
} catch (error) {
console.error("Failed to change password", error);
passwordError = error instanceof Error ? error.message : "Unable to update password";
} finally {
isChangingPassword = false;
}
};
</script> </script>
<div class="size-full flex flex-col justify-between"> <div class="size-full flex flex-col justify-between">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4 max-w-md">
<p>{$appData?.user?.name}'s preferences</p> <p>{$appData?.user?.name}'s preferences</p>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<button class="btn" class:btn-disabled={signingOut} onclick={handleSignOut}> <button
class="btn"
class:btn-disabled={signingOut}
onclick={handleSignOut}
>
{signingOut ? "Signing out..." : "Sign out"} {signingOut ? "Signing out..." : "Sign out"}
</button> </button>
<button class="btn btn-outline" onclick={openClientConfig}> <button class="btn btn-outline" onclick={openClientConfig}>
Advanced options Advanced options
</button> </button>
</div> </div>
<div class="divider my-0"></div>
<div class="flex flex-col gap-3 max-w-sm">
<p class="text-sm opacity-70">Change password</p>
<label class="flex flex-col gap-1">
<span class="text-xs opacity-60">Current password</span>
<input
class="input input-bordered input-sm"
type="password"
autocomplete="current-password"
bind:value={passwordForm.currentPassword}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs opacity-60">New password</span>
<input
class="input input-bordered input-sm"
type="password"
autocomplete="new-password"
bind:value={passwordForm.newPassword}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs opacity-60">Confirm new password</span>
<input
class="input input-bordered input-sm"
type="password"
autocomplete="new-password"
bind:value={passwordForm.confirmPassword}
/>
</label>
<div class="flex flex-row gap-2 items-center">
<button
class="btn btn-sm"
class:btn-disabled={isChangingPassword}
disabled={isChangingPassword}
onclick={handleChangePassword}
>
{isChangingPassword ? "Updating..." : "Update password"}
</button>
{#if passwordSuccess}
<span class="text-xs text-success">{passwordSuccess}</span>
{/if}
</div>
{#if passwordError}
<p class="text-xs text-error">{passwordError}</p>
{/if}
</div>
</div> </div>
<div class="w-full flex flex-row justify-between"> <div class="w-full flex flex-row justify-between">
<div></div> <div></div>

View File

@@ -1,61 +1,77 @@
<script lang="ts"> <script lang="ts">
import { commands } from "$lib/bindings"; import { commands, events } from "$lib/bindings";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { onDestroy, onMount } from "svelte";
import DollPreview from "../app-menu/components/doll-preview.svelte"; import DollPreview from "../app-menu/components/doll-preview.svelte";
import ExternalLink from "../../assets/icons/external-link.svelte"; import ExternalLink from "../../assets/icons/external-link.svelte";
import type { UnlistenFn } from "@tauri-apps/api/event";
let isContinuing = false; let activeProvider: "google" | "discord" | null = null;
let useRegister = false;
let errorMessage = ""; let errorMessage = "";
let form = { let unlistenAuthFlow: UnlistenFn | null = null;
email: "",
password: "", type AuthFlowUpdatedPayload = {
name: "", provider: string;
username: "", status: "started" | "succeeded" | "failed" | "cancelled";
message: string | null;
}; };
const normalizeError = (value: unknown) => { const normalizeError = (value: unknown) => {
if (value instanceof Error) { if (value instanceof Error) {
return value.message; return value.message;
} }
return typeof value === "string" ? value : "Something went wrong"; return typeof value === "string" ? value : "Something went wrong";
}; };
const handleContinue = async () => { const startAuth = async (provider: "google" | "discord") => {
if (isContinuing) return; activeProvider = provider;
if (!form.email.trim() || !form.password) {
errorMessage = "Email and password are required";
return;
}
isContinuing = true;
errorMessage = ""; errorMessage = "";
try { try {
if (useRegister) { if (provider === "google") {
await commands.register( await commands.startGoogleAuth();
form.email.trim(), } else {
form.password, await commands.startDiscordAuth();
form.name.trim() || null, }
form.username.trim() || null, } catch (error) {
); console.error(`Failed to start ${provider} auth`, error);
useRegister = false; errorMessage = normalizeError(error);
resetRegisterFields(); if (activeProvider === provider) {
form.password = ""; activeProvider = null;
}
}
};
const providerLabel = (provider: "google" | "discord") =>
provider === "google" ? "Google" : "Discord";
const handleAuthFlowUpdated = ({ payload }: { payload: AuthFlowUpdatedPayload }) => {
const provider = payload.provider as "google" | "discord";
if (activeProvider !== provider) {
return; return;
} }
await commands.login(form.email.trim(), form.password); if (payload.status === "started") {
await getCurrentWebviewWindow().close(); return;
} catch (error) {
console.error("Failed to authenticate", error);
errorMessage = normalizeError(error);
} }
isContinuing = false;
activeProvider = null;
if (payload.status === "succeeded") {
errorMessage = "";
return;
}
errorMessage = payload.message ?? `Unable to sign in with ${providerLabel(provider)}.`;
}; };
const resetRegisterFields = () => { onMount(async () => {
form.name = ""; unlistenAuthFlow = await events.authFlowUpdated.listen(handleAuthFlowUpdated);
form.username = ""; });
};
onDestroy(() => {
unlistenAuthFlow?.();
});
const openClientConfig = async () => { const openClientConfig = async () => {
try { try {
@@ -81,76 +97,24 @@
a cute passive socialization layer! a cute passive socialization layer!
</p> </p>
</div> </div>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-3 max-w-80">
<div class="flex flex-col gap-2">
<label class="flex flex-col gap-1">
<span class="text-xs opacity-60">Email</span>
<input
class="input input-bordered input-sm"
type="email"
autocomplete="email"
bind:value={form.email}
placeholder="you@example.com"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs opacity-60">Password</span>
<input
class="input input-bordered input-sm"
type="password"
autocomplete={useRegister
? "new-password"
: "current-password"}
bind:value={form.password}
placeholder="••••••••"
/>
</label>
{#if useRegister}
<label class="flex flex-col gap-1">
<span class="text-xs opacity-60">Name (optional)</span>
<input
class="input input-bordered input-sm"
autocomplete="name"
bind:value={form.name}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs opacity-60">Username (optional)</span>
<input
class="input input-bordered input-sm"
autocomplete="username"
bind:value={form.username}
/>
</label>
{/if}
</div>
<button <button
class="btn btn-primary btn-xl" class="btn btn-primary btn-xl justify-between"
onclick={handleContinue} onclick={() => startAuth("google")}
disabled={isContinuing}
> >
{#if isContinuing} <span>{activeProvider === "google" ? "Restart Google sign-in" : "Continue with Google"}</span>
Loading...
{:else}
<div class="scale-70"> <div class="scale-70">
<ExternalLink /> <ExternalLink />
</div> </div>
{useRegister ? "Create account" : "Sign in"}
{/if}
</button> </button>
<button <button
class="btn btn-ghost btn-sm px-0 justify-start" class="btn btn-outline btn-xl justify-between"
onclick={() => { onclick={() => startAuth("discord")}
useRegister = !useRegister;
errorMessage = "";
if (!useRegister) {
resetRegisterFields();
}
}}
> >
{useRegister <span>{activeProvider === "discord" ? "Restart Discord sign-in" : "Continue with Discord"}</span>
? "Already have an account? Sign in" <div class="scale-70">
: "New here? Create an account"} <ExternalLink />
</div>
</button> </button>
<button <button
class="btn btn-link p-0 btn-sm text-base-content w-max" class="btn btn-link p-0 btn-sm text-base-content w-max"
@@ -163,8 +127,12 @@
{#if errorMessage} {#if errorMessage}
<p class="text-xs text-error max-w-72">{errorMessage}</p> <p class="text-xs text-error max-w-72">{errorMessage}</p>
{:else} {:else}
<p class="text-xs opacity-50 max-w-60"> <p class="text-xs opacity-50 max-w-72">
An account is needed to identify you for connecting with friends. {#if activeProvider}
Friendolls is waiting for the browser callback. Click either button again to restart sign-in at any time.
{:else}
Sign in through your browser, then return here once Friendolls finishes the handshake.
{/if}
</p> </p>
{/if} {/if}
</div> </div>