SSO auth
This commit is contained in:
51
src-tauri/src/assets/auth-success.html
Normal file
51
src-tauri/src/assets/auth-success.html
Normal 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>
|
||||||
@@ -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(¤t_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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -6,48 +6,44 @@ 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 +83,70 @@ 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 response = with_auth(http_client.post(format!("{}/auth/change-password", base_url)).json(
|
|
||||||
&ChangePasswordRequest {
|
|
||||||
current_password,
|
|
||||||
new_password,
|
|
||||||
},
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
ensure_success(response).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn reset_password(old_password: &str, new_password: &str) -> Result<(), 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/sso/exchange", base_url))
|
||||||
.header("Authorization", format!("Bearer {}", access_token))
|
.json(&ExchangeSsoCodeRequest { code })
|
||||||
.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 refresh_token(refresh_token: &str) -> Result<AuthPass, AuthError> {
|
||||||
|
let (base_url, http_client) = auth_http_context()?;
|
||||||
|
let response = http_client
|
||||||
|
.post(format!("{}/auth/refresh", base_url))
|
||||||
|
.json(&RefreshTokenRequest { refresh_token })
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let token_response: TokenResponse = ensure_success(response).await?.json().await?;
|
||||||
|
let auth_pass = build_auth_pass(
|
||||||
|
token_response.access_token,
|
||||||
|
token_response.expires_in,
|
||||||
|
token_response.refresh_token,
|
||||||
|
token_response.refresh_expires_in,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
lock_w!(FDOLL).auth.auth_pass = Some(auth_pass.clone());
|
||||||
|
save_auth_pass(&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(())
|
||||||
|
}
|
||||||
|
|||||||
187
src-tauri/src/services/auth/flow.rs
Normal file
187
src-tauri/src/services/auth/flow.rs
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
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::state::clear_auth_flow_state;
|
||||||
|
use crate::{lock_r, state::FDOLL};
|
||||||
|
|
||||||
|
use super::api::{exchange_sso_code, start_sso};
|
||||||
|
use super::storage::AuthError;
|
||||||
|
|
||||||
|
static AUTH_SUCCESS_HTML: &str = include_str!("../../assets/auth-success.html");
|
||||||
|
|
||||||
|
pub struct OAuthCallbackParams {
|
||||||
|
pub state: String,
|
||||||
|
pub code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_browser_auth_flow(provider: &str) -> Result<(), AuthError> {
|
||||||
|
clear_auth_flow_state();
|
||||||
|
|
||||||
|
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 = start_sso(provider, &redirect_uri).await?;
|
||||||
|
|
||||||
|
let cancel_token = CancellationToken::new();
|
||||||
|
{
|
||||||
|
let mut guard = crate::lock_w!(crate::state::FDOLL);
|
||||||
|
guard.auth.oauth_flow.state = Some(start_response.state.clone());
|
||||||
|
guard.auth.oauth_flow.background_auth_token = Some(cancel_token.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = build_authorize_url(provider, &start_response.state)?;
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
match listen_for_callback(listener, cancel_token.clone()).await {
|
||||||
|
Ok(params) => {
|
||||||
|
if params.state != expected_state {
|
||||||
|
error!("SSO state mismatch");
|
||||||
|
clear_auth_flow_state();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = exchange_sso_code(¶ms.code).await {
|
||||||
|
error!("Failed to exchange SSO code: {}", err);
|
||||||
|
clear_auth_flow_state();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear_auth_flow_state();
|
||||||
|
if let Err(err) = super::session::finish_login_session().await {
|
||||||
|
error!("Failed to finalize desktop login session: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(AuthError::Cancelled) => {
|
||||||
|
info!("Auth flow cancelled");
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("Auth callback listener failed: {}", err);
|
||||||
|
clear_auth_flow_state();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
get_app_handle()
|
||||||
|
.opener()
|
||||||
|
.open_url(auth_url, None::<&str>)?;
|
||||||
|
|
||||||
|
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<OAuthCallbackParams, 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(callback) = parse_callback(&mut stream).await? {
|
||||||
|
return Ok(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()))?;
|
||||||
|
let code = params
|
||||||
|
.get("code")
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| AuthError::MissingParameter("code".to_string()))?;
|
||||||
|
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\n\r\n{}",
|
||||||
|
AUTH_SUCCESS_HTML.len(),
|
||||||
|
AUTH_SUCCESS_HTML
|
||||||
|
);
|
||||||
|
stream.write_all(response.as_bytes()).await?;
|
||||||
|
stream.flush().await?;
|
||||||
|
|
||||||
|
Ok(Some(OAuthCallbackParams { state, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
|||||||
@@ -17,8 +17,21 @@ pub async fn get_access_token() -> Option<String> {
|
|||||||
|
|
||||||
pub fn logout() -> Result<(), AuthError> {
|
pub 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()
|
||||||
|
.map(|pass| pass.refresh_token);
|
||||||
clear_auth_pass()?;
|
clear_auth_pass()?;
|
||||||
|
|
||||||
|
if let Some(refresh_token) = refresh_token {
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
if let Err(err) = super::api::logout_remote(&refresh_token).await {
|
||||||
|
info!("Failed to revoke refresh token on server: {}", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,8 +41,7 @@ pub async fn logout_and_restart() -> Result<(), AuthError> {
|
|||||||
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 +49,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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,16 @@ 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,
|
||||||
|
pub refresh_token: String,
|
||||||
|
pub refresh_expires_in: 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 +70,8 @@ pub(crate) fn build_auth_pass(
|
|||||||
Ok(AuthPass {
|
Ok(AuthPass {
|
||||||
access_token,
|
access_token,
|
||||||
expires_in,
|
expires_in,
|
||||||
|
refresh_token,
|
||||||
|
refresh_expires_in,
|
||||||
issued_at: Some(issued_at),
|
issued_at: Some(issued_at),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,17 @@ 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 state: Option<String>,
|
||||||
|
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 {
|
||||||
@@ -29,10 +36,19 @@ pub fn init_auth_state() -> AuthState {
|
|||||||
|
|
||||||
AuthState {
|
AuthState {
|
||||||
auth_pass,
|
auth_pass,
|
||||||
|
oauth_flow: OAuthFlowTracker::default(),
|
||||||
background_refresh_token: None,
|
background_refresh_token: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn clear_auth_flow_state() {
|
||||||
|
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.state = None;
|
||||||
|
}
|
||||||
|
|
||||||
/// 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> {
|
||||||
@@ -46,9 +62,15 @@ 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 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 = issued_at.saturating_add(auth_pass.refresh_expires_in);
|
||||||
if !expired {
|
|
||||||
|
if 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,26 +83,37 @@ pub async fn get_auth_pass_with_refresh() -> Option<AuthPass> {
|
|||||||
lock_w!(FDOLL).auth.auth_pass = None;
|
lock_w!(FDOLL).auth.auth_pass = None;
|
||||||
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 = issued_at.saturating_add(auth_pass.refresh_expires_in);
|
||||||
|
|
||||||
|
if 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 {
|
match refresh_token(&auth_pass.refresh_token).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 refresh_if_expiring_soon() {
|
async fn refresh_if_expiring_soon() {
|
||||||
@@ -97,6 +130,12 @@ async fn refresh_if_expiring_soon() {
|
|||||||
Err(_) => return,
|
Err(_) => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let refresh_expires_at = issued_at.saturating_add(auth_pass.refresh_expires_in);
|
||||||
|
if current_time >= refresh_expires_at {
|
||||||
|
clear_expired_auth().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
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 access_expires_at.saturating_sub(current_time) >= 60 {
|
if access_expires_at.saturating_sub(current_time) >= 60 {
|
||||||
return;
|
return;
|
||||||
@@ -117,19 +156,20 @@ async fn refresh_if_expiring_soon() {
|
|||||||
Err(_) => return,
|
Err(_) => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let refresh_expires_at = latest_issued_at.saturating_add(latest_pass.refresh_expires_in);
|
||||||
|
if current_time >= refresh_expires_at {
|
||||||
|
clear_expired_auth().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
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 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 {
|
if let Err(e) = refresh_token(&latest_pass.refresh_token).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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,60 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { commands } from "$lib/bindings";
|
import { commands } from "$lib/bindings";
|
||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
|
||||||
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";
|
||||||
|
|
||||||
let isContinuing = false;
|
let loadingProvider: "google" | "discord" | null = null;
|
||||||
let useRegister = false;
|
|
||||||
let errorMessage = "";
|
let errorMessage = "";
|
||||||
let form = {
|
|
||||||
email: "",
|
|
||||||
password: "",
|
|
||||||
name: "",
|
|
||||||
username: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
if (loadingProvider) return;
|
||||||
if (!form.email.trim() || !form.password) {
|
|
||||||
errorMessage = "Email and password are required";
|
loadingProvider = provider;
|
||||||
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,
|
|
||||||
);
|
|
||||||
useRegister = false;
|
|
||||||
resetRegisterFields();
|
|
||||||
form.password = "";
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await commands.login(form.email.trim(), form.password);
|
|
||||||
await getCurrentWebviewWindow().close();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to authenticate", error);
|
console.error(`Failed to start ${provider} auth`, error);
|
||||||
errorMessage = normalizeError(error);
|
errorMessage = normalizeError(error);
|
||||||
|
loadingProvider = null;
|
||||||
}
|
}
|
||||||
isContinuing = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetRegisterFields = () => {
|
|
||||||
form.name = "";
|
|
||||||
form.username = "";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openClientConfig = async () => {
|
const openClientConfig = async () => {
|
||||||
@@ -81,76 +57,26 @@
|
|||||||
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}
|
disabled={loadingProvider !== null}
|
||||||
>
|
>
|
||||||
{#if isContinuing}
|
<span>{loadingProvider === "google" ? "Opening Google..." : "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;
|
disabled={loadingProvider !== null}
|
||||||
errorMessage = "";
|
|
||||||
if (!useRegister) {
|
|
||||||
resetRegisterFields();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{useRegister
|
<span>{loadingProvider === "discord" ? "Opening Discord..." : "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 +89,8 @@
|
|||||||
{#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.
|
Sign in in your browser, then return here once Friendolls finishes the handshake.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user