diff --git a/backend/migrations/20251208090000_organisation_invites.sql b/backend/migrations/20251208090000_organisation_invites.sql new file mode 100644 index 000000000..8dbdb7f6c --- /dev/null +++ b/backend/migrations/20251208090000_organisation_invites.sql @@ -0,0 +1,15 @@ +CREATE TABLE organisation_invites ( + id BIGINT PRIMARY KEY, + organisation_id BIGINT NOT NULL REFERENCES organisations(id) ON DELETE CASCADE, + code TEXT NOT NULL UNIQUE, + email TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + used_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + invited_by_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX IDX_organisation_invites_code ON organisation_invites(code); +CREATE INDEX IDX_organisation_invites_organisation_id ON organisation_invites(organisation_id); +CREATE INDEX IDX_organisation_invites_email ON organisation_invites(email); \ No newline at end of file diff --git a/backend/server/src/handler/invite.rs b/backend/server/src/handler/invite.rs new file mode 100644 index 000000000..6f5a3f37e --- /dev/null +++ b/backend/server/src/handler/invite.rs @@ -0,0 +1,87 @@ +use crate::models::app::AppMessage; +use crate::models::auth::AuthUser; +use crate::models::error::ChaosError; +use crate::models::invite::Invite; +use crate::models::organisation::Organisation; +use crate::models::transaction::DBTransaction; +use crate::models::user::User; +use axum::extract::Path; +use axum::response::IntoResponse; +use sqlx::query; +use std::ops::DerefMut; + + +/// Handler for invite-related HTTP requests. +pub struct InviteHandler; + +impl InviteHandler { + /// Gets invite details for a given invite code. + /// + /// # Arguments + /// + /// * `transaction` - Database transaction + /// * `code` - Invite code + /// + /// # Returns + /// + /// * `Result` - Invite details or error + pub async fn get( + mut transaction: DBTransaction<'_>, + Path(code): Path, + ) -> Result { + let invite = Invite::get_by_code(&code, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok(AppMessage::OkMessage(invite)) + } + + /// Accepts an invite for the current authenticated user. + /// + /// Validates the invite is not expired/used, then marks it as used. + /// + /// # Arguments + /// + /// * `transaction` - Database transaction + /// * `code` - Invite code + /// * `user` - Authenticated user + /// + /// # Returns + /// + /// * `Result` - Success message or error + pub async fn use_invite( + mut transaction: DBTransaction<'_>, + Path(code): Path, + auth_user: AuthUser, + ) -> Result { + let invite = Invite::get_by_code(&code, &mut transaction.tx).await?; + + // Ensure the invite can only be accepted by the account whose email matches the invite email. + // This prevents someone from accepting an invite intended for a different email address. + let user = User::get(auth_user.user_id, &mut transaction.tx).await?; + + if user.email != invite.email { + return Err(ChaosError::BadRequestWithMessage( + "Invite was sent for a different email address".to_string(), + )); + } + + // Validate the invite is not already used or expired. + if invite.used { + return Err(ChaosError::BadRequestWithMessage("Invite already used".to_string())); + } + if invite.expired { + return Err(ChaosError::BadRequestWithMessage("Invite expired".to_string())); + } + + // Add the user to the organisation. + Organisation::add_user(invite.organisation_id, auth_user.user_id, &mut transaction.tx).await?; + + // Mark the invite as used. + Invite::mark_used(&code, auth_user.user_id, &mut transaction.tx).await?; + + transaction.tx.commit().await?; + Ok(AppMessage::OkMessage("Invite accepted successfully")) + } + + +} \ No newline at end of file diff --git a/backend/server/src/handler/mod.rs b/backend/server/src/handler/mod.rs index effc73011..55f5f58d8 100644 --- a/backend/server/src/handler/mod.rs +++ b/backend/server/src/handler/mod.rs @@ -11,6 +11,7 @@ //! - `email_template`: Processes email template requests //! - `offer`: Handles offer-related requests //! - `organisation`: Processes organisation-related requests +//! - `invite`: Handles invite-related requests //! - `question`: Handles question-related requests //! - `rating`: Processes rating-related requests //! - `role`: Handles role-related requests @@ -23,6 +24,7 @@ pub mod campaign; pub mod email_template; pub mod offer; pub mod organisation; +pub mod invite; pub mod question; pub mod rating; pub mod role; diff --git a/backend/server/src/handler/organisation.rs b/backend/server/src/handler/organisation.rs index 57e396a79..34700b958 100644 --- a/backend/server/src/handler/organisation.rs +++ b/backend/server/src/handler/organisation.rs @@ -359,14 +359,22 @@ impl OrganisationHandler { pub async fn invite_user( mut transaction: DBTransaction<'_>, Path(id): Path, - _admin: OrganisationAdmin, - State(state): State, + admin: OrganisationAdmin, + State(mut state): State, Json(request_body): Json, ) -> Result { - Organisation::invite_user(id, request_body.email, state.email_credentials, &mut transaction.tx).await?; + let invite_code = Organisation::invite_user( + id, + admin.user_id, + request_body.email, + state.email_credentials.clone(), + &mut state.snowflake_generator, + &mut transaction.tx, + ) + .await?; transaction.tx.commit().await?; - Ok(AppMessage::OkMessage("Successfully invited user to organisation")) + Ok(AppMessage::OkMessage(invite_code)) } /// Updates an organisation's logo. diff --git a/backend/server/src/models/app.rs b/backend/server/src/models/app.rs index 9eb4c3e6b..6c067eb8f 100644 --- a/backend/server/src/models/app.rs +++ b/backend/server/src/models/app.rs @@ -8,6 +8,7 @@ use crate::handler::organisation::OrganisationHandler; use crate::handler::question::QuestionHandler; use crate::handler::rating::RatingHandler; use crate::handler::role::RoleHandler; +use crate::handler::invite::InviteHandler; use crate::handler::user::UserHandler; use crate::models::email::{ChaosEmail, EmailCredentials}; use crate::models::error::ChaosError; @@ -28,6 +29,7 @@ use serde::Serialize; use tower_http::cors::CorsLayer; use crate::service::oauth2::build_oauth_client; + #[derive(Serialize)] pub enum AppMessage { OkMessage(T), @@ -424,6 +426,17 @@ pub async fn app() -> Result { "/api/v1/offer/:offer_id/send", post(OfferHandler::send_offer), ) + + // Invite routes + // - GET /api/v1/invite/:code -> invite details + // - POST /api/v1/invite/:code -> accept invite + .route( + "/api/v1/invite/:code", get(InviteHandler::get).post(InviteHandler::use_invite) + ) + + + + .layer(cors) .with_state(state)) } diff --git a/backend/server/src/models/invite.rs b/backend/server/src/models/invite.rs new file mode 100644 index 000000000..5b38ae120 --- /dev/null +++ b/backend/server/src/models/invite.rs @@ -0,0 +1,146 @@ +use crate::models::error::ChaosError; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{Postgres, Transaction}; +use std::ops::DerefMut; + +/// Represents an organisation invite. +#[derive(Deserialize, Serialize, sqlx::FromRow, Clone, Debug)] +pub struct Invite { + /// Unique identifier for the invite + #[serde(serialize_with = "crate::models::serde_string::serialize")] + #[serde(deserialize_with = "crate::models::serde_string::deserialize")] + pub id: i64, + /// Unique invite code (used by invite URL) + pub code: String, + /// Organisation being invited to + #[serde(serialize_with = "crate::models::serde_string::serialize")] + #[serde(deserialize_with = "crate::models::serde_string::deserialize")] + pub organisation_id: i64, + /// Email address the invite was sent to + pub email: String, + /// When the invite expires + pub expires_at: DateTime, + /// When the invite was used (if used) + pub used_at: Option>, + /// User who used the invite (if used) + #[serde(serialize_with = "crate::models::serde_string::serialize_option")] + #[serde(deserialize_with = "crate::models::serde_string::deserialize_option")] + pub used_by: Option, + /// When the invite was created + pub created_at: DateTime, + /// ID of the user that invited the member to the organisation + #[serde(serialize_with = "crate::models::serde_string::serialize_option")] + #[serde(deserialize_with = "crate::models::serde_string::deserialize_option")] + pub invited_by_user_id: Option, +} + +/// Response payload for invite details expected by the frontend. +#[derive(Serialize)] +pub struct InviteDetails { + #[serde(serialize_with = "crate::models::serde_string::serialize")] + pub id: i64, + pub code: String, + #[serde(serialize_with = "crate::models::serde_string::serialize")] + pub organisation_id: i64, + pub organisation_name: String, + pub email: String, + pub expires_at: DateTime, + pub used: bool, + pub expired: bool, + /// ID of the user that invited the member to the organisation + #[serde(serialize_with = "crate::models::serde_string::serialize_option")] + #[serde(deserialize_with = "crate::models::serde_string::deserialize_option")] + pub invited_by_user_id: Option +} + +impl Invite { + /// Fetches an invite by its code. + /// + /// # Arguments + /// + /// * `code` - Invite code + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result` - Invite row or error + pub async fn get_by_code( + code: &str, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let invite = sqlx::query_as!( + InviteDetails, + r#" + SELECT + oi.id, + code, + organisation_id, + o.name AS organisation_name, + email, + expires_at, + used_at IS NOT NULL AS "used!: bool", + expires_at <= NOW() AS "expired!: bool", + invited_by_user_id + FROM organisation_invites oi + JOIN organisations o ON o.id = oi.organisation_id + WHERE code = $1 AND used_at IS NULL AND expires_at > NOW() + "#, + code + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(invite) + } + + /// Marks an invite as used by a user. + /// + /// # Arguments + /// + /// * `code` - Invite code + /// * `user_id` - User redeeming the invite + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result<(), ChaosError>` - Ok on success + pub async fn mark_used( + code: &str, + user_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + let _ = sqlx::query!( + r#" + UPDATE organisation_invites + SET used_at = $1, used_by = $2 + WHERE code = $3 AND used_at IS NULL AND expires_at > NOW() + RETURNING id + "#, + Utc::now(), + user_id, + code, + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } + + /// Deletes an invite by code. + pub async fn delete_by_code( + code: &str, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + let _ = sqlx::query!( + "DELETE FROM organisation_invites WHERE code = $1 RETURNING id", + code + ) + .fetch_one(transaction.deref_mut()) + .await?; + + Ok(()) + } + + +} \ No newline at end of file diff --git a/backend/server/src/models/mod.rs b/backend/server/src/models/mod.rs index fd198b653..ecbe39151 100644 --- a/backend/server/src/models/mod.rs +++ b/backend/server/src/models/mod.rs @@ -16,6 +16,7 @@ pub mod serde_string; pub mod email; pub mod email_template; pub mod error; +pub mod invite; pub mod offer; pub mod organisation; pub mod question; diff --git a/backend/server/src/models/organisation.rs b/backend/server/src/models/organisation.rs index f0f58abbb..cfd4dd047 100644 --- a/backend/server/src/models/organisation.rs +++ b/backend/server/src/models/organisation.rs @@ -6,7 +6,7 @@ use crate::models::campaign::OrganisationCampaign; use crate::models::error::ChaosError; use crate::models::storage::Storage; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Utc, Duration}; use s3::Bucket; use serde::{Deserialize, Serialize}; use snowflake::SnowflakeIdGenerator; @@ -16,6 +16,9 @@ use uuid::Uuid; use crate::models::email::EmailCredentials; use crate::models::user::User; use crate::service::campaign::create_proper_slug; +use nanoid::nanoid; +use crate::constants::NANOID_ALPHABET; +use crate::models::email::ChaosEmail; /// Represents an organisation in the database. /// @@ -681,10 +684,14 @@ impl Organisation { pub async fn invite_user( organisation_id: i64, + inviting_user_id: i64, email: String, email_credentials: EmailCredentials, + snowflake_generator: &mut SnowflakeIdGenerator, transaction: &mut Transaction<'_, Postgres>, - ) -> Result<(), ChaosError> { + ) -> Result { + let email = email.to_lowercase(); + let _ = sqlx::query!( "SELECT id FROM organisations WHERE id = $1", organisation_id @@ -692,18 +699,90 @@ impl Organisation { .fetch_one(transaction.deref_mut()) .await?; - let possible_user = User::find_by_email(email, transaction).await?; + let possible_user = User::find_by_email(email.clone(), transaction).await?; if let Some(user) = possible_user { if (Self::check_user_already_member(organisation_id, user.id, transaction).await?) { return Err(ChaosError::BadRequestWithMessage("User already a member of organisation".to_string())) } Self::add_user(organisation_id, user.id, transaction).await?; - } else { - // TODO: email invite system + return Ok("existing-user-added".to_string()); } - Ok(()) - } + // If an invite already exists for this organisation/email, not allow duplicates, we refresh the invite. + let potential_invite = sqlx::query!( + r#" + SELECT id, used_at + FROM organisation_invites + WHERE organisation_id = $1 AND email = $2 + ORDER BY created_at DESC + LIMIT 1 + "#, + organisation_id, + email + ) + .fetch_optional(transaction.deref_mut()) + .await?; + + if let Some(existing) = potential_invite { + let refreshed_code = nanoid!(10, &NANOID_ALPHABET); + let refreshed_expiry = Utc::now() + Duration::days(7); + + sqlx::query!( + r#" + UPDATE organisation_invites + SET + code = $1, + expires_at = $2, + created_at = NOW(), + used_at = NULL, + used_by = NULL, + invited_by_user_id = $3 + WHERE id = $4 + "#, + refreshed_code, + refreshed_expiry, + inviting_user_id, + existing.id + ) + .execute(transaction.deref_mut()) + .await?; + + return Ok(refreshed_code); + } + + // New invite creation + let id = snowflake_generator.real_time_generate(); // generate a new invite ID + let code = nanoid!(10, &NANOID_ALPHABET); // generate a new invite code + let expires_at = Utc::now() + Duration::days(7); // set the invite expiry to 7 days + let invited_by_user_id = inviting_user_id; + // insert the new invite into the database + sqlx::query!( + r#" + INSERT INTO organisation_invites + (id, organisation_id, code, email, expires_at, used_at, used_by, created_at, invited_by_user_id) + VALUES ($1, $2, $3, $4, $5, NULL, NULL, NOW(), $6) + "#, + id, + organisation_id, + code, + email, + expires_at, + invited_by_user_id + ) + .execute(transaction.deref_mut()) + .await?; + + // TODO: Get SMTP credentials + // ChaosEmail::send_message( + // "".to_string(), + // email, + // "You have been invited to join an organisation on Chaos".to_string(), + // format!("You have been invited to join an organisation on Chaos. Please use the following link to accept the invite: https://chaos.devsoc.app/invite/${code}").to_string(), + // email_credentials, + // ).await?; + + return Ok(code); +} pub async fn update_logo( id: i64, diff --git a/backend/server/src/models/serde_string.rs b/backend/server/src/models/serde_string.rs index da9d2b2bf..1a05333ba 100644 --- a/backend/server/src/models/serde_string.rs +++ b/backend/server/src/models/serde_string.rs @@ -27,6 +27,29 @@ where s.parse::().map_err(Error::custom) } +/// Serializes `Option` as `Option` for JavaScript compatibility. +pub fn serialize_option(value: &Option, serializer: S) -> Result +where + S: Serializer, +{ + match value { + Some(v) => serializer.serialize_some(&v.to_string()), + None => serializer.serialize_none(), + } +} + +/// Deserializes `Option` into `Option`. +pub fn deserialize_option<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let opt = Option::::deserialize(deserializer)?; + match opt { + Some(s) => Ok(Some(s.parse::().map_err(Error::custom)?)), + None => Ok(None), + } +} + pub fn deserialize_vec<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, diff --git a/frontend-nextjs/src/app/[lang]/dashboard/invite/[code]/invite-client.tsx b/frontend-nextjs/src/app/[lang]/dashboard/invite/[code]/invite-client.tsx new file mode 100644 index 000000000..e572105e0 --- /dev/null +++ b/frontend-nextjs/src/app/[lang]/dashboard/invite/[code]/invite-client.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { ApiError } from "@/lib/api"; +import { acceptInvite, getInvite } from "@/models/invite"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; + +type Props = { + code: string; + dict: any; +}; + +export default function InviteClient({ code, dict }: Props) { + const router = useRouter(); + + const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); + const [message, setMessage] = useState(null); + + const { data: invite } = useQuery({ + queryKey: [`invite-${code}`], + queryFn: () => getInvite(code), + }); + + // After successful acceptance, redirect after a short delay. + useEffect(() => { + if (status === "success") { + const timer = setTimeout(() => { + router.push("/dashboard"); + }, 2000); + return () => clearTimeout(timer); + } + }, [status, router]); + + const handleAccept = async () => { + setStatus("loading"); + setMessage(null); + + await acceptInvite(code); + setStatus("success"); + setMessage(dict.dashboard.invite.accepted); + }; + + const inviteInvalid = !invite || invite.expired || invite.used || status === "success"; + + return ( +
+

{dict.dashboard.invite.title}

+

+ {invite?.organisation_name}{" "} + {invite + ? dict.dashboard.invite.invited_by + : dict?.common?.loading ?? "Loading..."} +

+ {/* Show the email the invite was sent to */} + {invite && ( +

+ {dict.dashboard.invite.sent_to}: {invite.email} +

+ )} + {/* Show the expired message if the invite has expired */} + {invite?.expired && ( +

{dict.dashboard.invite.expired}

+ )} + {/* Show the used message if the invite has been used */} + {invite?.used && ( +

{dict.dashboard.invite.used}

+ )} + {/* Show the message if there is an error */} + {message && ( +

+ {message} +

+ )} +
+ + + + + {/* Show the wrong account message if the account is not invited */} +

+ {dict.dashboard.invite.wrong_account} +

+
+
+ ); +} + + diff --git a/frontend-nextjs/src/app/[lang]/dashboard/invite/[code]/page.tsx b/frontend-nextjs/src/app/[lang]/dashboard/invite/[code]/page.tsx new file mode 100644 index 000000000..6e5ab0a4e --- /dev/null +++ b/frontend-nextjs/src/app/[lang]/dashboard/invite/[code]/page.tsx @@ -0,0 +1,24 @@ +import { getDictionary } from "@/app/[lang]/dictionaries"; +import { getInvite } from "@/models/invite"; +import { HydrationBoundary, QueryClient, dehydrate } from "@tanstack/react-query"; +import InviteClient from "./invite-client"; + +export default async function Page({ params }: { params: Promise<{ lang: string; code: string }> }) { + const { lang, code } = await params; + const dict = await getDictionary(lang); + const queryClient = new QueryClient(); + + await queryClient.prefetchQuery({ + queryKey: [`invite-${code}`], + queryFn: () => getInvite(code), + }); + + return ( + + + + ); +} + + + diff --git a/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/members/members.tsx b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/members/members.tsx index 020be895c..ed7c51e50 100644 --- a/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/members/members.tsx +++ b/frontend-nextjs/src/app/[lang]/dashboard/organisation/[orgId]/members/members.tsx @@ -20,6 +20,7 @@ import { DataTable } from "@/components/ui/data-table"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useState } from "react"; +import { ApiError } from "@/lib/api"; export default function OrganisationMembers({ orgId, dict }: { orgId: string, dict: any }) { const queryClient = useQueryClient(); @@ -59,14 +60,29 @@ export function AddMemberDialog({ orgId, dict }: { orgId: string, dict: any }) { const queryClient = useQueryClient(); const [email, setEmail] = useState(""); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); const handleInviteMember = async () => { - await inviteOrganisationUser(orgId, email); + setErrorMessage(null); + + const normalisedEmail = email.trim().toLowerCase(); + if (!normalisedEmail) { + setErrorMessage(dict?.common?.email_required ?? "Email is required."); + return; + } + + setLoading(true); + await inviteOrganisationUser(orgId, normalisedEmail); await queryClient.invalidateQueries({ queryKey: [`${orgId}-members`] }); + setEmail(""); + setOpen(false); + setLoading(false); } return ( - + @@ -77,10 +93,18 @@ export function AddMemberDialog({ orgId, dict }: { orgId: string, dict: any }) {

{dict.dashboard.members.admin_edit_block_description}

- setEmail(e.target.value)} /> + setEmail(e.target.value)} /> + {errorMessage && ( +

{errorMessage}

+ )}
- +
diff --git a/frontend-nextjs/src/dictionaries/en.json b/frontend-nextjs/src/dictionaries/en.json index e0a814dd0..756eec0e7 100644 --- a/frontend-nextjs/src/dictionaries/en.json +++ b/frontend-nextjs/src/dictionaries/en.json @@ -89,6 +89,17 @@ "delete_confirmation": "This will permanently delete the email template and cannot be undone.", "edit_template": "Edit Template" }, + "invite": { + "title": "You're invited", + "invited_by": "has invited you to join their organisation on Chaos", + "sent_to": "This invite was sent to", + "login_cta": "Click here to create a Chaos account", + "accept_cta": "Accept invite", + "used": "This invite has already been used", + "expired": "This invite has expired", + "accepted": "Invite accepted! You've been added to the organisation", + "wrong_account": "This account was not invited, please logout and try again" + }, "actions": { "new": "New", "edit": "Edit", diff --git a/frontend-nextjs/src/dictionaries/zh.json b/frontend-nextjs/src/dictionaries/zh.json index f36a34ebd..6e80f319f 100644 --- a/frontend-nextjs/src/dictionaries/zh.json +++ b/frontend-nextjs/src/dictionaries/zh.json @@ -89,6 +89,17 @@ "delete_confirmation": "这将永久删除邮件模板并无法撤销", "edit_template": "编辑模板" }, + "invite": { + "title": "你被邀请了", + "invited_by": "邀请你加入他们的组织 on Chaos", + "sent_to": "此邀请发送给", + "login_cta": "点击这里创建 Chaos 账户", + "accept_cta": "接受邀请", + "used": "此邀请已被使用", + "expired": "此邀请已过期", + "accepted": "邀请已接受!你已加入组织", + "wrong_account": "此账户未被邀请,请退出并重新尝试" + }, "actions": { "new": "新建", "edit": "编辑", diff --git a/frontend-nextjs/src/models/invite.ts b/frontend-nextjs/src/models/invite.ts new file mode 100644 index 000000000..72e56d721 --- /dev/null +++ b/frontend-nextjs/src/models/invite.ts @@ -0,0 +1,26 @@ +import { apiRequest } from "@/lib/api"; +import { AppMessage } from "./app"; + +export type InviteDetails = { + organisation_id: string; + organisation_name: string; + email: string; + expires_at: string; + used: boolean; + expired: boolean; +}; + +type InviteResponse = { message: InviteDetails }; + +export async function getInvite(code: string): Promise { + const res = await apiRequest(`/api/v1/invite/${code}`); + return res.message; +} + +export async function acceptInvite(code: string): Promise { + return await apiRequest(`/api/v1/invite/${code}`, { + method: "POST", + }); +} + +