diff --git a/README.md b/README.md index 9238dbc..f10e4c1 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,7 @@ AWS_SECRET_ACCESS_KEY= | `AWS_REGION` | AWS region for Secrets Manager | AWS console | | `AWS_ACCESS_KEY_ID` | AWS IAM access key | [AWS IAM](https://console.aws.amazon.com/iam) → Security credentials | | `AWS_SECRET_ACCESS_KEY` | AWS IAM secret key | [AWS IAM](https://console.aws.amazon.com/iam) → Security credentials | +| `CLERK_GUEST_USER_ID` | Clerk user ID of the pre-provisioned demo account used by "Continue as Guest" | See [Guest Sign-In](#guest-sign-in-recruiter-demo) below | ### `apps/embed` (build-time only) @@ -179,6 +180,31 @@ VITE_WIDGET_URL=http://localhost:3001 --- +## Guest Sign-In (Recruiter Demo) + +The dashboard sign-in page has a "Continue as Guest" button so people evaluating the project (e.g. recruiters) can explore it without creating an account. It signs the visitor into a single pre-provisioned demo account whose write access is disabled at the API layer, so it's safe to share — guests can view everything but can't send messages, change settings, disconnect plugins, upload/delete files, or overwrite secrets. + +**One-time setup in the Clerk Dashboard:** + +1. **Users → Create user** — make a dedicated demo account (e.g. `guest-demo@yourdomain.com`), no password required. +2. Sign in as that user once and create/join an **Organization** for it — this becomes the shared demo workspace. Populate it from the dashboard with a few example conversations, widget settings, etc. so guests see a populated demo instead of an empty one. +3. **Organizations → Roles** — create a custom role with the key `org:guest` (name/permissions don't matter; the app enforces read-only access itself, not Clerk's built-in permission checks). +4. Open that organization → **Members**, and set the demo user's role to `org:guest`. +5. Copy the demo user's ID from **Users → (the demo user)** — it's shown at the top and starts with `user_`. +6. Set it as a Convex environment variable: + +```bash +cd packages/backend +npx convex env set CLERK_GUEST_USER_ID user_xxxxxxxxxxxx +``` + +**Notes:** +- If your Clerk plan doesn't support custom organization roles, swap the `org:guest` role check in `packages/backend/convex/private/checkUserIdentityAndGetOrgId.ts` for a check on the user's `publicMetadata` (e.g. `{ isGuest: true }`) exposed via a custom claim in your Clerk JWT template instead. +- The write guard runs server-side on every mutation, so even a guest calling a mutation directly from devtools gets rejected with a `GUEST_READ_ONLY` error — the button is just the convenient path in. +- Until `CLERK_GUEST_USER_ID` is set, clicking "Continue as Guest" fails with "Guest sign-in is unavailable right now." + +--- + ## Embedding the Widget Once deployed, add the following snippet to any website: diff --git a/apps/web/modules/auth/ui/components/GuestSignInButton.tsx b/apps/web/modules/auth/ui/components/GuestSignInButton.tsx new file mode 100644 index 0000000..791b2d9 --- /dev/null +++ b/apps/web/modules/auth/ui/components/GuestSignInButton.tsx @@ -0,0 +1,53 @@ +"use client" + +import { useAction } from "convex/react"; +import { useSignIn } from "@clerk/nextjs"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { api } from "@workspace/backend/_generated/api"; +import { Button } from "@workspace/ui/components/button"; +import { toast } from "sonner"; + +const GuestSignInButton = () => { + const { signIn, setActive, isLoaded } = useSignIn(); + const createSignInTicket = useAction(api.public.guest.createSignInTicket); + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + + const handleGuestSignIn = async () => { + if (!isLoaded || isLoading) return; + + setIsLoading(true); + + try { + const { ticket } = await createSignInTicket({}); + const attempt = await signIn.create({ strategy: "ticket", ticket }); + + if (attempt.status === "complete") { + await setActive({ session: attempt.createdSessionId }); + router.push("/"); + } else { + toast.error("Guest sign-in failed. Please try again."); + } + } catch (error) { + console.error(error); + toast.error("Guest sign-in is unavailable right now."); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +}; + +export default GuestSignInButton; diff --git a/apps/web/modules/auth/ui/views/SignInView.tsx b/apps/web/modules/auth/ui/views/SignInView.tsx index d43815a..f214953 100644 --- a/apps/web/modules/auth/ui/views/SignInView.tsx +++ b/apps/web/modules/auth/ui/views/SignInView.tsx @@ -1,7 +1,13 @@ import { SignIn } from '@clerk/nextjs' import React from 'react' +import GuestSignInButton from '../components/GuestSignInButton' export default function SignInView() { - return + return ( +
+ + +
+ ) } diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index f4f86d2..16340e5 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -26,6 +26,7 @@ import type * as private_vapi from "../private/vapi.js"; import type * as private_widgetSettings from "../private/widgetSettings.js"; import type * as public_contactSessions from "../public/contactSessions.js"; import type * as public_conversations from "../public/conversations.js"; +import type * as public_guest from "../public/guest.js"; import type * as public_messages from "../public/messages.js"; import type * as public_organizations from "../public/organizations.js"; import type * as public_secrets from "../public/secrets.js"; @@ -69,6 +70,7 @@ declare const fullApi: ApiFromModules<{ "private/widgetSettings": typeof private_widgetSettings; "public/contactSessions": typeof public_contactSessions; "public/conversations": typeof public_conversations; + "public/guest": typeof public_guest; "public/messages": typeof public_messages; "public/organizations": typeof public_organizations; "public/secrets": typeof public_secrets; diff --git a/packages/backend/convex/private/checkUserIdentityAndGetOrgId.ts b/packages/backend/convex/private/checkUserIdentityAndGetOrgId.ts index 8f3fa8f..800e9c9 100644 --- a/packages/backend/convex/private/checkUserIdentityAndGetOrgId.ts +++ b/packages/backend/convex/private/checkUserIdentityAndGetOrgId.ts @@ -8,7 +8,14 @@ type CtxWithAuth = | Pick | Pick -export async function checkUserIdentityAndGetOrgId(ctx: CtxWithAuth): Promise{ +// Org role assigned to the shared recruiter demo account (see convex/public/guest.ts). +// Kept out of that account's write paths so concurrent guests can't corrupt the demo data. +export const GUEST_ORG_ROLE = "org:guest"; + +export async function checkUserIdentityAndGetOrgId( + ctx: CtxWithAuth, + options?: { requireWrite?: boolean }, +): Promise{ const identity = await ctx.auth.getUserIdentity(); @@ -28,6 +35,13 @@ export async function checkUserIdentityAndGetOrgId(ctx: CtxWithAuth): Promise { - const orgId = await checkUserIdentityAndGetOrgId(ctx); - const conversation = await ctx.db.get(args.conversationId); + const orgId = await checkUserIdentityAndGetOrgId(ctx, { requireWrite: true }); + const conversation = await ctx.db.get(args.conversationId); if(!conversation) { throw new ConvexError({ diff --git a/packages/backend/convex/private/files.ts b/packages/backend/convex/private/files.ts index 4a5a9e1..7d467ab 100644 --- a/packages/backend/convex/private/files.ts +++ b/packages/backend/convex/private/files.ts @@ -66,7 +66,7 @@ export const addFile = action({ // Verify identity and get the organization ID - const orgId = await checkUserIdentityAndGetOrgId(ctx); + const orgId = await checkUserIdentityAndGetOrgId(ctx, { requireWrite: true }); //Only pro customers can add files for knowledge base const subscription = await ctx.runQuery(internal.system.subscriptions.getByOrganizationId, { @@ -164,7 +164,7 @@ export const deleteFile = mutation({ handler: async (ctx, args) => { // Verify user identity and get the organization ID - const orgId = await checkUserIdentityAndGetOrgId(ctx); + const orgId = await checkUserIdentityAndGetOrgId(ctx, { requireWrite: true }); // Ensure the namespace exists for this organization const namespace = await rag.getNamespace(ctx, {namespace: orgId}); diff --git a/packages/backend/convex/private/messages.ts b/packages/backend/convex/private/messages.ts index e703ec0..d0d34b8 100644 --- a/packages/backend/convex/private/messages.ts +++ b/packages/backend/convex/private/messages.ts @@ -53,7 +53,7 @@ export const create = mutation({ }, handler: async (ctx, args) => { - const orgId = await checkUserIdentityAndGetOrgId(ctx); + const orgId = await checkUserIdentityAndGetOrgId(ctx, { requireWrite: true }); const conversation = await ctx.db.get(args.conversationId); @@ -98,7 +98,7 @@ export const enhanceResponse = action({ }, handler: async (ctx, args) => { - const orgId = await checkUserIdentityAndGetOrgId(ctx); + const orgId = await checkUserIdentityAndGetOrgId(ctx, { requireWrite: true }); //Only pro customers can ENHANCE RESPONSE const subscription = await ctx.runQuery( diff --git a/packages/backend/convex/private/plugins.ts b/packages/backend/convex/private/plugins.ts index 73fa284..8517dcf 100644 --- a/packages/backend/convex/private/plugins.ts +++ b/packages/backend/convex/private/plugins.ts @@ -47,7 +47,7 @@ export const remove = mutation({ service: v.union(v.literal("vapi")) }, handler: async(ctx, args) => { - const orgId = await checkUserIdentityAndGetOrgId(ctx); + const orgId = await checkUserIdentityAndGetOrgId(ctx, { requireWrite: true }); const existingPlugin = await ctx.db .query("plugins") diff --git a/packages/backend/convex/private/secrets.ts b/packages/backend/convex/private/secrets.ts index 7ac4bed..63ef919 100644 --- a/packages/backend/convex/private/secrets.ts +++ b/packages/backend/convex/private/secrets.ts @@ -78,7 +78,7 @@ export const upsert = mutation({ }, handler: async(ctx, args) =>{ - const orgId = await checkUserIdentityAndGetOrgId(ctx); + const orgId = await checkUserIdentityAndGetOrgId(ctx, { requireWrite: true }); /* diff --git a/packages/backend/convex/private/widgetSettings.ts b/packages/backend/convex/private/widgetSettings.ts index df3d47a..4ccdbe8 100644 --- a/packages/backend/convex/private/widgetSettings.ts +++ b/packages/backend/convex/private/widgetSettings.ts @@ -45,7 +45,7 @@ export const upsert = mutation({ }, handler: async (ctx, args) => { - const orgId = await checkUserIdentityAndGetOrgId(ctx); + const orgId = await checkUserIdentityAndGetOrgId(ctx, { requireWrite: true }); const existingWidgetSettings = await ctx.db .query("widgetSettings") diff --git a/packages/backend/convex/public/guest.ts b/packages/backend/convex/public/guest.ts new file mode 100644 index 0000000..c4f6425 --- /dev/null +++ b/packages/backend/convex/public/guest.ts @@ -0,0 +1,42 @@ +import { ConvexError } from "convex/values"; +import { action } from "../_generated/server"; +import { createClerkClient } from "@clerk/backend"; + +if (!process.env.CLERK_SECRET_KEY) { + throw new Error( + "CLERK_SECRET_KEY environment variable is required" + ) +} + +const clerkClient = createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY }); + +/** + * Mints a single-use, short-lived Clerk sign-in ticket for the shared + * recruiter demo account (CLERK_GUEST_USER_ID). The client redeems it via + * `signIn.create({ strategy: "ticket", ticket })` to get a real, isolated + * Clerk session without ever handling a shared password. + * + * The guest account's org role (org:guest) is what actually protects the + * demo data — see checkUserIdentityAndGetOrgId's `requireWrite` guard. + */ +export const createSignInTicket = action({ + args: {}, + + handler: async () => { + const guestUserId = process.env.CLERK_GUEST_USER_ID; + + if (!guestUserId) { + throw new ConvexError({ + code: "NOT_CONFIGURED", + message: "Guest sign-in is not configured", + }); + } + + const signInToken = await clerkClient.signInTokens.createSignInToken({ + userId: guestUserId, + expiresInSeconds: 60, + }); + + return { ticket: signInToken.token }; + }, +});