mirror of
https://github.com/rybbit-io/rybbit.git
synced 2025-05-11 12:25:36 +02:00
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:
parent
e09a239c8a
commit
0d0b7c33b2
8 changed files with 410 additions and 107 deletions
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,88 +81,166 @@ 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">
|
||||
<CardTitle className="text-xl">
|
||||
{org.name}
|
||||
<span className="text-sm font-normal text-muted-foreground ml-2">
|
||||
({org.slug})
|
||||
</span>
|
||||
</CardTitle>
|
||||
<>
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl">
|
||||
{org.name}
|
||||
<span className="text-sm font-normal text-muted-foreground ml-2">
|
||||
({org.slug})
|
||||
</span>
|
||||
</CardTitle>
|
||||
|
||||
<div className="flex items-center">
|
||||
{isOwner && (
|
||||
<>
|
||||
<InviteMemberDialog
|
||||
organizationId={org.id}
|
||||
onSuccess={handleRefresh}
|
||||
/>
|
||||
<EditOrganizationDialog
|
||||
organization={org}
|
||||
onSuccess={handleRefresh}
|
||||
/>
|
||||
<DeleteOrganizationDialog
|
||||
organization={org}
|
||||
onSuccess={handleRefresh}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
{isOwner && (
|
||||
<>
|
||||
<AddMemberDialog
|
||||
organizationId={org.id}
|
||||
onSuccess={handleRefresh}
|
||||
/>
|
||||
<EditOrganizationDialog
|
||||
organization={org}
|
||||
onSuccess={handleRefresh}
|
||||
/>
|
||||
<DeleteOrganizationDialog
|
||||
organization={org}
|
||||
onSuccess={handleRefresh}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Joined</TableHead>
|
||||
{isOwner && <TableHead className="w-12">Actions</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members?.data?.map((member: any) => (
|
||||
<TableRow key={member.id}>
|
||||
<TableCell>{member.user?.name || "—"}</TableCell>
|
||||
<TableCell>{member.user?.email}</TableCell>
|
||||
<TableCell className="capitalize">{member.role}</TableCell>
|
||||
<TableCell>
|
||||
{DateTime.fromISO(member.createdAt).toLocaleString(
|
||||
DateTime.DATE_SHORT
|
||||
)}
|
||||
</TableCell>
|
||||
{isOwner && (
|
||||
<TableCell className="text-right">
|
||||
{member.role !== "owner" && (
|
||||
<RemoveMemberDialog
|
||||
member={member}
|
||||
organizationId={org.id}
|
||||
onSuccess={handleRefresh}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Joined</TableHead>
|
||||
{isOwner && <TableHead className="w-12">Actions</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members?.data?.map((member: any) => (
|
||||
<TableRow key={member.id}>
|
||||
<TableCell>{member.user?.name || "—"}</TableCell>
|
||||
<TableCell>{member.user?.email}</TableCell>
|
||||
<TableCell className="capitalize">{member.role}</TableCell>
|
||||
<TableCell>
|
||||
{DateTime.fromSQL(member.createdAt).toLocaleString(
|
||||
DateTime.DATE_SHORT
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
{(!members?.data || members.data.length === 0) && (
|
||||
{isOwner && (
|
||||
<TableCell className="text-right">
|
||||
{member.role !== "owner" && (
|
||||
<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>
|
||||
<TableCell
|
||||
colSpan={isOwner ? 5 : 4}
|
||||
className="text-center py-6 text-muted-foreground"
|
||||
>
|
||||
No members found
|
||||
</TableCell>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
{isOwner && <TableHead className="w-12">Actions</TableHead>}
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
73
server/src/api/user/addUserToOrganization.ts
Normal file
73
server/src/api/user/addUserToOrganization.ts
Normal 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) });
|
||||
}
|
||||
}
|
|
@ -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) });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,26 +12,15 @@ dotenv.config();
|
|||
|
||||
type AuthType = ReturnType<typeof betterAuth> | null;
|
||||
|
||||
const pluginList = IS_CLOUD
|
||||
? [
|
||||
admin(),
|
||||
organization({
|
||||
// Allow users to create organizations
|
||||
allowUserToCreateOrganization: true,
|
||||
// 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",
|
||||
}),
|
||||
];
|
||||
const pluginList = [
|
||||
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",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue