Add enterprise features management: implement license key settings and update user schema

This commit is contained in:
Mauricio Siu
2026-01-28 11:03:00 -06:00
parent 20226a300c
commit f680818b56
7 changed files with 7236 additions and 1 deletions

View File

@@ -0,0 +1,108 @@
import { Key } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
export function LicenseKeySettings() {
const utils = api.useUtils();
const { data, isLoading } = api.organization.getEnterpriseSettings.useQuery();
const { mutateAsync: updateEnterpriseSettings, isLoading: isSaving } =
api.organization.updateEnterpriseSettings.useMutation();
const [licenseKey, setLicenseKey] = useState("");
useEffect(() => {
if (data?.licenseKey !== undefined) {
setLicenseKey(data.licenseKey ?? "");
}
}, [data?.licenseKey]);
const enabled = !!data?.enableEnterpriseFeatures;
return (
<div className="flex flex-col gap-4 rounded-lg border p-4">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Key className="size-6 text-muted-foreground" />
<CardTitle className="text-xl">License Key</CardTitle>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{enabled ? "Enabled" : "Disabled"}
</span>
<Switch
checked={enabled}
disabled={isLoading || isSaving}
onCheckedChange={async (next) => {
try {
await updateEnterpriseSettings({
enableEnterpriseFeatures: next,
});
await utils.organization.getEnterpriseSettings.invalidate();
toast.success("Enterprise features updated");
} catch (error) {
console.error(error);
toast.error("Failed to update enterprise features");
}
}}
/>
</div>
</div>
<p className="text-sm text-muted-foreground">
To unlock extra features you need an enterprise license key. Contact us{" "}
<Link
href="http://localhost:3001/contact"
target="_blank"
rel="noreferrer"
className="underline underline-offset-4"
>
here
</Link>
.
</p>
</div>
{enabled && (
<div className="grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="licenseKey">
License Key
</label>
<Input
id="licenseKey"
placeholder="Enter your enterprise license key"
value={licenseKey}
onChange={(e) => setLicenseKey(e.target.value)}
/>
</div>
<div className="md:justify-self-end">
<Button
variant="secondary"
disabled={isSaving}
onClick={async () => {
try {
await updateEnterpriseSettings({ licenseKey });
await utils.organization.getEnterpriseSettings.invalidate();
toast.success("License key saved");
} catch (error) {
console.error(error);
toast.error("Failed to save license key");
}
}}
>
Save
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
ALTER TABLE "user" ADD COLUMN "enableEnterpriseFeatures" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "licenseKey" text;

File diff suppressed because it is too large Load Diff

View File

@@ -960,6 +960,13 @@
"when": 1769580434296,
"tag": "0136_tidy_puff_adder",
"breakpoints": true
},
{
"idx": 137,
"version": "7",
"when": 1769616589728,
"tag": "0137_naive_power_pack",
"breakpoints": true
}
]
}

View File

@@ -6,6 +6,7 @@ import superjson from "superjson";
import { ShowBackups } from "@/components/dashboard/database/backups/show-backups";
import { WebDomain } from "@/components/dashboard/settings/web-domain";
import { WebServer } from "@/components/dashboard/settings/web-server";
import { LicenseKeySettings } from "@/components/dashboard/settings/web-server/license-key";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
@@ -28,6 +29,13 @@ const Page = () => {
/>
</Card>
</div>
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<div className="p-6">
<LicenseKeySettings />
</div>
</div>
</Card>
</div>
</div>
);

View File

@@ -4,9 +4,51 @@ import { and, desc, eq, exists } from "drizzle-orm";
import { nanoid } from "nanoid";
import { z } from "zod";
import { db } from "@/server/db";
import { invitation, member, organization } from "@/server/db/schema";
import { invitation, member, organization, user } from "@/server/db/schema";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc";
export const organizationRouter = createTRPCRouter({
getEnterpriseSettings: adminProcedure.query(async ({ ctx }) => {
const currentUserId = ctx.user.id;
const currentUser = await db.query.user.findFirst({
where: eq(user.id, currentUserId),
});
if (!currentUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
return {
enableEnterpriseFeatures: !!currentUser.enableEnterpriseFeatures,
licenseKey: currentUser.licenseKey ?? "",
};
}),
updateEnterpriseSettings: adminProcedure
.input(
z.object({
enableEnterpriseFeatures: z.boolean().optional(),
licenseKey: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const currentUserId = ctx.user.id;
await db
.update(user)
.set({
...(input.enableEnterpriseFeatures === undefined
? {}
: { enableEnterpriseFeatures: input.enableEnterpriseFeatures }),
...(input.licenseKey === undefined ? {} : { licenseKey: input.licenseKey }),
})
.where(eq(user.id, currentUserId));
return true;
}),
create: protectedProcedure
.input(
z.object({

View File

@@ -53,6 +53,11 @@ export const user = pgTable("user", {
// Metrics
enablePaidFeatures: boolean("enablePaidFeatures").notNull().default(false),
allowImpersonation: boolean("allowImpersonation").notNull().default(false),
// Enterprise / proprietary features
enableEnterpriseFeatures: boolean("enableEnterpriseFeatures")
.notNull()
.default(false),
licenseKey: text("licenseKey"),
stripeCustomerId: text("stripeCustomerId"),
stripeSubscriptionId: text("stripeSubscriptionId"),
serversQuantity: integer("serversQuantity").notNull().default(0),