Bernt Christian Egeland 5 ماه پیش
والد
کامیت
43052baf39

+ 2 - 1
package.json

@@ -14,7 +14,8 @@
     "check:fix": "biome check . --write",
     "check:fix": "biome check . --write",
     "prepare": "next-ws patch",
     "prepare": "next-ws patch",
     "test": "vitest run",
     "test": "vitest run",
-    "test:coverage": "vitest --coverage"
+    "test:coverage": "vitest --coverage",
+    "seed:demo": "node --experimental-strip-types --no-warnings plans/seed_dummy_data.ts"
   },
   },
   "dependencies": {
   "dependencies": {
     "@aws-sdk/client-ses": "^3.1011.0",
     "@aws-sdk/client-ses": "^3.1011.0",

+ 2 - 0
src/app/(public)/auth/sign-in/page.tsx

@@ -1,5 +1,6 @@
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { SignInForm } from './sign-in-form'
 import { SignInForm } from './sign-in-form'
+import { isDemoMode } from '@/lib/demo'
 
 
 export const dynamic = 'force-dynamic'
 export const dynamic = 'force-dynamic'
 
 
@@ -12,6 +13,7 @@ export default async function SignInPage() {
   return (
   return (
     <SignInForm
     <SignInForm
       registrationDisabled={regSetting?.value === 'true'}
       registrationDisabled={regSetting?.value === 'true'}
+      demoMode={isDemoMode}
     />
     />
   )
   )
 }
 }

+ 58 - 5
src/app/(public)/auth/sign-in/sign-in-form.tsx

@@ -1,6 +1,6 @@
 'use client'
 'use client'
 
 
-import { Suspense, useState } from 'react'
+import { Suspense, useEffect, useRef, useState } from 'react'
 import { useRouter, useSearchParams } from 'next/navigation'
 import { useRouter, useSearchParams } from 'next/navigation'
 import Link from 'next/link'
 import Link from 'next/link'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
@@ -9,10 +9,13 @@ import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Label } from '@/components/ui/label'
 import { Separator } from '@/components/ui/separator'
 import { Separator } from '@/components/ui/separator'
-import { Fingerprint, Loader2, XCircle } from 'lucide-react'
+import { Fingerprint, Loader2, PlayCircle, XCircle } from 'lucide-react'
 import { AuthLogo } from '@/components/auth-logo'
 import { AuthLogo } from '@/components/auth-logo'
 
 
-function SignInFormInner({ registrationDisabled }: { registrationDisabled: boolean }) {
+const DEMO_EMAIL = 'demo@torqvoice.com'
+const DEMO_PASSWORD = 'demo'
+
+function SignInFormInner({ registrationDisabled, demoMode = false }: { registrationDisabled: boolean; demoMode?: boolean }) {
   const t = useTranslations('auth.signIn')
   const t = useTranslations('auth.signIn')
   const tc = useTranslations('common')
   const tc = useTranslations('common')
   const [email, setEmail] = useState('')
   const [email, setEmail] = useState('')
@@ -48,6 +51,34 @@ function SignInFormInner({ registrationDisabled }: { registrationDisabled: boole
     }
     }
   }
   }
 
 
+  const handleDemoSignIn = async () => {
+    setLoading(true)
+    setError('')
+    try {
+      const result = await signIn.email({ email: DEMO_EMAIL, password: DEMO_PASSWORD })
+      if (result.error) {
+        setError(result.error.message || tc('errors.unexpected'))
+        setLoading(false)
+      } else {
+        router.push('/')
+        router.refresh()
+      }
+    } catch {
+      setError(tc('errors.unexpected'))
+      setLoading(false)
+    }
+  }
+
+  // Auto-sign-in when arriving with ?demo=1 (deep-link from marketing site)
+  const autoTriggered = useRef(false)
+  useEffect(() => {
+    if (!demoMode || autoTriggered.current) return
+    if (searchParams.get('demo') !== '1') return
+    autoTriggered.current = true
+    void handleDemoSignIn()
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [demoMode, searchParams])
+
   const handlePasskeySignIn = async () => {
   const handlePasskeySignIn = async () => {
     setPasskeyLoading(true)
     setPasskeyLoading(true)
     setError('')
     setError('')
@@ -87,6 +118,28 @@ function SignInFormInner({ registrationDisabled }: { registrationDisabled: boole
         </div>
         </div>
       )}
       )}
 
 
+      {demoMode && (
+        <div className="mb-6">
+          <Button
+            type="button"
+            className="h-11 w-full"
+            disabled={loading}
+            onClick={handleDemoSignIn}
+          >
+            {loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <PlayCircle className="mr-2 h-4 w-4" />}
+            Try the demo
+          </Button>
+          <p className="mt-2 text-center text-xs text-muted-foreground">
+            Or sign in manually with <code className="rounded bg-muted px-1">{DEMO_EMAIL}</code> / <code className="rounded bg-muted px-1">{DEMO_PASSWORD}</code>
+          </p>
+          <div className="relative my-4">
+            <div className="absolute inset-0 flex items-center">
+              <Separator className="w-full" />
+            </div>
+          </div>
+        </div>
+      )}
+
       <form onSubmit={handleSubmit} className="space-y-4">
       <form onSubmit={handleSubmit} className="space-y-4">
         <div className="space-y-2">
         <div className="space-y-2">
           <Label htmlFor="email">{tc('form.email')}</Label>
           <Label htmlFor="email">{tc('form.email')}</Label>
@@ -169,7 +222,7 @@ function SignInFormInner({ registrationDisabled }: { registrationDisabled: boole
   )
   )
 }
 }
 
 
-export function SignInForm({ registrationDisabled }: { registrationDisabled: boolean }) {
+export function SignInForm({ registrationDisabled, demoMode = false }: { registrationDisabled: boolean; demoMode?: boolean }) {
   return (
   return (
     <div className="grid-bg flex min-h-screen items-center justify-center p-4">
     <div className="grid-bg flex min-h-screen items-center justify-center p-4">
       <div className="absolute inset-0 overflow-hidden">
       <div className="absolute inset-0 overflow-hidden">
@@ -177,7 +230,7 @@ export function SignInForm({ registrationDisabled }: { registrationDisabled: boo
         <div className="absolute -bottom-40 -left-40 h-96 w-96 rounded-full bg-primary/5 blur-3xl" />
         <div className="absolute -bottom-40 -left-40 h-96 w-96 rounded-full bg-primary/5 blur-3xl" />
       </div>
       </div>
       <Suspense>
       <Suspense>
-        <SignInFormInner registrationDisabled={registrationDisabled} />
+        <SignInFormInner registrationDisabled={registrationDisabled} demoMode={demoMode} />
       </Suspense>
       </Suspense>
     </div>
     </div>
   )
   )

+ 5 - 0
src/app/api/protected/backup/import-invoice-ninja/route.ts

@@ -1,6 +1,7 @@
 import { NextRequest, NextResponse } from "next/server";
 import { NextRequest, NextResponse } from "next/server";
 import { getAuthContext } from "@/lib/get-auth-context";
 import { getAuthContext } from "@/lib/get-auth-context";
 import { db } from "@/lib/db";
 import { db } from "@/lib/db";
+import { isDemoMode } from "@/lib/demo";
 import { resolveInvoicePrefix } from "@/lib/invoice-utils";
 import { resolveInvoicePrefix } from "@/lib/invoice-utils";
 import { mkdir, writeFile, rm } from "fs/promises";
 import { mkdir, writeFile, rm } from "fs/promises";
 import path from "path";
 import path from "path";
@@ -208,6 +209,10 @@ function getMimeType(filename: string): string {
 // ── API Route ────────────────────────────────────────────────────────────────
 // ── API Route ────────────────────────────────────────────────────────────────
 
 
 export async function POST(request: NextRequest) {
 export async function POST(request: NextRequest) {
+  if (isDemoMode) {
+    return NextResponse.json({ error: "Data import is disabled on the demo." }, { status: 403 });
+  }
+
   const ctx = await getAuthContext();
   const ctx = await getAuthContext();
   if (!ctx) {
   if (!ctx) {
     return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
     return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

+ 5 - 0
src/app/api/protected/backup/import-lubelog/route.ts

@@ -1,6 +1,7 @@
 import { NextRequest, NextResponse } from "next/server";
 import { NextRequest, NextResponse } from "next/server";
 import { getAuthContext } from "@/lib/get-auth-context";
 import { getAuthContext } from "@/lib/get-auth-context";
 import { db } from "@/lib/db";
 import { db } from "@/lib/db";
+import { isDemoMode } from "@/lib/demo";
 import { resolveInvoicePrefix } from "@/lib/invoice-utils";
 import { resolveInvoicePrefix } from "@/lib/invoice-utils";
 import { mkdir, writeFile, stat, readdir, rm } from "fs/promises";
 import { mkdir, writeFile, stat, readdir, rm } from "fs/promises";
 import { readFileSync } from "fs";
 import { readFileSync } from "fs";
@@ -193,6 +194,10 @@ async function extractZipToTempDir(zipBuffer: Buffer): Promise<string> {
 // ── API Route ────────────────────────────────────────────────────────────────
 // ── API Route ────────────────────────────────────────────────────────────────
 
 
 export async function POST(request: NextRequest) {
 export async function POST(request: NextRequest) {
+  if (isDemoMode) {
+    return NextResponse.json({ error: "Data import is disabled on the demo." }, { status: 403 });
+  }
+
   const ctx = await getAuthContext();
   const ctx = await getAuthContext();
   if (!ctx) {
   if (!ctx) {
     return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
     return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

+ 5 - 0
src/app/api/protected/backup/import/route.ts

@@ -1,6 +1,7 @@
 import { NextRequest, NextResponse } from "next/server";
 import { NextRequest, NextResponse } from "next/server";
 import { getAuthContext } from "@/lib/get-auth-context";
 import { getAuthContext } from "@/lib/get-auth-context";
 import { db } from "@/lib/db";
 import { db } from "@/lib/db";
+import { isDemoMode } from "@/lib/demo";
 import { Prisma } from "@/generated/prisma/client";
 import { Prisma } from "@/generated/prisma/client";
 import JSZip from "jszip";
 import JSZip from "jszip";
 import { mkdir, rm, writeFile } from "fs/promises";
 import { mkdir, rm, writeFile } from "fs/promises";
@@ -122,6 +123,10 @@ function rewriteFileUrl(
 }
 }
 
 
 export async function POST(request: NextRequest) {
 export async function POST(request: NextRequest) {
+  if (isDemoMode) {
+    return NextResponse.json({ error: "Backup import is disabled on the demo." }, { status: 403 });
+  }
+
   const ctx = await getAuthContext();
   const ctx = await getAuthContext();
 
 
   if (!ctx) {
   if (!ctx) {

+ 2 - 0
src/app/layout.tsx

@@ -10,6 +10,7 @@ import { TooltipProvider } from '@/components/ui/tooltip'
 import { PWAServiceWorker } from '@/components/pwa-service-worker'
 import { PWAServiceWorker } from '@/components/pwa-service-worker'
 import { PostHogProvider } from '@/components/posthog-provider'
 import { PostHogProvider } from '@/components/posthog-provider'
 import { isCloudMode } from '@/lib/features'
 import { isCloudMode } from '@/lib/features'
+import { DemoBanner } from '@/components/demo-banner'
 import './globals.css'
 import './globals.css'
 
 
 const geistSans = Geist({
 const geistSans = Geist({
@@ -105,6 +106,7 @@ export default async function RootLayout({
             <ThemeProvider defaultTheme="dark">
             <ThemeProvider defaultTheme="dark">
               <QueryProvider>
               <QueryProvider>
                 <TooltipProvider>
                 <TooltipProvider>
+                  <DemoBanner />
                   {children}
                   {children}
                   <GlassModal />
                   <GlassModal />
                   <Toaster richColors position="bottom-right" />
                   <Toaster richColors position="bottom-right" />

+ 17 - 0
src/components/demo-banner.tsx

@@ -0,0 +1,17 @@
+import { isDemoMode } from "@/lib/demo";
+
+export function DemoBanner() {
+  if (!isDemoMode) return null;
+
+  return (
+    <div className="bg-amber-500 text-center text-xs font-medium text-amber-950 py-1.5 px-4">
+      Demo instance — data resets hourly. Outgoing email, SMS, Telegram and team invites are disabled.{" "}
+      <a
+        href="https://torqvoice.com"
+        className="underline underline-offset-2 hover:text-amber-900"
+      >
+        Install your own →
+      </a>
+    </div>
+  );
+}

+ 5 - 0
src/features/email/Actions/emailActions.ts

@@ -15,6 +15,7 @@ import { getFeatures } from "@/lib/features";
 import { getTorqvoiceLogoDataUri } from "@/lib/torqvoice-branding";
 import { getTorqvoiceLogoDataUri } from "@/lib/torqvoice-branding";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { requireFeature } from "@/lib/features";
 import { requireFeature } from "@/lib/features";
+import { demoGuard } from "@/lib/demo";
 
 
 async function getWorkshopSettings(organizationId: string) {
 async function getWorkshopSettings(organizationId: string) {
   const [settings, org] = await Promise.all([
   const [settings, org] = await Promise.all([
@@ -76,6 +77,7 @@ export async function sendQuoteEmail(input: {
   message?: string;
   message?: string;
 }) {
 }) {
   return withAuth(async ({ organizationId }) => {
   return withAuth(async ({ organizationId }) => {
+    demoGuard();
     await requireFeature(organizationId, "smtp");
     await requireFeature(organizationId, "smtp");
 
 
     const { quoteId, recipientEmail, message } = input;
     const { quoteId, recipientEmail, message } = input;
@@ -176,6 +178,7 @@ export async function sendNotificationEmail(input: {
   body: string;
   body: string;
 }) {
 }) {
   return withAuth(async ({ organizationId }) => {
   return withAuth(async ({ organizationId }) => {
+    demoGuard();
     await requireFeature(organizationId, "smtp");
     await requireFeature(organizationId, "smtp");
 
 
     const settings = await getWorkshopSettings(organizationId);
     const settings = await getWorkshopSettings(organizationId);
@@ -211,6 +214,7 @@ export async function sendInvoiceEmail(input: {
   message?: string;
   message?: string;
 }) {
 }) {
   return withAuth(async ({ organizationId }) => {
   return withAuth(async ({ organizationId }) => {
+    demoGuard();
     await requireFeature(organizationId, "smtp");
     await requireFeature(organizationId, "smtp");
 
 
     const { serviceRecordId, recipientEmail, message } = input;
     const { serviceRecordId, recipientEmail, message } = input;
@@ -312,6 +316,7 @@ export async function sendInspectionEmail(input: {
   message?: string;
   message?: string;
 }) {
 }) {
   return withAuth(async ({ organizationId }) => {
   return withAuth(async ({ organizationId }) => {
+    demoGuard();
     await requireFeature(organizationId, "smtp");
     await requireFeature(organizationId, "smtp");
 
 
     const { inspectionId, recipientEmail, message } = input;
     const { inspectionId, recipientEmail, message } = input;

+ 2 - 0
src/features/sms/Actions/smsActions.ts

@@ -7,6 +7,7 @@ import { requireFeature } from "@/lib/features";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
 import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
 import { SMS_TEMPLATE_DEFAULTS } from "@/lib/sms-templates";
 import { SMS_TEMPLATE_DEFAULTS } from "@/lib/sms-templates";
+import { demoGuard } from "@/lib/demo";
 
 
 export async function sendSmsToCustomer(input: {
 export async function sendSmsToCustomer(input: {
   customerId: string;
   customerId: string;
@@ -16,6 +17,7 @@ export async function sendSmsToCustomer(input: {
 }) {
 }) {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
+      demoGuard();
       await requireFeature(organizationId, "sms");
       await requireFeature(organizationId, "sms");
 
 
       const customer = await db.customer.findFirst({
       const customer = await db.customer.findFirst({

+ 3 - 0
src/features/subscription/Actions/subscriptionActions.ts

@@ -3,9 +3,11 @@
 import { withAuth } from "@/lib/with-auth";
 import { withAuth } from "@/lib/with-auth";
 import { db } from "@/lib/db";
 import { db } from "@/lib/db";
 import { getStripeClient } from "@/lib/stripe-config";
 import { getStripeClient } from "@/lib/stripe-config";
+import { demoGuard } from "@/lib/demo";
 
 
 export async function cancelSubscription() {
 export async function cancelSubscription() {
   return withAuth(async ({ organizationId }) => {
   return withAuth(async ({ organizationId }) => {
+    demoGuard();
     const subscription = await db.subscription.findUnique({
     const subscription = await db.subscription.findUnique({
       where: { organizationId },
       where: { organizationId },
     });
     });
@@ -37,6 +39,7 @@ export async function cancelSubscription() {
 
 
 export async function resumeSubscription() {
 export async function resumeSubscription() {
   return withAuth(async ({ organizationId }) => {
   return withAuth(async ({ organizationId }) => {
+    demoGuard();
     const subscription = await db.subscription.findUnique({
     const subscription = await db.subscription.findUnique({
       where: { organizationId },
       where: { organizationId },
     });
     });

+ 4 - 0
src/features/team/Actions/acceptInvitation.ts

@@ -4,8 +4,12 @@ import { cookies } from "next/headers";
 import { db } from "@/lib/db";
 import { db } from "@/lib/db";
 import { getCachedSession } from "@/lib/cached-session";
 import { getCachedSession } from "@/lib/cached-session";
 import { acceptInvitationSchema } from "../Schema/teamSchema";
 import { acceptInvitationSchema } from "../Schema/teamSchema";
+import { isDemoMode } from "@/lib/demo";
 
 
 export async function acceptInvitation(input: unknown) {
 export async function acceptInvitation(input: unknown) {
+  if (isDemoMode) {
+    return { success: false, error: "This action is disabled on the demo." };
+  }
   try {
   try {
     const data = acceptInvitationSchema.parse(input);
     const data = acceptInvitationSchema.parse(input);
 
 

+ 2 - 0
src/features/team/Actions/sendInvitation.ts

@@ -7,9 +7,11 @@ import { sendInvitationSchema } from "../Schema/teamSchema";
 import { sendOrgMail, getOrgFromAddress } from "@/lib/email";
 import { sendOrgMail, getOrgFromAddress } from "@/lib/email";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { revalidatePath } from "next/cache";
 import { revalidatePath } from "next/cache";
+import { demoGuard } from "@/lib/demo";
 
 
 export async function sendInvitation(input: unknown) {
 export async function sendInvitation(input: unknown) {
   return withAuth(async ({ userId, organizationId }) => {
   return withAuth(async ({ userId, organizationId }) => {
+    demoGuard();
     const data = sendInvitationSchema.parse(input);
     const data = sendInvitationSchema.parse(input);
 
 
     // Fetch membership for organization name (authorization is handled by withAuth's requiredPermissions)
     // Fetch membership for organization name (authorization is handled by withAuth's requiredPermissions)

+ 2 - 0
src/features/telegram/Actions/telegramActions.ts

@@ -5,6 +5,7 @@ import { withAuth } from "@/lib/with-auth";
 import { sendTelegramMessage } from "@/lib/telegram";
 import { sendTelegramMessage } from "@/lib/telegram";
 import { requireFeature } from "@/lib/features";
 import { requireFeature } from "@/lib/features";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { demoGuard } from "@/lib/demo";
 
 
 export async function sendTelegramToCustomer(input: {
 export async function sendTelegramToCustomer(input: {
   customerId: string;
   customerId: string;
@@ -14,6 +15,7 @@ export async function sendTelegramToCustomer(input: {
 }) {
 }) {
   return withAuth(
   return withAuth(
     async ({ organizationId }) => {
     async ({ organizationId }) => {
+      demoGuard();
       await requireFeature(organizationId, "telegram");
       await requireFeature(organizationId, "telegram");
 
 
       const customer = await db.customer.findFirst({
       const customer = await db.customer.findFirst({

+ 28 - 0
src/lib/demo.ts

@@ -0,0 +1,28 @@
+/**
+ * Demo-mode utilities.
+ *
+ * When `DEMO_MODE=true`, actions that would send external messages,
+ * invite users, or touch billing/admin-level state are blocked so the
+ * public demo instance at demo.torqvoice.com can't be abused.
+ *
+ * Regular CRUD (customers, vehicles, service records, settings, ...) is
+ * intentionally NOT blocked — demo visitors should be able to play with
+ * the app. The hourly reset cron reverts their changes.
+ */
+
+export const isDemoMode = process.env.DEMO_MODE === "true";
+
+/** Credentials the sign-in page auto-fills. The seed script provisions this user. */
+export const DEMO_USER_EMAIL = process.env.DEMO_USER_EMAIL || "demo@torqvoice.com";
+export const DEMO_USER_PASSWORD = process.env.DEMO_USER_PASSWORD || "demo";
+
+/**
+ * Throws inside a server action when demo mode is active. `withAuth`
+ * catches the error and surfaces it as `{ success: false, error }` to
+ * the client, which shows it as a toast.
+ */
+export function demoGuard(): void {
+  if (isDemoMode) {
+    throw new Error("This action is disabled on the demo. Install Torqvoice on your own server to use it.");
+  }
+}