diff --git a/src/backend/env/invite.rs b/src/backend/env/invite.rs new file mode 100644 index 00000000..3d31d3b1 --- /dev/null +++ b/src/backend/env/invite.rs @@ -0,0 +1,300 @@ +use crate::optional; + +use super::{user::UserId, Credits, Principal, RealmId, State, User}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +#[derive(Default, Serialize, Deserialize, Clone)] +pub struct Invite { + pub credits: Credits, + pub credits_per_user: Credits, + pub joined_user_ids: BTreeSet, + pub realm_id: Option, + pub inviter_user_id: UserId, +} + +impl Invite { + pub fn new( + credits: Credits, + credits_per_user: Credits, + realm_id: Option, + inviter_user_id: UserId, + ) -> Self { + // Convert empty realm_id to None + let converted_realm_id: Option = realm_id.filter(|id| !id.is_empty()); + + Self { + credits, + credits_per_user, + joined_user_ids: BTreeSet::new(), + realm_id: converted_realm_id, + inviter_user_id, + } + } + + pub fn consume(&mut self, joined_user_id: UserId) -> Result<(), String> { + if self.joined_user_ids.contains(&joined_user_id) { + return Err("user already credited".into()); + } + + let new_credits = self + .credits + .checked_sub(self.credits_per_user) + .expect("invite credits too low"); + self.credits = new_credits; + + self.joined_user_ids.insert(joined_user_id); + + Ok(()) + } + + pub fn update( + &mut self, + credits: Option, + realm_id: Option, + user_id: UserId, + ) -> Result<(), String> { + if self.inviter_user_id != user_id { + return Err("owner does not match".into()); + } + + if let Some(new_credits) = credits { + if new_credits % self.credits_per_user != 0 { + return Err(format!( + "credits per user {} are not a multiple of new credits {}", + self.credits_per_user, new_credits, + )); + } + + // Protect against creating invite and setting to 0 without usage + if new_credits == 0 && self.joined_user_ids.is_empty() { + return Err("cannot set credits to 0 as it has never been used".into()); + } + + self.credits = new_credits; + } + + self.realm_id = optional(realm_id.unwrap_or_default()); + + Ok(()) + } +} + +pub fn invites_by_principal( + state: &State, + principal: Principal, +) -> Box + '_> { + match state.principal_to_user(principal).map(|user| { + state + .invite_codes + .iter() + .filter(move |(_, invite)| invite.inviter_user_id == user.id) + }) { + Some(iter) => Box::new(iter), + _ => Box::new(std::iter::empty()), + } +} + +/// Check allocated credits in invites do not exceed user's credits balance. +/// Protects against creating infinite number of invites. +pub fn validate_user_invites_credits( + state: &State, + user: &User, + new_credits: Credits, + old_credits: Option, +) -> Result<(), String> { + let total_invites_credits: Credits = state + .invite_codes + .values() + .filter(|invite| invite.inviter_user_id == user.id) + .map(|invite| invite.credits) + .sum(); + + let total_with_diff = total_invites_credits + .checked_add(new_credits) + .ok_or("invite credits overflow")? + .checked_sub(old_credits.unwrap_or_default()) + .ok_or("invite credits underflow")?; + + if total_with_diff > user.credits() { + return Err(format!( + "not enough credits available: {} (needed for invites: {})", + user.credits(), + total_with_diff + )); + } + + Ok(()) +} + +#[cfg(test)] +pub(crate) mod tests { + use crate::{ + mutate, + tests::{create_user, create_user_with_credits, pr}, + Realm, + }; + + use super::*; + + /// Creates invite with 200 credits, "test" realm, 50 credits per user + pub fn create_invite_with_realm( + state: &mut State, + principal: Principal, + ) -> (UserId, String, RealmId) { + let id = create_user_with_credits(state, principal, 2000); + state + .create_realm( + principal, + "test".into(), + Realm { + controllers: vec![id].into_iter().collect(), + ..Default::default() + }, + ) + .expect("realm creation failed"); + state + .create_invite(principal, 200, Some(50), Some("test".to_string())) + .expect("invite creation failed"); + + ( + id, + invites_by_principal(state, principal) + .last() + .expect("invite not found") + .0 + .clone(), + "test".to_string(), + ) + } + + #[test] + fn test_invite_validation() { + mutate(|state| { + let user_id = create_user(state, pr(0)); + + state.invite_codes.insert( + "foo".into(), + Invite { + credits: 500, + ..Default::default() + }, + ); + state.invite_codes.insert( + "bar".into(), + Invite { + credits: 490, + ..Default::default() + }, + ); + + let user = state.users.get(&user_id).unwrap(); + assert_eq!(user.credits(), 1000); + + assert_eq!( + validate_user_invites_credits(state, user, 1100, None), + Err("not enough credits available: 1000 (needed for invites: 2090)".into()) + ); + + assert_eq!( + validate_user_invites_credits(state, user, 100, Some(2000)), + Err("invite credits underflow".into()) + ); + + assert_eq!( + validate_user_invites_credits(state, user, 100, Some(1)), + Err("not enough credits available: 1000 (needed for invites: 1089)".into()) + ); + }) + } + + #[test] + fn test_update() { + let (user_id, code, realm_id) = mutate(|state| create_invite_with_realm(state, pr(1))); + + // Update credits and realm + mutate(|state| { + let invite = state.invite_codes.get_mut(&code).expect("invite not found"); + // Unset realm + assert_eq!(invite.update(None, Some("".to_string()), user_id), Ok(())); + assert_eq!(invite.credits, 200); + assert_eq!(invite.realm_id, None); + // Set different credits and realm + assert_eq!( + invite.update(Some(250), Some(realm_id.clone()), user_id), + Ok(()) + ); + assert_eq!(invite.credits, 250); + assert_eq!(invite.realm_id, Some(realm_id.clone())); + }); + + // Unset to 0 is not allowed unless it was used at least once + mutate(|state| { + let invite = state.invite_codes.get_mut(&code).expect("invite not found"); + assert_eq!( + invite.update(Some(0), None, user_id), + Err("cannot set credits to 0 as it has never been used".into()) + ); + + invite.joined_user_ids.insert(101); // Mock + assert_eq!(invite.update(Some(0), None, user_id), Ok(())); // Pass + }); + + // Credits per user have to be mutliple of credits + mutate(|state| { + let invite = state.invite_codes.get_mut(&code).expect("invite not found"); + assert_eq!( + invite.update(Some(140), None, user_id), + Err("credits per user 50 are not a multiple of new credits 140".into()) + ); + }); + + // Owner does not match + let other_user = mutate(|state| create_user(state, pr(5))); + mutate(|state| { + let invite = state.invite_codes.get_mut(&code).expect("invite not found"); + assert_eq!( + invite.update(Some(200), None, other_user), + Err("owner does not match".into()) + ); + }); + } + + #[test] + fn test_consume() { + let (_, code, invitee_id_1) = mutate(|state| { + let (user_id, code, _) = create_invite_with_realm(state, pr(6)); + let invitee_id_1 = create_user(state, pr(7)); + (user_id, code, invitee_id_1) + }); + + mutate(|state| { + let invite = state.invite_codes.get_mut(&code).expect("invite not found"); + // Consume credits + assert_eq!(invite.consume(invitee_id_1), Ok(())); + assert_eq!(invite.credits, 150); + // Already credited + assert_eq!( + invite.consume(invitee_id_1), + Err("user already credited".into()) + ); + }); + } + + #[test] + #[should_panic] + fn test_consume_panic() { + let (_, code, invitee_id_1) = mutate(|state| { + let (user_id, code, _) = create_invite_with_realm(state, pr(8)); + let invitee_id_1 = create_user(state, pr(9)); + (user_id, code, invitee_id_1) + }); + + let _ = mutate(|state| { + let invite = state.invite_codes.get_mut(&code).expect("invite not found"); + // Credits too low + invite.credits = 10; + invite.consume(invitee_id_1) + }); + } +} diff --git a/src/backend/env/mod.rs b/src/backend/env/mod.rs index 3186f859..bb70165a 100644 --- a/src/backend/env/mod.rs +++ b/src/backend/env/mod.rs @@ -1,5 +1,6 @@ use self::auction::Auction; use self::canisters::{icrc_transfer, upgrade_main_canister, NNSVote}; +use self::invite::Invite; use self::invoices::{Invoice, USER_ICP_SUBACCOUNT}; use self::post::{archive_cold_posts, Extension, Poll, Post, PostId}; use self::post_iterators::{IteratorMerger, MergeStrategy}; @@ -33,6 +34,7 @@ pub mod auction; pub mod canisters; pub mod config; pub mod features; +pub mod invite; pub mod invoices; pub mod memory; pub mod pfp; @@ -171,7 +173,11 @@ pub struct State { pub storage: storage::Storage, pub logger: Logger, + // TODO: delete pub invites: BTreeMap, + // New invites, indexed by code + #[serde(default)] + pub invite_codes: BTreeMap, pub realms: BTreeMap, #[serde(skip)] @@ -910,29 +916,56 @@ impl State { pub async fn create_user( principal: Principal, name: String, - invite: Option, - ) -> Result<(), String> { - let invited = mutate(|state| { + invite_code: Option, + ) -> Result, String> { + let (invited, realm_id) = mutate(|state| { state.validate_username(&name)?; if let Some(user) = state.principal_to_user(principal) { return Err(format!("principal already assigned to user @{}", user.name)); } - if let Some((inviter_id, credits)) = invite.and_then(|code| state.invites.remove(&code)) + if let Some((credits, credits_per_user, inviter_id, code, realm_id)) = invite_code + .and_then(|code| { + state.invite_codes.get(&code).map(|invite| { + ( + invite.credits, + invite.credits_per_user, + invite.inviter_user_id, + code, + invite.realm_id.clone(), + ) + }) + }) { + // Return gracefully before any updates + if credits < credits_per_user { + return Err("invite has not enough credits".into()); + } let inviter = state.users.get_mut(&inviter_id).ok_or("no user found")?; - let new_user_id = if inviter.credits() > credits { + let new_user_id = if inviter.credits() > credits_per_user { let new_user_id = state.new_user(principal, time(), name.clone(), None)?; + + state + .invite_codes + .get_mut(&code) + .expect("invite not found") // Revert newly created user in an edge case + .consume(new_user_id)?; + state .credit_transfer( inviter_id, new_user_id, - credits, + credits_per_user, 0, Destination::Credits, "claimed by invited user", None, ) .unwrap_or_else(|err| panic!("couldn't use the invite: {}", err)); + + if let Some(id) = realm_id.clone() { + state.toggle_realm_membership(principal, id); + } + new_user_id } else { return Err("inviter has not enough credits".into()); @@ -945,36 +978,24 @@ impl State { name, CONFIG.name )); } - return Ok(true); + return Ok((true, realm_id.clone())); } - Ok(false) + Ok((false, None)) })?; if invited { - return Ok(()); + return Ok(realm_id); } if let Ok(Invoice { paid: true, .. }) = State::mint_credits(principal, 0).await { mutate(|state| state.new_user(principal, time(), name, None))?; // After the user has beed created, transfer credits. - return State::mint_credits(principal, 0).await.map(|_| ()); + return State::mint_credits(principal, 0).await.map(|_| (None)); } Err("payment missing or the invite is invalid".to_string()) } - pub fn invites(&self, principal: Principal) -> Vec<(String, Credits)> { - self.principal_to_user(principal) - .map(|user| { - self.invites - .iter() - .filter(|(_, (user_id, _))| user_id == &user.id) - .map(|(code, (_, credits))| (code.clone(), *credits)) - .collect::>() - }) - .unwrap_or_default() - } - /// Assigns a new Avataggr to the user. pub fn set_pfp(&mut self, user_id: UserId, pfp: Pfp) -> Result<(), String> { let bytes = pfp::pfp( @@ -996,26 +1017,70 @@ impl State { Ok(()) } - pub fn create_invite(&mut self, principal: Principal, credits: Credits) -> Result<(), String> { + pub fn create_invite( + &mut self, + principal: Principal, + credits: Credits, + credits_per_user_opt: Option, + realm_id: Option, + ) -> Result<(), String> { + let credits_per_user = credits_per_user_opt.unwrap_or(credits); + if credits % credits_per_user != 0 { + return Err("credits per user are not a multiple of credits".into()); + } let min_credits = CONFIG.min_credits_for_inviting; - let user = self - .principal_to_user_mut(principal) - .ok_or("no user found")?; - if credits < min_credits { + let user = self.principal_to_user(principal).ok_or("no user found")?; + if credits_per_user < min_credits { return Err(format!( "smallest invite must contain {} credits", min_credits )); } - if user.credits() < credits { - return Err("not enough credits".into()); - } + + self.validate_realm_id(realm_id.as_ref())?; + invite::validate_user_invites_credits(self, user, credits, None)?; + let mut hasher = Sha256::new(); hasher.update(principal.as_slice()); hasher.update(time().to_be_bytes()); let code = format!("{:x}", hasher.finalize())[..10].to_string(); let user_id = user.id; - self.invites.insert(code, (user_id, credits)); + let invite = Invite::new(credits, credits_per_user, realm_id, user_id); + self.invite_codes.insert(code, invite); + + Ok(()) + } + + pub fn update_invite( + &mut self, + principal: Principal, + invite_code: String, + credits: Option, + realm_id: Option, + ) -> Result<(), String> { + if credits.is_none() && realm_id.is_none() { + return Err("update is empty".into()); + } + let user = self.principal_to_user(principal).ok_or("user not found")?; + let user_id = user.id; + + self.validate_realm_id(realm_id.as_ref())?; + + let Invite { + credits: invite_credits, + .. + } = self + .invite_codes + .get(&invite_code) + .ok_or(format!("invite '{}' not found", invite_code))?; + if let Some(credits) = credits { + invite::validate_user_invites_credits(self, user, credits, Some(*invite_credits))?; + } + + self.invite_codes + .get_mut(&invite_code) + .ok_or(format!("invite '{}' not found", invite_code))? + .update(credits, realm_id, user_id)?; Ok(()) } @@ -3030,6 +3095,16 @@ impl State { } added } + + fn validate_realm_id(&self, realm_id: Option<&RealmId>) -> Result<(), String> { + if let Some(id) = realm_id { + if !id.is_empty() && !self.realms.contains_key(id) { + return Err(format!("realm {} not found", id.clone())); + }; + } + + Ok(()) + } } // Checks if any feed represents the superset for the given tag set. @@ -3057,8 +3132,8 @@ pub fn display_tokens(amount: u64, decimals: u32) -> String { #[cfg(test)] pub(crate) mod tests { - use super::*; + use invite::tests::create_invite_with_realm; use post::Post; pub fn pr(n: usize) -> Principal { @@ -4808,21 +4883,21 @@ pub(crate) mod tests { // use too many credits assert_eq!( - state.create_invite(principal, 1111), - Err("not enough credits".to_string()) + state.create_invite(principal, 1111, None, None), + Err("not enough credits available: 1000 (needed for invites: 1111)".into()) ); // use enough credits and make sure they were deducted let prev_balance = state.users.get(&id).unwrap().credits(); - assert_eq!(state.create_invite(principal, 111), Ok(())); + assert_eq!(state.create_invite(principal, 111, None, None), Ok(())); let new_balance = state.users.get(&id).unwrap().credits(); // no charging yet assert_eq!(new_balance, prev_balance); - let invite = state.invites(principal); - assert_eq!(invite.len(), 1); - let (code, credits) = invite.first().unwrap().clone(); - assert_eq!(credits, 111); - (id, code, prev_balance) + let invites = invite::invites_by_principal(state, principal); + // assert_eq!(invites.count(), 1); + let (code, Invite { credits, .. }) = invites.last().unwrap(); + assert_eq!(*credits, 111); + (id, code.to_string(), prev_balance) }); // use the invite @@ -4836,11 +4911,11 @@ pub(crate) mod tests { let (id, code, prev_balance) = mutate(|state| { let user = state.users.get_mut(&id).unwrap(); let prev_balance = user.credits(); - assert_eq!(state.create_invite(principal, 222), Ok(())); - let invite = state.invites(principal); - let (code, credits) = invite.first().unwrap().clone(); - assert_eq!(credits, 222); - (id, code, prev_balance) + assert_eq!(state.create_invite(principal, 222, None, None), Ok(())); + let invites = invite::invites_by_principal(state, principal); + let (code, Invite { credits, .. }) = invites.last().unwrap(); + assert_eq!(*credits, 222); + (id, code.to_string(), prev_balance) }); let prev_revenue = read(|state| state.burned_cycles); @@ -4856,6 +4931,29 @@ pub(crate) mod tests { }); } + #[actix_rt::test] + async fn test_invites_with_realm() { + let principal = pr(4); + let (_, invite_code, realm_id) = mutate(|state| create_invite_with_realm(state, principal)); + + // New user should be joined to realm + let new_principal = pr(5); + assert_eq!( + State::create_user(new_principal, "name".to_string(), Some(invite_code)).await, + Ok(Some(realm_id.clone())) + ); + read(|state| { + let user = state.principal_to_user(new_principal).unwrap(); + assert_eq!(user.credits(), 50); // Invite gives 50 credits + assert_eq!(user.realms.first().cloned(), Some(realm_id)); + + let (_, invite) = invite::invites_by_principal(state, principal) + .last() + .unwrap(); + assert_eq!(invite.credits, 150); + }); + } + #[test] fn test_icp_distribution() { mutate(|state| { diff --git a/src/backend/env/user.rs b/src/backend/env/user.rs index 2a74f6b2..858d03e8 100644 --- a/src/backend/env/user.rs +++ b/src/backend/env/user.rs @@ -611,6 +611,24 @@ impl User { Ok(()) }) } + + /// Protect against invite phishing + /// + /// TODO: credits_burned reset every week, think of better way + pub fn validate_send_credits(&self, state: &State) -> Result<(), String> { + if let Some(invited_by) = self.invited_by { + let invite = state.invite_codes.values().find(|invite| { + invited_by == invite.inviter_user_id && invite.joined_user_ids.contains(&self.id) + }); + if let Some(invite) = invite { + if self.credits_burned() < invite.credits_per_user { + return Err("you are not allowed to send credits acquired in invite".into()); + } + } + } + + Ok(()) + } } #[cfg(test)] diff --git a/src/backend/queries.rs b/src/backend/queries.rs index a46015d0..5b445b1a 100644 --- a/src/backend/queries.rs +++ b/src/backend/queries.rs @@ -23,7 +23,7 @@ use serde_bytes::ByteBuf; #[export_name = "canister_query check_invite"] fn check_invite() { let code: String = parse(&arg_data_raw()); - read(|state| reply(state.invites.contains_key(&code))) + read(|state| reply(state.invite_codes.contains_key(&code))) } #[export_name = "canister_query migration_pending"] @@ -334,7 +334,7 @@ fn tags_cost() { #[export_name = "canister_query invites"] fn invites() { - read(|state| reply(state.invites(caller()))); + read(|state| reply(invite::invites_by_principal(state, caller()).collect::>())); } fn personal_filter(state: &State, user: Option<&User>, post: &Post) -> bool { diff --git a/src/backend/updates.rs b/src/backend/updates.rs index 3ec69a85..7b399d85 100644 --- a/src/backend/updates.rs +++ b/src/backend/updates.rs @@ -7,6 +7,7 @@ use super::*; use env::{ canisters::get_full_neuron, config::CONFIG, + invite::Invite, post::{Extension, Post, PostId}, user::{Draft, User, UserId}, State, @@ -82,48 +83,17 @@ fn post_upgrade() { #[allow(clippy::all)] fn sync_post_upgrade_fixtures() { - // Restore consistency of user's controlled_realms hashset. + // Move all invites to new structure mutate(|state| { - let controllers = state - .realms - .iter() - .map(|(id, realm)| (id, realm.controllers.clone())) - .collect::>(); - - for (realm_id, controllers) in controllers { - for user_id in controllers { - state - .users - .get_mut(&user_id) - .unwrap() - .controlled_realms - .insert(realm_id.clone()); + for (invite_code, (user_id, credits)) in state.invites.iter() { + if !state.invite_codes.contains_key(invite_code) { + state.invite_codes.insert( + invite_code.clone(), + Invite::new(credits.clone(), credits.clone(), None, user_id.clone()), + ); } } }); - - // Send all tokens sent to the main canister back. - mutate(|state| { - let mut txs = Vec::new(); - for (_, tx) in state.memory.ledger.iter() { - // If the onwer == canister id, we have a transfer to the canister. - if tx.to.owner == id() { - txs.push(tx.clone()); - } - } - - for tx in txs { - let args = TransferArgs { - from_subaccount: None, - to: tx.from, - amount: tx.amount as u128 - icrc1_fee(), - fee: Some(icrc1_fee()), - memo: None, - created_at_time: None, - }; - token::transfer(state, time(), id(), args).unwrap(); - } - }) } #[allow(clippy::all)] @@ -298,6 +268,9 @@ fn transfer_credits() { let (recipient, amount): (UserId, Credits) = parse(&arg_data_raw()); reply(mutate(|state| { let sender = state.principal_to_user(caller()).expect("no user found"); + + sender.validate_send_credits(state)?; + let recipient_name = &state.users.get(&recipient).expect("no user found").name; state.credit_transfer( sender.id, @@ -332,8 +305,17 @@ fn mint_credits() { #[export_name = "canister_update create_invite"] fn create_invite() { - let credits: Credits = parse(&arg_data_raw()); - mutate(|state| reply(state.create_invite(caller(), credits))); + let (credits, credits_per_user, realm_id): (Credits, Option, Option) = + parse(&arg_data_raw()); + mutate(|state| reply(state.create_invite(caller(), credits, credits_per_user, realm_id))); +} + +#[export_name = "canister_update update_invite"] +fn update_invite() { + let (invite_code, credits, realm_id): (String, Option, Option) = + parse(&arg_data_raw()); + + mutate(|state| reply(state.update_invite(caller(), invite_code, credits, realm_id))); } #[export_name = "canister_update delay_weekly_chores"] diff --git a/src/frontend/src/common.tsx b/src/frontend/src/common.tsx index 4d7bd649..9852c06e 100644 --- a/src/frontend/src/common.tsx +++ b/src/frontend/src/common.tsx @@ -353,8 +353,10 @@ export const ButtonWithLoading = ({ title={title} disabled={disabled || loading} className={`fat ${ - loading ? classNameArg?.replaceAll("active", "") : classNameArg - }`} + loading || disabled + ? classNameArg?.replaceAll("active", "") + : classNameArg + } ${disabled ? "inactive" : ""}`} style={styleArg || null} data-testid={testId} onClick={async (e) => { diff --git a/src/frontend/src/index.tsx b/src/frontend/src/index.tsx index 11ed751b..53e84c52 100644 --- a/src/frontend/src/index.tsx +++ b/src/frontend/src/index.tsx @@ -120,7 +120,10 @@ const App = () => { ) : ( ); - } else if (handler == "wallet" || (window.principalId && !window.user)) { + } else if ( + (handler == "wallet" || (window.principalId && !window.user)) && + !window.realm + ) { content = ; } else if (handler == "post") { const id = parseInt(param); diff --git a/src/frontend/src/invites.tsx b/src/frontend/src/invites.tsx index bebec5ff..ea0c4a3a 100644 --- a/src/frontend/src/invites.tsx +++ b/src/frontend/src/invites.tsx @@ -1,12 +1,32 @@ import * as React from "react"; -import { bigScreen, CopyToClipboard, HeadBar, Loading } from "./common"; +import { + bigScreen, + ButtonWithLoading, + CopyToClipboard, + HeadBar, + Loading, +} from "./common"; import { Credits } from "./icons"; +import { UserList } from "./user_resolve"; + +interface Invite { + credits: number; + credits_per_user: number; + joined_user_ids: number[]; + realm_id?: string | null | undefined; + inviter_user_id: number; + dirty: boolean; +} export const Invites = () => { const [credits, setCredits] = React.useState( window.backendCache.config.min_credits_for_inviting, ); - const [invites, setInvites] = React.useState<[string, number][]>([]); + const [credits_per_user, setCreditsPerUser] = React.useState( + window.backendCache.config.min_credits_for_inviting, + ); + const [inviteRealm, setInviteRealm] = React.useState(""); + const [invites, setInvites] = React.useState<[string, Invite][]>([]); const [busy, setBusy] = React.useState(false); const loadInvites = async () => { @@ -17,97 +37,239 @@ export const Invites = () => { loadInvites(); }, []); + const create = async () => { + setBusy(true); + const result = await window.api.call( + "create_invite", + credits, + credits_per_user, + inviteRealm, + ); + if ("Err" in result) alert(`Failed: ${result.Err}`); + else loadInvites(); + setBusy(false); + }; + + const saveInvites = async () => { + for (const i in invites) { + const [code, invite] = invites[i]; + if (!invite.dirty) continue; + const response = await window.api.call( + "update_invite", + code, + invite.credits !== undefined && invite.credits >= 0 + ? invite.credits + : null, + invite.realm_id !== undefined ? invite.realm_id : null, + ); + if ("Err" in (response || {})) { + alert(`Error: ${response.Err}`); + setBusy(true); + await loadInvites(); // Set back to prior state + setBusy(false); + return; + } + } + await loadInvites(); + }; + + const updateInvite = ( + id: string, + field: "credits" | "realm_id", + value: any, + ) => { + for (const i in invites) { + const [code, invite] = invites[i]; + if (code != id) continue; + // @ts-ignore + invite[field] = value; + invite.dirty = true; + setInvites([...invites]); + return; + } + }; + return ( <> -
-

Create an invite

-
    -
  • - You can invite new users to{" "} - {window.backendCache.config.name} by creating invites - for them. -
  • -
  • - Every invite is a funded by at least{" "} - - { - window.backendCache.config - .min_credits_for_inviting +
    +
    +

    Invite creation

    +
      +
    • + You can invite new users to{" "} + {window.backendCache.config.name} by creating + invites for them. +
    • +
    • + Every invite is a pre-charged with at least{" "} + + { + window.backendCache.config + .min_credits_for_inviting + } + {" "} + credits: you will be charged once the invite is + used. +
    • +
    • + One invite can be used by multiple users, each + receiving a pre-defined amount of credits. +
    • +
    • + The invite will not work if your credit balance + drops below the amount attached to the invite. +
    • +
    • + In an invite specifies a realm, users joining via + this invite will automartically join the realm. +
    • +
    • + Invites can be canceled by setting the credits to 0. +
    • +
    +
    + Total credits + + setCredits(parseInt(event.target.value)) } - {" "} - credits: you will be charged once the invite is used. -
  • -
  • - The invite will not work if your invite budget or credit - balance drops below the amount attached to the invite. -
  • -
  • Invites are not cancelable.
  • -
-
- - setCredits(parseInt(event.target.value)) - } - /> - {!busy && ( - - )} + /> + Spend per user + + setCreditsPerUser(parseInt(event.target.value)) + } + /> + Realm (optional) + + setInviteRealm( + event.target.value + .replaceAll("/", "") + .toUpperCase(), + ) + } + /> + +
{invites.length > 0 &&

Your invites

} {busy && } {!busy && invites.length > 0 && ( - - - - - - - - - - {invites.map(([code, credits]) => ( - - - - +
+
- - CODEURL
- {credits} - - - - - bigScreen() ? url : "" - } - /> -
+ + + + + + + + - ))} - -
+ + + Per User + RealmUsersCODEURL
+ + + {invites.map( + ([ + code, + { + credits, + credits_per_user, + joined_user_ids, + realm_id, + }, + ]) => ( + + + + updateInvite( + code, + "credits", + +event.target.value, + ) + } + /> + + + {credits_per_user} + + + + updateInvite( + code, + "realm_id", + event.target.value + .toUpperCase() + .replaceAll( + "/", + "", + ), + ) + } + /> + + + + + + + + + + bigScreen() + ? url + : "" + } + /> + + + ), + )} + + + invite.dirty) + } + />{" "} + )} diff --git a/src/frontend/src/settings.tsx b/src/frontend/src/settings.tsx index dc14d287..45e32077 100644 --- a/src/frontend/src/settings.tsx +++ b/src/frontend/src/settings.tsx @@ -81,6 +81,7 @@ export const Settings = ({ invite }: { invite?: string }) => { const submit = async () => { const registrationFlow = !user; + let registrationRealmId: string | undefined; if (registrationFlow) { let response = await window.api.call( "create_user", @@ -90,6 +91,7 @@ export const Settings = ({ invite }: { invite?: string }) => { if ("Err" in response) { return alert(`Error: ${response.Err}`); } + registrationRealmId = response?.Ok; } const nameChange = !registrationFlow && user.name != name; @@ -141,7 +143,12 @@ export const Settings = ({ invite }: { invite?: string }) => { return; } } - if (registrationFlow || nameChange) location.href = "/"; + if (registrationFlow) { + await window.reloadUser(); + location.href = registrationRealmId + ? `/#/realm/${registrationRealmId}` + : "/"; + } else if (nameChange) location.href = "/"; else if (uiRefresh) { await window.reloadUser(); window.uiInitialized = false;