mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-26 04:26:38 +00:00
Merge branch 'logging-provision' of github.com:fosrl/pangolin into logging-provision
This commit is contained in:
@@ -323,6 +323,25 @@
|
|||||||
"apiKeysDelete": "Delete API Key",
|
"apiKeysDelete": "Delete API Key",
|
||||||
"apiKeysManage": "Manage API Keys",
|
"apiKeysManage": "Manage API Keys",
|
||||||
"apiKeysDescription": "API keys are used to authenticate with the integration API",
|
"apiKeysDescription": "API keys are used to authenticate with the integration API",
|
||||||
|
"provisioningKeysTitle": "Provisioning Key",
|
||||||
|
"provisioningKeysManage": "Manage Provisioning Keys",
|
||||||
|
"provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.",
|
||||||
|
"provisioningKeys": "Provisioning Keys",
|
||||||
|
"searchProvisioningKeys": "Search provisioning keys...",
|
||||||
|
"provisioningKeysAdd": "Generate Provisioning Key",
|
||||||
|
"provisioningKeysErrorDelete": "Error deleting provisioning key",
|
||||||
|
"provisioningKeysErrorDeleteMessage": "Error deleting provisioning key",
|
||||||
|
"provisioningKeysQuestionRemove": "Are you sure you want to remove this provisioning key from the organization?",
|
||||||
|
"provisioningKeysMessageRemove": "Once removed, the key can no longer be used for site provisioning.",
|
||||||
|
"provisioningKeysDeleteConfirm": "Confirm Delete Provisioning Key",
|
||||||
|
"provisioningKeysDelete": "Delete Provisioning key",
|
||||||
|
"provisioningKeysCreate": "Generate Provisioning Key",
|
||||||
|
"provisioningKeysCreateDescription": "Generate a new provisioning key for the organization",
|
||||||
|
"provisioningKeysSeeAll": "See all provisioning keys",
|
||||||
|
"provisioningKeysSave": "Save the provisioning key",
|
||||||
|
"provisioningKeysSaveDescription": "You will only be able to see this once. Copy it to a secure place.",
|
||||||
|
"provisioningKeysErrorCreate": "Error creating provisioning key",
|
||||||
|
"provisioningKeysList": "New provisioning key",
|
||||||
"apiKeysSettings": "{apiKeyName} Settings",
|
"apiKeysSettings": "{apiKeyName} Settings",
|
||||||
"userTitle": "Manage All Users",
|
"userTitle": "Manage All Users",
|
||||||
"userDescription": "View and manage all users in the system",
|
"userDescription": "View and manage all users in the system",
|
||||||
@@ -1266,6 +1285,7 @@
|
|||||||
"sidebarRoles": "Roles",
|
"sidebarRoles": "Roles",
|
||||||
"sidebarShareableLinks": "Links",
|
"sidebarShareableLinks": "Links",
|
||||||
"sidebarApiKeys": "API Keys",
|
"sidebarApiKeys": "API Keys",
|
||||||
|
"sidebarProvisioning": "Provisioning",
|
||||||
"sidebarSettings": "Settings",
|
"sidebarSettings": "Settings",
|
||||||
"sidebarAllUsers": "All Users",
|
"sidebarAllUsers": "All Users",
|
||||||
"sidebarIdentityProviders": "Identity Providers",
|
"sidebarIdentityProviders": "Identity Providers",
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ export enum ActionsEnum {
|
|||||||
listApiKeyActions = "listApiKeyActions",
|
listApiKeyActions = "listApiKeyActions",
|
||||||
listApiKeys = "listApiKeys",
|
listApiKeys = "listApiKeys",
|
||||||
getApiKey = "getApiKey",
|
getApiKey = "getApiKey",
|
||||||
|
createSiteProvisioningKey = "createSiteProvisioningKey",
|
||||||
|
listSiteProvisioningKeys = "listSiteProvisioningKeys",
|
||||||
|
deleteSiteProvisioningKey = "deleteSiteProvisioningKey",
|
||||||
getCertificate = "getCertificate",
|
getCertificate = "getCertificate",
|
||||||
restartCertificate = "restartCertificate",
|
restartCertificate = "restartCertificate",
|
||||||
billing = "billing",
|
billing = "billing",
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
bigint,
|
bigint,
|
||||||
real,
|
real,
|
||||||
text,
|
text,
|
||||||
index
|
index,
|
||||||
|
primaryKey
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { InferSelectModel } from "drizzle-orm";
|
import { InferSelectModel } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
@@ -91,7 +92,9 @@ export const subscriptions = pgTable("subscriptions", {
|
|||||||
|
|
||||||
export const subscriptionItems = pgTable("subscriptionItems", {
|
export const subscriptionItems = pgTable("subscriptionItems", {
|
||||||
subscriptionItemId: serial("subscriptionItemId").primaryKey(),
|
subscriptionItemId: serial("subscriptionItemId").primaryKey(),
|
||||||
stripeSubscriptionItemId: varchar("stripeSubscriptionItemId", { length: 255 }),
|
stripeSubscriptionItemId: varchar("stripeSubscriptionItemId", {
|
||||||
|
length: 255
|
||||||
|
}),
|
||||||
subscriptionId: varchar("subscriptionId", { length: 255 })
|
subscriptionId: varchar("subscriptionId", { length: 255 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => subscriptions.subscriptionId, {
|
.references(() => subscriptions.subscriptionId, {
|
||||||
@@ -370,13 +373,44 @@ export const approvals = pgTable("approvals", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const bannedEmails = pgTable("bannedEmails", {
|
export const bannedEmails = pgTable("bannedEmails", {
|
||||||
email: varchar("email", { length: 255 }).primaryKey(),
|
email: varchar("email", { length: 255 }).primaryKey()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const bannedIps = pgTable("bannedIps", {
|
export const bannedIps = pgTable("bannedIps", {
|
||||||
ip: varchar("ip", { length: 255 }).primaryKey(),
|
ip: varchar("ip", { length: 255 }).primaryKey()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const siteProvisioningKeys = pgTable("siteProvisioningKeys", {
|
||||||
|
siteProvisioningKeyId: varchar("siteProvisioningKeyId", {
|
||||||
|
length: 255
|
||||||
|
}).primaryKey(),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
siteProvisioningKeyHash: text("siteProvisioningKeyHash").notNull(),
|
||||||
|
lastChars: varchar("lastChars", { length: 4 }).notNull(),
|
||||||
|
createdAt: varchar("dateCreated", { length: 255 }).notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const siteProvisioningKeyOrg = pgTable(
|
||||||
|
"siteProvisioningKeyOrg",
|
||||||
|
{
|
||||||
|
siteProvisioningKeyId: varchar("siteProvisioningKeyId", {
|
||||||
|
length: 255
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
.references(() => siteProvisioningKeys.siteProvisioningKeyId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
orgId: varchar("orgId", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => orgs.orgId, { onDelete: "cascade" })
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
primaryKey({
|
||||||
|
columns: [table.siteProvisioningKeyId, table.orgId]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { InferSelectModel } from "drizzle-orm";
|
|||||||
import {
|
import {
|
||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
|
primaryKey,
|
||||||
real,
|
real,
|
||||||
sqliteTable,
|
sqliteTable,
|
||||||
text
|
text
|
||||||
@@ -357,7 +358,6 @@ export const approvals = sqliteTable("approvals", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
export const bannedEmails = sqliteTable("bannedEmails", {
|
export const bannedEmails = sqliteTable("bannedEmails", {
|
||||||
email: text("email").primaryKey()
|
email: text("email").primaryKey()
|
||||||
});
|
});
|
||||||
@@ -366,6 +366,33 @@ export const bannedIps = sqliteTable("bannedIps", {
|
|||||||
ip: text("ip").primaryKey()
|
ip: text("ip").primaryKey()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const siteProvisioningKeys = sqliteTable("siteProvisioningKeys", {
|
||||||
|
siteProvisioningKeyId: text("siteProvisioningKeyId").primaryKey(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
siteProvisioningKeyHash: text("siteProvisioningKeyHash").notNull(),
|
||||||
|
lastChars: text("lastChars").notNull(),
|
||||||
|
createdAt: text("dateCreated").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const siteProvisioningKeyOrg = sqliteTable(
|
||||||
|
"siteProvisioningKeyOrg",
|
||||||
|
{
|
||||||
|
siteProvisioningKeyId: text("siteProvisioningKeyId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => siteProvisioningKeys.siteProvisioningKeyId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
orgId: text("orgId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => orgs.orgId, { onDelete: "cascade" })
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
primaryKey({
|
||||||
|
columns: [table.siteProvisioningKeyId, table.orgId]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export * from "./verifyClientAccess";
|
|||||||
export * from "./integration";
|
export * from "./integration";
|
||||||
export * from "./verifyUserHasAction";
|
export * from "./verifyUserHasAction";
|
||||||
export * from "./verifyApiKeyAccess";
|
export * from "./verifyApiKeyAccess";
|
||||||
|
export * from "./verifySiteProvisioningKeyAccess";
|
||||||
export * from "./verifyDomainAccess";
|
export * from "./verifyDomainAccess";
|
||||||
export * from "./verifyUserIsOrgOwner";
|
export * from "./verifyUserIsOrgOwner";
|
||||||
export * from "./verifySiteResourceAccess";
|
export * from "./verifySiteResourceAccess";
|
||||||
|
|||||||
131
server/middlewares/verifySiteProvisioningKeyAccess.ts
Normal file
131
server/middlewares/verifySiteProvisioningKeyAccess.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { db, userOrgs, siteProvisioningKeys, siteProvisioningKeyOrg } from "@server/db";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
|
|
||||||
|
export async function verifySiteProvisioningKeyAccess(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
const siteProvisioningKeyId = req.params.siteProvisioningKeyId;
|
||||||
|
const orgId = req.params.orgId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!siteProvisioningKeyId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(siteProvisioningKeys)
|
||||||
|
.innerJoin(
|
||||||
|
siteProvisioningKeyOrg,
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
siteProvisioningKeys.siteProvisioningKeyId,
|
||||||
|
siteProvisioningKeyOrg.siteProvisioningKeyId
|
||||||
|
),
|
||||||
|
eq(siteProvisioningKeyOrg.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
siteProvisioningKeys.siteProvisioningKeyId,
|
||||||
|
siteProvisioningKeyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row?.siteProvisioningKeys) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Site provisioning key with ID ${siteProvisioningKeyId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row.siteProvisioningKeyOrg.orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
`Site provisioning key with ID ${siteProvisioningKeyId} does not have an organization ID`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.userOrg) {
|
||||||
|
const userOrgRole = await db
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.userId, userId),
|
||||||
|
eq(
|
||||||
|
userOrgs.orgId,
|
||||||
|
row.siteProvisioningKeyOrg.orgId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
req.userOrg = userOrgRole[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.userOrg) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not have access to this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) {
|
||||||
|
const policyCheck = await checkOrgAccessPolicy({
|
||||||
|
orgId: req.userOrg.orgId,
|
||||||
|
userId,
|
||||||
|
session: req.session
|
||||||
|
});
|
||||||
|
req.orgPolicyAllowed = policyCheck.allowed;
|
||||||
|
if (!policyCheck.allowed || policyCheck.error) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Failed organization access policy check: " +
|
||||||
|
(policyCheck.error || "Unknown error")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userOrgRoleId = req.userOrg.roleId;
|
||||||
|
req.userOrgRoleId = userOrgRoleId;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Error verifying site provisioning key access"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import * as accessToken from "./accessToken";
|
|||||||
import * as idp from "./idp";
|
import * as idp from "./idp";
|
||||||
import * as blueprints from "./blueprints";
|
import * as blueprints from "./blueprints";
|
||||||
import * as apiKeys from "./apiKeys";
|
import * as apiKeys from "./apiKeys";
|
||||||
|
import * as siteProvisioning from "./siteProvisioning";
|
||||||
import * as logs from "./auditLogs";
|
import * as logs from "./auditLogs";
|
||||||
import * as newt from "./newt";
|
import * as newt from "./newt";
|
||||||
import * as olm from "./olm";
|
import * as olm from "./olm";
|
||||||
@@ -42,7 +43,8 @@ import {
|
|||||||
verifyUserIsOrgOwner,
|
verifyUserIsOrgOwner,
|
||||||
verifySiteResourceAccess,
|
verifySiteResourceAccess,
|
||||||
verifyOlmAccess,
|
verifyOlmAccess,
|
||||||
verifyLimits
|
verifyLimits,
|
||||||
|
verifySiteProvisioningKeyAccess
|
||||||
} from "@server/middlewares";
|
} from "@server/middlewares";
|
||||||
import { ActionsEnum } from "@server/auth/actions";
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
||||||
@@ -993,6 +995,31 @@ authenticated.get(
|
|||||||
apiKeys.listRootApiKeys
|
apiKeys.listRootApiKeys
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
`/org/:orgId/site-provisioning-key`,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
|
verifyUserHasAction(ActionsEnum.createSiteProvisioningKey),
|
||||||
|
logActionAudit(ActionsEnum.createSiteProvisioningKey),
|
||||||
|
siteProvisioning.createSiteProvisioningKey
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
`/org/:orgId/site-provisioning-keys`,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listSiteProvisioningKeys),
|
||||||
|
siteProvisioning.listSiteProvisioningKeys
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
`/org/:orgId/site-provisioning-key/:siteProvisioningKeyId`,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifySiteProvisioningKeyAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.deleteSiteProvisioningKey),
|
||||||
|
logActionAudit(ActionsEnum.deleteSiteProvisioningKey),
|
||||||
|
siteProvisioning.deleteSiteProvisioningKey
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
`/api-key/:apiKeyId/actions`,
|
`/api-key/:apiKeyId/actions`,
|
||||||
verifyUserIsServerAdmin,
|
verifyUserIsServerAdmin,
|
||||||
|
|||||||
108
server/routers/siteProvisioning/createSiteProvisioningKey.ts
Normal file
108
server/routers/siteProvisioning/createSiteProvisioningKey.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import moment from "moment";
|
||||||
|
import {
|
||||||
|
generateId,
|
||||||
|
generateIdFromEntropySize
|
||||||
|
} from "@server/auth/sessions/app";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
|
||||||
|
const paramsSchema = z.object({
|
||||||
|
orgId: z.string().nonempty()
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodySchema = z.strictObject({
|
||||||
|
name: z.string().min(1).max(255)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateSiteProvisioningKeyBody = z.infer<typeof bodySchema>;
|
||||||
|
|
||||||
|
export type CreateSiteProvisioningKeyResponse = {
|
||||||
|
siteProvisioningKeyId: string;
|
||||||
|
orgId: string;
|
||||||
|
name: string;
|
||||||
|
siteProvisioningKey: string;
|
||||||
|
lastChars: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createSiteProvisioningKey(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId } = parsedParams.data;
|
||||||
|
const { name } = parsedBody.data;
|
||||||
|
|
||||||
|
const siteProvisioningKeyId = `spk-${generateId(15)}`;
|
||||||
|
const siteProvisioningKey = generateIdFromEntropySize(25);
|
||||||
|
const siteProvisioningKeyHash = await hashPassword(siteProvisioningKey);
|
||||||
|
const lastChars = siteProvisioningKey.slice(-4);
|
||||||
|
const createdAt = moment().toISOString();
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
await trx.insert(siteProvisioningKeys).values({
|
||||||
|
siteProvisioningKeyId,
|
||||||
|
name,
|
||||||
|
siteProvisioningKeyHash,
|
||||||
|
createdAt,
|
||||||
|
lastChars
|
||||||
|
});
|
||||||
|
|
||||||
|
await trx.insert(siteProvisioningKeyOrg).values({
|
||||||
|
siteProvisioningKeyId,
|
||||||
|
orgId
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return response<CreateSiteProvisioningKeyResponse>(res, {
|
||||||
|
data: {
|
||||||
|
siteProvisioningKeyId,
|
||||||
|
orgId,
|
||||||
|
name,
|
||||||
|
siteProvisioningKey,
|
||||||
|
lastChars,
|
||||||
|
createdAt
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Site provisioning key created",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to create site provisioning key"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
116
server/routers/siteProvisioning/deleteSiteProvisioningKey.ts
Normal file
116
server/routers/siteProvisioning/deleteSiteProvisioningKey.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
siteProvisioningKeyOrg,
|
||||||
|
siteProvisioningKeys
|
||||||
|
} from "@server/db";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const paramsSchema = z.object({
|
||||||
|
siteProvisioningKeyId: z.string().nonempty(),
|
||||||
|
orgId: z.string().nonempty()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function deleteSiteProvisioningKey(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { siteProvisioningKeyId, orgId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(siteProvisioningKeys)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
siteProvisioningKeys.siteProvisioningKeyId,
|
||||||
|
siteProvisioningKeyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.innerJoin(
|
||||||
|
siteProvisioningKeyOrg,
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
siteProvisioningKeys.siteProvisioningKeyId,
|
||||||
|
siteProvisioningKeyOrg.siteProvisioningKeyId
|
||||||
|
),
|
||||||
|
eq(siteProvisioningKeyOrg.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Site provisioning key with ID ${siteProvisioningKeyId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
await trx
|
||||||
|
.delete(siteProvisioningKeyOrg)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
siteProvisioningKeyOrg.siteProvisioningKeyId,
|
||||||
|
siteProvisioningKeyId
|
||||||
|
),
|
||||||
|
eq(siteProvisioningKeyOrg.orgId, orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const siteProvisioningKeyOrgs = await trx
|
||||||
|
.select()
|
||||||
|
.from(siteProvisioningKeyOrg)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
siteProvisioningKeyOrg.siteProvisioningKeyId,
|
||||||
|
siteProvisioningKeyId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (siteProvisioningKeyOrgs.length === 0) {
|
||||||
|
await trx
|
||||||
|
.delete(siteProvisioningKeys)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
siteProvisioningKeys.siteProvisioningKeyId,
|
||||||
|
siteProvisioningKeyId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Site provisioning key deleted successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
server/routers/siteProvisioning/index.ts
Normal file
3
server/routers/siteProvisioning/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./createSiteProvisioningKey";
|
||||||
|
export * from "./listSiteProvisioningKeys";
|
||||||
|
export * from "./deleteSiteProvisioningKey";
|
||||||
115
server/routers/siteProvisioning/listSiteProvisioningKeys.ts
Normal file
115
server/routers/siteProvisioning/listSiteProvisioningKeys.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import {
|
||||||
|
db,
|
||||||
|
siteProvisioningKeyOrg,
|
||||||
|
siteProvisioningKeys
|
||||||
|
} from "@server/db";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
const paramsSchema = z.object({
|
||||||
|
orgId: z.string().nonempty()
|
||||||
|
});
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
limit: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("1000")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.int().positive()),
|
||||||
|
offset: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("0")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.int().nonnegative())
|
||||||
|
});
|
||||||
|
|
||||||
|
function querySiteProvisioningKeys(orgId: string) {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
siteProvisioningKeyId:
|
||||||
|
siteProvisioningKeys.siteProvisioningKeyId,
|
||||||
|
orgId: siteProvisioningKeyOrg.orgId,
|
||||||
|
lastChars: siteProvisioningKeys.lastChars,
|
||||||
|
createdAt: siteProvisioningKeys.createdAt,
|
||||||
|
name: siteProvisioningKeys.name
|
||||||
|
})
|
||||||
|
.from(siteProvisioningKeyOrg)
|
||||||
|
.innerJoin(
|
||||||
|
siteProvisioningKeys,
|
||||||
|
eq(
|
||||||
|
siteProvisioningKeys.siteProvisioningKeyId,
|
||||||
|
siteProvisioningKeyOrg.siteProvisioningKeyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(eq(siteProvisioningKeyOrg.orgId, orgId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListSiteProvisioningKeysResponse = {
|
||||||
|
siteProvisioningKeys: Awaited<
|
||||||
|
ReturnType<typeof querySiteProvisioningKeys>
|
||||||
|
>;
|
||||||
|
pagination: { total: number; limit: number; offset: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listSiteProvisioningKeys(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedQuery = querySchema.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedQuery.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId } = parsedParams.data;
|
||||||
|
const { limit, offset } = parsedQuery.data;
|
||||||
|
|
||||||
|
const siteProvisioningKeysList = await querySiteProvisioningKeys(orgId)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return response<ListSiteProvisioningKeysResponse>(res, {
|
||||||
|
data: {
|
||||||
|
siteProvisioningKeys: siteProvisioningKeysList,
|
||||||
|
pagination: {
|
||||||
|
total: siteProvisioningKeysList.length,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Site provisioning keys retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/app/[orgId]/settings/provisioning/create/page.tsx
Normal file
10
src/app/[orgId]/settings/provisioning/create/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
params: Promise<{ orgId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ProvisioningCreateRedirect(props: PageProps) {
|
||||||
|
const params = await props.params;
|
||||||
|
redirect(`/${params.orgId}/settings/provisioning`);
|
||||||
|
}
|
||||||
50
src/app/[orgId]/settings/provisioning/page.tsx
Normal file
50
src/app/[orgId]/settings/provisioning/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { internal } from "@app/lib/api";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import SiteProvisioningKeysTable, {
|
||||||
|
SiteProvisioningKeyRow
|
||||||
|
} from "../../../../components/SiteProvisioningKeysTable";
|
||||||
|
import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/listSiteProvisioningKeys";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
|
type ProvisioningPageProps = {
|
||||||
|
params: Promise<{ orgId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function ProvisioningPage(props: ProvisioningPageProps) {
|
||||||
|
const params = await props.params;
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
let siteProvisioningKeys: ListSiteProvisioningKeysResponse["siteProvisioningKeys"] =
|
||||||
|
[];
|
||||||
|
try {
|
||||||
|
const res = await internal.get<
|
||||||
|
AxiosResponse<ListSiteProvisioningKeysResponse>
|
||||||
|
>(
|
||||||
|
`/org/${params.orgId}/site-provisioning-keys`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
siteProvisioningKeys = res.data.data.siteProvisioningKeys;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
const rows: SiteProvisioningKeyRow[] = siteProvisioningKeys.map((k) => ({
|
||||||
|
name: k.name,
|
||||||
|
id: k.siteProvisioningKeyId,
|
||||||
|
key: `${k.siteProvisioningKeyId}••••••••••••••••••••${k.lastChars}`,
|
||||||
|
createdAt: k.createdAt
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title={t("provisioningKeysManage")}
|
||||||
|
description={t("provisioningKeysDescription")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SiteProvisioningKeysTable keys={rows} orgId={params.orgId} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { SidebarNavItem } from "@app/components/SidebarNav";
|
|||||||
import { Env } from "@app/lib/types/env";
|
import { Env } from "@app/lib/types/env";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import {
|
import {
|
||||||
|
Boxes,
|
||||||
Building2,
|
Building2,
|
||||||
Cable,
|
Cable,
|
||||||
ChartLine,
|
ChartLine,
|
||||||
@@ -209,6 +210,11 @@ export const orgNavSections = (
|
|||||||
href: "/{orgId}/settings/api-keys",
|
href: "/{orgId}/settings/api-keys",
|
||||||
icon: <KeyRound className="size-4 flex-none" />
|
icon: <KeyRound className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "sidebarProvisioning",
|
||||||
|
href: "/{orgId}/settings/provisioning",
|
||||||
|
icon: <Boxes className="size-4 flex-none" />
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "sidebarBluePrints",
|
title: "sidebarBluePrints",
|
||||||
href: "/{orgId}/settings/blueprints",
|
href: "/{orgId}/settings/blueprints",
|
||||||
|
|||||||
195
src/components/CreateSiteProvisioningKeyCredenza.tsx
Normal file
195
src/components/CreateSiteProvisioningKeyCredenza.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Credenza,
|
||||||
|
CredenzaBody,
|
||||||
|
CredenzaClose,
|
||||||
|
CredenzaContent,
|
||||||
|
CredenzaDescription,
|
||||||
|
CredenzaFooter,
|
||||||
|
CredenzaHeader,
|
||||||
|
CredenzaTitle
|
||||||
|
} from "@app/components/Credenza";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { CreateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/createSiteProvisioningKey";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { InfoIcon } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
|
|
||||||
|
const FORM_ID = "create-site-provisioning-key-form";
|
||||||
|
|
||||||
|
type CreateSiteProvisioningKeyCredenzaProps = {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
orgId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CreateSiteProvisioningKeyCredenza({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
orgId
|
||||||
|
}: CreateSiteProvisioningKeyCredenzaProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const router = useRouter();
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [created, setCreated] =
|
||||||
|
useState<CreateSiteProvisioningKeyResponse | null>(null);
|
||||||
|
|
||||||
|
const createFormSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, {
|
||||||
|
message: t("nameMin", { len: 1 })
|
||||||
|
})
|
||||||
|
.max(255, {
|
||||||
|
message: t("nameMax", { len: 255 })
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
type CreateFormValues = z.infer<typeof createFormSchema>;
|
||||||
|
|
||||||
|
const form = useForm<CreateFormValues>({
|
||||||
|
resolver: zodResolver(createFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setCreated(null);
|
||||||
|
form.reset({ name: "" });
|
||||||
|
}
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
|
async function onSubmit(data: CreateFormValues) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api
|
||||||
|
.put<
|
||||||
|
AxiosResponse<CreateSiteProvisioningKeyResponse>
|
||||||
|
>(`/org/${orgId}/site-provisioning-key`, { name: data.name })
|
||||||
|
.catch((e) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("provisioningKeysErrorCreate"),
|
||||||
|
description: formatAxiosError(e)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.status === 201) {
|
||||||
|
setCreated(res.data.data);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential =
|
||||||
|
created &&
|
||||||
|
`${created.siteProvisioningKeyId}.${created.siteProvisioningKey}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Credenza open={open} onOpenChange={setOpen}>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>
|
||||||
|
{created
|
||||||
|
? t("provisioningKeysList")
|
||||||
|
: t("provisioningKeysCreate")}
|
||||||
|
</CredenzaTitle>
|
||||||
|
{!created && (
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("provisioningKeysCreateDescription")}
|
||||||
|
</CredenzaDescription>
|
||||||
|
)}
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
{!created && (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id={FORM_ID}
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("name")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{created && credential && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert variant="neutral">
|
||||||
|
<InfoIcon className="h-4 w-4" />
|
||||||
|
<AlertTitle className="font-semibold">
|
||||||
|
{t("provisioningKeysSave")}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{t("provisioningKeysSaveDescription")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<CopyTextBox text={credential} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
{!created ? (
|
||||||
|
<>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">{t("close")}</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form={FORM_ID}
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{t("generate")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="default">{t("done")}</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
)}
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
src/components/SiteProvisioningKeysTable.tsx
Normal file
216
src/components/SiteProvisioningKeysTable.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
ExtendedColumnDef
|
||||||
|
} from "@app/components/ui/data-table";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@app/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import CreateSiteProvisioningKeyCredenza from "@app/components/CreateSiteProvisioningKeyCredenza";
|
||||||
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import moment from "moment";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export type SiteProvisioningKeyRow = {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SiteProvisioningKeysTableProps = {
|
||||||
|
keys: SiteProvisioningKeyRow[];
|
||||||
|
orgId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SiteProvisioningKeysTable({
|
||||||
|
keys,
|
||||||
|
orgId
|
||||||
|
}: SiteProvisioningKeysTableProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<SiteProvisioningKeyRow | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [rows, setRows] = useState<SiteProvisioningKeyRow[]>(keys);
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
const t = useTranslations();
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRows(keys);
|
||||||
|
}, [keys]);
|
||||||
|
|
||||||
|
const refreshData = async () => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: t("refreshError"),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteKey = async (siteProvisioningKeyId: string) => {
|
||||||
|
try {
|
||||||
|
await api.delete(
|
||||||
|
`/org/${orgId}/site-provisioning-key/${siteProvisioningKeyId}`
|
||||||
|
);
|
||||||
|
router.refresh();
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setSelected(null);
|
||||||
|
setRows((prev) => prev.filter((row) => row.id !== siteProvisioningKeyId));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(t("provisioningKeysErrorDelete"), e);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("provisioningKeysErrorDelete"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
t("provisioningKeysErrorDeleteMessage")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ExtendedColumnDef<SiteProvisioningKeyRow>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
enableHiding: false,
|
||||||
|
friendlyName: t("name"),
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("name")}
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "key",
|
||||||
|
friendlyName: t("key"),
|
||||||
|
header: () => <span className="p-3">{t("key")}</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
return <span className="font-mono">{r.key}</span>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
friendlyName: t("createdAt"),
|
||||||
|
header: () => <span className="p-3">{t("createdAt")}</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
return <span>{moment(r.createdAt).format("lll")}</span>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
enableHiding: false,
|
||||||
|
header: () => <span className="p-3"></span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">
|
||||||
|
{t("openMenu")}
|
||||||
|
</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setSelected(r);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-red-500">
|
||||||
|
{t("delete")}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CreateSiteProvisioningKeyCredenza
|
||||||
|
open={createOpen}
|
||||||
|
setOpen={setCreateOpen}
|
||||||
|
orgId={orgId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
setOpen={(val) => {
|
||||||
|
setIsDeleteModalOpen(val);
|
||||||
|
if (!val) {
|
||||||
|
setSelected(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
dialog={
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>{t("provisioningKeysQuestionRemove")}</p>
|
||||||
|
<p>{t("provisioningKeysMessageRemove")}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
buttonText={t("provisioningKeysDeleteConfirm")}
|
||||||
|
onConfirm={async () => deleteKey(selected.id)}
|
||||||
|
string={selected.name}
|
||||||
|
title={t("provisioningKeysDelete")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
persistPageSize="Org-provisioning-keys-table"
|
||||||
|
title={t("provisioningKeys")}
|
||||||
|
searchPlaceholder={t("searchProvisioningKeys")}
|
||||||
|
searchColumn="name"
|
||||||
|
onAdd={() => setCreateOpen(true)}
|
||||||
|
onRefresh={refreshData}
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
addButtonText={t("provisioningKeysAdd")}
|
||||||
|
enableColumnVisibility={true}
|
||||||
|
stickyLeftColumn="name"
|
||||||
|
stickyRightColumn="actions"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user