@@ -47,6 +55,31 @@ export default function DashboardLayout({
);
}
+ if (!isAllowed) {
+ return (
+
+
+
+
+ Access denied
+
+
+ {accessStatus?.email
+ ? `${accessStatus.email} is not allowed to access this Nudgra deployment.`
+ : "This Google account is not allowed to access this Nudgra deployment."}
+
+
void signOut().then(() => router.push("/signin"))}
+ className="mt-6 inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90"
+ >
+ Sign out
+
+
+
+ );
+ }
+
return (
{
+ await requireAllowedOperatorUserId(ctx, userId);
+ },
+ },
});
diff --git a/convex/lib/auth.ts b/convex/lib/auth.ts
index ab4f120..ee87273 100644
--- a/convex/lib/auth.ts
+++ b/convex/lib/auth.ts
@@ -1,6 +1,7 @@
import { getAuthUserId } from "@convex-dev/auth/server";
import { Doc, Id } from "../_generated/dataModel";
import { MutationCtx, QueryCtx } from "../_generated/server";
+import { requireAllowedOperatorUserId } from "./operatorAccess";
type DbCtx = QueryCtx | MutationCtx;
@@ -53,6 +54,7 @@ export async function requireCurrentUserId(ctx: DbCtx): Promise> {
if (userId === null) {
throw new Error("You must be signed in to access this workspace.");
}
+ await requireAllowedOperatorUserId(ctx, userId);
return userId;
}
@@ -63,6 +65,7 @@ export async function getCurrentWorkspace(
if (userId === null) {
return null;
}
+ await requireAllowedOperatorUserId(ctx, userId);
return await ctx.db
.query("workspaces")
.withIndex("by_owner_user_id", (q) => q.eq("ownerUserId", userId))
@@ -156,6 +159,7 @@ export async function getSelectedWorkspaceInstagramAccount(
if (userId === null) {
return null;
}
+ await requireAllowedOperatorUserId(ctx, userId);
const [preference, accounts] = await Promise.all([
getWorkspaceUserPreference(ctx, workspaceId, userId),
diff --git a/convex/lib/operatorAccess.ts b/convex/lib/operatorAccess.ts
new file mode 100644
index 0000000..2569a01
--- /dev/null
+++ b/convex/lib/operatorAccess.ts
@@ -0,0 +1,51 @@
+import { Id } from "../_generated/dataModel";
+import { MutationCtx, QueryCtx } from "../_generated/server";
+
+type DbCtx = QueryCtx | MutationCtx;
+
+export const OPERATOR_ACCESS_DENIED_MESSAGE =
+ "This Google account is not allowed to access this Nudgra deployment.";
+
+export function normalizeOperatorEmail(email: string) {
+ return email.trim().toLowerCase();
+}
+
+export function getAllowedOperatorEmails() {
+ return new Set(
+ (process.env.NUDGRA_ALLOWED_EMAILS ?? "")
+ .split(",")
+ .map(normalizeOperatorEmail)
+ .filter((email) => email.length > 0),
+ );
+}
+
+export function isOperatorEmailAllowed(email: string | null | undefined) {
+ if (!email) {
+ return false;
+ }
+
+ return getAllowedOperatorEmails().has(normalizeOperatorEmail(email));
+}
+
+export async function getOperatorAccessForUserId(
+ ctx: DbCtx,
+ userId: Id<"users">,
+) {
+ const user = await ctx.db.get(userId);
+ const email = typeof user?.email === "string" ? user.email : null;
+
+ return {
+ allowed: isOperatorEmailAllowed(email),
+ email,
+ };
+}
+
+export async function requireAllowedOperatorUserId(
+ ctx: DbCtx,
+ userId: Id<"users">,
+) {
+ const access = await getOperatorAccessForUserId(ctx, userId);
+ if (!access.allowed) {
+ throw new Error(OPERATOR_ACCESS_DENIED_MESSAGE);
+ }
+}
diff --git a/convex/test.setup.ts b/convex/test.setup.ts
index 9bc36e5..b002670 100644
--- a/convex/test.setup.ts
+++ b/convex/test.setup.ts
@@ -1,5 +1,7 @@
///
+process.env.NUDGRA_ALLOWED_EMAILS ??= "operator@example.com,test@example.com";
+
export const modules = import.meta.glob([
"./**/*.ts",
"!./test.setup.ts",
diff --git a/convex/workspaces.ts b/convex/workspaces.ts
index b406e57..e5d7d3f 100644
--- a/convex/workspaces.ts
+++ b/convex/workspaces.ts
@@ -4,6 +4,8 @@ import {
getWorkspaceUserPreference,
requireCurrentUserId,
} from "./lib/auth";
+import { getOperatorAccessForUserId } from "./lib/operatorAccess";
+import { getAuthUserId } from "@convex-dev/auth/server";
const DEFAULT_TAGS = [
{ label: "new", color: "slate" },
@@ -97,3 +99,24 @@ export const getCurrentWorkspaceSummary = query({
};
},
});
+
+export const getOperatorAccessStatus = query({
+ args: {},
+ handler: async (ctx) => {
+ const userId = await getAuthUserId(ctx);
+ if (userId === null) {
+ return {
+ isAuthenticated: false,
+ isAllowed: false,
+ email: null,
+ };
+ }
+
+ const access = await getOperatorAccessForUserId(ctx, userId);
+ return {
+ isAuthenticated: true,
+ isAllowed: access.allowed,
+ email: access.email,
+ };
+ },
+});
diff --git a/tests/operator-access.test.ts b/tests/operator-access.test.ts
new file mode 100644
index 0000000..0d75021
--- /dev/null
+++ b/tests/operator-access.test.ts
@@ -0,0 +1,159 @@
+import { describe, expect, it, afterEach } from "vitest";
+import { convexTest } from "convex-test";
+import { api } from "@/convex/_generated/api";
+import type { Id } from "@/convex/_generated/dataModel";
+import schema from "@/convex/schema";
+import { modules } from "@/convex/test.setup";
+
+const DEFAULT_ALLOWED_EMAIL = "operator@example.com";
+const DEFAULT_ALLOWED_EMAILS = "operator@example.com,test@example.com";
+
+async function seedUser(
+ t: ReturnType,
+ args: { email: string; name?: string },
+) {
+ return await t.run(async (ctx) => {
+ return await ctx.db.insert("users", {
+ name: args.name ?? "Operator",
+ email: args.email,
+ });
+ });
+}
+
+async function seedWorkspaceWithRule(
+ t: ReturnType,
+ userId: Id<"users">,
+) {
+ return await t.run(async (ctx) => {
+ const workspaceId = await ctx.db.insert("workspaces", {
+ ownerUserId: userId,
+ name: "Workspace",
+ timezone: "UTC",
+ });
+ const accountId = await ctx.db.insert("instagramAccounts", {
+ workspaceId,
+ instagramAccountId: "ig_operator_access",
+ metaUserId: null,
+ username: "operator",
+ name: "Operator",
+ profilePictureUrl: null,
+ accountType: "business",
+ status: "connected",
+ graphAccessToken: "token",
+ tokenExpiresAt: Date.now() + 60 * 24 * 60 * 60 * 1000,
+ scopes: [],
+ webhookSubscriptionStatus: "active",
+ lastWebhookAt: null,
+ lastError: null,
+ connectedAt: Date.now(),
+ disconnectedAt: null,
+ graphApiVersion: "v23.0",
+ });
+ const ruleId = await ctx.db.insert("automationRules", {
+ workspaceId,
+ instagramAccountId: accountId,
+ name: "Operator access rule",
+ triggerType: "keyword",
+ matchType: "contains",
+ keywords: ["guide"],
+ replyText: "Here is the guide.",
+ linkDmText: "Here is the guide.",
+ linkButtons: [],
+ followGateEnabled: false,
+ followGateText: "",
+ emailCollectionEnabled: false,
+ emailCollectionText: "",
+ followUpEnabled: false,
+ followUpText: "",
+ isActive: true,
+ tagIds: [],
+ sequenceDefinitionId: null,
+ createdByUserId: userId,
+ triggerCount: 0,
+ lastTriggeredAt: null,
+ });
+
+ return { workspaceId, accountId, ruleId };
+ });
+}
+
+describe("operator access allowlist", () => {
+ afterEach(() => {
+ process.env.NUDGRA_ALLOWED_EMAILS = DEFAULT_ALLOWED_EMAILS;
+ });
+
+ it("allows an allowlisted Google email to create and read the current workspace", async () => {
+ process.env.NUDGRA_ALLOWED_EMAILS = "operator@example.com, backup@example.com";
+ const t = convexTest({ schema, modules });
+ const userId = await seedUser(t, { email: DEFAULT_ALLOWED_EMAIL });
+ const authT = t.withIdentity({ subject: userId });
+
+ const result = await authT.mutation(api.workspaces.ensureCurrentWorkspace, {});
+ const summary = await authT.query(api.workspaces.getCurrentWorkspaceSummary, {});
+
+ expect(result.created).toBe(true);
+ expect(summary).toMatchObject({
+ id: result.workspaceId,
+ name: "Operator",
+ timezone: "UTC",
+ });
+ });
+
+ it("rejects a signed-in Google account that is not in the allowlist", async () => {
+ process.env.NUDGRA_ALLOWED_EMAILS = DEFAULT_ALLOWED_EMAIL;
+ const t = convexTest({ schema, modules });
+ const userId = await seedUser(t, { email: "intruder@example.com" });
+ const authT = t.withIdentity({ subject: userId });
+
+ await expect(
+ authT.mutation(api.workspaces.ensureCurrentWorkspace, {}),
+ ).rejects.toThrow("not allowed");
+
+ const access = await authT.query(api.workspaces.getOperatorAccessStatus, {});
+ expect(access).toEqual({
+ isAuthenticated: true,
+ isAllowed: false,
+ email: "intruder@example.com",
+ });
+ });
+
+ it("blocks existing unauthorized sessions from reading, mutating, and deleting workspace resources", async () => {
+ process.env.NUDGRA_ALLOWED_EMAILS = DEFAULT_ALLOWED_EMAIL;
+ const t = convexTest({ schema, modules });
+ const userId = await seedUser(t, { email: "former-operator@example.com" });
+ const fixture = await seedWorkspaceWithRule(t, userId);
+ const authT = t.withIdentity({ subject: userId });
+
+ await expect(
+ authT.query(api.workspaces.getCurrentWorkspaceSummary, {}),
+ ).rejects.toThrow("not allowed");
+ await expect(
+ authT.mutation(api.workspaces.ensureCurrentWorkspace, {}),
+ ).rejects.toThrow("not allowed");
+ await expect(
+ authT.mutation(api.automations.rules.deleteRule, {
+ accountId: fixture.accountId,
+ ruleId: fixture.ruleId,
+ }),
+ ).rejects.toThrow("not allowed");
+
+ const storedRule = await t.run((ctx) => ctx.db.get(fixture.ruleId));
+ expect(storedRule).not.toBeNull();
+ });
+
+ it("fails closed when the allowlist is empty or missing", async () => {
+ const t = convexTest({ schema, modules });
+ const userId = await seedUser(t, { email: DEFAULT_ALLOWED_EMAIL });
+ const authT = t.withIdentity({ subject: userId });
+
+ process.env.NUDGRA_ALLOWED_EMAILS = "";
+ await expect(
+ authT.mutation(api.workspaces.ensureCurrentWorkspace, {}),
+ ).rejects.toThrow("not allowed");
+
+ delete process.env.NUDGRA_ALLOWED_EMAILS;
+ await expect(
+ authT.mutation(api.workspaces.ensureCurrentWorkspace, {}),
+ ).rejects.toThrow("not allowed");
+ });
+});