native auth

This commit is contained in:
2026-02-11 01:10:08 +08:00
parent 624ee9a222
commit 8f91b6b680
14 changed files with 506 additions and 845 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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(&current_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())
}

View File

@@ -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

View File

@@ -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>,
} }

View File

@@ -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;

View File

@@ -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

View File

@@ -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(),
},
} }
} }

View File

@@ -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();
} }
} }

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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, };