Add member functionality and organization management enhancements

- Introduced `AddMemberDialog` to facilitate adding members to organizations in the settings page.
- Updated the organization component layout for improved user experience and accessibility.
- Refactored member handling logic to ensure proper refresh and state management.
- Added a new API endpoint for adding users to organizations, enhancing backend functionality.
- Modified account creation to include organization association, improving user onboarding process.
This commit is contained in:
Bill Yang 2025-04-26 00:00:51 -07:00
parent e09a239c8a
commit 0d0b7c33b2
8 changed files with 410 additions and 107 deletions

View file

@ -0,0 +1,131 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { UserPlus } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Alert } from "../../../../components/ui/alert";
import { BACKEND_URL } from "../../../../lib/const";
interface AddMemberDialogProps {
organizationId: string;
onSuccess: () => void;
}
export function AddMemberDialog({
organizationId,
onSuccess,
}: AddMemberDialogProps) {
const [email, setEmail] = useState("");
const [role, setRole] = useState("member");
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
const [error, setError] = useState("");
const handleInvite = async () => {
if (!email) {
toast.error("Email is required");
return;
}
setIsLoading(true);
try {
await fetch(`${BACKEND_URL}/add-user-to-organization`, {
method: "POST",
body: JSON.stringify({
email: email,
role: role,
organizationId: organizationId,
}),
headers: {
"Content-Type": "application/json",
},
credentials: "include",
});
toast.success(`Invitation sent to ${email}`);
setOpen(false);
onSuccess();
setEmail("");
setRole("member");
} catch (error: any) {
toast.error(error.message || "Failed to send invitation");
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="ml-2">
<UserPlus className="h-4 w-4 mr-1" />
Add Member
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Add a new member</DialogTitle>
<DialogDescription>
Add a new member to this organization.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="email@example.com"
value={email}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setEmail(e.target.value)
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="role">Role</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="owner">Owner</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="member">Member</SelectItem>
</SelectContent>
</Select>
</div>
{error && <Alert variant="destructive">{error}</Alert>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={handleInvite} disabled={isLoading} variant="success">
{isLoading ? "Adding..." : "Add"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -19,13 +19,16 @@ import { authClient } from "../../../lib/auth";
// Import the separated dialog components // Import the separated dialog components
import { DeleteOrganizationDialog } from "./components/DeleteOrganizationDialog"; import { DeleteOrganizationDialog } from "./components/DeleteOrganizationDialog";
import { EditOrganizationDialog } from "./components/EditOrganizationDialog"; import { EditOrganizationDialog } from "./components/EditOrganizationDialog";
import { InviteMemberDialog } from "./components/InviteMemberDialog";
import { RemoveMemberDialog } from "./components/RemoveMemberDialog"; import { RemoveMemberDialog } from "./components/RemoveMemberDialog";
import { useOrganizationMembers } from "../../../api/admin/auth"; import { useOrganizationMembers } from "../../../api/admin/auth";
import { import {
UserOrganization, UserOrganization,
useUserOrganizations, useUserOrganizations,
} from "../../../api/admin/organizations"; } from "../../../api/admin/organizations";
import { AddMemberDialog } from "./components/AddMemberDialog";
import { useQuery } from "@tanstack/react-query";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
// Types for our component // Types for our component
export type Organization = { export type Organization = {
@ -53,6 +56,23 @@ export type Member = {
function Organization({ org }: { org: UserOrganization }) { function Organization({ org }: { org: UserOrganization }) {
const { data: members, refetch } = useOrganizationMembers(org.id); const { data: members, refetch } = useOrganizationMembers(org.id);
// const { data: invitations, refetch: refetchInvitations } = useQuery({
// queryKey: ["invitations", org.id],
// queryFn: async () => {
// const invitations = await authClient.organization.listInvitations({
// query: {
// organizationId: org.id,
// },
// });
// if (invitations.error) {
// throw new Error(invitations.error.message);
// }
// return invitations.data;
// },
// });
const { data } = authClient.useSession(); const { data } = authClient.useSession();
const isOwner = members?.data.find( const isOwner = members?.data.find(
@ -61,88 +81,166 @@ function Organization({ org }: { org: UserOrganization }) {
const handleRefresh = () => { const handleRefresh = () => {
refetch(); refetch();
// refetchInvitations();
}; };
return ( return (
<Card className="w-full"> <>
<CardHeader className="pb-2"> <Card className="w-full">
<div className="flex items-center justify-between"> <CardHeader className="pb-2">
<CardTitle className="text-xl"> <div className="flex items-center justify-between">
{org.name} <CardTitle className="text-xl">
<span className="text-sm font-normal text-muted-foreground ml-2"> {org.name}
({org.slug}) <span className="text-sm font-normal text-muted-foreground ml-2">
</span> ({org.slug})
</CardTitle> </span>
</CardTitle>
<div className="flex items-center"> <div className="flex items-center">
{isOwner && ( {isOwner && (
<> <>
<InviteMemberDialog <AddMemberDialog
organizationId={org.id} organizationId={org.id}
onSuccess={handleRefresh} onSuccess={handleRefresh}
/> />
<EditOrganizationDialog <EditOrganizationDialog
organization={org} organization={org}
onSuccess={handleRefresh} onSuccess={handleRefresh}
/> />
<DeleteOrganizationDialog <DeleteOrganizationDialog
organization={org} organization={org}
onSuccess={handleRefresh} onSuccess={handleRefresh}
/> />
</> </>
)} )}
</div>
</div> </div>
</div> </CardHeader>
</CardHeader> <CardContent>
<CardContent> <Table>
<Table> <TableHeader>
<TableHeader> <TableRow>
<TableRow> <TableHead>Name</TableHead>
<TableHead>Name</TableHead> <TableHead>Email</TableHead>
<TableHead>Email</TableHead> <TableHead>Role</TableHead>
<TableHead>Role</TableHead> <TableHead>Joined</TableHead>
<TableHead>Joined</TableHead> {isOwner && <TableHead className="w-12">Actions</TableHead>}
{isOwner && <TableHead className="w-12">Actions</TableHead>} </TableRow>
</TableRow> </TableHeader>
</TableHeader> <TableBody>
<TableBody> {members?.data?.map((member: any) => (
{members?.data?.map((member: any) => ( <TableRow key={member.id}>
<TableRow key={member.id}> <TableCell>{member.user?.name || "—"}</TableCell>
<TableCell>{member.user?.name || "—"}</TableCell> <TableCell>{member.user?.email}</TableCell>
<TableCell>{member.user?.email}</TableCell> <TableCell className="capitalize">{member.role}</TableCell>
<TableCell className="capitalize">{member.role}</TableCell> <TableCell>
<TableCell> {DateTime.fromSQL(member.createdAt).toLocaleString(
{DateTime.fromISO(member.createdAt).toLocaleString( DateTime.DATE_SHORT
DateTime.DATE_SHORT
)}
</TableCell>
{isOwner && (
<TableCell className="text-right">
{member.role !== "owner" && (
<RemoveMemberDialog
member={member}
organizationId={org.id}
onSuccess={handleRefresh}
/>
)} )}
</TableCell> </TableCell>
)} {isOwner && (
</TableRow> <TableCell className="text-right">
))} {member.role !== "owner" && (
{(!members?.data || members.data.length === 0) && ( <RemoveMemberDialog
member={member}
organizationId={org.id}
onSuccess={handleRefresh}
/>
)}
</TableCell>
)}
</TableRow>
))}
{(!members?.data || members.data.length === 0) && (
<TableRow>
<TableCell
colSpan={isOwner ? 5 : 4}
className="text-center py-6 text-muted-foreground"
>
No members found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Disabled for now. We aren't using this */}
{/*
<Card className="w-full">
<CardHeader className="pb-2">
<CardTitle className="text-xl">Pending Invitations</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow> <TableRow>
<TableCell <TableHead>Email</TableHead>
colSpan={isOwner ? 5 : 4} <TableHead>Role</TableHead>
className="text-center py-6 text-muted-foreground" <TableHead>Status</TableHead>
> <TableHead>Expires</TableHead>
No members found {isOwner && <TableHead className="w-12">Actions</TableHead>}
</TableCell>
</TableRow> </TableRow>
)} </TableHeader>
</TableBody> <TableBody>
</Table> {invitations?.length && invitations.length > 0 ? (
</CardContent> invitations.map((invitation) => (
</Card> <TableRow key={invitation.id}>
<TableCell>{invitation.email}</TableCell>
<TableCell className="capitalize">
{invitation.role}
</TableCell>
<TableCell>
<Badge
variant={
invitation.status === "pending"
? "outline"
: invitation.status === "accepted"
? "default"
: "destructive"
}
>
{invitation.status}
</Badge>
</TableCell>
<TableCell>
{DateTime.fromJSDate(
new Date(invitation.expiresAt)
).toLocaleString(DateTime.DATE_SHORT)}
</TableCell>
{isOwner && (
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={async () => {
await authClient.organization.cancelInvitation({
invitationId: invitation.id,
});
refetchInvitations();
}}
>
Cancel
</Button>
</TableCell>
)}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={isOwner ? 5 : 4}
className="text-center py-6 text-muted-foreground"
>
No pending invitations
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card> */}
</>
); );
} }

View file

@ -0,0 +1,73 @@
import { eq } from "drizzle-orm";
import { FastifyReply, FastifyRequest } from "fastify";
import { db } from "../../db/postgres/postgres.js";
import { member, user } from "../../db/postgres/schema.js";
import { randomBytes } from "crypto";
function generateId(len = 32) {
const alphabet =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const bytes = randomBytes(len);
let id = "";
for (let i = 0; i < len; i++) {
id += alphabet[bytes[i] % alphabet.length];
}
return id;
}
interface AddUserToOrganization {
Body: {
email: string;
role: string;
organizationId: string;
};
}
export async function addUserToOrganization(
request: FastifyRequest<AddUserToOrganization>,
reply: FastifyReply
) {
try {
const { email, role, organizationId } = request.body;
// Validate input
if (!email || !role || !organizationId) {
return reply.status(400).send({
error: "Missing required fields: email, role, and organizationId",
});
}
// Check password strength
if (role !== "admin" && role !== "member" && role !== "owner") {
return reply.status(400).send({
error: "Role must be either admin, member, or owner",
});
}
const foundUser = await db.query.user.findFirst({
where: eq(user.email, email),
});
if (!foundUser) {
return reply.status(404).send({ error: "User not found" });
}
await db.insert(member).values([
{
userId: foundUser.id,
organizationId: organizationId,
role: role,
id: generateId(),
createdAt: new Date().toISOString(),
},
]);
return reply.status(201).send({
message: "User added to organization successfully",
});
} catch (error: any) {
console.error(String(error));
return reply.status(500).send({ error: String(error) });
}
}

View file

@ -4,10 +4,10 @@ import { auth } from "../../lib/auth.js";
interface CreateAccountRequest { interface CreateAccountRequest {
Body: { Body: {
email: string; email: string;
username: string;
name: string; name: string;
password: string; password: string;
isAdmin: boolean; isAdmin: boolean;
organizationId: string;
}; };
} }
@ -16,10 +16,10 @@ export async function createAccount(
reply: FastifyReply reply: FastifyReply
) { ) {
try { try {
const { email, name, username, password, isAdmin } = request.body; const { email, name, password, isAdmin, organizationId } = request.body;
// Validate input // Validate input
if (!email || !username || !password) { if (!email || !password || !organizationId || !name) {
return reply.status(400).send({ return reply.status(400).send({
error: error:
"Missing required fields: email, name, and password are required", "Missing required fields: email, name, and password are required",
@ -35,10 +35,21 @@ export async function createAccount(
// Create the account using auth.api.signUpEmail // Create the account using auth.api.signUpEmail
const result = await auth!.api.signUpEmail({ const result = await auth!.api.signUpEmail({
body: { email: "bill3@gmail.com", name: "billy3", password: "12345noob" },
});
// const result = await auth!.api.signUpEmail({
// body: {
// email,
// name,
// password,
// },
// });
await (auth!.api as any).addMember({
body: { body: {
email, userId: result.user.id,
name, organizationId: organizationId,
password, role: isAdmin ? "admin" : "member", // this can also be an array for multiple roles (e.g. ["admin", "sale"])
}, },
}); });
@ -63,6 +74,6 @@ export async function createAccount(
} }
} }
return reply.status(500).send({ error: "Failed to create account" }); return reply.status(500).send({ error: String(error) });
} }
} }

View file

@ -34,8 +34,8 @@ export const initializeClickhouse = async () => {
country LowCardinality(FixedString(2)), country LowCardinality(FixedString(2)),
region LowCardinality(String), region LowCardinality(String),
city String, city String,
latitude Float64, lat Float64,
longitude Float64, lon Float64,
screen_width UInt16, screen_width UInt16,
screen_height UInt16, screen_height UInt16,
device_type LowCardinality(String), device_type LowCardinality(String),

View file

@ -193,7 +193,6 @@ export const invitation = pgTable(
role: text().notNull(), role: text().notNull(),
status: text().notNull(), status: text().notNull(),
expiresAt: timestamp({ mode: "string" }).notNull(), expiresAt: timestamp({ mode: "string" }).notNull(),
createdAt: timestamp({ mode: "string" }).notNull(),
}, },
(table) => [ (table) => [
foreignKey({ foreignKey({

View file

@ -50,6 +50,7 @@ import { createPortalSession } from "./api/stripe/createPortalSession.js";
import { getSubscription } from "./api/stripe/getSubscription.js"; import { getSubscription } from "./api/stripe/getSubscription.js";
import { handleWebhook } from "./api/stripe/webhook.js"; import { handleWebhook } from "./api/stripe/webhook.js";
import { IS_CLOUD } from "./lib/const.js"; import { IS_CLOUD } from "./lib/const.js";
import { addUserToOrganization } from "./api/user/addUserToOrganization.js";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@ -219,6 +220,7 @@ server.get(
listOrganizationMembers listOrganizationMembers
); );
server.get("/user/organizations", getUserOrganizations); server.get("/user/organizations", getUserOrganizations);
server.post("/add-user-to-organization", addUserToOrganization);
if (IS_CLOUD) { if (IS_CLOUD) {
// Stripe Routes // Stripe Routes

View file

@ -1,6 +1,6 @@
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, organization, username } from "better-auth/plugins"; import { admin, organization } from "better-auth/plugins";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import pg from "pg"; import pg from "pg";
@ -12,26 +12,15 @@ dotenv.config();
type AuthType = ReturnType<typeof betterAuth> | null; type AuthType = ReturnType<typeof betterAuth> | null;
const pluginList = IS_CLOUD const pluginList = [
? [ admin(),
admin(), organization({
organization({ // Allow users to create organizations
// Allow users to create organizations allowUserToCreateOrganization: true,
allowUserToCreateOrganization: true, // Set the creator role to owner
// Set the creator role to owner creatorRole: "owner",
creatorRole: "owner", }),
}), ];
]
: [
username(),
admin(),
organization({
// Allow users to create organizations
allowUserToCreateOrganization: true,
// Set the creator role to owner
creatorRole: "owner",
}),
];
export let auth: AuthType | null = betterAuth({ export let auth: AuthType | null = betterAuth({
basePath: "/auth", basePath: "/auth",
@ -53,7 +42,7 @@ export let auth: AuthType | null = betterAuth({
enabled: true, enabled: true,
}, },
}, },
plugins: pluginList as any, plugins: pluginList,
trustedOrigins: ["http://localhost:3002"], trustedOrigins: ["http://localhost:3002"],
advanced: { advanced: {
useSecureCookies: process.env.NODE_ENV === "production", // don't mark Secure in dev useSecureCookies: process.env.NODE_ENV === "production", // don't mark Secure in dev
@ -140,7 +129,7 @@ export function initAuth(allowedOrigins: string[]) {
}, },
}, },
}, },
plugins: pluginList as any, plugins: pluginList,
trustedOrigins: allowedOrigins, trustedOrigins: allowedOrigins,
advanced: { advanced: {
useSecureCookies: process.env.NODE_ENV === "production", useSecureCookies: process.env.NODE_ENV === "production",