logoAffluent docs

Space members & invitation system

How space owners can manage their space members.

Overview

The space members and invitation system allows space owners to manage their space members and invite new users to join their space. This document outlines the technical implementation and workflows of this system.

System workflow

The following diagram illustrates the complete invitation flow from creation to space membership:

The diagram shows:

  • Green: Initial invite creation process
  • Orange: Core invite processing
  • Blue: Final state updates
  • Dotted lines: File references for key operations

Core components

Space members management

The space members management interface is implemented in two main components:

  1. app/[locale]/space/settings/space-members/page.tsx: Server component that handles data prefetching
  2. app/[locale]/space/settings/space-members/space-members-client.tsx: Client component that renders the UI

The management interface allows space owners to:

  • View all current space members
  • Add new members via email invitation
  • Remove existing members
  • View member roles and permissions
// Example: Space members client component structure
export function SpaceMembersClient() {
  // State for managing the add member dialog
  const [isAddMemberDialogOpen, setIsAddMemberDialogOpen] = useState(false);

  // Hooks for fetching space members data
  const { spaceMembers, isLoadingSpaceMembers } = useSpaceMembers();
  const currentUserMembership = useCurrentUserMembership();
  const isOwner = currentUserMembership?.role === SpaceMemberRole.Owner;

  return (
    <div>
      {/* Add member button (only visible to owners) */}
      {isOwner && <Button>Add Member</Button>}

      {/* Members table */}
      <SpaceMembersTable spaceMembers={spaceMembers} isOwner={isOwner} />

      {/* Add member dialog */}
      <AddMemberDialog />
    </div>
  );
}

Invitation workflow

The invitation system follows a multi-step workflow:

1. Invitation creation

Files involved:

  • app/[locale]/space/settings/space-members/components/add-member-dialog.tsx

    // Handles the UI for sending invites
    export function AddMemberDialog({ onInviteSent }) {
      const form = useForm<InviteFormData>();
      // Form for collecting invitee email and role
    }
  • app/lib/queries/invite.query.ts

    // Creates the invite in the database
    export async function createInvite({
      email,
      spaceId,
      role,
    }: CreateInviteParams): Promise<ApiResponse<Invite>> {
      // Generates invite code
      // Creates database record
      // Triggers email sending
    }
  • app/lib/actions/emails/send-invite-email.ts

    // Sends the invitation email
    export async function sendInviteEmail(
      inviteeEmail: string,
      inviteCode: string,
      inviterName: string
    ) {
      // Formats and sends email with invite link
    }

2. Invitation acceptance

Files involved:

  • app/api/accept-invite/route.ts

    export async function GET(request: NextRequest) {
      const inviteId = url.searchParams.get("invite_id");
    
      // Validates invite via Supabase RPC
      const { error } = await supabase.rpc("accept_invite", {
        invite_id: inviteId,
      });
    
      // Redirects to signup/login with invite context
      return NextResponse.redirect(
        new URL(`/signup?invite_id=${inviteId}`, request.url)
      );
    }
  • database/functions/accept_invite.sql

    -- Supabase stored procedure
    create or replace function accept_invite(invite_id uuid)
    returns void as $$
    begin
      -- Validates invite status
      -- Updates invite status to accepted
      -- Records acceptance timestamp
    end;
    $$ language plpgsql;

3. User authentication

Files involved:

  • app/[locale]/(auth)/signup/page.tsx

    export default async function SignUp({ searchParams }) {
      const inviteId = searchParams.invite_id;
    
      // Fetches invite details if present
      const invite = inviteId ? await getInvite(inviteId) : null;
    
      return <SignUpForm invite={invite} />;
    }
  • app/[locale]/(auth)/signup/components/sign-up-form.tsx

    export function SignUpForm({ invite, inviter }) {
      // Pre-fills form with invite data
      const form = useForm({
        defaultValues: {
          email: invite?.email || "",
          inviteCode: invite?.code || "",
        },
      });
    
      // Handles form submission with invite context
      const onSubmit = async (data) => {
        // Creates user account
        // Associates with space if invite present
      };
    }
  • app/[locale]/(auth)/login/components/sign-in-form.tsx

    export function SignInForm() {
      const { invite_id } = useSearchParams();
    
      // Handles login with invite context
      const handleSubmit = async (data) => {
        // Authenticates user
        // Processes pending invite if present
        if (invite_id) {
          await processInviteAfterAuth(invite_id);
        }
      };
    }

4. Space association (final Step)

Files involved:

  • app/lib/queries/space.query.ts
    // Associates user with space after successful auth
    export async function addSpaceMember({
      userId,
      spaceId,
      role,
    }: AddSpaceMemberParams): Promise<ApiResponse<null>> {
      // Creates space_member record
      // Sets up initial permissions
      // Sends welcome notification
    }

Each step includes validation, error handling, and appropriate user feedback. The system maintains data consistency through database transactions and handles edge cases like:

  • Expired invites
  • Already accepted invites
  • Invalid invite codes
  • Concurrent invite processing

Database operations

The system uses several key database queries (implemented in space.query.ts):

  1. Get space members
export async function getSpaceMembers(
  spaceId: string
): Promise<ApiResponse<SpaceMember[]>> {
  // Fetches all members of a space with their roles
}
  1. Remove space member
export async function removeSpaceMember(
  spaceGroupId: string,
  userId: string,
  spaceOwnerUserId?: string
): Promise<ApiResponse<null>> {
  // Removes member and handles cleanup
}

Authentication flow with invites

The authentication system (sign-in-form.tsx and sign-up-form.tsx) handles invite-specific logic:

  1. Sign up with invite

    • Form pre-fills email if provided in invite
    • Validates invite code
    • Creates user account
    • Associates user with space upon successful signup
  2. Sign in with invite

    • Checks for invite context
    • After successful login, processes pending invite
    • Adds user to space
// Example: Sign up form with invite handling
const form = useForm<z.infer<typeof signUpFormSchema>>({
  defaultValues: {
    email: invitee?.email || "",
    inviteCode: invite?.inviteCode || searchParams.get("inviteCode") || "",
  },
});

Security considerations

  1. Invite validation

    • Invites are single-use only
    • Invites expire after a configured time period
    • Invite codes are cryptographically secure
  2. Permission checks

    • Only space owners can send invites
    • Users can't accept invites for spaces they're already members of
    • Role-based access control for member management

Error handling

The system implements comprehensive error handling:

  1. Invalid invites

    • Shows clear error messages for expired/used invites
    • Redirects to appropriate error pages
  2. Rate limiting

    • Prevents spam by limiting invite creation
    • Implements cooldown periods for failed attempts
  3. Edge cases

    • Handles concurrent invite acceptances
    • Manages race conditions in member addition/removal
  • AddMemberDialog: UI for sending invites
  • SpaceMembersTable: Displays and manages members
  • InviteAcceptance: Handles invite processing
  • Email templates for invitations