npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@flyweightdev/convex-organizations

v0.1.10

Published

Convex component for organizations, RBAC, invitations, user profiles, device management, audit logging, and admin impersonation

Readme

@flyweightdev/convex-organizations

A Convex component for organizations, role-based access control, invitations, user profiles, device management, audit logging, and admin impersonation.

Built on top of Convex Auth. Works with Next.js, React, and Expo/React Native.

This project was created with the help of Claude Code (Opus 4.6) and reviewed by GPT-5.3-Codex, CodeRabbitAI and humans.

Why This Exists

Services like Clerk charge $80+/month just to unlock more than two organization roles. Better Auth requires polyfills, version-pinned dependencies, CLI-generated schemas, and unsupported plugins to get organizations working on Convex.

We wanted something simpler. Convex already has native auth with OTP, OAuth, passwords, and first-class Expo support. What it doesn't have is organizations, roles, invitations, and admin tooling. So we built that as a standalone Convex component you can pull into any app.

The result: install one package, register the component, export a few functions, and you have a full user/org management system with unlimited custom roles, audit logging, and admin impersonation — no external auth service required.

Features

  • Organizations — Create, update, soft-delete orgs with slugs, logos, and metadata
  • Role-Based Access Control — Per-org roles defined in a table with granular resource:action permissions. System roles seeded from config, custom roles created at runtime. Role hierarchy enforcement (can't promote above yourself)
  • Members — Invite, list, update roles, remove. Role hierarchy checks on every operation
  • Invitations — Email or phone invitations with cryptographic tokens, expiry, accept/decline flow. Auto-accept on signup
  • User Profiles — Synced from auth on login. Display name, avatar, metadata, active org tracking
  • Device Management — Track sessions with parsed user-agent info. Users can view and revoke their own devices
  • Audit Logging — Every mutation produces an audit entry with actor, effective user (for impersonation), resource, and metadata
  • Admin Impersonation — Actor/effective-user model. Admin stays authenticated as themselves, sees what the target user sees. No device pollution on the target. Full audit trail
  • Admin Dashboard Support — List all users/orgs, ban/unban, set platform admins, force-remove members, transfer ownership
  • Auth Providers — Pre-built Resend (email OTP, magic links) and Twilio (SMS OTP, Twilio Verify) providers
  • React Hooks — Headless hooks for profiles, orgs, members, roles, invitations, devices, audit logs, and impersonation
  • Expo / React Native — Same backend, same hooks, no extra packages

Prerequisites

You need a Convex project and at least one of these for OTP delivery:

| Service | What For | Env Variable(s) | | ------------------------------------------ | -------------------------------------------------------- | ---------------------------------------------------------------------- | | Resend | Email OTP, magic links | RESEND_API_KEY | | Twilio | SMS OTP (you generate code) | TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER | | Twilio Verify | SMS OTP (Twilio generates + validates code, recommended) | TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_VERIFY_SERVICE_SID |

Set these as server-side environment variables in the Convex Dashboard under Settings > Environment Variables.

Your frontend only needs the Convex deployment URL:

# Next.js
NEXT_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud

# Expo
EXPO_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud

Quick Start

1. Install the Component

npm install @flyweightdev/convex-organizations @convex-dev/auth

2. Add to Your Convex App

Create or update convex/convex.config.ts:

import { defineApp } from "convex/server";
import userOrg from "@flyweightdev/convex-organizations/convex.config.js";

const app = defineApp();
app.use(userOrg);

export default app;

3. Configure Auth with Providers

Create convex/auth.ts:

import { convexAuth } from "@convex-dev/auth/server";
import { ResendOTP, TwilioOTP } from "@flyweightdev/convex-organizations/providers";
import { createAuthCallbacks } from "@flyweightdev/convex-organizations";
import { components } from "./_generated/api";

export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
  providers: [ResendOTP({ appName: "MyApp", fromEmail: "[email protected]" }), TwilioOTP({ appName: "MyApp" })],
  callbacks: createAuthCallbacks(components.userOrg, {
    parseDeviceInfo: true,
  }),
});

The callbacks automatically sync user profiles and auto-accept pending invitations when a user signs up.

Note: The afterSessionCreated callback is exported but not called by Convex Auth — Convex Auth only supports the afterUserCreatedOrUpdated and redirect callbacks. Device registration must be done from the client. See Device Management below for the setup.

If you need custom logic in afterUserCreatedOrUpdated (e.g. casting profile fields, handling migration), wrap the base callbacks:

import { convexAuth } from "@convex-dev/auth/server";
import { ResendOTP } from "@flyweightdev/convex-organizations/providers";
import { createAuthCallbacks } from "@flyweightdev/convex-organizations";
import { components } from "./_generated/api";

const baseCallbacks = createAuthCallbacks(components.userOrg, {
  parseDeviceInfo: true,
  migrationLinking: true,
});

export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
  providers: [ResendOTP({ appName: "MyApp", fromEmail: "[email protected]" })],
  callbacks: {
    async afterUserCreatedOrUpdated(ctx, args) {
      await baseCallbacks.afterUserCreatedOrUpdated(ctx, {
        userId: args.userId,
        existingUserId: args.existingUserId ?? undefined,
        profile: {
          email: args.profile?.email as string | undefined,
          phone: args.profile?.phone as string | undefined,
          name: args.profile?.name as string | undefined,
        },
      });
      // Your custom logic here
    },
  },
});

4. Export the User/Org API

Create convex/userOrg.ts:

import { createUserOrgAPI } from "@flyweightdev/convex-organizations";
import { components } from "./_generated/api";

export const { getMyProfile, updateMyProfile, setActiveOrg, createOrg, getOrg, getOrgBySlug, updateOrg, deleteOrg, listMyOrgs, listRoles, createRole, updateRole, deleteRole, listMembers, getMyMembership, updateMemberRole, removeMember, leaveOrg, createInvitation, listInvitations, revokeInvitation, getInvitationByToken, acceptInvitation, declineInvitation, getCurrentSessionId, registerDevice, listMyDevices, removeDevice, removeAllOtherDevices, checkPermission, listAuditLogs } = createUserOrgAPI(components.userOrg, {
  roles: [
    { name: "owner", permissions: ["*"], sortOrder: 0, isSystem: true },
    { name: "admin", permissions: ["org:read", "org:write", "member:read", "member:invite", "member:manage", "member:remove", "role:read", "role:manage", "invitation:read", "invitation:manage", "audit:read"], sortOrder: 10, isSystem: true },
    { name: "member", permissions: ["org:read", "member:read", "role:read", "invitation:read"], sortOrder: 20, isSystem: true },
    { name: "viewer", permissions: ["org:read"], sortOrder: 30, isSystem: true },
  ],
  createPersonalOrg: false,
  invitationExpiryMs: 7 * 24 * 60 * 60 * 1000,
  impersonationTtlMs: 60 * 60 * 1000,
});

5. Export the Admin API

Create convex/admin.ts:

import { createAdminAPI } from "@flyweightdev/convex-organizations/admin";
import { components } from "./_generated/api";

export const { listAllUsers, getUserDetail, banUser, unbanUser, setAdmin, deleteUser, listAllOrgs, getOrgDetail, forceRemoveMember, transferOwnership, startImpersonation, stopImpersonation, getActiveImpersonation, listImpersonationHistory, listPlatformAuditLogs } = createAdminAPI(components.userOrg);

6. Set Up HTTP Routes

Create or update convex/http.ts:

import { httpRouter } from "convex/server";
import { auth } from "./auth";

const http = httpRouter();
auth.addHttpRoutes(http);

export default http;

7. Set Up the Schema

Create or update convex/schema.ts:

import { defineSchema } from "convex/server";
import { authTables } from "@convex-dev/auth/server";

export default defineSchema({
  ...authTables,
  // Add your own app tables here
});

Tip: Convex Auth writes fields like emailVerificationTime, phoneVerificationTime, and isAnonymous to the users table. If your app has a getCurrentUser query with a returns validator, include all auth-managed fields or it will throw a ReturnsValidationError:

const userValidator = v.object({
  _id: v.id("users"),
  _creationTime: v.number(),
  name: v.optional(v.string()),
  email: v.optional(v.string()),
  emailVerificationTime: v.optional(v.number()),
  phone: v.optional(v.string()),
  phoneVerificationTime: v.optional(v.number()),
  isAnonymous: v.optional(v.boolean()),
  // your app-specific fields...
});

8. Add the Auth and Org Providers

Next.js

Important: Do not use ConvexAuthProvider from @convex-dev/auth/react in Next.js apps that use middleware auth protection (e.g. convexAuthNextjsMiddleware). That provider stores tokens only in localStorage, so isAuthenticated() in middleware always returns false, causing an infinite redirect loop after login.

Use the Next.js-specific providers from @convex-dev/auth/nextjs:

// app/layout.tsx (server component)
import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <ConvexAuthNextjsServerProvider>
          {children}
        </ConvexAuthNextjsServerProvider>
      </body>
    </html>
  );
}
// app/providers.tsx (client component)
"use client";

import { ConvexAuthNextjsProvider } from "@convex-dev/auth/nextjs";
import { ConvexReactClient } from "convex/react";
import { UserOrgProvider } from "@flyweightdev/convex-organizations/react";
import { api } from "../convex/_generated/api";

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);

export default function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ConvexAuthNextjsProvider client={convex}>
      <UserOrgProvider
        api={api.userOrg}
        adminApi={api.admin}>
        {children}
      </UserOrgProvider>
    </ConvexAuthNextjsProvider>
  );
}

React (Vite, CRA, etc.)

"use client";

import { ConvexAuthProvider } from "@convex-dev/auth/react";
import { ConvexReactClient } from "convex/react";
import { UserOrgProvider } from "@flyweightdev/convex-organizations/react";
import { api } from "../convex/_generated/api";

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);

export default function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ConvexAuthProvider client={convex}>
      <UserOrgProvider
        api={api.userOrg}
        adminApi={api.admin}>
        {children}
      </UserOrgProvider>
    </ConvexAuthProvider>
  );
}

9. Start Using It

import { useUser, useActiveOrganization, useOrganizationList } from "@flyweightdev/convex-organizations/react";

function Dashboard() {
  const { profile } = useUser();
  const { organization, role, hasPermission } = useActiveOrganization();
  const { organizations, setActive } = useOrganizationList();

  if (!profile) return <SignIn />;

  return (
    <div>
      <p>Welcome, {profile.displayName}</p>
      {organization && <p>Current org: {organization.name} ({role?.name})</p>}
      {hasPermission("member:invite") && <InviteButton />}
    </div>
  );
}

Auth Providers

The package exports pre-built auth provider factories for use with @convex-dev/auth.

Email OTP (Resend)

Sends a numeric verification code via Resend. Convex Auth generates and validates the code.

import { ResendOTP } from "@flyweightdev/convex-organizations/providers";

ResendOTP({ appName: "MyApp", fromEmail: "[email protected]" });

Requires: RESEND_API_KEY

Email Magic Link (Resend)

Sends a sign-in link via Resend.

import { ResendMagicLink } from "@flyweightdev/convex-organizations/providers";

ResendMagicLink({ appName: "MyApp", fromEmail: "[email protected]" });

Requires: RESEND_API_KEY

Phone OTP (Twilio)

Sends an OTP via Twilio SMS. Convex Auth generates the code, Twilio delivers it.

import { TwilioOTP } from "@flyweightdev/convex-organizations/providers";

TwilioOTP({ appName: "MyApp" });

Requires: TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER

Roles and Permissions

Roles are stored per organization in the orgRoles table. When an org is created, system roles are seeded from your config. Org admins can create additional custom roles at runtime.

Permission Strings

Permissions follow a resource:action convention:

| Permission | Description | | ------------------- | ------------------------------------- | | org:read | View org details | | org:write | Update org name, slug, logo, metadata | | org:delete | Delete the organization | | member:read | View member list | | member:invite | Send invitations | | member:manage | Change member roles | | member:remove | Remove members | | role:read | View available roles | | role:manage | Create, update, delete custom roles | | invitation:read | View pending invitations | | invitation:manage | Revoke invitations | | audit:read | View audit logs |

The wildcard "*" grants all permissions (used by the owner role).

Role Hierarchy

Each role has a sortOrder. Lower values mean higher authority. A member can only assign or modify roles with a sortOrder greater than or equal to their own — you can't promote someone above yourself. The owner role has sortOrder: 0.

Custom Roles

Define system roles in your config (seeded on org creation, cannot be deleted by users). Org admins with role:manage permission can create additional non-system roles at runtime:

const createRole = useCreateRole();

await createRole({
  orgId,
  name: "billing-admin",
  description: "Can manage billing settings",
  permissions: ["org:read", "billing:read", "billing:manage"],
  sortOrder: 15,
});

Invitations

Invite users by email or phone number. The component generates a cryptographic token (stored hashed), returns it once, and tracks invitation status.

const createInvitation = useCreateInvitation();

// Invite by email
const { token } = await createInvitation({
  orgId,
  email: "[email protected]",
  roleId: memberRoleId,
});

// Invite by phone
const { token } = await createInvitation({
  orgId,
  phone: "+14155551234",
  roleId: memberRoleId,
});

When a user signs up with a matching email or phone, the auth callbacks automatically accept pending invitations for that user.

Invitations have a configurable expiry (default 7 days) and can be revoked by org admins.

Device Management

Note: Convex Auth does not call afterSessionCreated, so the parseDeviceInfo option in createAuthCallbacks does not automatically register devices. Use the built-in registerDevice mutation from the client instead.

Registering Devices

The registerDevice mutation is included in createUserOrgAPI() output. It extracts the userId and sessionId from the auth identity, parses the user-agent string, and registers the device — no boilerplate needed.

Call it once on app load from the client:

import { useMutation, useQuery } from "convex/react";
import { useRef, useEffect } from "react";
import { api } from "../convex/_generated/api";

function useRegisterDevice() {
  const registerDevice = useMutation(api.userOrg.registerDevice);
  const profile = useQuery(api.userOrg.getMyProfile);
  const registered = useRef(false);

  useEffect(() => {
    if (registered.current || !profile) return;
    registered.current = true;
    registerDevice({ userAgent: navigator.userAgent }).catch(() => {});
  }, [profile, registerDevice]);
}

Viewing and Revoking Devices

const { devices, currentDevice } = useDevices();
const removeDevice = useRemoveDevice();
const removeAllOtherDevices = useRemoveAllOtherDevices();

// Show all devices
devices.map(device => (
  <div key={device._id}>
    {device.deviceName} — {device.browser} on {device.os}
    {device._id !== currentDevice?._id && (
      <button onClick={() => removeDevice({ deviceId: device._id })}>
        Revoke
      </button>
    )}
  </div>
));

// Revoke all other sessions
await removeAllOtherDevices();

When a device is removed, the mutation returns the sessionId (or sessionIds for bulk removal). Your app must invalidate the corresponding auth session(s) on the host side using your auth provider's API. The component tracks devices but cannot directly invalidate auth tokens.

const removeDevice = useRemoveDevice();
const { sessionId } = await removeDevice({ deviceId });
// Invalidate sessionId with your auth provider

const removeAll = useRemoveAllOtherDevices();
const { sessionIds } = await removeAll({ currentSessionId });
// Invalidate each sessionId with your auth provider

Audit Logging

Every mutation in the component writes an audit log entry. Entries include:

  • actorUserId — who performed the action
  • effectiveUserId — the impersonated user (if applicable)
  • action — what happened (org.created, member.role_changed, invitation.accepted, etc.)
  • resourceType and resourceId — what was affected
  • metadata — action-specific payload (old/new values, etc.)
  • timestamp

Query audit logs with the audit:read permission:

const { logs, loadMore } = useAuditLogs(orgId, {
  action: "member.*",
  limit: 50,
});

Platform admins can query cross-org audit logs via listPlatformAuditLogs.

Audit Actions

| Action | Description | | ----------------------- | ----------------------------- | | org.created | Organization created | | org.updated | Organization settings changed | | org.deleted | Organization soft-deleted | | member.added | Member joined the org | | member.removed | Member removed from org | | member.role_changed | Member's role updated | | member.left | Member left the org | | invitation.created | Invitation sent | | invitation.accepted | Invitation accepted | | invitation.declined | Invitation declined | | invitation.revoked | Invitation revoked | | role.created | Custom role created | | role.updated | Role permissions changed | | role.deleted | Custom role deleted | | device.registered | New device registered | | device.removed | Device revoked | | device.revoked_all | All other devices revoked | | profile.updated | User profile updated | | profile.banned | User banned | | profile.unbanned | User unbanned | | impersonation.started | Admin started impersonating | | impersonation.ended | Admin stopped impersonating |

Data Retention & Soft Deletion

Both users and organizations are soft-deleted — they are marked for deletion but retained for a 7-day grace period before permanent removal. This allows admins to inspect recently deleted accounts and provides a window for recovery if needed.

How It Works

Users — When a user is deleted (via deleteUser or the admin API):

  • The profile is marked with a deletedAt timestamp
  • Memberships and devices are removed immediately (they affect active org operations)
  • The profile record remains in the database for 7 days
  • During this period, the user cannot log in or be looked up via normal queries
  • Admin queries (listAllUsers, getUserDetail) still show the deleted user with its deletedAt timestamp

Organizations — When an org is deleted (via deleteOrg):

  • The org status is set to "deleted" and deletedAt is recorded
  • Members, roles, and invitations remain in the database for 7 days
  • The org no longer appears in member-facing queries
  • Admin queries continue to show the deleted org

Automatic Purge

A daily cron job permanently removes data past the retention period:

| Cron | Schedule | What It Purges | | ------------------------------- | ------------------ | ------------------------------------------------------------------------------------ | | purge deleted users | Daily at 03:00 UTC | User profiles where deletedAt is older than 7 days | | purge deleted orgs | Daily at 03:30 UTC | Deleted orgs (+ their roles, memberships, invitations, audit logs) older than 7 days | | expire impersonation sessions | Hourly | Active impersonation sessions past their TTL |

What Gets Purged

When a user is purged: the profile record is permanently deleted.

When an org is purged: the organization record, all its roles, remaining memberships, invitations, and associated audit logs are permanently deleted.

Admin and Impersonation

Admin Functions

Users with isAdmin: true on their profile can access platform-level admin functions:

import { createAdminAPI } from "@flyweightdev/convex-organizations/admin";

// List all users (paginated, searchable)
const users = await listAllUsers({ cursor, limit: 50, search: "alice" });

// Get full user detail (profile + orgs + devices)
const detail = await getUserDetail({ targetUserId });

// Ban/unban users
await banUser({ targetUserId, reason: "Violation of terms" });
await unbanUser({ targetUserId });

// Force-remove a member (bypasses role hierarchy)
await forceRemoveMember({ orgId, targetUserId });

// Transfer org ownership
await transferOwnership({ orgId, newOwnerUserId });

Impersonation

The impersonation model uses an actor/effective-user approach. The admin stays authenticated as themselves — no session swapping, no token juggling:

const { isImpersonating, targetUser, startImpersonation, stopImpersonation } = useImpersonation();

// Start impersonating
await startImpersonation(targetUserId, "Debugging user's billing issue");

// Now all hooks (useUser, useActiveOrganization, useDevices, etc.)
// automatically resolve data for the target user.

// Stop impersonating
await stopImpersonation();

How it works:

  1. Admin calls startImpersonation — creates a time-limited impersonation session (default 1 hour)
  2. On every subsequent request, the wrapper factory resolves the effectiveUserId from the active impersonation session
  3. Queries return the target user's data. Mutations execute as the target user but the audit log records both actorUserId (admin) and effectiveUserId (target)
  4. Device registration is skipped during impersonation — the admin's device never appears in the target user's device list
  5. An admin cannot impersonate another admin

Expo / React Native

The component works with Expo out of the box. Same backend, same hooks, no extra packages beyond expo-secure-store.

// app/_layout.tsx
import { ConvexAuthProvider } from "@convex-dev/auth/react";
import { ConvexReactClient } from "convex/react";
import { UserOrgProvider } from "@flyweightdev/convex-organizations/react";
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
import { api } from "../convex/_generated/api";

const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!, {
  unsavedChangesWarning: false,
});

const secureStorage = {
  getItem: SecureStore.getItemAsync,
  setItem: SecureStore.setItemAsync,
  removeItem: SecureStore.deleteItemAsync,
};

export default function RootLayout() {
  return (
    <ConvexAuthProvider
      client={convex}
      storage={Platform.OS !== "web" ? secureStorage : undefined}>
      <UserOrgProvider api={api.userOrg}>
        <Slot />
      </UserOrgProvider>
    </ConvexAuthProvider>
  );
}

Phone OTP sign-in is identical on web and mobile — two-step flow (enter phone, then enter code) using useAuthActions().signIn("twilio-verify", formData).

React Hooks

All hooks are headless (no UI components) and work on both web and React Native.

| Hook | Returns | | ------------------------------- | -------------------------------------------------------------------------------------- | | useUser() | { profile, isLoading } | | useUpdateProfile() | Mutation to update display name, avatar, metadata | | useOrganizationList() | { organizations, isLoading } + setActive, create | | useActiveOrganization() | { organization, membership, role, hasPermission, setActive, isLoading } | | useMembers(orgId) | { members, isLoading } | | useUpdateMemberRole() | Mutation to change a member's role | | useRemoveMember() | Mutation to remove a member | | useLeaveOrg() | Mutation to leave an org | | useRoles(orgId) | { roles, isLoading } | | useCreateRole() | Mutation to create a custom role | | useUpdateRole() | Mutation to update a role's permissions | | useDeleteRole() | Mutation to delete a custom role | | useInvitations(orgId) | { invitations, isLoading } | | useCreateInvitation() | Mutation to send an invitation | | useRevokeInvitation() | Mutation to revoke an invitation | | useAcceptInvitation() | Mutation to accept an invitation | | useDeclineInvitation() | Mutation to decline an invitation | | useCurrentSessionId() | Current session ID (string or null) | | useDevices() | { devices, currentDevice, isLoading }currentDevice auto-resolved via session ID | | useRemoveDevice() | Mutation to revoke a device (returns sessionId for host-side invalidation) | | useRemoveAllOtherDevices() | Mutation to revoke all other devices (returns sessionIds for host-side invalidation) | | useAuditLogs(orgId, filters?) | { logs, isLoading, loadMore } | | useImpersonation() | { isImpersonating, targetUser, startImpersonation, stopImpersonation } |

Database Schema

The component creates these tables in its own isolated namespace (separate from your app's tables):

organizations

| Field | Type | Description | | ------------ | -------- | ------------------------------------------------- | | name | string | Display name | | slug | string | URL-safe unique identifier | | logoUrl | string? | Logo URL | | metadata | any? | App-specific JSON | | createdBy | string | userId of creator | | isPersonal | boolean? | Auto-created 1:1 org per user | | status | string | "active", "suspended", or "deleted" | | deletedAt | number? | Timestamp when soft-deleted (for retention purge) |

orgRoles

| Field | Type | Description | | ------------- | -------- | ----------------------------- | | orgId | id | Organization reference | | name | string | Role name | | description | string? | Human-readable description | | permissions | string[] | Permission strings | | isSystem | boolean | System roles can't be deleted | | sortOrder | number | Lower = higher authority |

orgMembers

| Field | Type | Description | | ----------- | ------- | ---------------------- | | orgId | id | Organization reference | | userId | string | Host auth user ID | | roleId | id | Role reference | | joinedAt | number | Timestamp | | invitedBy | string? | userId who invited |

invitations

| Field | Type | Description | | ----------- | ------- | ----------------------------------------------------------------- | | orgId | id | Organization reference | | email | string? | Invite target email | | phone | string? | Invite target phone | | roleId | id | Role to assign on accept | | invitedBy | string | userId | | status | string | "pending", "accepted", "declined", "expired", "revoked" | | token | string | Hashed cryptographic token | | expiresAt | number | Expiry timestamp |

userProfiles

| Field | Type | Description | | ------------- | ------- | ------------------------------------------------- | | userId | string | Host auth user ID (unique) | | email | string? | Synced from auth | | phone | string? | Synced from auth | | displayName | string? | Display name | | avatarUrl | string? | Avatar URL | | metadata | any? | App-specific data | | activeOrgId | id? | Currently selected org | | isBanned | boolean | Ban flag | | isAdmin | boolean | Platform super-admin flag | | deletedAt | number? | Timestamp when soft-deleted (for retention purge) |

userDevices

| Field | Type | Description | | -------------- | ------- | -------------------------------------------- | | userId | string | User reference | | sessionId | string | Maps to host authSessions._id | | deviceName | string? | Parsed device name | | deviceType | string? | "web", "mobile", "tablet", "desktop" | | browser | string? | Parsed from user-agent | | os | string? | Parsed from user-agent | | ipAddress | string? | IP hint | | lastActiveAt | number | Last activity timestamp | | createdAt | number | First seen timestamp |

impersonationSessions

| Field | Type | Description | | -------------- | ------- | ---------------------------------- | | adminUserId | string | Admin performing impersonation | | targetUserId | string | User being impersonated | | reason | string? | Justification | | startedAt | number | Start timestamp | | expiresAt | number | TTL expiry | | endedAt | number? | When stopped | | status | string | "active", "expired", "ended" |

auditLogs

| Field | Type | Description | | ----------------- | ------- | -------------------------------------------------- | | orgId | id? | Organization (null for platform-level actions) | | actorUserId | string | Who performed the action | | effectiveUserId | string? | Impersonated user (if applicable) | | action | string | Action name (e.g., "member.role_changed") | | resourceType | string | Resource type (e.g., "member", "organization") | | resourceId | string? | Affected resource ID | | metadata | any? | Action-specific payload | | ipAddress | string? | IP hint | | timestamp | number | When it happened |

Configuration Reference

createUserOrgAPI(component, config)

| Option | Type | Default | Description | | -------------------- | -------------- | -------------------- | ----------------------------------------- | | roles | RoleConfig[] | Required | System roles seeded on org creation | | createPersonalOrg | boolean | false | Auto-create a personal org on user signup | | invitationExpiryMs | number | 604800000 (7 days) | Invitation token expiry | | impersonationTtlMs | number | 3600000 (1 hour) | Impersonation session TTL |

RoleConfig

| Field | Type | Description | | ------------- | ---------- | -------------------------- | | name | string | Role name | | description | string? | Human-readable description | | permissions | string[] | Permission strings | | sortOrder | number | Lower = higher authority | | isSystem | boolean | Cannot be deleted by users |

createAuthCallbacks(component, config)

| Option | Type | Default | Description | | ------------------ | --------- | ------- | ----------------------------------------------------------------- | | parseDeviceInfo | boolean | false | Parse user-agent into device info (used with client-side registration) | | migrationLinking | boolean | false | Remap temporary userId to real userId (for Clerk → Convex Auth migration) |

Host App File Structure

After integration, your Convex directory looks like this:

convex/
├── convex.config.ts         # app.use(userOrg)
├── schema.ts                # ...authTables, ...appTables
├── auth.ts                  # convexAuth({ providers, callbacks })
├── http.ts                  # auth.addHttpRoutes(http)
├── userOrg.ts               # createUserOrgAPI(components.userOrg, config)
├── admin.ts                 # createAdminAPI(components.userOrg)
└── _generated/

No subdirectories. No polyfills. No adapters. No CLI schema generation.

Common Patterns

Convex Auth identity.subject Encoding

Convex Auth encodes identity.subject as "userId|sessionId". This library splits on | internally (fixed in v0.1.8). If you write custom queries against the component's internal tables, always split the subject:

const identity = await ctx.auth.getUserIdentity();
const [userId, sessionId] = identity.subject.split("|");

Minimum required version: v0.1.8. Earlier versions used the raw identity.subject as the userId, which caused membership lookups to fail.

Getting the Current Session ID

To identify "this device" in the device list, use the built-in getCurrentSessionId query (included in createUserOrgAPI output):

// Already exported from your userOrg.ts:
// export const { getCurrentSessionId, listMyDevices, ... } = createUserOrgAPI(...);

// In your component:
const currentSessionId = useQuery(api.userOrg.getCurrentSessionId);
const devices = useQuery(api.userOrg.listMyDevices);
const currentDevice = devices?.find(d => d.sessionId === currentSessionId);

Or use the useCurrentSessionId React hook directly. The useDevices hook also auto-resolves the current device — no need to pass currentSessionId manually:

import { useDevices, useCurrentSessionId } from "@flyweightdev/convex-organizations/react";

const { devices, currentDevice } = useDevices(); // currentDevice auto-resolved
const sessionId = useCurrentSessionId(); // if you need the raw ID

Checking Workspace/Org Membership in Your Own Functions

The README shows how to create orgs and list members, but your app likely needs to gate access in its own queries and mutations. Use the component's internal query to check membership:

// convex/helpers.ts
import { components } from "./_generated/api";

export async function requireOrgAccess(
  ctx: any,
  orgId: string,
): Promise<{ userId: string; role: string }> {
  const identity = await ctx.auth.getUserIdentity();
  if (!identity) throw new Error("Not authenticated");
  const [userId] = identity.subject.split("|");

  const membership = await ctx.runQuery(
    components.userOrg.lib.getMembershipQuery,
    { userId, orgId },
  );
  if (!membership) throw new Error("Not a member of this organization");
  return { userId, role: membership.role.name };
}

Syncing Profile Updates to the Component

When users update their name or other profile fields in your host app, sync the changes to the component so the member list stays current:

// After patching the user record in your app:
await ctx.runMutation(components.userOrg.lib.syncUser, {
  userId,
  email: user.email,
  name: user.name,
});

Authentication

This component is designed for Convex Auth but the component itself is auth-agnostic — it only receives userId strings. If you use a different auth provider (Clerk, Auth0, etc.), you can still use the component by wiring up the userId yourself in the wrapper factory.

License

Apache-2.0