merge: bring fixes for dashboards/export/site-header/auth/prisma and env hygiene into main
This commit is contained in:
commit
1900f65e5e
13 changed files with 186 additions and 94 deletions
72
.env.example
72
.env.example
|
|
@ -1,62 +1,28 @@
|
|||
# Ambiente local — Sistema de Chamados
|
||||
# Copie este arquivo para `.env` e preencha os valores sensíveis.
|
||||
# Nunca faça commit de `.env` com segredos reais.
|
||||
NODE_ENV=development
|
||||
|
||||
# Convex
|
||||
CONVEX_DEPLOYMENT=anonymous:anonymous-sistema-de-chamados
|
||||
NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210
|
||||
CONVEX_SYNC_SECRET=dev-sync-secret
|
||||
|
||||
# Next.js / App URL
|
||||
# Public app URL
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
|
||||
# Better Auth
|
||||
# Gere um segredo forte (ex.: `openssl rand -hex 32`)
|
||||
BETTER_AUTH_SECRET=change-me
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
BETTER_AUTH_SECRET=change-me-in-prod
|
||||
|
||||
# Banco de dados (Prisma)
|
||||
# Convex (dev server URL)
|
||||
NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210
|
||||
|
||||
# SQLite database (local dev)
|
||||
DATABASE_URL=file:./prisma/db.dev.sqlite
|
||||
|
||||
# Seeds automáticos (Better Auth)
|
||||
# Por padrão (true), garantindo apenas existência dos usuários padrão sem resetar senhas
|
||||
SEED_ENSURE_ONLY=true
|
||||
# Optional SMTP (dev)
|
||||
# SMTP_ADDRESS=localhost
|
||||
# SMTP_PORT=1025
|
||||
# SMTP_TLS=false
|
||||
# SMTP_USERNAME=
|
||||
# SMTP_PASSWORD=
|
||||
# SMTP_AUTHENTICATION=login
|
||||
# SMTP_ENABLE_STARTTLS_AUTO=false
|
||||
# MAILER_SENDER_EMAIL=no-reply@example.com
|
||||
|
||||
# Provisionamento e inventário de máquinas
|
||||
# Segredo obrigatório para registrar/atualizar máquinas (Convex)
|
||||
MACHINE_PROVISIONING_SECRET=change-me-provisioning
|
||||
# Tempo de vida do token de máquina (ms) — padrão 30 dias
|
||||
MACHINE_TOKEN_TTL_MS=2592000000
|
||||
# Opcional: segredo dedicado para webhook do FleetDM (senão usa o de provisionamento)
|
||||
FLEET_SYNC_SECRET=
|
||||
|
||||
# SMTP (envio de e-mails)
|
||||
SMTP_ADDRESS=
|
||||
SMTP_PORT=465
|
||||
SMTP_DOMAIN=
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_AUTHENTICATION=login
|
||||
SMTP_ENABLE_STARTTLS_AUTO=false
|
||||
SMTP_TLS=true
|
||||
MAILER_SENDER_EMAIL="Suporte <no-reply@seu-dominio.com>"
|
||||
|
||||
# Alertas (actions do Convex)
|
||||
# Hora local (America/Sao_Paulo) para rodar alertas automáticos
|
||||
ALERTS_LOCAL_HOUR=8
|
||||
|
||||
# Seeds e sincronizações auxiliares
|
||||
SYNC_TENANT_ID=tenant-atlas
|
||||
SYNC_DEFAULT_ASSIGNEE=agent@example.com
|
||||
SEED_TENANT_ID=tenant-atlas
|
||||
SEED_ADMIN_PASSWORD=admin123
|
||||
SEED_AGENT_PASSWORD=agent123
|
||||
SEED_USER_TENANT=tenant-atlas
|
||||
SEED_USER_EMAIL=
|
||||
SEED_USER_PASSWORD=
|
||||
SEED_USER_NAME=
|
||||
SEED_USER_ROLE=admin
|
||||
|
||||
# Desenvolvimento Desktop (Tauri/Vite)
|
||||
# Em redes locais, defina o IP do host para HMR.
|
||||
TAURI_DEV_HOST=
|
||||
# Dev-only bypass to simplify local testing (do NOT enable in prod)
|
||||
# DEV_BYPASS_AUTH=0
|
||||
# NEXT_PUBLIC_DEV_BYPASS_AUTH=0
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -36,6 +36,10 @@ yarn-error.log*
|
|||
!.env.example
|
||||
!apps/desktop/.env.example
|
||||
|
||||
# Accidental Windows duplicate downloads (e.g., "env (1)")
|
||||
env (*)
|
||||
env (1)
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
|
|
|
|||
19
scripts/seed-convex-demo.mjs
Normal file
19
scripts/seed-convex-demo.mjs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import 'dotenv/config'
|
||||
import { ConvexHttpClient } from 'convex/browser'
|
||||
import { api } from '../convex/_generated/api.js'
|
||||
|
||||
async function main() {
|
||||
const url = process.env.NEXT_PUBLIC_CONVEX_URL || 'http://127.0.0.1:3210'
|
||||
const client = new ConvexHttpClient(url)
|
||||
console.log(`[seed] Using Convex at ${url}`)
|
||||
try {
|
||||
await client.mutation(api.seed.seedDemo, {})
|
||||
console.log('[seed] Convex demo data ensured (queues/users)')
|
||||
} catch (err) {
|
||||
console.error('[seed] Failed to seed Convex demo data:', err?.message || err)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
|
|
@ -31,7 +31,23 @@ export async function POST(request: Request) {
|
|||
let browser: Awaited<ReturnType<typeof chromium.launch>> | null = null
|
||||
|
||||
try {
|
||||
browser = await chromium.launch()
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true })
|
||||
} catch (launchError) {
|
||||
const msg = (launchError as Error)?.message || String(launchError)
|
||||
// Dev-friendly response when Playwright browsers are missing
|
||||
if (/Executable doesn't exist|Please run the following command to download new browsers/i.test(msg)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Playwright browsers not installed",
|
||||
hint: "Execute: npx playwright install",
|
||||
details: msg,
|
||||
},
|
||||
{ status: 501 },
|
||||
)
|
||||
}
|
||||
throw launchError
|
||||
}
|
||||
const page = await browser.newPage({ viewport: { width, height } })
|
||||
await page.goto(payload.url, { waitUntil: "networkidle" })
|
||||
if (waitForSelector) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { AppShell } from "@/components/app-shell"
|
||||
import { SiteHeader } from "@/components/site-header"
|
||||
import { SiteHeader, SiteHeaderPrimaryButton, SiteHeaderSecondaryButton } from "@/components/site-header"
|
||||
import { PlayNextTicketCard } from "@/components/tickets/play-next-ticket-card"
|
||||
import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary"
|
||||
import { requireAuthenticatedSession } from "@/lib/auth-server"
|
||||
|
|
@ -12,8 +12,8 @@ export default async function PlayPage() {
|
|||
<SiteHeader
|
||||
title="Modo play"
|
||||
lead="Distribua tickets automaticamente conforme prioridade"
|
||||
secondaryAction={<SiteHeader.SecondaryButton>Pausar notificações</SiteHeader.SecondaryButton>}
|
||||
primaryAction={<SiteHeader.PrimaryButton>Iniciar sessão</SiteHeader.PrimaryButton>}
|
||||
secondaryAction={<SiteHeaderSecondaryButton>Pausar notificações</SiteHeaderSecondaryButton>}
|
||||
primaryAction={<SiteHeaderPrimaryButton>Iniciar sessão</SiteHeaderPrimaryButton>}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -441,6 +441,14 @@ function filtersEqual(a: DashboardFilters, b: DashboardFilters) {
|
|||
)
|
||||
}
|
||||
|
||||
function deepEqual<T>(a: T, b: T) {
|
||||
try {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const widgetConfigSchema = z.object({
|
||||
title: z.string().min(1, "Informe um título"),
|
||||
type: z.string(),
|
||||
|
|
@ -500,12 +508,14 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
|||
|
||||
useEffect(() => {
|
||||
if (!detail) return
|
||||
setDashboard(detail.dashboard)
|
||||
setWidgets(detail.widgets)
|
||||
setShares(detail.shares)
|
||||
setDashboard((prev) => (prev && deepEqual(prev, detail.dashboard) ? prev : detail.dashboard))
|
||||
setWidgets((prev) => (deepEqual(prev, detail.widgets) ? prev : detail.widgets))
|
||||
setShares((prev) => (deepEqual(prev, detail.shares) ? prev : detail.shares))
|
||||
const nextFilters = normalizeFilters(detail.dashboard.filters)
|
||||
filtersHydratingRef.current = true
|
||||
setFilters(nextFilters)
|
||||
if (!filtersEqual(filters, nextFilters)) {
|
||||
filtersHydratingRef.current = true
|
||||
setFilters(nextFilters)
|
||||
}
|
||||
const syncedLayout = syncLayoutWithWidgets(layoutRef.current, detail.widgets, detail.dashboard.layout)
|
||||
if (!layoutItemsEqual(layoutRef.current, syncedLayout)) {
|
||||
layoutRef.current = syncedLayout
|
||||
|
|
@ -543,6 +553,30 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
|||
return new Set(currentSection?.widgetKeys ?? [])
|
||||
}, [enforceTv, sections, activeSectionIndex])
|
||||
|
||||
// Ready handlers (stable per widget) defined before usage in canvasItems
|
||||
const handleWidgetReady = useCallback((key: string, ready: boolean) => {
|
||||
setReadyWidgets((prev) => {
|
||||
const currentlyReady = prev.has(key)
|
||||
if (currentlyReady === ready) return prev
|
||||
const next = new Set(prev)
|
||||
if (ready) next.add(key)
|
||||
else next.delete(key)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const readyHandlersRef = useRef(new Map<string, (ready: boolean) => void>())
|
||||
const getReadyHandler = useCallback(
|
||||
(key: string) => {
|
||||
const cached = readyHandlersRef.current.get(key)
|
||||
if (cached) return cached
|
||||
const handler = (ready: boolean) => handleWidgetReady(key, ready)
|
||||
readyHandlersRef.current.set(key, handler)
|
||||
return handler
|
||||
},
|
||||
[handleWidgetReady],
|
||||
)
|
||||
|
||||
const canvasItems = packedLayout
|
||||
.map((item) => {
|
||||
const widget = widgetMap.get(item.i)
|
||||
|
|
@ -565,7 +599,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
|||
onDuplicate={() => handleDuplicateWidget(widget)}
|
||||
onRemove={() => handleRemoveWidget(widget)}
|
||||
onViewData={() => setDataTarget(widget)}
|
||||
onReadyChange={(ready) => handleWidgetReady(widget.widgetKey, ready)}
|
||||
onReadyChange={getReadyHandler(widget.widgetKey)}
|
||||
/>
|
||||
),
|
||||
...(item.minW !== undefined ? { minW: item.minW } : {}),
|
||||
|
|
@ -576,18 +610,6 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
|||
|
||||
const allWidgetsReady = canvasItems.length > 0 && canvasItems.every((item) => readyWidgets.has(item.key))
|
||||
|
||||
const handleWidgetReady = useCallback((key: string, ready: boolean) => {
|
||||
setReadyWidgets((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (ready) {
|
||||
next.add(key)
|
||||
} else {
|
||||
next.delete(key)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const keys = new Set(canvasItems.map((item) => item.key))
|
||||
setReadyWidgets((prev) => {
|
||||
|
|
@ -799,7 +821,13 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
|||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error("Export request failed")
|
||||
let msg = "Export request failed"
|
||||
try {
|
||||
const data = await response.json()
|
||||
if (data?.hint) msg = `${msg}: ${data.hint}`
|
||||
else if (data?.error) msg = `${msg}: ${data.error}`
|
||||
} catch {}
|
||||
throw new Error(msg)
|
||||
}
|
||||
const blob = await response.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
|
@ -811,7 +839,10 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
|||
toast.success("Exportação gerada com sucesso!")
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Não foi possível exportar o dashboard.")
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
toast.error(message.includes("Playwright browsers not installed") || message.includes("npx playwright install")
|
||||
? "Exportação indisponível em DEV: rode `npx playwright install` para habilitar."
|
||||
: "Não foi possível exportar o dashboard.")
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { useAuth } from "@/lib/auth-client"
|
|||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Dropzone } from "@/components/ui/dropzone"
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
|
@ -513,6 +513,11 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
|||
<X className="size-4" />
|
||||
</DialogClose>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="sr-only">
|
||||
{isPreviewImage
|
||||
? "Pré-visualização da imagem anexada."
|
||||
: "Pré-visualização do arquivo selecionado. Utilize o botão para abrir em uma nova aba."}
|
||||
</DialogDescription>
|
||||
{previewAttachment ? (
|
||||
isPreviewImage ? (
|
||||
<div className="rounded-b-2xl bg-neutral-900/5">
|
||||
|
|
@ -696,5 +701,3 @@ function PortalCommentAttachmentCard({
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -42,8 +42,8 @@ export function SiteHeader({
|
|||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
SiteHeader.PrimaryButton = function SiteHeaderPrimaryButton({
|
||||
|
||||
export function SiteHeaderPrimaryButton({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
|
|
@ -55,7 +55,7 @@ SiteHeader.PrimaryButton = function SiteHeaderPrimaryButton({
|
|||
)
|
||||
}
|
||||
|
||||
SiteHeader.SecondaryButton = function SiteHeaderSecondaryButton({
|
||||
export function SiteHeaderSecondaryButton({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
|
|
@ -66,3 +66,7 @@ SiteHeader.SecondaryButton = function SiteHeaderSecondaryButton({
|
|||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// Backward compatibility: attach as static members (client-only usage)
|
||||
;(SiteHeader as any).PrimaryButton = SiteHeaderPrimaryButton
|
||||
;(SiteHeader as any).SecondaryButton = SiteHeaderSecondaryButton
|
||||
|
|
|
|||
|
|
@ -90,12 +90,20 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
|
|||
const [hoverScore, setHoverScore] = useState<number | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const ratedAtTimestamp = ticket.csatRatedAt ? ticket.csatRatedAt.getTime() : null
|
||||
|
||||
useEffect(() => {
|
||||
setScore(initialScore)
|
||||
setComment(initialComment)
|
||||
setRatedAt(ticket.csatRatedAt ?? null)
|
||||
setHasSubmitted(initialScore > 0)
|
||||
}, [initialScore, initialComment, ticket.csatRatedAt])
|
||||
setScore((prev) => (prev === initialScore ? prev : initialScore))
|
||||
setComment((prev) => (prev === initialComment ? prev : initialComment))
|
||||
const nextRatedAt = ratedAtTimestamp == null ? null : new Date(ratedAtTimestamp)
|
||||
setRatedAt((prev) => {
|
||||
if (prev === nextRatedAt) return prev
|
||||
if (prev && nextRatedAt && prev.getTime() === nextRatedAt.getTime()) return prev
|
||||
return nextRatedAt
|
||||
})
|
||||
const nextHasSubmitted = initialScore > 0
|
||||
setHasSubmitted((prev) => (prev === nextHasSubmitted ? prev : nextHasSubmitted))
|
||||
}, [initialScore, initialComment, ratedAtTimestamp])
|
||||
|
||||
const effectiveScore = hasSubmitted ? score : hoverScore ?? score
|
||||
const viewerIsStaff = viewerRole === "ADMIN" || viewerRole === "AGENT" || viewerRole === "MANAGER"
|
||||
|
|
|
|||
|
|
@ -90,7 +90,22 @@ export function useAuth() {
|
|||
export const { signIn, signOut, useSession } = authClient
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const { data: session, isPending } = useSession()
|
||||
const devBypass = process.env.NODE_ENV !== "production" && process.env.NEXT_PUBLIC_DEV_BYPASS_AUTH === "1"
|
||||
const { data: baseSession, isPending } = useSession()
|
||||
const session: AppSession | null = baseSession ?? (devBypass
|
||||
? {
|
||||
session: { id: "dev-session", expiresAt: Date.now() + 1000 * 60 * 60 },
|
||||
user: {
|
||||
id: "dev-user",
|
||||
name: "Dev Admin",
|
||||
email: "admin@sistema.dev",
|
||||
role: "admin",
|
||||
tenantId: "tenant-atlas",
|
||||
avatarUrl: null,
|
||||
machinePersona: null,
|
||||
},
|
||||
}
|
||||
: null)
|
||||
const ensureUser = useMutation(api.users.ensureUser)
|
||||
const [convexUserId, setConvexUserId] = useState<string | null>(null)
|
||||
const [machineContext, setMachineContext] = useState<MachineContext | null>(null)
|
||||
|
|
|
|||
|
|
@ -55,6 +55,24 @@ async function buildRequest() {
|
|||
|
||||
export async function getServerSession(): Promise<ServerSession | null> {
|
||||
try {
|
||||
// Dev-only bypass to simplify local dashboard access when auth is misconfigured.
|
||||
if (process.env.NODE_ENV !== "production" && process.env.DEV_BYPASS_AUTH === "1") {
|
||||
return {
|
||||
session: {
|
||||
id: "dev-session",
|
||||
expiresAt: Date.now() + 1000 * 60 * 60,
|
||||
},
|
||||
user: {
|
||||
id: "dev-user",
|
||||
name: "Dev Admin",
|
||||
email: "admin@sistema.dev",
|
||||
role: "admin",
|
||||
tenantId: "tenant-atlas",
|
||||
avatarUrl: null,
|
||||
machinePersona: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
const request = await buildRequest()
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ export const auth = betterAuth({
|
|||
provider: "sqlite",
|
||||
}),
|
||||
user: {
|
||||
modelName: "authUser",
|
||||
// Prisma model name (case-sensitive)
|
||||
modelName: "AuthUser",
|
||||
additionalFields: {
|
||||
role: {
|
||||
type: "string",
|
||||
|
|
@ -47,17 +48,17 @@ export const auth = betterAuth({
|
|||
},
|
||||
},
|
||||
session: {
|
||||
modelName: "authSession",
|
||||
modelName: "AuthSession",
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 60 * 5,
|
||||
},
|
||||
},
|
||||
account: {
|
||||
modelName: "authAccount",
|
||||
modelName: "AuthAccount",
|
||||
},
|
||||
verification: {
|
||||
modelName: "authVerification",
|
||||
modelName: "AuthVerification",
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ const resolvedDatabaseUrl = (() => {
|
|||
if (process.env.NODE_ENV === "production") {
|
||||
return "file:/app/data/db.sqlite"
|
||||
}
|
||||
return resolveFileUrl("file:./prisma/db.sqlite")
|
||||
// In development, prefer a dedicated dev DB file
|
||||
return resolveFileUrl("file:./prisma/db.dev.sqlite")
|
||||
})()
|
||||
|
||||
export const prisma =
|
||||
|
|
@ -51,3 +52,9 @@ export const prisma =
|
|||
if (process.env.NODE_ENV !== "production") {
|
||||
global.prisma = prisma
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
// Helps detect mismatched DB path during dev server bootstrap
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[prisma] Using database:", resolvedDatabaseUrl)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue