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 { DeleteOrganizationDialog } from "./components/DeleteOrganizationDialog";
import { EditOrganizationDialog } from "./components/EditOrganizationDialog";
import { InviteMemberDialog } from "./components/InviteMemberDialog";
import { RemoveMemberDialog } from "./components/RemoveMemberDialog";
import { useOrganizationMembers } from "../../../api/admin/auth";
import {
UserOrganization,
useUserOrganizations,
} 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
export type Organization = {
@ -53,6 +56,23 @@ export type Member = {
function Organization({ org }: { org: UserOrganization }) {
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 isOwner = members?.data.find(
@ -61,9 +81,11 @@ function Organization({ org }: { org: UserOrganization }) {
const handleRefresh = () => {
refetch();
// refetchInvitations();
};
return (
<>
<Card className="w-full">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
@ -77,7 +99,7 @@ function Organization({ org }: { org: UserOrganization }) {
<div className="flex items-center">
{isOwner && (
<>
<InviteMemberDialog
<AddMemberDialog
organizationId={org.id}
onSuccess={handleRefresh}
/>
@ -112,7 +134,7 @@ function Organization({ org }: { org: UserOrganization }) {
<TableCell>{member.user?.email}</TableCell>
<TableCell className="capitalize">{member.role}</TableCell>
<TableCell>
{DateTime.fromISO(member.createdAt).toLocaleString(
{DateTime.fromSQL(member.createdAt).toLocaleString(
DateTime.DATE_SHORT
)}
</TableCell>
@ -143,6 +165,82 @@ function Organization({ org }: { org: UserOrganization }) {
</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>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>Expires</TableHead>
{isOwner && <TableHead className="w-12">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{invitations?.length && invitations.length > 0 ? (
invitations.map((invitation) => (
<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 {
Body: {
email: string;
username: string;
name: string;
password: string;
isAdmin: boolean;
organizationId: string;
};
}
@ -16,10 +16,10 @@ export async function createAccount(
reply: FastifyReply
) {
try {
const { email, name, username, password, isAdmin } = request.body;
const { email, name, password, isAdmin, organizationId } = request.body;
// Validate input
if (!email || !username || !password) {
if (!email || !password || !organizationId || !name) {
return reply.status(400).send({
error:
"Missing required fields: email, name, and password are required",
@ -35,10 +35,21 @@ export async function createAccount(
// Create the account using 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: {
email,
name,
password,
userId: result.user.id,
organizationId: organizationId,
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)),
region LowCardinality(String),
city String,
latitude Float64,
longitude Float64,
lat Float64,
lon Float64,
screen_width UInt16,
screen_height UInt16,
device_type LowCardinality(String),

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import { betterAuth } from "better-auth";
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 { eq } from "drizzle-orm";
import pg from "pg";
@ -12,8 +12,7 @@ dotenv.config();
type AuthType = ReturnType<typeof betterAuth> | null;
const pluginList = IS_CLOUD
? [
const pluginList = [
admin(),
organization({
// Allow users to create organizations
@ -21,17 +20,7 @@ const pluginList = IS_CLOUD
// Set the creator role to 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({
basePath: "/auth",
@ -53,7 +42,7 @@ export let auth: AuthType | null = betterAuth({
enabled: true,
},
},
plugins: pluginList as any,
plugins: pluginList,
trustedOrigins: ["http://localhost:3002"],
advanced: {
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,
advanced: {
useSecureCookies: process.env.NODE_ENV === "production",