add roles input on resource and make spacing more consistent

This commit is contained in:
Milo Schwartz 2024-11-15 18:25:27 -05:00
parent 8e64b5e0e9
commit 28bae40390
No known key found for this signature in database
36 changed files with 1235 additions and 724 deletions

View file

@ -43,6 +43,7 @@
"cookie-parser": "1.4.6",
"cors": "2.8.5",
"drizzle-orm": "0.33.0",
"emblor": "1.4.6",
"express": "4.21.0",
"express-rate-limit": "7.4.0",
"glob": "11.0.0",

View file

@ -35,8 +35,7 @@ export enum ActionsEnum {
listUsers = "listUsers",
listSiteRoles = "listSiteRoles",
listResourceRoles = "listResourceRoles",
addRoleSite = "addRoleSite",
addRoleResource = "addRoleResource",
setResourceRoles = "setResourceRoles",
removeRoleResource = "removeRoleResource",
removeRoleSite = "removeRoleSite",
// addRoleAction = "addRoleAction",

View file

@ -25,7 +25,6 @@ export const sites = sqliteTable("sites", {
export const resources = sqliteTable("resources", {
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
fullDomain: text("fullDomain", { length: 2048 }),
siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade",
}),
@ -33,7 +32,7 @@ export const resources = sqliteTable("resources", {
onDelete: "cascade",
}),
name: text("name").notNull(),
subdomain: text("subdomain"),
subdomain: text("subdomain").notNull(),
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
});

View file

@ -1,10 +1,16 @@
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { roles, userOrgs } from "@server/db/schema";
import { and, eq } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import logger from "@server/logger";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const verifyRoleAccessSchema = z.object({
roleIds: z.array(z.number().int().positive()).optional(),
});
export async function verifyRoleAccess(
req: Request,
@ -12,7 +18,7 @@ export async function verifyRoleAccess(
next: NextFunction
) {
const userId = req.user?.userId;
const roleId = parseInt(
const singleRoleId = parseInt(
req.params.roleId || req.body.roleId || req.query.roleId
);
@ -22,61 +28,61 @@ export async function verifyRoleAccess(
);
}
if (isNaN(roleId)) {
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID"));
}
try {
const role = await db
.select()
.from(roles)
.where(eq(roles.roleId, roleId))
.limit(1);
if (role.length === 0) {
const parsedBody = verifyRoleAccessSchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Role with ID ${roleId} not found`
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
if (!req.userOrg) {
const { roleIds } = parsedBody.data;
const allRoleIds = roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]);
if (allRoleIds.length === 0) {
return next();
}
try {
const rolesData = await db
.select()
.from(roles)
.where(inArray(roles.roleId, allRoleIds));
if (rolesData.length !== allRoleIds.length) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"One or more roles not found"
)
);
}
// Check user access to each role's organization
for (const role of rolesData) {
const userOrgRole = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, role[0].orgId!)
eq(userOrgs.orgId, role.orgId!)
)
)
.limit(1);
req.userOrg = userOrgRole[0];
}
if (!req.userOrg) {
if (userOrgRole.length === 0) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
`User does not have access to organization for role ID ${role.roleId}`
)
);
}
if (req.userOrg.orgId !== role[0].orgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Role does not belong to the organization"
)
);
}
req.userOrgRoleId = req.userOrg.roleId;
req.userOrgId = req.userOrg.orgId;
return next();
} catch (error) {
logger.error("Error verifying role access:", error);

View file

@ -20,6 +20,7 @@ import {
verifyTargetAccess,
verifyRoleAccess,
verifyUserAccess,
verifyUserInRole,
} from "./auth";
import { verifyUserHasAction } from "./auth/verifyUserHasAction";
import { ActionsEnum } from "@server/auth/actions";
@ -135,12 +136,13 @@ authenticated.post(
); // maybe make this /invite/create instead
authenticated.post("/invite/accept", user.acceptInvite);
// authenticated.get(
// "/resource/:resourceId/roles",
// verifyResourceAccess,
// verifyUserHasAction(ActionsEnum.listResourceRoles),
// resource.listResourceRoles
// );
authenticated.get(
"/resource/:resourceId/roles",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.listResourceRoles),
resource.listResourceRoles
);
authenticated.get(
"/resource/:resourceId",
verifyResourceAccess,
@ -251,20 +253,15 @@ authenticated.post(
// verifyUserHasAction(ActionsEnum.listRoleSites),
// role.listRoleSites
// );
// authenticated.put(
// "/role/:roleId/resource",
// verifyRoleAccess,
// verifyUserInRole,
// verifyUserHasAction(ActionsEnum.addRoleResource),
// role.addRoleResource
// );
// authenticated.delete(
// "/role/:roleId/resource",
// verifyRoleAccess,
// verifyUserInRole,
// verifyUserHasAction(ActionsEnum.removeRoleResource),
// role.removeRoleResource
// );
authenticated.post(
"/resource/:resourceId/roles",
verifyResourceAccess,
verifyRoleAccess,
verifyUserHasAction(ActionsEnum.setResourceRoles),
role.addRoleResource
);
// authenticated.get(
// "/role/:roleId/resources",
// verifyRoleAccess,

View file

@ -15,6 +15,7 @@ import createHttpError from "http-errors";
import { eq, and } from "drizzle-orm";
import stoi from "@server/utils/stoi";
import { fromError } from "zod-validation-error";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
const createResourceParamsSchema = z.object({
siteId: z
@ -28,7 +29,7 @@ const createResourceParamsSchema = z.object({
const createResourceSchema = z
.object({
name: z.string().min(1).max(255),
subdomain: z.string().min(1).max(255).optional(),
subdomain: subdomainSchema,
})
.strict();
@ -87,12 +88,9 @@ export async function createResource(
);
}
const fullDomain = `${subdomain}.${org[0].domain}`;
const newResource = await db
.insert(resources)
.values({
fullDomain,
siteId,
orgId,
name,

View file

@ -13,6 +13,23 @@ const listResourceRolesSchema = z.object({
resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
});
async function query(resourceId: number) {
return await db
.select({
roleId: roles.roleId,
name: roles.name,
description: roles.description,
isAdmin: roles.isAdmin,
})
.from(roleResources)
.innerJoin(roles, eq(roleResources.roleId, roles.roleId))
.where(eq(roleResources.resourceId, resourceId));
}
export type ListResourceRolesResponse = {
roles: NonNullable<Awaited<ReturnType<typeof query>>>;
};
export async function listResourceRoles(
req: Request,
res: Response,
@ -31,19 +48,12 @@ export async function listResourceRoles(
const { resourceId } = parsedParams.data;
const resourceRolesList = await db
.select({
roleId: roles.roleId,
name: roles.name,
description: roles.description,
isAdmin: roles.isAdmin,
})
.from(roleResources)
.innerJoin(roles, eq(roleResources.roleId, roles.roleId))
.where(eq(roleResources.resourceId, resourceId));
const resourceRolesList = await query(resourceId);
return response(res, {
data: resourceRolesList,
return response<ListResourceRolesResponse>(res, {
data: {
roles: resourceRolesList,
},
success: true,
error: false,
message: "Resource roles retrieved successfully",

View file

@ -0,0 +1,111 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { roleResources, roles } from "@server/db/schema";
import response from "@server/utils/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and, ne } from "drizzle-orm";
const setResourceRolesBodySchema = z.object({
roleIds: z.array(z.number().int().positive()),
});
const setResourceRolesParamsSchema = z.object({
resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
});
export async function addRoleResource(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = setResourceRolesBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { roleIds } = parsedBody.data;
const parsedParams = setResourceRolesParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
// get this org's admin role
const adminRole = await db
.select()
.from(roles)
.where(
and(
eq(roles.name, "Admin"),
eq(roles.orgId, req.userOrg!.orgId)
)
)
.limit(1);
if (!adminRole.length) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Admin role not found"
)
);
}
if (roleIds.includes(adminRole[0].roleId)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Admin role cannot be assigned to resources"
)
);
}
await db.transaction(async (trx) => {
await trx.delete(roleResources).where(
and(
eq(roleResources.resourceId, resourceId),
ne(roleResources.roleId, adminRole[0].roleId) // delete all but the admin role
)
);
const newRoleResources = await Promise.all(
roleIds.map((roleId) =>
trx
.insert(roleResources)
.values({ roleId, resourceId })
.returning()
)
);
return response(res, {
data: {},
success: true,
error: false,
message: "Roles set for resource successfully",
status: HttpCode.CREATED,
});
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -8,6 +8,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
const updateResourceParamsSchema = z.object({
resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
@ -16,7 +17,7 @@ const updateResourceParamsSchema = z.object({
const updateResourceBodySchema = z
.object({
name: z.string().min(1).max(255).optional(),
subdomain: z.string().min(1).max(255).optional(),
subdomain: subdomainSchema.optional(),
ssl: z.boolean().optional(),
// siteId: z.number(),
})

View file

@ -1,70 +0,0 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { roleResources } from "@server/db/schema";
import response from "@server/utils/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
const addRoleResourceParamsSchema = z.object({
roleId: z.string().transform(Number).pipe(z.number().int().positive()),
});
const addRoleResourceSchema = z.object({
resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
});
export async function addRoleResource(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = addRoleResourceSchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { resourceId } = parsedBody.data;
const parsedParams = addRoleResourceParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { roleId } = parsedParams.data;
const newRoleResource = await db
.insert(roleResources)
.values({
roleId,
resourceId,
})
.returning();
return response(res, {
data: newRoleResource[0],
success: true,
error: false,
message: "Resource added to role successfully",
status: HttpCode.CREATED,
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -1,5 +1,5 @@
export * from "./addRoleAction";
export * from "./addRoleResource";
export * from "../resource/setResourceRoles";
export * from "./addRoleSite";
export * from "./createRole";
export * from "./deleteRole";

View file

@ -18,10 +18,15 @@ export async function traefikConfigProvider(
schema.resources,
eq(schema.targets.resourceId, schema.resources.resourceId)
)
.innerJoin(
schema.orgs,
eq(schema.resources.orgId, schema.orgs.orgId)
)
.where(
and(
eq(schema.targets.enabled, true),
isNotNull(schema.resources.fullDomain)
isNotNull(schema.resources.subdomain),
isNotNull(schema.orgs.domain)
)
);
@ -60,15 +65,22 @@ export async function traefikConfigProvider(
for (const item of all) {
const target = item.targets;
const resource = item.resources;
const org = item.orgs;
const routerName = `${target.targetId}-router`;
const serviceName = `${target.targetId}-service`;
if (!resource.fullDomain) {
if (!resource || !resource.subdomain) {
continue;
}
const domainParts = resource.fullDomain.split(".");
if (!org || !org.domain) {
continue;
}
const fullDomain = `${resource.subdomain}.${org.domain}`;
const domainParts = fullDomain.split(".");
let wildCard;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
@ -97,7 +109,7 @@ export async function traefikConfigProvider(
],
middlewares: resource.ssl ? [badgerMiddlewareName] : [],
service: serviceName,
rule: `Host(\`${resource.fullDomain}\`)`,
rule: `Host(\`${fullDomain}\`)`,
...(resource.ssl ? { tls } : {}),
};
@ -107,7 +119,7 @@ export async function traefikConfigProvider(
entryPoints: [config.traefik.http_entrypoint],
middlewares: [redirectMiddlewareName],
service: serviceName,
rule: `Host(\`${resource.fullDomain}\`)`,
rule: `Host(\`${fullDomain}\`)`,
};
}

View file

@ -0,0 +1,9 @@
import { z } from "zod";
export const subdomainSchema = z
.string()
.regex(
/^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/,
"Invalid subdomain format"
)
.min(1, "Subdomain must be at least 1 character long");

View file

@ -123,7 +123,7 @@ export default function CreateRoleForm({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
className="space-y-6"
id="create-role-form"
>
<FormField

View file

@ -155,19 +155,22 @@ export default function DeleteRoleForm({
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<p className="mb-1">
<div className="space-y-6">
<div className="space-y-4">
<p>
You're about to delete the{" "}
<b>{roleToDelete.name}</b> role. You cannot undo
this action.
<b>{roleToDelete.name}</b> role. You cannot
undo this action.
</p>
<p className="mb-4">
Before deleting this role, please select a new role
to transfer existing members to.
<p>
Before deleting this role, please select a
new role to transfer existing members to.
</p>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
className="space-y-6"
id="remove-role-form"
>
<FormField
@ -177,7 +180,9 @@ export default function DeleteRoleForm({
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={field.onChange}
onValueChange={
field.onChange
}
value={field.value}
>
<FormControl>
@ -188,7 +193,9 @@ export default function DeleteRoleForm({
<SelectContent>
{roles.map((role) => (
<SelectItem
key={role.roleId}
key={
role.roleId
}
value={role.roleId.toString()}
>
{role.name}
@ -202,6 +209,7 @@ export default function DeleteRoleForm({
/>
</form>
</Form>
</div>
</CredenzaBody>
<CredenzaFooter>
<Button

View file

@ -64,6 +64,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
return (
<>
<div className="flex items-center justify-end">
{!roleRow.isAdmin && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -92,6 +93,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</>
);
},

View file

@ -110,6 +110,7 @@ export default function AccessControlsPage() {
return (
<>
<div className="space-y-6">
<SettingsSectionTitle
title="Access Controls"
description="Manage what this user can access and do in the organization"
@ -119,7 +120,7 @@ export default function AccessControlsPage() {
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
className="space-y-6"
>
<FormField
control={form.control}
@ -151,11 +152,16 @@ export default function AccessControlsPage() {
</FormItem>
)}
/>
<Button type="submit" loading={loading} disabled={loading}>
<Button
type="submit"
loading={loading}
disabled={loading}
>
Save Changes
</Button>
</form>
</Form>
</div>
</>
);
}

View file

@ -171,6 +171,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-6">
{!inviteLink && (
<Form {...form}>
<form
@ -211,16 +212,20 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
{roles.map(
(role) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{role.name}
{
role.name
}
</SelectItem>
))}
)
)}
</SelectContent>
</Select>
<FormMessage />
@ -232,7 +237,9 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
name="validForHours"
render={({ field }) => (
<FormItem>
<FormLabel>Valid For</FormLabel>
<FormLabel>
Valid For
</FormLabel>
<Select
onValueChange={
field.onChange
@ -270,17 +277,19 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
)}
{inviteLink && (
<div className="max-w-md">
<p className="mb-4">
The user has been successfully invited. They
must access the link below to accept the
invitation.
<div className="max-w-md space-y-4">
<p>
The user has been successfully invited.
They must access the link below to
accept the invitation.
</p>
<p className="mb-4">
<p>
The invite will expire in{" "}
<b>
{expiresInDays}{" "}
{expiresInDays === 1 ? "day" : "days"}
{expiresInDays === 1
? "day"
: "days"}
</b>
.
</p>
@ -290,6 +299,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
/>
</div>
)}
</div>
</CredenzaBody>
<CredenzaFooter>
<Button

View file

@ -8,7 +8,7 @@ import {
DropdownMenuTrigger,
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import { ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
import { ArrowRight, ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
import { UsersDataTable } from "./UsersDataTable";
import { useState } from "react";
import InviteUserForm from "./InviteUserForm";
@ -112,7 +112,9 @@ export default function UsersTable({ users: u }: UsersTableProps) {
return (
<>
<div className="flex items-center justify-end">
{!userRow.isOwner && (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@ -138,8 +140,12 @@ export default function UsersTable({ users: u }: UsersTableProps) {
<button
className="text-red-600 hover:text-red-800"
onClick={() => {
setIsDeleteModalOpen(true);
setSelectedUser(userRow);
setIsDeleteModalOpen(
true
);
setSelectedUser(
userRow
);
}}
>
Remove User
@ -148,7 +154,21 @@ export default function UsersTable({ users: u }: UsersTableProps) {
)}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"gray"}
className="ml-2"
onClick={() =>
router.push(
`/${org?.org.orgId}/settings/access/users/${userRow.id}`
)
}
>
Manage{" "}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</>
)}
</div>
</>
);
},
@ -194,13 +214,13 @@ export default function UsersTable({ users: u }: UsersTableProps) {
setSelectedUser(null);
}}
dialog={
<div>
<p className="mb-2">
<div className="space-y-4">
<p>
Are you sure you want to remove{" "}
<b>{selectedUser?.email}</b> from the organization?
</p>
<p className="mb-2">
<p>
Once removed, this user will no longer have access
to the organization. You can always re-invite them
later, but they will need to accept the invitation
@ -213,10 +233,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
</p>
</div>
}
buttonText="Confirm remove user"
buttonText="Confirm Remove User"
onConfirm={removeUser}
string={selectedUser?.email ?? ""}
title="Remove user from organization"
title="Remove User from Organization"
/>
<InviteUserForm

View file

@ -97,7 +97,7 @@ export default function Header({ email, orgName, name, orgs }: HeaderProps) {
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={logout}>
Log out
Logout
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>

View file

@ -71,7 +71,6 @@ export default async function GeneralSettingsPage({
<SettingsSectionTitle
title="General"
description="Configure your organization's general settings"
size="1xl"
/>
<SidebarSettings sidebarNavItems={sidebarNavItems}>

View file

@ -46,6 +46,7 @@ export default function GeneralPage() {
title="Delete organization"
/>
<div className="space-y-6">
{orgUser.isOwner ? (
<Button onClick={() => setIsDeleteModalOpen(true)}>
Delete Organization
@ -53,6 +54,7 @@ export default function GeneralPage() {
) : (
<p>Nothing to see here</p>
)}
</div>
</>
);
}

View file

@ -0,0 +1,204 @@
"use client";
import { useEffect, useState } from "react";
import api from "@app/api";
import { ListRolesResponse } from "@server/routers/role";
import { useToast } from "@app/hooks/useToast";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/utils";
import { ListResourceRolesResponse } from "@server/routers/resource";
import { Button } from "@app/components/ui/button";
import { set, z } from "zod";
import { Tag } from "emblor";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@app/components/ui/form";
import { TagInput } from "emblor";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
const FormSchema = z.object({
roles: z.array(
z.object({
id: z.string(),
text: z.string(),
})
),
});
export default function ResourceAuthenticationPage() {
const { toast } = useToast();
const { org } = useOrgContext();
const { resource } = useResourceContext();
const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>(
[]
);
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: { roles: [] },
});
useEffect(() => {
api.get<AxiosResponse<ListRolesResponse>>(
`/org/${org?.org.orgId}/roles`
)
.then((res) => {
setAllRoles(
res.data.data.roles
.map((role) => ({
id: role.roleId.toString(),
text: role.name,
}))
.filter((role) => role.text !== "Admin")
);
})
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: "Failed to fetch roles",
description: formatAxiosError(
e,
"An error occurred while fetching the roles"
),
});
});
api.get<AxiosResponse<ListResourceRolesResponse>>(
`/resource/${resource.resourceId}/roles`
)
.then((res) => {
form.setValue(
"roles",
res.data.data.roles
.map((i) => ({
id: i.roleId.toString(),
text: i.name,
}))
.filter((role) => role.text !== "Admin")
);
})
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: "Failed to fetch roles",
description: formatAxiosError(
e,
"An error occurred while fetching the roles"
),
});
});
}, []);
async function onSubmit(data: z.infer<typeof FormSchema>) {
try {
setLoading(true);
await api.post(`/resource/${resource.resourceId}/roles`, {
roleIds: data.roles.map((i) => parseInt(i.id)),
});
toast({
title: "Roles set",
description: "Roles set for resource successfully",
});
} catch (e) {
console.error(e);
toast({
variant: "destructive",
title: "Failed to set roles",
description: formatAxiosError(
e,
"An error occurred while setting the roles"
),
});
} finally {
setLoading(false);
}
}
return (
<>
<div className="space-y-6 lg:max-w-2xl">
<SettingsSectionTitle
title="Users & Roles"
description="Configure who can visit this resource"
size="1xl"
/>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
<FormField
control={form.control}
name="roles"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>Roles</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
placeholder="Enter a role"
tags={form.getValues().roles}
setTags={(newRoles) => {
form.setValue(
"roles",
newRoles as [Tag, ...Tag[]]
);
}}
enableAutocomplete={true}
autocompleteOptions={allRoles}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
styleClasses={{
tag: {
body: "bg-muted hover:bg-accent text-foreground p-2",
},
input: "border-none bg-transparent text-inherit placeholder:text-inherit shadow-none"
}}
inputFieldPosition={"top"}
/>
</FormControl>
<FormDescription>
Users with these roles will be able to
access this resource. Admins can always
access this resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
loading={loading}
disabled={loading}
>
Save Changes
</Button>
</form>
</Form>
</div>
</>
);
}

View file

@ -6,19 +6,17 @@ import { Input } from "@/components/ui/input";
interface CustomDomainInputProps {
domainSuffix: string;
placeholder?: string;
value: string;
onChange?: (value: string) => void;
}
export default function CustomDomainInput(
{
export default function CustomDomainInput({
domainSuffix,
placeholder = "Enter subdomain",
value: defaultValue,
onChange,
}: CustomDomainInputProps = {
domainSuffix: ".example.com",
}
) {
const [value, setValue] = React.useState("");
}: CustomDomainInputProps) {
const [value, setValue] = React.useState(defaultValue);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value;

View file

@ -0,0 +1,89 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { InfoIcon, LinkIcon, CheckIcon, CopyIcon } from "lucide-react";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { useResourceContext } from "@app/hooks/useResourceContext";
type ResourceInfoBoxType = {};
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
const [copied, setCopied] = useState(false);
const { org } = useOrgContext();
const { resource } = useResourceContext();
const fullUrl = `${resource.ssl ? "https" : "http"}://${
resource.subdomain
}.${org.org.domain}`;
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(fullUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy text: ", err);
}
};
return (
<Card>
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
Resource Information
</AlertTitle>
<AlertDescription className="mt-3">
<p className="mb-2">
The current full URL for this resource is:
</p>
<div className="flex items-center space-x-2 bg-muted p-2 rounded-md">
<LinkIcon className="h-4 w-4" />
<a
href={fullUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-mono flex-grow hover:underline"
>
{fullUrl}
</a>
<Button
variant="outline"
size="sm"
onClick={copyToClipboard}
className="ml-2"
type="button"
>
{copied ? (
<CheckIcon className="h-4 w-4 text-green-500" />
) : (
<CopyIcon className="h-4 w-4" />
)}
<span className="ml-2">
{copied ? "Copied!" : "Copy"}
</span>
</Button>
</div>
{/* <ul className="mt-3 space-y-1 text-sm list-disc list-inside">
<li>
Protocol:{" "}
<span className="font-semibold">{protocol}</span>
</li>
<li>
Subdomain:{" "}
<span className="font-semibold">{subdomain}</span>
</li>
<li>
Domain:{" "}
<span className="font-semibold">{domain}</span>
</li>
</ul> */}
</AlertDescription>
</Alert>
</Card>
);
}

View file

@ -339,9 +339,7 @@ export default function ReverseProxyTargets(props: {
return (
<div>
{/* <div className="lg:max-w-2xl"> */}
<div>
<div className="mb-8">
<div className="space-y-6">
<SettingsSectionTitle
title="SSL"
description="Setup SSL to secure your connections with LetsEncrypt certificates"
@ -356,9 +354,7 @@ export default function ReverseProxyTargets(props: {
/>
<Label htmlFor="ssl-toggle">Enable SSL (https)</Label>
</div>
</div>
<div className="mb-8">
<SettingsSectionTitle
title="Targets"
description="Setup targets to route traffic to your services"
@ -367,10 +363,7 @@ export default function ReverseProxyTargets(props: {
<Form {...addTargetForm}>
<form
onSubmit={addTargetForm.handleSubmit(
addTarget as any
)}
className="space-y-4"
onSubmit={addTargetForm.handleSubmit(addTarget as any)}
>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<FormField
@ -383,8 +376,7 @@ export default function ReverseProxyTargets(props: {
<Input id="ip" {...field} />
</FormControl>
<FormDescription>
Enter the IP address of the
target
Enter the IP address of the target
</FormDescription>
<FormMessage />
</FormItem>
@ -420,8 +412,8 @@ export default function ReverseProxyTargets(props: {
</Select>
</FormControl>
<FormDescription>
Choose the method for how the
target is accessed
Choose the method for how the target
is accessed
</FormDescription>
<FormMessage />
</FormItem>
@ -492,9 +484,8 @@ export default function ReverseProxyTargets(props: {
</Button>
</form>
</Form>
</div>
<div className="rounded-md mt-4">
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@ -540,9 +531,7 @@ export default function ReverseProxyTargets(props: {
</TableBody>
</Table>
</div>
</div>
<div className="mt-8">
<Button onClick={saveAll} loading={loading} disabled={loading}>
Save Changes
</Button>

View file

@ -39,10 +39,15 @@ import { useForm } from "react-hook-form";
import { GetResourceResponse } from "@server/routers/resource";
import { useToast } from "@app/hooks/useToast";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { useOrgContext } from "@app/hooks/useOrgContext";
import CustomDomainInput from "../components/CustomDomainInput";
import ResourceInfoBox from "../components/ResourceInfoBox";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
const GeneralFormSchema = z.object({
name: z.string(),
siteId: z.number(),
subdomain: subdomainSchema,
// siteId: z.number(),
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@ -51,16 +56,19 @@ export default function GeneralForm() {
const params = useParams();
const { toast } = useToast();
const { resource, updateResource } = useResourceContext();
const { org } = useOrgContext();
const orgId = params.orgId;
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [saveLoading, setSaveLoading] = useState(false);
const [domainSuffix, setDomainSuffix] = useState(org.org.domain);
const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
name: resource.name,
subdomain: resource.subdomain,
// siteId: resource.siteId!,
},
mode: "onChange",
@ -78,12 +86,12 @@ export default function GeneralForm() {
async function onSubmit(data: GeneralFormValues) {
setSaveLoading(true);
updateResource({ name: data.name, siteId: data.siteId });
api.post<AxiosResponse<GetResourceResponse>>(
`resource/${resource?.resourceId}`,
{
name: data.name,
subdomain: data.subdomain,
// siteId: data.siteId,
}
)
@ -102,13 +110,15 @@ export default function GeneralForm() {
title: "Resource updated",
description: "The resource has been updated successfully",
});
updateResource({ name: data.name, subdomain: data.subdomain });
})
.finally(() => setSaveLoading(false));
}
return (
<>
<div className="lg:max-w-2xl">
<div className="lg:max-w-2xl space-y-6">
<SettingsSectionTitle
title="General Settings"
description="Configure the general settings for this resource"
@ -118,7 +128,7 @@ export default function GeneralForm() {
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
className="space-y-6"
>
<FormField
control={form.control}
@ -130,13 +140,46 @@ export default function GeneralForm() {
<Input {...field} />
</FormControl>
<FormDescription>
This is the display name of the
resource.
This is the display name of the resource
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<SettingsSectionTitle
title="Domain"
description="Define the domain that users will use to access this resource"
size="1xl"
/>
<FormField
control={form.control}
name="subdomain"
render={({ field }) => (
<FormItem>
<FormLabel>Subdomain</FormLabel>
<FormControl>
<CustomDomainInput
value={field.value}
domainSuffix={domainSuffix}
placeholder="Enter subdomain"
onChange={(value) =>
form.setValue(
"subdomain",
value
)
}
/>
</FormControl>
{/* <FormDescription>
This is the subdomain that will be used
to access the resource
</FormDescription> */}
<FormMessage />
</FormItem>
)}
/>
{/* <FormField
control={form.control}
name="siteId"

View file

@ -8,6 +8,10 @@ import { SidebarSettings } from "@app/components/SidebarSettings";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider";
import { cache } from "react";
import ResourceInfoBox from "./components/ResourceInfoBox";
interface ResourceLayoutProps {
children: React.ReactNode;
@ -20,7 +24,6 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
const { children } = props;
let resource = null;
try {
const res = await internal.get<AxiosResponse<GetResourceResponse>>(
`/resource/${params.resourceId}`,
@ -31,6 +34,28 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
redirect(`/${params.orgId}/settings/resources`);
}
if (!resource) {
redirect(`/${params.orgId}/settings/resources`);
}
let org = null;
try {
const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`,
await authCookieHeader()
)
);
const res = await getOrg();
org = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/resources`);
}
if (!org) {
redirect(`/${params.orgId}/settings/resources`);
}
const sidebarNavItems = [
{
title: "General",
@ -65,14 +90,19 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
description="Configure the settings on your resource"
/>
<OrgProvider org={org}>
<ResourceProvider resource={resource}>
<SidebarSettings
sidebarNavItems={sidebarNavItems}
limitWidth={false}
>
<div className="mb-8">
<ResourceInfoBox />
</div>
{children}
</SidebarSettings>
</ResourceProvider>
</OrgProvider>
</>
);
}

View file

@ -48,16 +48,11 @@ import { CaretSortIcon } from "@radix-ui/react-icons";
import CustomDomainInput from "../[resourceId]/components/CustomDomainInput";
import { Axios, AxiosResponse } from "axios";
import { Resource } from "@server/db/schema";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
const accountFormSchema = z.object({
subdomain: z
.string()
.min(2, {
message: "Name must be at least 2 characters.",
})
.max(30, {
message: "Name must not be longer than 30 characters.",
}),
subdomain: subdomainSchema,
name: z.string(),
siteId: z.number(),
});
@ -65,7 +60,7 @@ const accountFormSchema = z.object({
type AccountFormValues = z.infer<typeof accountFormSchema>;
const defaultValues: Partial<AccountFormValues> = {
subdomain: "someanimalherefromapi",
subdomain: "",
name: "My Resource",
};
@ -86,8 +81,10 @@ export default function CreateResourceForm({
const orgId = params.orgId;
const router = useRouter();
const { org } = useOrgContext();
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [domainSuffix, setDomainSuffix] = useState<string>(".example.com");
const [domainSuffix, setDomainSuffix] = useState<string>(org.org.domain);
const form = useForm<AccountFormValues>({
resolver: zodResolver(accountFormSchema),
@ -193,9 +190,15 @@ export default function CreateResourceForm({
<FormLabel>Subdomain</FormLabel>
<FormControl>
<CustomDomainInput
{...field}
value={field.value}
domainSuffix={domainSuffix}
placeholder="Enter subdomain"
onChange={(value) =>
form.setValue(
"subdomain",
value
)
}
/>
</FormControl>
<FormDescription>

View file

@ -196,10 +196,10 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
</p>
</div>
}
buttonText="Confirm delete resource"
buttonText="Confirm Delete Resource"
onConfirm={async () => deleteResource(selectedResource!.id)}
string={selectedResource.name}
title="Delete resource"
title="Delete Resource"
/>
)}

View file

@ -4,6 +4,10 @@ import ResourcesTable, { ResourceRow } from "./components/ResourcesTable";
import { AxiosResponse } from "axios";
import { ListResourcesResponse } from "@server/routers/resource";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { redirect } from "next/navigation";
import { cache } from "react";
import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider";
type ResourcesPageProps = {
params: Promise<{ orgId: string }>;
@ -22,6 +26,24 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
console.error("Error fetching resources", e);
}
let org = null;
try {
const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`,
await authCookieHeader()
)
);
const res = await getOrg();
org = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/resources`);
}
if (!org) {
redirect(`/${params.orgId}/settings/resources`);
}
const resourceRows: ResourceRow[] = resources.map((resource) => {
return {
id: resource.resourceId,
@ -39,7 +61,9 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
description="Create secure proxies to your private applications"
/>
<OrgProvider org={org}>
<ResourcesTable resources={resourceRows} orgId={params.orgId} />
</OrgProvider>
</>
);
}

View file

@ -64,6 +64,7 @@ export default function GeneralPage() {
return (
<>
<div className="space-y-6">
<SettingsSectionTitle
title="General Settings"
description="Configure the general settings for this site"
@ -73,7 +74,7 @@ export default function GeneralPage() {
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
className="space-y-6"
>
<FormField
control={form.control}
@ -91,9 +92,10 @@ export default function GeneralPage() {
</FormItem>
)}
/>
<Button type="submit">Update Site</Button>
<Button type="submit">Save Changes</Button>
</form>
</Form>
</div>
</>
);
}

View file

@ -191,10 +191,11 @@ sh get-docker.sh`;
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-6">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
className="space-y-6"
id="create-site-form"
>
<FormField
@ -205,13 +206,13 @@ sh get-docker.sh`;
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Your name"
placeholder="Site name"
{...field}
/>
</FormControl>
<FormDescription>
This is the name that will be
displayed for this site.
This is the name that will
be displayed for this site.
</FormDescription>
<FormMessage />
</FormItem>
@ -259,7 +260,8 @@ sh get-docker.sh`;
) : form.watch("method") === "wg" &&
isLoading ? (
<p>
Loading WireGuard configuration...
Loading WireGuard
configuration...
</p>
) : (
<CopyTextBox
@ -269,7 +271,7 @@ sh get-docker.sh`;
)}
</div>
<span className="text-sm text-muted-foreground mt-2">
<span className="text-sm text-muted-foreground">
You will only be able to see the
configuration once.
</span>
@ -278,7 +280,9 @@ sh get-docker.sh`;
<Checkbox
id="terms"
checked={isChecked}
onCheckedChange={handleCheckboxChange}
onCheckedChange={
handleCheckboxChange
}
/>
<label
htmlFor="terms"
@ -289,6 +293,7 @@ sh get-docker.sh`;
</div>
</form>
</Form>
</div>
</CredenzaBody>
<CredenzaFooter>
<Button

View file

@ -174,14 +174,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
setSelectedSite(null);
}}
dialog={
<div>
<p className="mb-2">
<div className="space-y-4">
<p>
Are you sure you want to remove the site{" "}
<b>{selectedSite?.name || selectedSite?.id}</b>{" "}
from the organization?
</p>
<p className="mb-2">
<p>
Once removed, the site will no longer be
accessible.{" "}
<b>
@ -196,10 +196,10 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
</p>
</div>
}
buttonText="Confirm delete site"
buttonText="Confirm Delete Site"
onConfirm={async () => deleteSite(selectedSite!.id)}
string={selectedSite.name}
title="Delete site"
title="Delete Site"
/>
)}

View file

@ -2,7 +2,7 @@ import { GetOrgResponse } from "@server/routers/org";
import { createContext } from "react";
interface OrgContextType {
org: GetOrgResponse | null;
org: GetOrgResponse;
updateOrg: (updateOrg: Partial<GetOrgResponse>) => void;
}

View file

@ -12,6 +12,10 @@ interface OrgProviderProps {
export function OrgProvider({ children, org: serverOrg }: OrgProviderProps) {
const [org, setOrg] = useState<GetOrgResponse | null>(serverOrg);
if (!org) {
throw new Error("No org provided");
}
const updateOrg = (updatedOrg: Partial<GetOrgResponse>) => {
if (!org) {
throw new Error("No org to update");