# better-auth-organization-member

A Better Auth plugin that extends the organization plugin with additional member management capabilities.

## Features

- **Update Member Endpoint**: `/organization/update-member` - Update member information including role AND additional fields (firstName, lastName, avatar, and any custom fields)
- **Automatic Hook Injection**: Automatically adds `afterAcceptInvitation` hook to the organization plugin to transfer invitation data from invitations to member records
- **Before/After Update Hooks**: Lifecycle hooks for member updates
- **Full Type Inference**: Automatically infers member additional fields from organization plugin schema

## Requirements

- `better-auth` >= 1.4.9
- `organization` plugin must be enabled

## Installation

```sh
npm install better-auth-organization-member
// or yarn
// yarn add better-auth-organization-member
```

## Usage

### Server Setup

Simply add the plugin after the organization plugin. **No manual hooks required!**

```typescript
import { betterAuth } from 'better-auth';
import { organization } from 'better-auth/plugins';
import { organizationMember } from 'better-auth-organization-member';

export const auth = betterAuth({
  // ... other config
  plugins: [
    organization({
      // organization config
      schema: {
        member: {
          additionalFields: {
            firstName: { type: 'string', input: true, required: false },
            lastName: { type: 'string', input: true, required: false },
            avatar: { type: 'string', input: true, required: false },
          },
        },
        invitation: {
          additionalFields: {
            firstName: { type: 'string', input: true, required: false },
            lastName: { type: 'string', input: true, required: false },
            avatar: { type: 'string', input: true, required: false },
          },
        },
      },
    }),
    // This plugin automatically injects the afterAcceptInvitation hook
    organizationMember({
      schema: {
        member: {
          additionalFields: {
            firstName: { type: 'string', input: true, required: false },
            lastName: { type: 'string', input: true, required: false },
            avatar: { type: 'string', input: true, required: false },
          },
        }
      },
      organizationMemberHooks: {
        // Optional: Hook before member update
        async beforeUpdateMember(data) {
          console.log('Updating member:', data.member.id);
          // You can modify the updates here
          return {
            data: {
              ...data.updates,
              // Add custom logic
            },
          };
        },
        // Optional: Hook after member update
        async afterUpdateMember(data) {
          console.log('Member updated:', data.member.id);
        },
      },
    }),
  ],
});
```

### What Happens Automatically

When the `organizationMember` plugin is added:

1. ✅ It automatically injects an `afterAcceptInvitation` hook into the organization plugin
2. ✅ When an invitation is accepted, the hook transfers `firstName`, `lastName`, and `avatar` from the invitation to the member record
3. ✅ No need to manually add hooks in your provider configuration!

### Client Usage

```typescript
import { createAuthClient } from 'better-auth/client';
import { organizationClient } from 'better-auth/client/plugins';
import { organizationMemberClient, inferOrgMemberAdditionalFields } from 'better-auth-organization-member/client';
import type { auth } from './auth'; // import the auth object type only

const client = createAuthClient({
  plugins: [
    organizationClient({
      schema: inferOrgMemberAdditionalFields<typeof auth>()
    }),
    organizationMemberClient({
      schema: inferOrgMemberAdditionalFields<typeof auth>()
    })
  ],
});
```

Or if you can't import the auth object type, you can pass the schema directly:

```typescript
import { createAuthClient } from 'better-auth/client';
import { organizationClient } from 'better-auth/client/plugins';
import { organizationMemberClient } from 'better-auth-organization-member/client';

const client = createAuthClient({
  plugins: [
    organizationClient({
      schema: {
        member: {
          additionalFields: {
            firstName: { type: 'string', input: true, required: false },
            lastName: { type: 'string', input: true, required: false },
            avatar: { type: 'string', input: true, required: false },
          },
        },
        invitation: {
          additionalFields: {
            firstName: { type: 'string', input: true, required: false },
            lastName: { type: 'string', input: true, required: false },
            avatar: { type: 'string', input: true, required: false },
          },
        },
      },
    }),
    organizationMemberClient({
      schema: {
        member: {
          additionalFields: {
            firstName: { type: 'string', input: true, required: false },
            lastName: { type: 'string', input: true, required: false },
            avatar: { type: 'string', input: true, required: false },
          },
        },
        invitation: {
          additionalFields: {
            firstName: { type: 'string', input: true, required: false },
            lastName: { type: 'string', input: true, required: false },
            avatar: { type: 'string', input: true, required: false },
          },
        },
      },
    }),
  ],
});

// Update member information (role + additional fields)
await client.organization.updateMember({
  memberId: 'member-id',
  data: {
    role: 'admin', // Can update role
    firstName: 'John',
    lastName: 'Doe',
    avatar: 'https://example.com/avatar.jpg',
    // any other custom fields defined in your member schema
  },
  fetchOptions: {
    headers: {
      'X-Custom-Header': 'value',
    },
  },
});

// Update invitation information
await client.organization.updateInvitation({
  invitationId: 'invitation-id',
  data: {
    role: 'admin', // Can update role
    firstName: 'John',
    lastName: 'Doe',
    avatar: 'https://example.com/avatar.jpg',
    // any other custom fields defined in your invitation schema
  },
  fetchOptions: {
    headers: {
      'X-Custom-Header': 'value',
    },
  },
});

// List invitations with filtering and sorting (overrides original listInvitations)
const result = await client.organization.listInvitations({
  query: {
    organizationId: 'org-id', // optional
    limit: 10,
    offset: 0,
    sortBy: 'createdAt',
    sortDirection: 'desc',
    filterField: 'status',
    filterValue: 'pending',
    filterOperator: 'eq',
  },
  fetchOptions: {
    headers: {
      'X-Custom-Header': 'value',
    },
  },
});
// Response: { invitations: [...], total: number }
```

## API

### Endpoint: `/organization/update-member`

Updates member information in an organization. Follows the **exact same permission logic** as `/organization/update-member-role` but accepts additional fields.

**Method**: `POST`

**Body**:
```typescript
{
  memberId: string;                    // Required: ID of the member to update
  organizationId?: string;             // Optional: defaults to active organization
  data: {
    role?: string | string[];          // Optional: Role(s) to assign
    // ... any additional fields defined in member.additionalFields
    // Examples: firstName, lastName, avatar, etc.
  };
  fetchOptions?: BetterFetchOption;   // Optional: fetch options (headers, etc.)
}
```

**Response**:
```typescript
{
  id: string;
  userId: string;
  organizationId: string;
  role: string;
  user: {
    id: string;
    email: string;
    name: string | null;
    image: string | null;
  };
  // ... all additional fields
}
```

**Permissions**: Same as `updateMemberRole` - requires `member:update` permission or owner/admin role.

**Role Logic** (same as `updateMemberRole`):
- ✅ Owners can update any member
- ✅ Admins with `member:update` permission can update members
- ❌ Non-creators cannot update creators
- ❌ Last owner cannot demote themselves
- ❌ Same permission checks as the built-in role update

### Endpoint: `/organization/list-invitations`

Lists invitations in an organization with filtering, sorting, and pagination support. **This endpoint overrides the original `listInvitations` endpoint** from the organization plugin.

**Method**: `GET`

**Query Parameters**:
```typescript
{
  organizationId?: string;             // Optional: defaults to active organization
  organizationSlug?: string;           // Optional: organization slug instead of ID
  limit?: number;                      // Optional: number of invitations to return (default: 100)
  offset?: number;                     // Optional: offset to start from (default: 0)
  sortBy?: string;                     // Optional: field to sort by
  sortDirection?: "asc" | "desc";      // Optional: sort direction (default: "asc")
  filterField?: string;                // Optional: field to filter by
  filterValue?: string | number | boolean; // Optional: value to filter by
  filterOperator?: "eq" | "ne" | "lt" | "lte" | "gt" | "gte" | "contains"; // Optional: filter operator
}
```

**Response**:
```typescript
{
  invitations: Invitation[];  // Array of invitations
  total: number;              // Total count of invitations (before pagination)
}
```

**Permissions**: Requires membership in the organization.

**Features**:
- ✅ **Filtering**: Filter by any field using operators (`eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `contains`)
- ✅ **Sorting**: Sort by any field in ascending or descending order
- ✅ **Pagination**: Support for `limit` and `offset`
- ✅ **Organization support**: Can filter by `organizationId` or `organizationSlug`
- ✅ **Same response format as `listMembers`**: Returns `{ invitations, total }` for consistency

## Hooks

### `beforeUpdateMember`

Called before a member's information is updated. Can modify the update data.

```typescript
beforeUpdateMember?: (data: {
  member: Member;
  updates: Record<string, any>;
  user: User;
  organization: Organization;
}) => Promise<void | { data: Record<string, any> }>;
```

### `afterUpdateMember`

Called after a member's information is updated.

```typescript
afterUpdateMember?: (data: {
  member: Member;
  previousData: Record<string, any>;
  user: User;
  organization: Organization;
}) => Promise<void>;
```

### Automatic `afterAcceptInvitation` Hook

This hook is **automatically injected** into the organization plugin when you add `organizationMember()`. It:

1. Extracts **all additional fields** from `invitation.additionalFields` 
2. Automatically transfers them to the newly created member record
3. Works with any custom fields you define in both invitation and member schemas
4. Logs the update (or errors if they occur)
5. Does not fail the invitation acceptance if the update fails

## Comparison with `updateMemberRole`

### Built-in `updateMemberRole` (organization plugin)

```typescript
// Only updates the role field
await client.organization.updateMemberRole({
  memberId: 'member-id',
  role: 'admin',
});
```

### This Plugin's `updateMember`

```typescript
// Updates role AND additional fields in one call
await client.organization.updateMember({
  memberId: 'member-id',
  data: {
    role: 'admin',                                    // ✅ Can update role
    firstName: 'John',                                // ✅ Plus additional fields
    lastName: 'Doe',
    avatar: 'https://example.com/avatar.jpg',
    customField: 'value',                             // ✅ Plus any custom fields
  },
});
```

**Key Features:**
- ✅ **Exact same permission logic** as `updateMemberRole` (identical role checks)
- ✅ **Same validation** for role updates (creator protection, owner checks, etc.)
- ✅ **Additional field support** - Update member additional fields in the same request
- ✅ **Full type inference** - TypeScript autocomplete for all additional fields
- ✅ **Automatic schema validation** - Uses `toZodSchema` to validate additional fields

## Implementation Details

This plugin:

1. **Extends `updateMemberRole`**: Uses the exact same permission logic, role validation, and error handling as `updateMemberRole`, but accepts additional fields via `toZodSchema`
2. **Auto-injects hooks**: Uses the plugin's `init()` method to inject `afterAcceptInvitation` into the organization plugin's hooks
3. **Type-safe**: Full TypeScript support with `InferAdditionalFieldsFromPluginOptions` for automatic type inference of all additional fields
4. **Uses `getOrgAdapter`**: Leverages the organization plugin's adapter utilities for consistent behavior
5. **Dynamic field transfer**: Automatically detects and transfers all fields defined in `invitation.additionalFields` to member records
6. **Production-ready**: Follows Better Auth best practices and patterns

### How It Works

1. **At initialization**: The plugin reads the organization plugin's schema to get member additional fields
2. **Schema generation**: Uses `toZodSchema` to generate validation schema from `member.additionalFields`
3. **Endpoint creation**: Creates the `/organization/update-member` endpoint with merged schemas (base + additional fields)
4. **Hook injection**: Injects `afterAcceptInvitation` to automatically transfer invitation fields to member
5. **Type inference**: TypeScript automatically infers all additional fields for full autocomplete support

## License

MIT License

Copyright (c) 2026 ShareRing

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

