diff --git a/messages/en-US.json b/messages/en-US.json index 1785f0491..a7d16f30f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -342,6 +342,22 @@ "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", + "provisioningKeysMaxBatchSize": "Max batch size", + "provisioningKeysUnlimitedBatchSize": "Unlimited batch size (no limit)", + "provisioningKeysMaxBatchUnlimited": "Unlimited", + "provisioningKeysMaxBatchSizeInvalid": "Enter a valid max batch size (1–1,000,000).", + "provisioningKeysValidUntil": "Valid until", + "provisioningKeysValidUntilHint": "Leave empty for no expiration.", + "provisioningKeysValidUntilInvalid": "Enter a valid date and time.", + "provisioningKeysNumUsed": "Times used", + "provisioningKeysLastUsed": "Last used", + "provisioningKeysNoExpiry": "No expiration", + "provisioningKeysNeverUsed": "Never", + "provisioningKeysEdit": "Edit Provisioning Key", + "provisioningKeysEditDescription": "Update the max batch size and expiration time for this key.", + "provisioningKeysUpdateError": "Error updating provisioning key", + "provisioningKeysUpdated": "Provisioning key updated", + "provisioningKeysUpdatedDescription": "Your changes have been saved.", "apiKeysSettings": "{apiKeyName} Settings", "userTitle": "Manage All Users", "userDescription": "View and manage all users in the system", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 6cdc4fa0a..20f1fe795 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -111,6 +111,7 @@ export enum ActionsEnum { getApiKey = "getApiKey", createSiteProvisioningKey = "createSiteProvisioningKey", listSiteProvisioningKeys = "listSiteProvisioningKeys", + updateSiteProvisioningKey = "updateSiteProvisioningKey", deleteSiteProvisioningKey = "deleteSiteProvisioningKey", getCertificate = "getCertificate", restartCertificate = "restartCertificate", diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index b1dc98253..bb1e866c4 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -387,7 +387,11 @@ export const siteProvisioningKeys = pgTable("siteProvisioningKeys", { name: varchar("name", { length: 255 }).notNull(), siteProvisioningKeyHash: text("siteProvisioningKeyHash").notNull(), lastChars: varchar("lastChars", { length: 4 }).notNull(), - createdAt: varchar("dateCreated", { length: 255 }).notNull() + createdAt: varchar("dateCreated", { length: 255 }).notNull(), + lastUsed: varchar("lastUsed", { length: 255 }), + maxBatchSize: integer("maxBatchSize"), // null = no limit + numUsed: integer("numUsed").notNull().default(0), + validUntil: varchar("validUntil", { length: 255 }) }); export const siteProvisioningKeyOrg = pgTable( diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index e78343d6d..5913497b3 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -371,7 +371,11 @@ export const siteProvisioningKeys = sqliteTable("siteProvisioningKeys", { name: text("name").notNull(), siteProvisioningKeyHash: text("siteProvisioningKeyHash").notNull(), lastChars: text("lastChars").notNull(), - createdAt: text("dateCreated").notNull() + createdAt: text("dateCreated").notNull(), + lastUsed: text("lastUsed"), + maxBatchSize: integer("maxBatchSize"), // null = no limit + numUsed: integer("numUsed").notNull().default(0), + validUntil: text("validUntil") }); export const siteProvisioningKeyOrg = sqliteTable( diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index f8a0cd2f5..8c41be5bf 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -16,7 +16,8 @@ export enum TierFeature { SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning - SshPam = "sshPam" + SshPam = "sshPam", + SiteProvisioningKeys = "siteProvisioningKeys" // handle downgrade by revoking keys if needed } export const tierMatrix: Record = { @@ -50,5 +51,6 @@ export const tierMatrix: Record = { "enterprise" ], [TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"], - [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"] + [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"], + [TierFeature.SiteProvisioningKeys]: ["enterprise"] }; diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts index 9536a87f0..32e81784b 100644 --- a/server/private/routers/billing/featureLifecycle.ts +++ b/server/private/routers/billing/featureLifecycle.ts @@ -26,9 +26,11 @@ import { orgs, resources, roles, + siteProvisioningKeyOrg, + siteProvisioningKeys, siteResources } from "@server/db"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; /** * Get the maximum allowed retention days for a given tier @@ -291,6 +293,10 @@ async function disableFeature( await disableSshPam(orgId); break; + case TierFeature.SiteProvisioningKeys: + await disableSiteProvisioningKeys(orgId); + break; + default: logger.warn( `Unknown feature ${feature} for org ${orgId}, skipping` @@ -326,6 +332,57 @@ async function disableSshPam(orgId: string): Promise { ); } +async function disableSiteProvisioningKeys(orgId: string): Promise { + const rows = await db + .select({ + siteProvisioningKeyId: + siteProvisioningKeyOrg.siteProvisioningKeyId + }) + .from(siteProvisioningKeyOrg) + .where(eq(siteProvisioningKeyOrg.orgId, orgId)); + + for (const { siteProvisioningKeyId } of rows) { + await db.transaction(async (trx) => { + await trx + .delete(siteProvisioningKeyOrg) + .where( + and( + eq( + siteProvisioningKeyOrg.siteProvisioningKeyId, + siteProvisioningKeyId + ), + eq(siteProvisioningKeyOrg.orgId, orgId) + ) + ); + + const remaining = await trx + .select() + .from(siteProvisioningKeyOrg) + .where( + eq( + siteProvisioningKeyOrg.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ); + + if (remaining.length === 0) { + await trx + .delete(siteProvisioningKeys) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ); + } + }); + } + + logger.info( + `Removed site provisioning keys for org ${orgId} after tier downgrade` + ); +} + async function disableLoginPageBranding(orgId: string): Promise { const [existingBranding] = await db .select() diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index f06ad4517..6cac25e20 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -26,6 +26,7 @@ import * as misc from "#private/routers/misc"; import * as reKey from "#private/routers/re-key"; import * as approval from "#private/routers/approvals"; import * as ssh from "#private/routers/ssh"; +import * as siteProvisioning from "#private/routers/siteProvisioning"; import { verifyOrgAccess, @@ -33,7 +34,8 @@ import { verifyUserIsServerAdmin, verifySiteAccess, verifyClientAccess, - verifyLimits + verifyLimits, + verifySiteProvisioningKeyAccess } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import { @@ -537,3 +539,45 @@ authenticated.post( // logActionAudit(ActionsEnum.signSshKey), // it is handled inside of the function below so we can include more metadata ssh.signSshKey ); + +authenticated.put( + "/org/:orgId/site-provisioning-key", + verifyValidLicense, + verifyValidSubscription(tierMatrix.siteProvisioningKeys), + verifyOrgAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.createSiteProvisioningKey), + logActionAudit(ActionsEnum.createSiteProvisioningKey), + siteProvisioning.createSiteProvisioningKey +); + +authenticated.get( + "/org/:orgId/site-provisioning-keys", + verifyValidLicense, + verifyValidSubscription(tierMatrix.siteProvisioningKeys), + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listSiteProvisioningKeys), + siteProvisioning.listSiteProvisioningKeys +); + +authenticated.delete( + "/org/:orgId/site-provisioning-key/:siteProvisioningKeyId", + verifyValidLicense, + verifyValidSubscription(tierMatrix.siteProvisioningKeys), + verifyOrgAccess, + verifySiteProvisioningKeyAccess, + verifyUserHasAction(ActionsEnum.deleteSiteProvisioningKey), + logActionAudit(ActionsEnum.deleteSiteProvisioningKey), + siteProvisioning.deleteSiteProvisioningKey +); + +authenticated.patch( + "/org/:orgId/site-provisioning-key/:siteProvisioningKeyId", + verifyValidLicense, + verifyValidSubscription(tierMatrix.siteProvisioningKeys), + verifyOrgAccess, + verifySiteProvisioningKeyAccess, + verifyUserHasAction(ActionsEnum.updateSiteProvisioningKey), + logActionAudit(ActionsEnum.updateSiteProvisioningKey), + siteProvisioning.updateSiteProvisioningKey +); diff --git a/server/routers/siteProvisioning/createSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts similarity index 62% rename from server/routers/siteProvisioning/createSiteProvisioningKey.ts rename to server/private/routers/siteProvisioning/createSiteProvisioningKey.ts index 9bb298966..45d980810 100644 --- a/server/routers/siteProvisioning/createSiteProvisioningKey.ts +++ b/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts @@ -1,3 +1,16 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + import { NextFunction, Request, Response } from "express"; import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db"; import HttpCode from "@server/types/HttpCode"; @@ -12,26 +25,37 @@ import { } from "@server/auth/sessions/app"; import logger from "@server/logger"; import { hashPassword } from "@server/auth/password"; +import type { CreateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types"; const paramsSchema = z.object({ orgId: z.string().nonempty() }); -const bodySchema = z.strictObject({ - name: z.string().min(1).max(255) -}); +const bodySchema = z + .strictObject({ + name: z.string().min(1).max(255), + maxBatchSize: z.union([ + z.null(), + z.coerce.number().int().positive().max(1_000_000) + ]), + validUntil: z.string().max(255).optional() + }) + .superRefine((data, ctx) => { + const v = data.validUntil; + if (v == null || v.trim() === "") { + return; + } + if (Number.isNaN(Date.parse(v))) { + ctx.addIssue({ + code: "custom", + message: "Invalid validUntil", + path: ["validUntil"] + }); + } + }); export type CreateSiteProvisioningKeyBody = z.infer; -export type CreateSiteProvisioningKeyResponse = { - siteProvisioningKeyId: string; - orgId: string; - name: string; - siteProvisioningKey: string; - lastChars: string; - createdAt: string; -}; - export async function createSiteProvisioningKey( req: Request, res: Response, @@ -58,7 +82,12 @@ export async function createSiteProvisioningKey( } const { orgId } = parsedParams.data; - const { name } = parsedBody.data; + const { name, maxBatchSize } = parsedBody.data; + const vuRaw = parsedBody.data.validUntil; + const validUntil = + vuRaw == null || vuRaw.trim() === "" + ? null + : new Date(Date.parse(vuRaw)).toISOString(); const siteProvisioningKeyId = `spk-${generateId(15)}`; const siteProvisioningKey = generateIdFromEntropySize(25); @@ -72,7 +101,11 @@ export async function createSiteProvisioningKey( name, siteProvisioningKeyHash, createdAt, - lastChars + lastChars, + lastUsed: null, + maxBatchSize, + numUsed: 0, + validUntil }); await trx.insert(siteProvisioningKeyOrg).values({ @@ -89,7 +122,11 @@ export async function createSiteProvisioningKey( name, siteProvisioningKey, lastChars, - createdAt + createdAt, + lastUsed: null, + maxBatchSize, + numUsed: 0, + validUntil }, success: true, error: false, diff --git a/server/routers/siteProvisioning/deleteSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts similarity index 90% rename from server/routers/siteProvisioning/deleteSiteProvisioningKey.ts rename to server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts index d1da01d97..fc8b05e60 100644 --- a/server/routers/siteProvisioning/deleteSiteProvisioningKey.ts +++ b/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts @@ -1,3 +1,16 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { diff --git a/server/private/routers/siteProvisioning/index.ts b/server/private/routers/siteProvisioning/index.ts new file mode 100644 index 000000000..d143274f6 --- /dev/null +++ b/server/private/routers/siteProvisioning/index.ts @@ -0,0 +1,17 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./createSiteProvisioningKey"; +export * from "./listSiteProvisioningKeys"; +export * from "./deleteSiteProvisioningKey"; +export * from "./updateSiteProvisioningKey"; diff --git a/server/routers/siteProvisioning/listSiteProvisioningKeys.ts b/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts similarity index 80% rename from server/routers/siteProvisioning/listSiteProvisioningKeys.ts rename to server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts index 65360625c..5f7531a2c 100644 --- a/server/routers/siteProvisioning/listSiteProvisioningKeys.ts +++ b/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts @@ -1,3 +1,16 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + import { db, siteProvisioningKeyOrg, @@ -11,6 +24,7 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { eq } from "drizzle-orm"; +import type { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types"; const paramsSchema = z.object({ orgId: z.string().nonempty() @@ -39,7 +53,11 @@ function querySiteProvisioningKeys(orgId: string) { orgId: siteProvisioningKeyOrg.orgId, lastChars: siteProvisioningKeys.lastChars, createdAt: siteProvisioningKeys.createdAt, - name: siteProvisioningKeys.name + name: siteProvisioningKeys.name, + lastUsed: siteProvisioningKeys.lastUsed, + maxBatchSize: siteProvisioningKeys.maxBatchSize, + numUsed: siteProvisioningKeys.numUsed, + validUntil: siteProvisioningKeys.validUntil }) .from(siteProvisioningKeyOrg) .innerJoin( @@ -52,13 +70,6 @@ function querySiteProvisioningKeys(orgId: string) { .where(eq(siteProvisioningKeyOrg.orgId, orgId)); } -export type ListSiteProvisioningKeysResponse = { - siteProvisioningKeys: Awaited< - ReturnType - >; - pagination: { total: number; limit: number; offset: number }; -}; - export async function listSiteProvisioningKeys( req: Request, res: Response, diff --git a/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts new file mode 100644 index 000000000..526d8bfb8 --- /dev/null +++ b/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts @@ -0,0 +1,199 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +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"; +import type { UpdateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types"; + +const paramsSchema = z.object({ + siteProvisioningKeyId: z.string().nonempty(), + orgId: z.string().nonempty() +}); + +const bodySchema = z + .strictObject({ + maxBatchSize: z + .union([ + z.null(), + z.coerce.number().int().positive().max(1_000_000) + ]) + .optional(), + validUntil: z.string().max(255).optional() + }) + .superRefine((data, ctx) => { + if ( + data.maxBatchSize === undefined && + data.validUntil === undefined + ) { + ctx.addIssue({ + code: "custom", + message: "Provide maxBatchSize and/or validUntil", + path: ["maxBatchSize"] + }); + } + const v = data.validUntil; + if (v == null || v.trim() === "") { + return; + } + if (Number.isNaN(Date.parse(v))) { + ctx.addIssue({ + code: "custom", + message: "Invalid validUntil", + path: ["validUntil"] + }); + } + }); + +export type UpdateSiteProvisioningKeyBody = z.infer; + +export async function updateSiteProvisioningKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + 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 { siteProvisioningKeyId, orgId } = parsedParams.data; + const body = parsedBody.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` + ) + ); + } + + const setValues: { + maxBatchSize?: number | null; + validUntil?: string | null; + } = {}; + if (body.maxBatchSize !== undefined) { + setValues.maxBatchSize = body.maxBatchSize; + } + if (body.validUntil !== undefined) { + setValues.validUntil = + body.validUntil.trim() === "" + ? null + : new Date(Date.parse(body.validUntil)).toISOString(); + } + + await db + .update(siteProvisioningKeys) + .set(setValues) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ); + + const [updated] = await db + .select({ + siteProvisioningKeyId: + siteProvisioningKeys.siteProvisioningKeyId, + name: siteProvisioningKeys.name, + lastChars: siteProvisioningKeys.lastChars, + createdAt: siteProvisioningKeys.createdAt, + lastUsed: siteProvisioningKeys.lastUsed, + maxBatchSize: siteProvisioningKeys.maxBatchSize, + numUsed: siteProvisioningKeys.numUsed, + validUntil: siteProvisioningKeys.validUntil + }) + .from(siteProvisioningKeys) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ) + .limit(1); + + if (!updated) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to load updated site provisioning key" + ) + ); + } + + return response(res, { + data: { + ...updated, + orgId + }, + success: true, + error: false, + message: "Site provisioning key updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 90f208863..45ab58bba 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -15,7 +15,6 @@ import * as accessToken from "./accessToken"; import * as idp from "./idp"; import * as blueprints from "./blueprints"; import * as apiKeys from "./apiKeys"; -import * as siteProvisioning from "./siteProvisioning"; import * as logs from "./auditLogs"; import * as newt from "./newt"; import * as olm from "./olm"; @@ -43,8 +42,7 @@ import { verifyUserIsOrgOwner, verifySiteResourceAccess, verifyOlmAccess, - verifyLimits, - verifySiteProvisioningKeyAccess + verifyLimits } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; @@ -988,31 +986,6 @@ authenticated.get( 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( `/api-key/:apiKeyId/actions`, verifyUserIsServerAdmin, diff --git a/server/routers/siteProvisioning/index.ts b/server/routers/siteProvisioning/index.ts deleted file mode 100644 index b3f69f100..000000000 --- a/server/routers/siteProvisioning/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./createSiteProvisioningKey"; -export * from "./listSiteProvisioningKeys"; -export * from "./deleteSiteProvisioningKey"; diff --git a/server/routers/siteProvisioning/types.ts b/server/routers/siteProvisioning/types.ts new file mode 100644 index 000000000..d06c1fe26 --- /dev/null +++ b/server/routers/siteProvisioning/types.ts @@ -0,0 +1,41 @@ +export type SiteProvisioningKeyListItem = { + siteProvisioningKeyId: string; + orgId: string; + lastChars: string; + createdAt: string; + name: string; + lastUsed: string | null; + maxBatchSize: number | null; + numUsed: number; + validUntil: string | null; +}; + +export type ListSiteProvisioningKeysResponse = { + siteProvisioningKeys: SiteProvisioningKeyListItem[]; + pagination: { total: number; limit: number; offset: number }; +}; + +export type CreateSiteProvisioningKeyResponse = { + siteProvisioningKeyId: string; + orgId: string; + name: string; + siteProvisioningKey: string; + lastChars: string; + createdAt: string; + lastUsed: string | null; + maxBatchSize: number | null; + numUsed: number; + validUntil: string | null; +}; + +export type UpdateSiteProvisioningKeyResponse = { + siteProvisioningKeyId: string; + orgId: string; + name: string; + lastChars: string; + createdAt: string; + lastUsed: string | null; + maxBatchSize: number | null; + numUsed: number; + validUntil: string | null; +}; diff --git a/src/app/[orgId]/settings/provisioning/create/page.tsx b/src/app/[orgId]/settings/provisioning/create/page.tsx deleted file mode 100644 index 98573147a..000000000 --- a/src/app/[orgId]/settings/provisioning/create/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -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`); -} diff --git a/src/app/[orgId]/settings/provisioning/page.tsx b/src/app/[orgId]/settings/provisioning/page.tsx index f8a30b86f..e8b53104f 100644 --- a/src/app/[orgId]/settings/provisioning/page.tsx +++ b/src/app/[orgId]/settings/provisioning/page.tsx @@ -1,12 +1,14 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { AxiosResponse } from "axios"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SiteProvisioningKeysTable, { SiteProvisioningKeyRow } from "../../../../components/SiteProvisioningKeysTable"; -import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/listSiteProvisioningKeys"; +import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types"; import { getTranslations } from "next-intl/server"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; type ProvisioningPageProps = { params: Promise<{ orgId: string }>; @@ -34,7 +36,11 @@ export default async function ProvisioningPage(props: ProvisioningPageProps) { name: k.name, id: k.siteProvisioningKeyId, key: `${k.siteProvisioningKeyId}••••••••••••••••••••${k.lastChars}`, - createdAt: k.createdAt + createdAt: k.createdAt, + lastUsed: k.lastUsed, + maxBatchSize: k.maxBatchSize, + numUsed: k.numUsed, + validUntil: k.validUntil })); return ( @@ -44,6 +50,10 @@ export default async function ProvisioningPage(props: ProvisioningPageProps) { description={t("provisioningKeysDescription")} /> + + ); diff --git a/src/components/CreateSiteProvisioningKeyCredenza.tsx b/src/components/CreateSiteProvisioningKeyCredenza.tsx index 456731ed6..70c48ff08 100644 --- a/src/components/CreateSiteProvisioningKeyCredenza.tsx +++ b/src/components/CreateSiteProvisioningKeyCredenza.tsx @@ -13,18 +13,20 @@ import { import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { Button } from "@app/components/ui/button"; +import { Checkbox } from "@app/components/ui/checkbox"; 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 { CreateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types"; import { AxiosResponse } from "axios"; import { InfoIcon } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -55,30 +57,61 @@ export default function CreateSiteProvisioningKeyCredenza({ const [created, setCreated] = useState(null); - const createFormSchema = z.object({ - name: z - .string() - .min(1, { - message: t("nameMin", { len: 1 }) - }) - .max(255, { - message: t("nameMax", { len: 255 }) - }) - }); + const createFormSchema = z + .object({ + name: z + .string() + .min(1, { + message: t("nameMin", { len: 1 }) + }) + .max(255, { + message: t("nameMax", { len: 255 }) + }), + unlimitedBatchSize: z.boolean(), + maxBatchSize: z + .number() + .int() + .min(1, { message: t("provisioningKeysMaxBatchSizeInvalid") }) + .max(1_000_000, { + message: t("provisioningKeysMaxBatchSizeInvalid") + }), + validUntil: z.string().optional() + }) + .superRefine((data, ctx) => { + const v = data.validUntil; + if (v == null || v.trim() === "") { + return; + } + if (Number.isNaN(Date.parse(v))) { + ctx.addIssue({ + code: "custom", + message: t("provisioningKeysValidUntilInvalid"), + path: ["validUntil"] + }); + } + }); type CreateFormValues = z.infer; const form = useForm({ resolver: zodResolver(createFormSchema), defaultValues: { - name: "" + name: "", + unlimitedBatchSize: false, + maxBatchSize: 100, + validUntil: "" } }); useEffect(() => { if (!open) { setCreated(null); - form.reset({ name: "" }); + form.reset({ + name: "", + unlimitedBatchSize: false, + maxBatchSize: 100, + validUntil: "" + }); } }, [open, form]); @@ -88,7 +121,16 @@ export default function CreateSiteProvisioningKeyCredenza({ const res = await api .put< AxiosResponse - >(`/org/${orgId}/site-provisioning-key`, { name: data.name }) + >(`/org/${orgId}/site-provisioning-key`, { + name: data.name, + maxBatchSize: data.unlimitedBatchSize + ? null + : data.maxBatchSize, + validUntil: + data.validUntil == null || data.validUntil.trim() === "" + ? undefined + : data.validUntil + }) .catch((e) => { toast({ variant: "destructive", @@ -110,6 +152,8 @@ export default function CreateSiteProvisioningKeyCredenza({ created && `${created.siteProvisioningKeyId}.${created.siteProvisioningKey}`; + const unlimitedBatchSize = form.watch("unlimitedBatchSize"); + return ( @@ -149,6 +193,96 @@ export default function CreateSiteProvisioningKeyCredenza({ )} /> + ( + + + {t( + "provisioningKeysMaxBatchSize" + )} + + + { + const v = + e.target.value; + field.onChange( + v === "" + ? 100 + : Number(v) + ); + }} + value={field.value} + /> + + + + )} + /> + ( + + + + field.onChange( + c === true + ) + } + /> + + + {t( + "provisioningKeysUnlimitedBatchSize" + )} + + + )} + /> + ( + + + {t( + "provisioningKeysValidUntil" + )} + + + + + + {t( + "provisioningKeysValidUntilHint" + )} + + + + )} + /> )} diff --git a/src/components/EditSiteProvisioningKeyCredenza.tsx b/src/components/EditSiteProvisioningKeyCredenza.tsx new file mode 100644 index 000000000..9603374d5 --- /dev/null +++ b/src/components/EditSiteProvisioningKeyCredenza.tsx @@ -0,0 +1,297 @@ +"use client"; + +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Button } from "@app/components/ui/button"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { Input } from "@app/components/ui/input"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { UpdateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types"; +import { AxiosResponse } from "axios"; +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 moment from "moment"; + +const FORM_ID = "edit-site-provisioning-key-form"; + +export type EditableSiteProvisioningKey = { + id: string; + name: string; + maxBatchSize: number | null; + validUntil: string | null; +}; + +type EditSiteProvisioningKeyCredenzaProps = { + open: boolean; + setOpen: (open: boolean) => void; + orgId: string; + provisioningKey: EditableSiteProvisioningKey | null; +}; + +export default function EditSiteProvisioningKeyCredenza({ + open, + setOpen, + orgId, + provisioningKey +}: EditSiteProvisioningKeyCredenzaProps) { + const t = useTranslations(); + const router = useRouter(); + const api = createApiClient(useEnvContext()); + const [loading, setLoading] = useState(false); + + const editFormSchema = z + .object({ + name: z.string(), + unlimitedBatchSize: z.boolean(), + maxBatchSize: z + .number() + .int() + .min(1, { message: t("provisioningKeysMaxBatchSizeInvalid") }) + .max(1_000_000, { + message: t("provisioningKeysMaxBatchSizeInvalid") + }), + validUntil: z.string().optional() + }) + .superRefine((data, ctx) => { + const v = data.validUntil; + if (v == null || v.trim() === "") { + return; + } + if (Number.isNaN(Date.parse(v))) { + ctx.addIssue({ + code: "custom", + message: t("provisioningKeysValidUntilInvalid"), + path: ["validUntil"] + }); + } + }); + + type EditFormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(editFormSchema), + defaultValues: { + name: "", + unlimitedBatchSize: false, + maxBatchSize: 100, + validUntil: "" + } + }); + + useEffect(() => { + if (!open || !provisioningKey) { + return; + } + form.reset({ + name: provisioningKey.name, + unlimitedBatchSize: provisioningKey.maxBatchSize == null, + maxBatchSize: provisioningKey.maxBatchSize ?? 100, + validUntil: provisioningKey.validUntil + ? moment(provisioningKey.validUntil).format("YYYY-MM-DDTHH:mm") + : "" + }); + }, [open, provisioningKey, form]); + + async function onSubmit(data: EditFormValues) { + if (!provisioningKey) { + return; + } + setLoading(true); + try { + const res = await api + .patch< + AxiosResponse + >( + `/org/${orgId}/site-provisioning-key/${provisioningKey.id}`, + { + maxBatchSize: data.unlimitedBatchSize + ? null + : data.maxBatchSize, + validUntil: + data.validUntil == null || + data.validUntil.trim() === "" + ? "" + : data.validUntil + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("provisioningKeysUpdateError"), + description: formatAxiosError(e) + }); + }); + + if (res && res.status === 200) { + toast({ + title: t("provisioningKeysUpdated"), + description: t("provisioningKeysUpdatedDescription") + }); + setOpen(false); + router.refresh(); + } + } finally { + setLoading(false); + } + } + + const unlimitedBatchSize = form.watch("unlimitedBatchSize"); + + if (!provisioningKey) { + return null; + } + + return ( + + + + {t("provisioningKeysEdit")} + + {t("provisioningKeysEditDescription")} + + + +
+ + ( + + {t("name")} + + + + + )} + /> + ( + + + {t("provisioningKeysMaxBatchSize")} + + + { + const v = e.target.value; + field.onChange( + v === "" + ? 100 + : Number(v) + ); + }} + value={field.value} + /> + + + + )} + /> + ( + + + + field.onChange(c === true) + } + /> + + + {t( + "provisioningKeysUnlimitedBatchSize" + )} + + + )} + /> + ( + + + {t("provisioningKeysValidUntil")} + + + + + + {t("provisioningKeysValidUntilHint")} + + + + )} + /> + + +
+ + + + + + +
+
+ ); +} diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx index e1c883a2b..854cad6db 100644 --- a/src/components/LayoutMobileMenu.tsx +++ b/src/components/LayoutMobileMenu.tsx @@ -93,7 +93,7 @@ export function LayoutMobileMenu({ ) } > - + diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index e9e2d61eb..1cd2131f7 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -169,8 +169,8 @@ export function LayoutSidebar({ > @@ -222,36 +222,34 @@ export function LayoutSidebar({ )} -
- -
+
{canShowProductUpdates && ( -
+
)} {build === "enterprise" && ( -
+
)} {build === "oss" && ( -
+
)} {build === "saas" && ( -
+
)} {!isSidebarCollapsed && ( -
+
{loadFooterLinks() ? ( <> {loadFooterLinks()!.map((link, index) => ( diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index 01689d9d7..76ab0252d 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -192,13 +192,13 @@ function ProductUpdatesListPopup({
- +

{t("productUpdateWhatsNew")} @@ -346,13 +346,13 @@ function NewVersionAvailable({ rel="noopener noreferrer" className={cn( "relative z-2 group cursor-pointer block", - "rounded-md border border-primary/30 bg-linear-to-br dark:from-primary/20 from-primary/20 via-background to-background p-2 py-3 w-full flex flex-col gap-2 text-sm", + "rounded-md border bg-secondary p-2 py-3 w-full flex flex-col gap-2 text-sm", "transition duration-300 ease-in-out", "data-closed:opacity-0 data-closed:translate-y-full" )} >

- +

{t("pangolinUpdateAvailable")}

diff --git a/src/components/SiteProvisioningKeysTable.tsx b/src/components/SiteProvisioningKeysTable.tsx index 3fb3eb872..df7fd241c 100644 --- a/src/components/SiteProvisioningKeysTable.tsx +++ b/src/components/SiteProvisioningKeysTable.tsx @@ -15,19 +15,27 @@ import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import CreateSiteProvisioningKeyCredenza from "@app/components/CreateSiteProvisioningKeyCredenza"; +import EditSiteProvisioningKeyCredenza from "@app/components/EditSiteProvisioningKeyCredenza"; 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 { usePaidStatus } from "@app/hooks/usePaidStatus"; import moment from "moment"; import { useTranslations } from "next-intl"; +import { build } from "@server/build"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; export type SiteProvisioningKeyRow = { id: string; key: string; name: string; createdAt: string; + lastUsed: string | null; + maxBatchSize: number | null; + numUsed: number; + validUntil: string | null; }; type SiteProvisioningKeysTableProps = { @@ -47,8 +55,15 @@ export default function SiteProvisioningKeysTable({ const [rows, setRows] = useState(keys); const api = createApiClient(useEnvContext()); const t = useTranslations(); + const { isPaidUser } = usePaidStatus(); + const canUseSiteProvisioning = + isPaidUser(tierMatrix[TierFeature.SiteProvisioningKeys]) && + build !== "oss"; const [isRefreshing, setIsRefreshing] = useState(false); const [createOpen, setCreateOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); + const [editingKey, setEditingKey] = + useState(null); useEffect(() => { setRows(keys); @@ -121,6 +136,68 @@ export default function SiteProvisioningKeysTable({ return {r.key}; } }, + { + accessorKey: "maxBatchSize", + friendlyName: t("provisioningKeysMaxBatchSize"), + header: () => ( + {t("provisioningKeysMaxBatchSize")} + ), + cell: ({ row }) => { + const r = row.original; + return ( + + {r.maxBatchSize == null + ? t("provisioningKeysMaxBatchUnlimited") + : r.maxBatchSize} + + ); + } + }, + { + accessorKey: "numUsed", + friendlyName: t("provisioningKeysNumUsed"), + header: () => ( + {t("provisioningKeysNumUsed")} + ), + cell: ({ row }) => { + const r = row.original; + return {r.numUsed}; + } + }, + { + accessorKey: "validUntil", + friendlyName: t("provisioningKeysValidUntil"), + header: () => ( + {t("provisioningKeysValidUntil")} + ), + cell: ({ row }) => { + const r = row.original; + return ( + + {r.validUntil + ? moment(r.validUntil).format("lll") + : t("provisioningKeysNoExpiry")} + + ); + } + }, + { + accessorKey: "lastUsed", + friendlyName: t("provisioningKeysLastUsed"), + header: () => ( + {t("provisioningKeysLastUsed")} + ), + cell: ({ row }) => { + const r = row.original; + return ( + + {r.lastUsed + ? moment(r.lastUsed).format("lll") + : t("provisioningKeysNeverUsed")} + + ); + } + }, { accessorKey: "createdAt", friendlyName: t("createdAt"), @@ -149,6 +226,16 @@ export default function SiteProvisioningKeysTable({ { + setEditingKey(r); + setEditOpen(true); + }} + > + {t("edit")} + + { setSelected(r); setIsDeleteModalOpen(true); @@ -174,6 +261,18 @@ export default function SiteProvisioningKeysTable({ orgId={orgId} /> + { + setEditOpen(v); + if (!v) { + setEditingKey(null); + } + }} + orgId={orgId} + provisioningKey={editingKey} + /> + {selected && ( setCreateOpen(true)} + onAdd={() => { + if (canUseSiteProvisioning) { + setCreateOpen(true); + } + }} + addButtonDisabled={!canUseSiteProvisioning} onRefresh={refreshData} isRefreshing={isRefreshing} addButtonText={t("provisioningKeysAdd")} diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index 834c56e88..a0c11ffdf 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -171,6 +171,7 @@ type DataTableProps = { title?: string; addButtonText?: string; onAdd?: () => void; + addButtonDisabled?: boolean; onRefresh?: () => void; isRefreshing?: boolean; searchPlaceholder?: string; @@ -203,6 +204,7 @@ export function DataTable({ title, addButtonText, onAdd, + addButtonDisabled = false, onRefresh, isRefreshing, searchPlaceholder = "Search...", @@ -635,7 +637,7 @@ export function DataTable({ )} {onAdd && addButtonText && (
-