Add account connections, guild wallets and admin fixes [skip ci]
This commit is contained in:
1 parent
7076e847ea
commit
004862ea88
38 files changed
+1696
-458
No files matched your search
@@ -67,7 +67,7 @@ impl AccountsFile {
|
||||
}
|
||||
}
|
||||
|
||||
async fn panel_error(resp: reqwest::Response) -> anyhow::Error {
|
||||
pub(crate) async fn panel_error(resp: reqwest::Response) -> anyhow::Error {
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
anyhow!(
|
||||
@@ -115,7 +115,7 @@ pub async fn register_panel(state: &AppState, username: &str, password: &str, em
|
||||
Ok((Some(save_panel_account(state, &panel, auth)?), false))
|
||||
}
|
||||
|
||||
fn save_panel_account(state: &AppState, panel: &str, auth: AuthResponse) -> Result<Account> {
|
||||
pub(crate) fn save_panel_account(state: &AppState, panel: &str, auth: AuthResponse) -> Result<Account> {
|
||||
let account = Account {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
kind: "panel".into(),
|
||||
|
||||
+206
-100
@@ -7,9 +7,8 @@ use crate::state::{build, AppState};
|
||||
use crate::updater;
|
||||
use scopenet_core::ping::ServerStatus;
|
||||
use scopenet_shared::{
|
||||
Achievement, BaltopEntry, DirectMessage, EconomyTransaction, FriendInfo, GameInvite, Guild,
|
||||
GuildClaim, GuildMember, GuildPost, LauncherManifest, MemberProfile, PlayerProfile,
|
||||
ServerEconomyBalance, SkinProfile, UserLevelInfo, UserPost, UserProfileView, UserQuest,
|
||||
Achievement, BaltopEntry, DirectMessage, EconomyTransaction, FriendInfo, GameInvite, Guild, GuildClaim, GuildMember, GuildPost,
|
||||
LauncherManifest, MemberProfile, PlayerProfile, ServerEconomyBalance, SkinProfile, UserLevelInfo, UserPost, UserProfileView, UserQuest,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
@@ -56,7 +55,10 @@ pub struct Bootstrap {
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RunningGameInfo { run_id: String, instance_id: String }
|
||||
struct RunningGameInfo {
|
||||
run_id: String,
|
||||
instance_id: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn bootstrap(state: State<'_, AppState>) -> Bootstrap {
|
||||
@@ -77,7 +79,13 @@ pub fn bootstrap(state: State<'_, AppState>) -> Bootstrap {
|
||||
accounts: accounts.accounts,
|
||||
active_account: accounts.active,
|
||||
manifest: state.manifest.read().unwrap().clone(),
|
||||
game_running: state.game.lock().unwrap().iter().map(|g| RunningGameInfo { run_id: g.run_id.clone(), instance_id: g.instance_id.clone() }).collect(),
|
||||
game_running: state
|
||||
.game
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|g| RunningGameInfo { run_id: g.run_id.clone(), instance_id: g.instance_id.clone() })
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +142,9 @@ pub fn save_instance_options(state: State<'_, AppState>, instance_id: String) ->
|
||||
return Err("close this instance before saving its game settings".into());
|
||||
}
|
||||
let options = scopenet_core::options::read_vanilla_preferences(&state.layout.instance_dir(&instance_id)).map_err(aerr)?;
|
||||
if options.is_empty() { return Err("no vanilla options.txt settings found yet".into()); }
|
||||
if options.is_empty() {
|
||||
return Err("no vanilla options.txt settings found yet".into());
|
||||
}
|
||||
state.settings.write().unwrap().instance_game_options.insert(instance_id, options.clone());
|
||||
state.save_settings().map_err(aerr)?;
|
||||
Ok(options)
|
||||
@@ -147,6 +157,80 @@ pub async fn login_panel(state: State<'_, AppState>, username: String, password:
|
||||
accounts::login_panel(&state, &username, &password).await.map_err(aerr)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn discord_sign_in_start(state: State<'_, AppState>) -> Res<serde_json::Value> {
|
||||
let panel = state.panel_url().ok_or("no panel configured")?;
|
||||
let response = state.http.get(format!("{panel}/api/v1/auth/discord/start")).send().await.map_err(err)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(accounts::panel_error(response).await.to_string());
|
||||
}
|
||||
response.json().await.map_err(err)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn discord_sign_in_poll(state: State<'_, AppState>, oauth_state: String) -> Res<serde_json::Value> {
|
||||
let panel = state.panel_url().ok_or("no panel configured")?;
|
||||
let response =
|
||||
state.http.get(format!("{panel}/api/v1/auth/discord/poll")).query(&[("state", oauth_state)]).send().await.map_err(err)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(accounts::panel_error(response).await.to_string());
|
||||
}
|
||||
let result: serde_json::Value = response.json().await.map_err(err)?;
|
||||
if result.get("token").is_some() {
|
||||
let auth = serde_json::from_value(result.clone()).map_err(err)?;
|
||||
let account = accounts::save_panel_account(&state, &panel, auth).map_err(aerr)?;
|
||||
return Ok(serde_json::json!({"account":account}));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn request_password_reset(state: State<'_, AppState>, email: String) -> Res<String> {
|
||||
let panel = state.panel_url().ok_or("no panel configured")?;
|
||||
let response = state
|
||||
.http
|
||||
.post(format!("{panel}/api/v1/auth/forgot-password"))
|
||||
.json(&serde_json::json!({"email":email}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(err)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(accounts::panel_error(response).await.to_string());
|
||||
}
|
||||
let body: serde_json::Value = response.json().await.map_err(err)?;
|
||||
Ok(body["message"].as_str().unwrap_or("Check your email for a reset link.").to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn discord_connection(state: State<'_, AppState>) -> Res<serde_json::Value> {
|
||||
let req = account_api(&state, reqwest::Method::GET, "/account/connections/discord").await?;
|
||||
let response = req.send().await.map_err(err)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(accounts::panel_error(response).await.to_string());
|
||||
}
|
||||
response.json().await.map_err(err)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn discord_link_start(state: State<'_, AppState>) -> Res<serde_json::Value> {
|
||||
let req = account_api(&state, reqwest::Method::GET, "/auth/discord/start?kind=link").await?;
|
||||
let response = req.send().await.map_err(err)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(accounts::panel_error(response).await.to_string());
|
||||
}
|
||||
response.json().await.map_err(err)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn discord_unlink(state: State<'_, AppState>) -> Res<()> {
|
||||
let req = account_api(&state, reqwest::Method::DELETE, "/account/connections/discord").await?;
|
||||
let response = req.send().await.map_err(err)?;
|
||||
if !response.status().is_success() {
|
||||
return Err(accounts::panel_error(response).await.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RegisterResult {
|
||||
account: Option<Account>,
|
||||
@@ -242,10 +326,7 @@ pub async fn set_cape(state: State<'_, AppState>, cape_id: Option<i64>) -> Res<P
|
||||
|
||||
fn load_skin_profiles(data_dir: &std::path::Path) -> Vec<SkinProfile> {
|
||||
let path = data_dir.join("skin_profiles.json");
|
||||
std::fs::read(&path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default()
|
||||
std::fs::read(&path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn write_skin_profiles(data_dir: &std::path::Path, profiles: &[SkinProfile]) -> anyhow::Result<()> {
|
||||
@@ -283,10 +364,7 @@ pub async fn save_skin_profile(state: State<'_, AppState>, mut profile: SkinProf
|
||||
}
|
||||
}
|
||||
if profile.created_at.is_empty() {
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs().to_string())
|
||||
.unwrap_or_default();
|
||||
let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs().to_string()).unwrap_or_default();
|
||||
profile.created_at = ts;
|
||||
}
|
||||
let mut profiles = load_skin_profiles(&state.data_dir);
|
||||
@@ -391,7 +469,9 @@ pub fn cancel_launch(app: AppHandle, state: State<'_, AppState>, instance_id: St
|
||||
#[tauri::command]
|
||||
pub fn kill_game(state: State<'_, AppState>, run_id: String) {
|
||||
if let Some(g) = state.game.lock().unwrap().iter_mut().find(|g| g.run_id == run_id) {
|
||||
if let Some(kill) = g.kill.take() { kill.send(()).ok(); }
|
||||
if let Some(kill) = g.kill.take() {
|
||||
kill.send(()).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,11 +592,7 @@ pub async fn get_player_stats(state: State<'_, AppState>) -> Res<serde_json::Val
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_leaderboard(
|
||||
state: State<'_, AppState>,
|
||||
server_id: Option<i64>,
|
||||
sort: Option<String>,
|
||||
) -> Res<serde_json::Value> {
|
||||
pub async fn get_leaderboard(state: State<'_, AppState>, server_id: Option<i64>, sort: Option<String>) -> Res<serde_json::Value> {
|
||||
let panel = state.panel_url().ok_or("no panel configured")?;
|
||||
let sort_query = sort.map(|s| format!("?sort={s}")).unwrap_or_default();
|
||||
let url = if let Some(id) = server_id {
|
||||
@@ -539,9 +615,12 @@ pub async fn get_public_servers(state: State<'_, AppState>) -> Res<serde_json::V
|
||||
return Err("unable to load servers".into());
|
||||
}
|
||||
let data: serde_json::Value = resp.json().await.map_err(err)?;
|
||||
let urls = data["servers"].as_array().into_iter().flatten().filter_map(|s| {
|
||||
Some((s["id"].as_i64()?, s["map_url"].as_str()?.to_owned()))
|
||||
}).collect();
|
||||
let urls = data["servers"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|s| Some((s["id"].as_i64()?, s["map_url"].as_str()?.to_owned())))
|
||||
.collect();
|
||||
*state.map_urls.lock().unwrap() = urls;
|
||||
Ok(data)
|
||||
}
|
||||
@@ -557,24 +636,29 @@ pub struct MapResource {
|
||||
/// cross-origin Vantage live server, so the launcher uses its HTTP client.
|
||||
/// Only paths under a map URL supplied by the panel can be fetched.
|
||||
#[tauri::command]
|
||||
pub async fn fetch_vantage_resource(
|
||||
state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>,
|
||||
) -> Res<MapResource> {
|
||||
pub async fn fetch_vantage_resource(state: State<'_, AppState>, server_id: i64, url: String, etag: Option<String>) -> Res<MapResource> {
|
||||
use base64::Engine;
|
||||
let configured = state.map_urls.lock().unwrap().get(&server_id).cloned().ok_or("map not configured for this server")?;
|
||||
let base = reqwest::Url::parse(&configured).map_err(err)?;
|
||||
let target = reqwest::Url::parse(&url).map_err(err)?;
|
||||
let root = base.join(".").map_err(err)?;
|
||||
if !matches!(target.scheme(), "http" | "https") || target.origin() != base.origin()
|
||||
|| !target.path().starts_with(root.path()) || target.username() != ""
|
||||
|| target.password().is_some() || target.fragment().is_some() {
|
||||
if !matches!(target.scheme(), "http" | "https")
|
||||
|| target.origin() != base.origin()
|
||||
|| !target.path().starts_with(root.path())
|
||||
|| target.username() != ""
|
||||
|| target.password().is_some()
|
||||
|| target.fragment().is_some()
|
||||
{
|
||||
return Err("map asset is outside the configured Vantage world".into());
|
||||
}
|
||||
if target.as_str().len() > 2048 { return Err("map asset URL is too long".into()); }
|
||||
if target.as_str().len() > 2048 {
|
||||
return Err("map asset URL is too long".into());
|
||||
}
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build().map_err(err)?;
|
||||
.build()
|
||||
.map_err(err)?;
|
||||
let mut request = client.get(target);
|
||||
if let Some(etag) = etag.filter(|e| e.len() < 256 && !e.contains('\n') && !e.contains('\r')) {
|
||||
request = request.header(reqwest::header::IF_NONE_MATCH, etag);
|
||||
@@ -582,13 +666,19 @@ pub async fn fetch_vantage_resource(
|
||||
let response = request.send().await.map_err(err)?;
|
||||
let status = response.status().as_u16();
|
||||
let etag = response.headers().get(reqwest::header::ETAG).and_then(|v| v.to_str().ok()).map(str::to_owned);
|
||||
if status == 304 { return Ok(MapResource { status, data: None, etag }); }
|
||||
if !response.status().is_success() { return Ok(MapResource { status, data: None, etag }); }
|
||||
if status == 304 {
|
||||
return Ok(MapResource { status, data: None, etag });
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Ok(MapResource { status, data: None, etag });
|
||||
}
|
||||
if response.content_length().is_some_and(|n| n > 64 * 1024 * 1024) {
|
||||
return Err("Vantage asset exceeds 64 MiB".into());
|
||||
}
|
||||
let bytes = response.bytes().await.map_err(err)?;
|
||||
if bytes.len() > 64 * 1024 * 1024 { return Err("Vantage asset exceeds 64 MiB".into()); }
|
||||
if bytes.len() > 64 * 1024 * 1024 {
|
||||
return Err("Vantage asset exceeds 64 MiB".into());
|
||||
}
|
||||
Ok(MapResource { status, data: Some(base64::engine::general_purpose::STANDARD.encode(bytes)), etag })
|
||||
}
|
||||
|
||||
@@ -678,14 +768,18 @@ pub async fn create_guild(
|
||||
banner_url: Option<String>,
|
||||
) -> Res<Guild> {
|
||||
let req = account_api(&state, reqwest::Method::POST, "/guilds").await?;
|
||||
let resp = req.json(&serde_json::json!({
|
||||
"instance_id": instance_id,
|
||||
"name": name,
|
||||
"tag": tag,
|
||||
"description": description,
|
||||
"icon_url": icon_url,
|
||||
"banner_url": banner_url,
|
||||
})).send().await.map_err(err)?;
|
||||
let resp = req
|
||||
.json(&serde_json::json!({
|
||||
"instance_id": instance_id,
|
||||
"name": name,
|
||||
"tag": tag,
|
||||
"description": description,
|
||||
"icon_url": icon_url,
|
||||
"banner_url": banner_url,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("unable to create guild").to_string());
|
||||
@@ -723,13 +817,17 @@ pub async fn claim_guild_chunk(
|
||||
chunk_z: i32,
|
||||
) -> Res<GuildClaim> {
|
||||
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/claim")).await?;
|
||||
let resp = req.json(&serde_json::json!({
|
||||
"instance_id": instance_id,
|
||||
"server_id": server_id,
|
||||
"dimension": dimension,
|
||||
"chunk_x": chunk_x,
|
||||
"chunk_z": chunk_z,
|
||||
})).send().await.map_err(err)?;
|
||||
let resp = req
|
||||
.json(&serde_json::json!({
|
||||
"instance_id": instance_id,
|
||||
"server_id": server_id,
|
||||
"dimension": dimension,
|
||||
"chunk_x": chunk_x,
|
||||
"chunk_z": chunk_z,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("unable to claim chunk").to_string());
|
||||
@@ -755,17 +853,16 @@ pub async fn get_guild_posts(state: State<'_, AppState>, guild_id: String) -> Re
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_guild_post(
|
||||
state: State<'_, AppState>,
|
||||
guild_id: String,
|
||||
title: String,
|
||||
content: String,
|
||||
) -> Res<GuildPost> {
|
||||
pub async fn create_guild_post(state: State<'_, AppState>, guild_id: String, title: String, content: String) -> Res<GuildPost> {
|
||||
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/posts")).await?;
|
||||
let resp = req.json(&serde_json::json!({
|
||||
"title": title,
|
||||
"content": content,
|
||||
})).send().await.map_err(err)?;
|
||||
let resp = req
|
||||
.json(&serde_json::json!({
|
||||
"title": title,
|
||||
"content": content,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("unable to post announcement").to_string());
|
||||
@@ -783,6 +880,24 @@ pub async fn get_guild_members(state: State<'_, AppState>, guild_id: String) ->
|
||||
resp.json().await.map_err(err)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_guild_wallet(state: State<'_, AppState>, guild_id: String, server_id: i64) -> Res<serde_json::Value> {
|
||||
let req = account_api(&state, reqwest::Method::GET, &format!("/guilds/{guild_id}/wallet?server_id={server_id}")).await?;
|
||||
req.send().await.map_err(err)?.error_for_status().map_err(err)?.json().await.map_err(err)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn transfer_guild_wallet(state: State<'_, AppState>, guild_id: String, server_id: i64, amount: f64, withdraw: bool) -> Res<serde_json::Value> {
|
||||
let action = if withdraw { "withdraw" } else { "deposit" };
|
||||
let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/wallet/{action}")).await?;
|
||||
let resp = req.json(&serde_json::json!({"server_id":server_id,"amount":amount})).send().await.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("Guild wallet transfer failed").to_string());
|
||||
}
|
||||
resp.json().await.map_err(err)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Social: Friends, DMs, Invites, Profiles & Feed
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -798,11 +913,7 @@ pub async fn get_friends(state: State<'_, AppState>) -> Res<Vec<FriendInfo>> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_friend_request(
|
||||
state: State<'_, AppState>,
|
||||
friend_username: Option<String>,
|
||||
username: Option<String>,
|
||||
) -> Res<()> {
|
||||
pub async fn send_friend_request(state: State<'_, AppState>, friend_username: Option<String>, username: Option<String>) -> Res<()> {
|
||||
let target = friend_username.or(username).ok_or("username is required")?;
|
||||
let req = account_api(&state, reqwest::Method::POST, "/friends/request").await?;
|
||||
let resp = req.json(&serde_json::json!({ "username": target })).send().await.map_err(err)?;
|
||||
@@ -831,11 +942,7 @@ pub async fn respond_friend_request(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_friend(
|
||||
state: State<'_, AppState>,
|
||||
friend_uuid: Option<String>,
|
||||
target_uuid: Option<String>,
|
||||
) -> Res<()> {
|
||||
pub async fn remove_friend(state: State<'_, AppState>, friend_uuid: Option<String>, target_uuid: Option<String>) -> Res<()> {
|
||||
let target = friend_uuid.or(target_uuid).ok_or("target_uuid is required")?;
|
||||
let req = account_api(&state, reqwest::Method::DELETE, &format!("/friends/{target}")).await?;
|
||||
let resp = req.send().await.map_err(err)?;
|
||||
@@ -887,11 +994,7 @@ pub async fn get_transactions(state: State<'_, AppState>) -> Res<Vec<EconomyTran
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_direct_messages(
|
||||
state: State<'_, AppState>,
|
||||
friend_uuid: String,
|
||||
before_id: Option<i64>,
|
||||
) -> Res<Vec<DirectMessage>> {
|
||||
pub async fn get_direct_messages(state: State<'_, AppState>, friend_uuid: String, before_id: Option<i64>) -> Res<Vec<DirectMessage>> {
|
||||
// FIX #13: Support optional before_id for loading older messages beyond the first 100.
|
||||
let path = if let Some(before) = before_id {
|
||||
format!("/messages/{friend_uuid}?before_id={before}")
|
||||
@@ -928,18 +1031,17 @@ pub async fn get_game_invites(state: State<'_, AppState>) -> Res<Vec<GameInvite>
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_game_invite(
|
||||
state: State<'_, AppState>,
|
||||
recipient_uuid: String,
|
||||
instance_id: String,
|
||||
server_id: Option<i64>,
|
||||
) -> Res<()> {
|
||||
pub async fn send_game_invite(state: State<'_, AppState>, recipient_uuid: String, instance_id: String, server_id: Option<i64>) -> Res<()> {
|
||||
let req = account_api(&state, reqwest::Method::POST, "/invites").await?;
|
||||
let resp = req.json(&serde_json::json!({
|
||||
"recipient_uuid": recipient_uuid,
|
||||
"instance_id": instance_id,
|
||||
"server_id": server_id,
|
||||
})).send().await.map_err(err)?;
|
||||
let resp = req
|
||||
.json(&serde_json::json!({
|
||||
"recipient_uuid": recipient_uuid,
|
||||
"instance_id": instance_id,
|
||||
"server_id": server_id,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("unable to send invite").to_string());
|
||||
@@ -977,12 +1079,16 @@ pub async fn update_my_profile(
|
||||
featured_achievement_id: Option<String>,
|
||||
) -> Res<UserProfileView> {
|
||||
let req = account_api(&state, reqwest::Method::PUT, "/profiles/me").await?;
|
||||
let resp = req.json(&serde_json::json!({
|
||||
"bio": bio,
|
||||
"banner_url": banner_url,
|
||||
"custom_badge": custom_badge,
|
||||
"featured_achievement_id": featured_achievement_id,
|
||||
})).send().await.map_err(err)?;
|
||||
let resp = req
|
||||
.json(&serde_json::json!({
|
||||
"bio": bio,
|
||||
"banner_url": banner_url,
|
||||
"custom_badge": custom_badge,
|
||||
"featured_achievement_id": featured_achievement_id,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("unable to update profile").to_string());
|
||||
@@ -1003,16 +1109,16 @@ pub async fn get_user_posts(state: State<'_, AppState>, user_uuid: Option<String
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_user_post(
|
||||
state: State<'_, AppState>,
|
||||
content: String,
|
||||
image_url: Option<String>,
|
||||
) -> Res<UserPost> {
|
||||
pub async fn create_user_post(state: State<'_, AppState>, content: String, image_url: Option<String>) -> Res<UserPost> {
|
||||
let req = account_api(&state, reqwest::Method::POST, "/profiles/me/posts").await?;
|
||||
let resp = req.json(&serde_json::json!({
|
||||
"content": content,
|
||||
"image_url": image_url,
|
||||
})).send().await.map_err(err)?;
|
||||
let resp = req
|
||||
.json(&serde_json::json!({
|
||||
"content": content,
|
||||
"image_url": image_url,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(err)?;
|
||||
if !resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
return Err(body["error"].as_str().unwrap_or("unable to publish post").to_string());
|
||||
|
||||
@@ -53,6 +53,12 @@ pub fn run() {
|
||||
commands::save_settings,
|
||||
commands::save_instance_options,
|
||||
commands::login_panel,
|
||||
commands::discord_sign_in_start,
|
||||
commands::discord_sign_in_poll,
|
||||
commands::request_password_reset,
|
||||
commands::discord_connection,
|
||||
commands::discord_link_start,
|
||||
commands::discord_unlink,
|
||||
commands::register_panel,
|
||||
commands::add_offline,
|
||||
commands::account_profile,
|
||||
@@ -99,6 +105,8 @@ pub fn run() {
|
||||
commands::get_guild_posts,
|
||||
commands::create_guild_post,
|
||||
commands::get_guild_members,
|
||||
commands::get_guild_wallet,
|
||||
commands::transfer_guild_wallet,
|
||||
commands::get_friends,
|
||||
commands::send_friend_request,
|
||||
commands::respond_friend_request,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { UserRound, WifiOff, LoaderCircle, Check, ArrowRight, UserPlus } from '@lucide/svelte';
|
||||
import { accountAdded, app, toast } from '../lib/store.svelte';
|
||||
import { errorText, invoke } from '../lib/tauri';
|
||||
import { errorText, invoke, openUrl } from '../lib/tauri';
|
||||
import type { Account } from '../lib/types';
|
||||
|
||||
const auth = $derived(app.manifest?.auth);
|
||||
@@ -23,6 +23,9 @@
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let pendingNotice = $state(false);
|
||||
let recoveryEmail = $state('');
|
||||
let recovering = $state(false);
|
||||
let recoveryNotice = $state('');
|
||||
|
||||
const canRegister = $derived(auth?.registration && auth.registration !== 'closed');
|
||||
|
||||
@@ -48,6 +51,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function signInDiscord() {
|
||||
error = ''; busy = true;
|
||||
try {
|
||||
const flow = await invoke<{url: string; state: string}>('discord_sign_in_start');
|
||||
await openUrl(flow.url);
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
const result = await invoke<{account?: Account; pending?: boolean}>('discord_sign_in_poll', {oauthState: flow.state});
|
||||
if (result.account) {
|
||||
await accountAdded(result.account);
|
||||
toast(`Signed in as ${result.account.username}`);
|
||||
return;
|
||||
}
|
||||
if (!result.pending) { pendingNotice = true; return; }
|
||||
}
|
||||
throw new Error('Discord sign-in timed out. Try again.');
|
||||
} catch (err) { error = errorText(err); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
|
||||
async function forgotPassword() {
|
||||
error = ''; busy = true;
|
||||
try { recoveryNotice = await invoke<string>('request_password_reset', {email: recoveryEmail}); }
|
||||
catch (err) { error = errorText(err); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div class="signin">
|
||||
@@ -89,6 +119,15 @@
|
||||
{registering ? 'Already have an account? Sign in' : "Don't have an account? Sign up"}
|
||||
</button>
|
||||
{/if}
|
||||
{#if method === 'panel' && !registering}
|
||||
<button type="button" class="ghost sm switch" onclick={signInDiscord} disabled={busy}>Sign in with Discord</button>
|
||||
<button type="button" class="ghost sm switch" onclick={() => recovering = !recovering}>Forgot password?</button>
|
||||
{#if recovering}
|
||||
<label class="field">Account email<input type="email" bind:value={recoveryEmail} placeholder="you@example.com" /></label>
|
||||
<button type="button" disabled={busy || !recoveryEmail} onclick={forgotPassword}>Email reset link</button>
|
||||
{#if recoveryNotice}<p class="help">{recoveryNotice}</p>{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import Avatar from '../components/Avatar.svelte';
|
||||
import ChunkMap from '../components/ChunkMap.svelte';
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import { app, instances, selectedInstance, toast } from '../lib/store.svelte';
|
||||
import { activeAccount, app, instances, selectedInstance, toast } from '../lib/store.svelte';
|
||||
import { invoke } from '../lib/tauri';
|
||||
import type { Guild, GuildMember, GuildPost } from '../lib/types';
|
||||
|
||||
@@ -16,8 +16,34 @@
|
||||
let allGuilds = $state<Guild[]>([]);
|
||||
let members = $state<GuildMember[]>([]);
|
||||
let posts = $state<GuildPost[]>([]);
|
||||
let activeTab = $state<'territory' | 'members' | 'feed'>('territory');
|
||||
let activeTab = $state<'territory' | 'members' | 'feed' | 'wallet'>('territory');
|
||||
let loading = $state(false);
|
||||
let walletServers = $state<Array<{ id: number; name: string; instance_id: string }>>([]);
|
||||
let walletServerId = $state<number | null>(null);
|
||||
let wallet = $state<{ balance: number; transactions: Array<{ id: number; actor_uuid: string; kind: string; amount: number; created_at: string }> } | null>(null);
|
||||
let walletAmount = $state('');
|
||||
let walletBusy = $state(false);
|
||||
const canWithdraw = $derived(members.some(m => m.uuid === activeAccount()?.uuid && ['leader', 'officer'].includes(m.role)));
|
||||
|
||||
async function loadWallet() {
|
||||
if (!myGuild || !walletServerId) { wallet = null; return; }
|
||||
try { wallet = await invoke('get_guild_wallet', { guildId: myGuild.id, serverId: walletServerId }); }
|
||||
catch (e: any) { toast(e?.message ?? 'Unable to load guild wallet', 'error'); }
|
||||
}
|
||||
|
||||
async function transferWallet(withdraw: boolean) {
|
||||
if (!myGuild || !walletServerId || walletBusy) return;
|
||||
const amount = Number(walletAmount);
|
||||
if (!Number.isFinite(amount) || amount <= 0 || Math.round(amount * 100) !== amount * 100) { toast('Enter a valid amount with at most two decimals', 'error'); return; }
|
||||
walletBusy = true;
|
||||
try {
|
||||
await invoke('transfer_guild_wallet', { guildId: myGuild.id, serverId: walletServerId, amount, withdraw });
|
||||
walletAmount = '';
|
||||
await loadWallet();
|
||||
toast(withdraw ? 'Funds withdrawn' : 'Funds deposited', 'ok');
|
||||
} catch (e: any) { toast(e?.message ?? 'Transfer failed', 'error'); }
|
||||
finally { walletBusy = false; }
|
||||
}
|
||||
|
||||
// Create Guild Modal
|
||||
let createModalOpen = $state(false);
|
||||
@@ -67,6 +93,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try { walletServers = (await invoke<{ servers: typeof walletServers }>('get_public_servers')).servers; }
|
||||
catch { walletServers = []; }
|
||||
});
|
||||
$effect(() => {
|
||||
const available = walletServers.filter(s => s.instance_id === selectedInstId);
|
||||
if (!available.some(s => s.id === walletServerId)) walletServerId = available[0]?.id ?? null;
|
||||
});
|
||||
$effect(() => { if (activeTab === 'wallet' && myGuild && walletServerId) loadWallet(); });
|
||||
|
||||
async function handleCreateGuild() {
|
||||
if (!newGuildName.trim() || !newGuildTag.trim()) {
|
||||
toast('Please enter both guild name and tag', 'error');
|
||||
@@ -201,6 +237,9 @@
|
||||
<button class:active={activeTab === 'feed'} onclick={() => (activeTab = 'feed')}>
|
||||
<MessageSquare size={15} /> Guild Feed ({posts.length})
|
||||
</button>
|
||||
<button class:active={activeTab === 'wallet'} onclick={() => (activeTab = 'wallet')}>
|
||||
Guild Wallet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -225,6 +264,28 @@
|
||||
onunclaim={() => { if (myGuild) myGuild.claims_count = Math.max(0, myGuild.claims_count - 1); }}
|
||||
/>
|
||||
</div>
|
||||
{:else if activeTab === 'wallet'}
|
||||
<div class="wallet-tab glass">
|
||||
<h3>Guild Wallet</h3>
|
||||
<label>Game server
|
||||
<select bind:value={walletServerId}>
|
||||
{#each walletServers.filter(s => s.instance_id === selectedInstId) as server}<option value={server.id}>{server.name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
{#if !walletServerId}<p>Link a game server to this instance to use the guild wallet.</p>{/if}
|
||||
{#if wallet}
|
||||
<p>Balance: ${wallet.balance.toFixed(2)}</p>
|
||||
<div class="wallet-actions">
|
||||
<input type="number" min="0.01" step="0.01" placeholder="Amount" bind:value={walletAmount} aria-label="Transfer amount" />
|
||||
<button class="primary sm" disabled={walletBusy || !walletServerId} onclick={() => transferWallet(false)}>Deposit</button>
|
||||
{#if canWithdraw}<button class="ghost sm" disabled={walletBusy || !walletServerId} onclick={() => transferWallet(true)}>Withdraw</button>{/if}
|
||||
</div>
|
||||
<h4>Recent transfers</h4>
|
||||
{#each wallet.transactions as tx (tx.id)}
|
||||
<p>{tx.kind === 'withdraw' ? '−' : '+'}${tx.amount.toFixed(2)} · {new Date(tx.created_at).toLocaleString()}</p>
|
||||
{:else}<p>No transfers yet.</p>{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{:else if activeTab === 'members'}
|
||||
<div class="members-tab">
|
||||
<div class="members-grid">
|
||||
|
||||
@@ -15,6 +15,36 @@
|
||||
import type { Account, Gc, PlayerProfile, SkinProfile, UpdateInfo } from '../lib/types';
|
||||
|
||||
const serverAccount = $derived(activeAccount()?.kind === 'panel');
|
||||
let discordConnection = $state<{id: string; name: string} | null>(null);
|
||||
let discordBusy = $state(false);
|
||||
$effect(() => {
|
||||
if (serverAccount && app.settingsTab === 'account') {
|
||||
invoke<{id: string; name: string} | null>('discord_connection').then(x => discordConnection = x).catch(() => discordConnection = null);
|
||||
}
|
||||
});
|
||||
async function linkDiscord() {
|
||||
discordBusy = true;
|
||||
try {
|
||||
const flow = await invoke<{url: string; state: string}>('discord_link_start');
|
||||
await openUrl(flow.url);
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
const result = await invoke<{linked?: boolean; pending?: boolean}>('discord_sign_in_poll', {oauthState: flow.state});
|
||||
if (result.linked) {
|
||||
discordConnection = await invoke('discord_connection');
|
||||
toast('Discord connected'); return;
|
||||
}
|
||||
}
|
||||
toast('Discord link timed out', 'error');
|
||||
} catch (e) { toast(errorText(e), 'error'); }
|
||||
finally { discordBusy = false; }
|
||||
}
|
||||
async function unlinkDiscord() {
|
||||
discordBusy = true;
|
||||
try { await invoke('discord_unlink'); discordConnection = null; toast('Discord disconnected'); }
|
||||
catch (e) { toast(errorText(e), 'error'); }
|
||||
finally { discordBusy = false; }
|
||||
}
|
||||
const tabs = $derived([
|
||||
{ id: 'account', label: 'Accounts', icon: UserRound },
|
||||
...(serverAccount ? [{ id: 'skin', label: 'Skin & cape', icon: Shirt }] : []),
|
||||
@@ -323,6 +353,18 @@
|
||||
{/each}
|
||||
<button class="add" onclick={() => (app.addAccount = true)}><Plus size={16} /> Add account</button>
|
||||
</div>
|
||||
{#if serverAccount}
|
||||
<div class="card glass col">
|
||||
<h3>Connections → Discord</h3>
|
||||
<p class="muted small">Connect your Discord account to sign in and show your Discord identity in SCOPENET.</p>
|
||||
{#if discordConnection}
|
||||
<p>Connected as <strong>{discordConnection.name}</strong></p>
|
||||
<button disabled={discordBusy} onclick={unlinkDiscord}>Disconnect Discord</button>
|
||||
{:else}
|
||||
<button disabled={discordBusy} onclick={linkDiscord}>{discordBusy ? 'Waiting for Discord…' : 'Connect Discord'}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if app.settingsTab === 'skin'}
|
||||
<h1>Skin & cape</h1>
|
||||
<form class="card glass col" onsubmit={(e) => { e.preventDefault(); renameAccount(); }}>
|
||||
|
||||
Reference in new issue
Block a user