show owner in users table, list roles query in invite form, and more

This commit is contained in:
Milo Schwartz 2024-11-08 00:03:54 -05:00
parent 458de04fcf
commit 9c2e481d2b
No known key found for this signature in database
13 changed files with 145 additions and 81 deletions

View file

@ -1,6 +1,6 @@
// import { orgs, sites, resources, exitNodes, targets } from "@server/db/schema"; // import { orgs, sites, resources, exitNodes, targets } from "@server/db/schema";
// import db from "@server/db"; // import db from "@server/db";
// import { crateAdminRole } from "@server/db/ensureActions"; // import { createAdminRole } from "@server/db/ensureActions";
// async function insertDummyData() { // async function insertDummyData() {
// const org1 = db // const org1 = db
@ -13,7 +13,7 @@
// .returning() // .returning()
// .get(); // .get();
// await crateAdminRole(org1.orgId!); // await createAdminRole(org1.orgId!);
// // Insert dummy exit nodes // // Insert dummy exit nodes
// const exitNode1 = db // const exitNode1 = db

View file

@ -37,7 +37,7 @@ export const SendInviteLink = ({
<Body className="font-sans"> <Body className="font-sans">
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8"> <Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8">
<Heading className="text-2xl font-semibold text-gray-800 text-center"> <Heading className="text-2xl font-semibold text-gray-800 text-center">
You're invite to join a Fossorial organization You're invited to join a Fossorial organization
</Heading> </Heading>
<Text className="text-base text-gray-700 mt-4"> <Text className="text-base text-gray-700 mt-4">
Hi {email || "there"}, Hi {email || "there"},
@ -45,12 +45,15 @@ export const SendInviteLink = ({
<Text className="text-base text-gray-700 mt-2"> <Text className="text-base text-gray-700 mt-2">
Youve been invited to join the organization{" "} Youve been invited to join the organization{" "}
{orgName} {orgName}
{inviterName ? ` by ${inviterName}.` : ""}. Please {inviterName ? ` by ${inviterName}.` : "."} Please
access the link below to accept the invite. access the link below to accept the invite.
</Text> </Text>
<Text className="text-base text-gray-700 mt-2"> <Text className="text-base text-gray-700 mt-2">
This invite will expire in{" "} This invite will expire in{" "}
<b>{expiresInDays} days.</b> <b>
{expiresInDays}{" "}
{expiresInDays === "1" ? "day" : "days"}.
</b>
</Text> </Text>
<Section className="text-center my-6"> <Section className="text-center my-6">
<Button <Button

View file

@ -199,12 +199,12 @@ authenticated.delete(
// verifyUserHasAction(ActionsEnum.createRole), // verifyUserHasAction(ActionsEnum.createRole),
// role.createRole // role.createRole
// ); // );
// authenticated.get( authenticated.get(
// "/org/:orgId/roles", "/org/:orgId/roles",
// verifyOrgAccess, verifyOrgAccess,
// verifyUserHasAction(ActionsEnum.listRoles), verifyUserHasAction(ActionsEnum.listRoles),
// role.listRoles role.listRoles
// ); );
// authenticated.get( // authenticated.get(
// "/role/:roleId", // "/role/:roleId",
// verifyRoleAccess, // verifyRoleAccess,

View file

@ -99,6 +99,7 @@ export async function createOrg(
userId: req.user!.userId, userId: req.user!.userId,
orgId: newOrg[0].orgId, orgId: newOrg[0].orgId,
roleId: roleId, roleId: roleId,
isOwner: true,
}) })
.execute(); .execute();

View file

@ -8,6 +8,7 @@ import createHttpError from "http-errors";
import { sql, eq } from "drizzle-orm"; import { sql, eq } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import stoi from "@server/utils/stoi";
const listRolesParamsSchema = z.object({ const listRolesParamsSchema = z.object({
orgId: z.string(), orgId: z.string(),
@ -17,20 +18,43 @@ const listRolesSchema = z.object({
limit: z limit: z
.string() .string()
.optional() .optional()
.default("1000")
.transform(Number) .transform(Number)
.pipe(z.number().int().positive().default(10)), .pipe(z.number().int().nonnegative()),
offset: z offset: z
.string() .string()
.optional() .optional()
.default("0")
.transform(Number) .transform(Number)
.pipe(z.number().int().nonnegative().default(0)), .pipe(z.number().int().nonnegative()),
orgId: z
.string()
.optional()
.transform(Number)
.pipe(z.number().int().positive()),
}); });
async function queryRoles(orgId: string, limit: number, offset: number) {
return await db
.select({
roleId: roles.roleId,
orgId: roles.orgId,
isAdmin: roles.isAdmin,
name: roles.name,
description: roles.description,
orgName: orgs.name,
})
.from(roles)
.leftJoin(orgs, eq(roles.orgId, orgs.orgId))
.where(eq(roles.orgId, orgId))
.limit(limit)
.offset(offset);
}
export type ListRolesResponse = {
roles: NonNullable<Awaited<ReturnType<typeof queryRoles>>>;
pagination: {
total: number;
limit: number;
offset: number;
};
};
export async function listRoles( export async function listRoles(
req: Request, req: Request,
res: Response, res: Response,
@ -61,25 +85,12 @@ export async function listRoles(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
let baseQuery: any = db
.select({
roleId: roles.roleId,
orgId: roles.orgId,
isAdmin: roles.isAdmin,
name: roles.name,
description: roles.description,
orgName: orgs.name,
})
.from(roles)
.leftJoin(orgs, eq(roles.orgId, orgs.orgId))
.where(eq(roles.orgId, orgId));
let countQuery: any = db let countQuery: any = db
.select({ count: sql<number>`cast(count(*) as integer)` }) .select({ count: sql<number>`cast(count(*) as integer)` })
.from(roles) .from(roles)
.where(eq(roles.orgId, orgId)); .where(eq(roles.orgId, orgId));
const rolesList = await baseQuery.limit(limit).offset(offset); const rolesList = await queryRoles(orgId, limit, offset);
const totalCountResult = await countQuery; const totalCountResult = await countQuery;
const totalCount = totalCountResult[0].count; const totalCount = totalCountResult[0].count;

View file

@ -145,7 +145,7 @@ export async function inviteUser(
email, email,
inviteLink, inviteLink,
expiresInDays: (validHours / 24).toString(), expiresInDays: (validHours / 24).toString(),
orgName: orgId, orgName: org[0].name || orgId,
inviterName: req.user?.email, inviterName: req.user?.email,
}), }),
{ {

View file

@ -37,6 +37,7 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
orgId: userOrgs.orgId, orgId: userOrgs.orgId,
roleId: userOrgs.roleId, roleId: userOrgs.roleId,
roleName: roles.name, roleName: roles.name,
isOwner: userOrgs.isOwner,
}) })
.from(users) .from(users)
.leftJoin(userOrgs, sql`${users.userId} = ${userOrgs.userId}`) .leftJoin(userOrgs, sql`${users.userId} = ${userOrgs.userId}`)

View file

@ -29,7 +29,7 @@ export function TopbarNav({
className={cn( className={cn(
"flex overflow-x-auto space-x-4 lg:space-x-6", "flex overflow-x-auto space-x-4 lg:space-x-6",
disabled && "opacity-50 pointer-events-none", disabled && "opacity-50 pointer-events-none",
className, className
)} )}
{...props} {...props}
> >
@ -38,22 +38,23 @@ export function TopbarNav({
key={item.href} key={item.href}
href={item.href.replace("{orgId}", orgId)} href={item.href.replace("{orgId}", orgId)}
className={cn( className={cn(
"px-2 py-3 text-md", "relative px-3 py-3 text-md",
pathname.startsWith(item.href.replace("{orgId}", orgId)) pathname.startsWith(item.href.replace("{orgId}", orgId))
? "border-b-2 border-primary text-primary font-medium" ? "border-b-2 border-primary text-primary font-medium"
: "hover:text-primary text-muted-foreground font-medium", : "hover:text-primary text-muted-foreground font-medium",
"whitespace-nowrap", "whitespace-nowrap",
disabled && "cursor-not-allowed", disabled && "cursor-not-allowed"
)} )}
onClick={disabled ? (e) => e.preventDefault() : undefined} onClick={disabled ? (e) => e.preventDefault() : undefined}
tabIndex={disabled ? -1 : undefined} tabIndex={disabled ? -1 : undefined}
aria-disabled={disabled} aria-disabled={disabled}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 relative px-2 py-0.5 rounded-md">
{item.icon && ( {item.icon && (
<div className="hidden md:block">{item.icon}</div> <div className="hidden md:block">{item.icon}</div>
)} )}
{item.title} <span className="relative z-10">{item.title}</span>
<span className="absolute inset-x-0 bottom-0 border-b-2 border-transparent group-hover:border-primary"></span>
</div> </div>
</Link> </Link>
))} ))}

View file

@ -86,7 +86,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
return ( return (
<> <>
<div className="w-full bg-muted mb-6 select-none sm:px-0 px-3 pt-3"> <div className="w-full border-b bg-neutral-100 dark:bg-neutral-900 mb-6 select-none sm:px-0 px-3 pt-3">
<div className="container mx-auto flex flex-col content-between gap-4 "> <div className="container mx-auto flex flex-col content-between gap-4 ">
<Header <Header
email={user.email} email={user.email}

View file

@ -24,6 +24,7 @@ import { useParams } from "next/navigation";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Checkbox } from "@app/components/ui/checkbox"; import { Checkbox } from "@app/components/ui/checkbox";
import { PickSiteDefaultsResponse } from "@server/routers/site"; import { PickSiteDefaultsResponse } from "@server/routers/site";
import CopyTextBox from "@app/components/CopyTextBox";
const method = [ const method = [
{ label: "Wireguard", value: "wg" }, { label: "Wireguard", value: "wg" },
@ -188,19 +189,11 @@ sh get-docker.sh`;
)} )}
/> />
{form.watch("method") === "wg" && !isLoading ? ( {form.watch("method") === "wg" && !isLoading ? (
<pre className="mt-2 w-full rounded-md bg-muted p-4 overflow-x-auto"> <CopyTextBox text={wgConfig} />
<code className="whitespace-pre-wrap font-mono">
{wgConfig}
</code>
</pre>
) : form.watch("method") === "wg" && isLoading ? ( ) : form.watch("method") === "wg" && isLoading ? (
<p>Loading WireGuard configuration...</p> <p>Loading WireGuard configuration...</p>
) : ( ) : (
<pre className="mt-2 w-full rounded-md bg-muted p-4 overflow-x-auto"> <CopyTextBox text={newtConfig} wrapText={false} />
<code className="whitespace-pre-wrap">
{newtConfig}
</code>
</pre>
)} )}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox

View file

@ -22,7 +22,7 @@ import { useToast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { InviteUserBody, InviteUserResponse } from "@server/routers/user"; import { InviteUserBody, InviteUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import CopyTextBox from "@app/components/CopyTextBox"; import CopyTextBox from "@app/components/CopyTextBox";
@ -37,6 +37,7 @@ import {
CredenzaTitle, CredenzaTitle,
} from "@app/components/Credenza"; } from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { ListRolesResponse } from "@server/routers/role";
type InviteUserFormProps = { type InviteUserFormProps = {
open: boolean; open: boolean;
@ -45,8 +46,8 @@ type InviteUserFormProps = {
const formSchema = z.object({ const formSchema = z.object({
email: z.string().email({ message: "Invalid email address" }), email: z.string().email({ message: "Invalid email address" }),
validForHours: z.string(), validForHours: z.string().min(1, { message: "Please select a duration" }),
roleId: z.string(), roleId: z.string().min(1, { message: "Please select a role" }),
}); });
export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) { export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
@ -57,7 +58,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [expiresInDays, setExpiresInDays] = useState(1); const [expiresInDays, setExpiresInDays] = useState(1);
const roles = [{ roleId: 1, name: "Admin" }]; const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const validFor = [ const validFor = [
{ hours: 24, name: "1 day" }, { hours: 24, name: "1 day" },
@ -73,11 +74,44 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
email: "", email: "",
validForHours: "168", validForHours: "72",
roleId: "4", roleId: "",
}, },
}); });
useEffect(() => {
if (!open) {
return;
}
async function fetchRoles() {
const res = await api
.get<AxiosResponse<ListRolesResponse>>(
`/org/${org?.org.orgId}/roles`
)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: "Failed to fetch roles",
description:
e.message ||
"An error occurred while fetching the roles",
});
});
if (res?.status === 200) {
setRoles(res.data.data.roles);
// form.setValue(
// "roleId",
// res.data.data.roles[0].roleId.toString()
// );
}
}
fetchRoles();
}, [open]);
async function onSubmit(values: z.infer<typeof formSchema>) { async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true); setLoading(true);
@ -167,7 +201,6 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
onValueChange={ onValueChange={
field.onChange field.onChange
} }
defaultValue={field.value.toString()}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>

View file

@ -8,11 +8,10 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@app/components/ui/dropdown-menu"; } from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import { ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
import { UsersDataTable } from "./UsersDataTable"; import { UsersDataTable } from "./UsersDataTable";
import { useState } from "react"; import { useState } from "react";
import InviteUserForm from "./InviteUserForm"; import InviteUserForm from "./InviteUserForm";
import { Badge } from "@app/components/ui/badge";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useUserContext } from "@app/hooks/useUserContext"; import { useUserContext } from "@app/hooks/useUserContext";
import api from "@app/api"; import api from "@app/api";
@ -24,6 +23,7 @@ export type UserRow = {
email: string; email: string;
status: string; status: string;
role: string; role: string;
isOwner: boolean;
}; };
type UsersTableProps = { type UsersTableProps = {
@ -87,6 +87,16 @@ export default function UsersTable({ users }: UsersTableProps) {
</Button> </Button>
); );
}, },
cell: ({ row }) => {
const userRow = row.original;
return (
<div className="flex flex-row items-center gap-1">
{userRow.isOwner && <Crown className="w-4 h-4" />}
<span>{userRow.role}</span>
</div>
);
},
}, },
{ {
id: "actions", id: "actions",
@ -95,15 +105,23 @@ export default function UsersTable({ users }: UsersTableProps) {
return ( return (
<> <>
{!userRow.isOwner && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0"> <Button
<span className="sr-only">Open menu</span> variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem>Manage user</DropdownMenuItem> <DropdownMenuItem>
Manage user
</DropdownMenuItem>
{userRow.email !== user?.email && ( {userRow.email !== user?.email && (
<DropdownMenuItem> <DropdownMenuItem>
<button <button
@ -119,6 +137,7 @@ export default function UsersTable({ users }: UsersTableProps) {
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)}
</> </>
); );
}, },

View file

@ -8,6 +8,7 @@ import { cache } from "react";
import OrgProvider from "@app/providers/OrgProvider"; import OrgProvider from "@app/providers/OrgProvider";
import UserProvider from "@app/providers/UserProvider"; import UserProvider from "@app/providers/UserProvider";
import { verifySession } from "@app/lib/auth/verifySession"; import { verifySession } from "@app/lib/auth/verifySession";
import CopyTextBox from "@app/components/CopyTextBox";
type UsersPageProps = { type UsersPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
@ -55,7 +56,8 @@ export default async function UsersPage(props: UsersPageProps) {
id: user.id, id: user.id,
email: user.email, email: user.email,
status: "Confirmed", status: "Confirmed",
role: user.roleName || "", role: user.isOwner ? "Owner" : user.roleName || "Member",
isOwner: user.isOwner || false,
}; };
}); });