Learn
Organizations

Organizations

Organizations are the foundation of multi-tenant applications in Optare. They provide complete data isolation and role-based access control for SaaS products.

Overview

An organization represents a group of users working together. Each organization has:

  • Members - Users with specific roles
  • Roles - Owner, Admin, or Member
  • Resources - OAuth clients, API keys, webhooks
  • Subscriptions - Product licenses with seat limits
  • Settings - Custom configuration and branding

Creating Organizations

Automatic Creation on Signup

Organizations can be created automatically when a user signs up:

import { auth } from "~/lib/auth";
 
// In your signup handler
const { user, session } = await auth.api.signUp({
  email: "founder@startup.com",
  password: "secure-password",
  name: "Jane Doe",
});
 
// Create organization for the user
const organization = await auth.api.organization.create({
  name: "Startup Inc",
  slug: "startup-inc",
  metadata: {
    industry: "Technology",
    size: "1-10",
  },
  headers: request.headers,
});
 
// Add user as owner
await auth.api.organization.addMember({
  organizationId: organization.id,
  userId: user.id,
  role: "owner",
  headers: request.headers,
});

Manual Organization Creation

Users can create organizations from your UI:

"use client";
 
import { authClient } from "~/lib/auth-client";
import { useState } from "react";
 
export function CreateOrganizationForm() {
  const [name, setName] = useState("");
  const [slug, setSlug] = useState("");
 
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
 
    const { data, error } = await authClient.organization.create({
      name,
      slug,
    });
 
    if (error) {
      console.error("Failed to create organization:", error);
      return;
    }
 
    console.log("Organization created:", data);
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Organization Name"
        value={name}
        onChange={(e) => setName(e.target.value)}
        required
      />
      <input
        type="text"
        placeholder="slug"
        value={slug}
        onChange={(e) => setSlug(e.target.value)}
        required
      />
      <button type="submit">Create Organization</button>
    </form>
  );
}

Organization Roles

Optare supports three built-in roles:

Owner

  • Full control over the organization
  • Can delete the organization
  • Can manage all members and roles
  • Can access billing and subscriptions
  • Limit: One owner per organization

Admin

  • Can manage members (except owner)
  • Can create and manage resources
  • Can view audit logs
  • Cannot delete organization
  • Cannot change owner

Member

  • Read-only access to organization
  • Can use licensed products
  • Cannot manage other members
  • Cannot create resources

Role Comparison

PermissionOwnerAdminMember
Delete organization✅❌❌
Manage owner✅❌❌
Manage admins✅✅❌
Manage members✅✅❌
Create OAuth clients✅✅❌
Create API keys✅✅❌
Create webhooks✅✅❌
View audit logs✅✅❌
Use products✅✅✅

Managing Members

Inviting Members

Send email invitations to new members:

import { auth } from "~/lib/auth";
 
export async function inviteMember(
  organizationId: string,
  email: string,
  role: "admin" | "member"
) {
  const invitation = await auth.api.createInvitation({
    body: {
      email,
      role,
      organizationId,
      resend: false, // Set to true to resend
    },
    headers: request.headers,
  });
 
  // Invitation email is sent automatically
  return invitation;
}

The invitation email contains a link like:

https://yourapp.com/invites/{invitationId}

Accepting Invitations

Create an invitation acceptance page:

// app/routes/invites.$token.tsx
import { auth } from "~/lib/auth";
import { redirect } from "@remix-run/node";
 
export async function loader({ params, request }: LoaderFunctionArgs) {
  const { token } = params;
  
  // Get invitation details
  const invitation = await auth.api.getInvitation({
    invitationId: token,
  });
 
  if (!invitation || invitation.status !== "pending") {
    throw new Response("Invalid or expired invitation", { status: 404 });
  }
 
  return json({ invitation });
}
 
export async function action({ params, request }: ActionFunctionArgs) {
  const { token } = params;
 
  // Accept the invitation
  await auth.api.acceptInvitation({
    invitationId: token,
    headers: request.headers,
  });
 
  return redirect("/dashboard");
}

Listing Members

Get all members of an organization:

import { db } from "~/lib/db";
import { organizationMembers, users } from "~/lib/db/schema";
import { eq } from "drizzle-orm";
 
export async function getOrganizationMembers(organizationId: string) {
  const members = await db
    .select({
      id: organizationMembers.id,
      role: organizationMembers.role,
      status: organizationMembers.status,
      joinedAt: organizationMembers.joinedAt,
      user: {
        id: users.id,
        name: users.name,
        email: users.email,
        image: users.image,
      },
    })
    .from(organizationMembers)
    .innerJoin(users, eq(organizationMembers.userId, users.id))
    .where(eq(organizationMembers.organizationId, organizationId))
    .orderBy(organizationMembers.joinedAt);
 
  return members;
}

Removing Members

Remove a member from an organization:

import { auth } from "~/lib/auth";
 
export async function removeMember(
  organizationId: string,
  memberId: string
) {
  await auth.api.organization.removeMember({
    organizationId,
    memberId,
    headers: request.headers,
  });
}

Updating Member Roles

Change a member's role:

import { db } from "~/lib/db";
import { organizationMembers } from "~/lib/db/schema";
import { eq } from "drizzle-orm";
 
export async function updateMemberRole(
  memberId: string,
  newRole: "owner" | "admin" | "member"
) {
  await db
    .update(organizationMembers)
    .set({ role: newRole })
    .where(eq(organizationMembers.id, memberId));
}

Organization Context

Getting Current Organization

Retrieve the user's current organization from session:

import { auth } from "~/lib/auth";
 
export async function loader({ request }: LoaderFunctionArgs) {
  const session = await auth.api.getSession({
    headers: request.headers,
  });
 
  if (!session) {
    throw redirect("/login");
  }
 
  // Get user's organization membership
  const membership = await db.query.organizationMembers.findFirst({
    where: eq(organizationMembers.userId, session.user.id),
    with: {
      organization: true,
    },
  });
 
  if (!membership) {
    throw redirect("/onboarding");
  }
 
  return json({
    user: session.user,
    organization: membership.organization,
    role: membership.role,
  });
}

Client-Side Organization Access

Access organization data in React components:

"use client";
 
import { useSession } from "~/lib/auth-client";
 
export function OrganizationHeader() {
  const { data: session } = useSession();
 
  if (!session?.organizationId) {
    return null;
  }
 
  return (
    <div>
      <h2>{session.organization.name}</h2>
      <p>Role: {session.role}</p>
    </div>
  );
}

Domain Verification

Enable automatic organization joining for verified email domains:

Adding a Domain

import { db } from "~/lib/db";
import { organizations } from "~/lib/db/schema";
import { eq } from "drizzle-orm";
 
export async function addDomain(
  organizationId: string,
  domain: string
) {
  const org = await db.query.organizations.findFirst({
    where: eq(organizations.id, organizationId),
  });
 
  const domains = org.domains || [];
  domains.push({
    domain,
    status: "pending",
    verificationToken: crypto.randomUUID(),
  });
 
  await db
    .update(organizations)
    .set({ domains })
    .where(eq(organizations.id, organizationId));
}

Verifying a Domain

Users add a TXT record to their DNS:

TXT record: optare-verification={verificationToken}

Then verify:

import { Resolver } from "dns/promises";
 
export async function verifyDomain(
  organizationId: string,
  domain: string
) {
  const org = await db.query.organizations.findFirst({
    where: eq(organizations.id, organizationId),
  });
 
  const domainConfig = org.domains.find((d) => d.domain === domain);
  if (!domainConfig) {
    throw new Error("Domain not found");
  }
 
  // Check DNS TXT record
  const resolver = new Resolver();
  const txtRecords = await resolver.resolveTxt(domain);
  
  const verified = txtRecords.some((record) =>
    record.includes(`optare-verification=${domainConfig.verificationToken}`)
  );
 
  if (verified) {
    // Update domain status
    const updatedDomains = org.domains.map((d) =>
      d.domain === domain ? { ...d, status: "verified" } : d
    );
 
    await db
      .update(organizations)
      .set({ domains: updatedDomains })
      .where(eq(organizations.id, organizationId));
  }
 
  return verified;
}

Auto-Join on Signup

When a user signs up with a verified domain email:

// In auth configuration (libs/auth/src/index.ts)
events: {
  async onSignUp({ user }) {
    const emailDomain = user.email.split('@')[1];
    if (!emailDomain) return;
 
    // Find organization with verified domain
    const org = await db.query.organizations.findFirst({
      where: sql`domains @> '[{"domain": ${emailDomain}, "status": "verified"}]'`,
    });
 
    if (org) {
      // Automatically add user as member
      await db.insert(organizationMembers).values({
        id: crypto.randomUUID(),
        organizationId: org.id,
        userId: user.id,
        role: 'member',
        status: 'active',
        joinedAt: new Date(),
      });
    }
  }
}

Organization Switching

Allow users to switch between multiple organizations:

"use client";
 
import { useState } from "react";
import { authClient } from "~/lib/auth-client";
 
export function OrganizationSwitcher({ organizations }) {
  const [currentOrg, setCurrentOrg] = useState(organizations[0]);
 
  const switchOrganization = async (orgId: string) => {
    // Update session with new organization
    await authClient.organization.setActive({
      organizationId: orgId,
    });
 
    // Reload to update UI
    window.location.reload();
  };
 
  return (
    <select
      value={currentOrg.id}
      onChange={(e) => switchOrganization(e.target.value)}
    >
      {organizations.map((org) => (
        <option key={org.id} value={org.id}>
          {org.name}
        </option>
      ))}
    </select>
  );
}

Best Practices

1. Always Check Organization Access

export async function loader({ request, params }: LoaderFunctionArgs) {
  const session = await auth.api.getSession({ headers: request.headers });
  
  const membership = await db.query.organizationMembers.findFirst({
    where: and(
      eq(organizationMembers.userId, session.user.id),
      eq(organizationMembers.organizationId, params.orgId)
    ),
  });
 
  if (!membership) {
    throw new Response("Forbidden", { status: 403 });
  }
 
  // User has access, continue...
}

2. Enforce Role Permissions

function requireRole(membership, allowedRoles: string[]) {
  if (!allowedRoles.includes(membership.role)) {
    throw new Response("Insufficient permissions", { status: 403 });
  }
}
 
// Usage
requireRole(membership, ["owner", "admin"]);

3. Use Organization Scoping

Always scope queries to the current organization:

// ✅ Good
const clients = await db
  .select()
  .from(oauthClients)
  .where(eq(oauthClients.organizationId, membership.organizationId));
 
// ❌ Bad - exposes all organizations
const clients = await db.select().from(oauthClients);

4. Handle Seat Limits

Check available seats before inviting:

export async function checkAvailableSeats(organizationId: string) {
  const subscription = await db.query.organizationSubscriptions.findFirst({
    where: eq(organizationSubscriptions.organizationId, organizationId),
  });
 
  if (!subscription) {
    throw new Error("No active subscription");
  }
 
  const availableSeats = subscription.totalSeats - subscription.seatsUsed;
  
  if (availableSeats <= 0) {
    throw new Error("No available seats. Please upgrade your plan.");
  }
 
  return availableSeats;
}

Edge Cases

User Leaves Last Organization

// Redirect to onboarding to create new organization
if (userOrganizations.length === 0) {
  return redirect("/onboarding");
}

Deleting Organization with Active Members

export async function deleteOrganization(organizationId: string) {
  // Check if user is owner
  if (membership.role !== "owner") {
    throw new Error("Only owners can delete organizations");
  }
 
  // Remove all members first
  await db
    .delete(organizationMembers)
    .where(eq(organizationMembers.organizationId, organizationId));
 
  // Delete organization (cascades to resources)
  await db
    .delete(organizations)
    .where(eq(organizations.id, organizationId));
}

Invitation Expiration

// Check if invitation is expired
const isExpired = new Date() > new Date(invitation.expiresAt);
 
if (isExpired) {
  throw new Error("This invitation has expired");
}