native auth
This commit is contained in:
@@ -1,3 +1 @@
|
|||||||
API_BASE_URL=http://127.0.0.1:3000
|
API_BASE_URL=http://127.0.0.1:3000
|
||||||
AUTH_URL=https://auth.example.com/realms/friendolls/protocol/openid-connect
|
|
||||||
JWT_AUDIENCE=friendolls-desktop
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Authentication Successful</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family:
|
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
height: 100vh;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
text-align: center;
|
|
||||||
max-width: 330px;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
color: #2d3748;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
color: #718096;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.checkmark {
|
|
||||||
font-size: 4rem;
|
|
||||||
color: #48bb78;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="checkmark">✓</div>
|
|
||||||
<h1>Signed in!</h1>
|
|
||||||
<p>You have been successfully authenticated.</p>
|
|
||||||
<p>You may now close this window and return to the application.</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,27 +1,47 @@
|
|||||||
use tauri;
|
use crate::services::auth;
|
||||||
use tracing;
|
|
||||||
|
|
||||||
use crate::{init::lifecycle::construct_user_session, services::scene::close_splash_window};
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn logout_and_restart() -> Result<(), String> {
|
pub async fn logout_and_restart() -> Result<(), String> {
|
||||||
crate::services::auth::logout_and_restart()
|
auth::logout_and_restart().await.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn login(email: String, password: String) -> Result<(), String> {
|
||||||
|
auth::login_and_init_session(&email, &password)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn start_auth_flow() -> Result<(), String> {
|
pub async fn register(
|
||||||
// Cancel any in-flight auth listener/state before starting a new one
|
email: String,
|
||||||
crate::services::auth::cancel_auth_flow();
|
password: String,
|
||||||
|
name: Option<String>,
|
||||||
crate::services::auth::init_auth_code_retrieval(|| {
|
username: Option<String>,
|
||||||
tracing::info!("Authentication successful, constructing user session...");
|
) -> Result<String, String> {
|
||||||
crate::services::welcome::close_welcome_window();
|
auth::register(
|
||||||
tauri::async_runtime::spawn(async {
|
&email,
|
||||||
construct_user_session().await;
|
&password,
|
||||||
close_splash_window();
|
name.as_deref(),
|
||||||
});
|
username.as_deref(),
|
||||||
})
|
)
|
||||||
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
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]
|
||||||
|
pub async fn reset_password(old_password: String, new_password: String) -> Result<(), String> {
|
||||||
|
auth::reset_password(&old_password, &new_password)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use crate::services::{
|
|||||||
};
|
};
|
||||||
use commands::app::{quit_app, restart_app, retry_connection};
|
use commands::app::{quit_app, restart_app, retry_connection};
|
||||||
use commands::app_data::{get_app_data, refresh_app_data};
|
use commands::app_data::{get_app_data, refresh_app_data};
|
||||||
use commands::auth::{logout_and_restart, start_auth_flow};
|
use commands::auth::{change_password, login, logout_and_restart, register, reset_password};
|
||||||
use commands::config::{get_client_config, open_client_config_manager, save_client_config};
|
use commands::config::{get_client_config, open_client_config_manager, 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,
|
||||||
@@ -81,7 +81,10 @@ pub fn run() {
|
|||||||
open_doll_editor_window,
|
open_doll_editor_window,
|
||||||
set_scene_interactive,
|
set_scene_interactive,
|
||||||
set_pet_menu_state,
|
set_pet_menu_state,
|
||||||
start_auth_flow,
|
login,
|
||||||
|
register,
|
||||||
|
change_password,
|
||||||
|
reset_password,
|
||||||
logout_and_restart,
|
logout_and_restart,
|
||||||
send_interaction_cmd,
|
send_interaction_cmd,
|
||||||
send_user_status_cmd
|
send_user_status_cmd
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use ts_rs::TS;
|
|||||||
#[ts(export)]
|
#[ts(export)]
|
||||||
pub struct UserProfile {
|
pub struct UserProfile {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub keycloak_sub: String,
|
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub username: Option<String>,
|
pub username: Option<String>,
|
||||||
@@ -16,4 +15,4 @@ pub struct UserProfile {
|
|||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
pub last_login_at: Option<String>,
|
pub last_login_at: Option<String>,
|
||||||
pub active_doll_id: Option<String>,
|
pub active_doll_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
pub mod dolls;
|
pub mod dolls;
|
||||||
pub mod friends;
|
pub mod friends;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod session;
|
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
use reqwest::Error;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use crate::services::auth::with_auth;
|
|
||||||
use crate::{lock_r, state::FDOLL};
|
|
||||||
|
|
||||||
pub struct SessionRemote {
|
|
||||||
pub base_url: String,
|
|
||||||
pub client: reqwest::Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SessionRemote {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let guard = lock_r!(FDOLL);
|
|
||||||
Self {
|
|
||||||
base_url: guard
|
|
||||||
.app_config
|
|
||||||
.api_base_url
|
|
||||||
.as_ref()
|
|
||||||
.expect("App configuration error")
|
|
||||||
.clone(),
|
|
||||||
client: guard
|
|
||||||
.network.clients
|
|
||||||
.as_ref()
|
|
||||||
.expect("App configuration error")
|
|
||||||
.http_client
|
|
||||||
.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn logout(
|
|
||||||
&self,
|
|
||||||
refresh_token: &str,
|
|
||||||
session_state: Option<&str>,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
let url = format!("{}/users/logout", self.base_url);
|
|
||||||
let body = json!({
|
|
||||||
"refreshToken": refresh_token,
|
|
||||||
"sessionState": session_state,
|
|
||||||
});
|
|
||||||
let resp = with_auth(self.client.post(url))
|
|
||||||
.await
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
resp.error_for_status()?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -8,16 +8,9 @@ use url::Url;
|
|||||||
|
|
||||||
use crate::get_app_handle;
|
use crate::get_app_handle;
|
||||||
|
|
||||||
#[derive(Default, Serialize, Deserialize, Clone, Debug)]
|
|
||||||
pub struct AuthConfig {
|
|
||||||
pub audience: String,
|
|
||||||
pub auth_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Serialize, Deserialize, Clone, Debug)]
|
#[derive(Default, Serialize, Deserialize, Clone, Debug)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
pub api_base_url: Option<String>,
|
pub api_base_url: Option<String>,
|
||||||
pub auth: AuthConfig,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -39,8 +32,6 @@ pub enum ClientConfigError {
|
|||||||
pub static CLIENT_CONFIG_MANAGER_WINDOW_LABEL: &str = "client_config_manager";
|
pub static CLIENT_CONFIG_MANAGER_WINDOW_LABEL: &str = "client_config_manager";
|
||||||
const CONFIG_FILENAME: &str = "client_config.json";
|
const CONFIG_FILENAME: &str = "client_config.json";
|
||||||
const DEFAULT_API_BASE_URL: &str = "https://api.fdolls.adamcv.com";
|
const DEFAULT_API_BASE_URL: &str = "https://api.fdolls.adamcv.com";
|
||||||
const DEFAULT_AUTH_URL: &str = "https://auth.adamcv.com/realms/friendolls/protocol/openid-connect";
|
|
||||||
const DEFAULT_JWT_AUDIENCE: &str = "friendolls-desktop";
|
|
||||||
|
|
||||||
fn config_file_path(app_handle: &tauri::AppHandle) -> Result<PathBuf, ClientConfigError> {
|
fn config_file_path(app_handle: &tauri::AppHandle) -> Result<PathBuf, ClientConfigError> {
|
||||||
let dir = app_handle
|
let dir = app_handle
|
||||||
@@ -79,26 +70,12 @@ fn sanitize(mut config: AppConfig) -> AppConfig {
|
|||||||
.or_else(|| Some(DEFAULT_API_BASE_URL.to_string()))
|
.or_else(|| Some(DEFAULT_API_BASE_URL.to_string()))
|
||||||
.map(|v| strip_trailing_slash(&v));
|
.map(|v| strip_trailing_slash(&v));
|
||||||
|
|
||||||
let auth_url_trimmed = config.auth.auth_url.trim();
|
|
||||||
config.auth.auth_url =
|
|
||||||
parse_http_url(auth_url_trimmed).unwrap_or_else(|| DEFAULT_AUTH_URL.to_string());
|
|
||||||
|
|
||||||
if config.auth.audience.trim().is_empty() {
|
|
||||||
config.auth.audience = DEFAULT_JWT_AUDIENCE.to_string();
|
|
||||||
} else {
|
|
||||||
config.auth.audience = config.auth.audience.trim().to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
config
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_app_config() -> AppConfig {
|
pub fn default_app_config() -> AppConfig {
|
||||||
AppConfig {
|
AppConfig {
|
||||||
api_base_url: Some(DEFAULT_API_BASE_URL.to_string()),
|
api_base_url: Some(DEFAULT_API_BASE_URL.to_string()),
|
||||||
auth: AuthConfig {
|
|
||||||
audience: DEFAULT_JWT_AUDIENCE.to_string(),
|
|
||||||
auth_url: DEFAULT_AUTH_URL.to_string(),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,17 +12,8 @@ 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 code_verifier: Option<String>,
|
|
||||||
pub initiated_at: Option<u64>,
|
|
||||||
pub cancel_token: Option<tokio_util::sync::CancellationToken>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AuthState {
|
pub struct AuthState {
|
||||||
pub auth_pass: Option<AuthPass>,
|
pub auth_pass: Option<AuthPass>,
|
||||||
pub oauth_flow: OAuthFlowTracker,
|
|
||||||
pub background_refresh_token: Option<tokio_util::sync::CancellationToken>,
|
pub background_refresh_token: Option<tokio_util::sync::CancellationToken>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +21,6 @@ impl Default for AuthState {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
auth_pass: None,
|
auth_pass: None,
|
||||||
oauth_flow: OAuthFlowTracker::default(),
|
|
||||||
background_refresh_token: None,
|
background_refresh_token: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,13 +38,12 @@ pub fn init_auth_state() -> AuthState {
|
|||||||
|
|
||||||
AuthState {
|
AuthState {
|
||||||
auth_pass,
|
auth_pass,
|
||||||
oauth_flow: OAuthFlowTracker::default(),
|
|
||||||
background_refresh_token: None,
|
background_refresh_token: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the auth pass object, including access token, refresh token, and metadata.
|
/// Returns the auth pass object, including access token and metadata.
|
||||||
/// Automatically refreshes if expired and clears session if refresh token is expired.
|
/// 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> {
|
||||||
info!("Retrieving tokens");
|
info!("Retrieving tokens");
|
||||||
let Some(auth_pass) = ({ lock_r!(FDOLL).auth.auth_pass.clone() }) else {
|
let Some(auth_pass) = ({ lock_r!(FDOLL).auth.auth_pass.clone() }) else {
|
||||||
@@ -68,24 +57,12 @@ 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 expired = current_time - issued_at >= auth_pass.expires_in;
|
let expires_at = issued_at.saturating_add(auth_pass.expires_in);
|
||||||
let refresh_expired = current_time - issued_at >= auth_pass.refresh_expires_in;
|
let expired = current_time >= expires_at;
|
||||||
|
|
||||||
if !expired {
|
if !expired {
|
||||||
return Some(auth_pass);
|
return Some(auth_pass);
|
||||||
}
|
}
|
||||||
|
|
||||||
if refresh_expired {
|
|
||||||
info!("Refresh token expired, clearing auth state");
|
|
||||||
lock_w!(FDOLL).auth.auth_pass = None;
|
|
||||||
if let Err(e) = clear_auth_pass() {
|
|
||||||
error!("Failed to clear expired auth pass: {}", e);
|
|
||||||
}
|
|
||||||
destruct_user_session().await;
|
|
||||||
open_welcome_window();
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let _guard = REFRESH_LOCK.lock().await;
|
let _guard = REFRESH_LOCK.lock().await;
|
||||||
|
|
||||||
let auth_pass = lock_r!(FDOLL).auth.auth_pass.clone()?;
|
let auth_pass = lock_r!(FDOLL).auth.auth_pass.clone()?;
|
||||||
@@ -95,33 +72,23 @@ 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 expired = current_time - issued_at >= auth_pass.expires_in;
|
let expires_at = issued_at.saturating_add(auth_pass.expires_in);
|
||||||
let refresh_expired = current_time - issued_at >= auth_pass.refresh_expires_in;
|
let expired = current_time >= expires_at;
|
||||||
|
|
||||||
if refresh_expired {
|
|
||||||
info!("Refresh token expired, clearing auth state after refresh lock");
|
|
||||||
lock_w!(FDOLL).auth.auth_pass = None;
|
|
||||||
if let Err(e) = clear_auth_pass() {
|
|
||||||
error!("Failed to clear expired auth pass: {}", e);
|
|
||||||
}
|
|
||||||
destruct_user_session().await;
|
|
||||||
open_welcome_window();
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !expired {
|
if !expired {
|
||||||
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.refresh_token).await {
|
match refresh_token(&auth_pass.access_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);
|
||||||
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 auth pass after refresh failure: {}", e);
|
error!("Failed to clear expired auth pass: {}", e);
|
||||||
}
|
}
|
||||||
|
destruct_user_session().await;
|
||||||
|
open_welcome_window();
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,17 +108,6 @@ 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 {
|
|
||||||
lock_w!(FDOLL).auth.auth_pass = None;
|
|
||||||
if let Err(e) = clear_auth_pass() {
|
|
||||||
error!("Failed to clear expired auth pass: {}", e);
|
|
||||||
}
|
|
||||||
destruct_user_session().await;
|
|
||||||
open_welcome_window();
|
|
||||||
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;
|
||||||
@@ -172,24 +128,19 @@ 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 {
|
|
||||||
lock_w!(FDOLL).auth.auth_pass = None;
|
|
||||||
if let Err(e) = clear_auth_pass() {
|
|
||||||
error!("Failed to clear expired auth pass: {}", e);
|
|
||||||
}
|
|
||||||
destruct_user_session().await;
|
|
||||||
open_welcome_window();
|
|
||||||
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.refresh_token).await {
|
if let Err(e) = refresh_token(&latest_pass.access_token).await {
|
||||||
warn!("Background refresh failed: {}", e);
|
warn!("Background refresh failed: {}", e);
|
||||||
|
lock_w!(FDOLL).auth.auth_pass = None;
|
||||||
|
if let Err(e) = clear_auth_pass() {
|
||||||
|
error!("Failed to clear auth pass after refresh failure: {}", e);
|
||||||
|
}
|
||||||
|
destruct_user_session().await;
|
||||||
|
open_welcome_window();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
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;
|
||||||
@@ -23,10 +31,43 @@
|
|||||||
console.error("Failed to open client config manager", error);
|
console.error("Failed to open client config manager", 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 invoke("change_password", {
|
||||||
|
currentPassword: passwordForm.currentPassword,
|
||||||
|
newPassword: 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-2">
|
<div class="flex flex-col gap-4">
|
||||||
<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}>
|
||||||
@@ -36,6 +77,53 @@
|
|||||||
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>
|
||||||
|
|||||||
@@ -2,19 +2,12 @@
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
type AuthConfig = {
|
|
||||||
audience: string;
|
|
||||||
auth_url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AppConfig = {
|
type AppConfig = {
|
||||||
api_base_url?: string | null;
|
api_base_url?: string | null;
|
||||||
auth: AuthConfig;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let form: AppConfig = {
|
let form: AppConfig = {
|
||||||
api_base_url: "",
|
api_base_url: "",
|
||||||
auth: { audience: "", auth_url: "" },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let saving = false;
|
let saving = false;
|
||||||
@@ -27,10 +20,6 @@
|
|||||||
const config = (await invoke("get_client_config")) as AppConfig;
|
const config = (await invoke("get_client_config")) as AppConfig;
|
||||||
form = {
|
form = {
|
||||||
api_base_url: config.api_base_url ?? "",
|
api_base_url: config.api_base_url ?? "",
|
||||||
auth: {
|
|
||||||
audience: config.auth.audience,
|
|
||||||
auth_url: config.auth.auth_url,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorMessage = `Failed to load config: ${err}`;
|
errorMessage = `Failed to load config: ${err}`;
|
||||||
@@ -38,23 +27,6 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
if (!form.auth.auth_url.trim()) {
|
|
||||||
return "Auth URL is required";
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = new URL(form.auth.auth_url.trim());
|
|
||||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
||||||
return "Auth URL must start with http or https";
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
return "Auth URL must be a valid URL";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!form.auth.audience.trim()) {
|
|
||||||
return "JWT audience is required";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (form.api_base_url?.trim()) {
|
if (form.api_base_url?.trim()) {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(
|
const parsed = new URL(
|
||||||
@@ -86,10 +58,6 @@
|
|||||||
await invoke("save_client_config", {
|
await invoke("save_client_config", {
|
||||||
config: {
|
config: {
|
||||||
api_base_url: form.api_base_url?.trim() || null,
|
api_base_url: form.api_base_url?.trim() || null,
|
||||||
auth: {
|
|
||||||
audience: form.auth.audience.trim(),
|
|
||||||
auth_url: form.auth.auth_url.trim(),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,7 +85,7 @@
|
|||||||
<div class="flex flex-col gap-4 w-full">
|
<div class="flex flex-col gap-4 w-full">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<p class="text-xl font-semibold">Client Configuration</p>
|
<p class="text-xl font-semibold">Client Configuration</p>
|
||||||
<p class="opacity-70 text-sm">Set custom API and auth endpoints.</p>
|
<p class="opacity-70 text-sm">Set a custom API endpoint.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
@@ -129,14 +97,6 @@
|
|||||||
placeholder="https://api.fdolls.adamcv.com"
|
placeholder="https://api.fdolls.adamcv.com"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1">
|
|
||||||
<span class="text-sm">Auth URL</span>
|
|
||||||
<input class="input input-bordered" bind:value={form.auth.auth_url} />
|
|
||||||
</label>
|
|
||||||
<label class="flex flex-col gap-1">
|
|
||||||
<span class="text-sm">JWT Audience</span>
|
|
||||||
<input class="input input-bordered" bind:value={form.auth.audience} />
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if errorMessage}
|
{#if errorMessage}
|
||||||
|
|||||||
@@ -5,17 +5,59 @@
|
|||||||
import ExternalLink from "../../assets/icons/external-link.svelte";
|
import ExternalLink from "../../assets/icons/external-link.svelte";
|
||||||
|
|
||||||
let isContinuing = false;
|
let isContinuing = false;
|
||||||
|
let useRegister = false;
|
||||||
|
let errorMessage = "";
|
||||||
|
let form = {
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
name: "",
|
||||||
|
username: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeError = (value: unknown) => {
|
||||||
|
if (value instanceof Error) {
|
||||||
|
return value.message;
|
||||||
|
}
|
||||||
|
return typeof value === "string" ? value : "Something went wrong";
|
||||||
|
};
|
||||||
|
|
||||||
const handleContinue = async () => {
|
const handleContinue = async () => {
|
||||||
if (isContinuing) return;
|
if (isContinuing) return;
|
||||||
|
if (!form.email.trim() || !form.password) {
|
||||||
|
errorMessage = "Email and password are required";
|
||||||
|
return;
|
||||||
|
}
|
||||||
isContinuing = true;
|
isContinuing = true;
|
||||||
|
errorMessage = "";
|
||||||
try {
|
try {
|
||||||
await invoke("start_auth_flow");
|
if (useRegister) {
|
||||||
|
await invoke("register", {
|
||||||
|
email: form.email.trim(),
|
||||||
|
password: form.password,
|
||||||
|
name: form.name.trim() || null,
|
||||||
|
username: form.username.trim() || null,
|
||||||
|
});
|
||||||
|
useRegister = false;
|
||||||
|
resetRegisterFields();
|
||||||
|
form.password = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await invoke("login", {
|
||||||
|
email: form.email.trim(),
|
||||||
|
password: form.password,
|
||||||
|
});
|
||||||
await getCurrentWebviewWindow().close();
|
await getCurrentWebviewWindow().close();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start auth flow", error);
|
console.error("Failed to authenticate", error);
|
||||||
isContinuing = false;
|
errorMessage = normalizeError(error);
|
||||||
}
|
}
|
||||||
|
isContinuing = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetRegisterFields = () => {
|
||||||
|
form.name = "";
|
||||||
|
form.username = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const openClientConfigManager = async () => {
|
const openClientConfigManager = async () => {
|
||||||
@@ -42,7 +84,47 @@
|
|||||||
a cute passive socialization layer!
|
a cute passive socialization layer!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-4 *:w-max">
|
<div class="flex flex-col gap-4">
|
||||||
|
<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"
|
||||||
onclick={handleContinue}
|
onclick={handleContinue}
|
||||||
@@ -54,20 +136,36 @@
|
|||||||
<div class="scale-70">
|
<div class="scale-70">
|
||||||
<ExternalLink />
|
<ExternalLink />
|
||||||
</div>
|
</div>
|
||||||
Sign in
|
{useRegister ? "Create account" : "Sign in"}
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn btn-link p-0 btn-sm text-base-content"
|
class="btn btn-ghost btn-sm px-0 justify-start"
|
||||||
|
onclick={() => {
|
||||||
|
useRegister = !useRegister;
|
||||||
|
errorMessage = "";
|
||||||
|
if (!useRegister) {
|
||||||
|
resetRegisterFields();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{useRegister ? "Already have an account? Sign in" : "New here? Create an account"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-link p-0 btn-sm text-base-content w-max"
|
||||||
onclick={openClientConfigManager}
|
onclick={openClientConfigManager}
|
||||||
>
|
>
|
||||||
Advanced options
|
Advanced options
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-xs opacity-50 max-w-60">
|
{#if errorMessage}
|
||||||
An account is needed to identify you for connecting with friends.
|
<p class="text-xs text-error max-w-72">{errorMessage}</p>
|
||||||
</p>
|
{:else}
|
||||||
|
<p class="text-xs opacity-50 max-w-60">
|
||||||
|
An account is needed to identify you for connecting with friends.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export type UserProfile = { id: string, keycloakSub: string, name: string, email: string, username: string | null, roles: Array<string>, createdAt: string, updatedAt: string, lastLoginAt: string | null, activeDollId: string | null, };
|
export type UserProfile = { id: string, name: string, email: string, username: string | null, roles: Array<string>, createdAt: string, updatedAt: string, lastLoginAt: string | null, activeDollId: string | null, };
|
||||||
|
|||||||
Reference in New Issue
Block a user