From 8c18b44e6296e5cf627efb272a6baa06807cae77 Mon Sep 17 00:00:00 2001 From: Peter Nguyen Date: Fri, 19 Dec 2025 18:15:31 +0700 Subject: [PATCH 01/13] Add email invitation functionality for new organisation members - Updated the organisation model to include ChaosEmail for sending invitations. - Added TODOs for future invite code generation and summary of related UI. --- backend/server/src/models/organisation.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/backend/server/src/models/organisation.rs b/backend/server/src/models/organisation.rs index f0f58abbb..d84b576a1 100644 --- a/backend/server/src/models/organisation.rs +++ b/backend/server/src/models/organisation.rs @@ -13,7 +13,7 @@ use snowflake::SnowflakeIdGenerator; use sqlx::{FromRow, Postgres, Transaction}; use std::ops::DerefMut; use uuid::Uuid; -use crate::models::email::EmailCredentials; +use crate::models::email::{ChaosEmail, EmailCredentials}; use crate::models::user::User; use crate::service::campaign::create_proper_slug; @@ -692,16 +692,29 @@ 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 + // TODO: generate an invite code, there will be a page, /dashboard/invite/[code], + // where it says sign up with this code credential and u will get added + + ChaosEmail::send_message( + "random name test".to_string(), + email.clone(), + "Testing email sending".to_string(), + "You have been invited to join an organisation".to_string(), + email_credentials, + ) + .await?; } + Ok(()) } From f386fdc43235581112af84fc8b19242f4054c78a Mon Sep 17 00:00:00 2001 From: Peter Nguyen Date: Mon, 29 Dec 2025 10:48:51 +0700 Subject: [PATCH 02/13] UI for email invite --- .../20251208090000_organisation_invites.sql | 13 +++ .../[lang]/dashboard/invite/invite-client.tsx | 79 +++++++++++++++++++ .../src/app/[lang]/dashboard/invite/page.tsx | 22 ++++++ frontend-nextjs/src/dictionaries/en.json | 11 +++ frontend-nextjs/src/dictionaries/zh.json | 11 +++ frontend-nextjs/src/models/invite.ts | 23 ++++++ 6 files changed, 159 insertions(+) create mode 100644 backend/migrations/20251208090000_organisation_invites.sql create mode 100644 frontend-nextjs/src/app/[lang]/dashboard/invite/invite-client.tsx create mode 100644 frontend-nextjs/src/app/[lang]/dashboard/invite/page.tsx create mode 100644 frontend-nextjs/src/models/invite.ts diff --git a/backend/migrations/20251208090000_organisation_invites.sql b/backend/migrations/20251208090000_organisation_invites.sql new file mode 100644 index 000000000..1ad7a978a --- /dev/null +++ b/backend/migrations/20251208090000_organisation_invites.sql @@ -0,0 +1,13 @@ +CREATE TABLE organisation_invites ( + code TEXT PRIMARY KEY, + organisation_id BIGINT NOT NULL REFERENCES organisations(id) ON DELETE CASCADE, + 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 +); + +CREATE INDEX IDX_organisation_invites_org_email ON organisation_invites (organisation_id, email); + + diff --git a/frontend-nextjs/src/app/[lang]/dashboard/invite/invite-client.tsx b/frontend-nextjs/src/app/[lang]/dashboard/invite/invite-client.tsx new file mode 100644 index 000000000..27ec0a8b2 --- /dev/null +++ b/frontend-nextjs/src/app/[lang]/dashboard/invite/invite-client.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { ApiError } from "@/lib/api"; +import { acceptInvite, InviteDetails } from "@/models/invite"; +import Link from "next/link"; +import { useState } from "react"; + +type Props = { + code: string; + invite: InviteDetails; + dict: any; +}; + +export default function InviteClient({ code, invite, dict }: Props) { + const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); + const [message, setMessage] = useState(null); + + const handleAccept = async () => { + setStatus("loading"); + setMessage(null); + try { + await acceptInvite(code); + setStatus("success"); + setMessage(dict.dashboard.invite.accepted); + } catch (err) { + setStatus("error"); + if (err instanceof ApiError) { + setMessage(err.message); + } else { + setMessage("Something went wrong. Please try again."); + } + } + }; + + const inviteInvalid = invite.expired || invite.used; + + return ( +
+

{dict.dashboard.invite.title}

+

+ {dict.dashboard.invite.invited_by.replace("{org}", invite.organisation_name)} +

+

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

+ {invite.expired && ( +

{dict.dashboard.invite.expired}

+ )} + {invite.used && ( +

{dict.dashboard.invite.used}

+ )} + {message && ( +

+ {message} +

+ )} +
+ + + + +

+ {dict.dashboard.invite.wrong_account} +

+
+
+ ); +} + + diff --git a/frontend-nextjs/src/app/[lang]/dashboard/invite/page.tsx b/frontend-nextjs/src/app/[lang]/dashboard/invite/page.tsx new file mode 100644 index 000000000..c0a2ae69a --- /dev/null +++ b/frontend-nextjs/src/app/[lang]/dashboard/invite/page.tsx @@ -0,0 +1,22 @@ +import { getDictionary } from "@/app/[lang]/dictionaries"; +import { getInvite } from "@/models/invite"; +import { notFound } from "next/navigation"; +import InviteClient from "./invite-client"; + +type Params = Promise<{ lang: string; code: string }>; + +export default async function InvitePage({ params }: { params: Params }) { + const { lang, code } = await params; + const dict = await getDictionary(lang); + + let invite; + try { + invite = await getInvite(code); + } catch { + return notFound(); + } + + return ; +} + + diff --git a/frontend-nextjs/src/dictionaries/en.json b/frontend-nextjs/src/dictionaries/en.json index e0a814dd0..caf5bf8c2 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": "{org} 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..6d48fedb0 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": "You're invited", + "invited_by": "{org} 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": "新建", "edit": "编辑", diff --git a/frontend-nextjs/src/models/invite.ts b/frontend-nextjs/src/models/invite.ts new file mode 100644 index 000000000..1e6f267a7 --- /dev/null +++ b/frontend-nextjs/src/models/invite.ts @@ -0,0 +1,23 @@ +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; +}; + +export async function getInvite(code: string): Promise { + return await apiRequest(`/api/v1/invite/${code}`); +} + +export async function acceptInvite(code: string): Promise { + return await apiRequest(`/api/v1/invite/${code}`, { + method: "POST", + }); +} + + From b429e36442013d3325de24190c32578c23ea2eb4 Mon Sep 17 00:00:00 2001 From: Peter Nguyen Date: Mon, 29 Dec 2025 10:50:01 +0700 Subject: [PATCH 03/13] remigrate db, invite model and handler --- .../20251208090000_organisation_invites.sql | 7 +- backend/server/src/handler/invite.rs | 79 +++++++++++++++++++ backend/server/src/models/app.rs | 28 +++++++ backend/server/src/models/invite.rs | 74 +++++++++++++++++ .../invite/{ => [inviteId]}/invite-client.tsx | 7 +- .../dashboard/invite/[inviteId]/page.tsx | 40 ++++++++++ .../src/app/[lang]/dashboard/invite/page.tsx | 22 ------ 7 files changed, 229 insertions(+), 28 deletions(-) create mode 100644 backend/server/src/handler/invite.rs create mode 100644 backend/server/src/models/invite.rs rename frontend-nextjs/src/app/[lang]/dashboard/invite/{ => [inviteId]}/invite-client.tsx (93%) create mode 100644 frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/page.tsx delete mode 100644 frontend-nextjs/src/app/[lang]/dashboard/invite/page.tsx diff --git a/backend/migrations/20251208090000_organisation_invites.sql b/backend/migrations/20251208090000_organisation_invites.sql index 1ad7a978a..b6643baa4 100644 --- a/backend/migrations/20251208090000_organisation_invites.sql +++ b/backend/migrations/20251208090000_organisation_invites.sql @@ -1,6 +1,7 @@ CREATE TABLE organisation_invites ( - code TEXT PRIMARY KEY, + 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, @@ -8,6 +9,4 @@ CREATE TABLE organisation_invites ( created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); -CREATE INDEX IDX_organisation_invites_org_email ON organisation_invites (organisation_id, email); - - +CREATE INDEX IDX_organisation_invites_org_email ON organisation_invites (organisation_id, 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..1cabe0112 --- /dev/null +++ b/backend/server/src/handler/invite.rs @@ -0,0 +1,79 @@ +use crate::models::error::ChaosError; +use sqlx::Postgres; +use chrono::Utc; +use crate::models::invite::Invite; +use crate::models::transaction::DBTransaction; +use axum::response::IntoResponse; + + +/// Handler for invite-related HTTP requests. +pub struct InviteHandler; + +impl InviteHandler { + /// Validates whether an invite code is still valid (i.e. not expired). + /// + /// # Arguments + /// + /// * `code` - The invite code to validate + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result` - `Ok(invite)` if valid, `Err` if lookup fails + pub async fn validate_invite_is_valid( + code: &str, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let invite = Invite::get(code, transaction).await?; + // check if the invite has already been used + if invite.used_at.is_some() { + return Err(ChaosError::BadRequestWithMessage( + "Invite already used".to_string(), + )); + } + // check if the invite has expired + if invite.expires_at <= Utc::now() { + return Err(ChaosError::BadRequestWithMessage( + "Invite expired".to_string(), + )); + } + Ok(invite) + } + + + /// Uses an invite code for a user. + /// + /// Validates the invite, marks it as used by the given user + /// + /// # Arguments + /// + /// * `code` - The invite code being redeemed + /// * `user_id` - The ID of the user redeeming the invite + /// * `transaction` - Database transaction to use + /// + /// # Returns + /// + /// * `Result` - `Ok(())` if the invite was used; `Err` if the invite is invalid + pub async fn use_invite( + code: &str, + user_id: i64, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + + let invite = Self::validate_invite_is_valid(code, transaction).await?; + + Invite::save_used_by_person(user_id, &invite, transaction).await?; + + transaction.tx.commit().await?; + + Ok((StatusCode::OK, AppMessage::OkMessage("Invite used successfully"))) + } + + pub async fn get_code_by_id( + invite_id: i64, + transaction: &mut DBTransaction<'_>, + ) -> Result { + let invite = Invite::get(invite_id, &mut transaction.tx).await?; + Ok(invite.code) + } +} \ No newline at end of file diff --git a/backend/server/src/models/app.rs b/backend/server/src/models/app.rs index 9eb4c3e6b..786376a33 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,32 @@ pub async fn app() -> Result { "/api/v1/offer/:offer_id/send", post(OfferHandler::send_offer), ) + + // Invite routes + .route( + "/api/v1/invite/:invite_id", + get(InviteHandler::get), + ) + .route( + "/api/v1/invite/:invite_id/delete", + delete(InviteHandler::delete), + ) + .route( + "/api/v1/invite/:invite_id/create", + post(InviteHandler::create), + ) + + .route( + "/api/v1/invite/:invite_id/use", + post(InviteHandler::use_invite), + ) + + .route( + "/api/v1/invite/:invite_id/get_code", + get(InviteHandler::get_code_by_id), + ) + + .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..6d24423f6 --- /dev/null +++ b/backend/server/src/models/invite.rs @@ -0,0 +1,74 @@ +pub struct Invite { + pub id: i64, + pub code: String, + pub organisation_id: i64, + pub email: String, + pub expires_at: DateTime, + pub used_at: Option>, + pub used_by: Option, + pub created_at: DateTime, +} + +impl Invite { + + pub async fn create( + id: i64, + organisation_id: i64, + code: String, + email: String, + expires_at: DateTime, + created_at: DateTime, + ) -> Result { + Ok(Invite { + id, + code, + organisation_id, + email, + expires_at, + used_at: None, + used_by: None, + created_at, + }) + } + + pub async fn get( + code: &str, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + let invite = sqlx::query_as!( + Invite, + "SELECT * FROM invites WHERE code = $1", + code + ) + .fetch_one(transaction.deref_mut()) + .await?; + Ok(invite) + } + + pub async fn delete( + code: &str, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + sqlx::query!( + "DELETE FROM invites WHERE code = $1", + code + ) + .execute(transaction.deref_mut()) + .await?; + Ok(()) + } + + pub async fn save_used_by_person( + self, + user_id: i64, + invite: &mut Invite, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result<(), ChaosError> { + invite.used_at = Some(Utc::now()); + invite.used_by = Some(user_id); + invite.save(transaction).await?; + Ok(()) + } + + +} \ No newline at end of file diff --git a/frontend-nextjs/src/app/[lang]/dashboard/invite/invite-client.tsx b/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/invite-client.tsx similarity index 93% rename from frontend-nextjs/src/app/[lang]/dashboard/invite/invite-client.tsx rename to frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/invite-client.tsx index 27ec0a8b2..087fcb628 100644 --- a/frontend-nextjs/src/app/[lang]/dashboard/invite/invite-client.tsx +++ b/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/invite-client.tsx @@ -10,9 +10,10 @@ type Props = { code: string; invite: InviteDetails; dict: any; + mockMode?: boolean; }; -export default function InviteClient({ code, invite, dict }: Props) { +export default function InviteClient({ code, invite, dict, mockMode = false }: Props) { const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); const [message, setMessage] = useState(null); @@ -20,7 +21,9 @@ export default function InviteClient({ code, invite, dict }: Props) { setStatus("loading"); setMessage(null); try { - await acceptInvite(code); + if (!mockMode) { + await acceptInvite(code); + } setStatus("success"); setMessage(dict.dashboard.invite.accepted); } catch (err) { diff --git a/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/page.tsx b/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/page.tsx new file mode 100644 index 000000000..0d8631b2b --- /dev/null +++ b/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/page.tsx @@ -0,0 +1,40 @@ +"use client"; + +import type { InviteDetails } from "@/models/invite"; +import { useParams } from "next/navigation"; +import InviteClient from "./invite-client"; + +export default function Page() { + const params = useParams<{ lang?: string; inviteId?: string }>(); + const code = params?.inviteId ?? "mock-invite-code"; + + const invite: InviteDetails = { + organisation_id: "1", + organisation_name: "Chaos Demo Org", + email: "invited.user@example.com", + expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString(), + used: false, + expired: false, + }; + + // Minimal dict mock so the UI renders without pulling translations. + const dict = { + dashboard: { + invite: { + title: "Organisation invite", + invited_by: "You’ve been invited to join {org}.", + sent_to: "Invite sent to", + expired: "This invite has expired.", + used: "This invite has already been used.", + login_cta: "Log in", + accept_cta: "Accept invite", + accepted: "Invite accepted (mock).", + wrong_account: "Not you? Log in with a different account.", + }, + }, + }; + + return ; +} + + diff --git a/frontend-nextjs/src/app/[lang]/dashboard/invite/page.tsx b/frontend-nextjs/src/app/[lang]/dashboard/invite/page.tsx deleted file mode 100644 index c0a2ae69a..000000000 --- a/frontend-nextjs/src/app/[lang]/dashboard/invite/page.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { getDictionary } from "@/app/[lang]/dictionaries"; -import { getInvite } from "@/models/invite"; -import { notFound } from "next/navigation"; -import InviteClient from "./invite-client"; - -type Params = Promise<{ lang: string; code: string }>; - -export default async function InvitePage({ params }: { params: Params }) { - const { lang, code } = await params; - const dict = await getDictionary(lang); - - let invite; - try { - invite = await getInvite(code); - } catch { - return notFound(); - } - - return ; -} - - From 19259bcb69479f2dbee80576b90d76739f3b59e5 Mon Sep 17 00:00:00 2001 From: Peter Nguyen Date: Wed, 7 Jan 2026 13:57:58 +0700 Subject: [PATCH 04/13] add another field in organisation_invite table, according to Kavika's feedback. Fix the backend accordingly. --- .../20251208090000_organisation_invites.sql | 7 +- backend/server/src/handler/invite.rs | 122 +++++++++++------- backend/server/src/handler/mod.rs | 2 + backend/server/src/handler/organisation.rs | 13 +- backend/server/src/models/app.rs | 23 +--- backend/server/src/models/invite.rs | 120 ++++++++++++----- backend/server/src/models/mod.rs | 1 + backend/server/src/models/organisation.rs | 91 ++++++++++--- backend/server/src/models/serde_string.rs | 23 ++++ 9 files changed, 284 insertions(+), 118 deletions(-) diff --git a/backend/migrations/20251208090000_organisation_invites.sql b/backend/migrations/20251208090000_organisation_invites.sql index b6643baa4..a262382f4 100644 --- a/backend/migrations/20251208090000_organisation_invites.sql +++ b/backend/migrations/20251208090000_organisation_invites.sql @@ -6,7 +6,10 @@ CREATE TABLE organisation_invites ( 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 + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + invited_by_organisation_id BIGINT REFERENCES organisations(id) ON DELETE SET NULL ); -CREATE INDEX IDX_organisation_invites_org_email ON organisation_invites (organisation_id, email); \ No newline at end of file +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 index 1cabe0112..15d276fba 100644 --- a/backend/server/src/handler/invite.rs +++ b/backend/server/src/handler/invite.rs @@ -1,79 +1,111 @@ +use crate::models::app::AppMessage; +use crate::models::auth::AuthUser; use crate::models::error::ChaosError; -use sqlx::Postgres; -use chrono::Utc; use crate::models::invite::Invite; +use crate::models::organisation::Organisation; use crate::models::transaction::DBTransaction; +use axum::extract::Path; use axum::response::IntoResponse; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::query; +use std::ops::DerefMut; /// Handler for invite-related HTTP requests. pub struct InviteHandler; impl InviteHandler { - /// Validates whether an invite code is still valid (i.e. not expired). + /// Gets invite details for a given invite code. /// /// # Arguments /// - /// * `code` - The invite code to validate - /// * `transaction` - Database transaction to use + /// * `transaction` - Database transaction + /// * `code` - Invite code /// /// # Returns /// - /// * `Result` - `Ok(invite)` if valid, `Err` if lookup fails - pub async fn validate_invite_is_valid( - code: &str, - transaction: &mut Transaction<'_, Postgres>, - ) -> Result { - let invite = Invite::get(code, transaction).await?; - // check if the invite has already been used - if invite.used_at.is_some() { - return Err(ChaosError::BadRequestWithMessage( - "Invite already used".to_string(), - )); - } - // check if the invite has expired - if invite.expires_at <= Utc::now() { - return Err(ChaosError::BadRequestWithMessage( - "Invite expired".to_string(), - )); - } - Ok(invite) + /// * `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?; + let org = query!( + "SELECT name FROM organisations WHERE id = $1", + invite.organisation_id + ) + .fetch_one(transaction.tx.deref_mut()) + .await?; + + // Include derived booleans expected by the frontend. + let details = InviteDetails { + organisation_id: invite.organisation_id, + organisation_name: org.name, + email: invite.email, + expires_at: invite.expires_at, + used: invite.used_at.is_some(), + expired: invite.expires_at <= Utc::now(), + invited_by_organisation_id: invite.invited_by_organisation_id, + }; + + transaction.tx.commit().await?; + Ok(AppMessage::OkMessage(details)) } - - /// Uses an invite code for a user. + /// Accepts an invite for the current authenticated user. /// - /// Validates the invite, marks it as used by the given user + /// Validates the invite is not expired/used, then marks it as used. /// /// # Arguments /// - /// * `code` - The invite code being redeemed - /// * `user_id` - The ID of the user redeeming the invite - /// * `transaction` - Database transaction to use + /// * `transaction` - Database transaction + /// * `code` - Invite code + /// * `user` - Authenticated user /// /// # Returns /// - /// * `Result` - `Ok(())` if the invite was used; `Err` if the invite is invalid + /// * `Result` - Success message or error pub async fn use_invite( - code: &str, - user_id: i64, - transaction: &mut Transaction<'_, Postgres>, + mut transaction: DBTransaction<'_>, + Path(code): Path, + user: AuthUser, ) -> Result { + let invite = Invite::get_by_code(&code, &mut transaction.tx).await?; - let invite = Self::validate_invite_is_valid(code, transaction).await?; + // Validate the invite is not already used or expired. + if invite.used_at.is_some() { + return Err(ChaosError::BadRequestWithMessage("Invite already used".to_string())); + } + if invite.expires_at <= Utc::now() { + return Err(ChaosError::BadRequestWithMessage("Invite expired".to_string())); + } - Invite::save_used_by_person(user_id, &invite, transaction).await?; + // Add the user to the organisation. + Organisation::add_user(invite.organisation_id, user.user_id, &mut transaction.tx).await?; - transaction.tx.commit().await?; + // Mark the invite as used. + Invite::mark_used(&code, user.user_id, invite.invited_by_organisation_id, &mut transaction.tx).await?; - Ok((StatusCode::OK, AppMessage::OkMessage("Invite used successfully"))) + transaction.tx.commit().await?; + Ok(AppMessage::OkMessage("Invite accepted successfully")) } - pub async fn get_code_by_id( - invite_id: i64, - transaction: &mut DBTransaction<'_>, - ) -> Result { - let invite = Invite::get(invite_id, &mut transaction.tx).await?; - Ok(invite.code) - } + +} + +/// Response payload for invite details expected by the frontend. +#[derive(Serialize)] +pub struct InviteDetails { + #[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 organisation that invited the user + #[serde(serialize_with = "crate::models::serde_string::serialize_option")] + #[serde(deserialize_with = "crate::models::serde_string::deserialize_option")] + pub invited_by_organisation_id: Option } \ 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..4e03bb6d8 100644 --- a/backend/server/src/handler/organisation.rs +++ b/backend/server/src/handler/organisation.rs @@ -360,13 +360,20 @@ impl OrganisationHandler { mut transaction: DBTransaction<'_>, Path(id): Path, _admin: OrganisationAdmin, - State(state): State, + 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, + 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 786376a33..6c067eb8f 100644 --- a/backend/server/src/models/app.rs +++ b/backend/server/src/models/app.rs @@ -428,28 +428,13 @@ pub async fn app() -> Result { ) // Invite routes + // - GET /api/v1/invite/:code -> invite details + // - POST /api/v1/invite/:code -> accept invite .route( - "/api/v1/invite/:invite_id", - get(InviteHandler::get), - ) - .route( - "/api/v1/invite/:invite_id/delete", - delete(InviteHandler::delete), - ) - .route( - "/api/v1/invite/:invite_id/create", - post(InviteHandler::create), - ) - - .route( - "/api/v1/invite/:invite_id/use", - post(InviteHandler::use_invite), + "/api/v1/invite/:code", get(InviteHandler::get).post(InviteHandler::use_invite) ) - .route( - "/api/v1/invite/:invite_id/get_code", - get(InviteHandler::get_code_by_id), - ) + .layer(cors) diff --git a/backend/server/src/models/invite.rs b/backend/server/src/models/invite.rs index 6d24423f6..99edb63c2 100644 --- a/backend/server/src/models/invite.rs +++ b/backend/server/src/models/invite.rs @@ -1,72 +1,126 @@ +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 organisation that invited the user + #[serde(serialize_with = "crate::models::serde_string::serialize_option")] + #[serde(deserialize_with = "crate::models::serde_string::deserialize_option")] + pub invited_by_organisation_id: Option, + } impl Invite { - - pub async fn create( - id: i64, - organisation_id: i64, - code: String, - email: String, - expires_at: DateTime, - created_at: DateTime, - ) -> Result { - Ok(Invite { - id, - code, - organisation_id, - email, - expires_at, - used_at: None, - used_by: None, - created_at, - }) - } - - pub async fn get( + /// 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!( Invite, - "SELECT * FROM invites WHERE code = $1", + r#" + SELECT + id, + code, + organisation_id, + email, + expires_at, + used_at, + used_by, + created_at, + invited_by_organisation_id + FROM organisation_invites + WHERE code = $1 + "#, code ) .fetch_one(transaction.deref_mut()) .await?; + Ok(invite) } - pub async fn delete( + /// 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, + invited_by_organisation_id: Option, transaction: &mut Transaction<'_, Postgres>, ) -> Result<(), ChaosError> { sqlx::query!( - "DELETE FROM invites WHERE code = $1", - code + r#" + UPDATE organisation_invites + SET used_at = $1, used_by = $2 + WHERE code = $3 AND invited_by_organisation_id = $4 + "#, + Utc::now(), + user_id, + code, + invited_by_organisation_id ) .execute(transaction.deref_mut()) .await?; + Ok(()) } - pub async fn save_used_by_person( - self, - user_id: i64, - invite: &mut Invite, + /// Deletes an invite by code. + pub async fn delete_by_code( + code: &str, transaction: &mut Transaction<'_, Postgres>, ) -> Result<(), ChaosError> { - invite.used_at = Some(Utc::now()); - invite.used_by = Some(user_id); - invite.save(transaction).await?; + sqlx::query!( + "DELETE FROM organisation_invites WHERE code = $1", + code + ) + .execute(transaction.deref_mut()) + .await?; + Ok(()) } 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 d84b576a1..aad8161b8 100644 --- a/backend/server/src/models/organisation.rs +++ b/backend/server/src/models/organisation.rs @@ -6,16 +6,19 @@ 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; use sqlx::{FromRow, Postgres, Transaction}; use std::ops::DerefMut; use uuid::Uuid; -use crate::models::email::{ChaosEmail, EmailCredentials}; +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. /// @@ -683,8 +686,9 @@ impl Organisation { organisation_id: i64, email: String, email_credentials: EmailCredentials, + snowflake_generator: &mut SnowflakeIdGenerator, transaction: &mut Transaction<'_, Postgres>, - ) -> Result<(), ChaosError> { + ) -> Result { let _ = sqlx::query!( "SELECT id FROM organisations WHERE id = $1", organisation_id @@ -698,24 +702,79 @@ impl Organisation { return Err(ChaosError::BadRequestWithMessage("User already a member of organisation".to_string())) } Self::add_user(organisation_id, user.id, transaction).await?; + return Ok("existing-user-added".to_string()); } else { - - // TODO: email invite system - // TODO: generate an invite code, there will be a page, /dashboard/invite/[code], - // where it says sign up with this code credential and u will get added - - ChaosEmail::send_message( - "random name test".to_string(), - email.clone(), - "Testing email sending".to_string(), - "You have been invited to join an organisation".to_string(), - email_credentials, + // If an invite already exists for this organisation/email, not allow duplicates, we refresh the invite. + if let Some(existing) = 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 existing.used_at.is_some() { + return Err(ChaosError::BadRequestWithMessage("Invite already used for this email".to_string())); + } + + 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 + WHERE id = $3 + "#, + refreshed_code, + refreshed_expiry, + existing.id + ) + .execute(transaction.deref_mut()) + .await?; + + return Ok(refreshed_code); + } + + // handling 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_organisation_id = organisation_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_organisation_id) + VALUES ($1, $2, $3, $4, $5, NULL, NULL, NOW(), $6) + "#, + id, + organisation_id, + code, + email, + expires_at, + invited_by_organisation_id + ) + .execute(transaction.deref_mut()) .await?; - } + + // 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); + } - Ok(()) } pub async fn update_logo( 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>, From a713b62c9ab4bcf336cee909185f865ac6cfd913 Mon Sep 17 00:00:00 2001 From: Peter Nguyen Date: Wed, 7 Jan 2026 13:58:31 +0700 Subject: [PATCH 05/13] frontend the invite page, will now show error log, for example, user already exists etc --- .../invite/[inviteId]/invite-client.tsx | 22 ++++++- .../dashboard/invite/[inviteId]/page.tsx | 47 +++++---------- .../organisation/[orgId]/members/members.tsx | 45 ++++++++++++-- frontend-nextjs/src/lib/api.ts | 60 +++++++++++++++---- frontend-nextjs/src/models/invite.ts | 15 ++++- 5 files changed, 138 insertions(+), 51 deletions(-) diff --git a/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/invite-client.tsx b/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/invite-client.tsx index 087fcb628..707404450 100644 --- a/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/invite-client.tsx +++ b/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/invite-client.tsx @@ -4,7 +4,8 @@ import { Button } from "@/components/ui/button"; import { ApiError } from "@/lib/api"; import { acceptInvite, InviteDetails } from "@/models/invite"; import Link from "next/link"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; type Props = { code: string; @@ -16,6 +17,17 @@ type Props = { export default function InviteClient({ code, invite, dict, mockMode = false }: Props) { const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); const [message, setMessage] = useState(null); + const router = useRouter(); + + // 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"); @@ -29,14 +41,18 @@ export default function InviteClient({ code, invite, dict, mockMode = false }: P } catch (err) { setStatus("error"); if (err instanceof ApiError) { - setMessage(err.message); + if (err.status === 401) { + setMessage(dict.dashboard.invite.login_required ?? "Please log in to accept this invite."); + } else { + setMessage(err.message); + } } else { setMessage("Something went wrong. Please try again."); } } }; - const inviteInvalid = invite.expired || invite.used; + const inviteInvalid = invite.expired || invite.used || status === "success"; return (
diff --git a/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/page.tsx b/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/page.tsx index 0d8631b2b..b297ba60d 100644 --- a/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/page.tsx +++ b/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/page.tsx @@ -1,40 +1,23 @@ -"use client"; - -import type { InviteDetails } from "@/models/invite"; -import { useParams } from "next/navigation"; +import { getDictionary } from "@/app/[lang]/dictionaries"; +import { getInvite } from "@/models/invite"; +import { notFound } from "next/navigation"; import InviteClient from "./invite-client"; -export default function Page() { - const params = useParams<{ lang?: string; inviteId?: string }>(); - const code = params?.inviteId ?? "mock-invite-code"; +type Params = Promise<{ lang: string; inviteId: string }>; + +export default async function Page({ params }: { params: Params }) { + const { lang, inviteId } = await params; - const invite: InviteDetails = { - organisation_id: "1", - organisation_name: "Chaos Demo Org", - email: "invited.user@example.com", - expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString(), - used: false, - expired: false, - }; + const dict = await getDictionary(lang); - // Minimal dict mock so the UI renders without pulling translations. - const dict = { - dashboard: { - invite: { - title: "Organisation invite", - invited_by: "You’ve been invited to join {org}.", - sent_to: "Invite sent to", - expired: "This invite has expired.", - used: "This invite has already been used.", - login_cta: "Log in", - accept_cta: "Accept invite", - accepted: "Invite accepted (mock).", - wrong_account: "Not you? Log in with a different account.", - }, - }, - }; + let invite; + try { + invite = await getInvite(inviteId); + } catch { + return notFound(); + } - return ; + 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..7ff0a62ad 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,40 @@ export function AddMemberDialog({ orgId, dict }: { orgId: string, dict: any }) { const queryClient = useQueryClient(); const [email, setEmail] = useState(""); + const [open, setOpen] = useState(false); + const [status, setStatus] = useState<"idle" | "loading">("idle"); + const [errorMessage, setErrorMessage] = useState(null); const handleInviteMember = async () => { - await inviteOrganisationUser(orgId, email); - await queryClient.invalidateQueries({ queryKey: [`${orgId}-members`] }); + setErrorMessage(null); + + const trimmed = email.trim(); + if (!trimmed) { + setErrorMessage(dict?.common?.email_required ?? "Email is required."); + return; + } + + setStatus("loading"); + try { + await inviteOrganisationUser(orgId, trimmed); + await queryClient.invalidateQueries({ queryKey: [`${orgId}-members`] }); + setEmail(""); + setOpen(false); + } catch (err) { + if (err instanceof ApiError) { + setErrorMessage(err.message); + } else if (err instanceof Error) { + setErrorMessage(err.message); + } else { + setErrorMessage("Failed to invite member."); + } + } finally { + setStatus("idle"); + } } return ( - + @@ -77,10 +104,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/lib/api.ts b/frontend-nextjs/src/lib/api.ts index 76c77c359..d9ac7f004 100644 --- a/frontend-nextjs/src/lib/api.ts +++ b/frontend-nextjs/src/lib/api.ts @@ -71,23 +71,63 @@ export async function apiRequest( const response = await fetch(url, fetchOptions); if (!response.ok) { - if (response.status === 401 || okRequiredOtherwiseLogin) { - if (isServer) { - const { redirect } = await import("next/navigation"); - const { headers } = await import("next/headers"); - const headersList = await headers(); - const pathname = headersList.get("x-pathname") || "/"; - - redirect(`/login?to=${encodeURIComponent(pathname)}`); + // Best-effort parse of backend error payloads like `{ "error": "..." }` + // Use `response.clone()` so we don't consume the original body if needed elsewhere. + let serverMessage: string | undefined; + try { + const cloned = response.clone(); + const contentType = cloned.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + const parsed = (await cloned.json()) as unknown; + if ( + parsed && + typeof parsed === "object" && + "error" in parsed && + typeof (parsed as { error?: unknown }).error === "string" + ) { + serverMessage = (parsed as { error: string }).error; + } else if ( + parsed && + typeof parsed === "object" && + "message" in parsed && + typeof (parsed as { message?: unknown }).message === "string" + ) { + serverMessage = (parsed as { message: string }).message; + } } else { - window.location.href = `/login?to=${encodeURIComponent(window.location.pathname)}`; + const text = await cloned.text(); + // Some servers mislabel JSON error responses; try JSON parse anyway. + try { + const parsed = JSON.parse(text) as unknown; + if ( + parsed && + typeof parsed === "object" && + "error" in parsed && + typeof (parsed as { error?: unknown }).error === "string" + ) { + serverMessage = (parsed as { error: string }).error; + } else if ( + parsed && + typeof parsed === "object" && + "message" in parsed && + typeof (parsed as { message?: unknown }).message === "string" + ) { + serverMessage = (parsed as { message: string }).message; + } else { + serverMessage = text; + } + } catch { + serverMessage = text; + } } + } catch { + // ignore parse errors } throw new ApiError( response.status, response.statusText, - `API request failed: ${method} ${path}` + serverMessage || `API request failed: ${method} ${path}` ); } diff --git a/frontend-nextjs/src/models/invite.ts b/frontend-nextjs/src/models/invite.ts index 1e6f267a7..918e8a165 100644 --- a/frontend-nextjs/src/models/invite.ts +++ b/frontend-nextjs/src/models/invite.ts @@ -10,10 +10,23 @@ export type InviteDetails = { expired: boolean; }; +type InviteResponse = { message: InviteDetails }; + +/** + * Gets the invite details for a given invite code. + * @param code - The invite code + * @returns The invite details + */ export async function getInvite(code: string): Promise { - return await apiRequest(`/api/v1/invite/${code}`); + const res = await apiRequest(`/api/v1/invite/${code}`); + return res.message; } +/** + * Accepts an invite for the current authenticated user. + * @param code - The invite code + * @returns The app message + */ export async function acceptInvite(code: string): Promise { return await apiRequest(`/api/v1/invite/${code}`, { method: "POST", From 640e8e47a93a24ea3c2c094d353c987a1ce53ffd Mon Sep 17 00:00:00 2001 From: Kavika Date: Sat, 10 Jan 2026 22:31:53 +1100 Subject: [PATCH 06/13] track inviting user's id --- .../20251208090000_organisation_invites.sql | 2 +- backend/server/src/handler/invite.rs | 8 +++---- backend/server/src/handler/organisation.rs | 3 ++- backend/server/src/models/invite.rs | 23 +++++++++---------- backend/server/src/models/organisation.rs | 7 +++--- 5 files changed, 22 insertions(+), 21 deletions(-) diff --git a/backend/migrations/20251208090000_organisation_invites.sql b/backend/migrations/20251208090000_organisation_invites.sql index a262382f4..58e85bf26 100644 --- a/backend/migrations/20251208090000_organisation_invites.sql +++ b/backend/migrations/20251208090000_organisation_invites.sql @@ -7,7 +7,7 @@ CREATE TABLE organisation_invites ( used_at TIMESTAMPTZ, used_by BIGINT REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - invited_by_organisation_id BIGINT REFERENCES organisations(id) ON DELETE SET NULL + invited_by_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL ); CREATE INDEX IDX_organisation_invites_code ON organisation_invites (code); diff --git a/backend/server/src/handler/invite.rs b/backend/server/src/handler/invite.rs index 15d276fba..63633d324 100644 --- a/backend/server/src/handler/invite.rs +++ b/backend/server/src/handler/invite.rs @@ -46,7 +46,7 @@ impl InviteHandler { expires_at: invite.expires_at, used: invite.used_at.is_some(), expired: invite.expires_at <= Utc::now(), - invited_by_organisation_id: invite.invited_by_organisation_id, + invited_by_user_id: invite.invited_by_user_id, }; transaction.tx.commit().await?; @@ -85,7 +85,7 @@ impl InviteHandler { Organisation::add_user(invite.organisation_id, user.user_id, &mut transaction.tx).await?; // Mark the invite as used. - Invite::mark_used(&code, user.user_id, invite.invited_by_organisation_id, &mut transaction.tx).await?; + Invite::mark_used(&code, user.user_id, &mut transaction.tx).await?; transaction.tx.commit().await?; Ok(AppMessage::OkMessage("Invite accepted successfully")) @@ -104,8 +104,8 @@ pub struct InviteDetails { pub expires_at: DateTime, pub used: bool, pub expired: bool, - /// ID of the organisation that invited the user + /// 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_organisation_id: Option + pub invited_by_user_id: Option } \ No newline at end of file diff --git a/backend/server/src/handler/organisation.rs b/backend/server/src/handler/organisation.rs index 4e03bb6d8..34700b958 100644 --- a/backend/server/src/handler/organisation.rs +++ b/backend/server/src/handler/organisation.rs @@ -359,12 +359,13 @@ impl OrganisationHandler { pub async fn invite_user( mut transaction: DBTransaction<'_>, Path(id): Path, - _admin: OrganisationAdmin, + admin: OrganisationAdmin, State(mut state): State, Json(request_body): Json, ) -> Result { let invite_code = Organisation::invite_user( id, + admin.user_id, request_body.email, state.email_credentials.clone(), &mut state.snowflake_generator, diff --git a/backend/server/src/models/invite.rs b/backend/server/src/models/invite.rs index 99edb63c2..7dbe045d4 100644 --- a/backend/server/src/models/invite.rs +++ b/backend/server/src/models/invite.rs @@ -29,10 +29,10 @@ pub struct Invite { pub used_by: Option, /// When the invite was created pub created_at: DateTime, - /// ID of the organisation that invited the user + /// 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_organisation_id: Option, + pub invited_by_user_id: Option, } @@ -63,9 +63,9 @@ impl Invite { used_at, used_by, created_at, - invited_by_organisation_id + invited_by_user_id FROM organisation_invites - WHERE code = $1 + WHERE code = $1 AND used_at IS NULL AND expires_at > NOW() "#, code ) @@ -89,21 +89,20 @@ impl Invite { pub async fn mark_used( code: &str, user_id: i64, - invited_by_organisation_id: Option, transaction: &mut Transaction<'_, Postgres>, ) -> Result<(), ChaosError> { - sqlx::query!( + let _ = sqlx::query!( r#" UPDATE organisation_invites SET used_at = $1, used_by = $2 - WHERE code = $3 AND invited_by_organisation_id = $4 + WHERE code = $3 AND used_at IS NULL AND expires_at > NOW() + RETURNING id "#, Utc::now(), user_id, code, - invited_by_organisation_id ) - .execute(transaction.deref_mut()) + .fetch_one(transaction.deref_mut()) .await?; Ok(()) @@ -114,11 +113,11 @@ impl Invite { code: &str, transaction: &mut Transaction<'_, Postgres>, ) -> Result<(), ChaosError> { - sqlx::query!( - "DELETE FROM organisation_invites WHERE code = $1", + let _ = sqlx::query!( + "DELETE FROM organisation_invites WHERE code = $1 RETURNING id", code ) - .execute(transaction.deref_mut()) + .fetch_one(transaction.deref_mut()) .await?; Ok(()) diff --git a/backend/server/src/models/organisation.rs b/backend/server/src/models/organisation.rs index aad8161b8..f8a57598c 100644 --- a/backend/server/src/models/organisation.rs +++ b/backend/server/src/models/organisation.rs @@ -684,6 +684,7 @@ impl Organisation { pub async fn invite_user( organisation_id: i64, + inviting_user_id: i64, email: String, email_credentials: EmailCredentials, snowflake_generator: &mut SnowflakeIdGenerator, @@ -750,7 +751,7 @@ impl Organisation { sqlx::query!( r#" INSERT INTO organisation_invites - (id, organisation_id, code, email, expires_at, used_at, used_by, created_at, invited_by_organisation_id) + (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, @@ -758,12 +759,12 @@ impl Organisation { code, email, expires_at, - invited_by_organisation_id + inviting_user_id ) .execute(transaction.deref_mut()) .await?; - + // TODO: Get SMTP credentials // ChaosEmail::send_message( // "".to_string(), // email, From b46d01efb4f47d08e8411b3b59d7388dfb80b712 Mon Sep 17 00:00:00 2001 From: Peter Nguyen Date: Wed, 14 Jan 2026 13:47:46 +0700 Subject: [PATCH 07/13] revert api.ts --- frontend-nextjs/src/lib/api.ts | 62 ++++++---------------------------- 1 file changed, 11 insertions(+), 51 deletions(-) diff --git a/frontend-nextjs/src/lib/api.ts b/frontend-nextjs/src/lib/api.ts index d9ac7f004..6c1f9c411 100644 --- a/frontend-nextjs/src/lib/api.ts +++ b/frontend-nextjs/src/lib/api.ts @@ -71,63 +71,23 @@ export async function apiRequest( const response = await fetch(url, fetchOptions); if (!response.ok) { - // Best-effort parse of backend error payloads like `{ "error": "..." }` - // Use `response.clone()` so we don't consume the original body if needed elsewhere. - let serverMessage: string | undefined; - try { - const cloned = response.clone(); - const contentType = cloned.headers.get("content-type") || ""; - if (contentType.includes("application/json")) { - const parsed = (await cloned.json()) as unknown; - if ( - parsed && - typeof parsed === "object" && - "error" in parsed && - typeof (parsed as { error?: unknown }).error === "string" - ) { - serverMessage = (parsed as { error: string }).error; - } else if ( - parsed && - typeof parsed === "object" && - "message" in parsed && - typeof (parsed as { message?: unknown }).message === "string" - ) { - serverMessage = (parsed as { message: string }).message; - } + if (response.status === 401 || okRequiredOtherwiseLogin) { + if (isServer) { + const { redirect } = await import("next/navigation"); + const { headers } = await import("next/headers"); + const headersList = await headers(); + const pathname = headersList.get("x-pathname") || "/"; + + redirect(`/login?to=${encodeURIComponent(pathname)}`); } else { - const text = await cloned.text(); - // Some servers mislabel JSON error responses; try JSON parse anyway. - try { - const parsed = JSON.parse(text) as unknown; - if ( - parsed && - typeof parsed === "object" && - "error" in parsed && - typeof (parsed as { error?: unknown }).error === "string" - ) { - serverMessage = (parsed as { error: string }).error; - } else if ( - parsed && - typeof parsed === "object" && - "message" in parsed && - typeof (parsed as { message?: unknown }).message === "string" - ) { - serverMessage = (parsed as { message: string }).message; - } else { - serverMessage = text; - } - } catch { - serverMessage = text; - } + window.location.href = `/login?to=${encodeURIComponent(window.location.pathname)}`; } - } catch { - // ignore parse errors } throw new ApiError( response.status, response.statusText, - serverMessage || `API request failed: ${method} ${path}` + `API request failed: ${method} ${path}` ); } @@ -136,4 +96,4 @@ export async function apiRequest( } return (await response.json()) as T; -} +} \ No newline at end of file From b6061366f79321aa2598ebfc4ae6f2ccd2398703 Mon Sep 17 00:00:00 2001 From: Peter Nguyen Date: Wed, 14 Jan 2026 13:48:11 +0700 Subject: [PATCH 08/13] fix from passing props to using react-query --- .../invite/[inviteId]/invite-client.tsx | 63 +++++++++++-------- .../dashboard/invite/[inviteId]/page.tsx | 15 +++-- frontend-nextjs/src/models/invite.ts | 18 ++++++ 3 files changed, 64 insertions(+), 32 deletions(-) diff --git a/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/invite-client.tsx b/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/invite-client.tsx index 707404450..a6a22f2d9 100644 --- a/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/invite-client.tsx +++ b/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/invite-client.tsx @@ -2,22 +2,22 @@ import { Button } from "@/components/ui/button"; import { ApiError } from "@/lib/api"; -import { acceptInvite, InviteDetails } from "@/models/invite"; +import { acceptInvite, inviteQueryOptions } 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; - invite: InviteDetails; dict: any; - mockMode?: boolean; }; -export default function InviteClient({ code, invite, dict, mockMode = false }: Props) { +export default function InviteClient({ code, dict }: Props) { const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); const [message, setMessage] = useState(null); const router = useRouter(); + const { data: invite } = useQuery(inviteQueryOptions(code)); // After successful acceptance, redirect after a short delay. useEffect(() => { @@ -32,43 +32,51 @@ export default function InviteClient({ code, invite, dict, mockMode = false }: P const handleAccept = async () => { setStatus("loading"); setMessage(null); - try { - if (!mockMode) { - await acceptInvite(code); - } - setStatus("success"); - setMessage(dict.dashboard.invite.accepted); - } catch (err) { - setStatus("error"); - if (err instanceof ApiError) { - if (err.status === 401) { - setMessage(dict.dashboard.invite.login_required ?? "Please log in to accept this invite."); + + acceptInvite(code) + .then(() => { + setStatus("success"); + setMessage(dict.dashboard.invite.accepted); + }) + .catch((err) => { + setStatus("error"); + if (err instanceof ApiError) { + if (err.status === 401) { + setMessage(dict.dashboard.invite.login_required ?? "Please log in to accept this invite."); + } else { + setMessage(err.message); + } } else { - setMessage(err.message); + setMessage("Something went wrong. Please try again."); } - } else { - setMessage("Something went wrong. Please try again."); - } - } + }); }; - const inviteInvalid = invite.expired || invite.used || status === "success"; + const inviteInvalid = !invite || invite.expired || invite.used || status === "success"; return (

{dict.dashboard.invite.title}

- {dict.dashboard.invite.invited_by.replace("{org}", invite.organisation_name)} -

-

- {dict.dashboard.invite.sent_to}: {invite.email} + {invite + ? dict.dashboard.invite.invited_by.replace("{org}", invite.organisation_name) + : dict?.common?.loading ?? "Loading..."}

- {invite.expired && ( + {/* 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}

)} - {invite.used && ( + {/* 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} @@ -87,6 +95,7 @@ export default function InviteClient({ code, invite, dict, mockMode = false }: P > {status === "loading" ? "Loading..." : dict.dashboard.invite.accept_cta} + {/* 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/[inviteId]/page.tsx b/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/page.tsx index b297ba60d..6bd1a6255 100644 --- a/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/page.tsx +++ b/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/page.tsx @@ -1,5 +1,6 @@ import { getDictionary } from "@/app/[lang]/dictionaries"; -import { getInvite } from "@/models/invite"; +import { inviteQueryOptions } from "@/models/invite"; +import { HydrationBoundary, QueryClient, dehydrate } from "@tanstack/react-query"; import { notFound } from "next/navigation"; import InviteClient from "./invite-client"; @@ -10,14 +11,18 @@ export default async function Page({ params }: { params: Params }) { const dict = await getDictionary(lang); - let invite; try { - invite = await getInvite(inviteId); + const queryClient = new QueryClient(); + await queryClient.prefetchQuery(inviteQueryOptions(inviteId)); + + return ( + + + + ); } catch { return notFound(); } - - return ; } diff --git a/frontend-nextjs/src/models/invite.ts b/frontend-nextjs/src/models/invite.ts index 918e8a165..c8431c130 100644 --- a/frontend-nextjs/src/models/invite.ts +++ b/frontend-nextjs/src/models/invite.ts @@ -1,4 +1,5 @@ import { apiRequest } from "@/lib/api"; +import type { QueryKey } from "@tanstack/react-query"; import { AppMessage } from "./app"; export type InviteDetails = { @@ -33,4 +34,21 @@ export async function acceptInvite(code: string): Promise { }); } +/** + * Gets the query key for the invite. + * @param code - The invite code + * @returns The query key + */ +export const inviteQueryKey = (code: string): QueryKey => ["invite", code]; + +/** + * Gets the query options for the invite. + * @param code - The invite code + * @returns The query options + */ +export const inviteQueryOptions = (code: string) => ({ + queryKey: inviteQueryKey(code), + queryFn: () => getInvite(code), +}); + From 619a656eebcb657895525e50f321ba2924689f92 Mon Sep 17 00:00:00 2001 From: Peter Nguyen Date: Wed, 14 Jan 2026 14:04:09 +0700 Subject: [PATCH 09/13] remove try and catch in the invite frontend component --- .../dashboard/invite/[inviteId]/page.tsx | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/page.tsx b/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/page.tsx index 6bd1a6255..5b254baea 100644 --- a/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/page.tsx +++ b/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/page.tsx @@ -11,18 +11,20 @@ export default async function Page({ params }: { params: Params }) { const dict = await getDictionary(lang); - try { - const queryClient = new QueryClient(); - await queryClient.prefetchQuery(inviteQueryOptions(inviteId)); - - return ( - - - - ); - } catch { - return notFound(); - } + const queryClient = new QueryClient(); + const ok = await queryClient + .prefetchQuery(inviteQueryOptions(inviteId)) + .then(() => true) + .catch(() => false); + + if (!ok) return notFound(); + + return ( + + + + ); } + From 49c66e7327d9eeb0e38d1928258ab6c6113a2268 Mon Sep 17 00:00:00 2001 From: Peter Nguyen Date: Wed, 14 Jan 2026 15:20:21 +0700 Subject: [PATCH 10/13] fix not allow duplicate invites in the backend side --- backend/server/src/handler/invite.rs | 19 +++++++++++++++ backend/server/src/models/organisation.rs | 28 ++++++++++++++++++----- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/backend/server/src/handler/invite.rs b/backend/server/src/handler/invite.rs index 63633d324..96850d643 100644 --- a/backend/server/src/handler/invite.rs +++ b/backend/server/src/handler/invite.rs @@ -73,6 +73,25 @@ impl InviteHandler { ) -> 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_row = query!( + r#" + SELECT email + FROM users + WHERE id = $1 + "#, + user.user_id + ) + .fetch_one(transaction.tx.deref_mut()) + .await?; + + if user_row.email.trim().to_lowercase() != invite.email.trim().to_lowercase() { + return Err(ChaosError::BadRequestWithMessage( + "Invite was sent to a different email address".to_string(), + )); + } + // Validate the invite is not already used or expired. if invite.used_at.is_some() { return Err(ChaosError::BadRequestWithMessage("Invite already used".to_string())); diff --git a/backend/server/src/models/organisation.rs b/backend/server/src/models/organisation.rs index f8a57598c..b8ac8bfd1 100644 --- a/backend/server/src/models/organisation.rs +++ b/backend/server/src/models/organisation.rs @@ -719,8 +719,17 @@ impl Organisation { ) .fetch_optional(transaction.deref_mut()) .await? { + // If an invite was previously used, only block re-invites when that email + // currently belongs to a member. This allows re-inviting after a user is deleted + // / removed and later re-creates an account. if existing.used_at.is_some() { - return Err(ChaosError::BadRequestWithMessage("Invite already used for this email".to_string())); + if let Some(user) = User::find_by_email(email.clone(), transaction).await? { + if Self::check_user_already_member(organisation_id, user.id, transaction).await? { + return Err(ChaosError::BadRequestWithMessage( + "User already a member of organisation".to_string(), + )); + } + } } let refreshed_code = nanoid!(10, &NANOID_ALPHABET); @@ -729,11 +738,18 @@ impl Organisation { sqlx::query!( r#" UPDATE organisation_invites - SET code = $1, expires_at = $2, created_at = NOW(), used_at = NULL, used_by = NULL - WHERE id = $3 + 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()) @@ -745,8 +761,8 @@ impl Organisation { // handling 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_organisation_id = organisation_id; + 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#" @@ -759,7 +775,7 @@ impl Organisation { code, email, expires_at, - inviting_user_id + invited_by_user_id ) .execute(transaction.deref_mut()) .await?; From 7c3531afedab7a74805ca63de62229b42d1953b0 Mon Sep 17 00:00:00 2001 From: Peter Nguyen Date: Wed, 14 Jan 2026 15:20:45 +0700 Subject: [PATCH 11/13] accept invite if not log in will direct to create account --- .../[lang]/dashboard/invite/[inviteId]/invite-client.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/invite-client.tsx b/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/invite-client.tsx index a6a22f2d9..d326887f0 100644 --- a/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/invite-client.tsx +++ b/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/invite-client.tsx @@ -39,14 +39,17 @@ export default function InviteClient({ code, dict }: Props) { setMessage(dict.dashboard.invite.accepted); }) .catch((err) => { - setStatus("error"); if (err instanceof ApiError) { if (err.status === 401) { - setMessage(dict.dashboard.invite.login_required ?? "Please log in to accept this invite."); + // Not logged in -> send user to login (they can create an account from there). + router.push(`/login?to=${encodeURIComponent(`/dashboard/invite/${code}`)}`); + return; } else { + setStatus("error"); setMessage(err.message); } } else { + setStatus("error"); setMessage("Something went wrong. Please try again."); } }); From 4d7339cf95be80c8f57fc5fdebea6e83ed2eab9c Mon Sep 17 00:00:00 2001 From: Kavika Date: Thu, 15 Jan 2026 21:24:44 +1100 Subject: [PATCH 12/13] invite backend fixes --- .../20251208090000_organisation_invites.sql | 6 +- backend/server/src/handler/invite.rs | 63 ++------ backend/server/src/models/invite.rs | 35 ++++- backend/server/src/models/organisation.rs | 148 ++++++++---------- 4 files changed, 109 insertions(+), 143 deletions(-) diff --git a/backend/migrations/20251208090000_organisation_invites.sql b/backend/migrations/20251208090000_organisation_invites.sql index 58e85bf26..8dbdb7f6c 100644 --- a/backend/migrations/20251208090000_organisation_invites.sql +++ b/backend/migrations/20251208090000_organisation_invites.sql @@ -10,6 +10,6 @@ CREATE TABLE organisation_invites ( 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 +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 index 96850d643..6f5a3f37e 100644 --- a/backend/server/src/handler/invite.rs +++ b/backend/server/src/handler/invite.rs @@ -4,10 +4,9 @@ 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 chrono::{DateTime, Utc}; -use serde::Serialize; use sqlx::query; use std::ops::DerefMut; @@ -31,26 +30,9 @@ impl InviteHandler { Path(code): Path, ) -> Result { let invite = Invite::get_by_code(&code, &mut transaction.tx).await?; - let org = query!( - "SELECT name FROM organisations WHERE id = $1", - invite.organisation_id - ) - .fetch_one(transaction.tx.deref_mut()) - .await?; - - // Include derived booleans expected by the frontend. - let details = InviteDetails { - organisation_id: invite.organisation_id, - organisation_name: org.name, - email: invite.email, - expires_at: invite.expires_at, - used: invite.used_at.is_some(), - expired: invite.expires_at <= Utc::now(), - invited_by_user_id: invite.invited_by_user_id, - }; transaction.tx.commit().await?; - Ok(AppMessage::OkMessage(details)) + Ok(AppMessage::OkMessage(invite)) } /// Accepts an invite for the current authenticated user. @@ -69,62 +51,37 @@ impl InviteHandler { pub async fn use_invite( mut transaction: DBTransaction<'_>, Path(code): Path, - user: AuthUser, + 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_row = query!( - r#" - SELECT email - FROM users - WHERE id = $1 - "#, - user.user_id - ) - .fetch_one(transaction.tx.deref_mut()) - .await?; + let user = User::get(auth_user.user_id, &mut transaction.tx).await?; - if user_row.email.trim().to_lowercase() != invite.email.trim().to_lowercase() { + if user.email != invite.email { return Err(ChaosError::BadRequestWithMessage( - "Invite was sent to a different email address".to_string(), + "Invite was sent for a different email address".to_string(), )); } // Validate the invite is not already used or expired. - if invite.used_at.is_some() { + if invite.used { return Err(ChaosError::BadRequestWithMessage("Invite already used".to_string())); } - if invite.expires_at <= Utc::now() { + if invite.expired { return Err(ChaosError::BadRequestWithMessage("Invite expired".to_string())); } // Add the user to the organisation. - Organisation::add_user(invite.organisation_id, user.user_id, &mut transaction.tx).await?; + Organisation::add_user(invite.organisation_id, auth_user.user_id, &mut transaction.tx).await?; // Mark the invite as used. - Invite::mark_used(&code, user.user_id, &mut transaction.tx).await?; + Invite::mark_used(&code, auth_user.user_id, &mut transaction.tx).await?; transaction.tx.commit().await?; Ok(AppMessage::OkMessage("Invite accepted successfully")) } -} - -/// Response payload for invite details expected by the frontend. -#[derive(Serialize)] -pub struct InviteDetails { - #[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 } \ No newline at end of file diff --git a/backend/server/src/models/invite.rs b/backend/server/src/models/invite.rs index 7dbe045d4..5b38ae120 100644 --- a/backend/server/src/models/invite.rs +++ b/backend/server/src/models/invite.rs @@ -33,7 +33,25 @@ pub struct Invite { #[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 { @@ -50,21 +68,22 @@ impl Invite { pub async fn get_by_code( code: &str, transaction: &mut Transaction<'_, Postgres>, - ) -> Result { + ) -> Result { let invite = sqlx::query_as!( - Invite, + InviteDetails, r#" SELECT - id, + oi.id, code, organisation_id, + o.name AS organisation_name, email, expires_at, - used_at, - used_by, - created_at, + used_at IS NOT NULL AS "used!: bool", + expires_at <= NOW() AS "expired!: bool", invited_by_user_id - FROM organisation_invites + 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 diff --git a/backend/server/src/models/organisation.rs b/backend/server/src/models/organisation.rs index b8ac8bfd1..cfd4dd047 100644 --- a/backend/server/src/models/organisation.rs +++ b/backend/server/src/models/organisation.rs @@ -690,6 +690,8 @@ impl Organisation { snowflake_generator: &mut SnowflakeIdGenerator, transaction: &mut Transaction<'_, Postgres>, ) -> Result { + let email = email.to_lowercase(); + let _ = sqlx::query!( "SELECT id FROM organisations WHERE id = $1", organisation_id @@ -704,95 +706,83 @@ impl Organisation { } Self::add_user(organisation_id, user.id, transaction).await?; return Ok("existing-user-added".to_string()); - } else { - // If an invite already exists for this organisation/email, not allow duplicates, we refresh the invite. - if let Some(existing) = 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 an invite was previously used, only block re-invites when that email - // currently belongs to a member. This allows re-inviting after a user is deleted - // / removed and later re-creates an account. - if existing.used_at.is_some() { - if let Some(user) = User::find_by_email(email.clone(), transaction).await? { - if Self::check_user_already_member(organisation_id, user.id, transaction).await? { - return Err(ChaosError::BadRequestWithMessage( - "User already a member of organisation".to_string(), - )); - } - } - } - - 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); - } + } + + // 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); - // handling 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) + 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 "#, - id, - organisation_id, - code, - email, - expires_at, - invited_by_user_id + refreshed_code, + refreshed_expiry, + inviting_user_id, + existing.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); + 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, From 8098dc4a426e7fd21b1bcaa030aa25916378ff9e Mon Sep 17 00:00:00 2001 From: Kavika Date: Thu, 15 Jan 2026 21:38:05 +1100 Subject: [PATCH 13/13] invite frontend fixes --- .../{[inviteId] => [code]}/invite-client.tsx | 37 +++++++------------ .../[lang]/dashboard/invite/[code]/page.tsx | 24 ++++++++++++ .../dashboard/invite/[inviteId]/page.tsx | 30 --------------- .../organisation/[orgId]/members/members.tsx | 33 ++++++----------- frontend-nextjs/src/dictionaries/en.json | 10 ++--- frontend-nextjs/src/dictionaries/zh.json | 18 ++++----- frontend-nextjs/src/lib/api.ts | 2 +- frontend-nextjs/src/models/invite.ts | 28 -------------- 8 files changed, 63 insertions(+), 119 deletions(-) rename frontend-nextjs/src/app/[lang]/dashboard/invite/{[inviteId] => [code]}/invite-client.tsx (76%) create mode 100644 frontend-nextjs/src/app/[lang]/dashboard/invite/[code]/page.tsx delete mode 100644 frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/page.tsx diff --git a/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/invite-client.tsx b/frontend-nextjs/src/app/[lang]/dashboard/invite/[code]/invite-client.tsx similarity index 76% rename from frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/invite-client.tsx rename to frontend-nextjs/src/app/[lang]/dashboard/invite/[code]/invite-client.tsx index d326887f0..e572105e0 100644 --- a/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/invite-client.tsx +++ b/frontend-nextjs/src/app/[lang]/dashboard/invite/[code]/invite-client.tsx @@ -2,7 +2,7 @@ import { Button } from "@/components/ui/button"; import { ApiError } from "@/lib/api"; -import { acceptInvite, inviteQueryOptions } from "@/models/invite"; +import { acceptInvite, getInvite } from "@/models/invite"; import Link from "next/link"; import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; @@ -14,10 +14,15 @@ type Props = { }; 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 router = useRouter(); - const { data: invite } = useQuery(inviteQueryOptions(code)); + + const { data: invite } = useQuery({ + queryKey: [`invite-${code}`], + queryFn: () => getInvite(code), + }); // After successful acceptance, redirect after a short delay. useEffect(() => { @@ -33,26 +38,9 @@ export default function InviteClient({ code, dict }: Props) { setStatus("loading"); setMessage(null); - acceptInvite(code) - .then(() => { - setStatus("success"); - setMessage(dict.dashboard.invite.accepted); - }) - .catch((err) => { - if (err instanceof ApiError) { - if (err.status === 401) { - // Not logged in -> send user to login (they can create an account from there). - router.push(`/login?to=${encodeURIComponent(`/dashboard/invite/${code}`)}`); - return; - } else { - setStatus("error"); - setMessage(err.message); - } - } else { - setStatus("error"); - setMessage("Something went wrong. Please try again."); - } - }); + await acceptInvite(code); + setStatus("success"); + setMessage(dict.dashboard.invite.accepted); }; const inviteInvalid = !invite || invite.expired || invite.used || status === "success"; @@ -61,8 +49,9 @@ export default function InviteClient({ code, dict }: Props) {

{dict.dashboard.invite.title}

+ {invite?.organisation_name}{" "} {invite - ? dict.dashboard.invite.invited_by.replace("{org}", invite.organisation_name) + ? dict.dashboard.invite.invited_by : dict?.common?.loading ?? "Loading..."}

{/* Show the email the invite was sent to */} 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/invite/[inviteId]/page.tsx b/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/page.tsx deleted file mode 100644 index 5b254baea..000000000 --- a/frontend-nextjs/src/app/[lang]/dashboard/invite/[inviteId]/page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { getDictionary } from "@/app/[lang]/dictionaries"; -import { inviteQueryOptions } from "@/models/invite"; -import { HydrationBoundary, QueryClient, dehydrate } from "@tanstack/react-query"; -import { notFound } from "next/navigation"; -import InviteClient from "./invite-client"; - -type Params = Promise<{ lang: string; inviteId: string }>; - -export default async function Page({ params }: { params: Params }) { - const { lang, inviteId } = await params; - - const dict = await getDictionary(lang); - - const queryClient = new QueryClient(); - const ok = await queryClient - .prefetchQuery(inviteQueryOptions(inviteId)) - .then(() => true) - .catch(() => false); - - if (!ok) return notFound(); - - 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 7ff0a62ad..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 @@ -61,35 +61,24 @@ export function AddMemberDialog({ orgId, dict }: { orgId: string, dict: any }) { const [email, setEmail] = useState(""); const [open, setOpen] = useState(false); - const [status, setStatus] = useState<"idle" | "loading">("idle"); + const [loading, setLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const handleInviteMember = async () => { setErrorMessage(null); - const trimmed = email.trim(); - if (!trimmed) { + const normalisedEmail = email.trim().toLowerCase(); + if (!normalisedEmail) { setErrorMessage(dict?.common?.email_required ?? "Email is required."); return; } - setStatus("loading"); - try { - await inviteOrganisationUser(orgId, trimmed); - await queryClient.invalidateQueries({ queryKey: [`${orgId}-members`] }); - setEmail(""); - setOpen(false); - } catch (err) { - if (err instanceof ApiError) { - setErrorMessage(err.message); - } else if (err instanceof Error) { - setErrorMessage(err.message); - } else { - setErrorMessage("Failed to invite member."); - } - } finally { - setStatus("idle"); - } + setLoading(true); + await inviteOrganisationUser(orgId, normalisedEmail); + await queryClient.invalidateQueries({ queryKey: [`${orgId}-members`] }); + setEmail(""); + setOpen(false); + setLoading(false); } return ( @@ -112,9 +101,9 @@ export function AddMemberDialog({ orgId, dict }: { orgId: string, dict: any }) { diff --git a/frontend-nextjs/src/dictionaries/en.json b/frontend-nextjs/src/dictionaries/en.json index caf5bf8c2..756eec0e7 100644 --- a/frontend-nextjs/src/dictionaries/en.json +++ b/frontend-nextjs/src/dictionaries/en.json @@ -91,14 +91,14 @@ }, "invite": { "title": "You're invited", - "invited_by": "{org} has invited you to join their organisation on Chaos.", + "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." + "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", diff --git a/frontend-nextjs/src/dictionaries/zh.json b/frontend-nextjs/src/dictionaries/zh.json index 6d48fedb0..6e80f319f 100644 --- a/frontend-nextjs/src/dictionaries/zh.json +++ b/frontend-nextjs/src/dictionaries/zh.json @@ -90,15 +90,15 @@ "edit_template": "编辑模板" }, "invite": { - "title": "You're invited", - "invited_by": "{org} 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." + "title": "你被邀请了", + "invited_by": "邀请你加入他们的组织 on Chaos", + "sent_to": "此邀请发送给", + "login_cta": "点击这里创建 Chaos 账户", + "accept_cta": "接受邀请", + "used": "此邀请已被使用", + "expired": "此邀请已过期", + "accepted": "邀请已接受!你已加入组织", + "wrong_account": "此账户未被邀请,请退出并重新尝试" }, "actions": { "new": "新建", diff --git a/frontend-nextjs/src/lib/api.ts b/frontend-nextjs/src/lib/api.ts index 6c1f9c411..76c77c359 100644 --- a/frontend-nextjs/src/lib/api.ts +++ b/frontend-nextjs/src/lib/api.ts @@ -96,4 +96,4 @@ export async function apiRequest( } return (await response.json()) as T; -} \ No newline at end of file +} diff --git a/frontend-nextjs/src/models/invite.ts b/frontend-nextjs/src/models/invite.ts index c8431c130..72e56d721 100644 --- a/frontend-nextjs/src/models/invite.ts +++ b/frontend-nextjs/src/models/invite.ts @@ -1,5 +1,4 @@ import { apiRequest } from "@/lib/api"; -import type { QueryKey } from "@tanstack/react-query"; import { AppMessage } from "./app"; export type InviteDetails = { @@ -13,42 +12,15 @@ export type InviteDetails = { type InviteResponse = { message: InviteDetails }; -/** - * Gets the invite details for a given invite code. - * @param code - The invite code - * @returns The invite details - */ export async function getInvite(code: string): Promise { const res = await apiRequest(`/api/v1/invite/${code}`); return res.message; } -/** - * Accepts an invite for the current authenticated user. - * @param code - The invite code - * @returns The app message - */ export async function acceptInvite(code: string): Promise { return await apiRequest(`/api/v1/invite/${code}`, { method: "POST", }); } -/** - * Gets the query key for the invite. - * @param code - The invite code - * @returns The query key - */ -export const inviteQueryKey = (code: string): QueryKey => ["invite", code]; - -/** - * Gets the query options for the invite. - * @param code - The invite code - * @returns The query options - */ -export const inviteQueryOptions = (code: string) => ({ - queryKey: inviteQueryKey(code), - queryFn: () => getInvite(code), -}); -