admin: Superforms + Zod validation, three-address-defaults schema, profiles toast fix

- Add Superforms + Zod 4 for all admin form validation
- Replace address isDefault with three separate boolean defaults (Mailing, Billing, Shipping)
- Update profiles page with three-checkbox address form and toast improvements
- Convert all admin page server actions to pass request directly to crud
- Export superFormErrorsToFieldErrors helper for profiles server actions
- Regenerate Prisma client for new schema fields
This commit is contained in:
NeilClack
2026-07-07 13:36:23 -05:00
parent 38347f5f24
commit 38d1752f5b
98 changed files with 3408 additions and 1758 deletions
@@ -11,28 +11,28 @@ description: Configure multi-tenant organizations, manage members and invitation
4. Verify: check that organization, member, invitation tables exist in your database
```ts
import { betterAuth } from "better-auth";
import { organization } from "better-auth/plugins";
import { betterAuth } from 'better-auth';
import { organization } from 'better-auth/plugins';
export const auth = betterAuth({
plugins: [
organization({
allowUserToCreateOrganization: true,
organizationLimit: 5, // Max orgs per user
membershipLimit: 100, // Max members per org
}),
],
plugins: [
organization({
allowUserToCreateOrganization: true,
organizationLimit: 5, // Max orgs per user
membershipLimit: 100 // Max members per org
})
]
});
```
### Client-Side Setup
```ts
import { createAuthClient } from "better-auth/client";
import { organizationClient } from "better-auth/client/plugins";
import { createAuthClient } from 'better-auth/client';
import { organizationClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({
plugins: [organizationClient()],
plugins: [organizationClient()]
});
```
@@ -42,12 +42,12 @@ The creator is automatically assigned the `owner` role.
```ts
const createOrg = async () => {
const { data, error } = await authClient.organization.create({
name: "My Company",
slug: "my-company",
logo: "https://example.com/logo.png",
metadata: { plan: "pro" },
});
const { data, error } = await authClient.organization.create({
name: 'My Company',
slug: 'my-company',
logo: 'https://example.com/logo.png',
metadata: { plan: 'pro' }
});
};
```
@@ -57,13 +57,13 @@ Restrict who can create organizations based on user attributes:
```ts
organization({
allowUserToCreateOrganization: async (user) => {
return user.emailVerified === true;
},
organizationLimit: async (user) => {
// Premium users get more organizations
return user.plan === "premium" ? 20 : 3;
},
allowUserToCreateOrganization: async (user) => {
return user.emailVerified === true;
},
organizationLimit: async (user) => {
// Premium users get more organizations
return user.plan === 'premium' ? 20 : 3;
}
});
```
@@ -73,26 +73,25 @@ Administrators can create organizations for other users (server-side only):
```ts
await auth.api.createOrganization({
body: {
name: "Client Organization",
slug: "client-org",
userId: "user-id-who-will-be-owner", // `userId` is required
},
body: {
name: 'Client Organization',
slug: 'client-org',
userId: 'user-id-who-will-be-owner' // `userId` is required
}
});
```
**Note**: The `userId` parameter cannot be used alongside session headers.
## Active Organizations
Stored in the session and scopes subsequent API calls. Set after user selects one.
```ts
const setActive = async (organizationId: string) => {
const { data, error } = await authClient.organization.setActive({
organizationId,
});
const { data, error } = await authClient.organization.setActive({
organizationId
});
};
```
@@ -106,11 +105,11 @@ Use `getFullOrganization()` to retrieve the active org with all members, invitat
```ts
await auth.api.addMember({
body: {
userId: "user-id",
role: "member",
organizationId: "org-id",
},
body: {
userId: 'user-id',
role: 'member',
organizationId: 'org-id'
}
});
```
@@ -120,11 +119,11 @@ For client-side member additions, use the invitation system instead.
```ts
await auth.api.addMember({
body: {
userId: "user-id",
role: ["admin", "moderator"],
organizationId: "org-id",
},
body: {
userId: 'user-id',
role: ['admin', 'moderator'],
organizationId: 'org-id'
}
});
```
@@ -140,12 +139,12 @@ Use `updateMemberRole({ memberId, role })`.
```ts
organization({
membershipLimit: async (user, organization) => {
if (organization.metadata?.plan === "enterprise") {
return 1000;
}
return 50;
},
membershipLimit: async (user, organization) => {
if (organization.metadata?.plan === 'enterprise') {
return 1000;
}
return 50;
}
});
```
@@ -154,29 +153,29 @@ organization({
### Setting Up Invitation Emails
```ts
import { betterAuth } from "better-auth";
import { organization } from "better-auth/plugins";
import { sendEmail } from "./email";
import { betterAuth } from 'better-auth';
import { organization } from 'better-auth/plugins';
import { sendEmail } from './email';
export const auth = betterAuth({
plugins: [
organization({
sendInvitationEmail: async (data) => {
const { email, organization, inviter, invitation } = data;
plugins: [
organization({
sendInvitationEmail: async (data) => {
const { email, organization, inviter, invitation } = data;
await sendEmail({
to: email,
subject: `Join ${organization.name}`,
html: `
await sendEmail({
to: email,
subject: `Join ${organization.name}`,
html: `
<p>${inviter.user.name} invited you to join ${organization.name}</p>
<a href="https://yourapp.com/accept-invite?id=${invitation.id}">
Accept Invitation
</a>
`,
});
},
}),
],
`
});
}
})
]
});
```
@@ -184,8 +183,8 @@ export const auth = betterAuth({
```ts
await authClient.organization.inviteMember({
email: "newuser@example.com",
role: "member",
email: 'newuser@example.com',
role: 'member'
});
```
@@ -193,9 +192,9 @@ await authClient.organization.inviteMember({
```ts
const { data } = await authClient.organization.getInvitationURL({
email: "newuser@example.com",
role: "member",
callbackURL: "https://yourapp.com/dashboard",
email: 'newuser@example.com',
role: 'member',
callbackURL: 'https://yourapp.com/dashboard'
});
// Share data.url via any channel
@@ -207,9 +206,9 @@ This endpoint does not call `sendInvitationEmail` — handle delivery yourself.
```ts
organization({
invitationExpiresIn: 60 * 60 * 24 * 7, // 7 days (default: 48 hours)
invitationLimit: 100, // Max pending invitations per org
cancelPendingInvitationsOnReInvite: true, // Cancel old invites when re-inviting
invitationExpiresIn: 60 * 60 * 24 * 7, // 7 days (default: 48 hours)
invitationLimit: 100, // Max pending invitations per org
cancelPendingInvitationsOnReInvite: true // Cancel old invites when re-inviting
});
```
@@ -221,11 +220,11 @@ Default roles: `owner` (full access), `admin` (manage members/invitations/settin
```ts
const { data } = await authClient.organization.hasPermission({
permission: "member:write",
permission: 'member:write'
});
if (data?.hasPermission) {
// User can manage members
// User can manage members
}
```
@@ -236,16 +235,16 @@ Use `checkRolePermission({ role, permissions })` for client-side UI rendering (s
### Enabling Teams
```ts
import { organization } from "better-auth/plugins";
import { organization } from 'better-auth/plugins';
export const auth = betterAuth({
plugins: [
organization({
teams: {
enabled: true
}
}),
],
plugins: [
organization({
teams: {
enabled: true
}
})
]
});
```
@@ -253,7 +252,7 @@ export const auth = betterAuth({
```ts
const { data } = await authClient.organization.createTeam({
name: "Engineering",
name: 'Engineering'
});
```
@@ -267,11 +266,11 @@ Set active team with `setActiveTeam({ teamId })`.
```ts
organization({
teams: {
maximumTeams: 20, // Max teams per org
maximumMembersPerTeam: 50, // Max members per team
allowRemovingAllTeams: false, // Prevent removing last team
}
teams: {
maximumTeams: 20, // Max teams per org
maximumMembersPerTeam: 50, // Max members per team
allowRemovingAllTeams: false // Prevent removing last team
}
});
```
@@ -280,17 +279,17 @@ organization({
### Enabling Dynamic Access Control
```ts
import { organization } from "better-auth/plugins";
import { dynamicAccessControl } from "@better-auth/organization/addons";
import { organization } from 'better-auth/plugins';
import { dynamicAccessControl } from '@better-auth/organization/addons';
export const auth = betterAuth({
plugins: [
organization({
dynamicAccessControl: {
enabled: true
}
}),
],
plugins: [
organization({
dynamicAccessControl: {
enabled: true
}
})
]
});
```
@@ -298,11 +297,11 @@ export const auth = betterAuth({
```ts
await authClient.organization.createRole({
role: "moderator",
permission: {
member: ["read"],
invitation: ["read"],
},
role: 'moderator',
permission: {
member: ['read'],
invitation: ['read']
}
});
```
@@ -314,37 +313,37 @@ Execute custom logic at various points in the organization lifecycle:
```ts
organization({
hooks: {
organization: {
beforeCreate: async ({ data, user }) => {
// Validate or modify data before creation
return {
data: {
...data,
metadata: { ...data.metadata, createdBy: user.id },
},
};
},
afterCreate: async ({ organization, member }) => {
// Post-creation logic (e.g., send welcome email, create default resources)
await createDefaultResources(organization.id);
},
beforeDelete: async ({ organization }) => {
// Cleanup before deletion
await archiveOrganizationData(organization.id);
},
},
member: {
afterCreate: async ({ member, organization }) => {
await notifyAdmins(organization.id, `New member joined`);
},
},
invitation: {
afterCreate: async ({ invitation, organization, inviter }) => {
await logInvitation(invitation);
},
},
},
hooks: {
organization: {
beforeCreate: async ({ data, user }) => {
// Validate or modify data before creation
return {
data: {
...data,
metadata: { ...data.metadata, createdBy: user.id }
}
};
},
afterCreate: async ({ organization, member }) => {
// Post-creation logic (e.g., send welcome email, create default resources)
await createDefaultResources(organization.id);
},
beforeDelete: async ({ organization }) => {
// Cleanup before deletion
await archiveOrganizationData(organization.id);
}
},
member: {
afterCreate: async ({ member, organization }) => {
await notifyAdmins(organization.id, `New member joined`);
}
},
invitation: {
afterCreate: async ({ invitation, organization, inviter }) => {
await logInvitation(invitation);
}
}
}
});
```
@@ -354,32 +353,32 @@ Customize table names, field names, and add additional fields:
```ts
organization({
schema: {
organization: {
modelName: "workspace", // Rename table
fields: {
name: "workspaceName", // Rename fields
},
additionalFields: {
billingId: {
type: "string",
required: false,
},
},
},
member: {
additionalFields: {
department: {
type: "string",
required: false,
},
title: {
type: "string",
required: false,
},
},
},
},
schema: {
organization: {
modelName: 'workspace', // Rename table
fields: {
name: 'workspaceName' // Rename fields
},
additionalFields: {
billingId: {
type: 'string',
required: false
}
}
},
member: {
additionalFields: {
department: {
type: 'string',
required: false
},
title: {
type: 'string',
required: false
}
}
}
}
});
```
@@ -396,8 +395,8 @@ Always ensure ownership transfer before removing the current owner:
```ts
// Transfer ownership first
await authClient.organization.updateMemberRole({
memberId: "new-owner-member-id",
role: "owner",
memberId: 'new-owner-member-id',
role: 'owner'
});
// Then the previous owner can be demoted or removed
@@ -409,7 +408,7 @@ Deleting an organization removes all associated data (members, invitations, team
```ts
organization({
disableOrganizationDeletion: true, // Disable via config
disableOrganizationDeletion: true // Disable via config
});
```
@@ -417,15 +416,15 @@ Or implement soft delete via hooks:
```ts
organization({
hooks: {
organization: {
beforeDelete: async ({ organization }) => {
// Archive instead of delete
await archiveOrganization(organization.id);
throw new Error("Organization archived, not deleted");
},
},
},
hooks: {
organization: {
beforeDelete: async ({ organization }) => {
// Archive instead of delete
await archiveOrganization(organization.id);
throw new Error('Organization archived, not deleted');
}
}
}
});
```
@@ -438,42 +437,42 @@ organization({
## Complete Configuration Example
```ts
import { betterAuth } from "better-auth";
import { organization } from "better-auth/plugins";
import { sendEmail } from "./email";
import { betterAuth } from 'better-auth';
import { organization } from 'better-auth/plugins';
import { sendEmail } from './email';
export const auth = betterAuth({
plugins: [
organization({
// Organization limits
allowUserToCreateOrganization: true,
organizationLimit: 10,
membershipLimit: 100,
creatorRole: "owner",
plugins: [
organization({
// Organization limits
allowUserToCreateOrganization: true,
organizationLimit: 10,
membershipLimit: 100,
creatorRole: 'owner',
// Slugs
defaultOrganizationIdField: "slug",
// Slugs
defaultOrganizationIdField: 'slug',
// Invitations
invitationExpiresIn: 60 * 60 * 24 * 7, // 7 days
invitationLimit: 50,
sendInvitationEmail: async (data) => {
await sendEmail({
to: data.email,
subject: `Join ${data.organization.name}`,
html: `<a href="https://app.com/invite/${data.invitation.id}">Accept</a>`,
});
},
// Invitations
invitationExpiresIn: 60 * 60 * 24 * 7, // 7 days
invitationLimit: 50,
sendInvitationEmail: async (data) => {
await sendEmail({
to: data.email,
subject: `Join ${data.organization.name}`,
html: `<a href="https://app.com/invite/${data.invitation.id}">Accept</a>`
});
},
// Hooks
hooks: {
organization: {
afterCreate: async ({ organization }) => {
console.log(`Organization ${organization.name} created`);
},
},
},
}),
],
// Hooks
hooks: {
organization: {
afterCreate: async ({ organization }) => {
console.log(`Organization ${organization.name} created`);
}
}
}
})
]
});
```