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", "cookie-parser": "1.4.6",
"cors": "2.8.5", "cors": "2.8.5",
"drizzle-orm": "0.33.0", "drizzle-orm": "0.33.0",
"emblor": "1.4.6",
"express": "4.21.0", "express": "4.21.0",
"express-rate-limit": "7.4.0", "express-rate-limit": "7.4.0",
"glob": "11.0.0", "glob": "11.0.0",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,6 +13,23 @@ const listResourceRolesSchema = z.object({
resourceId: z.string().transform(Number).pipe(z.number().int().positive()), 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( export async function listResourceRoles(
req: Request, req: Request,
res: Response, res: Response,
@ -31,19 +48,12 @@ export async function listResourceRoles(
const { resourceId } = parsedParams.data; const { resourceId } = parsedParams.data;
const resourceRolesList = await db const resourceRolesList = await query(resourceId);
.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));
return response(res, { return response<ListResourceRolesResponse>(res, {
data: resourceRolesList, data: {
roles: resourceRolesList,
},
success: true, success: true,
error: false, error: false,
message: "Resource roles retrieved successfully", 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 createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
const updateResourceParamsSchema = z.object({ const updateResourceParamsSchema = z.object({
resourceId: z.string().transform(Number).pipe(z.number().int().positive()), resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
@ -16,7 +17,7 @@ const updateResourceParamsSchema = z.object({
const updateResourceBodySchema = z const updateResourceBodySchema = z
.object({ .object({
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
subdomain: z.string().min(1).max(255).optional(), subdomain: subdomainSchema.optional(),
ssl: z.boolean().optional(), ssl: z.boolean().optional(),
// siteId: z.number(), // 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 "./addRoleAction";
export * from "./addRoleResource"; export * from "../resource/setResourceRoles";
export * from "./addRoleSite"; export * from "./addRoleSite";
export * from "./createRole"; export * from "./createRole";
export * from "./deleteRole"; export * from "./deleteRole";

View file

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

View file

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

View file

@ -64,34 +64,36 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
return ( return (
<> <>
{!roleRow.isAdmin && ( <div className="flex items-center justify-end">
<DropdownMenu> {!roleRow.isAdmin && (
<DropdownMenuTrigger asChild> <DropdownMenu>
<Button <DropdownMenuTrigger asChild>
variant="ghost" <Button
className="h-8 w-8 p-0" variant="ghost"
> className="h-8 w-8 p-0"
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<button
className="text-red-600 hover:text-red-800"
onClick={() => {
setIsDeleteModalOpen(true);
setUserToRemove(roleRow);
}}
> >
Delete Role <span className="sr-only">
</button> Open menu
</DropdownMenuItem> </span>
</DropdownMenuContent> <MoreHorizontal className="h-4 w-4" />
</DropdownMenu> </Button>
)} </DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<button
className="text-red-600 hover:text-red-800"
onClick={() => {
setIsDeleteModalOpen(true);
setUserToRemove(roleRow);
}}
>
Delete Role
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</> </>
); );
}, },

View file

@ -110,52 +110,58 @@ export default function AccessControlsPage() {
return ( return (
<> <>
<SettingsSectionTitle <div className="space-y-6">
title="Access Controls" <SettingsSectionTitle
description="Manage what this user can access and do in the organization" title="Access Controls"
size="1xl" description="Manage what this user can access and do in the organization"
/> size="1xl"
/>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4" className="space-y-6"
> >
<FormField <FormField
control={form.control} control={form.control}
name="roleId" name="roleId"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Role</FormLabel> <FormLabel>Role</FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
value={field.value} value={field.value}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select role" /> <SelectValue placeholder="Select role" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{roles.map((role) => ( {roles.map((role) => (
<SelectItem <SelectItem
key={role.roleId} key={role.roleId}
value={role.roleId.toString()} value={role.roleId.toString()}
> >
{role.name} {role.name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<Button type="submit" loading={loading} disabled={loading}> <Button
Save Changes type="submit"
</Button> loading={loading}
</form> disabled={loading}
</Form> >
Save Changes
</Button>
</form>
</Form>
</div>
</> </>
); );
} }

View file

@ -171,125 +171,135 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
{!inviteLink && ( <div className="space-y-6">
<Form {...form}> {!inviteLink && (
<form <Form {...form}>
onSubmit={form.handleSubmit(onSubmit)} <form
className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}
id="invite-user-form" className="space-y-4"
> id="invite-user-form"
<FormField >
control={form.control} <FormField
name="email" control={form.control}
render={({ field }) => ( name="email"
<FormItem> render={({ field }) => (
<FormLabel>Email</FormLabel> <FormItem>
<FormControl> <FormLabel>Email</FormLabel>
<Input
placeholder="Enter an email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl> <FormControl>
<SelectTrigger> <Input
<SelectValue placeholder="Select role" /> placeholder="Enter an email"
</SelectTrigger> {...field}
/>
</FormControl> </FormControl>
<SelectContent> <FormMessage />
{roles.map((role) => ( </FormItem>
<SelectItem )}
key={ />
role.roleId <FormField
} control={form.control}
value={role.roleId.toString()} name="roleId"
> render={({ field }) => (
{role.name} <FormItem>
</SelectItem> <FormLabel>Role</FormLabel>
))} <Select
</SelectContent> onValueChange={
</Select> field.onChange
<FormMessage /> }
</FormItem> >
)} <FormControl>
/> <SelectTrigger>
<FormField <SelectValue placeholder="Select role" />
control={form.control} </SelectTrigger>
name="validForHours" </FormControl>
render={({ field }) => ( <SelectContent>
<FormItem> {roles.map(
<FormLabel>Valid For</FormLabel> (role) => (
<Select <SelectItem
onValueChange={ key={
field.onChange role.roleId
} }
defaultValue={field.value.toString()} value={role.roleId.toString()}
> >
<FormControl> {
<SelectTrigger> role.name
<SelectValue placeholder="Select duration" /> }
</SelectTrigger> </SelectItem>
</FormControl> )
<SelectContent> )}
{validFor.map( </SelectContent>
(option) => ( </Select>
<SelectItem <FormMessage />
key={ </FormItem>
option.hours )}
} />
value={option.hours.toString()} <FormField
> control={form.control}
{ name="validForHours"
option.name render={({ field }) => (
} <FormItem>
</SelectItem> <FormLabel>
) Valid For
)} </FormLabel>
</SelectContent> <Select
</Select> onValueChange={
<FormMessage /> field.onChange
</FormItem> }
)} defaultValue={field.value.toString()}
/> >
</form> <FormControl>
</Form> <SelectTrigger>
)} <SelectValue placeholder="Select duration" />
</SelectTrigger>
</FormControl>
<SelectContent>
{validFor.map(
(option) => (
<SelectItem
key={
option.hours
}
value={option.hours.toString()}
>
{
option.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
{inviteLink && ( {inviteLink && (
<div className="max-w-md"> <div className="max-w-md space-y-4">
<p className="mb-4"> <p>
The user has been successfully invited. They The user has been successfully invited.
must access the link below to accept the They must access the link below to
invitation. accept the invitation.
</p> </p>
<p className="mb-4"> <p>
The invite will expire in{" "} The invite will expire in{" "}
<b> <b>
{expiresInDays}{" "} {expiresInDays}{" "}
{expiresInDays === 1 ? "day" : "days"} {expiresInDays === 1
</b> ? "day"
. : "days"}
</p> </b>
<CopyTextBox .
text={inviteLink} </p>
wrapText={false} <CopyTextBox
/> text={inviteLink}
</div> wrapText={false}
)} />
</div>
)}
</div>
</CredenzaBody> </CredenzaBody>
<CredenzaFooter> <CredenzaFooter>
<Button <Button

View file

@ -8,7 +8,7 @@ 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, Crown, MoreHorizontal } from "lucide-react"; import { ArrowRight, 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";
@ -112,43 +112,63 @@ export default function UsersTable({ users: u }: UsersTableProps) {
return ( return (
<> <>
{!userRow.isOwner && ( <div className="flex items-center justify-end">
<DropdownMenu> {!userRow.isOwner && (
<DropdownMenuTrigger asChild> <>
<Button <DropdownMenu>
variant="ghost" <DropdownMenuTrigger asChild>
className="h-8 w-8 p-0" <Button
> variant="ghost"
<span className="sr-only"> className="h-8 w-8 p-0"
Open menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
>
Manage User
</Link>
</DropdownMenuItem>
{userRow.email !== user?.email && (
<DropdownMenuItem>
<button
className="text-red-600 hover:text-red-800"
onClick={() => {
setIsDeleteModalOpen(true);
setSelectedUser(userRow);
}}
> >
Remove User <span className="sr-only">
</button> Open menu
</DropdownMenuItem> </span>
)} <MoreHorizontal className="h-4 w-4" />
</DropdownMenuContent> </Button>
</DropdownMenu> </DropdownMenuTrigger>
)} <DropdownMenuContent align="end">
<DropdownMenuItem>
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
>
Manage User
</Link>
</DropdownMenuItem>
{userRow.email !== user?.email && (
<DropdownMenuItem>
<button
className="text-red-600 hover:text-red-800"
onClick={() => {
setIsDeleteModalOpen(
true
);
setSelectedUser(
userRow
);
}}
>
Remove User
</button>
</DropdownMenuItem>
)}
</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); setSelectedUser(null);
}} }}
dialog={ dialog={
<div> <div className="space-y-4">
<p className="mb-2"> <p>
Are you sure you want to remove{" "} Are you sure you want to remove{" "}
<b>{selectedUser?.email}</b> from the organization? <b>{selectedUser?.email}</b> from the organization?
</p> </p>
<p className="mb-2"> <p>
Once removed, this user will no longer have access Once removed, this user will no longer have access
to the organization. You can always re-invite them to the organization. You can always re-invite them
later, but they will need to accept the invitation later, but they will need to accept the invitation
@ -213,10 +233,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
</p> </p>
</div> </div>
} }
buttonText="Confirm remove user" buttonText="Confirm Remove User"
onConfirm={removeUser} onConfirm={removeUser}
string={selectedUser?.email ?? ""} string={selectedUser?.email ?? ""}
title="Remove user from organization" title="Remove User from Organization"
/> />
<InviteUserForm <InviteUserForm

View file

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

View file

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

View file

@ -46,13 +46,15 @@ export default function GeneralPage() {
title="Delete organization" title="Delete organization"
/> />
{orgUser.isOwner ? ( <div className="space-y-6">
<Button onClick={() => setIsDeleteModalOpen(true)}> {orgUser.isOwner ? (
Delete Organization <Button onClick={() => setIsDeleteModalOpen(true)}>
</Button> Delete Organization
) : ( </Button>
<p>Nothing to see here</p> ) : (
)} <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 { interface CustomDomainInputProps {
domainSuffix: string; domainSuffix: string;
placeholder?: string; placeholder?: string;
value: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
} }
export default function CustomDomainInput( export default function CustomDomainInput({
{ domainSuffix,
domainSuffix, placeholder = "Enter subdomain",
placeholder = "Enter subdomain", value: defaultValue,
onChange, onChange,
}: CustomDomainInputProps = { }: CustomDomainInputProps) {
domainSuffix: ".example.com", const [value, setValue] = React.useState(defaultValue);
}
) {
const [value, setValue] = React.useState("");
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value; 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,117 +339,109 @@ export default function ReverseProxyTargets(props: {
return ( return (
<div> <div>
{/* <div className="lg:max-w-2xl"> */} <div className="space-y-6">
<div> <SettingsSectionTitle
<div className="mb-8"> title="SSL"
<SettingsSectionTitle description="Setup SSL to secure your connections with LetsEncrypt certificates"
title="SSL" size="1xl"
description="Setup SSL to secure your connections with LetsEncrypt certificates" />
size="1xl"
/>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch <Switch
id="ssl-toggle" id="ssl-toggle"
defaultChecked={resource.ssl} defaultChecked={resource.ssl}
onCheckedChange={(val) => setSslEnabled(val)} onCheckedChange={(val) => setSslEnabled(val)}
/> />
<Label htmlFor="ssl-toggle">Enable SSL (https)</Label> <Label htmlFor="ssl-toggle">Enable SSL (https)</Label>
</div>
</div> </div>
<div className="mb-8"> <SettingsSectionTitle
<SettingsSectionTitle title="Targets"
title="Targets" description="Setup targets to route traffic to your services"
description="Setup targets to route traffic to your services" size="1xl"
size="1xl" />
/>
<Form {...addTargetForm}> <Form {...addTargetForm}>
<form <form
onSubmit={addTargetForm.handleSubmit( onSubmit={addTargetForm.handleSubmit(addTarget as any)}
addTarget as any >
)} <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
className="space-y-4" <FormField
> control={addTargetForm.control}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> name="ip"
<FormField render={({ field }) => (
control={addTargetForm.control} <FormItem>
name="ip" <FormLabel>IP Address</FormLabel>
render={({ field }) => ( <FormControl>
<FormItem> <Input id="ip" {...field} />
<FormLabel>IP Address</FormLabel> </FormControl>
<FormControl> <FormDescription>
<Input id="ip" {...field} /> Enter the IP address of the target
</FormControl> </FormDescription>
<FormDescription> <FormMessage />
Enter the IP address of the </FormItem>
target )}
</FormDescription> />
<FormMessage /> <FormField
</FormItem> control={addTargetForm.control}
)} name="method"
/> render={({ field }) => (
<FormField <FormItem>
control={addTargetForm.control} <FormLabel>Method</FormLabel>
name="method" <FormControl>
render={({ field }) => ( <Select
<FormItem> {...field}
<FormLabel>Method</FormLabel> onValueChange={(value) => {
<FormControl> addTargetForm.setValue(
<Select "method",
{...field} value
onValueChange={(value) => { );
addTargetForm.setValue( }}
"method", >
value <SelectTrigger id="method">
); <SelectValue placeholder="Select method" />
}} </SelectTrigger>
> <SelectContent>
<SelectTrigger id="method"> <SelectItem value="http">
<SelectValue placeholder="Select method" /> HTTP
</SelectTrigger> </SelectItem>
<SelectContent> <SelectItem value="https">
<SelectItem value="http"> HTTPS
HTTP </SelectItem>
</SelectItem> </SelectContent>
<SelectItem value="https"> </Select>
HTTPS </FormControl>
</SelectItem> <FormDescription>
</SelectContent> Choose the method for how the target
</Select> is accessed
</FormControl> </FormDescription>
<FormDescription> <FormMessage />
Choose the method for how the </FormItem>
target is accessed )}
</FormDescription> />
<FormMessage /> <FormField
</FormItem> control={addTargetForm.control}
)} name="port"
/> render={({ field }) => (
<FormField <FormItem>
control={addTargetForm.control} <FormLabel>Port</FormLabel>
name="port" <FormControl>
render={({ field }) => ( <Input
<FormItem> id="port"
<FormLabel>Port</FormLabel> type="number"
<FormControl> {...field}
<Input required
id="port" />
type="number" </FormControl>
{...field} <FormDescription>
required Specify the port number for the
/> target
</FormControl> </FormDescription>
<FormDescription> <FormMessage />
Specify the port number for the </FormItem>
target )}
</FormDescription> />
<FormMessage /> {/* <FormField
</FormItem>
)}
/>
{/* <FormField
control={addTargetForm.control} control={addTargetForm.control}
name="protocol" name="protocol"
render={({ field }) => ( render={({ field }) => (
@ -486,15 +478,14 @@ export default function ReverseProxyTargets(props: {
</FormItem> </FormItem>
)} )}
/> */} /> */}
</div> </div>
<Button type="submit" variant="gray"> <Button type="submit" variant="gray">
Add Target Add Target
</Button> </Button>
</form> </form>
</Form> </Form>
</div>
<div className="rounded-md mt-4"> <div className="rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
@ -540,9 +531,7 @@ export default function ReverseProxyTargets(props: {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div>
<div className="mt-8">
<Button onClick={saveAll} loading={loading} disabled={loading}> <Button onClick={saveAll} loading={loading} disabled={loading}>
Save Changes Save Changes
</Button> </Button>

View file

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

View file

@ -8,6 +8,10 @@ import { SidebarSettings } from "@app/components/SidebarSettings";
import Link from "next/link"; import Link from "next/link";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; 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 { interface ResourceLayoutProps {
children: React.ReactNode; children: React.ReactNode;
@ -20,7 +24,6 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
const { children } = props; const { children } = props;
let resource = null; let resource = null;
try { try {
const res = await internal.get<AxiosResponse<GetResourceResponse>>( const res = await internal.get<AxiosResponse<GetResourceResponse>>(
`/resource/${params.resourceId}`, `/resource/${params.resourceId}`,
@ -31,6 +34,28 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
redirect(`/${params.orgId}/settings/resources`); 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 = [ const sidebarNavItems = [
{ {
title: "General", title: "General",
@ -65,14 +90,19 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
description="Configure the settings on your resource" description="Configure the settings on your resource"
/> />
<ResourceProvider resource={resource}> <OrgProvider org={org}>
<SidebarSettings <ResourceProvider resource={resource}>
sidebarNavItems={sidebarNavItems} <SidebarSettings
limitWidth={false} sidebarNavItems={sidebarNavItems}
> limitWidth={false}
{children} >
</SidebarSettings> <div className="mb-8">
</ResourceProvider> <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 CustomDomainInput from "../[resourceId]/components/CustomDomainInput";
import { Axios, AxiosResponse } from "axios"; import { Axios, AxiosResponse } from "axios";
import { Resource } from "@server/db/schema"; import { Resource } from "@server/db/schema";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
const accountFormSchema = z.object({ const accountFormSchema = z.object({
subdomain: z subdomain: subdomainSchema,
.string()
.min(2, {
message: "Name must be at least 2 characters.",
})
.max(30, {
message: "Name must not be longer than 30 characters.",
}),
name: z.string(), name: z.string(),
siteId: z.number(), siteId: z.number(),
}); });
@ -65,7 +60,7 @@ const accountFormSchema = z.object({
type AccountFormValues = z.infer<typeof accountFormSchema>; type AccountFormValues = z.infer<typeof accountFormSchema>;
const defaultValues: Partial<AccountFormValues> = { const defaultValues: Partial<AccountFormValues> = {
subdomain: "someanimalherefromapi", subdomain: "",
name: "My Resource", name: "My Resource",
}; };
@ -86,8 +81,10 @@ export default function CreateResourceForm({
const orgId = params.orgId; const orgId = params.orgId;
const router = useRouter(); const router = useRouter();
const { org } = useOrgContext();
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]); 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>({ const form = useForm<AccountFormValues>({
resolver: zodResolver(accountFormSchema), resolver: zodResolver(accountFormSchema),
@ -193,9 +190,15 @@ export default function CreateResourceForm({
<FormLabel>Subdomain</FormLabel> <FormLabel>Subdomain</FormLabel>
<FormControl> <FormControl>
<CustomDomainInput <CustomDomainInput
{...field} value={field.value}
domainSuffix={domainSuffix} domainSuffix={domainSuffix}
placeholder="Enter subdomain" placeholder="Enter subdomain"
onChange={(value) =>
form.setValue(
"subdomain",
value
)
}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>

View file

@ -196,10 +196,10 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
</p> </p>
</div> </div>
} }
buttonText="Confirm delete resource" buttonText="Confirm Delete Resource"
onConfirm={async () => deleteResource(selectedResource!.id)} onConfirm={async () => deleteResource(selectedResource!.id)}
string={selectedResource.name} 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 { AxiosResponse } from "axios";
import { ListResourcesResponse } from "@server/routers/resource"; import { ListResourcesResponse } from "@server/routers/resource";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; 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 = { type ResourcesPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
@ -22,6 +26,24 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
console.error("Error fetching resources", e); 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) => { const resourceRows: ResourceRow[] = resources.map((resource) => {
return { return {
id: resource.resourceId, id: resource.resourceId,
@ -39,7 +61,9 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
description="Create secure proxies to your private applications" description="Create secure proxies to your private applications"
/> />
<ResourcesTable resources={resourceRows} orgId={params.orgId} /> <OrgProvider org={org}>
<ResourcesTable resources={resourceRows} orgId={params.orgId} />
</OrgProvider>
</> </>
); );
} }

View file

@ -64,36 +64,38 @@ export default function GeneralPage() {
return ( return (
<> <>
<SettingsSectionTitle <div className="space-y-6">
title="General Settings" <SettingsSectionTitle
description="Configure the general settings for this site" title="General Settings"
size="1xl" description="Configure the general settings for this site"
/> size="1xl"
/>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4" className="space-y-6"
> >
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Name</FormLabel> <FormLabel>Name</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
This is the display name of the site This is the display name of the site
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<Button type="submit">Update Site</Button> <Button type="submit">Save Changes</Button>
</form> </form>
</Form> </Form>
</div>
</> </>
); );
} }

View file

@ -191,104 +191,109 @@ sh get-docker.sh`;
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
<Form {...form}> <div className="space-y-6">
<form <Form {...form}>
onSubmit={form.handleSubmit(onSubmit)} <form
className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}
id="create-site-form" className="space-y-6"
> id="create-site-form"
<FormField >
control={form.control} <FormField
name="name" control={form.control}
render={({ field }) => ( name="name"
<FormItem> render={({ field }) => (
<FormLabel>Name</FormLabel> <FormItem>
<FormControl> <FormLabel>Name</FormLabel>
<Input <FormControl>
placeholder="Your name" <Input
{...field} placeholder="Site name"
/> {...field}
</FormControl> />
<FormDescription> </FormControl>
This is the name that will be <FormDescription>
displayed for this site. This is the name that will
</FormDescription> be displayed for this site.
<FormMessage /> </FormDescription>
</FormItem> <FormMessage />
)} </FormItem>
/> )}
<FormField
control={form.control}
name="method"
render={({ field }) => (
<FormItem>
<FormLabel>Method</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={
field.onChange
}
>
<SelectTrigger>
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="wg">
WireGuard
</SelectItem>
<SelectItem value="newt">
Newt
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
This is how you will connect
your site to Fossorial.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="max-w-md">
{form.watch("method") === "wg" &&
!isLoading ? (
<CopyTextBox text={wgConfig} />
) : form.watch("method") === "wg" &&
isLoading ? (
<p>
Loading WireGuard configuration...
</p>
) : (
<CopyTextBox
text={newtConfig}
wrapText={false}
/>
)}
</div>
<span className="text-sm text-muted-foreground mt-2">
You will only be able to see the
configuration once.
</span>
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
checked={isChecked}
onCheckedChange={handleCheckboxChange}
/> />
<label <FormField
htmlFor="terms" control={form.control}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" name="method"
> render={({ field }) => (
I have copied the config <FormItem>
</label> <FormLabel>Method</FormLabel>
</div> <FormControl>
</form> <Select
</Form> value={field.value}
onValueChange={
field.onChange
}
>
<SelectTrigger>
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="wg">
WireGuard
</SelectItem>
<SelectItem value="newt">
Newt
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
This is how you will connect
your site to Fossorial.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="max-w-md">
{form.watch("method") === "wg" &&
!isLoading ? (
<CopyTextBox text={wgConfig} />
) : form.watch("method") === "wg" &&
isLoading ? (
<p>
Loading WireGuard
configuration...
</p>
) : (
<CopyTextBox
text={newtConfig}
wrapText={false}
/>
)}
</div>
<span className="text-sm text-muted-foreground">
You will only be able to see the
configuration once.
</span>
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
checked={isChecked}
onCheckedChange={
handleCheckboxChange
}
/>
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I have copied the config
</label>
</div>
</form>
</Form>
</div>
</CredenzaBody> </CredenzaBody>
<CredenzaFooter> <CredenzaFooter>
<Button <Button

View file

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

View file

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