Просмотр исходного кода

Merge branch 'desktop-app' of github.com:Torqvoice/torqvoice into desktop-app

Bernt Christian Egeland 7 месяцев назад
Родитель
Сommit
26eb750cfc

+ 5 - 1
README.md

@@ -26,7 +26,11 @@
 
 
 Torqvoice is a self-hosted workshop management platform built for automotive service businesses. It replaces scattered tools with a single place to manage customers, vehicles, service records, quotes, invoicing, inventory, billing all with a clean modern UI.
 Torqvoice is a self-hosted workshop management platform built for automotive service businesses. It replaces scattered tools with a single place to manage customers, vehicles, service records, quotes, invoicing, inventory, billing all with a clean modern UI.
 
 
-## Deploy with Docker Compose
+## Installation
+
+For full setup instructions, see the [Installation Guide](https://torqvoice.com/docs/installation).
+
+### Quick Start with Docker Compose
 
 
 Create a `docker-compose.yml`:
 Create a `docker-compose.yml`:
 
 

BIN
public/icons/apple-touch-icon.png


BIN
public/icons/icon-192.png


BIN
public/icons/icon-512.png


+ 2 - 0
public/sw.js

@@ -0,0 +1,2 @@
+self.addEventListener('install', () => self.skipWaiting())
+self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()))

+ 2 - 0
src/app/(authenticated)/layout.tsx

@@ -12,6 +12,7 @@ import { DateSettingsProvider } from "@/components/date-settings-context";
 import { getCachedMembership } from "@/lib/cached-session";
 import { getCachedMembership } from "@/lib/cached-session";
 import { hasPermission, PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { hasPermission, PermissionAction, PermissionSubject } from "@/lib/permissions";
 import { OnlineTracker } from "@/components/online-tracker";
 import { OnlineTracker } from "@/components/online-tracker";
+import { InstallBanner } from "@/components/pwa-install-prompt";
 import { db } from "@/lib/db";
 import { db } from "@/lib/db";
 
 
 export default async function DashboardLayout({
 export default async function DashboardLayout({
@@ -99,6 +100,7 @@ export default async function DashboardLayout({
         <SearchCommand />
         <SearchCommand />
         {isOwnerOrAdmin && <NotificationInitializer />}
         {isOwnerOrAdmin && <NotificationInitializer />}
         <OnlineTracker />
         <OnlineTracker />
+        <InstallBanner />
       </ConfirmProvider>
       </ConfirmProvider>
       </DateSettingsProvider>
       </DateSettingsProvider>
     </SidebarProvider>
     </SidebarProvider>

+ 35 - 44
src/app/api/desktop/v1/me/route.ts

@@ -1,59 +1,36 @@
 import { NextResponse } from "next/server";
 import { NextResponse } from "next/server";
 import { db } from "@/lib/db";
 import { db } from "@/lib/db";
+import { withDesktopAuth } from "@/lib/with-desktop-auth";
 import { rateLimit } from "@/lib/rate-limit";
 import { rateLimit } from "@/lib/rate-limit";
 
 
 export async function GET(request: Request) {
 export async function GET(request: Request) {
-  const limited = rateLimit(request);
-  if (limited) return limited;
-
-  const authHeader = request.headers.get("Authorization");
-  if (!authHeader?.startsWith("Bearer ")) {
-    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-  }
-  const token = authHeader.slice(7);
-
-  const session = await db.session.findFirst({
-    where: { token, expiresAt: { gt: new Date() } },
-    select: { userId: true },
-  });
-
-  if (!session) {
-    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
-  }
-
-  const user = await db.user.findUnique({
-    where: { id: session.userId },
-    select: { id: true, name: true, email: true },
-  });
+  return withDesktopAuth(request, async ({ userId, organizationId }) => {
+    const user = await db.user.findUnique({
+      where: { id: userId },
+      select: { id: true, name: true, email: true },
+    });
 
 
-  if (!user) {
-    return NextResponse.json({ error: "User not found" }, { status: 401 });
-  }
+    if (!user) {
+      return NextResponse.json({ error: "User not found" }, { status: 401 });
+    }
 
 
-  // Get active organization
-  const membership = await db.organizationMember.findFirst({
-    where: { userId: session.userId },
-    select: {
-      organizationId: true,
-      organization: { select: { id: true, name: true } },
-    },
-  });
+    const org = await db.organization.findUnique({
+      where: { id: organizationId },
+      select: { id: true, name: true },
+    });
 
 
-  // Get company logo
-  let companyLogo: string | null = null;
-  if (membership?.organizationId) {
+    // Get company logo
+    let companyLogo: string | null = null;
     const logoSetting = await db.appSetting.findFirst({
     const logoSetting = await db.appSetting.findFirst({
-      where: { organizationId: membership.organizationId, key: "workshop.logo" },
+      where: { organizationId, key: "workshop.logo" },
       select: { value: true },
       select: { value: true },
     });
     });
     companyLogo = logoSetting?.value || null;
     companyLogo = logoSetting?.value || null;
-  }
 
 
-  return NextResponse.json({
-    user,
-    organization: membership?.organization
-      ? { ...membership.organization, logo: companyLogo }
-      : null,
+    return NextResponse.json({
+      user,
+      organization: org ? { ...org, logo: companyLogo } : null,
+    });
   });
   });
 }
 }
 
 
@@ -67,7 +44,21 @@ export async function DELETE(request: Request) {
   }
   }
   const token = authHeader.slice(7);
   const token = authHeader.slice(7);
 
 
-  await db.session.deleteMany({ where: { token } });
+  if (!token || token.length > 256) {
+    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+  }
+
+  // Only delete if the session is valid and belongs to a real user
+  const session = await db.session.findFirst({
+    where: { token, expiresAt: { gt: new Date() } },
+    select: { id: true },
+  });
+
+  if (!session) {
+    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+  }
+
+  await db.session.delete({ where: { id: session.id } });
 
 
   return new NextResponse(null, { status: 204 });
   return new NextResponse(null, { status: 204 });
 }
 }

+ 5 - 0
src/app/api/desktop/v1/sync/route.ts

@@ -1,6 +1,7 @@
 import { NextResponse } from "next/server";
 import { NextResponse } from "next/server";
 import { db } from "@/lib/db";
 import { db } from "@/lib/db";
 import { withDesktopAuth } from "@/lib/with-desktop-auth";
 import { withDesktopAuth } from "@/lib/with-desktop-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
 
 
 export async function GET(request: Request) {
 export async function GET(request: Request) {
   return withDesktopAuth(request, async ({ organizationId }) => {
   return withDesktopAuth(request, async ({ organizationId }) => {
@@ -218,5 +219,9 @@ export async function GET(request: Request) {
         deletedAt: d.deletedAt,
         deletedAt: d.deletedAt,
       })),
       })),
     });
     });
+  }, {
+    requiredPermissions: [
+      { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
+    ],
   });
   });
 }
 }

+ 10 - 1
src/app/api/desktop/v1/work-orders/[id]/email/route.ts

@@ -10,6 +10,15 @@ import React from "react";
 import { readFile } from "fs/promises";
 import { readFile } from "fs/promises";
 import { resolveUploadPath } from "@/lib/resolve-upload-path";
 import { resolveUploadPath } from "@/lib/resolve-upload-path";
 
 
+function escapeHtml(str: string): string {
+  return str
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/"/g, "&quot;")
+    .replace(/'/g, "&#39;");
+}
+
 async function getWorkshopSettings(organizationId: string) {
 async function getWorkshopSettings(organizationId: string) {
   const settings = await db.appSetting.findMany({ where: { organizationId } });
   const settings = await db.appSetting.findMany({ where: { organizationId } });
   const map: Record<string, string> = {};
   const map: Record<string, string> = {};
@@ -115,7 +124,7 @@ export async function POST(
           <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
           <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
             <h2>Invoice ${invoiceNum}</h2>
             <h2>Invoice ${invoiceNum}</h2>
             <p>Please find your invoice attached.</p>
             <p>Please find your invoice attached.</p>
-            ${message ? `<p>${message}</p>` : ""}
+            ${message ? `<p>${escapeHtml(message)}</p>` : ""}
             ${publicLink ? `<p><a href="${publicLink}" style="color: #2563eb;">View Invoice Online</a></p>` : ""}
             ${publicLink ? `<p><a href="${publicLink}" style="color: #2563eb;">View Invoice Online</a></p>` : ""}
             <hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;" />
             <hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;" />
             <p style="color: #666; font-size: 14px;">
             <p style="color: #666; font-size: 14px;">

+ 12 - 0
src/app/layout.tsx

@@ -7,6 +7,7 @@ import { ThemeProvider } from '@/components/theme-provider'
 import { QueryProvider } from '@/lib/query-provider'
 import { QueryProvider } from '@/lib/query-provider'
 import { GlassModal } from '@/components/glass-modal'
 import { GlassModal } from '@/components/glass-modal'
 import { TooltipProvider } from '@/components/ui/tooltip'
 import { TooltipProvider } from '@/components/ui/tooltip'
+import { PWAServiceWorker } from '@/components/pwa-service-worker'
 import './globals.css'
 import './globals.css'
 
 
 const geistSans = Geist({
 const geistSans = Geist({
@@ -21,6 +22,14 @@ const geistMono = Geist_Mono({
 
 
 export const metadata: Metadata = {
 export const metadata: Metadata = {
   metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'),
   metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'),
+  appleWebApp: {
+    capable: true,
+    statusBarStyle: 'black-translucent',
+    title: 'TorqVoice',
+  },
+  other: {
+    'mobile-web-app-capable': 'yes',
+  },
   title: {
   title: {
     default: 'TorqVoice - Workshop Management Platform',
     default: 'TorqVoice - Workshop Management Platform',
     template: '%s | TorqVoice',
     template: '%s | TorqVoice',
@@ -75,6 +84,8 @@ export default async function RootLayout({
   return (
   return (
     <html lang={locale} suppressHydrationWarning>
     <html lang={locale} suppressHydrationWarning>
       <head>
       <head>
+        <meta name="theme-color" content="#09090b" />
+        <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
         <script
         <script
           dangerouslySetInnerHTML={{
           dangerouslySetInnerHTML={{
             __html: `(function(){try{var t=localStorage.getItem('torqvoice-theme')||'dark';if(t==='system'){t=matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'}document.documentElement.classList.add(t)}catch(e){}})()`,
             __html: `(function(){try{var t=localStorage.getItem('torqvoice-theme')||'dark';if(t==='system'){t=matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'}document.documentElement.classList.add(t)}catch(e){}})()`,
@@ -89,6 +100,7 @@ export default async function RootLayout({
                 {children}
                 {children}
                 <GlassModal />
                 <GlassModal />
                 <Toaster richColors position="bottom-right" />
                 <Toaster richColors position="bottom-right" />
+                <PWAServiceWorker />
               </TooltipProvider>
               </TooltipProvider>
             </QueryProvider>
             </QueryProvider>
           </ThemeProvider>
           </ThemeProvider>

+ 16 - 0
src/app/manifest.ts

@@ -0,0 +1,16 @@
+import type { MetadataRoute } from 'next'
+
+export default function manifest(): MetadataRoute.Manifest {
+  return {
+    name: 'TorqVoice - Workshop Management',
+    short_name: 'TorqVoice',
+    start_url: '/',
+    display: 'standalone',
+    background_color: '#09090b',
+    theme_color: '#09090b',
+    icons: [
+      { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
+      { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
+    ],
+  }
+}

+ 2 - 0
src/components/app-sidebar.tsx

@@ -68,6 +68,7 @@ import { createNewOrganization } from '@/features/team/Actions/createNewOrganiza
 import type { PlanFeatures } from '@/lib/features'
 import type { PlanFeatures } from '@/lib/features'
 import { useTheme } from '@/components/theme-provider'
 import { useTheme } from '@/components/theme-provider'
 import { NotificationBell, NotificationPanel } from '@/features/notifications/Components/NotificationPanel'
 import { NotificationBell, NotificationPanel } from '@/features/notifications/Components/NotificationPanel'
+import { SidebarInstallButton } from '@/components/pwa-install-prompt'
 
 
 type OrgInfo = { id: string; name: string; role: string }
 type OrgInfo = { id: string; name: string; role: string }
 
 
@@ -305,6 +306,7 @@ export function AppSidebar({
             </SidebarMenu>
             </SidebarMenu>
           </SidebarGroup>
           </SidebarGroup>
         )}
         )}
+        <SidebarInstallButton />
       </SidebarContent>
       </SidebarContent>
       <SidebarFooter>
       <SidebarFooter>
         <SidebarMenu>
         <SidebarMenu>

+ 156 - 0
src/components/pwa-install-prompt.tsx

@@ -0,0 +1,156 @@
+'use client'
+
+import { useEffect, useState, useCallback } from 'react'
+import Image from 'next/image'
+import { Download, X } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import {
+  SidebarGroup,
+  SidebarMenu,
+  SidebarMenuButton,
+  SidebarMenuItem,
+} from '@/components/ui/sidebar'
+
+interface BeforeInstallPromptEvent extends Event {
+  prompt(): Promise<void>
+  userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
+}
+
+const DISMISS_KEY = 'pwa-install-dismissed'
+const DISMISS_DURATION = 30 * 24 * 60 * 60 * 1000 // 30 days
+
+function isDismissed(): boolean {
+  try {
+    const val = localStorage.getItem(DISMISS_KEY)
+    if (!val) return false
+    return Date.now() - Number(val) < DISMISS_DURATION
+  } catch {
+    return false
+  }
+}
+
+function dismiss() {
+  try {
+    localStorage.setItem(DISMISS_KEY, String(Date.now()))
+  } catch {
+    // localStorage unavailable
+  }
+}
+
+function isStandalone(): boolean {
+  return (
+    window.matchMedia('(display-mode: standalone)').matches ||
+    ('standalone' in navigator && (navigator as unknown as { standalone: boolean }).standalone)
+  )
+}
+
+function isIOSSafari(): boolean {
+  const ua = navigator.userAgent
+  return /iP(hone|od|ad)/.test(ua) && /Safari/.test(ua) && !/CriOS|FxiOS|EdgiOS/.test(ua)
+}
+
+function useInstallPrompt() {
+  const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
+  const [isIOS, setIsIOS] = useState(false)
+  const [canShow, setCanShow] = useState(false)
+
+  useEffect(() => {
+    if (isStandalone() || isDismissed()) return
+
+    if (isIOSSafari()) {
+      setIsIOS(true)
+      setCanShow(true)
+      return
+    }
+
+    const handler = (e: Event) => {
+      e.preventDefault()
+      setDeferredPrompt(e as BeforeInstallPromptEvent)
+      setCanShow(true)
+    }
+
+    window.addEventListener('beforeinstallprompt', handler)
+    return () => window.removeEventListener('beforeinstallprompt', handler)
+  }, [])
+
+  const install = useCallback(async () => {
+    if (!deferredPrompt) return
+    await deferredPrompt.prompt()
+    const { outcome } = await deferredPrompt.userChoice
+    if (outcome === 'accepted') {
+      setCanShow(false)
+    }
+    setDeferredPrompt(null)
+  }, [deferredPrompt])
+
+  const hide = useCallback(() => {
+    dismiss()
+    setCanShow(false)
+  }, [])
+
+  return { canShow, isIOS, install, hide }
+}
+
+export function InstallBanner() {
+  const { canShow, isIOS, install, hide } = useInstallPrompt()
+
+  if (!canShow) return null
+
+  return (
+    <div className="fixed bottom-0 left-0 right-0 z-50 animate-in slide-in-from-bottom duration-300">
+      <div className="border-t bg-background/80 backdrop-blur-lg">
+        <div className="mx-auto flex max-w-xl items-center gap-3 px-4 py-3">
+          <Image
+            src="/icons/icon-192.png"
+            alt="TorqVoice"
+            width={40}
+            height={40}
+            className="shrink-0 rounded-lg"
+          />
+          <div className="min-w-0 flex-1">
+            <p className="text-sm font-medium">Install TorqVoice</p>
+            <p className="truncate text-xs text-muted-foreground">
+              {isIOS
+                ? 'Tap Share then "Add to Home Screen"'
+                : 'Add to your home screen for quick access'}
+            </p>
+          </div>
+          {!isIOS && (
+            <Button size="sm" onClick={install}>
+              Install
+            </Button>
+          )}
+          <button
+            onClick={hide}
+            className="shrink-0 rounded-full p-1 text-muted-foreground hover:text-foreground"
+            aria-label="Dismiss"
+          >
+            <X className="size-4" />
+          </button>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export function SidebarInstallButton() {
+  const { canShow, isIOS, install, hide } = useInstallPrompt()
+
+  if (!canShow) return null
+
+  return (
+    <SidebarGroup>
+      <SidebarMenu>
+        <SidebarMenuItem>
+          <SidebarMenuButton
+            onClick={isIOS ? hide : install}
+            tooltip={isIOS ? 'Tap Share > Add to Home Screen' : 'Install App'}
+          >
+            <Download className="size-4" />
+            <span className="font-medium">Install App</span>
+          </SidebarMenuButton>
+        </SidebarMenuItem>
+      </SidebarMenu>
+    </SidebarGroup>
+  )
+}

+ 15 - 0
src/components/pwa-service-worker.tsx

@@ -0,0 +1,15 @@
+'use client'
+
+import { useEffect } from 'react'
+
+export function PWAServiceWorker() {
+  useEffect(() => {
+    if ('serviceWorker' in navigator) {
+      navigator.serviceWorker.register('/sw.js').catch(() => {
+        // Service worker registration failed — ignore silently
+      })
+    }
+  }, [])
+
+  return null
+}

+ 2 - 1
src/features/quotes/Components/QuotePDF.tsx

@@ -1,6 +1,7 @@
 import { Document, Page, Text, View, Image } from '@react-pdf/renderer'
 import { Document, Page, Text, View, Image } from '@react-pdf/renderer'
 import { formatCurrency, formatDateForPdf, DEFAULT_DATE_FORMAT } from '@/lib/format'
 import { formatCurrency, formatDateForPdf, DEFAULT_DATE_FORMAT } from '@/lib/format'
 import { createStyles, gray, getFontBold } from '@/features/vehicles/Components/invoice-pdf/styles'
 import { createStyles, gray, getFontBold } from '@/features/vehicles/Components/invoice-pdf/styles'
+import { HtmlToPdf } from '@/features/vehicles/Components/invoice-pdf/Notes'
 import type { TemplateConfig } from '@/features/vehicles/Components/invoice-pdf/types'
 import type { TemplateConfig } from '@/features/vehicles/Components/invoice-pdf/types'
 
 
 function fillTemplate(template: string, values: Record<string, string>): string {
 function fillTemplate(template: string, values: Record<string, string>): string {
@@ -481,7 +482,7 @@ export function QuotePDF({
         {data.description && (
         {data.description && (
           <View style={styles.notesSection}>
           <View style={styles.notesSection}>
             <Text style={styles.notesLabel}>Description</Text>
             <Text style={styles.notesLabel}>Description</Text>
-            <Text style={styles.notesText}>{data.description}</Text>
+            <HtmlToPdf html={data.description} baseStyle={styles.notesText} fontBold={fontBold} />
           </View>
           </View>
         )}
         )}
 
 

+ 1 - 1
src/features/vehicles/Components/invoice-pdf/Notes.tsx

@@ -78,7 +78,7 @@ function buildAST(tokens: Token[]): (ASTNode | string)[] {
   return root
   return root
 }
 }
 
 
-function HtmlToPdf({
+export function HtmlToPdf({
   html,
   html,
   baseStyle,
   baseStyle,
   fontBold,
   fontBold,

+ 26 - 7
src/lib/with-desktop-auth.ts

@@ -1,4 +1,5 @@
 import { NextResponse } from "next/server";
 import { NextResponse } from "next/server";
+import { ZodError } from "zod";
 import { db } from "./db";
 import { db } from "./db";
 import { hasAllPermissions, type PermissionInput } from "./permissions";
 import { hasAllPermissions, type PermissionInput } from "./permissions";
 import { rateLimit } from "./rate-limit";
 import { rateLimit } from "./rate-limit";
@@ -31,6 +32,10 @@ export async function withDesktopAuth(
   }
   }
   const token = authHeader.slice(7);
   const token = authHeader.slice(7);
 
 
+  if (!token || token.length > 256) {
+    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+  }
+
   // Look up session (expiry checked)
   // Look up session (expiry checked)
   const session = await db.session.findFirst({
   const session = await db.session.findFirst({
     where: { token, expiresAt: { gt: new Date() } },
     where: { token, expiresAt: { gt: new Date() } },
@@ -94,11 +99,25 @@ export async function withDesktopAuth(
     }
     }
   }
   }
 
 
-  return handler({
-    userId: session.userId,
-    organizationId: membership.organizationId,
-    role: isSuperAdmin ? "super_admin" : (membership.role ?? "member"),
-    isSuperAdmin,
-    isAdmin: isSuperAdmin || isOwnerOrAdmin || roleIsAdmin,
-  });
+  try {
+    return await handler({
+      userId: session.userId,
+      organizationId: membership.organizationId,
+      role: isSuperAdmin ? "super_admin" : (membership.role ?? "member"),
+      isSuperAdmin,
+      isAdmin: isSuperAdmin || isOwnerOrAdmin || roleIsAdmin,
+    });
+  } catch (err) {
+    if (err instanceof ZodError) {
+      return NextResponse.json(
+        { error: "Validation error", details: err.issues.map((e) => ({ path: e.path.join("."), message: e.message })) },
+        { status: 400 },
+      );
+    }
+    if (err instanceof SyntaxError) {
+      return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
+    }
+    console.error("[desktop-api]", err);
+    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+  }
 }
 }