Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ CONVEX_DEPLOY_KEY=
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=

# Comma-separated Google account emails allowed to access this private deployment.
# Example: NUDGRA_ALLOWED_EMAILS=operator@example.com,backup@example.com
NUDGRA_ALLOWED_EMAILS=

# Instagram / Meta.
# Set these on the Convex deployment for production.
META_APP_ID=
Expand Down
41 changes: 37 additions & 4 deletions app/dashboard/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";

import Link from "next/link";
import { useConvexAuth, useMutation } from "convex/react";
import { useAuthActions } from "@convex-dev/auth/react";
import { useConvexAuth, useMutation, useQuery } from "convex/react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Menu } from "lucide-react";
Expand All @@ -15,9 +16,16 @@ export default function DashboardLayout({
children: React.ReactNode;
}) {
const { isAuthenticated, isLoading } = useConvexAuth();
const { signOut } = useAuthActions();
const router = useRouter();
const ensureWorkspace = useMutation(api.workspaces.ensureCurrentWorkspace);
const accessStatus = useQuery(
api.workspaces.getOperatorAccessStatus,
isAuthenticated ? {} : "skip",
);
const [sidebarOpen, setSidebarOpen] = useState(false);
const isAccessLoading = isAuthenticated && accessStatus === undefined;
const isAllowed = accessStatus?.isAllowed === true;

useEffect(() => {
if (!isLoading && !isAuthenticated) {
Expand All @@ -26,12 +34,12 @@ export default function DashboardLayout({
}, [isAuthenticated, isLoading, router]);

useEffect(() => {
if (!isLoading && isAuthenticated) {
if (!isLoading && isAuthenticated && isAllowed) {
void ensureWorkspace({});
}
}, [ensureWorkspace, isAuthenticated, isLoading]);
}, [ensureWorkspace, isAllowed, isAuthenticated, isLoading]);

if (isLoading || !isAuthenticated) {
if (isLoading || !isAuthenticated || isAccessLoading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="flex gap-1.5">
Expand All @@ -47,6 +55,31 @@ export default function DashboardLayout({
);
}

if (!isAllowed) {
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="w-full max-w-md rounded-xl border border-border bg-card p-8 text-center shadow-sm">
<NudgraLogo size="lg" />
<h1 className="mt-8 text-xl font-semibold text-foreground">
Access denied
</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{accessStatus?.email
? `${accessStatus.email} is not allowed to access this Nudgra deployment.`
: "This Google account is not allowed to access this Nudgra deployment."}
</p>
<button
type="button"
onClick={() => 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
</button>
</div>
</div>
);
}

return (
<div className="flex h-screen overflow-hidden bg-background">
<DashboardSidebar
Expand Down
2 changes: 2 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type * as dashboard from "../dashboard.js";
import type * as http from "../http.js";
import type * as inbox from "../inbox.js";
import type * as lib_auth from "../lib/auth.js";
import type * as lib_operatorAccess from "../lib/operatorAccess.js";
import type * as lib_readModels from "../lib/readModels.js";
import type * as meta_authShared from "../meta/authShared.js";
import type * as meta_commentWebhooks from "../meta/commentWebhooks.js";
Expand Down Expand Up @@ -87,6 +88,7 @@ declare const fullApi: ApiFromModules<{
http: typeof http;
inbox: typeof inbox;
"lib/auth": typeof lib_auth;
"lib/operatorAccess": typeof lib_operatorAccess;
"lib/readModels": typeof lib_readModels;
"meta/authShared": typeof meta_authShared;
"meta/commentWebhooks": typeof meta_commentWebhooks;
Expand Down
6 changes: 6 additions & 0 deletions convex/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import Google from "@auth/core/providers/google";
import { convexAuth } from "@convex-dev/auth/server";
import { requireAllowedOperatorUserId } from "./lib/operatorAccess";

export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
providers: [Google],
callbacks: {
beforeSessionCreation: async (ctx, { userId }) => {
await requireAllowedOperatorUserId(ctx, userId);
},
},
});
4 changes: 4 additions & 0 deletions convex/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -53,6 +54,7 @@ export async function requireCurrentUserId(ctx: DbCtx): Promise<Id<"users">> {
if (userId === null) {
throw new Error("You must be signed in to access this workspace.");
}
await requireAllowedOperatorUserId(ctx, userId);
return userId;
}

Expand All @@ -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))
Expand Down Expand Up @@ -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),
Expand Down
51 changes: 51 additions & 0 deletions convex/lib/operatorAccess.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
2 changes: 2 additions & 0 deletions convex/test.setup.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/// <reference types="vite/client" />

process.env.NUDGRA_ALLOWED_EMAILS ??= "operator@example.com,test@example.com";

export const modules = import.meta.glob([
"./**/*.ts",
"!./test.setup.ts",
Expand Down
23 changes: 23 additions & 0 deletions convex/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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,
};
},
});
Loading
Loading