use niceId for client routes

This commit is contained in:
miloschwartz
2025-12-06 20:31:09 -05:00
parent 8a8c0edad3
commit d7e06161a8
22 changed files with 375 additions and 222 deletions

View File

@@ -1,6 +1,6 @@
import { join } from "path"; import { join } from "path";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
import { db, resources, siteResources } from "@server/db"; import { clients, db, resources, siteResources } from "@server/db";
import { randomInt } from "crypto"; import { randomInt } from "crypto";
import { exitNodes, sites } from "@server/db"; import { exitNodes, sites } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
@@ -16,6 +16,25 @@ if (!dev) {
} }
export const names = JSON.parse(readFileSync(file, "utf-8")); export const names = JSON.parse(readFileSync(file, "utf-8"));
export async function getUniqueClientName(orgId: string): Promise<string> {
let loops = 0;
while (true) {
if (loops > 100) {
throw new Error("Could not generate a unique name");
}
const name = generateName();
const count = await db
.select({ niceId: clients.niceId, orgId: clients.orgId })
.from(clients)
.where(and(eq(clients.niceId, name), eq(clients.orgId, orgId)));
if (count.length === 0) {
return name;
}
loops++;
}
}
export async function getUniqueSiteName(orgId: string): Promise<string> { export async function getUniqueSiteName(orgId: string): Promise<string> {
let loops = 0; let loops = 0;
while (true) { while (true) {
@@ -47,11 +66,21 @@ export async function getUniqueResourceName(orgId: string): Promise<string> {
db db
.select({ niceId: resources.niceId, orgId: resources.orgId }) .select({ niceId: resources.niceId, orgId: resources.orgId })
.from(resources) .from(resources)
.where(and(eq(resources.niceId, name), eq(resources.orgId, orgId))), .where(
and(eq(resources.niceId, name), eq(resources.orgId, orgId))
),
db db
.select({ niceId: siteResources.niceId, orgId: siteResources.orgId }) .select({
niceId: siteResources.niceId,
orgId: siteResources.orgId
})
.from(siteResources) .from(siteResources)
.where(and(eq(siteResources.niceId, name), eq(siteResources.orgId, orgId))) .where(
and(
eq(siteResources.niceId, name),
eq(siteResources.orgId, orgId)
)
)
]); ]);
if (resourceCount.length === 0 && siteResourceCount.length === 0) { if (resourceCount.length === 0 && siteResourceCount.length === 0) {
return name; return name;
@@ -60,7 +89,9 @@ export async function getUniqueResourceName(orgId: string): Promise<string> {
} }
} }
export async function getUniqueSiteResourceName(orgId: string): Promise<string> { export async function getUniqueSiteResourceName(
orgId: string
): Promise<string> {
let loops = 0; let loops = 0;
while (true) { while (true) {
if (loops > 100) { if (loops > 100) {
@@ -72,11 +103,21 @@ export async function getUniqueSiteResourceName(orgId: string): Promise<string>
db db
.select({ niceId: resources.niceId, orgId: resources.orgId }) .select({ niceId: resources.niceId, orgId: resources.orgId })
.from(resources) .from(resources)
.where(and(eq(resources.niceId, name), eq(resources.orgId, orgId))), .where(
and(eq(resources.niceId, name), eq(resources.orgId, orgId))
),
db db
.select({ niceId: siteResources.niceId, orgId: siteResources.orgId }) .select({
niceId: siteResources.niceId,
orgId: siteResources.orgId
})
.from(siteResources) .from(siteResources)
.where(and(eq(siteResources.niceId, name), eq(siteResources.orgId, orgId))) .where(
and(
eq(siteResources.niceId, name),
eq(siteResources.orgId, orgId)
)
)
]); ]);
if (resourceCount.length === 0 && siteResourceCount.length === 0) { if (resourceCount.length === 0 && siteResourceCount.length === 0) {
return name; return name;
@@ -87,9 +128,7 @@ export async function getUniqueSiteResourceName(orgId: string): Promise<string>
export async function getUniqueExitNodeEndpointName(): Promise<string> { export async function getUniqueExitNodeEndpointName(): Promise<string> {
let loops = 0; let loops = 0;
const count = await db const count = await db.select().from(exitNodes);
.select()
.from(exitNodes);
while (true) { while (true) {
if (loops > 100) { if (loops > 100) {
throw new Error("Could not generate a unique name"); throw new Error("Could not generate a unique name");
@@ -108,12 +147,9 @@ export async function getUniqueExitNodeEndpointName(): Promise<string> {
} }
} }
export function generateName(): string { export function generateName(): string {
const name = ( const name = (
names.descriptors[ names.descriptors[randomInt(names.descriptors.length)] +
randomInt(names.descriptors.length)
] +
"-" + "-" +
names.animals[randomInt(names.animals.length)] names.animals[randomInt(names.animals.length)]
) )

View File

@@ -1,10 +1,11 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db } from "@server/db"; import { Client, db } from "@server/db";
import { userOrgs, clients, roleClients, userClients } from "@server/db"; import { userOrgs, clients, roleClients, userClients } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } 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 { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import logger from "@server/logger";
export async function verifyClientAccess( export async function verifyClientAccess(
req: Request, req: Request,
@@ -12,33 +13,51 @@ export async function verifyClientAccess(
next: NextFunction next: NextFunction
) { ) {
const userId = req.user!.userId; // Assuming you have user information in the request const userId = req.user!.userId; // Assuming you have user information in the request
const clientId = parseInt( const clientIdStr =
req.params.clientId || req.body.clientId || req.query.clientId req.params?.clientId || req.body?.clientId || req.query?.clientId;
); const niceId = req.params?.niceId || req.body?.niceId || req.query?.niceId;
const orgId = req.params?.orgId || req.body?.orgId || req.query?.orgId;
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
if (isNaN(clientId)) {
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid client ID"));
}
try { try {
// Get the client if (!userId) {
const [client] = await db return next(
.select() createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
.from(clients) );
.where(eq(clients.clientId, clientId)) }
.limit(1);
let client: Client | null = null;
if (niceId && orgId) {
const [clientRes] = await db
.select()
.from(clients)
.where(
and(eq(clients.niceId, niceId), eq(clients.orgId, orgId))
)
.limit(1);
client = clientRes;
} else {
const clientId = parseInt(clientIdStr);
if (isNaN(clientId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid client ID")
);
}
// Get the client
const [clientRes] = await db
.select()
.from(clients)
.where(eq(clients.clientId, clientId))
.limit(1);
client = clientRes;
}
if (!client) { if (!client) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.NOT_FOUND,
`Client with ID ${clientId} not found` `Client with ID ${niceId || clientIdStr} not found`
) )
); );
} }
@@ -47,12 +66,12 @@ export async function verifyClientAccess(
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
`Client with ID ${clientId} does not have an organization ID` `Client with ID ${niceId || clientIdStr} does not have an organization ID`
) )
); );
} }
if (!req.userOrg) { if (!req.userOrg || req.userOrg?.orgId !== client.orgId) {
// Get user's role ID in the organization // Get user's role ID in the organization
const userOrgRole = await db const userOrgRole = await db
.select() .select()
@@ -104,7 +123,7 @@ export async function verifyClientAccess(
.from(roleClients) .from(roleClients)
.where( .where(
and( and(
eq(roleClients.clientId, clientId), eq(roleClients.clientId, client.clientId),
eq(roleClients.roleId, userOrgRoleId) eq(roleClients.roleId, userOrgRoleId)
) )
) )
@@ -122,7 +141,7 @@ export async function verifyClientAccess(
.where( .where(
and( and(
eq(userClients.userId, userId), eq(userClients.userId, userId),
eq(userClients.clientId, clientId) eq(userClients.clientId, client.clientId)
) )
) )
.limit(1); .limit(1);
@@ -140,6 +159,7 @@ export async function verifyClientAccess(
) )
); );
} catch (error) { } catch (error) {
logger.error("Error verifying client access", error);
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,

View File

@@ -27,6 +27,8 @@ export async function verifyOrgAccess(
); );
} }
logger.debug(`Verifying access for user ${userId} to organization ${orgId}`);
try { try {
if (!req.userOrg) { if (!req.userOrg) {
const userOrgRes = await db const userOrgRes = await db
@@ -68,6 +70,10 @@ export async function verifyOrgAccess(
// User has access, attach the user's role to the request for potential future use // User has access, attach the user's role to the request for potential future use
req.userOrgRoleId = req.userOrg.roleId; req.userOrgRoleId = req.userOrg.roleId;
req.userOrgId = orgId; req.userOrgId = orgId;
logger.debug(
`User ${userId} has access to organization ${orgId} with role ${req.userOrg.roleId}`
);
return next(); return next();
} catch (e) { } catch (e) {
return next( return next(

View File

@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db } from "@server/db"; import { db, Resource } from "@server/db";
import { resources, userOrgs, userResources, roleResources } from "@server/db"; import { resources, userOrgs, userResources, roleResources } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -12,36 +12,56 @@ export async function verifyResourceAccess(
next: NextFunction next: NextFunction
) { ) {
const userId = req.user!.userId; const userId = req.user!.userId;
const resourceId = const resourceIdStr =
req.params.resourceId || req.body.resourceId || req.query.resourceId; req.params?.resourceId || req.body?.resourceId || req.query?.resourceId;
const niceId = req.params?.niceId || req.body?.niceId || req.query?.niceId;
if (!userId) { const orgId = req.params?.orgId || req.body?.orgId || req.query?.orgId;
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
try { try {
const resource = await db if (!userId) {
.select() return next(
.from(resources) createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
.where(eq(resources.resourceId, resourceId)) );
.limit(1); }
if (resource.length === 0) { let resource: Resource | null = null;
if (orgId && niceId) {
const [resourceRes] = await db
.select()
.from(resources)
.where(
and(
eq(resources.niceId, niceId),
eq(resources.orgId, orgId)
)
)
.limit(1);
resource = resourceRes;
} else {
const resourceId = parseInt(resourceIdStr);
const [resourceRes] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
resource = resourceRes;
}
if (!resource) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found` `Resource with ID ${resourceIdStr || niceId} not found`
) )
); );
} }
if (!resource[0].orgId) { if (!resource.orgId) {
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
`Resource with ID ${resourceId} does not have an organization ID` `Resource with ID ${resourceIdStr || niceId} does not have an organization ID`
) )
); );
} }
@@ -53,14 +73,14 @@ export async function verifyResourceAccess(
.where( .where(
and( and(
eq(userOrgs.userId, userId), eq(userOrgs.userId, userId),
eq(userOrgs.orgId, resource[0].orgId) eq(userOrgs.orgId, resource.orgId)
) )
) )
.limit(1); .limit(1);
req.userOrg = userOrgRole[0]; req.userOrg = userOrgRole[0];
} }
if (!req.userOrg) { if (!req.userOrg || req.userOrg?.orgId !== resource.orgId) {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
@@ -89,14 +109,14 @@ export async function verifyResourceAccess(
const userOrgRoleId = req.userOrg.roleId; const userOrgRoleId = req.userOrg.roleId;
req.userOrgRoleId = userOrgRoleId; req.userOrgRoleId = userOrgRoleId;
req.userOrgId = resource[0].orgId; req.userOrgId = resource.orgId;
const roleResourceAccess = await db const roleResourceAccess = await db
.select() .select()
.from(roleResources) .from(roleResources)
.where( .where(
and( and(
eq(roleResources.resourceId, resourceId), eq(roleResources.resourceId, resource.resourceId),
eq(roleResources.roleId, userOrgRoleId) eq(roleResources.roleId, userOrgRoleId)
) )
) )
@@ -112,7 +132,7 @@ export async function verifyResourceAccess(
.where( .where(
and( and(
eq(userResources.userId, userId), eq(userResources.userId, userId),
eq(userResources.resourceId, resourceId) eq(userResources.resourceId, resource.resourceId)
) )
) )
.limit(1); .limit(1);

View File

@@ -1,10 +1,9 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db } from "@server/db"; import { db } from "@server/db";
import { sites, userOrgs, userSites, roleSites, roles } from "@server/db"; import { sites, Site, userOrgs, userSites, roleSites, roles } from "@server/db";
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";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
export async function verifySiteAccess( export async function verifySiteAccess(
@@ -13,9 +12,10 @@ export async function verifySiteAccess(
next: NextFunction next: NextFunction
) { ) {
const userId = req.user!.userId; // Assuming you have user information in the request const userId = req.user!.userId; // Assuming you have user information in the request
const siteId = parseInt( const siteIdStr =
req.params.siteId || req.body.siteId || req.query.siteId req.params?.siteId || req.body?.siteId || req.query?.siteId;
); const niceId = req.params?.niceId || req.body?.niceId || req.query?.niceId;
const orgId = req.params?.orgId || req.body?.orgId || req.query?.orgId;
if (!userId) { if (!userId) {
return next( return next(
@@ -23,32 +23,49 @@ export async function verifySiteAccess(
); );
} }
if (isNaN(siteId)) {
return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid site ID"));
}
try { try {
// Get the site let site: Site | null = null;
const site = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (site.length === 0) { if (niceId && orgId) {
const [siteRes] = await db
.select()
.from(sites)
.where(and(eq(sites.niceId, niceId), eq(sites.orgId, orgId)))
.limit(1);
site = siteRes;
} else {
const siteId = parseInt(siteIdStr);
if (isNaN(siteId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid site ID")
);
}
// Get the site
const [siteRes] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
site = siteRes;
}
if (!site) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.NOT_FOUND,
`Site with ID ${siteId} not found` `Site with ID ${siteIdStr || niceId} not found`
) )
); );
} }
if (!site[0].orgId) { if (!site.orgId) {
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
`Site with ID ${siteId} does not have an organization ID` `Site with ID ${siteIdStr} does not have an organization ID`
) )
); );
} }
@@ -59,16 +76,13 @@ export async function verifySiteAccess(
.select() .select()
.from(userOrgs) .from(userOrgs)
.where( .where(
and( and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, site[0].orgId)
)
) )
.limit(1); .limit(1);
req.userOrg = userOrgRole[0]; req.userOrg = userOrgRole[0];
} }
if (!req.userOrg) { if (!req.userOrg || req.userOrg?.orgId !== site.orgId) {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
@@ -97,7 +111,7 @@ export async function verifySiteAccess(
const userOrgRoleId = req.userOrg.roleId; const userOrgRoleId = req.userOrg.roleId;
req.userOrgRoleId = userOrgRoleId; req.userOrgRoleId = userOrgRoleId;
req.userOrgId = site[0].orgId; req.userOrgId = site.orgId;
// Check role-based site access first // Check role-based site access first
const roleSiteAccess = await db const roleSiteAccess = await db
@@ -105,7 +119,7 @@ export async function verifySiteAccess(
.from(roleSites) .from(roleSites)
.where( .where(
and( and(
eq(roleSites.siteId, siteId), eq(roleSites.siteId, site.siteId),
eq(roleSites.roleId, userOrgRoleId) eq(roleSites.roleId, userOrgRoleId)
) )
) )
@@ -121,7 +135,10 @@ export async function verifySiteAccess(
.select() .select()
.from(userSites) .from(userSites)
.where( .where(
and(eq(userSites.userId, userId), eq(userSites.siteId, siteId)) and(
eq(userSites.userId, userId),
eq(userSites.siteId, site.siteId)
)
) )
.limit(1); .limit(1);

View File

@@ -25,6 +25,7 @@ import { listExitNodes } from "#dynamic/lib/exitNodes";
import { generateId } from "@server/auth/sessions/app"; import { generateId } from "@server/auth/sessions/app";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { getUniqueClientName } from "@server/db/names";
const createClientParamsSchema = z.strictObject({ const createClientParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -206,9 +207,12 @@ export async function createClient(
); );
} }
const niceId = await getUniqueClientName(orgId);
[newClient] = await trx [newClient] = await trx
.insert(clients) .insert(clients)
.values({ .values({
niceId,
exitNodeId: randomExitNode.exitNodeId, exitNodeId: randomExitNode.exitNodeId,
orgId, orgId,
name, name,

View File

@@ -12,22 +12,34 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
const getClientSchema = z.strictObject({ const getClientSchema = z.strictObject({
clientId: z.string().transform(stoi).pipe(z.int().positive()) clientId: z
.string()
.optional()
.transform(stoi)
.pipe(z.int().positive().optional())
.optional(),
niceId: z.string().optional(),
orgId: z.string().optional()
}); });
async function query(clientId: number) { async function query(clientId?: number, niceId?: string, orgId?: string) {
// Get the client if (clientId) {
const [client] = await db const [res] = await db
.select() .select()
.from(clients) .from(clients)
.where(and(eq(clients.clientId, clientId))) .where(eq(clients.clientId, clientId))
.leftJoin(olms, eq(clients.olmId, olms.olmId)) .leftJoin(olms, eq(clients.clientId, olms.clientId))
.limit(1); .limit(1);
return res;
if (!client) { } else if (niceId && orgId) {
return null; const [res] = await db
.select()
.from(clients)
.where(and(eq(clients.niceId, niceId), eq(clients.orgId, orgId)))
.leftJoin(olms, eq(olms.clientId, olms.clientId))
.limit(1);
return res;
} }
return client;
} }
export type GetClientResponse = NonNullable< export type GetClientResponse = NonNullable<
@@ -36,13 +48,30 @@ export type GetClientResponse = NonNullable<
olmId: string | null; olmId: string | null;
}; };
registry.registerPath({
method: "get",
path: "/org/{orgId}/client/{niceId}",
description:
"Get a client by orgId and niceId. NiceId is a readable ID for the site and unique on a per org basis.",
tags: [OpenAPITags.Org, OpenAPITags.Site],
request: {
params: z.object({
orgId: z.string(),
niceId: z.string()
})
},
responses: {}
});
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
path: "/client/{clientId}", path: "/client/{clientId}",
description: "Get a client by its client ID.", description: "Get a client by its client ID.",
tags: [OpenAPITags.Client], tags: [OpenAPITags.Client],
request: { request: {
params: getClientSchema params: z.object({
clientId: z.number()
})
}, },
responses: {} responses: {}
}); });
@@ -66,9 +95,9 @@ export async function getClient(
); );
} }
const { clientId } = parsedParams.data; const { clientId, niceId, orgId } = parsedParams.data;
const client = await query(clientId); const client = await query(clientId, niceId, orgId);
if (!client) { if (!client) {
return next( return next(

View File

@@ -128,7 +128,8 @@ function queryClients(orgId: string, accessibleClientIds: number[], filter?: "us
olmVersion: olms.version, olmVersion: olms.version,
userId: clients.userId, userId: clients.userId,
username: users.username, username: users.username,
userEmail: users.email userEmail: users.email,
niceId: clients.niceId
}) })
.from(clients) .from(clients)
.leftJoin(orgs, eq(clients.orgId, orgs.orgId)) .leftJoin(orgs, eq(clients.orgId, orgs.orgId))

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 { Client, db, exitNodes, olms, sites } from "@server/db"; import { db } from "@server/db";
import { clients, clientSitesAssociationsCache } from "@server/db"; import { clients } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -15,7 +15,8 @@ const updateClientParamsSchema = z.strictObject({
}); });
const updateClientSchema = z.strictObject({ const updateClientSchema = z.strictObject({
name: z.string().min(1).max(255).optional() name: z.string().min(1).max(255).optional(),
niceId: z.string().min(1).max(255).optional()
}); });
export type UpdateClientBody = z.infer<typeof updateClientSchema>; export type UpdateClientBody = z.infer<typeof updateClientSchema>;
@@ -54,7 +55,7 @@ export async function updateClient(
); );
} }
const { name } = parsedBody.data; const { name, niceId } = parsedBody.data;
const parsedParams = updateClientParamsSchema.safeParse(req.params); const parsedParams = updateClientParamsSchema.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
@@ -84,9 +85,32 @@ export async function updateClient(
); );
} }
// if niceId is provided, check if it's already in use by another client
if (niceId) {
const [existingClient] = await db
.select()
.from(clients)
.where(
and(
eq(clients.niceId, niceId),
eq(clients.orgId, clients.orgId)
)
)
.limit(1);
if (existingClient) {
return next(
createHttpError(
HttpCode.CONFLICT,
`A client with niceId "${niceId}" already exists`
)
);
}
}
const updatedClient = await db const updatedClient = await db
.update(clients) .update(clients)
.set({ name }) .set({ name, niceId })
.where(eq(clients.clientId, clientId)) .where(eq(clients.clientId, clientId))
.returning(); .returning();

View File

@@ -111,6 +111,7 @@ authenticated.get(
authenticated.get( authenticated.get(
"/org/:orgId/site/:niceId", "/org/:orgId/site/:niceId",
verifyOrgAccess, verifyOrgAccess,
verifySiteAccess,
verifyUserHasAction(ActionsEnum.getSite), verifyUserHasAction(ActionsEnum.getSite),
site.getSite site.getSite
); );
@@ -149,6 +150,14 @@ authenticated.get(
client.getClient client.getClient
); );
authenticated.get(
"/org/:orgId/client/:niceId",
verifyOrgAccess,
verifyClientAccess,
verifyUserHasAction(ActionsEnum.getClient),
client.getClient
);
authenticated.put( authenticated.put(
"/org/:orgId/client", "/org/:orgId/client",
verifyOrgAccess, verifyOrgAccess,
@@ -458,6 +467,7 @@ authenticated.get(
authenticated.get( authenticated.get(
"/org/:orgId/resource/:niceId", "/org/:orgId/resource/:niceId",
verifyOrgAccess, verifyOrgAccess,
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.getResource), verifyUserHasAction(ActionsEnum.getResource),
resource.getResource resource.getResource
); );

View File

@@ -34,7 +34,8 @@ import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
const GeneralFormSchema = z.object({ const GeneralFormSchema = z.object({
name: z.string().nonempty("Name is required") name: z.string().nonempty("Name is required"),
niceId: z.string().min(1).max(255).optional()
}); });
type GeneralFormValues = z.infer<typeof GeneralFormSchema>; type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@@ -49,7 +50,8 @@ export default function GeneralPage() {
const form = useForm({ const form = useForm({
resolver: zodResolver(GeneralFormSchema), resolver: zodResolver(GeneralFormSchema),
defaultValues: { defaultValues: {
name: client?.name name: client?.name,
niceId: client?.niceId || ""
}, },
mode: "onChange" mode: "onChange"
}); });
@@ -84,10 +86,11 @@ export default function GeneralPage() {
try { try {
await api.post(`/client/${client?.clientId}`, { await api.post(`/client/${client?.clientId}`, {
name: data.name name: data.name,
niceId: data.niceId
}); });
updateClient({ name: data.name }); updateClient({ name: data.name, niceId: data.niceId });
toast({ toast({
title: t("clientUpdated"), title: t("clientUpdated"),
@@ -139,6 +142,28 @@ export default function GeneralPage() {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="niceId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("identifier")}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"enterIdentifier"
)}
className="flex-1"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form> </form>
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>

View File

@@ -4,7 +4,6 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import ClientProvider from "@app/providers/ClientProvider"; import ClientProvider from "@app/providers/ClientProvider";
import { build } from "@server/build";
import { GetClientResponse } from "@server/routers/client"; import { GetClientResponse } from "@server/routers/client";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
@@ -12,7 +11,7 @@ import { redirect } from "next/navigation";
type SettingsLayoutProps = { type SettingsLayoutProps = {
children: React.ReactNode; children: React.ReactNode;
params: Promise<{ clientId: number | string; orgId: string }>; params: Promise<{ niceId: number | string; orgId: string }>;
}; };
export default async function SettingsLayout(props: SettingsLayoutProps) { export default async function SettingsLayout(props: SettingsLayoutProps) {
@@ -22,8 +21,9 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
let client = null; let client = null;
try { try {
console.log("making request to ", `/org/${params.orgId}/client/${params.niceId}`);
const res = await internal.get<AxiosResponse<GetClientResponse>>( const res = await internal.get<AxiosResponse<GetClientResponse>>(
`/client/${params.clientId}`, `/org/${params.orgId}/client/${params.niceId}`,
await authCookieHeader() await authCookieHeader()
); );
client = res.data.data; client = res.data.data;
@@ -37,11 +37,11 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const navItems = [ const navItems = [
{ {
title: t("general"), title: t("general"),
href: `/{orgId}/settings/clients/machine/{clientId}/general` href: `/{orgId}/settings/clients/machine/{niceId}/general`
}, },
{ {
title: t("credentials"), title: t("credentials"),
href: `/{orgId}/settings/clients/machine/{clientId}/credentials` href: `/{orgId}/settings/clients/machine/{niceId}/credentials`
} }
]; ];

View File

@@ -1,10 +1,10 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export default async function ClientPage(props: { export default async function ClientPage(props: {
params: Promise<{ orgId: string; clientId: number | string }>; params: Promise<{ orgId: string; niceId: number | string }>;
}) { }) {
const params = await props.params; const params = await props.params;
redirect( redirect(
`/${params.orgId}/settings/clients/machine/${params.clientId}/general` `/${params.orgId}/settings/clients/machine/${params.niceId}/general`
); );
} }

View File

@@ -276,7 +276,7 @@ export default function Page() {
if (res && res.status === 201) { if (res && res.status === 201) {
const data = res.data.data; const data = res.data.data;
router.push(`/${orgId}/settings/clients/machine/${data.clientId}`); router.push(`/${orgId}/settings/clients/machine/${data.niceId}`);
} }
setCreateLoading(false); setCreateLoading(false);

View File

@@ -56,7 +56,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
olmUpdateAvailable: client.olmUpdateAvailable || false, olmUpdateAvailable: client.olmUpdateAvailable || false,
userId: client.userId, userId: client.userId,
username: client.username, username: client.username,
userEmail: client.userEmail userEmail: client.userEmail,
niceId: client.niceId
}; };
}; };

View File

@@ -66,7 +66,8 @@ export default async function ClientResourcesPage(
destination: siteResource.destination, destination: siteResource.destination,
// destinationPort: siteResource.destinationPort, // destinationPort: siteResource.destinationPort,
alias: siteResource.alias || null, alias: siteResource.alias || null,
siteNiceId: siteResource.siteNiceId siteNiceId: siteResource.siteNiceId,
niceId: siteResource.niceId
}; };
} }
); );

View File

@@ -19,25 +19,27 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
return ( return (
<Alert> <Alert>
<AlertDescription> <AlertDescription>
<InfoSections cols={2}> <InfoSections cols={3}>
<> <InfoSection>
<InfoSection> <InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
<InfoSectionTitle>{t("status")}</InfoSectionTitle> <InfoSectionContent>{client.niceId}</InfoSectionContent>
<InfoSectionContent> </InfoSection>
{client.online ? ( <InfoSection>
<div className="text-green-500 flex items-center space-x-2"> <InfoSectionTitle>{t("status")}</InfoSectionTitle>
<div className="w-2 h-2 bg-green-500 rounded-full"></div> <InfoSectionContent>
<span>{t("online")}</span> {client.online ? (
</div> <div className="text-green-500 flex items-center space-x-2">
) : ( <div className="w-2 h-2 bg-green-500 rounded-full"></div>
<div className="text-neutral-500 flex items-center space-x-2"> <span>{t("online")}</span>
<div className="w-2 h-2 bg-gray-500 rounded-full"></div> </div>
<span>{t("offline")}</span> ) : (
</div> <div className="text-neutral-500 flex items-center space-x-2">
)} <div className="w-2 h-2 bg-gray-500 rounded-full"></div>
</InfoSectionContent> <span>{t("offline")}</span>
</InfoSection> </div>
</> )}
</InfoSectionContent>
</InfoSection>
<InfoSection> <InfoSection>
<InfoSectionTitle>{t("address")}</InfoSectionTitle> <InfoSectionTitle>{t("address")}</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>

View File

@@ -25,32 +25,6 @@ import EditInternalResourceDialog from "@app/components/EditInternalResourceDial
import { orgQueries } from "@app/lib/queries"; import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
export type TargetHealth = {
targetId: number;
ip: string;
port: number;
enabled: boolean;
healthStatus?: "healthy" | "unhealthy" | "unknown";
};
export type ResourceRow = {
id: number;
nice: string | null;
name: string;
orgId: string;
domain: string;
authState: string;
http: boolean;
protocol: string;
proxyPort: number | null;
enabled: boolean;
domainId?: string;
ssl: boolean;
targetHost?: string;
targetPort?: number;
targets?: TargetHealth[];
};
export type InternalResourceRow = { export type InternalResourceRow = {
id: number; id: number;
name: string; name: string;
@@ -66,6 +40,7 @@ export type InternalResourceRow = {
destination: string; destination: string;
// destinationPort: number | null; // destinationPort: number | null;
alias: string | null; alias: string | null;
niceId: string;
}; };
type ClientResourcesTableProps = { type ClientResourcesTableProps = {
@@ -158,6 +133,28 @@ export default function ClientResourcesTable({
); );
} }
}, },
{
id: "niceId",
accessorKey: "niceId",
friendlyName: t("identifier"),
enableHiding: true,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <span>{row.original.niceId || "-"}</span>;
}
},
{ {
accessorKey: "siteName", accessorKey: "siteName",
friendlyName: t("site"), friendlyName: t("site"),

View File

@@ -40,6 +40,7 @@ export type ClientRow = {
userId: string | null; userId: string | null;
username: string | null; username: string | null;
userEmail: string | null; userEmail: string | null;
niceId: string;
}; };
type ClientTableProps = { type ClientTableProps = {
@@ -66,7 +67,8 @@ export default function MachineClientsTable({
const defaultMachineColumnVisibility = { const defaultMachineColumnVisibility = {
client: false, client: false,
subnet: false, subnet: false,
userId: false userId: false,
niceId: false
}; };
const refreshData = () => { const refreshData = () => {
@@ -129,8 +131,8 @@ export default function MachineClientsTable({
} }
}, },
{ {
accessorKey: "userId", accessorKey: "niceId",
friendlyName: "User", friendlyName: "Identifier",
header: ({ column }) => { header: ({ column }) => {
return ( return (
<Button <Button
@@ -141,54 +143,12 @@ export default function MachineClientsTable({
) )
} }
> >
User {t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );
},
cell: ({ row }) => {
const r = row.original;
return r.userId ? (
<Link
href={`/${r.orgId}/settings/access/users/${r.userId}`}
>
<Button variant="outline">
{r.userEmail || r.username || r.userId}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
) : (
"-"
);
} }
}, },
// {
// accessorKey: "siteName",
// header: ({ column }) => {
// return (
// <Button
// variant="ghost"
// onClick={() =>
// column.toggleSorting(column.getIsSorted() === "asc")
// }
// >
// Site
// <ArrowUpDown className="ml-2 h-4 w-4" />
// </Button>
// );
// },
// cell: ({ row }) => {
// const r = row.original;
// return (
// <Link href={`/${r.orgId}/settings/sites/${r.siteId}`}>
// <Button variant="outline">
// {r.siteName}
// <ArrowUpRight className="ml-2 h-4 w-4" />
// </Button>
// </Link>
// );
// }
// },
{ {
accessorKey: "online", accessorKey: "online",
friendlyName: "Connectivity", friendlyName: "Connectivity",
@@ -369,7 +329,7 @@ export default function MachineClientsTable({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Link <Link
href={`/${clientRow.orgId}/settings/clients/machine/${clientRow.id}`} href={`/${clientRow.orgId}/settings/clients/machine/${clientRow.niceId}`}
> >
<Button variant={"outline"}> <Button variant={"outline"}>
{t("edit")} {t("edit")}

View File

@@ -319,7 +319,7 @@ export default function ProxyResourcesTable({
{ {
id: "niceId", id: "niceId",
accessorKey: "nice", accessorKey: "nice",
friendlyName: t("niceId"), friendlyName: t("identifier"),
enableHiding: true, enableHiding: true,
header: ({ column }) => { header: ({ column }) => {
return ( return (
@@ -329,7 +329,7 @@ export default function ProxyResourcesTable({
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t("niceId")} {t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );

View File

@@ -128,7 +128,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
{ {
id: "niceId", id: "niceId",
accessorKey: "nice", accessorKey: "nice",
friendlyName: t("niceId"), friendlyName: t("identifier"),
enableHiding: true, enableHiding: true,
header: ({ column }) => { header: ({ column }) => {
return ( return (
@@ -138,7 +138,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
column.toggleSorting(column.getIsSorted() === "asc") column.toggleSorting(column.getIsSorted() === "asc")
} }
> >
{t("niceId")} {t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <ArrowUpDown className="ml-2 h-4 w-4" />
</Button> </Button>
); );