setup unifi wlans

This commit is contained in:
2026-02-22 19:12:34 -06:00
parent 70284bc14e
commit 3c89f24189
66 changed files with 7393 additions and 110 deletions
+20 -1
View File
@@ -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);
},
+24
View File
@@ -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"],
}),
);
+2 -1
View File
@@ -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 };
+12 -9
View File
@@ -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);
+12 -11
View File
@@ -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);
+48
View File
@@ -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"],
}),
);
+16
View File
@@ -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"],
}),
);
+6
View File
@@ -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"],
}),
);
+7
View File
@@ -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;
+1
View File
@@ -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;
+43
View File
@@ -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,
};
+23
View File
@@ -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"],
}),
);
+23
View File
@@ -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"],
}),
);
+21
View File
@@ -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"] }),
);
+20
View File
@@ -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"] }),
);
+26
View File
@@ -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"] }),
);
+21
View File
@@ -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"] }),
);
+21
View File
@@ -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"] }),
);
+40
View File
@@ -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"],
}),
);
+21
View File
@@ -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"] }),
);
+31
View File
@@ -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"] }),
);
+47
View File
@@ -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",
],
}),
);
+24
View File
@@ -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"],
}),
);
+117
View File
@@ -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"],
}),
);
+23
View File
@@ -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"],
}),
);
+23
View File
@@ -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"],
}),
);
+38
View File
@@ -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",
],
}),
);
+25
View File
@@ -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"] }),
);
+20
View File
@@ -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"] }),
);
+20
View File
@@ -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"] }),
);
+11
View File
@@ -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);
+61 -14
View File
@@ -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
);
})(),
})),
},
};
}
+88 -11
View File
@@ -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,
+29
View File
@@ -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,
};
}
}
+6
View File
@@ -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,
+13 -2
View File
@@ -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() {
-2
View File
@@ -79,8 +79,6 @@ export const credentialTypes = {
});
}
console.log(data.fields);
const credentialType = await prisma.credentialType.create({
data: {
name: data.name,
+288 -26
View File
@@ -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>}
+293
View File
@@ -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
}
+29 -8
View File
@@ -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;
};
+952
View File
@@ -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");
}
}
+434
View File
@@ -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[];
}
+140 -3
View File
@@ -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;
}
+267
View File
@@ -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>;
/**