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
| Permission | Owner | Admin | Member |
|---|---|---|---|
| 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");
}