This commit is contained in:
Owen Schwartz 2024-11-15 21:54:47 -05:00
commit 598ff561e5
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
102 changed files with 4021 additions and 2307 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) {
}
try {
const role = await db
.select()
.from(roles)
.where(eq(roles.roleId, roleId))
.limit(1);
if (role.length === 0) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.BAD_REQUEST,
`Role with ID ${roleId} not found` 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 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

@ -10,6 +10,7 @@ import {
import { and, eq, or } from "drizzle-orm"; import { and, eq, or } 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";
export async function verifySiteAccess( export async function verifySiteAccess(
req: Request, req: Request,
@ -28,6 +29,7 @@ export async function verifySiteAccess(
} }
if (isNaN(siteId)) { if (isNaN(siteId)) {
logger.debug(JSON.stringify(req.body));
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid site ID")); return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid site ID"));
} }

View file

@ -38,7 +38,7 @@ export async function verifyUserAccess(
req.userOrg = res[0]; req.userOrg = res[0];
} }
if (req.userOrg) { if (!req.userOrg) {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,

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,
@ -370,7 +367,7 @@ authRouter.use(
authRouter.put("/signup", auth.signup); authRouter.put("/signup", auth.signup);
authRouter.post("/login", auth.login); authRouter.post("/login", auth.login);
authRouter.post("/logout", auth.logout); authRouter.post("/logout", auth.logout);
authRouter.post('/newt/get-token', getToken); authRouter.post("/newt/get-token", getToken);
authRouter.post("/2fa/enable", verifySessionUserMiddleware, auth.verifyTotp); authRouter.post("/2fa/enable", verifySessionUserMiddleware, auth.verifyTotp);
authRouter.post( authRouter.post(

View file

@ -12,11 +12,13 @@ import config from "@server/config";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { defaultRoleAllowedActions } from "../role"; import { defaultRoleAllowedActions } from "../role";
const createOrgSchema = z.object({ const createOrgSchema = z
.object({
orgId: z.string(), orgId: z.string(),
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
// domain: z.string().min(1).max(255).optional(), // domain: z.string().min(1).max(255).optional(),
}); })
.strict();
const MAX_ORGS = 5; const MAX_ORGS = 5;

View file

@ -18,6 +18,7 @@ const updateOrgBodySchema = z
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
domain: z.string().min(1).max(255).optional(), domain: z.string().min(1).max(255).optional(),
}) })
.strict()
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
message: "At least one field must be provided for update", message: "At least one field must be provided for update",
}); });

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
@ -25,10 +26,12 @@ const createResourceParamsSchema = z.object({
orgId: z.string(), orgId: z.string(),
}); });
const createResourceSchema = z.object({ const createResourceSchema = z
.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();
export type CreateResourceResponse = Resource; export type CreateResourceResponse = Resource;
@ -85,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

@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { resources } from "@server/db/schema"; import { Resource, resources } from "@server/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import response from "@server/utils/response"; import response from "@server/utils/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@ -12,12 +12,7 @@ const getResourceSchema = z.object({
resourceId: z.string().transform(Number).pipe(z.number().int().positive()), resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
}); });
export type GetResourceResponse = { export type GetResourceResponse = Resource;
resourceId: number;
siteId: number;
orgId: string;
name: string;
};
export async function getResource( export async function getResource(
req: Request, req: Request,
@ -53,12 +48,7 @@ export async function getResource(
} }
return response(res, { return response(res, {
data: { data: resource[0],
resourceId: resource[0].resourceId,
siteId: resource[0].siteId,
orgId: resource[0].orgId,
name: resource[0].name,
},
success: true, success: true,
error: false, error: false,
message: "Resource retrieved successfully", message: "Resource retrieved successfully",

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

@ -1,13 +1,14 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { resources } from "@server/db/schema"; import { resources, sites } from "@server/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import response from "@server/utils/response"; import response from "@server/utils/response";
import HttpCode from "@server/types/HttpCode"; 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,8 +17,11 @@ 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(),
// siteId: z.number(),
}) })
.strict()
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
message: "At least one field must be provided for update", message: "At least one field must be provided for update",
}); });

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

@ -14,10 +14,12 @@ const createRoleParamsSchema = z.object({
orgId: z.string(), orgId: z.string(),
}); });
const createRoleSchema = z.object({ const createRoleSchema = z
.object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
description: z.string().optional(), description: z.string().optional(),
}); })
.strict();
export const defaultRoleAllowedActions: ActionsEnum[] = [ export const defaultRoleAllowedActions: ActionsEnum[] = [
ActionsEnum.getOrg, ActionsEnum.getOrg,

View file

@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { roles } from "@server/db/schema"; import { roles, userOrgs } from "@server/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import response from "@server/utils/response"; import response from "@server/utils/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@ -13,6 +13,10 @@ const deleteRoleSchema = z.object({
roleId: z.string().transform(Number).pipe(z.number().int().positive()), roleId: z.string().transform(Number).pipe(z.number().int().positive()),
}); });
const deelteRoleBodySchema = z.object({
roleId: z.string().transform(Number).pipe(z.number().int().positive()),
});
export async function deleteRole( export async function deleteRole(
req: Request, req: Request,
res: Response, res: Response,
@ -29,7 +33,27 @@ export async function deleteRole(
); );
} }
const parsedBody = deelteRoleBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { roleId } = parsedParams.data; const { roleId } = parsedParams.data;
const { roleId: newRoleId } = parsedBody.data;
if (roleId === newRoleId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Cannot delete a role and assign the same role`
)
);
}
const role = await db const role = await db
.select() .select()
@ -55,20 +79,30 @@ export async function deleteRole(
); );
} }
const deletedRole = await db const newRole = await db
.delete(roles) .select()
.where(eq(roles.roleId, roleId)) .from(roles)
.returning(); .where(eq(roles.roleId, newRoleId))
.limit(1);
if (deletedRole.length === 0) { if (newRole.length === 0) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.NOT_FOUND,
`Role with ID ${roleId} not found` `Role with ID ${newRoleId} not found`
) )
); );
} }
// move all users from the userOrgs table with roleId to newRoleId
await db
.update(userOrgs)
.set({ roleId: newRoleId })
.where(eq(userOrgs.roleId, roleId));
// delete the old role
await db.delete(roles).where(eq(roles.roleId, roleId));
return response(res, { return response(res, {
data: null, data: null,
success: true, success: true,

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,6 +18,7 @@ const updateRoleBodySchema = z
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
description: z.string().optional(), description: z.string().optional(),
}) })
.strict()
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
message: "At least one field must be provided for update", message: "At least one field must be provided for update",
}); });

View file

@ -15,13 +15,15 @@ const createSiteParamsSchema = z.object({
orgId: z.string(), orgId: z.string(),
}); });
const createSiteSchema = z.object({ const createSiteSchema = z
.object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
exitNodeId: z.number().int().positive(), exitNodeId: z.number().int().positive(),
subdomain: z.string().min(1).max(255).optional(), subdomain: z.string().min(1).max(255).optional(),
pubKey: z.string().optional(), pubKey: z.string().optional(),
subnet: z.string(), subnet: z.string(),
}); })
.strict();
export type CreateSiteResponse = { export type CreateSiteResponse = {
name: string; name: string;
@ -83,10 +85,7 @@ export async function createSite(
}; };
} }
const [newSite] = await db const [newSite] = await db.insert(sites).values(payload).returning();
.insert(sites)
.values(payload)
.returning();
const adminRole = await db const adminRole = await db
.select() .select()

View file

@ -76,7 +76,7 @@ export async function listSites(
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
parsedParams.error.errors.map((e) => e.message).join(", ") fromError(parsedParams.error)
) )
); );
} }

View file

@ -76,6 +76,7 @@ export async function pickSiteDefaults(
status: HttpCode.OK, status: HttpCode.OK,
}); });
} catch (error) { } catch (error) {
throw error;
logger.error(error); logger.error(error);
return next( return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")

View file

@ -23,6 +23,7 @@ const updateSiteBodySchema = z
megabytesIn: z.number().int().nonnegative().optional(), megabytesIn: z.number().int().nonnegative().optional(),
megabytesOut: z.number().int().nonnegative().optional(), megabytesOut: z.number().int().nonnegative().optional(),
}) })
.strict()
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
message: "At least one field must be provided for update", message: "At least one field must be provided for update",
}); });

View file

@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { resources, sites, targets } from "@server/db/schema"; import { resources, sites, Target, targets } from "@server/db/schema";
import response from "@server/utils/response"; import response from "@server/utils/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@ -15,13 +15,17 @@ const createTargetParamsSchema = z.object({
resourceId: z.string().transform(Number).pipe(z.number().int().positive()), resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
}); });
const createTargetSchema = z.object({ const createTargetSchema = z
.object({
ip: z.string().ip(), ip: z.string().ip(),
method: z.string().min(1).max(10), method: z.string().min(1).max(10),
port: z.number().int().min(1).max(65535), port: z.number().int().min(1).max(65535),
protocol: z.string().optional(), protocol: z.string().optional(),
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
}); })
.strict();
export type CreateTargetResponse = Target;
export async function createTarget( export async function createTarget(
req: Request, req: Request,
@ -102,6 +106,7 @@ export async function createTarget(
.insert(targets) .insert(targets)
.values({ .values({
resourceId, resourceId,
protocol: "tcp", // hard code for now
...targetData, ...targetData,
}) })
.returning(); .returning();
@ -126,7 +131,7 @@ export async function createTarget(
allowedIps: targetIps.flat(), allowedIps: targetIps.flat(),
}); });
return response(res, { return response<CreateTargetResponse>(res, {
data: newTarget[0], data: newTarget[0],
success: true, success: true,
error: false, error: false,

View file

@ -15,12 +15,12 @@ const updateTargetParamsSchema = z.object({
const updateTargetBodySchema = z const updateTargetBodySchema = z
.object({ .object({
// ip: z.string().ip().optional(), // for now we cant update the ip; you will have to delete ip: z.string().ip().optional(), // for now we cant update the ip; you will have to delete
method: z.string().min(1).max(10).optional(), method: z.string().min(1).max(10).optional(),
port: z.number().int().min(1).max(65535).optional(), port: z.number().int().min(1).max(65535).optional(),
protocol: z.string().optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
}) })
.strict()
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
message: "At least one field must be provided for update", message: "At least one field must be provided for update",
}); });

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

@ -89,7 +89,7 @@ export async function acceptInvite(
); );
} }
if (existingUser[0].email !== existingInvite[0].email) { if (req.user && req.user.email !== existingInvite[0].email) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,

View file

@ -8,10 +8,11 @@ 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 stoi from "@server/utils/stoi";
const addUserRoleParamsSchema = z.object({ const addUserRoleParamsSchema = z.object({
userId: z.string(), userId: z.string(),
roleId: z.number().int().positive(), roleId: z.string().transform(stoi).pipe(z.number()),
}); });
export type AddUserRoleResponse = z.infer<typeof addUserRoleParamsSchema>; export type AddUserRoleResponse = z.infer<typeof addUserRoleParamsSchema>;
@ -22,17 +23,17 @@ export async function addUserRole(
next: NextFunction next: NextFunction
): Promise<any> { ): Promise<any> {
try { try {
const parsedBody = addUserRoleParamsSchema.safeParse(req.body); const parsedParams = addUserRoleParamsSchema.safeParse(req.params);
if (!parsedBody.success) { if (!parsedParams.success) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString() fromError(parsedParams.error).toString()
) )
); );
} }
const { userId, roleId } = parsedBody.data; const { userId, roleId } = parsedParams.data;
if (!req.userOrg) { if (!req.userOrg) {
return next( return next(

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

@ -0,0 +1,40 @@
"use client";
import { SidebarSettings } from "@app/components/SidebarSettings";
type AccessPageHeaderAndNavProps = {
children: React.ReactNode;
};
export default function AccessPageHeaderAndNav({
children,
}: AccessPageHeaderAndNavProps) {
const sidebarNavItems = [
{
title: "Users",
href: `/{orgId}/settings/access/users`,
},
{
title: "Roles",
href: `/{orgId}/settings/access/roles`,
},
];
return (
<>
{" "}
<div className="space-y-0.5 select-none mb-6">
<h2 className="text-2xl font-bold tracking-tight">
Users & Roles
</h2>
<p className="text-muted-foreground">
Invite users and add them to roles to manage access to your
organization
</p>
</div>
<SidebarSettings sidebarNavItems={sidebarNavItems}>
{children}
</SidebarSettings>
</>
);
}

View file

@ -1,40 +1,14 @@
import { SidebarSettings } from "@app/components/SidebarSettings";
interface AccessLayoutProps { interface AccessLayoutProps {
children: React.ReactNode; children: React.ReactNode;
params: Promise<{ resourceId: number | string; orgId: string }>; params: Promise<{
resourceId: number | string;
orgId: string;
}>;
} }
export default async function ResourceLayout(props: AccessLayoutProps) { export default async function ResourceLayout(props: AccessLayoutProps) {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
const sidebarNavItems = [ return <>{children}</>;
{
title: "Users",
href: `/{orgId}/settings/access/users`,
},
{
title: "Roles",
href: `/{orgId}/settings/access/roles`,
},
];
return (
<>
<div className="space-y-0.5 select-none mb-6">
<h2 className="text-2xl font-bold tracking-tight">
Users & Roles
</h2>
<p className="text-muted-foreground">
Invite users and add them to roles to manage access to your
organization.
</p>
</div>
<SidebarSettings sidebarNavItems={sidebarNavItems}>
{children}
</SidebarSettings>
</>
);
} }

View file

@ -29,6 +29,7 @@ import {
} from "@app/components/Credenza"; } from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role"; import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/utils";
type CreateRoleFormProps = { type CreateRoleFormProps = {
open: boolean; open: boolean;
@ -74,9 +75,10 @@ export default function CreateRoleForm({
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Failed to create role", title: "Failed to create role",
description: description: formatAxiosError(
e.response?.data?.message || e,
"An error occurred while creating the role.", "An error occurred while creating the role."
),
}); });
}); });
@ -121,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

@ -0,0 +1,231 @@
"use client";
import api from "@app/api";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@app/components/ui/form";
import { useToast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { ListRolesResponse } from "@server/routers/role";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@app/components/ui/select";
import { RoleRow } from "./RolesTable";
import { formatAxiosError } from "@app/lib/utils";
type CreateRoleFormProps = {
open: boolean;
roleToDelete: RoleRow;
setOpen: (open: boolean) => void;
afterDelete?: () => void;
};
const formSchema = z.object({
newRoleId: z.string({ message: "New role is required" }),
});
export default function DeleteRoleForm({
open,
roleToDelete,
setOpen,
afterDelete,
}: CreateRoleFormProps) {
const { toast } = useToast();
const { org } = useOrgContext();
const [loading, setLoading] = useState(false);
const [roles, setRoles] = useState<ListRolesResponse["roles"]>([]);
useEffect(() => {
async function fetchRoles() {
const res = await api
.get<AxiosResponse<ListRolesResponse>>(
`/org/${org?.org.orgId}/roles`
)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: "Failed to fetch roles",
description: formatAxiosError(
e,
"An error occurred while fetching the roles"
),
});
});
if (res?.status === 200) {
setRoles(
res.data.data.roles.filter(
(r) => r.roleId !== roleToDelete.roleId
)
);
}
}
fetchRoles();
}, []);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
newRoleId: "",
},
});
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
const res = await api
.delete(`/role/${roleToDelete.roleId}`, {
data: {
roleId: values.newRoleId,
},
})
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to remove role",
description: formatAxiosError(
e,
"An error occurred while removing the role."
),
});
});
if (res && res.status === 200) {
toast({
variant: "default",
title: "Role removed",
description: "The role has been successfully removed.",
});
if (open) {
setOpen(false);
}
if (afterDelete) {
afterDelete();
}
}
setLoading(false);
}
return (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
setLoading(false);
form.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Remove Role</CredenzaTitle>
<CredenzaDescription>
Remove a role from the organization
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<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.
</p>
<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-6"
id="remove-role-form"
>
<FormField
control={form.control}
name="newRoleId"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={
field.onChange
}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</div>
</CredenzaBody>
<CredenzaFooter>
<Button
type="submit"
form="remove-role-form"
loading={loading}
disabled={loading}
>
Remove Role
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View file

@ -17,6 +17,7 @@ import { useToast } from "@app/hooks/useToast";
import { RolesDataTable } from "./RolesDataTable"; import { RolesDataTable } from "./RolesDataTable";
import { Role } from "@server/db/schema"; import { Role } from "@server/db/schema";
import CreateRoleForm from "./CreateRoleForm"; import CreateRoleForm from "./CreateRoleForm";
import DeleteRoleForm from "./DeleteRoleForm";
export type RoleRow = Role; export type RoleRow = Role;
@ -63,6 +64,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
return ( return (
<> <>
<div className="flex items-center justify-end">
{!roleRow.isAdmin && ( {!roleRow.isAdmin && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@ -91,41 +93,13 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
</div>
</> </>
); );
}, },
}, },
]; ];
async function removeRole() {
if (roleToRemove) {
const res = await api
.delete(`/role/${roleToRemove.roleId}`)
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to remove role",
description:
e.message ??
"An error occurred while removing the role.",
});
});
if (res && res.status === 200) {
toast({
variant: "default",
title: "Role removed",
description: `The role ${roleToRemove.name} has been removed from the organization.`,
});
setRoles((prev) =>
prev.filter((role) => role.roleId !== roleToRemove.roleId)
);
}
}
setIsDeleteModalOpen(false);
}
return ( return (
<> <>
<CreateRoleForm <CreateRoleForm
@ -136,34 +110,19 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
}} }}
/> />
<ConfirmDeleteDialog {roleToRemove && (
<DeleteRoleForm
open={isDeleteModalOpen} open={isDeleteModalOpen}
setOpen={(val) => { setOpen={setIsDeleteModalOpen}
setIsDeleteModalOpen(val); roleToDelete={roleToRemove}
afterDelete={() => {
setRoles((prev) =>
prev.filter((r) => r.roleId !== roleToRemove.roleId)
);
setUserToRemove(null); setUserToRemove(null);
}} }}
dialog={
<div>
<p className="mb-2">
Are you sure you want to remove the role{" "}
<b>{roleToRemove?.name}</b> from the organization?
</p>
<p className="mb-2">
You cannot undo this action. Please select a new
role to move existing users to after deletion.
</p>
<p>
To confirm, please type the name of the role below.
</p>
</div>
}
buttonText="Confirm remove role"
onConfirm={removeRole}
string={roleToRemove?.name ?? ""}
title="Remove role from organization"
/> />
)}
<RolesDataTable <RolesDataTable
columns={columns} columns={columns}

View file

@ -6,6 +6,8 @@ import { cache } from "react";
import OrgProvider from "@app/providers/OrgProvider"; import OrgProvider from "@app/providers/OrgProvider";
import { ListRolesResponse } from "@server/routers/role"; import { ListRolesResponse } from "@server/routers/role";
import RolesTable, { RoleRow } from "./components/RolesTable"; import RolesTable, { RoleRow } from "./components/RolesTable";
import { SidebarSettings } from "@app/components/SidebarSettings";
import AccessPageHeaderAndNav from "../components/AccessPageHeaderAndNav";
type RolesPageProps = { type RolesPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
@ -49,9 +51,11 @@ export default async function RolesPage(props: RolesPageProps) {
return ( return (
<> <>
<AccessPageHeaderAndNav>
<OrgProvider org={org}> <OrgProvider org={org}>
<RolesTable roles={roleRows} /> <RolesTable roles={roleRows} />
</OrgProvider> </OrgProvider>
</AccessPageHeaderAndNav>
</> </>
); );
} }

View file

@ -0,0 +1,167 @@
"use client";
import api from "@app/api";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@app/components/ui/select";
import { useToast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { InviteUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ListRolesResponse } from "@server/routers/role";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { useParams } from "next/navigation";
import { Button } from "@app/components/ui/button";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { formatAxiosError } from "@app/lib/utils";
const formSchema = z.object({
email: z.string().email({ message: "Please enter a valid email" }),
roleId: z.string().min(1, { message: "Please select a role" }),
});
export default function AccessControlsPage() {
const { toast } = useToast();
const { orgUser: user } = userOrgUserContext();
const { orgId } = useParams();
const [loading, setLoading] = useState(false);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: user.email!,
roleId: user.roleId?.toString(),
},
});
useEffect(() => {
async function fetchRoles() {
const res = await api
.get<AxiosResponse<ListRolesResponse>>(`/org/${orgId}/roles`)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: "Failed to fetch roles",
description: formatAxiosError(
e,
"An error occurred while fetching the roles"
),
});
});
if (res?.status === 200) {
setRoles(res.data.data.roles);
}
}
fetchRoles();
form.setValue("roleId", user.roleId.toString());
}, []);
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
const res = await api
.post<AxiosResponse<InviteUserResponse>>(
`/role/${values.roleId}/add/${user.userId}`
)
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to add user to role",
description: formatAxiosError(
e,
"An error occurred while adding user to the role."
),
});
});
if (res && res.status === 200) {
toast({
variant: "default",
title: "User invited",
description: "The user has been updated.",
});
}
setLoading(false);
}
return (
<>
<div className="space-y-6">
<SettingsSectionTitle
title="Access Controls"
description="Manage what this user can access and do in the organization"
size="1xl"
/>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
<FormField
control={form.control}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={role.roleId}
value={role.roleId.toString()}
>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
loading={loading}
disabled={loading}
>
Save Changes
</Button>
</form>
</Form>
</div>
</>
);
}

View file

@ -1,11 +1,20 @@
import SiteProvider from "@app/providers/SiteProvider";
import { internal } from "@app/api"; import { internal } from "@app/api";
import { GetSiteResponse } from "@server/routers/site";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/api/cookies"; import { authCookieHeader } from "@app/api/cookies";
import { SidebarSettings } from "@app/components/SidebarSettings"; import { SidebarSettings } from "@app/components/SidebarSettings";
import { GetOrgUserResponse } from "@server/routers/user"; import { GetOrgUserResponse } from "@server/routers/user";
import OrgUserProvider from "@app/providers/OrgUserProvider";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
interface UserLayoutProps { interface UserLayoutProps {
children: React.ReactNode; children: React.ReactNode;
@ -30,20 +39,31 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
const sidebarNavItems = [ const sidebarNavItems = [
{ {
title: "General", title: "Access Controls",
href: "/{orgId}/settings/access/users/{userId}", href: "/{orgId}/settings/access/users/{userId}/access-controls",
}, },
]; ];
return ( return (
<> <>
<OrgUserProvider orgUser={user}>
<div className="mb-4">
<Link
href="../../"
className="text-muted-foreground hover:underline"
>
<div className="flex flex-row items-center gap-1">
<ArrowLeft className="w-4 h-4" />{" "}
<span>All Users</span>
</div>
</Link>
</div>
<div className="space-y-0.5 select-none mb-6"> <div className="space-y-0.5 select-none mb-6">
<h2 className="text-2xl font-bold tracking-tight"> <h2 className="text-2xl font-bold tracking-tight">
User {user?.email} User {user?.email}
</h2> </h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">Manage user</p>
Manage user access and permissions
</p>
</div> </div>
<SidebarSettings <SidebarSettings
@ -52,6 +72,7 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
> >
{children} {children}
</SidebarSettings> </SidebarSettings>
</OrgUserProvider>
</> </>
); );
} }

View file

@ -1,20 +1,8 @@
import React from "react"; import { redirect } from "next/navigation";
import { Separator } from "@/components/ui/separator";
export default async function UserPage(props: { export default async function UserPage(props: {
params: Promise<{ niceId: string }>; params: Promise<{ orgId: string; userId: string }>;
}) { }) {
const params = await props.params; const { orgId, userId } = await props.params;
redirect(`/${orgId}/settings/access/users/${userId}/access-controls`);
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium">Manage User</h3>
<p className="text-sm text-muted-foreground">
Manage user access and permissions
</p>
</div>
<Separator />
</div>
);
} }

View file

@ -38,6 +38,7 @@ import {
} from "@app/components/Credenza"; } from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { ListRolesResponse } from "@server/routers/role"; import { ListRolesResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/utils";
type InviteUserFormProps = { type InviteUserFormProps = {
open: boolean; open: boolean;
@ -94,9 +95,10 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Failed to fetch roles", title: "Failed to fetch roles",
description: description: formatAxiosError(
e.message || e,
"An error occurred while fetching the roles", "An error occurred while fetching the roles"
),
}); });
}); });
@ -128,9 +130,10 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Failed to invite user", title: "Failed to invite user",
description: description: formatAxiosError(
e.response?.data?.message || e,
"An error occurred while inviting the user.", "An error occurred while inviting the user"
),
}); });
}); });
@ -168,6 +171,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
<div className="space-y-6">
{!inviteLink && ( {!inviteLink && (
<Form {...form}> <Form {...form}>
<form <form
@ -208,16 +212,20 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{roles.map((role) => ( {roles.map(
(role) => (
<SelectItem <SelectItem
key={ key={
role.roleId role.roleId
} }
value={role.roleId.toString()} value={role.roleId.toString()}
> >
{role.name} {
role.name
}
</SelectItem> </SelectItem>
))} )
)}
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@ -229,7 +237,9 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
name="validForHours" name="validForHours"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Valid For</FormLabel> <FormLabel>
Valid For
</FormLabel>
<Select <Select
onValueChange={ onValueChange={
field.onChange field.onChange
@ -267,17 +277,19 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
)} )}
{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
? "day"
: "days"}
</b> </b>
. .
</p> </p>
@ -287,6 +299,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
/> />
</div> </div>
)} )}
</div>
</CredenzaBody> </CredenzaBody>
<CredenzaFooter> <CredenzaFooter>
<Button <Button

View file

@ -1,226 +0,0 @@
"use client";
import api from "@app/api";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@app/components/ui/select";
import { useToast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { InviteUserResponse, ListUsersResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { ListRolesResponse } from "@server/routers/role";
import { ArrayElement } from "@server/types/ArrayElement";
type ManageUserFormProps = {
open: boolean;
setOpen: (open: boolean) => void;
user: ArrayElement<ListUsersResponse["users"]>;
onUserUpdate(): (
user: ArrayElement<ListUsersResponse["users"]>
) => Promise<void>;
};
const formSchema = z.object({
email: z.string().email({ message: "Please enter a valid email" }),
roleId: z.string().min(1, { message: "Please select a role" }),
});
export default function ManageUserForm({
open,
setOpen,
user,
}: ManageUserFormProps) {
const { toast } = useToast();
const { org } = useOrgContext();
const [loading, setLoading] = useState(false);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: user.email,
roleId: user.roleId?.toString(),
},
});
useEffect(() => {
if (!open) {
return;
}
async function fetchRoles() {
const res = await api
.get<AxiosResponse<ListRolesResponse>>(
`/org/${org?.org.orgId}/roles`
)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: "Failed to fetch roles",
description:
e.message ||
"An error occurred while fetching the roles",
});
});
if (res?.status === 200) {
setRoles(res.data.data.roles);
// form.setValue(
// "roleId",
// res.data.data.roles[0].roleId.toString()
// );
}
}
fetchRoles();
}, [open]);
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
const res = await api
.post<AxiosResponse<InviteUserResponse>>(
`/role/${values.roleId}/add/${user.id}`
)
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to add user to role",
description:
e.response?.data?.message ||
"An error occurred while adding user to the role.",
});
});
if (res && res.status === 200) {
toast({
variant: "default",
title: "User invited",
description: "The user has been updated.",
});
}
setLoading(false);
}
return (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
setLoading(false);
form.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Manage User</CredenzaTitle>
<CredenzaDescription>
Update the role of the user in the organization.
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="manage-user-form"
>
<FormField
control={form.control}
name="email"
disabled={true}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="User's email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={role.roleId}
value={role.roleId.toString()}
>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<Button
type="submit"
form="manage-user-form"
loading={loading}
disabled={loading}
>
Save User
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

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";
@ -17,8 +17,9 @@ import { useUserContext } from "@app/hooks/useUserContext";
import api from "@app/api"; import api from "@app/api";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import ManageUserForm from "./ManageUserForm";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/utils";
export type UserRow = { export type UserRow = {
id: string; id: string;
@ -39,6 +40,8 @@ export default function UsersTable({ users: u }: UsersTableProps) {
const [users, setUsers] = useState<UserRow[]>(u); const [users, setUsers] = useState<UserRow[]>(u);
const router = useRouter();
const user = useUserContext(); const user = useUserContext();
const { org } = useOrgContext(); const { org } = useOrgContext();
const { toast } = useToast(); const { toast } = useToast();
@ -109,7 +112,9 @@ export default function UsersTable({ users: u }: UsersTableProps) {
return ( return (
<> <>
<div className="flex items-center justify-end">
{!userRow.isOwner && ( {!userRow.isOwner && (
<>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@ -135,8 +140,12 @@ export default function UsersTable({ users: u }: UsersTableProps) {
<button <button
className="text-red-600 hover:text-red-800" className="text-red-600 hover:text-red-800"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(
setSelectedUser(userRow); true
);
setSelectedUser(
userRow
);
}} }}
> >
Remove User Remove User
@ -145,7 +154,21 @@ export default function UsersTable({ users: u }: UsersTableProps) {
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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>
</> </>
); );
}, },
@ -160,9 +183,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Failed to remove user", title: "Failed to remove user",
description: description: formatAxiosError(
e.message ?? e,
"An error occurred while removing the user.", "An error occurred while removing the user."
),
}); });
}); });
@ -190,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
@ -209,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

@ -8,6 +8,8 @@ import { cache } from "react";
import OrgProvider from "@app/providers/OrgProvider"; import OrgProvider from "@app/providers/OrgProvider";
import UserProvider from "@app/providers/UserProvider"; import UserProvider from "@app/providers/UserProvider";
import { verifySession } from "@app/lib/auth/verifySession"; import { verifySession } from "@app/lib/auth/verifySession";
import { SidebarSettings } from "@app/components/SidebarSettings";
import AccessPageHeaderAndNav from "../components/AccessPageHeaderAndNav";
type UsersPageProps = { type UsersPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
@ -62,11 +64,13 @@ export default async function UsersPage(props: UsersPageProps) {
return ( return (
<> <>
<AccessPageHeaderAndNav>
<UserProvider user={user!}> <UserProvider user={user!}>
<OrgProvider org={org}> <OrgProvider org={org}>
<UsersTable users={userRows} /> <UsersTable users={userRows} />
</OrgProvider> </OrgProvider>
</UserProvider> </UserProvider>
</AccessPageHeaderAndNav>
</> </>
); );
} }

View file

@ -21,6 +21,7 @@ import {
SelectValue, SelectValue,
} from "@app/components/ui/select"; } from "@app/components/ui/select";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/utils";
import { ListOrgsResponse } from "@server/routers/org"; import { ListOrgsResponse } from "@server/routers/org";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@ -51,6 +52,7 @@ export default function Header({ email, orgName, name, orgs }: HeaderProps) {
console.error("Error logging out", e); console.error("Error logging out", e);
toast({ toast({
title: "Error logging out", title: "Error logging out",
description: formatAxiosError(e, "Error logging out"),
}); });
}) })
.then(() => { .then(() => {
@ -95,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

@ -0,0 +1,83 @@
import { internal } from "@app/api";
import { authCookieHeader } from "@app/api/cookies";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { SidebarSettings } from "@app/components/SidebarSettings";
import { verifySession } from "@app/lib/auth/verifySession";
import OrgProvider from "@app/providers/OrgProvider";
import OrgUserProvider from "@app/providers/OrgUserProvider";
import { GetOrgResponse } from "@server/routers/org";
import { GetOrgUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { cache } from "react";
type GeneralSettingsProps = {
children: React.ReactNode;
params: Promise<{ orgId: string }>;
};
export default async function GeneralSettingsPage({
children,
params,
}: GeneralSettingsProps) {
const { orgId } = await params;
const getUser = cache(verifySession);
const user = await getUser();
if (!user) {
redirect("/auth/login");
}
let orgUser = null;
try {
const getOrgUser = cache(async () =>
internal.get<AxiosResponse<GetOrgUserResponse>>(
`/org/${orgId}/user/${user.userId}`,
await authCookieHeader()
)
);
const res = await getOrgUser();
orgUser = res.data.data;
} catch {
redirect(`/${orgId}`);
}
let org = null;
try {
const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${orgId}`,
await authCookieHeader()
)
);
const res = await getOrg();
org = res.data.data;
} catch {
redirect(`/${orgId}`);
}
const sidebarNavItems = [
{
title: "General",
href: `/{orgId}/settings/general`,
},
];
return (
<>
<OrgProvider org={org}>
<OrgUserProvider orgUser={orgUser}>
<SettingsSectionTitle
title="General"
description="Configure your organization's general settings"
/>
<SidebarSettings sidebarNavItems={sidebarNavItems}>
{children}
</SidebarSettings>
</OrgUserProvider>
</OrgProvider>
</>
);
}

View file

@ -0,0 +1,60 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Button } from "@app/components/ui/button";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { useState } from "react";
export default function GeneralPage() {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const { orgUser } = userOrgUserContext();
const { org } = useOrgContext();
async function deleteOrg() {
console.log("not implemented");
}
return (
<>
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
}}
dialog={
<div>
<p className="mb-2">
Are you sure you want to delete the organization{" "}
<b>{org?.org.name}?</b>
</p>
<p className="mb-2">
This action is irreversible and will delete all
associated data.
</p>
<p>
To confirm, type the name of the organization below.
</p>
</div>
}
buttonText="Confirm delete organization"
onConfirm={deleteOrg}
string={org?.org.name || ""}
title="Delete organization"
/>
<div className="space-y-6">
{orgUser.isOwner ? (
<Button onClick={() => setIsDeleteModalOpen(true)}>
Delete Organization
</Button>
) : (
<p>Nothing to see here</p>
)}
</div>
</>
);
}

View file

@ -69,7 +69,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
); );
const orgUser = await getOrgUser(); const orgUser = await getOrgUser();
if (!orgUser.data.data.isAdmin || !orgUser.data.data.isOwner) { if (!orgUser.data.data.isAdmin && !orgUser.data.data.isOwner) {
throw new Error("User is not an admin or owner"); throw new Error("User is not an admin or owner");
} }
} catch { } catch {

View file

@ -6,7 +6,7 @@ type OrgPageProps = {
export default async function SettingsPage(props: OrgPageProps) { export default async function SettingsPage(props: OrgPageProps) {
const params = await props.params; const params = await props.params;
redirect(`/${params.orgId}/settings/sites`); redirect(`/${params.orgId}/settings/resources`);
return <></>; return <></>;
} }

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

@ -1,241 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import {
CaretSortIcon,
CheckIcon,
} from "@radix-ui/react-icons";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { cn } from "@/lib/utils";
import { toast } from "@/hooks/useToast";
import { Button} from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import React, { useState, useEffect } from "react";
import { api } from "@/api";
import { useParams } from "next/navigation";
import { useRouter } from "next/navigation";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ListSitesResponse } from "@server/routers/site";
import { AxiosResponse } from "axios";
import CustomDomainInput from "./CustomDomainInput";
const method = [
{ label: "Wireguard", value: "wg" },
{ label: "Newt", value: "newt" },
] as const;
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.",
}),
name: z.string(),
siteId: z.number(),
});
type AccountFormValues = z.infer<typeof accountFormSchema>;
const defaultValues: Partial<AccountFormValues> = {
subdomain: "someanimalherefromapi",
name: "My Resource",
};
export function CreateResourceForm() {
const params = useParams();
const orgId = params.orgId;
const router = useRouter();
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [domainSuffix, setDomainSuffix] = useState<string>(".example.com");
const form = useForm<AccountFormValues>({
resolver: zodResolver(accountFormSchema),
defaultValues,
});
useEffect(() => {
if (typeof window !== "undefined") {
const fetchSites = async () => {
const res = await api.get<AxiosResponse<ListSitesResponse>>(
`/org/${orgId}/sites/`
);
setSites(res.data.data.sites);
};
fetchSites();
}
}, []);
async function onSubmit(data: AccountFormValues) {
console.log(data);
const res = await api
.put(`/org/${orgId}/site/${data.siteId}/resource/`, {
name: data.name,
subdomain: data.subdomain,
// subdomain: data.subdomain,
})
.catch((e) => {
toast({
title: "Error creating resource...",
});
});
if (res && res.status === 201) {
const niceId = res.data.data.niceId;
// navigate to the resource page
router.push(`/${orgId}/settings/resources/${niceId}`);
}
}
return (
<>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Your name" {...field} />
</FormControl>
<FormDescription>
This is the name that will be displayed for
this resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subdomain"
render={({ field }) => (
<FormItem>
<FormLabel>Subdomain</FormLabel>
<FormControl>
{/* <Input placeholder="Your name" {...field} /> */}
<CustomDomainInput
{...field}
domainSuffix={domainSuffix}
placeholder="Enter subdomain"
/>
</FormControl>
<FormDescription>
This is the fully qualified domain name that
will be used to access the resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Site</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-[350px] justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(site) =>
site.siteId ===
field.value
)?.name
: "Select site"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[350px] p-0">
<Command>
<CommandInput placeholder="Search site..." />
<CommandList>
<CommandEmpty>
No site found.
</CommandEmpty>
<CommandGroup>
{sites.map((site) => (
<CommandItem
value={site.name}
key={site.siteId}
onSelect={() => {
form.setValue(
"siteId",
site.siteId
);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
{site.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
This is the site that will be used in the
dashboard.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Create Resource</Button>
</form>
</Form>
</>
);
}

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;
@ -29,7 +27,7 @@ export default function CustomDomainInput(
}; };
return ( return (
<div className="relative w-full max-w-sm"> <div className="w-full">
<div className="flex"> <div className="flex">
<Input <Input
type="text" type="text"

View file

@ -1,194 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { Input } from "@/components/ui/input";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { ListSitesResponse } from "@server/routers/site";
import { useEffect, useState } from "react";
import { AxiosResponse } from "axios";
import api from "@app/api";
import { useParams } from "next/navigation";
import { useForm } from "react-hook-form";
import { GetResourceResponse } from "@server/routers/resource";
import { useToast } from "@app/hooks/useToast";
const GeneralFormSchema = z.object({
name: z.string(),
siteId: z.number(),
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
export function GeneralForm() {
const params = useParams();
const orgId = params.orgId;
const { resource, updateResource } = useResourceContext();
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const { toast } = useToast();
const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
name: resource?.name,
siteId: resource?.siteId,
},
mode: "onChange",
});
useEffect(() => {
if (typeof window !== "undefined") {
const fetchSites = async () => {
const res = await api.get<AxiosResponse<ListSitesResponse>>(
`/org/${orgId}/sites/`
);
setSites(res.data.data.sites);
};
fetchSites();
}
}, []);
async function onSubmit(data: GeneralFormValues) {
updateResource({ name: data.name, siteId: data.siteId });
await api
.post<AxiosResponse<GetResourceResponse>>(
`resource/${resource?.resourceId}`,
{
name: data.name,
siteId: data.siteId,
}
)
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to update resource",
description:
e.response?.data?.message ||
"An error occurred while updating the resource",
});
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
This is the display name of the resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Site</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-[350px] justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(site) =>
site.siteId ===
field.value
)?.name
: "Select site"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[350px] p-0">
<Command>
<CommandInput placeholder="Search site..." />
<CommandList>
<CommandEmpty>
No site found.
</CommandEmpty>
<CommandGroup>
{sites.map((site) => (
<CommandItem
value={site.name}
key={site.siteId}
onSelect={() => {
form.setValue(
"siteId",
site.siteId
);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
{site.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
This is the site that will be used in the
dashboard.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Update Resource</Button>
</form>
</Form>
);
}

View file

@ -0,0 +1,87 @@
"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";
import Link from "next/link";
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>
<p className="mt-3">
To create a proxy to your private services,{" "}
<Link
href={`/${org.org.orgId}/settings/resources/${resource.resourceId}/connectivity`}
className="text-primary hover:underline"
>
add targets
</Link>{" "}
to this resource
</p>
</AlertDescription>
</Alert>
</Card>
);
}

View file

@ -0,0 +1,541 @@
"use client";
import { useEffect, useState, use } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import api from "@app/api";
import { AxiosResponse } from "axios";
import { ListTargetsResponse } from "@server/routers/target/listTargets";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@app/components/ui/form";
import { CreateTargetResponse } from "@server/routers/target";
import {
ColumnDef,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
getCoreRowModel,
useReactTable,
flexRender,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@app/components/ui/table";
import { useToast } from "@app/hooks/useToast";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { ArrayElement } from "@server/types/ArrayElement";
import { Dot } from "lucide-react";
import { formatAxiosError } from "@app/lib/utils";
const addTargetSchema = z.object({
ip: z.string().ip(),
method: z.string(),
port: z
.string()
.refine((val) => !isNaN(Number(val)), {
message: "Port must be a number",
})
.transform((val) => Number(val)),
// protocol: z.string(),
});
type AddTargetFormValues = z.infer<typeof addTargetSchema>;
type LocalTarget = Omit<
ArrayElement<ListTargetsResponse["targets"]> & {
new?: boolean;
updated?: boolean;
},
"protocol"
>;
export default function ReverseProxyTargets(props: {
params: Promise<{ resourceId: number }>;
}) {
const params = use(props.params);
const { toast } = useToast();
const { resource, updateResource } = useResourceContext();
const [targets, setTargets] = useState<LocalTarget[]>([]);
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
const [sslEnabled, setSslEnabled] = useState(resource.ssl);
const [loading, setLoading] = useState(false);
const addTargetForm = useForm({
resolver: zodResolver(addTargetSchema),
defaultValues: {
ip: "",
method: "http",
port: "80",
// protocol: "TCP",
},
});
useEffect(() => {
const fetchSites = async () => {
const res = await api
.get<AxiosResponse<ListTargetsResponse>>(
`/resource/${params.resourceId}/targets`
)
.catch((err) => {
console.error(err);
toast({
variant: "destructive",
title: "Failed to fetch targets",
description: formatAxiosError(
err,
"An error occurred while fetching targets"
),
});
});
if (res && res.status === 200) {
setTargets(res.data.data.targets);
}
};
fetchSites();
}, []);
async function addTarget(data: AddTargetFormValues) {
const newTarget: LocalTarget = {
...data,
enabled: true,
targetId: new Date().getTime(),
new: true,
resourceId: resource.resourceId,
};
setTargets([...targets, newTarget]);
addTargetForm.reset();
}
const removeTarget = (targetId: number) => {
setTargets([
...targets.filter((target) => target.targetId !== targetId),
]);
if (!targets.find((target) => target.targetId === targetId)?.new) {
setTargetsToRemove([...targetsToRemove, targetId]);
}
};
async function updateTarget(targetId: number, data: Partial<LocalTarget>) {
setTargets(
targets.map((target) =>
target.targetId === targetId
? { ...target, ...data, updated: true }
: target
)
);
}
async function saveAll() {
try {
setLoading(true);
const res = await api.post(`/resource/${params.resourceId}`, {
ssl: sslEnabled,
});
updateResource({ ssl: sslEnabled });
for (const target of targets) {
const data = {
ip: target.ip,
port: target.port,
// protocol: target.protocol,
method: target.method,
enabled: target.enabled,
};
if (target.new) {
const res = await api.put<
AxiosResponse<CreateTargetResponse>
>(`/resource/${params.resourceId}/target`, data);
} else if (target.updated) {
const res = await api.post(
`/target/${target.targetId}`,
data
);
}
setTargets([
...targets.map((t) => {
return {
...t,
new: false,
updated: false,
};
}),
]);
}
for (const targetId of targetsToRemove) {
await api.delete(`/target/${targetId}`);
setTargets(
targets.filter((target) => target.targetId !== targetId)
);
}
toast({
title: "Resource updated",
description: "Resource and targets updated successfully",
});
setTargetsToRemove([]);
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: "Operation failed",
description: formatAxiosError(
err,
"An error occurred during the save operation"
),
});
}
setLoading(false);
}
const columns: ColumnDef<LocalTarget>[] = [
{
accessorKey: "ip",
header: "IP Address",
cell: ({ row }) => (
<Input
defaultValue={row.original.ip}
onBlur={(e) =>
updateTarget(row.original.targetId, {
ip: e.target.value,
})
}
/>
),
},
{
accessorKey: "port",
header: "Port",
cell: ({ row }) => (
<Input
type="number"
defaultValue={row.original.port}
onBlur={(e) =>
updateTarget(row.original.targetId, {
port: parseInt(e.target.value, 10),
})
}
/>
),
},
{
accessorKey: "method",
header: "Method",
cell: ({ row }) => (
<Select
defaultValue={row.original.method}
onValueChange={(value) =>
updateTarget(row.original.targetId, { method: value })
}
>
<SelectTrigger>{row.original.method}</SelectTrigger>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
</SelectContent>
</Select>
),
},
// {
// accessorKey: "protocol",
// header: "Protocol",
// cell: ({ row }) => (
// <Select
// defaultValue={row.original.protocol!}
// onValueChange={(value) =>
// updateTarget(row.original.targetId, { protocol: value })
// }
// >
// <SelectTrigger>{row.original.protocol}</SelectTrigger>
// <SelectContent>
// <SelectItem value="TCP">TCP</SelectItem>
// <SelectItem value="UDP">UDP</SelectItem>
// </SelectContent>
// </Select>
// ),
// },
{
accessorKey: "enabled",
header: "Enabled",
cell: ({ row }) => (
<Switch
defaultChecked={row.original.enabled}
onCheckedChange={(val) =>
updateTarget(row.original.targetId, { enabled: val })
}
/>
),
},
{
id: "actions",
cell: ({ row }) => (
<>
<div className="flex items-center justify-end space-x-2">
{/* <Dot
className={
row.original.new || row.original.updated
? "opacity-100"
: "opacity-0"
}
/> */}
<Button
variant="outline"
onClick={() => removeTarget(row.original.targetId)}
>
Delete
</Button>
</div>
</>
),
},
];
const table = useReactTable({
data: targets,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
});
return (
<div>
<div className="space-y-6">
<SettingsSectionTitle
title="SSL"
description="Setup SSL to secure your connections with LetsEncrypt certificates"
size="1xl"
/>
<div className="flex items-center space-x-2">
<Switch
id="ssl-toggle"
defaultChecked={resource.ssl}
onCheckedChange={(val) => setSslEnabled(val)}
/>
<Label htmlFor="ssl-toggle">Enable SSL (https)</Label>
</div>
<SettingsSectionTitle
title="Targets"
description="Setup targets to route traffic to your services"
size="1xl"
/>
<Form {...addTargetForm}>
<form
onSubmit={addTargetForm.handleSubmit(addTarget as any)}
>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<FormField
control={addTargetForm.control}
name="ip"
render={({ field }) => (
<FormItem>
<FormLabel>IP Address</FormLabel>
<FormControl>
<Input id="ip" {...field} />
</FormControl>
<FormDescription>
Enter the IP address of the target
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addTargetForm.control}
name="method"
render={({ field }) => (
<FormItem>
<FormLabel>Method</FormLabel>
<FormControl>
<Select
{...field}
onValueChange={(value) => {
addTargetForm.setValue(
"method",
value
);
}}
>
<SelectTrigger id="method">
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="http">
HTTP
</SelectItem>
<SelectItem value="https">
HTTPS
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
Choose the method for how the target
is accessed
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={addTargetForm.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
id="port"
type="number"
{...field}
required
/>
</FormControl>
<FormDescription>
Specify the port number for the
target
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* <FormField
control={addTargetForm.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>Protocol</FormLabel>
<FormControl>
<Select
{...field}
onValueChange={(value) => {
addTargetForm.setValue(
"protocol",
value
);
}}
>
<SelectTrigger id="protocol">
<SelectValue placeholder="Select protocol" />
</SelectTrigger>
<SelectContent>
<SelectItem value="UDP">
UDP
</SelectItem>
<SelectItem value="TCP">
TCP
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
Select the protocol used by the
target
</FormDescription>
<FormMessage />
</FormItem>
)}
/> */}
</div>
<Button type="submit" variant="gray">
Add Target
</Button>
</form>
</Form>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No targets. Add a target using the form.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<Button onClick={saveAll} loading={loading} disabled={loading}>
Save Changes
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,272 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { cn, formatAxiosError } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { Input } from "@/components/ui/input";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { ListSitesResponse } from "@server/routers/site";
import { useEffect, useState } from "react";
import { AxiosResponse } from "axios";
import api from "@app/api";
import { useParams } from "next/navigation";
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(),
subdomain: subdomainSchema,
// siteId: z.number(),
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
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",
});
useEffect(() => {
const fetchSites = async () => {
const res = await api.get<AxiosResponse<ListSitesResponse>>(
`/org/${orgId}/sites/`
);
setSites(res.data.data.sites);
};
fetchSites();
}, []);
async function onSubmit(data: GeneralFormValues) {
setSaveLoading(true);
api.post<AxiosResponse<GetResourceResponse>>(
`resource/${resource?.resourceId}`,
{
name: data.name,
subdomain: data.subdomain,
// siteId: data.siteId,
}
)
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to update resource",
description: formatAxiosError(
e,
"An error occurred while updating the resource"
),
});
})
.then(() => {
toast({
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 space-y-6">
<SettingsSectionTitle
title="General Settings"
description="Configure the general settings for this resource"
size="1xl"
/>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
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"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Site</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-[350px] justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(site) =>
site.siteId ===
field.value
)?.name
: "Select site"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[350px] p-0">
<Command>
<CommandInput placeholder="Search sites" />
<CommandList>
<CommandEmpty>
No sites found.
</CommandEmpty>
<CommandGroup>
{sites.map((site) => (
<CommandItem
value={
site.name
}
key={
site.siteId
}
onSelect={() => {
form.setValue(
"siteId",
site.siteId
);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
{site.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
This is the site that will be used in
the dashboard.
</FormDescription>
<FormMessage />
</FormItem>
)}
/> */}
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
>
Save Changes
</Button>
</form>
</Form>
</div>
</>
);
}

View file

@ -5,6 +5,13 @@ import { AxiosResponse } from "axios";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/api/cookies"; import { authCookieHeader } from "@app/api/cookies";
import { SidebarSettings } from "@app/components/SidebarSettings"; 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 { interface ResourceLayoutProps {
children: React.ReactNode; children: React.ReactNode;
@ -17,8 +24,6 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
const { children } = props; const { children } = props;
let resource = null; let resource = null;
if (params.resourceId !== "create") {
try { try {
const res = await internal.get<AxiosResponse<GetResourceResponse>>( const res = await internal.get<AxiosResponse<GetResourceResponse>>(
`/resource/${params.resourceId}`, `/resource/${params.resourceId}`,
@ -28,45 +33,76 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
} catch { } catch {
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",
href: `/{orgId}/settings/resources/resourceId`, href: `/{orgId}/settings/resources/{resourceId}/general`,
}, },
{ {
title: "Targets", title: "Connectivity",
href: `/{orgId}/settings/resources/{resourceId}/targets`, href: `/{orgId}/settings/resources/{resourceId}/connectivity`,
},
{
title: "Authentication",
href: `/{orgId}/settings/resources/{resourceId}/authentication`,
}, },
]; ];
const isCreate = params.resourceId === "create";
return ( return (
<> <>
<div className="space-y-0.5 select-none mb-6"> <div className="mb-4">
<h2 className="text-2xl font-bold tracking-tight"> <Link
{isCreate ? "New Resource" : resource?.name + " Settings"} href="../../"
</h2> className="text-muted-foreground hover:underline"
<p className="text-muted-foreground"> >
{isCreate <div className="flex flex-row items-center gap-1">
? "Create a new resource" <ArrowLeft className="w-4 h-4" />{" "}
: "Configure the settings on your resource: " + <span>All Resources</span>
resource?.name || ""} </div>
. </Link>
</p>
</div> </div>
<SettingsSectionTitle
title={`${resource?.name} Settings`}
description="Configure the settings on your resource"
/>
<OrgProvider org={org}>
<ResourceProvider resource={resource}> <ResourceProvider resource={resource}>
<SidebarSettings <SidebarSettings
sidebarNavItems={sidebarNavItems} sidebarNavItems={sidebarNavItems}
disabled={isCreate} limitWidth={false}
limitWidth={true}
> >
<div className="mb-8">
<ResourceInfoBox />
</div>
{children} {children}
</SidebarSettings> </SidebarSettings>
</ResourceProvider> </ResourceProvider>
</OrgProvider>
</> </>
); );
} }

View file

@ -1,29 +1,10 @@
import React from "react"; import { redirect } from "next/navigation";
import { Separator } from "@/components/ui/separator";
import { CreateResourceForm } from "./components/CreateResource";
import { GeneralForm } from "./components/GeneralForm";
export default async function ResourcePage(props: { export default async function ResourcePage(props: {
params: Promise<{ resourceId: number | string }>; params: Promise<{ resourceId: number | string; orgId: string }>;
}) { }) {
const params = await props.params; const params = await props.params;
const isCreate = params.resourceId === "create"; redirect(
`/${params.orgId}/settings/resources/${params.resourceId}/connectivity`
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium">
{isCreate ? "Create Resource" : "General"}
</h3>
<p className="text-sm text-muted-foreground">
{isCreate
? "Create a new resource"
: "Edit basic resource settings"}
</p>
</div>
<Separator />
{isCreate ? <CreateResourceForm /> : <GeneralForm />}
</div>
); );
} }

View file

@ -1,272 +0,0 @@
"use client";
import { useEffect, useState, use } from "react";
import { PlusCircle, Trash2, Server, Globe, Cpu } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import api from "@app/api";
import { AxiosResponse } from "axios";
import { ListTargetsResponse } from "@server/routers/target/listTargets";
const isValidIPAddress = (ip: string) => {
const ipv4Regex =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
return ipv4Regex.test(ip);
};
export default function ReverseProxyTargets(
props: {
params: Promise<{ resourceId: number }>;
}
) {
const params = use(props.params);
const [targets, setTargets] = useState<ListTargetsResponse["targets"]>([]);
const [nextId, setNextId] = useState(1);
const [ipError, setIpError] = useState("");
useEffect(() => {
if (typeof window !== "undefined") {
const fetchSites = async () => {
const res = await api.get<AxiosResponse<ListTargetsResponse>>(
`/resource/${params.resourceId}/targets`,
);
setTargets(res.data.data.targets);
};
fetchSites();
}
}, []);
const [newTarget, setNewTarget] = useState({
resourceId: params.resourceId,
ip: "",
method: "http",
port: 80,
protocol: "TCP",
});
const addTarget = () => {
if (!isValidIPAddress(newTarget.ip)) {
setIpError("Invalid IP address format");
return;
}
setIpError("");
api.put(`/resource/${params.resourceId}/target`, {
...newTarget,
resourceId: undefined,
})
.catch((err) => {
console.error(err);
})
.then((res) => {
// console.log(res)
setTargets([
...targets,
{ ...newTarget, targetId: nextId, enabled: true },
]);
setNextId(nextId + 1);
setNewTarget({
resourceId: params.resourceId,
ip: "",
method: "GET",
port: 80,
protocol: "http",
});
});
};
const removeTarget = (targetId: number) => {
api.delete(`/target/${targetId}`)
.catch((err) => {
console.error(err);
})
.then((res) => {
setTargets(
targets.filter((target) => target.targetId !== targetId),
);
});
};
const toggleTarget = (targetId: number) => {
setTargets(
targets.map((target) =>
target.targetId === targetId
? { ...target, enabled: !target.enabled }
: target,
),
);
api.post(`/target/${targetId}`, {
enabled: !targets.find((target) => target.targetId === targetId)
?.enabled,
}).catch((err) => {
console.error(err);
});
};
return (
<div className="space-y-6">
<form
onSubmit={(e) => {
e.preventDefault();
addTarget();
}}
className="space-y-4"
>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="ip">IP Address</Label>
<Input
id="ip"
value={newTarget.ip}
onChange={(e) => {
setNewTarget({
...newTarget,
ip: e.target.value,
});
setIpError("");
}}
required
/>
{ipError && (
<p className="text-red-500 text-sm">{ipError}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="method">Method</Label>
<Select
value={newTarget.method}
onValueChange={(value) =>
setNewTarget({ ...newTarget, method: value })
}
>
<SelectTrigger id="method">
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="https">HTTPS</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="port">Port</Label>
<Input
id="port"
type="number"
value={newTarget.port}
onChange={(e) =>
setNewTarget({
...newTarget,
port: parseInt(e.target.value),
})
}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="protocol">Protocol</Label>
<Select
value={newTarget.protocol}
onValueChange={(value) =>
setNewTarget({ ...newTarget, protocol: value })
}
>
<SelectTrigger id="protocol">
<SelectValue placeholder="Select protocol" />
</SelectTrigger>
<SelectContent>
<SelectItem value="UDP">UDP</SelectItem>
<SelectItem value="TCP">TCP</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button type="submit">
<PlusCircle className="mr-2 h-4 w-4" /> Add Target
</Button>
</form>
<div className="space-y-4">
{targets.map((target, i) => (
<Card
key={i}
id={`target-${target.targetId}`}
className="w-full p-4"
>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2 px-0 pt-0">
<CardTitle className="text-lg font-medium flex items-center">
<Server className="mr-2 h-5 w-5" />
Target {target.targetId}
</CardTitle>
<div className="flex flex-col items-end space-y-2">
<Switch
checked={target.enabled}
onCheckedChange={() =>
toggleTarget(target.targetId)
}
/>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() =>
removeTarget(target.targetId)
}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="px-0 py-2">
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-3">
<div className="flex items-center">
<Globe className="mr-2 h-4 w-4 text-muted-foreground" />
<span className="text-sm">
{target.ip}:{target.port}
</span>
</div>
<div className="flex items-center">
<Cpu className="mr-2 h-4 w-4 text-muted-foreground" />
<span className="text-sm">
{target.resourceId}
</span>
</div>
<div className="flex items-center space-x-2">
<Badge
variant={
target.enabled
? "default"
: "secondary"
}
>
{target.method}
</Badge>
<Badge
variant={
target.enabled
? "default"
: "secondary"
}
>
{target.protocol?.toUpperCase()}
</Badge>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,314 @@
"use client";
import api from "@app/api";
import { Button, buttonVariants } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useToast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
} from "@app/components/Credenza";
import { useParams, useRouter } from "next/navigation";
import { ListSitesResponse } from "@server/routers/site";
import { cn, formatAxiosError } from "@app/lib/utils";
import { CheckIcon } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@app/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@app/components/ui/command";
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: subdomainSchema,
name: z.string(),
siteId: z.number(),
});
type AccountFormValues = z.infer<typeof accountFormSchema>;
const defaultValues: Partial<AccountFormValues> = {
subdomain: "",
name: "My Resource",
};
type CreateResourceFormProps = {
open: boolean;
setOpen: (open: boolean) => void;
};
export default function CreateResourceForm({
open,
setOpen,
}: CreateResourceFormProps) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const params = useParams();
const orgId = params.orgId;
const router = useRouter();
const { org } = useOrgContext();
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [domainSuffix, setDomainSuffix] = useState<string>(org.org.domain);
const form = useForm<AccountFormValues>({
resolver: zodResolver(accountFormSchema),
defaultValues,
});
useEffect(() => {
if (!open) {
return;
}
const fetchSites = async () => {
const res = await api.get<AxiosResponse<ListSitesResponse>>(
`/org/${orgId}/sites/`
);
setSites(res.data.data.sites);
};
fetchSites();
}, [open]);
async function onSubmit(data: AccountFormValues) {
console.log(data);
const res = await api
.put<AxiosResponse<Resource>>(
`/org/${orgId}/site/${data.siteId}/resource/`,
{
name: data.name,
subdomain: data.subdomain,
// subdomain: data.subdomain,
}
)
.catch((e) => {
toast({
variant: "destructive",
title: "Error creating resource",
description: formatAxiosError(
e,
"An error occurred when creating the resource"
),
});
});
if (res && res.status === 201) {
const id = res.data.data.resourceId;
// navigate to the resource page
router.push(`/${orgId}/settings/resources/${id}`);
}
}
return (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
setLoading(false);
// reset all values
form.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Create Resource</CredenzaTitle>
<CredenzaDescription>
Create a new resource to proxy requests to your app
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="create-resource-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Your name"
{...field}
/>
</FormControl>
<FormDescription>
This is the name that will be
displayed for this resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<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 fully qualified
domain name that will be used to
access the resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Site</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-[350px] justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(site) =>
site.siteId ===
field.value
)?.name
: "Select site"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[350px] p-0">
<Command>
<CommandInput placeholder="Search site..." />
<CommandList>
<CommandEmpty>
No site found.
</CommandEmpty>
<CommandGroup>
{sites.map(
(site) => (
<CommandItem
value={
site.name
}
key={
site.siteId
}
onSelect={() => {
form.setValue(
"siteId",
site.siteId
);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
{
site.name
}
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
This is the site that will be
used in the dashboard.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<Button
type="submit"
form="create-resource-form"
loading={loading}
disabled={loading}
>
Create Resource
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View file

@ -9,10 +9,16 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@app/components/ui/dropdown-menu"; } from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import api from "@app/api"; import api from "@app/api";
import CreateResourceForm from "./CreateResourceForm";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { set } from "zod";
import { formatAxiosError } from "@app/lib/utils";
import { useToast } from "@app/hooks/useToast";
export type ResourceRow = { export type ResourceRow = {
id: number; id: number;
@ -22,7 +28,38 @@ export type ResourceRow = {
site: string; site: string;
}; };
export const columns: ColumnDef<ResourceRow>[] = [ type ResourcesTableProps = {
resources: ResourceRow[];
orgId: string;
};
export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
const router = useRouter();
const { toast } = useToast();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedResource, setSelectedResource] =
useState<ResourceRow | null>();
const deleteResource = (resourceId: number) => {
api.delete(`/resource/${resourceId}`)
.catch((e) => {
console.error("Error deleting resource", e);
toast({
variant: "destructive",
title: "Error deleting resource",
description: formatAxiosError(e, "Error deleting resource"),
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
};
const columns: ColumnDef<ResourceRow>[] = [
{ {
accessorKey: "name", accessorKey: "name",
header: ({ column }) => { header: ({ column }) => {
@ -66,21 +103,18 @@ export const columns: ColumnDef<ResourceRow>[] = [
const resourceRow = row.original; const resourceRow = row.original;
const deleteResource = (resourceId: number) => {
api.delete(`/resource/${resourceId}`)
.catch((e) => {
console.error("Error deleting resource", e);
})
.then(() => {
router.refresh();
});
};
return ( return (
<>
<div className="flex items-center justify-end">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0"> <Button
<span className="sr-only">Open menu</span> variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@ -94,7 +128,12 @@ export const columns: ColumnDef<ResourceRow>[] = [
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem>
<button <button
onClick={() => deleteResource(resourceRow.id)} onClick={() => {
setSelectedResource(
resourceRow
);
setIsDeleteModalOpen(true);
}}
className="text-red-600 hover:text-red-800 hover:underline cursor-pointer" className="text-red-600 hover:text-red-800 hover:underline cursor-pointer"
> >
Delete Delete
@ -102,26 +141,75 @@ export const columns: ColumnDef<ResourceRow>[] = [
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Button
variant={"gray"}
className="ml-2"
onClick={() =>
router.push(
`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`
)
}
>
Edit <ArrowRight className="ml-2 w-4 h-4" />
</Button>
</div>
</>
); );
}, },
}, },
]; ];
type ResourcesTableProps = {
resources: ResourceRow[];
orgId: string;
};
export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
const router = useRouter();
return ( return (
<>
<CreateResourceForm
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}
/>
{selectedResource && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedResource(null);
}}
dialog={
<div>
<p className="mb-2">
Are you sure you want to remove the resource{" "}
<b>
{selectedResource?.name ||
selectedResource?.id}
</b>{" "}
from the organization?
</p>
<p className="mb-2">
Once removed, the resource will no longer be
accessible. All targets attached to the resource
will be removed.
</p>
<p>
To confirm, please type the name of the resource
below.
</p>
</div>
}
buttonText="Confirm Delete Resource"
onConfirm={async () => deleteResource(selectedResource!.id)}
string={selectedResource.name}
title="Delete Resource"
/>
)}
<ResourcesDataTable <ResourcesDataTable
columns={columns} columns={columns}
data={resources} data={resources}
addResource={() => { addResource={() => {
router.push(`/${orgId}/settings/resources/create`); setIsCreateModalOpen(true);
}} }}
/> />
</>
); );
} }

View file

@ -3,6 +3,11 @@ import { authCookieHeader } from "@app/api/cookies";
import ResourcesTable, { ResourceRow } from "./components/ResourcesTable"; 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 { 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 }>;
@ -21,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,
@ -33,16 +56,14 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
return ( return (
<> <>
<div className="space-y-0.5 select-none mb-6"> <SettingsSectionTitle
<h2 className="text-2xl font-bold tracking-tight"> title="Manage Resources"
Manage Resources description="Create secure proxies to your private applications"
</h2> />
<p className="text-muted-foreground">
Create secure proxies to your private applications.
</p>
</div>
<OrgProvider org={org}>
<ResourcesTable resources={resourceRows} orgId={params.orgId} /> <ResourcesTable resources={resourceRows} orgId={params.orgId} />
</OrgProvider>
</> </>
); );
} }

View file

@ -1,218 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronDownIcon } from "@radix-ui/react-icons";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { cn } from "@/lib/utils";
import { toast } from "@/hooks/useToast";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { generateKeypair } from "./wireguardConfig";
import React, { useState, useEffect } from "react";
import { api } from "@/api";
import { useParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { Checkbox } from "@app/components/ui/checkbox";
import { PickSiteDefaultsResponse } from "@server/routers/site";
import CopyTextBox from "@app/components/CopyTextBox";
const method = [
{ label: "Wireguard", value: "wg" },
{ label: "Newt", value: "newt" },
] as const;
const accountFormSchema = z.object({
name: z
.string()
.min(2, {
message: "Name must be at least 2 characters.",
})
.max(30, {
message: "Name must not be longer than 30 characters.",
}),
method: z.enum(["wg", "newt"]),
});
type AccountFormValues = z.infer<typeof accountFormSchema>;
const defaultValues: Partial<AccountFormValues> = {
name: "",
method: "wg",
};
export function CreateSiteForm() {
const params = useParams();
const orgId = params.orgId;
const router = useRouter();
const [keypair, setKeypair] = useState<{
publicKey: string;
privateKey: string;
} | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isChecked, setIsChecked] = useState(false);
const [siteDefaults, setSiteDefaults] =
useState<PickSiteDefaultsResponse | null>(null);
const handleCheckboxChange = (checked: boolean) => {
setIsChecked(checked);
};
const form = useForm<AccountFormValues>({
resolver: zodResolver(accountFormSchema),
defaultValues,
});
useEffect(() => {
if (typeof window !== "undefined") {
const generatedKeypair = generateKeypair();
setKeypair(generatedKeypair);
setIsLoading(false);
api.get(`/org/${orgId}/pick-site-defaults`)
.catch((e) => {
toast({
title: "Error creating site...",
});
})
.then((res) => {
if (res && res.status === 200) {
setSiteDefaults(res.data.data);
}
});
}
}, []);
async function onSubmit(data: AccountFormValues) {
const res = await api
.put(`/org/${orgId}/site/`, {
name: data.name,
subnet: siteDefaults?.subnet,
exitNodeId: siteDefaults?.exitNodeId,
pubKey: keypair?.publicKey,
})
.catch((e) => {
toast({
title: "Error creating site...",
});
});
if (res && res.status === 201) {
const niceId = res.data.data.niceId;
// navigate to the site page
router.push(`/${orgId}/settings/sites/${niceId}`);
}
}
const wgConfig =
keypair && siteDefaults
? `[Interface]
Address = ${siteDefaults.subnet}
ListenPort = 51820
PrivateKey = ${keypair.privateKey}
[Peer]
PublicKey = ${siteDefaults.publicKey}
AllowedIPs = ${siteDefaults.address.split("/")[0]}/32
Endpoint = ${siteDefaults.endpoint}:${siteDefaults.listenPort}
PersistentKeepalive = 5`
: "";
const newtConfig = `curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh`;
return (
<>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Your name" {...field} />
</FormControl>
<FormDescription>
This is the name that will be displayed for
this site.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="method"
render={({ field }) => (
<FormItem>
<FormLabel>Method</FormLabel>
<div className="relative w-max">
<FormControl>
<select
className={cn(
buttonVariants({
variant: "outline",
}),
"w-[200px] appearance-none font-normal"
)}
{...field}
>
<option value="wg">
WireGuard
</option>
<option value="newt">Newt</option>
</select>
</FormControl>
<ChevronDownIcon className="absolute right-3 top-2.5 h-4 w-4 opacity-50" />
</div>
<FormDescription>
This is how you will connect your site to
Fossorial.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch("method") === "wg" && !isLoading ? (
<CopyTextBox text={wgConfig} />
) : form.watch("method") === "wg" && isLoading ? (
<p>Loading WireGuard configuration...</p>
) : (
<CopyTextBox text={newtConfig} wrapText={false} />
)}
<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>
<Button type="submit" disabled={!isChecked}>
Create Site
</Button>
</form>
</Form>
</>
);
}

View file

@ -1,80 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useSiteContext } from "@app/hooks/useSiteContext";
import { useForm } from "react-hook-form";
import api from "@app/api";
import { useToast } from "@app/hooks/useToast";
const GeneralFormSchema = z.object({
name: z.string(),
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
export function GeneralForm() {
const { site, updateSite } = useSiteContext();
const { toast } = useToast();
const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
name: site?.name,
},
mode: "onChange",
});
async function onSubmit(data: GeneralFormValues) {
updateSite({ name: data.name });
await api
.post(`/site/${site?.siteId}`, {
name: data.name,
})
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to update site",
description:
e.message ||
"An error occurred while updating the site.",
});
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
This is the display name of the site.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Update Site</Button>
</form>
</Form>
);
}

View file

@ -0,0 +1,101 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useSiteContext } from "@app/hooks/useSiteContext";
import { useForm } from "react-hook-form";
import api from "@app/api";
import { useToast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { formatAxiosError } from "@app/lib/utils";
const GeneralFormSchema = z.object({
name: z.string(),
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
export default function GeneralPage() {
const { site, updateSite } = useSiteContext();
const { toast } = useToast();
const router = useRouter();
const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
name: site?.name,
},
mode: "onChange",
});
async function onSubmit(data: GeneralFormValues) {
await api
.post(`/site/${site?.siteId}`, {
name: data.name,
})
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to update site",
description: formatAxiosError(
e,
"An error occurred while updating the site."
),
});
});
updateSite({ name: data.name });
router.refresh();
}
return (
<>
<div className="space-y-6">
<SettingsSectionTitle
title="General Settings"
description="Configure the general settings for this site"
size="1xl"
/>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
This is the display name of the site
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Save Changes</Button>
</form>
</Form>
</div>
</>
);
}

View file

@ -5,6 +5,9 @@ import { AxiosResponse } from "axios";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/api/cookies"; import { authCookieHeader } from "@app/api/cookies";
import { SidebarSettings } from "@app/components/SidebarSettings"; import { SidebarSettings } from "@app/components/SidebarSettings";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
interface SettingsLayoutProps { interface SettingsLayoutProps {
children: React.ReactNode; children: React.ReactNode;
@ -17,8 +20,6 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const { children } = props; const { children } = props;
let site = null; let site = null;
if (params.niceId !== "create") {
try { try {
const res = await internal.get<AxiosResponse<GetSiteResponse>>( const res = await internal.get<AxiosResponse<GetSiteResponse>>(
`/org/${params.orgId}/site/${params.niceId}`, `/org/${params.orgId}/site/${params.niceId}`,
@ -28,39 +29,40 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
} catch { } catch {
redirect(`/${params.orgId}/settings/sites`); redirect(`/${params.orgId}/settings/sites`);
} }
}
const sidebarNavItems = [ const sidebarNavItems = [
{ {
title: "General", title: "General",
href: "/{orgId}/settings/sites/{niceId}", href: "/{orgId}/settings/sites/{niceId}/general",
}, },
]; ];
const isCreate = params.niceId === "create";
return ( return (
<> <>
<div className="space-y-0.5 select-none mb-6"> <div className="mb-4">
<h2 className="text-2xl font-bold tracking-tight"> <Link
{isCreate ? "New Site" : site?.name + " Settings"} href="../../"
</h2> className="text-muted-foreground hover:underline"
<p className="text-muted-foreground"> >
{isCreate <div className="flex flex-row items-center gap-1">
? "Create a new site" <ArrowLeft className="w-4 h-4" /> <span>All Sites</span>
: "Configure the settings on your site: " + </div>
site?.name || ""} </Link>
.
</p>
</div> </div>
<SettingsSectionTitle
title={`${site?.name} Settings`}
description="Configure the settings on your site"
/>
<SiteProvider site={site}>
<SidebarSettings <SidebarSettings
sidebarNavItems={sidebarNavItems} sidebarNavItems={sidebarNavItems}
disabled={isCreate}
limitWidth={true} limitWidth={true}
> >
{children} {children}
</SidebarSettings> </SidebarSettings>
</SiteProvider>
</> </>
); );
} }

View file

@ -1,29 +1,8 @@
import React from "react"; import { redirect } from "next/navigation";
import { Separator } from "@/components/ui/separator";
import { CreateSiteForm } from "./components/CreateSite";
import { GeneralForm } from "./components/GeneralForm";
export default async function SitePage(props: { export default async function SitePage(props: {
params: Promise<{ niceId: string }>; params: Promise<{ orgId: string; niceId: string }>;
}) { }) {
const params = await props.params; const params = await props.params;
const isCreate = params.niceId === "create"; redirect(`/${params.orgId}/settings/sites/${params.niceId}/general`);
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium">
{isCreate ? "Create Site" : "General"}
</h3>
<p className="text-sm text-muted-foreground">
{isCreate
? "Create a new site"
: "Edit basic site settings"}
</p>
</div>
<Separator />
{isCreate ? <CreateSiteForm /> : <GeneralForm />}
</div>
);
} }

View file

@ -0,0 +1,315 @@
"use client";
import api from "@app/api";
import { Button, buttonVariants } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useToast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { useParams, useRouter } from "next/navigation";
import { PickSiteDefaultsResponse } from "@server/routers/site";
import { generateKeypair } from "../[niceId]/components/wireguardConfig";
import CopyTextBox from "@app/components/CopyTextBox";
import { Checkbox } from "@app/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@app/components/ui/select";
import { formatAxiosError } from "@app/lib/utils";
const method = [
{ label: "Wireguard", value: "wg" },
{ label: "Newt", value: "newt" },
] as const;
const accountFormSchema = z.object({
name: z
.string()
.min(2, {
message: "Name must be at least 2 characters.",
})
.max(30, {
message: "Name must not be longer than 30 characters.",
}),
method: z.enum(["wg", "newt"]),
});
type AccountFormValues = z.infer<typeof accountFormSchema>;
const defaultValues: Partial<AccountFormValues> = {
name: "",
method: "wg",
};
type CreateSiteFormProps = {
open: boolean;
setOpen: (open: boolean) => void;
};
export default function CreateSiteForm({ open, setOpen }: CreateSiteFormProps) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const params = useParams();
const orgId = params.orgId;
const router = useRouter();
const [keypair, setKeypair] = useState<{
publicKey: string;
privateKey: string;
} | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isChecked, setIsChecked] = useState(false);
const [siteDefaults, setSiteDefaults] =
useState<PickSiteDefaultsResponse | null>(null);
const handleCheckboxChange = (checked: boolean) => {
setIsChecked(checked);
};
const form = useForm<AccountFormValues>({
resolver: zodResolver(accountFormSchema),
defaultValues,
});
useEffect(() => {
if (!open) return;
if (typeof window !== "undefined") {
const generatedKeypair = generateKeypair();
setKeypair(generatedKeypair);
setIsLoading(false);
api.get(`/org/${orgId}/pick-site-defaults`)
.catch((e) => {
toast({
variant: "destructive",
title: "Error picking site defaults",
description: formatAxiosError(e),
});
})
.then((res) => {
if (res && res.status === 200) {
setSiteDefaults(res.data.data);
}
});
}
}, [open]);
async function onSubmit(data: AccountFormValues) {
setLoading(true);
const res = await api
.put(`/org/${orgId}/site/`, {
name: data.name,
subnet: siteDefaults?.subnet,
exitNodeId: siteDefaults?.exitNodeId,
pubKey: keypair?.publicKey,
})
.catch((e) => {
toast({
variant: "destructive",
title: "Error creating site",
description: formatAxiosError(e),
});
});
if (res && res.status === 201) {
const niceId = res.data.data.niceId;
// navigate to the site page
router.push(`/${orgId}/settings/sites/${niceId}`);
// close the modal
setOpen(false);
}
setLoading(false);
}
const wgConfig =
keypair && siteDefaults
? `[Interface]
Address = ${siteDefaults.subnet}
ListenPort = 51820
PrivateKey = ${keypair.privateKey}
[Peer]
PublicKey = ${siteDefaults.publicKey}
AllowedIPs = ${siteDefaults.address.split("/")[0]}/32
Endpoint = ${siteDefaults.endpoint}:${siteDefaults.listenPort}
PersistentKeepalive = 5`
: "";
const newtConfig = `curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh`;
return (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
setLoading(false);
// reset all values
form.reset();
setIsChecked(false);
setKeypair(null);
setSiteDefaults(null);
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Create Site</CredenzaTitle>
<CredenzaDescription>
Create a new site to start connecting your resources
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-6">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
id="create-site-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Site name"
{...field}
/>
</FormControl>
<FormDescription>
This is the name that will
be displayed for this site.
</FormDescription>
<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">
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>
<CredenzaFooter>
<Button
type="submit"
form="create-site-form"
loading={loading}
disabled={loading || !isChecked}
>
Create Site
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View file

@ -9,12 +9,16 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@app/components/ui/dropdown-menu"; } from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import api from "@app/api"; import api from "@app/api";
import { authCookieHeader } from "@app/api/cookies";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useState } from "react";
import CreateSiteForm from "./CreateSiteForm";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useToast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/utils";
export type SiteRow = { export type SiteRow = {
id: number; id: number;
@ -25,7 +29,42 @@ export type SiteRow = {
orgId: string; orgId: string;
}; };
export const columns: ColumnDef<SiteRow>[] = [ type SitesTableProps = {
sites: SiteRow[];
orgId: string;
};
export default function SitesTable({ sites, orgId }: SitesTableProps) {
const router = useRouter();
const { toast } = useToast();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
const callApi = async () => {
const res = await api.put<AxiosResponse<any>>(`/newt`);
console.log(res);
};
const deleteSite = (siteId: number) => {
api.delete(`/site/${siteId}`)
.catch((e) => {
console.error("Error deleting site", e);
toast({
variant: "destructive",
title: "Error deleting site",
description: formatAxiosError(e, "Error deleting site"),
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
};
const columns: ColumnDef<SiteRow>[] = [
{ {
accessorKey: "name", accessorKey: "name",
header: ({ column }) => { header: ({ column }) => {
@ -73,17 +112,8 @@ export const columns: ColumnDef<SiteRow>[] = [
const siteRow = row.original; const siteRow = row.original;
const deleteSite = (siteId: number) => {
api.delete(`/site/${siteId}`)
.catch((e) => {
console.error("Error deleting site", e);
})
.then(() => {
router.refresh();
});
};
return ( return (
<div className="flex items-center justify-end">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0"> <Button variant="ghost" className="h-8 w-8 p-0">
@ -101,7 +131,10 @@ export const columns: ColumnDef<SiteRow>[] = [
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem>
<button <button
onClick={() => deleteSite(siteRow.id)} onClick={() => {
setSelectedSite(siteRow);
setIsDeleteModalOpen(true);
}}
className="text-red-600 hover:text-red-800" className="text-red-600 hover:text-red-800"
> >
Delete Delete
@ -109,39 +142,75 @@ export const columns: ColumnDef<SiteRow>[] = [
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Button
variant={"gray"}
className="ml-2"
onClick={() =>
router.push(
`/${siteRow.orgId}/settings/sites/${siteRow.nice}`
)
}
>
Edit <ArrowRight className="ml-2 w-4 h-4" />
</Button>
</div>
); );
}, },
}, },
]; ];
type SitesTableProps = {
sites: SiteRow[];
orgId: string;
};
export default function SitesTable({ sites, orgId }: SitesTableProps) {
const router = useRouter();
const callApi = async () => {
const res = await api.put<AxiosResponse<any>>(
`/newt`
);
console.log(res);
};
return ( return (
<> <>
<CreateSiteForm
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}
/>
{selectedSite && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedSite(null);
}}
dialog={
<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>
Once removed, the site will no longer be
accessible.{" "}
<b>
All resources and targets associated with
the site will also be removed.
</b>
</p>
<p>
To confirm, please type the name of the site
below.
</p>
</div>
}
buttonText="Confirm Delete Site"
onConfirm={async () => deleteSite(selectedSite!.id)}
string={selectedSite.name}
title="Delete Site"
/>
)}
<SitesDataTable <SitesDataTable
columns={columns} columns={columns}
data={sites} data={sites}
addSite={() => { addSite={() => {
router.push(`/${orgId}/settings/sites/create`); setIsCreateModalOpen(true);
}} }}
/> />
<button onClick={callApi}>Create Newt</button> {/* <button onClick={callApi}>Create Newt</button> */}
</> </>
); );
} }

View file

@ -3,6 +3,7 @@ import { authCookieHeader } from "@app/api/cookies";
import { ListSitesResponse } from "@server/routers/site"; import { ListSitesResponse } from "@server/routers/site";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import SitesTable, { SiteRow } from "./components/SitesTable"; import SitesTable, { SiteRow } from "./components/SitesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
type SitesPageProps = { type SitesPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
@ -34,14 +35,10 @@ export default async function SitesPage(props: SitesPageProps) {
return ( return (
<> <>
<div className="space-y-0.5 select-none mb-6"> <SettingsSectionTitle
<h2 className="text-2xl font-bold tracking-tight"> title="Manage Sites"
Manage Sites description="Manage your existing sites here or create a new one."
</h2> />
<p className="text-muted-foreground">
Manage your existing sites here or create a new one.
</p>
</div>
<SitesTable sites={siteRows} orgId={params.orgId} /> <SitesTable sites={siteRows} orgId={params.orgId} />
</> </>

View file

@ -26,6 +26,7 @@ import { LoginResponse } from "@server/routers/auth";
import { api } from "@app/api"; import { api } from "@app/api";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/utils";
type LoginFormProps = { type LoginFormProps = {
redirect?: string; redirect?: string;
@ -65,8 +66,7 @@ export default function LoginForm({ redirect }: LoginFormProps) {
.catch((e) => { .catch((e) => {
console.error(e); console.error(e);
setError( setError(
e.response?.data?.message || formatAxiosError(e, "An error occurred while logging in")
"An error occurred while logging in",
); );
}); });
@ -146,7 +146,11 @@ export default function LoginForm({ redirect }: LoginFormProps) {
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}</AlertDescription>
</Alert> </Alert>
)} )}
<Button type="submit" className="w-full" loading={loading}> <Button
type="submit"
className="w-full"
loading={loading}
>
Login Login
</Button> </Button>
</form> </form>

View file

@ -27,6 +27,7 @@ import { api } from "@app/api";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { passwordSchema } from "@server/auth/passwordSchema"; import { passwordSchema } from "@server/auth/passwordSchema";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { formatAxiosError } from "@app/lib/utils";
type SignupFormProps = { type SignupFormProps = {
redirect?: string; redirect?: string;
@ -70,8 +71,7 @@ export default function SignupForm({ redirect }: SignupFormProps) {
.catch((e) => { .catch((e) => {
console.error(e); console.error(e);
setError( setError(
e.response?.data?.message || formatAxiosError(e, "An error occurred while signing up")
"An error occurred while signing up",
); );
}); });

View file

@ -34,6 +34,7 @@ import { Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "../../../components/ui/alert"; import { Alert, AlertDescription } from "../../../components/ui/alert";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/utils";
const FormSchema = z.object({ const FormSchema = z.object({
email: z.string().email({ message: "Invalid email address" }), email: z.string().email({ message: "Invalid email address" }),
@ -76,14 +77,14 @@ export default function VerifyEmailForm({
code: data.pin, code: data.pin,
}) })
.catch((e) => { .catch((e) => {
setError(e.response?.data?.message || "An error occurred"); setError(formatAxiosError(e, "An error occurred"));
console.error("Failed to verify email:", e); console.error("Failed to verify email:", e);
}); });
if (res && res.data?.data?.valid) { if (res && res.data?.data?.valid) {
setError(null); setError(null);
setSuccessMessage( setSuccessMessage(
"Email successfully verified! Redirecting you...", "Email successfully verified! Redirecting you..."
); );
setTimeout(() => { setTimeout(() => {
if (redirect && redirect.includes("http")) { if (redirect && redirect.includes("http")) {
@ -103,7 +104,7 @@ export default function VerifyEmailForm({
setIsResending(true); setIsResending(true);
const res = await api.post("/auth/verify-email/request").catch((e) => { const res = await api.post("/auth/verify-email/request").catch((e) => {
setError(e.response?.data?.message || "An error occurred"); setError(formatAxiosError(e, "An error occurred"));
console.error("Failed to resend verification code:", e); console.error("Failed to resend verification code:", e);
}); });

View file

@ -38,7 +38,7 @@
--chart-4: 23.33 8.82% 60%; --chart-4: 23.33 8.82% 60%;
--chart-5: 24 8.98% 67.25%; --chart-5: 24 8.98% 67.25%;
--radius: 0.75rem; --radius: 0.35rem;
} }
.dark { .dark {
--background: 0 0% 11.76%; --background: 0 0% 11.76%;
@ -75,7 +75,7 @@
--chart-4: 23.33 23.68% 14.9%; --chart-4: 23.33 23.68% 14.9%;
--chart-5: 24 23.81% 12.35%; --chart-5: 24 23.81% 12.35%;
--radius: 0.75rem; --radius: 0.35rem;
} }
} }

View file

@ -5,6 +5,7 @@ import { AcceptInviteResponse } from "@server/routers/user";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import InviteStatusCard from "./InviteStatusCard"; import InviteStatusCard from "./InviteStatusCard";
import { formatAxiosError } from "@app/lib/utils";
export default async function InvitePage(props: { export default async function InvitePage(props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
@ -47,8 +48,8 @@ export default async function InvitePage(props: {
await authCookieHeader() await authCookieHeader()
) )
.catch((e) => { .catch((e) => {
error = e.response?.data?.message; console.error(e);
console.log(error); error = formatAxiosError(e);
}); });
if (res && res.status === 200) { if (res && res.status === 200) {

View file

@ -1,6 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import { Inter } from "next/font/google"; import { Fira_Sans, Inter } from "next/font/google";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@app/providers/ThemeProvider"; import { ThemeProvider } from "@app/providers/ThemeProvider";

View file

@ -88,7 +88,7 @@ export function AccountForm() {
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"

View file

@ -55,7 +55,7 @@ export function AppearanceForm() {
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="font" name="font"

View file

@ -76,7 +76,7 @@ export function DisplayForm() {
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="items" name="items"

View file

@ -64,7 +64,7 @@ export default function SettingsLayout({ children }: SettingsLayoutProps) {
</p> </p>
</div> </div>
<Separator className="my-6" /> <Separator className="my-6" />
<div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> <div className="flex flex-col space-y-4 lg:flex-row lg:space-x-12 lg:space-y-0">
<aside className="-mx-4 lg:w-1/5"> <aside className="-mx-4 lg:w-1/5">
<SidebarNav items={sidebarNavItems} /> <SidebarNav items={sidebarNavItems} />
</aside> </aside>

View file

@ -60,7 +60,7 @@ export function NotificationsForm() {
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="type" name="type"

View file

@ -88,7 +88,7 @@ export function ProfileForm() {
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="username" name="username"

View file

@ -15,6 +15,7 @@ import {
CardTitle, CardTitle,
} from "@app/components/ui/card"; } from "@app/components/ui/card";
import CopyTextBox from "@app/components/CopyTextBox"; import CopyTextBox from "@app/components/CopyTextBox";
import { formatAxiosError } from "@app/lib/utils";
type Step = "org" | "site" | "resources"; type Step = "org" | "site" | "resources";
@ -43,7 +44,7 @@ export default function StepperForm() {
const debouncedCheckOrgIdAvailability = useCallback( const debouncedCheckOrgIdAvailability = useCallback(
debounce(checkOrgIdAvailability, 300), debounce(checkOrgIdAvailability, 300),
[checkOrgIdAvailability], [checkOrgIdAvailability]
); );
useEffect(() => { useEffect(() => {
@ -76,7 +77,9 @@ export default function StepperForm() {
}) })
.catch((e) => { .catch((e) => {
toast({ toast({
title: "Error creating org...", variant: "destructive",
title: "Error creating org",
description: formatAxiosError(e),
}); });
}); });
@ -106,36 +109,60 @@ export default function StepperForm() {
<div className="flex justify-between mb-2"> <div className="flex justify-between mb-2">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div <div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${currentStep === "org" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`} className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
currentStep === "org"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
> >
1 1
</div> </div>
<span <span
className={`text-sm font-medium ${currentStep === "org" ? "text-primary" : "text-muted-foreground"}`} className={`text-sm font-medium ${
currentStep === "org"
? "text-primary"
: "text-muted-foreground"
}`}
> >
Create Org Create Org
</span> </span>
</div> </div>
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div <div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${currentStep === "site" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`} className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
currentStep === "site"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
> >
2 2
</div> </div>
<span <span
className={`text-sm font-medium ${currentStep === "site" ? "text-primary" : "text-muted-foreground"}`} className={`text-sm font-medium ${
currentStep === "site"
? "text-primary"
: "text-muted-foreground"
}`}
> >
Create Site Create Site
</span> </span>
</div> </div>
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div <div
className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${currentStep === "resources" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`} className={`w-8 h-8 rounded-full flex items-center justify-center mb-2 ${
currentStep === "resources"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
> >
3 3
</div> </div>
<span <span
className={`text-sm font-medium ${currentStep === "resources" ? "text-primary" : "text-muted-foreground"}`} className={`text-sm font-medium ${
currentStep === "resources"
? "text-primary"
: "text-muted-foreground"
}`}
> >
Create Resources Create Resources
</span> </span>
@ -251,7 +278,7 @@ export default function StepperForm() {
function debounce<T extends (...args: any[]) => any>( function debounce<T extends (...args: any[]) => any>(
func: T, func: T,
wait: number, wait: number
): (...args: Parameters<T>) => void { ): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null; let timeout: NodeJS.Timeout | null = null;

View file

@ -37,6 +37,7 @@ export default function CopyTextBox({ text = "", wrapText = false }) {
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
type="button"
className="absolute top-1 right-1 z-10" className="absolute top-1 right-1 z-10"
onClick={copyToClipboard} onClick={copyToClipboard}
aria-label="Copy to clipboard" aria-label="Copy to clipboard"

View file

@ -0,0 +1,24 @@
type SettingsSectionTitleProps = {
title: string | React.ReactNode;
description: string | React.ReactNode;
size?: "2xl" | "1xl";
};
export default function SettingsSectionTitle({
title,
description,
size,
}: SettingsSectionTitleProps) {
return (
<div className="space-y-0.5 select-none mb-6">
<h2
className={`text-${
size ? size : "2xl"
} font-bold tracking-tight`}
>
{title}
</h2>
<p className="text-muted-foreground">{description}</p>
</div>
);
}

View file

@ -88,7 +88,7 @@ export function AccountForm() {
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"

View file

@ -62,7 +62,7 @@ export function AppearanceForm() {
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="font" name="font"

View file

@ -76,7 +76,7 @@ export function DisplayForm() {
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="items" name="items"

View file

@ -60,7 +60,7 @@ export function NotificationsForm() {
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="type" name="type"

View file

@ -88,7 +88,7 @@ export function ProfileForm() {
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="username" name="username"

View file

@ -59,9 +59,9 @@ export function SidebarNav({
<div> <div>
<div className="block lg:hidden px-4"> <div className="block lg:hidden px-4">
<Select <Select
defaultValue={getSelectedValue()}
onValueChange={handleSelectChange} onValueChange={handleSelectChange}
disabled={disabled} disabled={disabled}
defaultValue={getSelectedValue()}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select an option" /> <SelectValue placeholder="Select an option" />

View file

@ -0,0 +1,115 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View file

@ -19,6 +19,7 @@ const buttonVariants = cva(
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80", "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
gray: "bg-accent text-accent-foreground hover:bg-accent/90",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {

View file

@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-black/30 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className className
)} )}
{...props} {...props}
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"fixed left-[50%] top-[30%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className className
)} )}
{...props} {...props}

View file

@ -17,7 +17,7 @@ export function Toaster() {
<ToastProvider> <ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) { {toasts.map(function ({ id, title, description, action, ...props }) {
return ( return (
<Toast key={id} {...props}> <Toast key={id} {...props} className="mt-2">
<div className="grid gap-1"> <div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>} {title && <ToastTitle>{title}</ToastTitle>}
{description && ( {description && (

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

@ -0,0 +1,11 @@
import { GetOrgUserResponse } from "@server/routers/user";
import { createContext } from "react";
interface OrgUserContext {
orgUser: GetOrgUserResponse;
updateOrgUser: (updateOrgUser: Partial<GetOrgUserResponse>) => void;
}
const OrgUserContext = createContext<OrgUserContext | undefined>(undefined);
export default OrgUserContext;

View file

@ -2,7 +2,7 @@ import { GetResourceResponse } from "@server/routers/resource/getResource";
import { createContext } from "react"; import { createContext } from "react";
interface ResourceContextType { interface ResourceContextType {
resource: GetResourceResponse | null; resource: GetResourceResponse;
updateResource: (updatedResource: Partial<GetResourceResponse>) => void; updateResource: (updatedResource: Partial<GetResourceResponse>) => void;
} }

View file

@ -0,0 +1,12 @@
import OrgUserContext from "@app/contexts/orgUserContext";
import { useContext } from "react";
export function userOrgUserContext() {
const context = useContext(OrgUserContext);
if (context === undefined) {
throw new Error(
"useOrgUserContext must be used within a OrgUserProvider"
);
}
return context;
}

View file

@ -1,78 +1,75 @@
"use client" "use client";
// Inspired by react-hot-toast library // Inspired by react-hot-toast library
import * as React from "react" import * as React from "react";
import type { import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1 const TOAST_LIMIT = 3;
const TOAST_REMOVE_DELAY = 1000000 const TOAST_REMOVE_DELAY = 5 * 1000;
type ToasterToast = ToastProps & { type ToasterToast = ToastProps & {
id: string id: string;
title?: React.ReactNode title?: React.ReactNode;
description?: React.ReactNode description?: React.ReactNode;
action?: ToastActionElement action?: ToastActionElement;
} };
const actionTypes = { const actionTypes = {
ADD_TOAST: "ADD_TOAST", ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST", UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST", DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST", REMOVE_TOAST: "REMOVE_TOAST",
} as const } as const;
let count = 0 let count = 0;
function genId() { function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString() return count.toString();
} }
type ActionType = typeof actionTypes type ActionType = typeof actionTypes;
type Action = type Action =
| { | {
type: ActionType["ADD_TOAST"] type: ActionType["ADD_TOAST"];
toast: ToasterToast toast: ToasterToast;
} }
| { | {
type: ActionType["UPDATE_TOAST"] type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast> toast: Partial<ToasterToast>;
} }
| { | {
type: ActionType["DISMISS_TOAST"] type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"] toastId?: ToasterToast["id"];
} }
| { | {
type: ActionType["REMOVE_TOAST"] type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"] toastId?: ToasterToast["id"];
} };
interface State { interface State {
toasts: ToasterToast[] toasts: ToasterToast[];
} }
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => { const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) { if (toastTimeouts.has(toastId)) {
return return;
} }
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
toastTimeouts.delete(toastId) toastTimeouts.delete(toastId);
dispatch({ dispatch({
type: "REMOVE_TOAST", type: "REMOVE_TOAST",
toastId: toastId, toastId: toastId,
}) });
}, TOAST_REMOVE_DELAY) }, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout) toastTimeouts.set(toastId, timeout);
} };
export const reducer = (state: State, action: Action): State => { export const reducer = (state: State, action: Action): State => {
switch (action.type) { switch (action.type) {
@ -80,7 +77,7 @@ export const reducer = (state: State, action: Action): State => {
return { return {
...state, ...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
} };
case "UPDATE_TOAST": case "UPDATE_TOAST":
return { return {
@ -88,19 +85,19 @@ export const reducer = (state: State, action: Action): State => {
toasts: state.toasts.map((t) => toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t t.id === action.toast.id ? { ...t, ...action.toast } : t
), ),
} };
case "DISMISS_TOAST": { case "DISMISS_TOAST": {
const { toastId } = action const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action, // ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity // but I'll keep it here for simplicity
if (toastId) { if (toastId) {
addToRemoveQueue(toastId) addToRemoveQueue(toastId);
} else { } else {
state.toasts.forEach((toast) => { state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id) addToRemoveQueue(toast.id);
}) });
} }
return { return {
@ -113,44 +110,44 @@ export const reducer = (state: State, action: Action): State => {
} }
: t : t
), ),
} };
} }
case "REMOVE_TOAST": case "REMOVE_TOAST":
if (action.toastId === undefined) { if (action.toastId === undefined) {
return { return {
...state, ...state,
toasts: [], toasts: [],
} };
} }
return { return {
...state, ...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId), toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
} }
} };
}
const listeners: Array<(state: State) => void> = [] const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] } let memoryState: State = { toasts: [] };
function dispatch(action: Action) { function dispatch(action: Action) {
memoryState = reducer(memoryState, action) memoryState = reducer(memoryState, action);
listeners.forEach((listener) => { listeners.forEach((listener) => {
listener(memoryState) listener(memoryState);
}) });
} }
type Toast = Omit<ToasterToast, "id"> type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) { function toast({ ...props }: Toast) {
const id = genId() const id = genId();
const update = (props: ToasterToast) => const update = (props: ToasterToast) =>
dispatch({ dispatch({
type: "UPDATE_TOAST", type: "UPDATE_TOAST",
toast: { ...props, id }, toast: { ...props, id },
}) });
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({ dispatch({
type: "ADD_TOAST", type: "ADD_TOAST",
@ -159,36 +156,37 @@ function toast({ ...props }: Toast) {
id, id,
open: true, open: true,
onOpenChange: (open) => { onOpenChange: (open) => {
if (!open) dismiss() if (!open) dismiss();
}, },
}, },
}) });
return { return {
id: id, id: id,
dismiss, dismiss,
update, update,
} };
} }
function useToast() { function useToast() {
const [state, setState] = React.useState<State>(memoryState) const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => { React.useEffect(() => {
listeners.push(setState) listeners.push(setState);
return () => { return () => {
const index = listeners.indexOf(setState) const index = listeners.indexOf(setState);
if (index > -1) { if (index > -1) {
listeners.splice(index, 1) listeners.splice(index, 1);
} }
} };
}, [state]) }, [state]);
return { return {
...state, ...state,
toast, toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), dismiss: (toastId?: string) =>
} dispatch({ type: "DISMISS_TOAST", toastId }),
};
} }
export { useToast, toast } export { useToast, toast };

View file

@ -1,6 +1,15 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
}
export function formatAxiosError(error: any, defaultMessage?: string): string {
return (
error.response?.data?.message ||
error?.message ||
defaultMessage ||
"An error occurred"
);
} }

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");

Some files were not shown because too many files have changed in this diff Show more