setup unifi wlans
This commit is contained in:
@@ -14,6 +14,9 @@ export default createRoute(
|
||||
async (c) => {
|
||||
const company = await companies.fetch(c.req.param("identifier"));
|
||||
const includeAddress = c.req.query("includeAddress") === "true";
|
||||
const includePrimaryContact =
|
||||
c.req.query("includePrimaryContact") === "true";
|
||||
const includeAllContacts = c.req.query("includeAllContacts") === "true";
|
||||
|
||||
// Check for address-specific permission if includeAddress is requested
|
||||
if (includeAddress) {
|
||||
@@ -27,9 +30,25 @@ export default createRoute(
|
||||
}
|
||||
}
|
||||
|
||||
// Check for contacts permission if includeAllContacts is requested
|
||||
if (includeAllContacts) {
|
||||
const user = c.get("user");
|
||||
if (!user || !(await user.hasPermission("company.fetch.contacts"))) {
|
||||
throw new GenericError({
|
||||
name: "InsufficientPermission",
|
||||
message: "You do not have permission to view company contacts.",
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Company Fetched Successfully!",
|
||||
company.toJson({ includeAddress }),
|
||||
company.toJson({
|
||||
includeAddress,
|
||||
includePrimaryContact,
|
||||
includeAllContacts,
|
||||
}),
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { unifiSites } from "../../../managers/unifiSites";
|
||||
import { companies } from "../../../managers/companies";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* GET /v1/company/companies/:identifier/unifi/sites */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/companies/:identifier/unifi/sites"],
|
||||
async (c) => {
|
||||
const company = await companies.fetch(c.req.param("identifier"));
|
||||
const sites = await unifiSites.fetchByCompany(company.id);
|
||||
const response = apiResponse.successful(
|
||||
"Company UniFi Sites Fetched Successfully!",
|
||||
sites,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({
|
||||
permissions: ["unifi.access", "company.fetch"],
|
||||
}),
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
import { default as fetchAll } from "./fetchAll";
|
||||
import { default as fetch } from "./[id]/fetch";
|
||||
import { default as configurations } from "./[id]/configurations";
|
||||
import { default as unifiSites } from "./[id]/unifiSites";
|
||||
import { default as count } from "./count";
|
||||
|
||||
export { configurations, count, fetch, fetchAll };
|
||||
export { configurations, count, fetch, fetchAll, unifiSites };
|
||||
|
||||
@@ -15,19 +15,22 @@ export default createRoute(
|
||||
async (c) => {
|
||||
const body = await c.req.json();
|
||||
|
||||
const fieldSchema: z.ZodType<any> = z.lazy(() =>
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
required: z.boolean(),
|
||||
secure: z.boolean(),
|
||||
valueType: z.enum(Object.values(ValueType)),
|
||||
subFields: z.array(fieldSchema).optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
permissionScope: z.string().min(1, "Permission scope is required"),
|
||||
icon: z.string().optional(),
|
||||
fields: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
required: z.boolean(),
|
||||
secure: z.boolean(),
|
||||
valueType: z.enum(Object.values(ValueType)),
|
||||
}),
|
||||
),
|
||||
fields: z.array(fieldSchema),
|
||||
});
|
||||
|
||||
const data = schema.parse(body);
|
||||
|
||||
@@ -16,21 +16,22 @@ export default createRoute(
|
||||
const body = await c.req.json();
|
||||
const credentialType = await credentialTypes.fetch(c.req.param("id"));
|
||||
|
||||
const fieldSchema: z.ZodType<any> = z.lazy(() =>
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
required: z.boolean(),
|
||||
secure: z.boolean(),
|
||||
valueType: z.enum(Object.values(ValueType)),
|
||||
subFields: z.array(fieldSchema).optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().optional(),
|
||||
permissionScope: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
fields: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
required: z.boolean(),
|
||||
secure: z.boolean(),
|
||||
valueType: z.enum(Object.values(ValueType)),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
fields: z.array(fieldSchema).optional(),
|
||||
});
|
||||
|
||||
const data = schema.parse(body);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { credentials } from "../../managers/credentials";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
import { z } from "zod";
|
||||
|
||||
/* POST /v1/credential/credentials/:id/sub-credentials */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/credentials/:id/sub-credentials"],
|
||||
|
||||
async (c) => {
|
||||
const parentId = c.req.param("id");
|
||||
const body = await c.req.json();
|
||||
|
||||
const schema = z.object({
|
||||
fieldId: z.string().min(1, "Field ID is required"),
|
||||
name: z.string().min(1, "Name is required"),
|
||||
fields: z.array(
|
||||
z.object({
|
||||
fieldId: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const data = schema.parse(body);
|
||||
|
||||
const subCredential = await credentials.addSubCredential(
|
||||
parentId,
|
||||
data.fieldId,
|
||||
{
|
||||
name: data.name,
|
||||
fields: data.fields,
|
||||
},
|
||||
);
|
||||
|
||||
const response = apiResponse.created(
|
||||
"Sub-Credential Created Successfully!",
|
||||
subCredential.toJson(),
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({
|
||||
permissions: ["credential.fetch", "credential.sub_credentials.create"],
|
||||
}),
|
||||
);
|
||||
@@ -25,6 +25,22 @@ export default createRoute(
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
subCredentials: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.array(
|
||||
z.object({
|
||||
name: z.string().min(1, "Sub-credential name is required"),
|
||||
fields: z.array(
|
||||
z.object({
|
||||
fieldId: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const data = schema.parse(body);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { credentials } from "../../managers/credentials";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
|
||||
/* GET /v1/credential/credentials/:id/sub-credentials */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/credentials/:id/sub-credentials"],
|
||||
|
||||
async (c) => {
|
||||
const parentId = c.req.param("id");
|
||||
|
||||
// Verify the parent credential exists
|
||||
await credentials.fetch(parentId);
|
||||
|
||||
const subCredentials = await credentials.fetchSubCredentials(parentId);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Sub-Credentials Fetched Successfully!",
|
||||
subCredentials.map((sc) => sc.toJson()),
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({
|
||||
permissions: ["credential.fetch", "credential.sub_credentials.fetch"],
|
||||
}),
|
||||
);
|
||||
@@ -8,6 +8,9 @@ import { default as readSecureValues } from "./readSecureValues";
|
||||
import { default as readSecureValue } from "./readSecureValue";
|
||||
import { default as deleteCredential } from "./delete";
|
||||
import { default as valueTypes } from "./valueTypes";
|
||||
import { default as fetchSubCredentials } from "./fetchSubCredentials";
|
||||
import { default as addSubCredential } from "./addSubCredential";
|
||||
import { default as removeSubCredential } from "./removeSubCredential";
|
||||
|
||||
export {
|
||||
valueTypes,
|
||||
@@ -20,4 +23,7 @@ export {
|
||||
readSecureValues,
|
||||
readSecureValue,
|
||||
deleteCredential as delete,
|
||||
fetchSubCredentials,
|
||||
addSubCredential,
|
||||
removeSubCredential,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { credentials } from "../../managers/credentials";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../middleware/authorization";
|
||||
|
||||
/* DELETE /v1/credential/credentials/:id/sub-credentials/:subId */
|
||||
export default createRoute(
|
||||
"delete",
|
||||
["/credentials/:id/sub-credentials/:subId"],
|
||||
|
||||
async (c) => {
|
||||
const parentId = c.req.param("id");
|
||||
const subId = c.req.param("subId");
|
||||
|
||||
await credentials.removeSubCredential(parentId, subId);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Sub-Credential Removed Successfully!",
|
||||
null,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({
|
||||
permissions: ["credential.fetch", "credential.sub_credentials.delete"],
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import * as unifiRoutes from "../unifi";
|
||||
|
||||
const unifiRouter = new Hono();
|
||||
Object.values(unifiRoutes).map((r) => unifiRouter.route("/", r));
|
||||
|
||||
export default unifiRouter;
|
||||
@@ -54,6 +54,7 @@ v1.route("/credential", require("./routers/credentialRouter").default);
|
||||
v1.route("/credential-type", require("./routers/credentialTypeRouter").default);
|
||||
v1.route("/role", require("./routers/roleRouter").default);
|
||||
v1.route("/permissions", require("./routers/permissionRouter").default);
|
||||
v1.route("/unifi", require("./routers/unifiRouter").default);
|
||||
app.route("/v1", v1);
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { default as fetchAllSites } from "./sites/fetchAll";
|
||||
import { default as syncSites } from "./sites/sync";
|
||||
import { default as createSite } from "./sites/create";
|
||||
import { default as fetchSite } from "./site/fetch";
|
||||
import { default as siteOverview } from "./site/overview";
|
||||
import { default as siteDevices } from "./site/devices";
|
||||
import { default as siteNetworks } from "./site/networks";
|
||||
import { default as siteWifiFetchAll } from "./site/wifi/fetchAll";
|
||||
import { default as siteWifiUpdate } from "./site/wifi/update";
|
||||
import { default as siteWifiPpskFetchAll } from "./site/wifi/ppskFetchAll";
|
||||
import { default as siteWifiPpskCreate } from "./site/wifi/ppskCreate";
|
||||
import { default as siteLink } from "./site/link";
|
||||
import { default as siteUnlink } from "./site/unlink";
|
||||
import { default as siteWlanGroups } from "./site/wlanGroups";
|
||||
import { default as siteWlanGroupsCreate } from "./site/wlanGroupsCreate";
|
||||
import { default as siteAccessPoints } from "./site/accessPoints";
|
||||
import { default as siteApGroups } from "./site/apGroups";
|
||||
import { default as siteWifiLimits } from "./site/wifiLimits";
|
||||
import { default as siteSpeedProfilesFetchAll } from "./site/speedProfilesFetchAll";
|
||||
import { default as siteSpeedProfilesCreate } from "./site/speedProfilesCreate";
|
||||
|
||||
export {
|
||||
fetchAllSites,
|
||||
syncSites,
|
||||
createSite,
|
||||
fetchSite,
|
||||
siteOverview,
|
||||
siteDevices,
|
||||
siteNetworks,
|
||||
siteWifiFetchAll,
|
||||
siteWifiUpdate,
|
||||
siteWifiPpskFetchAll,
|
||||
siteWifiPpskCreate,
|
||||
siteLink,
|
||||
siteUnlink,
|
||||
siteWlanGroups,
|
||||
siteWlanGroupsCreate,
|
||||
siteAccessPoints,
|
||||
siteApGroups,
|
||||
siteWifiLimits,
|
||||
siteSpeedProfilesFetchAll,
|
||||
siteSpeedProfilesCreate,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { unifiSites } from "../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* GET /v1/unifi/site/:id/access-points */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/site/:id/access-points"],
|
||||
async (c) => {
|
||||
const site = await unifiSites.fetch(c.req.param("id"));
|
||||
const accessPoints = await unifiSites.getAccessPoints(site.siteId);
|
||||
const response = apiResponse.successful(
|
||||
"UniFi Access Points Fetched Successfully!",
|
||||
accessPoints,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({
|
||||
permissions: ["unifi.access", "unifi.site.access-points"],
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { unifiSites } from "../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* GET /v1/unifi/site/:id/ap-groups */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/site/:id/ap-groups"],
|
||||
async (c) => {
|
||||
const site = await unifiSites.fetch(c.req.param("id"));
|
||||
const apGroups = await unifiSites.getApGroups(site.siteId);
|
||||
const response = apiResponse.successful(
|
||||
"UniFi AP Groups Fetched Successfully!",
|
||||
apGroups,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({
|
||||
permissions: ["unifi.access", "unifi.site.ap-groups"],
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { unifiSites } from "../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* GET /v1/unifi/site/:id/devices */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/site/:id/devices"],
|
||||
async (c) => {
|
||||
const site = await unifiSites.fetch(c.req.param("id"));
|
||||
const devices = await unifiSites.getDevices(site.siteId);
|
||||
const response = apiResponse.successful(
|
||||
"UniFi Devices Fetched Successfully!",
|
||||
devices,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["unifi.access", "unifi.site.devices"] }),
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { unifiSites } from "../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* GET /v1/unifi/site/:id */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/site/:id"],
|
||||
async (c) => {
|
||||
const site = await unifiSites.fetch(c.req.param("id"));
|
||||
const response = apiResponse.successful(
|
||||
"UniFi Site Fetched Successfully!",
|
||||
site,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["unifi.access", "unifi.sites.fetch"] }),
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { unifiSites } from "../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { z } from "zod";
|
||||
|
||||
/* POST /v1/unifi/site/:id/link */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/site/:id/link"],
|
||||
async (c) => {
|
||||
const siteId = c.req.param("id");
|
||||
const body = await c.req.json();
|
||||
const schema = z.object({ companyId: z.string() }).strict();
|
||||
const { companyId } = schema.parse(body);
|
||||
|
||||
const site = await unifiSites.linkToCompany(siteId, companyId);
|
||||
const response = apiResponse.successful(
|
||||
"UniFi Site Linked to Company Successfully!",
|
||||
site,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["unifi.access", "unifi.sites.link"] }),
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { unifiSites } from "../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* GET /v1/unifi/site/:id/networks */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/site/:id/networks"],
|
||||
async (c) => {
|
||||
const site = await unifiSites.fetch(c.req.param("id"));
|
||||
const networks = await unifiSites.getNetworks(site.siteId);
|
||||
const response = apiResponse.successful(
|
||||
"UniFi Networks Fetched Successfully!",
|
||||
networks,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["unifi.access", "unifi.site.networks"] }),
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { unifiSites } from "../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* GET /v1/unifi/site/:id/overview */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/site/:id/overview"],
|
||||
async (c) => {
|
||||
const site = await unifiSites.fetch(c.req.param("id"));
|
||||
const overview = await unifiSites.getSiteOverview(site.siteId);
|
||||
const response = apiResponse.successful(
|
||||
"UniFi Site Overview Fetched Successfully!",
|
||||
overview,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["unifi.access", "unifi.site.overview"] }),
|
||||
);
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { unifiSites } from "../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { z } from "zod";
|
||||
|
||||
/* POST /v1/unifi/site/:id/speed-profiles */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/site/:id/speed-profiles"],
|
||||
async (c) => {
|
||||
const site = await unifiSites.fetch(c.req.param("id"));
|
||||
|
||||
const body = await c.req.json();
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
downloadLimitKbps: z.number().optional(),
|
||||
uploadLimitKbps: z.number().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const parsed = schema.parse(body);
|
||||
const profile = await unifiSites.createUserGroup(site.siteId, parsed);
|
||||
|
||||
const response = apiResponse.created(
|
||||
"UniFi Speed Profile Created Successfully!",
|
||||
profile,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({
|
||||
permissions: [
|
||||
"unifi.access",
|
||||
"unifi.site.speed-profiles",
|
||||
"unifi.site.speed-profiles.create",
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { unifiSites } from "../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* GET /v1/unifi/site/:id/speed-profiles */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/site/:id/speed-profiles"],
|
||||
async (c) => {
|
||||
const site = await unifiSites.fetch(c.req.param("id"));
|
||||
const profiles = await unifiSites.getUserGroups(site.siteId);
|
||||
const response = apiResponse.successful(
|
||||
"UniFi Speed Profiles Fetched Successfully!",
|
||||
profiles,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({
|
||||
permissions: ["unifi.access", "unifi.site.speed-profiles"],
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { unifiSites } from "../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* POST /v1/unifi/site/:id/unlink */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/site/:id/unlink"],
|
||||
async (c) => {
|
||||
const siteId = c.req.param("id");
|
||||
const site = await unifiSites.unlinkFromCompany(siteId);
|
||||
const response = apiResponse.successful(
|
||||
"UniFi Site Unlinked from Company Successfully!",
|
||||
site,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["unifi.access", "unifi.sites.link"] }),
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createRoute } from "../../../../modules/api-utils/createRoute";
|
||||
import { unifiSites } from "../../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/unifi/site/:id/wifi */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/site/:id/wifi"],
|
||||
async (c) => {
|
||||
const site = await unifiSites.fetch(c.req.param("id"));
|
||||
const wlans = await unifiSites.getWlanConf(site.siteId);
|
||||
|
||||
const processWlans = await Promise.all(
|
||||
wlans.map((wlan) =>
|
||||
processObjectValuePerms(wlan, "unifi.site.wifi.read", c.get("user")),
|
||||
),
|
||||
);
|
||||
|
||||
console.log(processWlans);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"UniFi WiFi Networks Fetched Successfully!",
|
||||
processWlans,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["unifi.access", "unifi.site.wifi"] }),
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createRoute } from "../../../../modules/api-utils/createRoute";
|
||||
import { unifiSites } from "../../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../middleware/authorization";
|
||||
import { z } from "zod";
|
||||
|
||||
/* POST /v1/unifi/site/:id/wifi/:wlanId/ppsk */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/site/:id/wifi/:wlanId/ppsk"],
|
||||
async (c) => {
|
||||
const site = await unifiSites.fetch(c.req.param("id"));
|
||||
const wlanId = c.req.param("wlanId");
|
||||
|
||||
const body = await c.req.json();
|
||||
const schema = z
|
||||
.object({
|
||||
key: z.string().min(8, "PSK must be at least 8 characters"),
|
||||
name: z.string().min(1, "Name is required"),
|
||||
mac: z.string().optional(),
|
||||
vlanId: z.number().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const parsed = schema.parse(body);
|
||||
const ppsks = await unifiSites.createPrivatePSK(
|
||||
site.siteId,
|
||||
wlanId,
|
||||
parsed,
|
||||
);
|
||||
|
||||
const response = apiResponse.created(
|
||||
"UniFi Private PSK Created Successfully!",
|
||||
ppsks,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({
|
||||
permissions: [
|
||||
"unifi.access",
|
||||
"unifi.site.wifi",
|
||||
"unifi.site.wifi.ppsk",
|
||||
"unifi.site.wifi.ppsk.create",
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createRoute } from "../../../../modules/api-utils/createRoute";
|
||||
import { unifiSites } from "../../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../middleware/authorization";
|
||||
|
||||
/* GET /v1/unifi/site/:id/wifi/:wlanId/ppsk */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/site/:id/wifi/:wlanId/ppsk"],
|
||||
async (c) => {
|
||||
const site = await unifiSites.fetch(c.req.param("id"));
|
||||
const wlanId = c.req.param("wlanId");
|
||||
const ppsks = await unifiSites.getPrivatePSKs(site.siteId, wlanId);
|
||||
const response = apiResponse.successful(
|
||||
"UniFi Private PSKs Fetched Successfully!",
|
||||
ppsks,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({
|
||||
permissions: ["unifi.access", "unifi.site.wifi", "unifi.site.wifi.ppsk"],
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,117 @@
|
||||
import { createRoute } from "../../../../modules/api-utils/createRoute";
|
||||
import { unifiSites } from "../../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../middleware/authorization";
|
||||
import { toWlanConfUpdate } from "../../../../modules/unifi-api/unifiTypes";
|
||||
import { z } from "zod";
|
||||
|
||||
/* PATCH /v1/unifi/site/:id/wifi/:wlanId */
|
||||
export default createRoute(
|
||||
"patch",
|
||||
["/site/:id/wifi/:wlanId"],
|
||||
async (c) => {
|
||||
const site = await unifiSites.fetch(c.req.param("id"));
|
||||
const wlanId = c.req.param("wlanId");
|
||||
|
||||
const body = await c.req.json();
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
passphrase: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
security: z.enum(["wpapsk", "wpaeap", "open", "osen"]).optional(),
|
||||
wpaMode: z.enum(["wpa2", "wpa3", "wpa2wpa3"]).optional(),
|
||||
wpaEnc: z.enum(["ccmp", "gcmp", "ccmp-gcmp"]).optional(),
|
||||
hideSSID: z.boolean().optional(),
|
||||
macFilterEnabled: z.boolean().optional(),
|
||||
macFilterPolicy: z.enum(["allow", "deny"]).optional(),
|
||||
isGuest: z.boolean().optional(),
|
||||
l2Isolation: z.boolean().optional(),
|
||||
fastRoamingEnabled: z.boolean().optional(),
|
||||
bssTransition: z.boolean().optional(),
|
||||
uapsdEnabled: z.boolean().optional(),
|
||||
groupRekey: z.number().optional(),
|
||||
dtimMode: z.enum(["default", "custom"]).optional(),
|
||||
dtimNg: z.number().optional(),
|
||||
dtimNa: z.number().optional(),
|
||||
minrateNgEnabled: z.boolean().optional(),
|
||||
minrateNaEnabled: z.boolean().optional(),
|
||||
radiusDasEnabled: z.boolean().optional(),
|
||||
radiusMacAuthEnabled: z.boolean().optional(),
|
||||
pmfMode: z.enum(["disabled", "optional", "required"]).optional(),
|
||||
band: z.enum(["both", "2g", "5g"]).optional(),
|
||||
usergroupId: z.string().optional(),
|
||||
proxyArp: z.boolean().optional(),
|
||||
mcastenhanceEnabled: z.boolean().optional(),
|
||||
macFilterList: z.array(z.string()).optional(),
|
||||
no2ghzOui: z.boolean().optional(),
|
||||
apGroupIds: z.array(z.string()).optional(),
|
||||
apGroupMode: z.enum(["all", "groups", "devices"]).optional(),
|
||||
deviceMacs: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const parsed = schema.parse(body);
|
||||
|
||||
// If deviceMacs is provided, manage the devices_ap_group:
|
||||
// - If already in devices mode with a for_wlanconf group, update that group
|
||||
// - Otherwise create a new for_wlanconf group and switch to devices mode
|
||||
if (parsed.deviceMacs && parsed.deviceMacs.length > 0) {
|
||||
const wlans = await unifiSites.getWlanConf(site.siteId);
|
||||
const currentWlan = wlans.find((w) => w.id === wlanId);
|
||||
|
||||
let existingGroupId: string | undefined;
|
||||
if (
|
||||
currentWlan?.apGroupMode === "devices" &&
|
||||
currentWlan.apGroupIds.length > 0
|
||||
) {
|
||||
// Check if the current group is a for_wlanconf group we can update
|
||||
const apGroups = await unifiSites.getApGroups(site.siteId);
|
||||
const currentGroup = apGroups.find(
|
||||
(g) => g.id === currentWlan.apGroupIds[0] && g.forWlanconf,
|
||||
);
|
||||
if (currentGroup) existingGroupId = currentGroup.id;
|
||||
}
|
||||
|
||||
if (existingGroupId) {
|
||||
// Update the existing for_wlanconf group's device list
|
||||
await unifiSites.updateApGroup(
|
||||
site.siteId,
|
||||
existingGroupId,
|
||||
parsed.deviceMacs,
|
||||
);
|
||||
parsed.apGroupMode = "devices";
|
||||
parsed.apGroupIds = [existingGroupId];
|
||||
} else {
|
||||
// Create a new for_wlanconf group
|
||||
const apGroup = await unifiSites.createApGroup(
|
||||
site.siteId,
|
||||
"devices_ap_group",
|
||||
parsed.deviceMacs,
|
||||
true, // for_wlanconf must be true for devices mode
|
||||
);
|
||||
parsed.apGroupMode = "devices";
|
||||
parsed.apGroupIds = [apGroup.id];
|
||||
}
|
||||
}
|
||||
|
||||
// Remove deviceMacs before converting — it's not a UniFi field
|
||||
const { deviceMacs: _, ...updateInput } = parsed;
|
||||
const updates = toWlanConfUpdate(updateInput);
|
||||
|
||||
const result = await unifiSites.updateWlanConf(
|
||||
site.siteId,
|
||||
wlanId,
|
||||
updates,
|
||||
);
|
||||
const response = apiResponse.successful(
|
||||
"UniFi WiFi Network Updated Successfully!",
|
||||
result,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({
|
||||
permissions: ["unifi.access", "unifi.site.wifi", "unifi.site.wifi.update"],
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { unifiSites } from "../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* GET /v1/unifi/site/:id/wifi-limits */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/site/:id/wifi-limits"],
|
||||
async (c) => {
|
||||
const site = await unifiSites.fetch(c.req.param("id"));
|
||||
const limits = await unifiSites.getWifiLimits(site.siteId);
|
||||
const response = apiResponse.successful(
|
||||
"UniFi WiFi Limits Fetched Successfully!",
|
||||
limits,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({
|
||||
permissions: ["unifi.access", "unifi.site.wifi-limits"],
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { unifiSites } from "../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* GET /v1/unifi/site/:id/wlan-groups */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/site/:id/wlan-groups"],
|
||||
async (c) => {
|
||||
const site = await unifiSites.fetch(c.req.param("id"));
|
||||
const wlanGroups = await unifiSites.getWlanGroups(site.siteId);
|
||||
const response = apiResponse.successful(
|
||||
"UniFi WLAN Groups Fetched Successfully!",
|
||||
wlanGroups,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({
|
||||
permissions: ["unifi.access", "unifi.site.wlan-groups"],
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { unifiSites } from "../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { z } from "zod";
|
||||
|
||||
/* POST /v1/unifi/site/:id/wlan-groups */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/site/:id/wlan-groups"],
|
||||
async (c) => {
|
||||
const site = await unifiSites.fetch(c.req.param("id"));
|
||||
|
||||
const body = await c.req.json();
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const parsed = schema.parse(body);
|
||||
const group = await unifiSites.createWlanGroup(site.siteId, parsed);
|
||||
|
||||
const response = apiResponse.created(
|
||||
"UniFi WLAN Group Created Successfully!",
|
||||
group,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({
|
||||
permissions: [
|
||||
"unifi.access",
|
||||
"unifi.site.wlan-groups",
|
||||
"unifi.site.wlan-groups.create",
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { unifiSites } from "../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
import { z } from "zod";
|
||||
|
||||
/* POST /v1/unifi/sites/create */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/sites/create"],
|
||||
async (c) => {
|
||||
const body = await c.req.json();
|
||||
const schema = z.object({ description: z.string().min(1) }).strict();
|
||||
const { description } = schema.parse(body);
|
||||
|
||||
const site = await unifiSites.createSite(description);
|
||||
const response = apiResponse.successful(
|
||||
"UniFi Site Created Successfully!",
|
||||
site,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["unifi.access", "unifi.sites.create"] }),
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { unifiSites } from "../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* GET /v1/unifi/sites */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/sites"],
|
||||
async (c) => {
|
||||
const sites = await unifiSites.fetchAll();
|
||||
const response = apiResponse.successful(
|
||||
"UniFi Sites Fetched Successfully!",
|
||||
sites,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["unifi.access", "unifi.sites.fetch.many"] }),
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { unifiSites } from "../../../managers/unifiSites";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* POST /v1/unifi/sites/sync */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/sites/sync"],
|
||||
async (c) => {
|
||||
const sites = await unifiSites.syncSites();
|
||||
const response = apiResponse.successful(
|
||||
"UniFi Sites Synced Successfully!",
|
||||
sites,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["unifi.access", "unifi.sites.sync"] }),
|
||||
);
|
||||
@@ -5,6 +5,7 @@ import * as msal from "@azure/msal-node";
|
||||
import { Server } from "socket.io";
|
||||
import { Server as Engine } from "@socket.io/bun-engine";
|
||||
import axios from "axios";
|
||||
import { UnifiClient } from "./modules/unifi-api/UnifiClient";
|
||||
|
||||
const connectionString = `${process.env.DATABASE_URL}`;
|
||||
const adapter = new PrismaPg({ connectionString });
|
||||
@@ -67,3 +68,13 @@ const connectWiseApi = axios.create({
|
||||
});
|
||||
|
||||
export { connectWiseApi };
|
||||
|
||||
// Unifi API Constants
|
||||
|
||||
export const unifiControllerBaseUrl =
|
||||
process.env.UNIFI_CONTROLLER_BASE_URL || "https://unifi.example.com";
|
||||
export const unifiSite = process.env.UNIFI_SITE || "default";
|
||||
export const unifiUsername = process.env.UNIFI_USERNAME || "admin";
|
||||
export const unifiPassword = process.env.UNIFI_PASSWORD || "";
|
||||
|
||||
export const unifi = new UnifiClient(unifiControllerBaseUrl);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Company } from "../../generated/prisma/client";
|
||||
import { fetchCwCompanyById } from "../modules/cw-utils/fetchCompany";
|
||||
import { fetchCompanyConfigurations } from "../modules/cw-utils/configurations/fetchCompanyConfigurations";
|
||||
import { updateCwInternalCompany } from "../modules/cw-utils/updateCompany";
|
||||
import { Company as CWCompany } from "../types/ConnectWiseTypes";
|
||||
import { Company as CWCompany, Contact } from "../types/ConnectWiseTypes";
|
||||
|
||||
/**
|
||||
* Company Controller
|
||||
@@ -16,9 +16,13 @@ export class CompanyController {
|
||||
public name: string;
|
||||
public readonly cw_Identifier: string;
|
||||
public readonly cw_CompanyId: number;
|
||||
public readonly cw_Data?: CWCompany;
|
||||
public readonly cw_Data?: {
|
||||
company: CWCompany;
|
||||
defaultContact: Contact;
|
||||
allContacts: Contact[];
|
||||
};
|
||||
|
||||
constructor(companyData: Company, cwData?: CWCompany) {
|
||||
constructor(companyData: Company, cwData?: typeof this.cw_Data) {
|
||||
this.id = companyData.id;
|
||||
this.name = companyData.name;
|
||||
this.cw_Identifier = companyData.cw_Identifier;
|
||||
@@ -67,23 +71,66 @@ export class CompanyController {
|
||||
return data;
|
||||
}
|
||||
|
||||
public toJson(opts?: { includeAddress: boolean }) {
|
||||
public toJson(opts?: {
|
||||
includeAddress: boolean;
|
||||
includePrimaryContact: boolean;
|
||||
includeAllContacts?: boolean;
|
||||
}) {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
cw_Identifier: this.cw_Identifier,
|
||||
cw_CompanyId: this.cw_CompanyId,
|
||||
cw_Data: {
|
||||
address: {
|
||||
line1: this.cw_Data?.addressLine1,
|
||||
line2: this.cw_Data?.addressLine2 ?? null,
|
||||
city: this.cw_Data?.city,
|
||||
state: this.cw_Data?.state,
|
||||
zip: this.cw_Data?.zip,
|
||||
country: this.cw_Data?.country
|
||||
? this.cw_Data.country.name
|
||||
: "United States",
|
||||
},
|
||||
address: !opts?.includeAddress
|
||||
? undefined
|
||||
: {
|
||||
line1: this.cw_Data?.company.addressLine1,
|
||||
line2: this.cw_Data?.company.addressLine2 ?? null,
|
||||
city: this.cw_Data?.company.city,
|
||||
state: this.cw_Data?.company.state,
|
||||
zip: this.cw_Data?.company.zip,
|
||||
country: this.cw_Data?.company.country
|
||||
? this.cw_Data.company.country.name
|
||||
: "United States",
|
||||
},
|
||||
primaryContact: !opts?.includePrimaryContact
|
||||
? undefined
|
||||
: {
|
||||
firstName: this.cw_Data?.defaultContact.firstName,
|
||||
lastName: this.cw_Data?.defaultContact.lastName,
|
||||
cwId: this.cw_Data?.defaultContact.id,
|
||||
inactive: this.cw_Data?.defaultContact.inactiveFlag,
|
||||
title: this.cw_Data?.defaultContact.title,
|
||||
phone: this.cw_Data?.defaultContact.defaultPhoneNbr,
|
||||
email: (() => {
|
||||
if (!this.cw_Data?.defaultContact.communicationItems)
|
||||
return null;
|
||||
return (
|
||||
this.cw_Data?.defaultContact.communicationItems.find(
|
||||
(v) => v.type.name === "Email",
|
||||
)?.value ?? null
|
||||
);
|
||||
})(),
|
||||
},
|
||||
allContacts: !opts?.includeAllContacts
|
||||
? undefined
|
||||
: this.cw_Data?.allContacts.map((contact) => ({
|
||||
firstName: contact.firstName,
|
||||
lastName: contact.lastName,
|
||||
cwId: contact.id,
|
||||
inactive: contact.inactiveFlag,
|
||||
title: contact.title,
|
||||
phone: contact.defaultPhoneNbr,
|
||||
email: (() => {
|
||||
if (!contact.communicationItems) return null;
|
||||
return (
|
||||
contact.communicationItems.find(
|
||||
(v) => v.type.name === "Email",
|
||||
)?.value ?? null
|
||||
);
|
||||
})(),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,11 +28,13 @@ export class CredentialController {
|
||||
public notes: string | null;
|
||||
public readonly typeId: string;
|
||||
public readonly companyId: string;
|
||||
public readonly subCredentialOfId: string | null;
|
||||
public fields: any;
|
||||
|
||||
private _type: CredentialType;
|
||||
private _company: Company;
|
||||
private _secureValues: SecureValue[];
|
||||
private _subCredentials: CredentialController[];
|
||||
|
||||
public readonly createdAt: Date;
|
||||
public updatedAt: Date;
|
||||
@@ -42,6 +44,11 @@ export class CredentialController {
|
||||
type: CredentialType;
|
||||
company: Company;
|
||||
securevalues: SecureValue[];
|
||||
subCredentials?: (Credential & {
|
||||
type: CredentialType;
|
||||
company: Company;
|
||||
securevalues: SecureValue[];
|
||||
})[];
|
||||
},
|
||||
) {
|
||||
this.id = credentialData.id;
|
||||
@@ -49,13 +56,69 @@ export class CredentialController {
|
||||
this.notes = credentialData.notes;
|
||||
this.typeId = credentialData.typeId;
|
||||
this.companyId = credentialData.companyId;
|
||||
this.subCredentialOfId = credentialData.subCredentialOfId;
|
||||
this._type = credentialData.type;
|
||||
this._company = credentialData.company;
|
||||
this._secureValues = credentialData.securevalues;
|
||||
this.fields = (() => {
|
||||
let fields = credentialData.fields as Record<string, any>;
|
||||
this._subCredentials = (credentialData.subCredentials ?? []).map(
|
||||
(sc) => new CredentialController(sc),
|
||||
);
|
||||
this.fields = this._buildFields(credentialData);
|
||||
this.createdAt = credentialData.createdAt;
|
||||
this.updatedAt = credentialData.updatedAt;
|
||||
}
|
||||
|
||||
return (this._type.fields! as any).map((f: any) => ({
|
||||
/**
|
||||
* Build Fields
|
||||
*
|
||||
* Maps raw credential data into a structured fields array.
|
||||
* - Regular credentials: maps through the type's field definitions.
|
||||
* - Multi-credential fields: returns sub-credential references and subField definitions.
|
||||
* - Sub-credentials: returns raw field data (validated against subFields, not the type's top-level fields).
|
||||
*/
|
||||
private _buildFields(credentialData: Credential) {
|
||||
const raw = credentialData.fields as Record<string, any>;
|
||||
const typeFields = this._type.fields as any as CredentialTypeField[];
|
||||
|
||||
// Sub-credentials: their fields don't match the type's top-level definitions,
|
||||
// so we return a simple id/value list built from raw JSON + secure values.
|
||||
if (credentialData.subCredentialOfId) {
|
||||
const result: any[] = [];
|
||||
|
||||
// Collect field IDs that have secure values
|
||||
const secureFieldIds = new Set(this._secureValues.map((sv) => sv.name));
|
||||
|
||||
// Non-secure fields from JSON
|
||||
Object.entries(raw).forEach(([fieldId, value]) => {
|
||||
if (!secureFieldIds.has(fieldId)) {
|
||||
result.push({ id: fieldId, value, secure: false });
|
||||
}
|
||||
});
|
||||
|
||||
// Secure value references
|
||||
this._secureValues.forEach((sv) => {
|
||||
result.push({ id: sv.name, value: `secure-${sv.id}`, secure: true });
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Regular (parent) credential: map through type field definitions
|
||||
return typeFields.map((f: any) => {
|
||||
if (f.valueType === ValueType.MULTI_CREDENTIAL) {
|
||||
const subCredIds: string[] = raw[f.id] ?? [];
|
||||
return {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
secure: false,
|
||||
required: f.required,
|
||||
valueType: f.valueType,
|
||||
subFields: f.subFields ?? [],
|
||||
subCredentialIds: subCredIds,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
secure: f.secure,
|
||||
@@ -63,13 +126,9 @@ export class CredentialController {
|
||||
valueType: f.valueType as ValueType,
|
||||
value: f.secure
|
||||
? `secure-${this._secureValues.find((sv) => sv.name === f.id)?.id}`
|
||||
: fields[f.id],
|
||||
}));
|
||||
|
||||
return fields;
|
||||
})();
|
||||
this.createdAt = credentialData.createdAt;
|
||||
this.updatedAt = credentialData.updatedAt;
|
||||
: raw[f.id],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,6 +210,19 @@ export class CredentialController {
|
||||
fieldsObject[field.fieldId] = field.value;
|
||||
});
|
||||
|
||||
// Preserve multi-credential field values (sub-credential ID arrays)
|
||||
const currentFields = (await prisma.credential.findFirst({
|
||||
where: { id: this.id },
|
||||
select: { fields: true },
|
||||
}))!.fields as Record<string, any>;
|
||||
|
||||
const typeFields = this._type.fields as any as CredentialTypeField[];
|
||||
typeFields.forEach((f) => {
|
||||
if (f.valueType === ValueType.MULTI_CREDENTIAL && currentFields[f.id]) {
|
||||
fieldsObject[f.id] = currentFields[f.id];
|
||||
}
|
||||
});
|
||||
|
||||
// Update the credential with non-secure fields
|
||||
const updatedCredential = await prisma.credential.update({
|
||||
where: { id: this.id },
|
||||
@@ -313,13 +385,14 @@ export class CredentialController {
|
||||
* @param opts - Options to change the output
|
||||
* @returns - An object that is JSON friendly
|
||||
*/
|
||||
toJson(opts?: { includeSecureValues?: boolean }) {
|
||||
toJson(opts?: { includeSecureValues?: boolean }): Record<string, any> {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
notes: this.notes,
|
||||
typeId: this.typeId,
|
||||
companyId: this.companyId,
|
||||
subCredentialOfId: this.subCredentialOfId ?? undefined,
|
||||
fields: this.fields,
|
||||
type: {
|
||||
id: this._type.id,
|
||||
@@ -331,6 +404,10 @@ export class CredentialController {
|
||||
id: this._company.id,
|
||||
name: this._company.name,
|
||||
},
|
||||
subCredentials:
|
||||
this._subCredentials.length > 0
|
||||
? this._subCredentials.map((sc) => sc.toJson(opts))
|
||||
: undefined,
|
||||
secureFieldIds: opts?.includeSecureValues
|
||||
? this._secureValues.map((sv) => sv.name)
|
||||
: undefined,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { UnifiSite } from "../../generated/prisma/client";
|
||||
|
||||
/**
|
||||
* UniFi Site Controller
|
||||
*
|
||||
* Handles formatting and presentation of UniFi site data.
|
||||
*/
|
||||
export class UnifiSiteController {
|
||||
public readonly id: string;
|
||||
public readonly name: string;
|
||||
public readonly siteId: string;
|
||||
public readonly companyId: string | null;
|
||||
|
||||
constructor(site: UnifiSite) {
|
||||
this.id = site.id;
|
||||
this.name = site.name;
|
||||
this.siteId = site.siteId;
|
||||
this.companyId = site.companyId;
|
||||
}
|
||||
|
||||
public toJson() {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
siteId: this.siteId,
|
||||
companyId: this.companyId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { refresh } from "./api/auth";
|
||||
import app from "./api/server";
|
||||
import { engine, PORT } from "./constants";
|
||||
import { unifiSites } from "./managers/unifiSites";
|
||||
import { refreshCompanies } from "./modules/cw-utils/refreshCompanies";
|
||||
import { events, setupEventDebugger } from "./modules/globalEvents";
|
||||
|
||||
@@ -13,6 +14,11 @@ setInterval(() => {
|
||||
return refreshCompanies();
|
||||
}, 60 * 1000);
|
||||
|
||||
await unifiSites.syncSites();
|
||||
setInterval(() => {
|
||||
return unifiSites.syncSites();
|
||||
}, 60 * 1000);
|
||||
|
||||
Bun.serve({
|
||||
port: PORT,
|
||||
websocket: engine.handler().websocket,
|
||||
|
||||
@@ -12,10 +12,21 @@ export const companies = {
|
||||
|
||||
if (!search) throw new Error("Unknown company.");
|
||||
|
||||
const freshCwData = await connectWiseApi.get(
|
||||
const freshCwData: { data: Company } = await connectWiseApi.get(
|
||||
`/company/companies/${search.cw_CompanyId}`,
|
||||
);
|
||||
return new CompanyController(search, freshCwData.data);
|
||||
const defaultContactData = await connectWiseApi.get(
|
||||
(freshCwData.data as Company).defaultContact._info.contact_href,
|
||||
);
|
||||
const allContactsData = await connectWiseApi.get(
|
||||
`${freshCwData.data._info.contacts_href}&pageSize=1000`,
|
||||
);
|
||||
|
||||
return new CompanyController(search, {
|
||||
company: freshCwData.data,
|
||||
defaultContact: defaultContactData.data,
|
||||
allContacts: allContactsData.data,
|
||||
});
|
||||
},
|
||||
|
||||
async count() {
|
||||
|
||||
@@ -79,8 +79,6 @@ export const credentialTypes = {
|
||||
});
|
||||
}
|
||||
|
||||
console.log(data.fields);
|
||||
|
||||
const credentialType = await prisma.credentialType.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
|
||||
+288
-26
@@ -4,10 +4,28 @@ import { fieldValidator } from "../modules/credentials/fieldValidator";
|
||||
import {
|
||||
CredentialField,
|
||||
CredentialTypeField,
|
||||
ValueType,
|
||||
} from "../modules/credentials/credentialTypeDefs";
|
||||
import { generateSecureValue } from "../modules/credentials/generateSecureValue";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
|
||||
/**
|
||||
* Standard include clause used by every credential query.
|
||||
* Includes the credential type, company, secure values, and one level of sub-credentials.
|
||||
*/
|
||||
const credentialInclude = {
|
||||
type: true,
|
||||
company: true,
|
||||
securevalues: true,
|
||||
subCredentials: {
|
||||
include: {
|
||||
type: true,
|
||||
company: true,
|
||||
securevalues: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const credentials = {
|
||||
/**
|
||||
* Fetch Credential
|
||||
@@ -20,11 +38,7 @@ export const credentials = {
|
||||
async fetch(id: string): Promise<CredentialController> {
|
||||
const credential = await prisma.credential.findFirst({
|
||||
where: { id },
|
||||
include: {
|
||||
type: true,
|
||||
company: true,
|
||||
securevalues: true,
|
||||
},
|
||||
include: credentialInclude,
|
||||
});
|
||||
|
||||
if (!credential) {
|
||||
@@ -42,19 +56,17 @@ export const credentials = {
|
||||
/**
|
||||
* Fetch Credentials by Company
|
||||
*
|
||||
* Fetch all credentials associated with a specific company.
|
||||
* Fetch all top-level credentials associated with a specific company.
|
||||
* Sub-credentials are excluded from the top-level list and instead
|
||||
* included nested under their parent credential.
|
||||
*
|
||||
* @param companyId - The company ID to fetch credentials for
|
||||
* @returns {Promise<CredentialController[]>} - Array of credential controllers
|
||||
*/
|
||||
async fetchByCompany(companyId: string): Promise<CredentialController[]> {
|
||||
const credentialsList = await prisma.credential.findMany({
|
||||
where: { companyId },
|
||||
include: {
|
||||
type: true,
|
||||
company: true,
|
||||
securevalues: true,
|
||||
},
|
||||
where: { companyId, subCredentialOfId: null },
|
||||
include: credentialInclude,
|
||||
});
|
||||
|
||||
return credentialsList.map((cred) => new CredentialController(cred));
|
||||
@@ -68,6 +80,10 @@ export const credentials = {
|
||||
* the credential type, encrypting secure fields, and inserting everything
|
||||
* into the database atomically.
|
||||
*
|
||||
* When the credential type contains multi-credential fields, pass
|
||||
* `subCredentials` keyed by the multi-credential field ID. Each entry
|
||||
* is an array of sub-credential objects with their own name and fields.
|
||||
*
|
||||
* @param data - The credential data to create
|
||||
* @returns {Promise<CredentialController>} - The created credential controller
|
||||
*/
|
||||
@@ -80,6 +96,10 @@ export const credentials = {
|
||||
fieldId: string;
|
||||
value: string;
|
||||
}[];
|
||||
subCredentials?: Record<
|
||||
string,
|
||||
{ name: string; fields: { fieldId: string; value: string }[] }[]
|
||||
>;
|
||||
}): Promise<CredentialController> {
|
||||
// Fetch the credential type to get acceptable fields
|
||||
const credentialType = await prisma.credentialType.findFirst({
|
||||
@@ -95,22 +115,31 @@ export const credentials = {
|
||||
});
|
||||
}
|
||||
|
||||
// Validate the fields against acceptable fields
|
||||
const acceptableFields = (
|
||||
credentialType.fields! as any as CredentialTypeField[]
|
||||
).map((f: { id: string; name: string; secure: boolean }) => ({
|
||||
const typeFields = credentialType.fields! as any as CredentialTypeField[];
|
||||
|
||||
// Validate the fields against acceptable fields (exclude multi-credential fields
|
||||
// from value validation since they don't carry a direct value).
|
||||
const acceptableFields = typeFields.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
secure: f.secure,
|
||||
required: f.required,
|
||||
valueType: f.valueType,
|
||||
subFields: f.subFields,
|
||||
})) as CredentialTypeField[];
|
||||
|
||||
const validatedFields = await fieldValidator(
|
||||
data.fields as any as CredentialField[],
|
||||
acceptableFields,
|
||||
);
|
||||
|
||||
// Separate secure and non-secure fields
|
||||
const secureFields = validatedFields.filter((f) => f.secure);
|
||||
const nonSecureFields = validatedFields.filter((f) => !f.secure);
|
||||
// Separate secure, non-secure, and multi-credential fields
|
||||
const secureFields = validatedFields.filter(
|
||||
(f) => f.secure && !f.isMultiCredential,
|
||||
);
|
||||
const nonSecureFields = validatedFields.filter(
|
||||
(f) => !f.secure && !f.isMultiCredential,
|
||||
);
|
||||
|
||||
// Build fields object for non-secure fields
|
||||
const fieldsObject: Record<string, any> = {};
|
||||
@@ -118,6 +147,13 @@ export const credentials = {
|
||||
fieldsObject[field.fieldId] = field.value;
|
||||
});
|
||||
|
||||
// Initialise multi-credential field slots with empty arrays
|
||||
typeFields
|
||||
.filter((f) => f.valueType === ValueType.MULTI_CREDENTIAL)
|
||||
.forEach((f) => {
|
||||
fieldsObject[f.id] = [];
|
||||
});
|
||||
|
||||
// Encrypt secure field values
|
||||
const secureValueData = secureFields.map((field) => {
|
||||
const { encrypted, hash } = generateSecureValue(field.value);
|
||||
@@ -128,7 +164,7 @@ export const credentials = {
|
||||
};
|
||||
});
|
||||
|
||||
// Create credential and all secure values in a transaction
|
||||
// Create the parent credential first
|
||||
const credential = await prisma.credential.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
@@ -140,20 +176,246 @@ export const credentials = {
|
||||
create: secureValueData,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
type: true,
|
||||
company: true,
|
||||
securevalues: true,
|
||||
},
|
||||
});
|
||||
|
||||
return new CredentialController(credential);
|
||||
// Create inline sub-credentials when provided
|
||||
if (data.subCredentials) {
|
||||
for (const [fieldId, subCredDataList] of Object.entries(
|
||||
data.subCredentials,
|
||||
)) {
|
||||
const fieldDef = typeFields.find((f) => f.id === fieldId);
|
||||
|
||||
if (!fieldDef || fieldDef.valueType !== ValueType.MULTI_CREDENTIAL) {
|
||||
throw new GenericError({
|
||||
message: `Field '${fieldId}' is not a multi-credential field`,
|
||||
name: "InvalidMultiCredentialField",
|
||||
cause: `Cannot create sub-credentials for field '${fieldId}' because it is not a multi-credential field.`,
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const subFieldDefs = (fieldDef.subFields ??
|
||||
[]) as CredentialTypeField[];
|
||||
const subCredIds: string[] = [];
|
||||
|
||||
for (const subCredData of subCredDataList) {
|
||||
const validatedSubFields = await fieldValidator(
|
||||
subCredData.fields as any as CredentialField[],
|
||||
subFieldDefs,
|
||||
);
|
||||
|
||||
const subSecure = validatedSubFields.filter((f) => f.secure);
|
||||
const subNonSecure = validatedSubFields.filter((f) => !f.secure);
|
||||
|
||||
const subFieldsObject: Record<string, any> = {};
|
||||
subNonSecure.forEach((f) => {
|
||||
subFieldsObject[f.fieldId] = f.value;
|
||||
});
|
||||
|
||||
const subSecureValueData = subSecure.map((f) => {
|
||||
const { encrypted, hash } = generateSecureValue(f.value);
|
||||
return { name: f.fieldId, content: encrypted, hash };
|
||||
});
|
||||
|
||||
const subCred = await prisma.credential.create({
|
||||
data: {
|
||||
name: subCredData.name,
|
||||
typeId: data.typeId,
|
||||
companyId: data.companyId,
|
||||
subCredentialOfId: credential.id,
|
||||
fields: subFieldsObject,
|
||||
securevalues: { create: subSecureValueData },
|
||||
},
|
||||
});
|
||||
|
||||
subCredIds.push(subCred.id);
|
||||
}
|
||||
|
||||
fieldsObject[fieldId] = subCredIds;
|
||||
}
|
||||
|
||||
// Persist the sub-credential ID arrays on the parent
|
||||
await prisma.credential.update({
|
||||
where: { id: credential.id },
|
||||
data: { fields: fieldsObject },
|
||||
});
|
||||
}
|
||||
|
||||
// Re-fetch with full includes
|
||||
const completeCredential = await prisma.credential.findFirst({
|
||||
where: { id: credential.id },
|
||||
include: credentialInclude,
|
||||
});
|
||||
|
||||
return new CredentialController(completeCredential!);
|
||||
},
|
||||
|
||||
/**
|
||||
* Add Sub-Credential
|
||||
*
|
||||
* Create a new sub-credential under an existing parent credential
|
||||
* for a specific multi-credential field.
|
||||
*
|
||||
* @param parentId - The parent credential ID
|
||||
* @param fieldId - The multi-credential field this sub-credential belongs to
|
||||
* @param data - The sub-credential data (name and fields)
|
||||
* @returns {Promise<CredentialController>} - The created sub-credential controller
|
||||
*/
|
||||
async addSubCredential(
|
||||
parentId: string,
|
||||
fieldId: string,
|
||||
data: {
|
||||
name: string;
|
||||
fields: { fieldId: string; value: string }[];
|
||||
},
|
||||
): Promise<CredentialController> {
|
||||
const parent = await prisma.credential.findFirst({
|
||||
where: { id: parentId },
|
||||
include: { type: true },
|
||||
});
|
||||
|
||||
if (!parent) {
|
||||
throw new GenericError({
|
||||
message: "Parent credential not found",
|
||||
name: "CredentialNotFound",
|
||||
cause: `No credential exists with ID '${parentId}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const typeFields = parent.type.fields as any as CredentialTypeField[];
|
||||
const fieldDef = typeFields.find((f) => f.id === fieldId);
|
||||
|
||||
if (!fieldDef || fieldDef.valueType !== ValueType.MULTI_CREDENTIAL) {
|
||||
throw new GenericError({
|
||||
message: `Field '${fieldId}' is not a multi-credential field`,
|
||||
name: "InvalidMultiCredentialField",
|
||||
cause: `Cannot create sub-credentials for field '${fieldId}' because it is not a multi-credential field.`,
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const subFieldDefs = (fieldDef.subFields ?? []) as CredentialTypeField[];
|
||||
const validatedFields = await fieldValidator(
|
||||
data.fields as any as CredentialField[],
|
||||
subFieldDefs,
|
||||
);
|
||||
|
||||
const secureFields = validatedFields.filter((f) => f.secure);
|
||||
const nonSecureFields = validatedFields.filter((f) => !f.secure);
|
||||
|
||||
const subFieldsObject: Record<string, any> = {};
|
||||
nonSecureFields.forEach((f) => {
|
||||
subFieldsObject[f.fieldId] = f.value;
|
||||
});
|
||||
|
||||
const secureValueData = secureFields.map((f) => {
|
||||
const { encrypted, hash } = generateSecureValue(f.value);
|
||||
return { name: f.fieldId, content: encrypted, hash };
|
||||
});
|
||||
|
||||
const subCredential = await prisma.credential.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
typeId: parent.typeId,
|
||||
companyId: parent.companyId,
|
||||
subCredentialOfId: parentId,
|
||||
fields: subFieldsObject,
|
||||
securevalues: { create: secureValueData },
|
||||
},
|
||||
include: credentialInclude,
|
||||
});
|
||||
|
||||
// Update parent's fields JSON to include the new sub-credential ID
|
||||
const parentFields = parent.fields as Record<string, any>;
|
||||
const subCredIds: string[] = parentFields[fieldId] ?? [];
|
||||
subCredIds.push(subCredential.id);
|
||||
parentFields[fieldId] = subCredIds;
|
||||
|
||||
await prisma.credential.update({
|
||||
where: { id: parentId },
|
||||
data: { fields: parentFields },
|
||||
});
|
||||
|
||||
return new CredentialController(subCredential);
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove Sub-Credential
|
||||
*
|
||||
* Delete a sub-credential and remove its reference from the parent credential's
|
||||
* multi-credential field.
|
||||
*
|
||||
* @param parentId - The parent credential ID
|
||||
* @param subCredentialId - The sub-credential ID to remove
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async removeSubCredential(
|
||||
parentId: string,
|
||||
subCredentialId: string,
|
||||
): Promise<void> {
|
||||
const subCredential = await prisma.credential.findFirst({
|
||||
where: { id: subCredentialId, subCredentialOfId: parentId },
|
||||
});
|
||||
|
||||
if (!subCredential) {
|
||||
throw new GenericError({
|
||||
message: "Sub-credential not found",
|
||||
name: "SubCredentialNotFound",
|
||||
cause: `No sub-credential with ID '${subCredentialId}' exists under credential '${parentId}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the sub-credential (cascade removes its secure values)
|
||||
await prisma.credential.delete({
|
||||
where: { id: subCredentialId },
|
||||
});
|
||||
|
||||
// Remove the sub-credential ID from the parent's fields JSON
|
||||
const parent = await prisma.credential.findFirst({
|
||||
where: { id: parentId },
|
||||
});
|
||||
|
||||
if (parent) {
|
||||
const parentFields = parent.fields as Record<string, any>;
|
||||
for (const key of Object.keys(parentFields)) {
|
||||
if (Array.isArray(parentFields[key])) {
|
||||
parentFields[key] = parentFields[key].filter(
|
||||
(id: string) => id !== subCredentialId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.credential.update({
|
||||
where: { id: parentId },
|
||||
data: { fields: parentFields },
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Sub-Credentials
|
||||
*
|
||||
* Fetch all sub-credentials that belong to a specific parent credential.
|
||||
*
|
||||
* @param parentId - The parent credential ID
|
||||
* @returns {Promise<CredentialController[]>} - Array of sub-credential controllers
|
||||
*/
|
||||
async fetchSubCredentials(parentId: string): Promise<CredentialController[]> {
|
||||
const subCredentials = await prisma.credential.findMany({
|
||||
where: { subCredentialOfId: parentId },
|
||||
include: credentialInclude,
|
||||
});
|
||||
|
||||
return subCredentials.map((sc) => new CredentialController(sc));
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete Credential
|
||||
*
|
||||
* Delete a credential by its ID.
|
||||
* Sub-credentials are cascade-deleted automatically by the database.
|
||||
*
|
||||
* @param id - The credential ID to delete
|
||||
* @returns {Promise<void>}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { prisma, unifi, unifiUsername, unifiPassword } from "../constants";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
import { UnifiSite } from "../../generated/prisma/client";
|
||||
|
||||
let loggedIn = false;
|
||||
|
||||
async function ensureLoggedIn(): Promise<void> {
|
||||
if (loggedIn) return;
|
||||
if (!unifiPassword)
|
||||
throw new GenericError({
|
||||
name: "UnifiConfigError",
|
||||
message: "UniFi controller credentials are not configured.",
|
||||
status: 503,
|
||||
});
|
||||
await unifi.login(unifiUsername, unifiPassword);
|
||||
loggedIn = true;
|
||||
}
|
||||
|
||||
export const unifiSites = {
|
||||
/**
|
||||
* Fetch a UniFi site record from the database by its internal ID.
|
||||
*/
|
||||
async fetch(id: string): Promise<UnifiSite> {
|
||||
const site = await prisma.unifiSite.findFirst({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!site)
|
||||
throw new GenericError({
|
||||
name: "UnifiSiteNotFound",
|
||||
message: `UniFi site with id '${id}' was not found.`,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
return site;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all UniFi site records from the database.
|
||||
*/
|
||||
async fetchAll(): Promise<UnifiSite[]> {
|
||||
return prisma.unifiSite.findMany({
|
||||
include: { company: true },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all UniFi site records linked to a specific company.
|
||||
*/
|
||||
async fetchByCompany(companyId: string): Promise<UnifiSite[]> {
|
||||
return prisma.unifiSite.findMany({
|
||||
where: { companyId },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Link a UniFi site to a company.
|
||||
*/
|
||||
async linkToCompany(siteId: string, companyId: string): Promise<UnifiSite> {
|
||||
const site = await prisma.unifiSite.findFirst({ where: { id: siteId } });
|
||||
if (!site)
|
||||
throw new GenericError({
|
||||
name: "UnifiSiteNotFound",
|
||||
message: `UniFi site '${siteId}' was not found.`,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
const company = await prisma.company.findFirst({
|
||||
where: { id: companyId },
|
||||
});
|
||||
if (!company)
|
||||
throw new GenericError({
|
||||
name: "CompanyNotFound",
|
||||
message: `Company '${companyId}' was not found.`,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
return prisma.unifiSite.update({
|
||||
where: { id: siteId },
|
||||
data: { companyId },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Unlink a UniFi site from its company.
|
||||
*/
|
||||
async unlinkFromCompany(siteId: string): Promise<UnifiSite> {
|
||||
return prisma.unifiSite.update({
|
||||
where: { id: siteId },
|
||||
data: { companyId: null },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Sync all sites from the UniFi controller into the database.
|
||||
* Creates new records for sites not yet tracked, updates names for existing ones.
|
||||
*/
|
||||
async syncSites(): Promise<UnifiSite[]> {
|
||||
await ensureLoggedIn();
|
||||
|
||||
// Fetch all sites from the controller
|
||||
const allSites = await unifi.getAllSites();
|
||||
|
||||
const results: UnifiSite[] = [];
|
||||
|
||||
for (const site of allSites) {
|
||||
const existing = await prisma.unifiSite.findFirst({
|
||||
where: { siteId: site.name },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const updated = await prisma.unifiSite.update({
|
||||
where: { id: existing.id },
|
||||
data: { name: site.description },
|
||||
});
|
||||
results.push(updated);
|
||||
} else {
|
||||
const created = await prisma.unifiSite.create({
|
||||
data: {
|
||||
name: site.description,
|
||||
siteId: site.name,
|
||||
},
|
||||
});
|
||||
results.push(created);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get live site overview from the UniFi controller.
|
||||
*/
|
||||
async getSiteOverview(siteId: string) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.getSiteOverview(siteId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get live devices from the UniFi controller for a site.
|
||||
*/
|
||||
async getDevices(siteId: string) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.getDevices(siteId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get live WiFi networks (WLANs) from the UniFi controller for a site.
|
||||
*/
|
||||
async getWlanConf(siteId: string) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.getWlanConf(siteId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a WiFi network on the UniFi controller.
|
||||
*/
|
||||
async updateWlanConf(
|
||||
siteId: string,
|
||||
wlanId: string,
|
||||
updates: Parameters<typeof unifi.updateWlanConf>[2],
|
||||
) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.updateWlanConf(siteId, wlanId, updates);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get live network configurations from the UniFi controller for a site.
|
||||
*/
|
||||
async getNetworks(siteId: string) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.getNetworks(siteId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new site on the UniFi controller and track it in the database.
|
||||
*/
|
||||
async createSite(description: string): Promise<UnifiSite> {
|
||||
await ensureLoggedIn();
|
||||
|
||||
const created = await unifi.createSite(description);
|
||||
|
||||
return prisma.unifiSite.create({
|
||||
data: {
|
||||
name: created.description,
|
||||
siteId: created.name,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get WLAN groups from the UniFi controller for a site.
|
||||
*/
|
||||
async getWlanGroups(siteId: string) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.getWlanGroups(siteId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new WLAN group (AP broadcasting group) on the UniFi controller.
|
||||
*/
|
||||
async createWlanGroup(
|
||||
siteId: string,
|
||||
input: Parameters<typeof unifi.createWlanGroup>[1],
|
||||
) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.createWlanGroup(siteId, input);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user groups (speed profiles) from the UniFi controller for a site.
|
||||
*/
|
||||
async getUserGroups(siteId: string) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.getUserGroups(siteId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new user group (speed profile) on the UniFi controller.
|
||||
*/
|
||||
async createUserGroup(
|
||||
siteId: string,
|
||||
input: Parameters<typeof unifi.createUserGroup>[1],
|
||||
) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.createUserGroup(siteId, input);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get AP groups from the UniFi controller for a site.
|
||||
*/
|
||||
async getApGroups(siteId: string) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.getApGroups(siteId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new AP group on the UniFi controller.
|
||||
*/
|
||||
async createApGroup(
|
||||
siteId: string,
|
||||
name: string,
|
||||
deviceMacs: string[],
|
||||
forWlanconf: boolean = false,
|
||||
) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.createApGroup(siteId, name, deviceMacs, forWlanconf);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing AP group's device MACs on the UniFi controller.
|
||||
*/
|
||||
async updateApGroup(siteId: string, groupId: string, deviceMacs: string[]) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.updateApGroup(siteId, groupId, deviceMacs);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get access points only from the UniFi controller for a site.
|
||||
*/
|
||||
async getAccessPoints(siteId: string) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.getAccessPoints(siteId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get WiFi SSID limits per AP per radio band.
|
||||
*/
|
||||
async getWifiLimits(siteId: string) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.getWifiLimits(siteId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get private pre-shared keys for a specific WLAN.
|
||||
*/
|
||||
async getPrivatePSKs(siteId: string, wlanId: string) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.getPrivatePSKs(siteId, wlanId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a private pre-shared key on a specific WLAN.
|
||||
*/
|
||||
async createPrivatePSK(
|
||||
siteId: string,
|
||||
wlanId: string,
|
||||
psk: Parameters<typeof unifi.createPrivatePSK>[2],
|
||||
) {
|
||||
await ensureLoggedIn();
|
||||
return unifi.createPrivatePSK(siteId, wlanId, psk);
|
||||
},
|
||||
};
|
||||
@@ -5,12 +5,14 @@ export enum ValueType {
|
||||
GENERIC_SECRET = "generic_secret",
|
||||
BITLOCKER_KEY = "bitlocker_key",
|
||||
PASSWORD = "password",
|
||||
MULTI_CREDENTIAL = "multi_credential",
|
||||
}
|
||||
|
||||
export interface CredentialTypeField {
|
||||
id: string; // I.e. "clientId", "clientSecret", etc.
|
||||
name: string; // I.e. "Client ID", "Client Secret", etc.
|
||||
required: boolean;
|
||||
subFields?: CredentialTypeField[]; // For multi-credential fields, defines the sub-fields that are required
|
||||
secure: boolean; // Whether this field should be stored encrypted in the database
|
||||
valueType: ValueType; // For future extensibility, currently all fields are strings
|
||||
}
|
||||
@@ -18,4 +20,5 @@ export interface CredentialTypeField {
|
||||
export interface CredentialField {
|
||||
fieldId: string; // I.e. "clientId", "clientSecret", etc.
|
||||
value: string; // Encrypted value stored in the database
|
||||
subCredentials?: string[]; // For multi-credential fields, the IDs of the sub-credentials that are associated with this field
|
||||
}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import { CredentialField, CredentialTypeField } from "./credentialTypeDefs";
|
||||
import {
|
||||
CredentialField,
|
||||
CredentialTypeField,
|
||||
ValueType,
|
||||
} from "./credentialTypeDefs";
|
||||
import GenericError from "../../Errors/GenericError";
|
||||
|
||||
export interface ValidatedField {
|
||||
fieldId: string;
|
||||
value: string;
|
||||
secure: boolean;
|
||||
isMultiCredential?: boolean;
|
||||
subCredentials?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Field Validator
|
||||
*
|
||||
@@ -11,19 +23,16 @@ import GenericError from "../../Errors/GenericError";
|
||||
* If all the credentials pass, it will return a processed version of the submitted fields including fields that need to be
|
||||
* stored securely (encrypted) and fields that do not.
|
||||
*
|
||||
* Multi-credential fields are handled specially — they don't carry a direct value but instead
|
||||
* reference sub-credential IDs.
|
||||
*
|
||||
* @param fields - The fields in object form that are being submitted.
|
||||
* @param acceptableFields - The acceptable field to be compared against.
|
||||
*/
|
||||
export const fieldValidator = async (
|
||||
fields: CredentialField[],
|
||||
acceptableFields: CredentialTypeField[],
|
||||
): Promise<
|
||||
{
|
||||
fieldId: string;
|
||||
value: string;
|
||||
secure: boolean;
|
||||
}[]
|
||||
> => {
|
||||
): Promise<ValidatedField[]> => {
|
||||
const afCollection = new Collection(acceptableFields.map((f) => [f.id, f]));
|
||||
|
||||
await Promise.all(
|
||||
@@ -45,6 +54,18 @@ export const fieldValidator = async (
|
||||
return fields.map((field) => {
|
||||
const matchingField = afCollection.get(field.fieldId)!;
|
||||
|
||||
// Multi-credential fields don't carry a direct value;
|
||||
// they reference sub-credential IDs instead.
|
||||
if (matchingField.valueType === ValueType.MULTI_CREDENTIAL) {
|
||||
return {
|
||||
fieldId: field.fieldId,
|
||||
value: "",
|
||||
secure: false,
|
||||
isMultiCredential: true,
|
||||
subCredentials: field.subCredentials ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
fieldId: field.fieldId,
|
||||
value: field.value,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import UserController from "../../controllers/UserController";
|
||||
|
||||
export const processObjectValuePerms = async <T>(
|
||||
obj: T,
|
||||
scope: string, // e.g. "unifi.wifi.read"
|
||||
user: UserController,
|
||||
): Promise<Partial<T>> => {
|
||||
let result: Partial<T> = {};
|
||||
|
||||
for (const key in obj) {
|
||||
if (await user.hasPermission(`${scope}.${key}`)) {
|
||||
result[key] = obj[key];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const processObjectPermMap = async <T extends Record<string, unknown>>(
|
||||
obj: T,
|
||||
scope: string,
|
||||
user: UserController,
|
||||
): Promise<Record<keyof T, boolean>> => {
|
||||
const result = {} as Record<keyof T, boolean>;
|
||||
|
||||
for (const key in obj) {
|
||||
result[key] = await user.hasPermission(`${scope}.${key}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,952 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import https from "https";
|
||||
import {
|
||||
ApGroup,
|
||||
ApRadioWifiUsage,
|
||||
ApWifiLimits,
|
||||
CreateSiteOptions,
|
||||
Device,
|
||||
DeviceRadio,
|
||||
DeviceState,
|
||||
DeviceUplink,
|
||||
Network,
|
||||
PrivatePSK,
|
||||
PrivatePSKCreateInput,
|
||||
SiteListItem,
|
||||
SiteOverview,
|
||||
SubsystemHealth,
|
||||
SysInfo,
|
||||
UserGroup,
|
||||
UserGroupCreateInput,
|
||||
WlanConf,
|
||||
WlanConfRaw,
|
||||
WlanConfUpdate,
|
||||
WlanGroup,
|
||||
WlanGroupCreateInput,
|
||||
} from "./unifiTypes";
|
||||
|
||||
export class UnifiClient {
|
||||
private client: AxiosInstance;
|
||||
|
||||
constructor(baseURL: string) {
|
||||
this.client = axios.create({
|
||||
baseURL,
|
||||
validateStatus: (s) => s >= 200 && s < 400,
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
});
|
||||
}
|
||||
|
||||
private persistSession(res: { headers: Record<string, unknown> }): void {
|
||||
// Cookies
|
||||
const raw = res.headers["set-cookie"];
|
||||
if (raw) {
|
||||
const cookies = (Array.isArray(raw) ? raw : [raw]) as string[];
|
||||
const cookieString = cookies.map((c) => c.split(";")[0]).join("; ");
|
||||
this.client.defaults.headers.common["Cookie"] = cookieString;
|
||||
}
|
||||
// CSRF token (UniFi OS)
|
||||
const csrf = res.headers["x-csrf-token"];
|
||||
if (typeof csrf === "string") {
|
||||
this.client.defaults.headers.common["X-CSRF-Token"] = csrf;
|
||||
}
|
||||
}
|
||||
|
||||
async login(username: string, password: string): Promise<void> {
|
||||
const body = { username, password };
|
||||
|
||||
try {
|
||||
// UniFi OS
|
||||
const res = await this.client.post("/api/auth/login", body);
|
||||
console.log("Login OK (UniFi OS)", res.status);
|
||||
this.persistSession(res);
|
||||
} catch (e) {
|
||||
// Legacy controller
|
||||
console.log("UniFi OS login failed, trying legacy...");
|
||||
const res = await this.client.post("/api/login", body);
|
||||
console.log("Login OK (legacy)", res.status);
|
||||
this.persistSession(res);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchWlanConfRaw(site: string): Promise<WlanConfRaw[]> {
|
||||
const paths = [
|
||||
`/proxy/network/api/s/${site}/rest/wlanconf`,
|
||||
`/api/s/${site}/rest/wlanconf`,
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const res = await this.client.get(path);
|
||||
const data = (res.data?.data ?? res.data) as WlanConfRaw[];
|
||||
console.log(`Fetched wlan from ${path}`);
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.log(
|
||||
`Failed ${path}:`,
|
||||
axios.isAxiosError(e) ? e.response?.status : e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not fetch WLAN config from any known path");
|
||||
}
|
||||
|
||||
private static parseWlanConf(w: any): WlanConf {
|
||||
return {
|
||||
id: w._id,
|
||||
name: (w.name || w.ssid || "").toString(),
|
||||
siteId: w.site_id ?? "",
|
||||
enabled: w.enabled ?? true,
|
||||
security: w.security ?? "open",
|
||||
wpaMode: w.wpa_mode ?? "",
|
||||
wpaEnc: w.wpa_enc ?? "",
|
||||
wpa3Support: w.wpa3_support ?? false,
|
||||
wpa3Transition: w.wpa3_transition ?? false,
|
||||
wpa3FastRoaming: w.wpa3_fast_roaming ?? false,
|
||||
wpa3Enhanced192: w.wpa3_enhanced_192 ?? false,
|
||||
passphrase: typeof w.x_passphrase === "string" ? w.x_passphrase : null,
|
||||
passphraseAutogenerated: w.passphrase_autogenerated ?? false,
|
||||
hideSSID: w.hide_ssid ?? false,
|
||||
isGuest: w.is_guest ?? false,
|
||||
band: w.wlan_band ?? "both",
|
||||
bands: w.wlan_bands ?? [],
|
||||
networkconfId: w.networkconf_id ?? "",
|
||||
usergroupId: w.usergroup_id ?? "",
|
||||
apGroupIds: w.ap_group_ids ?? [],
|
||||
apGroupMode: w.ap_group_mode ?? "devices",
|
||||
pmfMode: w.pmf_mode ?? "disabled",
|
||||
groupRekey: w.group_rekey ?? 0,
|
||||
dtimMode: w.dtim_mode ?? "default",
|
||||
dtimNg: w.dtim_ng ?? 1,
|
||||
dtimNa: w.dtim_na ?? 3,
|
||||
dtim6e: w.dtim_6e ?? 3,
|
||||
l2Isolation: w.l2_isolation ?? false,
|
||||
fastRoamingEnabled: w.fast_roaming_enabled ?? false,
|
||||
bssTransition: w.bss_transition ?? false,
|
||||
uapsdEnabled: w.uapsd_enabled ?? false,
|
||||
iappEnabled: w.iapp_enabled ?? false,
|
||||
proxyArp: w.proxy_arp ?? false,
|
||||
mcastenhanceEnabled: w.mcastenhance_enabled ?? false,
|
||||
macFilterEnabled: w.mac_filter_enabled ?? false,
|
||||
macFilterPolicy: w.mac_filter_policy ?? "allow",
|
||||
macFilterList: w.mac_filter_list ?? [],
|
||||
radiusDasEnabled: w.radius_das_enabled ?? false,
|
||||
radiusMacAuthEnabled: w.radius_mac_auth_enabled ?? false,
|
||||
radiusMacaclFormat: w.radius_macacl_format ?? "none_lower",
|
||||
minrateSettingPreference: w.minrate_setting_preference ?? "auto",
|
||||
minrateNgEnabled: w.minrate_ng_enabled ?? false,
|
||||
minrateNgDataRateKbps: w.minrate_ng_data_rate_kbps ?? 1000,
|
||||
minrateNgAdvertisingRates: w.minrate_ng_advertising_rates ?? false,
|
||||
minrateNaEnabled: w.minrate_na_enabled ?? false,
|
||||
minrateNaDataRateKbps: w.minrate_na_data_rate_kbps ?? 6000,
|
||||
minrateNaAdvertisingRates: w.minrate_na_advertising_rates ?? false,
|
||||
settingPreference: w.setting_preference ?? "auto",
|
||||
no2ghzOui: w.no2ghz_oui ?? false,
|
||||
privatePreSharedKeysEnabled: w.private_preshared_keys_enabled ?? false,
|
||||
privatePreSharedKeys: w.private_preshared_keys ?? [],
|
||||
saeGroups: w.sae_groups ?? [],
|
||||
saePsk: w.sae_psk ?? [],
|
||||
schedule: w.schedule ?? [],
|
||||
scheduleWithDuration: w.schedule_with_duration ?? [],
|
||||
bcFilterList: w.bc_filter_list ?? [],
|
||||
externalId: w.external_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async getWlanConf(site: string): Promise<WlanConf[]> {
|
||||
const raw = await this.fetchWlanConfRaw(site);
|
||||
return raw.map(UnifiClient.parseWlanConf);
|
||||
}
|
||||
|
||||
async updateWlanConf(
|
||||
site: string,
|
||||
wlanId: string,
|
||||
updates: WlanConfUpdate,
|
||||
): Promise<WlanConf> {
|
||||
const paths = [
|
||||
`/proxy/network/api/s/${site}/rest/wlanconf/${wlanId}`,
|
||||
`/api/s/${site}/rest/wlanconf/${wlanId}`,
|
||||
];
|
||||
|
||||
// Fetch current WLAN to check if a RADIUS profile is configured.
|
||||
// The controller rejects RADIUS fields when no profile is set.
|
||||
const currentWlans = await this.getWlanConf(site);
|
||||
const currentWlan = currentWlans.find((w) => w.id === wlanId);
|
||||
const hasRadius =
|
||||
currentWlan?.security === "wpaeap" || updates.security === "wpaeap";
|
||||
|
||||
if (!hasRadius) {
|
||||
delete updates.radius_das_enabled;
|
||||
delete updates.radius_mac_auth_enabled;
|
||||
}
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const res = await this.client.put(path, updates);
|
||||
const raw = (res.data?.data?.[0] ?? res.data) as any;
|
||||
return UnifiClient.parseWlanConf(raw);
|
||||
} catch (e) {
|
||||
if (!axios.isAxiosError(e)) throw e;
|
||||
// Try next path on 404/401, throw on other errors
|
||||
if (
|
||||
e.response &&
|
||||
e.response.status !== 404 &&
|
||||
e.response.status !== 401
|
||||
) {
|
||||
throw new Error(
|
||||
`Failed to update WLAN ${wlanId}: ${e.response.status} ${JSON.stringify(e.response.data)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not update WLAN config from any known path");
|
||||
}
|
||||
|
||||
async getAllSites(): Promise<SiteListItem[]> {
|
||||
const paths = ["/proxy/network/api/self/sites", "/api/self/sites"];
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const res = await this.client.get(path);
|
||||
const raw = (res.data?.data ?? res.data) as any[];
|
||||
return raw.map(
|
||||
(s: any): SiteListItem => ({
|
||||
id: s._id,
|
||||
name: s.name,
|
||||
description: s.desc ?? "",
|
||||
deviceCount: s.device_count ?? 0,
|
||||
role: s.role ?? "",
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!axios.isAxiosError(e)) throw e;
|
||||
if (
|
||||
e.response &&
|
||||
e.response.status !== 404 &&
|
||||
e.response.status !== 401
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not fetch sites from any known path");
|
||||
}
|
||||
|
||||
async getSiteOverview(site: string): Promise<SiteOverview> {
|
||||
const prefixes = ["/proxy/network", ""];
|
||||
|
||||
for (const prefix of prefixes) {
|
||||
try {
|
||||
const [healthRes, sysInfoRes, sitesRes] = await Promise.all([
|
||||
this.client.get(`${prefix}/api/s/${site}/stat/health`),
|
||||
this.client.get(`${prefix}/api/s/${site}/stat/sysinfo`),
|
||||
this.client.get(`${prefix}/api/self/sites`),
|
||||
]);
|
||||
|
||||
const healthRaw = (healthRes.data?.data ?? healthRes.data) as any[];
|
||||
const sysInfoRaw = (sysInfoRes.data?.data?.[0] ??
|
||||
sysInfoRes.data) as any;
|
||||
const sitesRaw = (sitesRes.data?.data ?? sitesRes.data) as any[];
|
||||
|
||||
const siteRaw = sitesRaw.find((s: any) => s.name === site);
|
||||
if (!siteRaw) throw new Error(`Site "${site}" not found in sites list`);
|
||||
|
||||
const health: SubsystemHealth[] = healthRaw.map((h: any) => ({
|
||||
subsystem: h.subsystem,
|
||||
status: h.status,
|
||||
numUser: h.num_user,
|
||||
numGuest: h.num_guest,
|
||||
numIot: h.num_iot,
|
||||
txBytesR: h["tx_bytes-r"],
|
||||
rxBytesR: h["rx_bytes-r"],
|
||||
numAp: h.num_ap,
|
||||
numSw: h.num_sw,
|
||||
numGw: h.num_gw,
|
||||
numAdopted: h.num_adopted,
|
||||
numDisconnected: h.num_disconnected,
|
||||
numPending: h.num_pending,
|
||||
numDisabled: h.num_disabled,
|
||||
}));
|
||||
|
||||
const sysInfo: SysInfo = {
|
||||
name: sysInfoRaw.name,
|
||||
hostname: sysInfoRaw.hostname,
|
||||
version: sysInfoRaw.version,
|
||||
build: sysInfoRaw.build,
|
||||
timezone: sysInfoRaw.timezone,
|
||||
uptime: sysInfoRaw.uptime,
|
||||
ipAddresses: sysInfoRaw.ip_addrs ?? [],
|
||||
updateAvailable: sysInfoRaw.update_available ?? false,
|
||||
isCloudConsole: sysInfoRaw.is_cloud_console ?? false,
|
||||
dataRetentionDays: sysInfoRaw.data_retention_days ?? 0,
|
||||
informPort: sysInfoRaw.inform_port,
|
||||
httpsPort: sysInfoRaw.https_port,
|
||||
unsupportedDeviceCount: sysInfoRaw.unsupported_device_count ?? 0,
|
||||
};
|
||||
|
||||
return {
|
||||
site: {
|
||||
id: siteRaw._id,
|
||||
name: siteRaw.name,
|
||||
description: siteRaw.desc ?? "",
|
||||
deviceCount: siteRaw.device_count ?? 0,
|
||||
role: siteRaw.role ?? "",
|
||||
},
|
||||
health,
|
||||
sysInfo,
|
||||
};
|
||||
} catch (e) {
|
||||
if (!axios.isAxiosError(e)) throw e;
|
||||
if (
|
||||
e.response &&
|
||||
e.response.status !== 404 &&
|
||||
e.response.status !== 401
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not fetch site overview from any known path");
|
||||
}
|
||||
|
||||
private static parseDeviceState(state: number): DeviceState {
|
||||
const map: Record<number, DeviceState> = {
|
||||
0: "disconnected",
|
||||
1: "connected",
|
||||
2: "pending",
|
||||
4: "adopting",
|
||||
5: "adopting",
|
||||
};
|
||||
return map[state] ?? "unknown";
|
||||
}
|
||||
|
||||
async getDevices(site: string): Promise<Device[]> {
|
||||
const paths = [
|
||||
`/proxy/network/api/s/${site}/stat/device`,
|
||||
`/api/s/${site}/stat/device`,
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const res = await this.client.get(path);
|
||||
const raw = (res.data?.data ?? res.data) as any[];
|
||||
|
||||
return raw.map((d: any): Device => {
|
||||
const uplink: DeviceUplink | null = d.uplink
|
||||
? {
|
||||
type: d.uplink.type,
|
||||
mac: d.uplink.uplink_mac,
|
||||
ip: d.uplink.uplink_remote_ip,
|
||||
uplinkRemotePort: d.uplink.uplink_remote_port,
|
||||
speed: d.uplink.speed,
|
||||
fullDuplex: d.uplink.full_duplex,
|
||||
}
|
||||
: null;
|
||||
|
||||
const radios: DeviceRadio[] = (d.radio_table ?? []).map(
|
||||
(r: any, i: number) => {
|
||||
const stats = d.radio_table_stats?.[i] ?? {};
|
||||
return {
|
||||
name: r.name ?? r.radio,
|
||||
radio: r.radio,
|
||||
channel: r.channel,
|
||||
txPower: r.tx_power,
|
||||
txPowerMode: r.tx_power_mode,
|
||||
minRssiEnabled: r.min_rssi_enabled ?? false,
|
||||
numSta: stats.num_sta ?? 0,
|
||||
satisfaction: stats.satisfaction ?? null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
id: d._id,
|
||||
mac: d.mac,
|
||||
ip: d.ip ?? "",
|
||||
name: d.name ?? d.mac,
|
||||
model: d.model,
|
||||
shortname: d.shortname ?? d.model,
|
||||
type: d.type,
|
||||
version: d.version ?? "",
|
||||
serial: d.serial ?? "",
|
||||
state: UnifiClient.parseDeviceState(d.state),
|
||||
adopted: d.adopted ?? false,
|
||||
uptime: d.uptime ?? 0,
|
||||
lastSeen: d.last_seen ?? 0,
|
||||
upgradable: d.upgradable ?? false,
|
||||
satisfaction: d.satisfaction ?? null,
|
||||
numClients: d.num_sta ?? 0,
|
||||
numUserClients: d["user-num_sta"] ?? 0,
|
||||
numGuestClients: d["guest-num_sta"] ?? 0,
|
||||
txBytes: d.tx_bytes ?? 0,
|
||||
rxBytes: d.rx_bytes ?? 0,
|
||||
uplink,
|
||||
radios,
|
||||
modelInLts: d.model_in_lts ?? false,
|
||||
modelInEol: d.model_in_eol ?? false,
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
if (!axios.isAxiosError(e)) throw e;
|
||||
if (
|
||||
e.response &&
|
||||
e.response.status !== 404 &&
|
||||
e.response.status !== 401
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not fetch devices from any known path");
|
||||
}
|
||||
|
||||
async getNetworks(site: string): Promise<Network[]> {
|
||||
const paths = [
|
||||
`/proxy/network/api/s/${site}/rest/networkconf`,
|
||||
`/api/s/${site}/rest/networkconf`,
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const res = await this.client.get(path);
|
||||
const raw = (res.data?.data ?? res.data) as any[];
|
||||
|
||||
return raw.map(
|
||||
(n: any): Network => ({
|
||||
id: n._id,
|
||||
name: n.name ?? "",
|
||||
purpose: n.purpose ?? "corporate",
|
||||
enabled: n.enabled ?? true,
|
||||
ipSubnet: n.ip_subnet ?? null,
|
||||
vlan: n.vlan != null ? Number(n.vlan) : null,
|
||||
vlanEnabled: n.vlan_enabled ?? false,
|
||||
isNat: n.is_nat ?? false,
|
||||
domainName: n.domain_name ?? null,
|
||||
networkGroup: n.networkgroup ?? null,
|
||||
dhcpdEnabled: n.dhcpd_enabled ?? false,
|
||||
dhcpdStart: n.dhcpd_start ?? null,
|
||||
dhcpdStop: n.dhcpd_stop ?? null,
|
||||
dhcpdLeasetime: n.dhcpd_leasetime ?? null,
|
||||
dhcpRelayEnabled: n.dhcp_relay_enabled ?? false,
|
||||
dhcpGuardEnabled: n.dhcpguard_enabled ?? false,
|
||||
igmpSnooping: n.igmp_snooping ?? false,
|
||||
ipv6Enabled: n.ipv6_enabled ?? false,
|
||||
ipv6InterfaceType: n.ipv6_interface_type ?? null,
|
||||
internetAccessEnabled: n.internet_access_enabled ?? null,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!axios.isAxiosError(e)) throw e;
|
||||
if (
|
||||
e.response &&
|
||||
e.response.status !== 404 &&
|
||||
e.response.status !== 401
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not fetch networks from any known path");
|
||||
}
|
||||
|
||||
async createSite(description: string): Promise<SiteListItem> {
|
||||
const paths = [
|
||||
"/proxy/network/api/s/default/cmd/sitemgr",
|
||||
"/api/s/default/cmd/sitemgr",
|
||||
];
|
||||
|
||||
const body = { cmd: "add-site", desc: description };
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const res = await this.client.post(path, body);
|
||||
const raw = (res.data?.data?.[0] ?? res.data) as any;
|
||||
return {
|
||||
id: raw._id,
|
||||
name: raw.name,
|
||||
description: raw.desc ?? description,
|
||||
deviceCount: raw.device_count ?? 0,
|
||||
role: raw.role ?? "",
|
||||
};
|
||||
} catch (e) {
|
||||
if (!axios.isAxiosError(e)) throw e;
|
||||
if (
|
||||
e.response &&
|
||||
e.response.status !== 404 &&
|
||||
e.response.status !== 401
|
||||
) {
|
||||
throw new Error(
|
||||
`Failed to create site: ${e.response.status} ${JSON.stringify(e.response.data)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not create site from any known path");
|
||||
}
|
||||
|
||||
// --- WLAN Groups ---
|
||||
|
||||
async getWlanGroups(site: string): Promise<WlanGroup[]> {
|
||||
const paths = [
|
||||
`/proxy/network/api/s/${site}/rest/wlangroup`,
|
||||
`/api/s/${site}/rest/wlangroup`,
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const res = await this.client.get(path);
|
||||
const raw = (res.data?.data ?? res.data) as any[];
|
||||
return raw.map(
|
||||
(g: any): WlanGroup => ({
|
||||
id: g._id,
|
||||
name: g.name ?? "",
|
||||
siteId: g.site_id ?? "",
|
||||
noDelete: g.attr_no_delete ?? false,
|
||||
noEdit: g.attr_no_edit ?? false,
|
||||
hidden: g.attr_hidden ?? false,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!axios.isAxiosError(e)) throw e;
|
||||
if (
|
||||
e.response &&
|
||||
e.response.status !== 404 &&
|
||||
e.response.status !== 401
|
||||
)
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not fetch WLAN groups from any known path");
|
||||
}
|
||||
|
||||
async createWlanGroup(
|
||||
site: string,
|
||||
input: WlanGroupCreateInput,
|
||||
): Promise<WlanGroup> {
|
||||
const paths = [
|
||||
`/proxy/network/api/s/${site}/rest/wlangroup`,
|
||||
`/api/s/${site}/rest/wlangroup`,
|
||||
];
|
||||
|
||||
const body: Record<string, unknown> = { name: input.name };
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const res = await this.client.post(path, body);
|
||||
const raw = (res.data?.data?.[0] ?? res.data) as any;
|
||||
return {
|
||||
id: raw._id,
|
||||
name: raw.name ?? input.name,
|
||||
siteId: raw.site_id ?? "",
|
||||
noDelete: raw.attr_no_delete ?? false,
|
||||
noEdit: raw.attr_no_edit ?? false,
|
||||
hidden: raw.attr_hidden ?? false,
|
||||
};
|
||||
} catch (e) {
|
||||
if (!axios.isAxiosError(e)) throw e;
|
||||
if (
|
||||
e.response &&
|
||||
e.response.status !== 404 &&
|
||||
e.response.status !== 401
|
||||
) {
|
||||
throw new Error(
|
||||
`Failed to create WLAN group: ${e.response.status} ${JSON.stringify(e.response.data)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not create WLAN group from any known path");
|
||||
}
|
||||
|
||||
// --- User Groups (Speed Profiles) ---
|
||||
|
||||
async getUserGroups(site: string): Promise<UserGroup[]> {
|
||||
const paths = [
|
||||
`/proxy/network/api/s/${site}/rest/usergroup`,
|
||||
`/api/s/${site}/rest/usergroup`,
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const res = await this.client.get(path);
|
||||
const raw = (res.data?.data ?? res.data) as any[];
|
||||
return raw.map(
|
||||
(g: any): UserGroup => ({
|
||||
id: g._id,
|
||||
name: g.name ?? "",
|
||||
siteId: g.site_id ?? "",
|
||||
noDelete: g.attr_no_delete ?? false,
|
||||
downloadLimitKbps: g.qos_rate_max_down ?? -1,
|
||||
uploadLimitKbps: g.qos_rate_max_up ?? -1,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!axios.isAxiosError(e)) throw e;
|
||||
if (
|
||||
e.response &&
|
||||
e.response.status !== 404 &&
|
||||
e.response.status !== 401
|
||||
)
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not fetch user groups from any known path");
|
||||
}
|
||||
|
||||
async createUserGroup(
|
||||
site: string,
|
||||
input: UserGroupCreateInput,
|
||||
): Promise<UserGroup> {
|
||||
const paths = [
|
||||
`/proxy/network/api/s/${site}/rest/usergroup`,
|
||||
`/api/s/${site}/rest/usergroup`,
|
||||
];
|
||||
|
||||
const body: Record<string, unknown> = { name: input.name };
|
||||
if (input.downloadLimitKbps !== undefined)
|
||||
body.qos_rate_max_down = input.downloadLimitKbps;
|
||||
if (input.uploadLimitKbps !== undefined)
|
||||
body.qos_rate_max_up = input.uploadLimitKbps;
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const res = await this.client.post(path, body);
|
||||
const raw = (res.data?.data?.[0] ?? res.data) as any;
|
||||
return {
|
||||
id: raw._id,
|
||||
name: raw.name ?? input.name,
|
||||
siteId: raw.site_id ?? "",
|
||||
noDelete: raw.attr_no_delete ?? false,
|
||||
downloadLimitKbps: raw.qos_rate_max_down ?? -1,
|
||||
uploadLimitKbps: raw.qos_rate_max_up ?? -1,
|
||||
};
|
||||
} catch (e) {
|
||||
if (!axios.isAxiosError(e)) throw e;
|
||||
if (
|
||||
e.response &&
|
||||
e.response.status !== 404 &&
|
||||
e.response.status !== 401
|
||||
) {
|
||||
throw new Error(
|
||||
`Failed to create user group: ${e.response.status} ${JSON.stringify(e.response.data)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not create user group from any known path");
|
||||
}
|
||||
|
||||
// --- AP Groups ---
|
||||
|
||||
async getApGroups(site: string): Promise<ApGroup[]> {
|
||||
const paths = [
|
||||
`/proxy/network/v2/api/site/${site}/apgroups`,
|
||||
`/v2/api/site/${site}/apgroups`,
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const res = await this.client.get(path);
|
||||
const raw = (res.data?.data ?? res.data) as any[];
|
||||
return raw.map(
|
||||
(g: any): ApGroup => ({
|
||||
id: g._id,
|
||||
name: g.name ?? "",
|
||||
deviceMacs: g.device_macs ?? [],
|
||||
noDelete: g.attr_no_delete ?? false,
|
||||
forWlanconf: g.for_wlanconf ?? false,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!axios.isAxiosError(e)) throw e;
|
||||
if (
|
||||
e.response &&
|
||||
e.response.status !== 404 &&
|
||||
e.response.status !== 401
|
||||
)
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not fetch AP groups from any known path");
|
||||
}
|
||||
|
||||
async createApGroup(
|
||||
site: string,
|
||||
name: string,
|
||||
deviceMacs: string[],
|
||||
forWlanconf: boolean = false,
|
||||
): Promise<ApGroup> {
|
||||
const paths = [
|
||||
`/proxy/network/v2/api/site/${site}/apgroups`,
|
||||
`/v2/api/site/${site}/apgroups`,
|
||||
];
|
||||
|
||||
const body = {
|
||||
name,
|
||||
device_macs: deviceMacs,
|
||||
for_wlanconf: forWlanconf,
|
||||
};
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const res = await this.client.post(path, body);
|
||||
const raw = (res.data?.data?.[0] ?? res.data) as any;
|
||||
return {
|
||||
id: raw._id,
|
||||
name: raw.name ?? name,
|
||||
deviceMacs: raw.device_macs ?? deviceMacs,
|
||||
noDelete: raw.attr_no_delete ?? false,
|
||||
forWlanconf: raw.for_wlanconf ?? forWlanconf,
|
||||
};
|
||||
} catch (e) {
|
||||
if (!axios.isAxiosError(e)) throw e;
|
||||
if (
|
||||
e.response &&
|
||||
e.response.status !== 404 &&
|
||||
e.response.status !== 401
|
||||
) {
|
||||
throw new Error(
|
||||
`Failed to create AP group: ${e.response.status} ${JSON.stringify(e.response.data)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not create AP group from any known path");
|
||||
}
|
||||
|
||||
async updateApGroup(
|
||||
site: string,
|
||||
groupId: string,
|
||||
deviceMacs: string[],
|
||||
): Promise<ApGroup> {
|
||||
const paths = [
|
||||
`/proxy/network/v2/api/site/${site}/apgroups/${groupId}`,
|
||||
`/v2/api/site/${site}/apgroups/${groupId}`,
|
||||
];
|
||||
|
||||
const body = {
|
||||
name: "devices_ap_group",
|
||||
device_macs: deviceMacs,
|
||||
for_wlanconf: true,
|
||||
};
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const res = await this.client.put(path, body);
|
||||
const raw = (res.data?.data?.[0] ?? res.data) as any;
|
||||
return {
|
||||
id: raw._id ?? groupId,
|
||||
name: raw.name ?? "devices_ap_group",
|
||||
deviceMacs: raw.device_macs ?? deviceMacs,
|
||||
noDelete: raw.attr_no_delete ?? false,
|
||||
forWlanconf: raw.for_wlanconf ?? true,
|
||||
};
|
||||
} catch (e) {
|
||||
if (!axios.isAxiosError(e)) throw e;
|
||||
if (
|
||||
e.response &&
|
||||
e.response.status !== 404 &&
|
||||
e.response.status !== 401
|
||||
) {
|
||||
throw new Error(
|
||||
`Failed to update AP group: ${e.response.status} ${JSON.stringify(e.response.data)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not update AP group from any known path");
|
||||
}
|
||||
|
||||
// --- Access Points ---
|
||||
|
||||
async getAccessPoints(site: string): Promise<Device[]> {
|
||||
const devices = await this.getDevices(site);
|
||||
return devices.filter((d) => d.type === "uap");
|
||||
}
|
||||
|
||||
// --- WiFi Limits ---
|
||||
|
||||
async getWifiLimits(site: string): Promise<ApWifiLimits[]> {
|
||||
const SSID_LIMIT_PER_RADIO = 8;
|
||||
|
||||
const paths = [
|
||||
`/proxy/network/api/s/${site}/stat/device`,
|
||||
`/api/s/${site}/stat/device`,
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const res = await this.client.get(path);
|
||||
const raw = (res.data?.data ?? res.data) as any[];
|
||||
const aps = raw.filter((d: any) => d.type === "uap");
|
||||
|
||||
return aps.map((ap: any): ApWifiLimits => {
|
||||
const vapTable: any[] = ap.vap_table ?? [];
|
||||
const radioMap = new Map<string, { wlanNames: Set<string> }>();
|
||||
|
||||
for (const vap of vapTable) {
|
||||
if (!vap.up || !vap.radio) continue;
|
||||
if (!radioMap.has(vap.radio)) {
|
||||
radioMap.set(vap.radio, { wlanNames: new Set() });
|
||||
}
|
||||
if (vap.essid) {
|
||||
radioMap.get(vap.radio)!.wlanNames.add(vap.essid);
|
||||
}
|
||||
}
|
||||
|
||||
const radioBandMap: Record<string, string> = {
|
||||
ng: "2g",
|
||||
na: "5g",
|
||||
"6e": "6e",
|
||||
};
|
||||
|
||||
const radios: ApRadioWifiUsage[] = Array.from(radioMap.entries()).map(
|
||||
([radio, data]): ApRadioWifiUsage => ({
|
||||
radio,
|
||||
band: radioBandMap[radio] ?? radio,
|
||||
activeWlans: data.wlanNames.size,
|
||||
limit: SSID_LIMIT_PER_RADIO,
|
||||
remaining: Math.max(
|
||||
0,
|
||||
SSID_LIMIT_PER_RADIO - data.wlanNames.size,
|
||||
),
|
||||
wlanNames: Array.from(data.wlanNames),
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
apId: ap._id,
|
||||
apName: ap.name ?? ap.mac,
|
||||
mac: ap.mac,
|
||||
model: ap.model ?? "",
|
||||
radios,
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
if (!axios.isAxiosError(e)) throw e;
|
||||
if (
|
||||
e.response &&
|
||||
e.response.status !== 404 &&
|
||||
e.response.status !== 401
|
||||
)
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not fetch WiFi limits from any known path");
|
||||
}
|
||||
|
||||
// --- Private Pre-Shared Keys ---
|
||||
|
||||
private static parsePPSKs(raw: any[]): PrivatePSK[] {
|
||||
return raw.map(
|
||||
(p: any): PrivatePSK => ({
|
||||
key: p.key ?? "",
|
||||
name: p.name ?? "",
|
||||
mac: p.mac ?? null,
|
||||
vlanId: p.vlan_id ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getPrivatePSKs(site: string, wlanId: string): Promise<PrivatePSK[]> {
|
||||
const paths = [
|
||||
`/proxy/network/api/s/${site}/rest/wlanconf/${wlanId}`,
|
||||
`/api/s/${site}/rest/wlanconf/${wlanId}`,
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const res = await this.client.get(path);
|
||||
const raw = (res.data?.data?.[0] ?? res.data) as any;
|
||||
return UnifiClient.parsePPSKs(raw.private_preshared_keys ?? []);
|
||||
} catch (e) {
|
||||
if (!axios.isAxiosError(e)) throw e;
|
||||
if (
|
||||
e.response &&
|
||||
e.response.status !== 404 &&
|
||||
e.response.status !== 401
|
||||
)
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not fetch PPSKs from any known path");
|
||||
}
|
||||
|
||||
async createPrivatePSK(
|
||||
site: string,
|
||||
wlanId: string,
|
||||
psk: PrivatePSKCreateInput,
|
||||
): Promise<PrivatePSK[]> {
|
||||
const paths = [
|
||||
`/proxy/network/api/s/${site}/rest/wlanconf/${wlanId}`,
|
||||
`/api/s/${site}/rest/wlanconf/${wlanId}`,
|
||||
];
|
||||
|
||||
// Fetch current PPSKs
|
||||
let currentPpsks: any[] = [];
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const res = await this.client.get(path);
|
||||
const raw = (res.data?.data?.[0] ?? res.data) as any;
|
||||
currentPpsks = (raw.private_preshared_keys ?? []) as any[];
|
||||
break;
|
||||
} catch (e) {
|
||||
if (!axios.isAxiosError(e)) throw e;
|
||||
if (
|
||||
e.response &&
|
||||
e.response.status !== 404 &&
|
||||
e.response.status !== 401
|
||||
)
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const newPsk: Record<string, unknown> = {
|
||||
key: psk.key,
|
||||
name: psk.name,
|
||||
};
|
||||
if (psk.mac) newPsk.mac = psk.mac;
|
||||
if (psk.vlanId !== undefined) newPsk.vlan_id = psk.vlanId;
|
||||
currentPpsks.push(newPsk);
|
||||
|
||||
// Update WLAN with new PPSKs
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const res = await this.client.put(path, {
|
||||
private_preshared_keys: currentPpsks,
|
||||
private_preshared_keys_enabled: true,
|
||||
});
|
||||
const raw = (res.data?.data?.[0] ?? res.data) as any;
|
||||
return UnifiClient.parsePPSKs(raw.private_preshared_keys ?? []);
|
||||
} catch (e) {
|
||||
if (!axios.isAxiosError(e)) throw e;
|
||||
if (
|
||||
e.response &&
|
||||
e.response.status !== 404 &&
|
||||
e.response.status !== 401
|
||||
) {
|
||||
throw new Error(
|
||||
`Failed to create PPSK: ${e.response.status} ${JSON.stringify(e.response.data)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not create PPSK from any known path");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
export interface WlanConfRaw {
|
||||
_id: string;
|
||||
name?: string;
|
||||
ssid?: string;
|
||||
x_passphrase?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface WlanConf {
|
||||
id: string;
|
||||
name: string;
|
||||
siteId: string;
|
||||
enabled: boolean;
|
||||
security: string;
|
||||
wpaMode: string;
|
||||
wpaEnc: string;
|
||||
wpa3Support: boolean;
|
||||
wpa3Transition: boolean;
|
||||
wpa3FastRoaming: boolean;
|
||||
wpa3Enhanced192: boolean;
|
||||
passphrase: string | null;
|
||||
passphraseAutogenerated: boolean;
|
||||
hideSSID: boolean;
|
||||
isGuest: boolean;
|
||||
band: string;
|
||||
bands: string[];
|
||||
networkconfId: string;
|
||||
usergroupId: string;
|
||||
apGroupIds: string[];
|
||||
apGroupMode: string;
|
||||
pmfMode: string;
|
||||
groupRekey: number;
|
||||
dtimMode: string;
|
||||
dtimNg: number;
|
||||
dtimNa: number;
|
||||
dtim6e: number;
|
||||
l2Isolation: boolean;
|
||||
fastRoamingEnabled: boolean;
|
||||
bssTransition: boolean;
|
||||
uapsdEnabled: boolean;
|
||||
iappEnabled: boolean;
|
||||
proxyArp: boolean;
|
||||
mcastenhanceEnabled: boolean;
|
||||
macFilterEnabled: boolean;
|
||||
macFilterPolicy: string;
|
||||
macFilterList: string[];
|
||||
radiusDasEnabled: boolean;
|
||||
radiusMacAuthEnabled: boolean;
|
||||
radiusMacaclFormat: string;
|
||||
minrateSettingPreference: string;
|
||||
minrateNgEnabled: boolean;
|
||||
minrateNgDataRateKbps: number;
|
||||
minrateNgAdvertisingRates: boolean;
|
||||
minrateNaEnabled: boolean;
|
||||
minrateNaDataRateKbps: number;
|
||||
minrateNaAdvertisingRates: boolean;
|
||||
settingPreference: string;
|
||||
no2ghzOui: boolean;
|
||||
privatePreSharedKeysEnabled: boolean;
|
||||
privatePreSharedKeys: unknown[];
|
||||
saeGroups: unknown[];
|
||||
saePsk: unknown[];
|
||||
schedule: unknown[];
|
||||
scheduleWithDuration: unknown[];
|
||||
bcFilterList: unknown[];
|
||||
externalId: string | null;
|
||||
}
|
||||
|
||||
export interface WlanConfUpdate {
|
||||
name?: string;
|
||||
x_passphrase?: string;
|
||||
enabled?: boolean;
|
||||
security?: "wpapsk" | "wpaeap" | "open" | "osen";
|
||||
wpa_mode?: "wpa2" | "wpa3" | "wpa2wpa3";
|
||||
wpa_enc?: "ccmp" | "gcmp" | "ccmp-gcmp";
|
||||
hide_ssid?: boolean;
|
||||
mac_filter_enabled?: boolean;
|
||||
mac_filter_policy?: "allow" | "deny";
|
||||
is_guest?: boolean;
|
||||
l2_isolation?: boolean;
|
||||
fast_roaming_enabled?: boolean;
|
||||
bss_transition?: boolean;
|
||||
uapsd_enabled?: boolean;
|
||||
group_rekey?: number;
|
||||
dtim_mode?: "default" | "custom";
|
||||
dtim_ng?: number;
|
||||
dtim_na?: number;
|
||||
minrate_ng_enabled?: boolean;
|
||||
minrate_na_enabled?: boolean;
|
||||
radius_das_enabled?: boolean;
|
||||
radius_mac_auth_enabled?: boolean;
|
||||
pmf_mode?: "disabled" | "optional" | "required";
|
||||
wlan_band?: "both" | "2g" | "5g";
|
||||
usergroup_id?: string;
|
||||
proxy_arp?: boolean;
|
||||
mcastenhance_enabled?: boolean;
|
||||
mac_filter_list?: string[];
|
||||
no2ghz_oui?: boolean;
|
||||
ap_group_ids?: string[];
|
||||
ap_group_mode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CamelCase update input matching the WlanConf return shape.
|
||||
* Accepted by the API and converted to WlanConfUpdate (snake_case) before
|
||||
* being sent to the UniFi controller.
|
||||
*/
|
||||
export interface WlanConfUpdateInput {
|
||||
name?: string;
|
||||
passphrase?: string;
|
||||
enabled?: boolean;
|
||||
security?: "wpapsk" | "wpaeap" | "open" | "osen";
|
||||
wpaMode?: "wpa2" | "wpa3" | "wpa2wpa3";
|
||||
wpaEnc?: "ccmp" | "gcmp" | "ccmp-gcmp";
|
||||
hideSSID?: boolean;
|
||||
macFilterEnabled?: boolean;
|
||||
macFilterPolicy?: "allow" | "deny";
|
||||
isGuest?: boolean;
|
||||
l2Isolation?: boolean;
|
||||
fastRoamingEnabled?: boolean;
|
||||
bssTransition?: boolean;
|
||||
uapsdEnabled?: boolean;
|
||||
groupRekey?: number;
|
||||
dtimMode?: "default" | "custom";
|
||||
dtimNg?: number;
|
||||
dtimNa?: number;
|
||||
minrateNgEnabled?: boolean;
|
||||
minrateNaEnabled?: boolean;
|
||||
radiusDasEnabled?: boolean;
|
||||
radiusMacAuthEnabled?: boolean;
|
||||
pmfMode?: "disabled" | "optional" | "required";
|
||||
band?: "both" | "2g" | "5g";
|
||||
usergroupId?: string;
|
||||
proxyArp?: boolean;
|
||||
mcastenhanceEnabled?: boolean;
|
||||
macFilterList?: string[];
|
||||
no2ghzOui?: boolean;
|
||||
apGroupIds?: string[];
|
||||
apGroupMode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a camelCase WlanConfUpdateInput to the snake_case WlanConfUpdate
|
||||
* expected by the UniFi controller API.
|
||||
*/
|
||||
export function toWlanConfUpdate(input: WlanConfUpdateInput): WlanConfUpdate {
|
||||
const result: WlanConfUpdate = {};
|
||||
|
||||
if (input.name !== undefined) result.name = input.name;
|
||||
if (input.passphrase !== undefined) result.x_passphrase = input.passphrase;
|
||||
if (input.enabled !== undefined) result.enabled = input.enabled;
|
||||
if (input.security !== undefined) result.security = input.security;
|
||||
if (input.wpaMode !== undefined) result.wpa_mode = input.wpaMode;
|
||||
if (input.wpaEnc !== undefined) result.wpa_enc = input.wpaEnc;
|
||||
if (input.hideSSID !== undefined) result.hide_ssid = input.hideSSID;
|
||||
if (input.macFilterEnabled !== undefined)
|
||||
result.mac_filter_enabled = input.macFilterEnabled;
|
||||
if (input.macFilterPolicy !== undefined)
|
||||
result.mac_filter_policy = input.macFilterPolicy;
|
||||
if (input.isGuest !== undefined) result.is_guest = input.isGuest;
|
||||
if (input.l2Isolation !== undefined) result.l2_isolation = input.l2Isolation;
|
||||
if (input.fastRoamingEnabled !== undefined)
|
||||
result.fast_roaming_enabled = input.fastRoamingEnabled;
|
||||
if (input.bssTransition !== undefined)
|
||||
result.bss_transition = input.bssTransition;
|
||||
if (input.uapsdEnabled !== undefined)
|
||||
result.uapsd_enabled = input.uapsdEnabled;
|
||||
if (input.groupRekey !== undefined) result.group_rekey = input.groupRekey;
|
||||
if (input.dtimMode !== undefined) result.dtim_mode = input.dtimMode;
|
||||
if (input.dtimNg !== undefined) result.dtim_ng = input.dtimNg;
|
||||
if (input.dtimNa !== undefined) result.dtim_na = input.dtimNa;
|
||||
if (input.minrateNgEnabled !== undefined)
|
||||
result.minrate_ng_enabled = input.minrateNgEnabled;
|
||||
if (input.minrateNaEnabled !== undefined)
|
||||
result.minrate_na_enabled = input.minrateNaEnabled;
|
||||
if (input.radiusDasEnabled !== undefined)
|
||||
result.radius_das_enabled = input.radiusDasEnabled;
|
||||
if (input.radiusMacAuthEnabled !== undefined)
|
||||
result.radius_mac_auth_enabled = input.radiusMacAuthEnabled;
|
||||
if (input.pmfMode !== undefined) result.pmf_mode = input.pmfMode;
|
||||
if (input.band !== undefined) result.wlan_band = input.band;
|
||||
if (input.usergroupId !== undefined) result.usergroup_id = input.usergroupId;
|
||||
if (input.proxyArp !== undefined) result.proxy_arp = input.proxyArp;
|
||||
if (input.mcastenhanceEnabled !== undefined)
|
||||
result.mcastenhance_enabled = input.mcastenhanceEnabled;
|
||||
if (input.macFilterList !== undefined)
|
||||
result.mac_filter_list = input.macFilterList;
|
||||
if (input.no2ghzOui !== undefined) result.no2ghz_oui = input.no2ghzOui;
|
||||
if (input.apGroupIds !== undefined) result.ap_group_ids = input.apGroupIds;
|
||||
if (input.apGroupMode !== undefined) result.ap_group_mode = input.apGroupMode;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Site overview types ---
|
||||
|
||||
export interface SubsystemHealth {
|
||||
subsystem: "wlan" | "wan" | "www" | "lan" | "vpn";
|
||||
status: "ok" | "warn" | "error" | "unknown";
|
||||
numUser?: number;
|
||||
numGuest?: number;
|
||||
numIot?: number;
|
||||
txBytesR?: number;
|
||||
rxBytesR?: number;
|
||||
// WLAN-specific
|
||||
numAp?: number;
|
||||
// LAN-specific
|
||||
numSw?: number;
|
||||
// WAN-specific
|
||||
numGw?: number;
|
||||
// Shared device counts
|
||||
numAdopted?: number;
|
||||
numDisconnected?: number;
|
||||
numPending?: number;
|
||||
numDisabled?: number;
|
||||
}
|
||||
|
||||
export interface SysInfo {
|
||||
name: string;
|
||||
hostname: string;
|
||||
version: string;
|
||||
build: string;
|
||||
timezone: string;
|
||||
uptime: number;
|
||||
ipAddresses: string[];
|
||||
updateAvailable: boolean;
|
||||
isCloudConsole: boolean;
|
||||
dataRetentionDays: number;
|
||||
informPort: number;
|
||||
httpsPort: number;
|
||||
unsupportedDeviceCount: number;
|
||||
}
|
||||
|
||||
export interface SiteInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
deviceCount: number;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface SiteOverview {
|
||||
site: SiteInfo;
|
||||
health: SubsystemHealth[];
|
||||
sysInfo: SysInfo;
|
||||
}
|
||||
|
||||
// --- Device types ---
|
||||
|
||||
export type DeviceType = "uap" | "usw" | "ugw" | "uxg" | "ubb" | "udm";
|
||||
export type DeviceState =
|
||||
| "connected"
|
||||
| "disconnected"
|
||||
| "pending"
|
||||
| "adopting"
|
||||
| "unknown";
|
||||
|
||||
export interface DeviceUplink {
|
||||
type?: string;
|
||||
mac?: string;
|
||||
ip?: string;
|
||||
uplinkRemotePort?: number;
|
||||
speed?: number;
|
||||
fullDuplex?: boolean;
|
||||
}
|
||||
|
||||
export interface DeviceRadio {
|
||||
name: string;
|
||||
radio: string;
|
||||
channel: number;
|
||||
txPower: number;
|
||||
txPowerMode: string;
|
||||
minRssiEnabled: boolean;
|
||||
numSta: number;
|
||||
satisfaction: number | null;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
id: string;
|
||||
mac: string;
|
||||
ip: string;
|
||||
name: string;
|
||||
model: string;
|
||||
shortname: string;
|
||||
type: DeviceType;
|
||||
version: string;
|
||||
serial: string;
|
||||
state: DeviceState;
|
||||
adopted: boolean;
|
||||
uptime: number;
|
||||
lastSeen: number;
|
||||
upgradable: boolean;
|
||||
satisfaction: number | null;
|
||||
numClients: number;
|
||||
numUserClients: number;
|
||||
numGuestClients: number;
|
||||
txBytes: number;
|
||||
rxBytes: number;
|
||||
uplink: DeviceUplink | null;
|
||||
radios: DeviceRadio[];
|
||||
modelInLts: boolean;
|
||||
modelInEol: boolean;
|
||||
}
|
||||
|
||||
// --- Network types ---
|
||||
|
||||
export type NetworkPurpose =
|
||||
| "corporate"
|
||||
| "vlan-only"
|
||||
| "wan"
|
||||
| "vpn-client"
|
||||
| "remote-user-vpn"
|
||||
| "site-vpn";
|
||||
|
||||
export interface Network {
|
||||
id: string;
|
||||
name: string;
|
||||
purpose: NetworkPurpose;
|
||||
enabled: boolean;
|
||||
ipSubnet: string | null;
|
||||
vlan: number | null;
|
||||
vlanEnabled: boolean;
|
||||
isNat: boolean;
|
||||
domainName: string | null;
|
||||
networkGroup: string | null;
|
||||
dhcpdEnabled: boolean;
|
||||
dhcpdStart: string | null;
|
||||
dhcpdStop: string | null;
|
||||
dhcpdLeasetime: number | null;
|
||||
dhcpRelayEnabled: boolean;
|
||||
dhcpGuardEnabled: boolean;
|
||||
igmpSnooping: boolean;
|
||||
ipv6Enabled: boolean;
|
||||
ipv6InterfaceType: string | null;
|
||||
internetAccessEnabled: boolean | null;
|
||||
}
|
||||
|
||||
// --- Site create types ---
|
||||
|
||||
export interface CreateSiteOptions {
|
||||
/** Human-readable description / display name for the site */
|
||||
description: string;
|
||||
}
|
||||
|
||||
// --- Site list types ---
|
||||
|
||||
export interface SiteListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
deviceCount: number;
|
||||
role: string;
|
||||
}
|
||||
|
||||
// --- WLAN Group types ---
|
||||
|
||||
export interface WlanGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
siteId: string;
|
||||
noDelete: boolean;
|
||||
noEdit: boolean;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface WlanGroupCreateInput {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// --- AP Group types ---
|
||||
|
||||
export interface ApGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
deviceMacs: string[];
|
||||
noDelete: boolean;
|
||||
forWlanconf: boolean;
|
||||
}
|
||||
|
||||
// --- User Group (Speed Profile) types ---
|
||||
|
||||
export interface UserGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
siteId: string;
|
||||
noDelete: boolean;
|
||||
/** Download rate limit in Kbps. -1 means unlimited. */
|
||||
downloadLimitKbps: number;
|
||||
/** Upload rate limit in Kbps. -1 means unlimited. */
|
||||
uploadLimitKbps: number;
|
||||
}
|
||||
|
||||
export interface UserGroupCreateInput {
|
||||
name: string;
|
||||
/** Download rate limit in Kbps. -1 or omit for unlimited. */
|
||||
downloadLimitKbps?: number;
|
||||
/** Upload rate limit in Kbps. -1 or omit for unlimited. */
|
||||
uploadLimitKbps?: number;
|
||||
}
|
||||
|
||||
// --- Private PSK types ---
|
||||
|
||||
export interface PrivatePSK {
|
||||
key: string;
|
||||
name: string;
|
||||
mac: string | null;
|
||||
vlanId: number | null;
|
||||
}
|
||||
|
||||
export interface PrivatePSKCreateInput {
|
||||
key: string;
|
||||
name: string;
|
||||
mac?: string;
|
||||
vlanId?: number;
|
||||
}
|
||||
|
||||
// --- WiFi Limit types ---
|
||||
|
||||
export interface ApRadioWifiUsage {
|
||||
radio: string;
|
||||
band: string;
|
||||
activeWlans: number;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
wlanNames: string[];
|
||||
}
|
||||
|
||||
export interface ApWifiLimits {
|
||||
apId: string;
|
||||
apName: string;
|
||||
mac: string;
|
||||
model: string;
|
||||
radios: ApRadioWifiUsage[];
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export interface Company {
|
||||
territory: Territory;
|
||||
market: Market;
|
||||
accountNumber: string;
|
||||
defaultContact: Contact;
|
||||
defaultContact: ContactHref;
|
||||
dateAcquired: string;
|
||||
annualRevenue: number;
|
||||
numberOfEmployees: number;
|
||||
@@ -27,7 +27,7 @@ export interface Company {
|
||||
billingTerms: BasicEntity;
|
||||
billToCompany: LinkedCompany;
|
||||
billingSite: LinkedSite;
|
||||
billingContact: Contact;
|
||||
billingContact: ContactHref;
|
||||
invoiceDeliveryMethod: BasicEntity;
|
||||
invoiceToEmailAddress: string;
|
||||
deletedFlag: boolean;
|
||||
@@ -91,7 +91,7 @@ export interface LinkedSite extends BasicEntity {
|
||||
};
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
export interface ContactHref {
|
||||
id: number;
|
||||
name: string;
|
||||
_info: {
|
||||
@@ -305,3 +305,140 @@ export interface ConfigurationInfo {
|
||||
|
||||
// Your payload is an array:
|
||||
export type ConfigurationResponse = ConfigurationItem[];
|
||||
export interface Contact {
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
company: ContactCompany;
|
||||
site: ContactSite;
|
||||
relationship: ContactRelationship;
|
||||
department: ContactDepartment;
|
||||
inactiveFlag: boolean;
|
||||
title: string;
|
||||
marriedFlag: boolean;
|
||||
childrenFlag: boolean;
|
||||
disablePortalLoginFlag: boolean;
|
||||
unsubscribeFlag: boolean;
|
||||
mobileGuid: string;
|
||||
facebookUrl: string;
|
||||
twitterUrl: string;
|
||||
linkedInUrl: string;
|
||||
defaultPhoneType: string;
|
||||
defaultPhoneNbr: string;
|
||||
defaultBillingFlag: boolean;
|
||||
defaultFlag: boolean;
|
||||
companyLocation: ContactCompanyLocation;
|
||||
communicationItems: ContactCommunicationItem[];
|
||||
types: ContactType[];
|
||||
customFields: ContactCustomField[];
|
||||
photo: ContactPhoto;
|
||||
ignoreDuplicates: boolean;
|
||||
_info: ContactInfo;
|
||||
}
|
||||
|
||||
export interface ContactCompany {
|
||||
id: number;
|
||||
identifier: string;
|
||||
name: string;
|
||||
_info: {
|
||||
company_href: string;
|
||||
mobileGuid: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContactSite {
|
||||
id: number;
|
||||
name: string;
|
||||
_info: {
|
||||
site_href: string;
|
||||
mobileGuid: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContactRelationship {
|
||||
id: number;
|
||||
name: string;
|
||||
_info: {
|
||||
relationship_href: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContactDepartment {
|
||||
id: number;
|
||||
name: string;
|
||||
_info: {
|
||||
department_href: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContactCompanyLocation {
|
||||
id: number;
|
||||
name: string;
|
||||
_info: {
|
||||
location_href: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type CommunicationTypeEnum = "Phone" | "Fax" | "Email"; // from CW docs [web:40]
|
||||
|
||||
export interface ContactCommunicationItem {
|
||||
id: number;
|
||||
type: {
|
||||
id: number;
|
||||
name: string;
|
||||
_info: {
|
||||
type_href: string;
|
||||
};
|
||||
};
|
||||
value: string;
|
||||
defaultFlag: boolean;
|
||||
communicationType: CommunicationTypeEnum;
|
||||
domain?: string; // only present for email entries
|
||||
}
|
||||
|
||||
export interface ContactType {
|
||||
id: number;
|
||||
name: string;
|
||||
_info: {
|
||||
type_href: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContactCustomField {
|
||||
id: number;
|
||||
caption: string;
|
||||
type: string;
|
||||
entryMethod: string;
|
||||
numberOfDecimals: number;
|
||||
value: string | number | boolean | null;
|
||||
connectWiseId: string;
|
||||
rowNum: number;
|
||||
userDefinedFieldRecId: number;
|
||||
podId: string;
|
||||
}
|
||||
|
||||
export interface ContactPhoto {
|
||||
id: number;
|
||||
name: string;
|
||||
_info: {
|
||||
filename: string;
|
||||
document_href: string;
|
||||
documentDownload_href: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContactInfo {
|
||||
lastUpdated: string; // ISO datetime
|
||||
updatedBy: string;
|
||||
communications_href: string;
|
||||
notes_href: string;
|
||||
tracks_href: string;
|
||||
portalSecurity_href: string;
|
||||
activities_href: string;
|
||||
documents_href: string;
|
||||
configurations_href: string;
|
||||
tickets_href: string;
|
||||
opportunities_href: string;
|
||||
projects_href: string;
|
||||
image_href: string;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,13 @@ export interface PermissionNode {
|
||||
usedIn: string[];
|
||||
/** Dependencies - other permissions that must be granted alongside this one */
|
||||
dependencies?: string[];
|
||||
/**
|
||||
* When present, indicates this permission gates individual fields on the
|
||||
* response object via `processObjectValuePerms`. Each entry is a full
|
||||
* permission node in the form `<scope>.<fieldName>`. Only fields whose
|
||||
* corresponding permission the user holds are included in the response.
|
||||
*/
|
||||
fieldLevelPermissions?: string[];
|
||||
}
|
||||
|
||||
export interface PermissionCategory {
|
||||
@@ -56,6 +63,12 @@ export const PERMISSION_NODES = {
|
||||
usedIn: ["src/api/companies/[id]/fetch.ts"],
|
||||
dependencies: ["company.fetch"],
|
||||
},
|
||||
{
|
||||
node: "company.fetch.contacts",
|
||||
description: "View all company contacts",
|
||||
usedIn: ["src/api/companies/[id]/fetch.ts"],
|
||||
dependencies: ["company.fetch"],
|
||||
},
|
||||
{
|
||||
node: "company.fetch.many",
|
||||
description: "Fetch multiple companies",
|
||||
@@ -120,6 +133,24 @@ export const PERMISSION_NODES = {
|
||||
],
|
||||
dependencies: ["credential.fetch"],
|
||||
},
|
||||
{
|
||||
node: "credential.sub_credentials.fetch",
|
||||
description: "Fetch sub-credentials of a parent credential",
|
||||
usedIn: ["src/api/credentials/fetchSubCredentials.ts"],
|
||||
dependencies: ["credential.fetch"],
|
||||
},
|
||||
{
|
||||
node: "credential.sub_credentials.create",
|
||||
description: "Create a sub-credential on a parent credential",
|
||||
usedIn: ["src/api/credentials/addSubCredential.ts"],
|
||||
dependencies: ["credential.fetch"],
|
||||
},
|
||||
{
|
||||
node: "credential.sub_credentials.delete",
|
||||
description: "Remove a sub-credential from a parent credential",
|
||||
usedIn: ["src/api/credentials/removeSubCredential.ts"],
|
||||
dependencies: ["credential.fetch"],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -309,6 +340,242 @@ export const PERMISSION_NODES = {
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
unifi: {
|
||||
name: "UniFi Permissions",
|
||||
description:
|
||||
"Permissions for accessing and managing UniFi network infrastructure",
|
||||
permissions: [
|
||||
{
|
||||
node: "unifi.access",
|
||||
description:
|
||||
"Gate permission for the entire UniFi API — required for all UniFi routes",
|
||||
usedIn: [
|
||||
"src/api/unifi/sites/fetchAll.ts",
|
||||
"src/api/unifi/sites/sync.ts",
|
||||
"src/api/unifi/site/fetch.ts",
|
||||
"src/api/unifi/site/overview.ts",
|
||||
"src/api/unifi/site/devices.ts",
|
||||
"src/api/unifi/site/wifi/fetchAll.ts",
|
||||
"src/api/unifi/site/wifi/update.ts",
|
||||
"src/api/unifi/site/wifi/ppskFetchAll.ts",
|
||||
"src/api/unifi/site/wifi/ppskCreate.ts",
|
||||
"src/api/unifi/site/networks.ts",
|
||||
"src/api/unifi/site/link.ts",
|
||||
"src/api/unifi/site/unlink.ts",
|
||||
"src/api/companies/[id]/unifiSites.ts",
|
||||
"src/api/unifi/sites/create.ts",
|
||||
"src/api/unifi/site/wlanGroups.ts",
|
||||
"src/api/unifi/site/accessPoints.ts",
|
||||
"src/api/unifi/site/wifiLimits.ts",
|
||||
"src/api/unifi/site/speedProfilesFetchAll.ts",
|
||||
"src/api/unifi/site/speedProfilesCreate.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
node: "unifi.sites.create",
|
||||
description: "Create a new site on the UniFi controller",
|
||||
usedIn: ["src/api/unifi/sites/create.ts"],
|
||||
dependencies: ["unifi.access"],
|
||||
},
|
||||
{
|
||||
node: "unifi.sites.fetch",
|
||||
description: "Fetch a single UniFi site",
|
||||
usedIn: ["src/api/unifi/site/fetch.ts"],
|
||||
dependencies: ["unifi.access"],
|
||||
},
|
||||
{
|
||||
node: "unifi.sites.fetch.many",
|
||||
description: "Fetch all UniFi sites",
|
||||
usedIn: ["src/api/unifi/sites/fetchAll.ts"],
|
||||
dependencies: ["unifi.access"],
|
||||
},
|
||||
{
|
||||
node: "unifi.sites.sync",
|
||||
description: "Sync sites from the UniFi controller into the database",
|
||||
usedIn: ["src/api/unifi/sites/sync.ts"],
|
||||
dependencies: ["unifi.access"],
|
||||
},
|
||||
{
|
||||
node: "unifi.sites.link",
|
||||
description: "Link or unlink a UniFi site to/from a company",
|
||||
usedIn: ["src/api/unifi/site/link.ts", "src/api/unifi/site/unlink.ts"],
|
||||
dependencies: ["unifi.access"],
|
||||
},
|
||||
{
|
||||
node: "unifi.site.overview",
|
||||
description: "View live site overview from the UniFi controller",
|
||||
usedIn: ["src/api/unifi/site/overview.ts"],
|
||||
dependencies: ["unifi.access"],
|
||||
},
|
||||
{
|
||||
node: "unifi.site.devices",
|
||||
description: "View live device list from the UniFi controller",
|
||||
usedIn: ["src/api/unifi/site/devices.ts"],
|
||||
dependencies: ["unifi.access"],
|
||||
},
|
||||
{
|
||||
node: "unifi.site.wifi",
|
||||
description: "View WiFi networks (WLANs) from the UniFi controller",
|
||||
usedIn: ["src/api/unifi/site/wifi/fetchAll.ts"],
|
||||
dependencies: ["unifi.access"],
|
||||
},
|
||||
{
|
||||
node: "unifi.site.wifi.read",
|
||||
description:
|
||||
"Field-level gate for WiFi network response data. Each key on the WlanConf object is checked as unifi.site.wifi.read.<field>. Only fields the user has permission for are included in the response.",
|
||||
usedIn: ["src/api/unifi/site/wifi/fetchAll.ts"],
|
||||
dependencies: ["unifi.access", "unifi.site.wifi"],
|
||||
fieldLevelPermissions: [
|
||||
"unifi.site.wifi.read.id",
|
||||
"unifi.site.wifi.read.name",
|
||||
"unifi.site.wifi.read.siteId",
|
||||
"unifi.site.wifi.read.enabled",
|
||||
"unifi.site.wifi.read.security",
|
||||
"unifi.site.wifi.read.wpaMode",
|
||||
"unifi.site.wifi.read.wpaEnc",
|
||||
"unifi.site.wifi.read.wpa3Support",
|
||||
"unifi.site.wifi.read.wpa3Transition",
|
||||
"unifi.site.wifi.read.wpa3FastRoaming",
|
||||
"unifi.site.wifi.read.wpa3Enhanced192",
|
||||
"unifi.site.wifi.read.passphrase",
|
||||
"unifi.site.wifi.read.passphraseAutogenerated",
|
||||
"unifi.site.wifi.read.hideSSID",
|
||||
"unifi.site.wifi.read.isGuest",
|
||||
"unifi.site.wifi.read.band",
|
||||
"unifi.site.wifi.read.bands",
|
||||
"unifi.site.wifi.read.networkconfId",
|
||||
"unifi.site.wifi.read.usergroupId",
|
||||
"unifi.site.wifi.read.apGroupIds",
|
||||
"unifi.site.wifi.read.apGroupMode",
|
||||
"unifi.site.wifi.read.pmfMode",
|
||||
"unifi.site.wifi.read.groupRekey",
|
||||
"unifi.site.wifi.read.dtimMode",
|
||||
"unifi.site.wifi.read.dtimNg",
|
||||
"unifi.site.wifi.read.dtimNa",
|
||||
"unifi.site.wifi.read.dtim6e",
|
||||
"unifi.site.wifi.read.l2Isolation",
|
||||
"unifi.site.wifi.read.fastRoamingEnabled",
|
||||
"unifi.site.wifi.read.bssTransition",
|
||||
"unifi.site.wifi.read.uapsdEnabled",
|
||||
"unifi.site.wifi.read.iappEnabled",
|
||||
"unifi.site.wifi.read.proxyArp",
|
||||
"unifi.site.wifi.read.mcastenhanceEnabled",
|
||||
"unifi.site.wifi.read.macFilterEnabled",
|
||||
"unifi.site.wifi.read.macFilterPolicy",
|
||||
"unifi.site.wifi.read.macFilterList",
|
||||
"unifi.site.wifi.read.radiusDasEnabled",
|
||||
"unifi.site.wifi.read.radiusMacAuthEnabled",
|
||||
"unifi.site.wifi.read.radiusMacaclFormat",
|
||||
"unifi.site.wifi.read.minrateSettingPreference",
|
||||
"unifi.site.wifi.read.minrateNgEnabled",
|
||||
"unifi.site.wifi.read.minrateNgDataRateKbps",
|
||||
"unifi.site.wifi.read.minrateNgAdvertisingRates",
|
||||
"unifi.site.wifi.read.minrateNaEnabled",
|
||||
"unifi.site.wifi.read.minrateNaDataRateKbps",
|
||||
"unifi.site.wifi.read.minrateNaAdvertisingRates",
|
||||
"unifi.site.wifi.read.settingPreference",
|
||||
"unifi.site.wifi.read.no2ghzOui",
|
||||
"unifi.site.wifi.read.privatePreSharedKeysEnabled",
|
||||
"unifi.site.wifi.read.privatePreSharedKeys",
|
||||
"unifi.site.wifi.read.saeGroups",
|
||||
"unifi.site.wifi.read.saePsk",
|
||||
"unifi.site.wifi.read.schedule",
|
||||
"unifi.site.wifi.read.scheduleWithDuration",
|
||||
"unifi.site.wifi.read.bcFilterList",
|
||||
"unifi.site.wifi.read.externalId",
|
||||
],
|
||||
},
|
||||
{
|
||||
node: "unifi.site.wifi.update",
|
||||
description: "Update a WiFi network on the UniFi controller",
|
||||
usedIn: ["src/api/unifi/site/wifi/update.ts"],
|
||||
dependencies: ["unifi.access", "unifi.site.wifi"],
|
||||
},
|
||||
{
|
||||
node: "unifi.site.networks",
|
||||
description: "View network configurations from the UniFi controller",
|
||||
usedIn: ["src/api/unifi/site/networks.ts"],
|
||||
dependencies: ["unifi.access"],
|
||||
},
|
||||
{
|
||||
node: "unifi.site.wlan-groups",
|
||||
description:
|
||||
"View WLAN groups (AP broadcasting groups) from the UniFi controller for a site",
|
||||
usedIn: [
|
||||
"src/api/unifi/site/wlanGroups.ts",
|
||||
"src/api/unifi/site/wlanGroupsCreate.ts",
|
||||
],
|
||||
dependencies: ["unifi.access"],
|
||||
},
|
||||
{
|
||||
node: "unifi.site.wlan-groups.create",
|
||||
description:
|
||||
"Create a new WLAN group (AP broadcasting group) on the UniFi controller",
|
||||
usedIn: ["src/api/unifi/site/wlanGroupsCreate.ts"],
|
||||
dependencies: ["unifi.access", "unifi.site.wlan-groups"],
|
||||
},
|
||||
{
|
||||
node: "unifi.site.access-points",
|
||||
description:
|
||||
"View access points (UAPs only) from the UniFi controller for a site",
|
||||
usedIn: ["src/api/unifi/site/accessPoints.ts"],
|
||||
dependencies: ["unifi.access"],
|
||||
},
|
||||
{
|
||||
node: "unifi.site.ap-groups",
|
||||
description:
|
||||
"View AP groups from the UniFi controller — shows which access points are grouped together for SSID broadcasting",
|
||||
usedIn: ["src/api/unifi/site/apGroups.ts"],
|
||||
dependencies: ["unifi.access"],
|
||||
},
|
||||
{
|
||||
node: "unifi.site.wifi-limits",
|
||||
description:
|
||||
"View WiFi SSID limits per access point per radio band — shows how many SSIDs each AP radio can still accept",
|
||||
usedIn: ["src/api/unifi/site/wifiLimits.ts"],
|
||||
dependencies: ["unifi.access"],
|
||||
},
|
||||
{
|
||||
node: "unifi.site.speed-profiles",
|
||||
description:
|
||||
"View speed limit profiles (user groups) from the UniFi controller",
|
||||
usedIn: [
|
||||
"src/api/unifi/site/speedProfilesFetchAll.ts",
|
||||
"src/api/unifi/site/speedProfilesCreate.ts",
|
||||
],
|
||||
dependencies: ["unifi.access"],
|
||||
},
|
||||
{
|
||||
node: "unifi.site.speed-profiles.create",
|
||||
description:
|
||||
"Create a new speed limit profile (user group) on the UniFi controller",
|
||||
usedIn: ["src/api/unifi/site/speedProfilesCreate.ts"],
|
||||
dependencies: ["unifi.access", "unifi.site.speed-profiles"],
|
||||
},
|
||||
{
|
||||
node: "unifi.site.wifi.ppsk",
|
||||
description:
|
||||
"View private pre-shared keys (PPSKs) for a specific WiFi network",
|
||||
usedIn: [
|
||||
"src/api/unifi/site/wifi/ppskFetchAll.ts",
|
||||
"src/api/unifi/site/wifi/ppskCreate.ts",
|
||||
],
|
||||
dependencies: ["unifi.access", "unifi.site.wifi"],
|
||||
},
|
||||
{
|
||||
node: "unifi.site.wifi.ppsk.create",
|
||||
description:
|
||||
"Create a private pre-shared key on a specific WiFi network",
|
||||
usedIn: ["src/api/unifi/site/wifi/ppskCreate.ts"],
|
||||
dependencies: [
|
||||
"unifi.access",
|
||||
"unifi.site.wifi",
|
||||
"unifi.site.wifi.ppsk",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as const satisfies Record<string, PermissionCategory>;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user