feat: config.js

This commit is contained in:
Vasily Zubarev
2025-04-03 14:36:16 +02:00
parent f523b1f8ba
commit f1a26e511e
26 changed files with 165 additions and 126 deletions

View File

@@ -4,7 +4,7 @@ import DashboardUnsortedWidget from "@/components/dashboard/unsorted-widget"
import { WelcomeWidget } from "@/components/dashboard/welcome-widget" import { WelcomeWidget } from "@/components/dashboard/welcome-widget"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { APP_DESCRIPTION } from "@/lib/constants" import config from "@/lib/config"
import { getUnsortedFiles } from "@/models/files" import { getUnsortedFiles } from "@/models/files"
import { getSettings } from "@/models/settings" import { getSettings } from "@/models/settings"
import { TransactionFilters } from "@/models/transactions" import { TransactionFilters } from "@/models/transactions"
@@ -12,7 +12,7 @@ import { Metadata } from "next"
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Dashboard", title: "Dashboard",
description: APP_DESCRIPTION, description: config.app.description,
} }
export default async function Dashboard({ searchParams }: { searchParams: Promise<TransactionFilters> }) { export default async function Dashboard({ searchParams }: { searchParams: Promise<TransactionFilters> }) {

View File

@@ -4,7 +4,7 @@ import { AppSidebar } from "@/components/sidebar/sidebar"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { Toaster } from "@/components/ui/sonner" import { Toaster } from "@/components/ui/sonner"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { APP_DESCRIPTION, APP_TITLE } from "@/lib/constants" import config from "@/lib/config"
import { getUnsortedFilesCount } from "@/models/files" import { getUnsortedFilesCount } from "@/models/files"
import type { Metadata, Viewport } from "next" import type { Metadata, Viewport } from "next"
import "../globals.css" import "../globals.css"
@@ -13,9 +13,9 @@ import { NotificationProvider } from "./context"
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
template: "%s | TaxHacker", template: "%s | TaxHacker",
default: APP_TITLE, default: config.app.title,
}, },
description: APP_DESCRIPTION, description: config.app.description,
icons: { icons: {
icon: "/favicon.ico", icon: "/favicon.ico",
shortcut: "/favicon.ico", shortcut: "/favicon.ico",
@@ -38,13 +38,14 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<SidebarProvider> <SidebarProvider>
<MobileMenu unsortedFilesCount={unsortedFilesCount} /> <MobileMenu unsortedFilesCount={unsortedFilesCount} />
<AppSidebar <AppSidebar
unsortedFilesCount={unsortedFilesCount}
profile={{ profile={{
id: user.id, id: user.id,
name: user.name || "", name: user.name || "",
email: user.email, email: user.email,
avatar: user.avatar || undefined, avatar: user.avatar || undefined,
}} }}
unsortedFilesCount={unsortedFilesCount}
isSelfHosted={config.selfHosted.isEnabled}
/> />
<SidebarInset className="w-full h-full mt-[60px] md:mt-0 overflow-auto">{children}</SidebarInset> <SidebarInset className="w-full h-full mt-[60px] md:mt-0 overflow-auto">{children}</SidebarInset>
</SidebarProvider> </SidebarProvider>

View File

@@ -1,5 +1,6 @@
import LLMSettingsForm from "@/components/settings/llm-settings-form" import LLMSettingsForm from "@/components/settings/llm-settings-form"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import config from "@/lib/config"
import { getFields } from "@/models/fields" import { getFields } from "@/models/fields"
import { getSettings } from "@/models/settings" import { getSettings } from "@/models/settings"
@@ -11,7 +12,7 @@ export default async function LlmSettingsPage() {
return ( return (
<> <>
<div className="w-full max-w-2xl"> <div className="w-full max-w-2xl">
<LLMSettingsForm settings={settings} fields={fields} /> <LLMSettingsForm settings={settings} fields={fields} showApiKey={config.selfHosted.isEnabled} />
</div> </div>
</> </>
) )

View File

@@ -6,7 +6,7 @@ import { buildLLMPrompt } from "@/ai/prompt"
import { fieldsToJsonSchema } from "@/ai/schema" import { fieldsToJsonSchema } from "@/ai/schema"
import { transactionFormSchema } from "@/forms/transactions" import { transactionFormSchema } from "@/forms/transactions"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { IS_SELF_HOSTED_MODE } from "@/lib/constants" import config from "@/lib/config"
import { getTransactionFileUploadPath, getUserUploadsDirectory } from "@/lib/files" import { getTransactionFileUploadPath, getUserUploadsDirectory } from "@/lib/files"
import { DEFAULT_PROMPT_ANALYSE_NEW_FILE } from "@/models/defaults" import { DEFAULT_PROMPT_ANALYSE_NEW_FILE } from "@/models/defaults"
import { deleteFile, getFileById, updateFile } from "@/models/files" import { deleteFile, getFileById, updateFile } from "@/models/files"
@@ -50,7 +50,7 @@ export async function analyzeFileAction(
prompt, prompt,
schema, schema,
attachments, attachments,
IS_SELF_HOSTED_MODE ? settings.openai_api_key : process.env.OPENAI_API_KEY || "" config.selfHosted.isEnabled ? settings.openai_api_key : process.env.OPENAI_API_KEY || ""
) )
console.log("Analysis results:", results) console.log("Analysis results:", results)

View File

@@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card" import { Card } from "@/components/ui/card"
import AnalyzeForm from "@/components/unsorted/analyze-form" import AnalyzeForm from "@/components/unsorted/analyze-form"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { IS_SELF_HOSTED_MODE } from "@/lib/constants" import config from "@/lib/config"
import { getCategories } from "@/models/categories" import { getCategories } from "@/models/categories"
import { getCurrencies } from "@/models/currencies" import { getCurrencies } from "@/models/currencies"
import { getFields } from "@/models/fields" import { getFields } from "@/models/fields"
@@ -36,7 +36,7 @@ export default async function UnsortedPage() {
<h2 className="text-3xl font-bold tracking-tight">You have {files.length} unsorted files</h2> <h2 className="text-3xl font-bold tracking-tight">You have {files.length} unsorted files</h2>
</header> </header>
{IS_SELF_HOSTED_MODE && !settings.openai_api_key && ( {config.selfHosted.isEnabled && !settings.openai_api_key && (
<Alert> <Alert>
<Settings className="h-4 w-4 mt-2" /> <Settings className="h-4 w-4 mt-2" />
<div className="flex flex-row justify-between pt-2"> <div className="flex flex-row justify-between pt-2">

View File

@@ -1,12 +1,12 @@
import { LoginForm } from "@/components/auth/login-form" import { LoginForm } from "@/components/auth/login-form"
import { Card, CardContent, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardTitle } from "@/components/ui/card"
import { ColoredText } from "@/components/ui/colored-text" import { ColoredText } from "@/components/ui/colored-text"
import { IS_SELF_HOSTED_MODE, SELF_HOSTED_REDIRECT_URL } from "@/lib/constants" import config from "@/lib/config"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
export default async function LoginPage() { export default async function LoginPage() {
if (IS_SELF_HOSTED_MODE) { if (config.selfHosted.isEnabled) {
redirect(SELF_HOSTED_REDIRECT_URL) redirect(config.selfHosted.redirectUrl)
} }
return ( return (

View File

@@ -3,7 +3,7 @@ import { FormInput } from "@/components/forms/simple"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardDescription, CardTitle } from "@/components/ui/card" import { Card, CardDescription, CardTitle } from "@/components/ui/card"
import { ColoredText } from "@/components/ui/colored-text" import { ColoredText } from "@/components/ui/colored-text"
import { IS_SELF_HOSTED_MODE, SELF_HOSTED_REDIRECT_URL } from "@/lib/constants" import config from "@/lib/config"
import { DEFAULT_CURRENCIES, DEFAULT_SETTINGS } from "@/models/defaults" import { DEFAULT_CURRENCIES, DEFAULT_SETTINGS } from "@/models/defaults"
import { getSelfHostedUser } from "@/models/users" import { getSelfHostedUser } from "@/models/users"
import { ShieldAlert } from "lucide-react" import { ShieldAlert } from "lucide-react"
@@ -11,7 +11,7 @@ import { redirect } from "next/navigation"
import { selfHostedGetStartedAction } from "../actions" import { selfHostedGetStartedAction } from "../actions"
export default async function SelfHostedWelcomePage() { export default async function SelfHostedWelcomePage() {
if (!IS_SELF_HOSTED_MODE) { if (!config.selfHosted.isEnabled) {
return ( return (
<Card className="w-full max-w-xl mx-auto p-8 flex flex-col items-center justify-center gap-6"> <Card className="w-full max-w-xl mx-auto p-8 flex flex-col items-center justify-center gap-6">
<CardTitle className="text-2xl font-bold flex items-center gap-2"> <CardTitle className="text-2xl font-bold flex items-center gap-2">
@@ -31,7 +31,7 @@ export default async function SelfHostedWelcomePage() {
const user = await getSelfHostedUser() const user = await getSelfHostedUser()
if (user) { if (user) {
redirect(SELF_HOSTED_REDIRECT_URL) redirect(config.selfHosted.redirectUrl)
} }
return ( return (

View File

@@ -1,17 +1,17 @@
import { AUTH_LOGIN_URL, IS_SELF_HOSTED_MODE, SELF_HOSTED_WELCOME_URL } from "@/lib/constants" import config from "@/lib/config"
import { createUserDefaults, isDatabaseEmpty } from "@/models/defaults" import { createUserDefaults, isDatabaseEmpty } from "@/models/defaults"
import { getSelfHostedUser } from "@/models/users" import { getSelfHostedUser } from "@/models/users"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
export async function GET(request: Request) { export async function GET(request: Request) {
if (!IS_SELF_HOSTED_MODE) { if (!config.selfHosted.isEnabled) {
redirect(AUTH_LOGIN_URL) redirect(config.auth.loginUrl)
} }
const user = await getSelfHostedUser() const user = await getSelfHostedUser()
if (!user) { if (!user) {
redirect(SELF_HOSTED_WELCOME_URL) redirect(config.selfHosted.welcomeUrl)
} }
if (await isDatabaseEmpty(user.id)) { if (await isDatabaseEmpty(user.id)) {

View File

@@ -1,11 +1,12 @@
import SignupForm from "@/components/auth/signup-form"
import { Card, CardContent, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardTitle } from "@/components/ui/card"
import { ColoredText } from "@/components/ui/colored-text" import { ColoredText } from "@/components/ui/colored-text"
import { IS_SELF_HOSTED_MODE, SELF_HOSTED_REDIRECT_URL } from "@/lib/constants" import config from "@/lib/config"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
export default async function LoginPage() { export default async function LoginPage() {
if (IS_SELF_HOSTED_MODE) { if (config.selfHosted.isEnabled) {
redirect(SELF_HOSTED_REDIRECT_URL) redirect(config.selfHosted.redirectUrl)
} }
return ( return (
@@ -15,10 +16,13 @@ export default async function LoginPage() {
<ColoredText>TaxHacker: Cloud Edition</ColoredText> <ColoredText>TaxHacker: Cloud Edition</ColoredText>
</CardTitle> </CardTitle>
<CardContent className="w-full"> <CardContent className="w-full">
<div className="text-center text-md text-muted-foreground"> {config.auth.disableSignup ? (
Creating new account is disabled for now. Please use the self-hosted version. <div className="text-center text-md text-muted-foreground">
</div> Creating new account is disabled for now. Please use the self-hosted version.
{/* <SignupForm /> */} </div>
) : (
<SignupForm />
)}
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@@ -1,5 +1,6 @@
"use server" "use server"
import config from "@/lib/config"
import { resend, sendNewsletterWelcomeEmail } from "@/lib/email" import { resend, sendNewsletterWelcomeEmail } from "@/lib/email"
export async function subscribeToNewsletterAction(email: string) { export async function subscribeToNewsletterAction(email: string) {
@@ -9,7 +10,7 @@ export async function subscribeToNewsletterAction(email: string) {
} }
const existingContacts = await resend.contacts.list({ const existingContacts = await resend.contacts.list({
audienceId: process.env.RESEND_AUDIENCE_ID as string, audienceId: config.email.audienceId,
}) })
if (existingContacts.data) { if (existingContacts.data) {
@@ -22,7 +23,7 @@ export async function subscribeToNewsletterAction(email: string) {
await resend.contacts.create({ await resend.contacts.create({
email, email,
audienceId: process.env.RESEND_AUDIENCE_ID as string, audienceId: config.email.audienceId,
unsubscribed: false, unsubscribed: false,
}) })

View File

@@ -1,13 +1,13 @@
import config from "@/lib/config"
import type { Metadata, Viewport } from "next" import type { Metadata, Viewport } from "next"
import "./globals.css" import "./globals.css"
import { APP_DESCRIPTION, APP_TITLE } from "@/lib/constants"
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
template: "%s | TaxHacker", template: "%s | TaxHacker",
default: APP_TITLE, default: config.app.title,
}, },
description: APP_DESCRIPTION, description: config.app.description,
icons: { icons: {
icon: "/favicon.ico", icon: "/favicon.ico",
shortcut: "/favicon.ico", shortcut: "/favicon.ico",

View File

@@ -1,13 +1,13 @@
import LandingPage from "@/app/landing/landing" import LandingPage from "@/app/landing/landing"
import { getSession } from "@/lib/auth" import { getSession } from "@/lib/auth"
import { IS_SELF_HOSTED_MODE, SELF_HOSTED_REDIRECT_URL } from "@/lib/constants" import config from "@/lib/config"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
export default async function Home() { export default async function Home() {
const session = await getSession() const session = await getSession()
if (!session) { if (!session) {
if (IS_SELF_HOSTED_MODE) { if (config.selfHosted.isEnabled) {
redirect(SELF_HOSTED_REDIRECT_URL) redirect(config.selfHosted.redirectUrl)
} }
return <LandingPage /> return <LandingPage />
} }

View File

@@ -3,7 +3,7 @@
import { useNotification } from "@/app/(app)/context" import { useNotification } from "@/app/(app)/context"
import { uploadFilesAction } from "@/app/(app)/files/actions" import { uploadFilesAction } from "@/app/(app)/files/actions"
import { FormError } from "@/components/forms/error" import { FormError } from "@/components/forms/error"
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/constants" import config from "@/lib/config"
import { Camera, Loader2 } from "lucide-react" import { Camera, Loader2 } from "lucide-react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { startTransition, useState } from "react" import { startTransition, useState } from "react"
@@ -48,7 +48,7 @@ export default function DashboardDropZoneWidget() {
id="fileInput" id="fileInput"
className="hidden" className="hidden"
multiple multiple
accept={FILE_ACCEPTED_MIMETYPES} accept={config.upload.acceptedMimeTypes}
onChange={handleFileChange} onChange={handleFileChange}
/> />
<div className="flex flex-col items-center justify-center gap-4 p-8 text-center h-full"> <div className="flex flex-col items-center justify-center gap-4 p-8 text-center h-full">

View File

@@ -3,7 +3,7 @@
import { useNotification } from "@/app/(app)/context" import { useNotification } from "@/app/(app)/context"
import { uploadFilesAction } from "@/app/(app)/files/actions" import { uploadFilesAction } from "@/app/(app)/files/actions"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/constants" import config from "@/lib/config"
import { Loader2 } from "lucide-react" import { Loader2 } from "lucide-react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { ComponentProps, startTransition, useRef, useState } from "react" import { ComponentProps, startTransition, useRef, useState } from "react"
@@ -54,7 +54,7 @@ export function UploadButton({ children, ...props }: { children: React.ReactNode
id="fileInput" id="fileInput"
className="hidden" className="hidden"
multiple multiple
accept={FILE_ACCEPTED_MIMETYPES} accept={config.upload.acceptedMimeTypes}
onChange={handleFileChange} onChange={handleFileChange}
/> />

View File

@@ -6,29 +6,40 @@ import { FormError } from "@/components/forms/error"
import { FormInput, FormTextarea } from "@/components/forms/simple" import { FormInput, FormTextarea } from "@/components/forms/simple"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardTitle } from "@/components/ui/card" import { Card, CardTitle } from "@/components/ui/card"
import { IS_SELF_HOSTED_MODE } from "@/lib/constants"
import { Field } from "@prisma/client" import { Field } from "@prisma/client"
import { CircleCheckBig, Edit } from "lucide-react" import { CircleCheckBig, Edit } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { useActionState } from "react" import { useActionState } from "react"
export default function LLMSettingsForm({ settings, fields }: { settings: Record<string, string>; fields: Field[] }) { export default function LLMSettingsForm({
settings,
fields,
showApiKey = true,
}: {
settings: Record<string, string>
fields: Field[]
showApiKey?: boolean
}) {
const [saveState, saveAction, pending] = useActionState(saveSettingsAction, null) const [saveState, saveAction, pending] = useActionState(saveSettingsAction, null)
return ( return (
<> <>
<form action={saveAction} className="space-y-4"> <form action={saveAction} className="space-y-4">
{IS_SELF_HOSTED_MODE && ( {showApiKey && (
<FormInput title="OpenAI API Key" name="openai_api_key" defaultValue={settings.openai_api_key} /> <>
)} <FormInput title="OpenAI API Key" name="openai_api_key" defaultValue={settings.openai_api_key} />
{IS_SELF_HOSTED_MODE && ( <small className="text-muted-foreground">
<small className="text-muted-foreground"> Get your API key from{" "}
Get your API key from{" "} <a
<a href="https://platform.openai.com/settings/organization/api-keys" target="_blank" className="underline"> href="https://platform.openai.com/settings/organization/api-keys"
OpenAI Platform Console target="_blank"
</a> className="underline"
</small> >
OpenAI Platform Console
</a>
</small>
</>
)} )}
<FormTextarea <FormTextarea

View File

@@ -2,7 +2,7 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { useSidebar } from "@/components/ui/sidebar" import { useSidebar } from "@/components/ui/sidebar"
import { APP_TITLE } from "@/lib/constants" import config from "@/lib/config"
import Link from "next/link" import Link from "next/link"
export default function MobileMenu({ unsortedFilesCount }: { unsortedFilesCount: number }) { export default function MobileMenu({ unsortedFilesCount }: { unsortedFilesCount: number }) {
@@ -15,7 +15,7 @@ export default function MobileMenu({ unsortedFilesCount }: { unsortedFilesCount:
<AvatarFallback className="rounded-lg">AI</AvatarFallback> <AvatarFallback className="rounded-lg">AI</AvatarFallback>
</Avatar> </Avatar>
<Link href="/" className="text-lg font-bold"> <Link href="/" className="text-lg font-bold">
{APP_TITLE} {config.app.title}
</Link> </Link>
<Link <Link
href="/unsorted" href="/unsorted"

View File

@@ -10,12 +10,11 @@ import {
import { SidebarMenuButton } from "@/components/ui/sidebar" import { SidebarMenuButton } from "@/components/ui/sidebar"
import { UserProfile } from "@/lib/auth" import { UserProfile } from "@/lib/auth"
import { authClient } from "@/lib/auth-client" import { authClient } from "@/lib/auth-client"
import { IS_SELF_HOSTED_MODE } from "@/lib/constants"
import { LogOut, MoreVertical, User } from "lucide-react" import { LogOut, MoreVertical, User } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
export default function SidebarUser({ profile }: { profile: UserProfile }) { export default function SidebarUser({ profile, isSelfHosted }: { profile: UserProfile; isSelfHosted: boolean }) {
const signOut = async () => { const signOut = async () => {
await authClient.signOut({}) await authClient.signOut({})
redirect("/") redirect("/")
@@ -61,14 +60,16 @@ export default function SidebarUser({ profile }: { profile: UserProfile }) {
</Link> </Link>
</DropdownMenuItem> */} </DropdownMenuItem> */}
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> {!isSelfHosted && (
{!IS_SELF_HOSTED_MODE && ( <>
<DropdownMenuItem asChild> <DropdownMenuSeparator />
<span onClick={signOut} className="flex items-center gap-2 text-red-600 cursor-pointer"> <DropdownMenuItem asChild>
<LogOut className="h-4 w-4" /> <span onClick={signOut} className="flex items-center gap-2 text-red-600 cursor-pointer">
Log out <LogOut className="h-4 w-4" />
</span> Log out
</DropdownMenuItem> </span>
</DropdownMenuItem>
</>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -17,7 +17,7 @@ import {
useSidebar, useSidebar,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
import { UserProfile } from "@/lib/auth" import { UserProfile } from "@/lib/auth"
import { APP_TITLE, IS_SELF_HOSTED_MODE } from "@/lib/constants" import config from "@/lib/config"
import { ClockArrowUp, FileText, Import, LayoutDashboard, Settings, Sparkles, Upload } from "lucide-react" import { ClockArrowUp, FileText, Import, LayoutDashboard, Settings, Sparkles, Upload } from "lucide-react"
import Image from "next/image" import Image from "next/image"
import Link from "next/link" import Link from "next/link"
@@ -28,7 +28,15 @@ import { Blinker } from "./blinker"
import { SidebarMenuItemWithHighlight } from "./sidebar-item" import { SidebarMenuItemWithHighlight } from "./sidebar-item"
import SidebarUser from "./sidebar-user" import SidebarUser from "./sidebar-user"
export function AppSidebar({ unsortedFilesCount, profile }: { unsortedFilesCount: number; profile: UserProfile }) { export function AppSidebar({
profile,
unsortedFilesCount,
isSelfHosted,
}: {
profile: UserProfile
unsortedFilesCount: number
isSelfHosted: boolean
}) {
const { open, setOpenMobile } = useSidebar() const { open, setOpenMobile } = useSidebar()
const pathname = usePathname() const pathname = usePathname()
const { notification } = useNotification() const { notification } = useNotification()
@@ -46,7 +54,7 @@ export function AppSidebar({ unsortedFilesCount, profile }: { unsortedFilesCount
<Image src="/logo/256.png" alt="Logo" className="h-10 w-10 rounded-lg" width={40} height={40} /> <Image src="/logo/256.png" alt="Logo" className="h-10 w-10 rounded-lg" width={40} height={40} />
<div className="grid flex-1 text-left leading-tight"> <div className="grid flex-1 text-left leading-tight">
<span className="truncate font-semibold text-lg"> <span className="truncate font-semibold text-lg">
<ColoredText>{APP_TITLE}</ColoredText> <ColoredText>{config.app.title}</ColoredText>
</span> </span>
</div> </div>
</Link> </Link>
@@ -124,7 +132,7 @@ export function AppSidebar({ unsortedFilesCount, profile }: { unsortedFilesCount
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
{IS_SELF_HOSTED_MODE && ( {isSelfHosted && (
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild> <SidebarMenuButton asChild>
<Link href="https://vas3k.com/donate/" target="_blank"> <Link href="https://vas3k.com/donate/" target="_blank">
@@ -146,7 +154,7 @@ export function AppSidebar({ unsortedFilesCount, profile }: { unsortedFilesCount
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarUser profile={profile} /> <SidebarUser profile={profile} isSelfHosted={isSelfHosted} />
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>

View File

@@ -4,7 +4,7 @@ import { deleteTransactionFileAction, uploadTransactionFilesAction } from "@/app
import { FilePreview } from "@/components/files/preview" import { FilePreview } from "@/components/files/preview"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card" import { Card } from "@/components/ui/card"
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/constants" import config from "@/lib/config"
import { File, Transaction } from "@prisma/client" import { File, Transaction } from "@prisma/client"
import { Loader2, Upload, X } from "lucide-react" import { Loader2, Upload, X } from "lucide-react"
import { useState } from "react" import { useState } from "react"
@@ -72,7 +72,7 @@ export default function TransactionFiles({ transaction, files }: { transaction:
name="file" name="file"
className="absolute inset-0 top-0 left-0 w-full h-full opacity-0" className="absolute inset-0 top-0 left-0 w-full h-full opacity-0"
onChange={handleFileChange} onChange={handleFileChange}
accept={FILE_ACCEPTED_MIMETYPES} accept={config.upload.acceptedMimeTypes}
/> />
</label> </label>
</Card> </Card>

View File

@@ -1,6 +1,6 @@
import { AUTH_LOGIN_URL, IS_SELF_HOSTED_MODE, SELF_HOSTED_REDIRECT_URL } from "@/lib/constants" import config from "@/lib/config"
import { createUserDefaults } from "@/models/defaults" import { createUserDefaults } from "@/models/defaults"
import { getSelfHostedUser, getUserByEmail } from "@/models/users" import { getSelfHostedUser } from "@/models/users"
import { User } from "@prisma/client" import { User } from "@prisma/client"
import { betterAuth } from "better-auth" import { betterAuth } from "better-auth"
import { prismaAdapter } from "better-auth/adapters/prisma" import { prismaAdapter } from "better-auth/adapters/prisma"
@@ -22,7 +22,7 @@ export const auth = betterAuth({
database: prismaAdapter(prisma, { provider: "postgresql" }), database: prismaAdapter(prisma, { provider: "postgresql" }),
email: { email: {
provider: "resend", provider: "resend",
from: process.env.RESEND_FROM_EMAIL!, from: config.email.from,
resend, resend,
}, },
session: { session: {
@@ -49,14 +49,10 @@ export const auth = betterAuth({
}, },
plugins: [ plugins: [
emailOTP({ emailOTP({
disableSignUp: true, disableSignUp: config.auth.disableSignup,
otpLength: 6, otpLength: 6,
expiresIn: 10 * 60, // 10 minutes expiresIn: 10 * 60, // 10 minutes
sendVerificationOTP: async ({ email, otp }) => { sendVerificationOTP: async ({ email, otp }) => {
const user = await getUserByEmail(email as string)
if (!user) {
throw new Error("User with this email does not exist")
}
await sendOTPCodeEmail({ email, otp }) await sendOTPCodeEmail({ email, otp })
}, },
}), }),
@@ -65,7 +61,7 @@ export const auth = betterAuth({
}) })
export async function getSession() { export async function getSession() {
if (IS_SELF_HOSTED_MODE) { if (config.selfHosted.isEnabled) {
const user = await getSelfHostedUser() const user = await getSelfHostedUser()
return user ? { user } : null return user ? { user } : null
} }
@@ -78,10 +74,10 @@ export async function getSession() {
export async function getCurrentUser(): Promise<User> { export async function getCurrentUser(): Promise<User> {
const session = await getSession() const session = await getSession()
if (!session || !session.user) { if (!session || !session.user) {
if (IS_SELF_HOSTED_MODE) { if (config.selfHosted.isEnabled) {
redirect(SELF_HOSTED_REDIRECT_URL) redirect(config.selfHosted.redirectUrl)
} else { } else {
redirect(AUTH_LOGIN_URL) redirect(config.auth.loginUrl)
} }
} }
return session.user as User return session.user as User

26
lib/config.ts Normal file
View File

@@ -0,0 +1,26 @@
const config = {
app: {
title: "TaxHacker",
description: "Your personal AI accountant",
version: process.env.npm_package_version || "0.0.1",
},
upload: {
acceptedMimeTypes: "image/*,.pdf,.doc,.docx,.xls,.xlsx",
},
selfHosted: {
isEnabled: process.env.SELF_HOSTED_MODE === "true",
redirectUrl: "/self-hosted/redirect",
welcomeUrl: "/self-hosted",
},
auth: {
loginUrl: "/enter",
disableSignup: process.env.DISABLE_SIGNUP === "true" || process.env.SELF_HOSTED_MODE === "true",
},
email: {
apiKey: process.env.RESEND_API_KEY || "",
from: process.env.RESEND_FROM_EMAIL || "",
audienceId: process.env.RESEND_AUDIENCE_ID || "",
},
}
export default config

View File

@@ -1,7 +0,0 @@
export const APP_TITLE = "TaxHacker"
export const APP_DESCRIPTION = "Your personal AI accountant"
export const FILE_ACCEPTED_MIMETYPES = "image/*,.pdf,.doc,.docx,.xls,.xlsx"
export const IS_SELF_HOSTED_MODE = process.env.SELF_HOSTED_MODE === "true"
export const SELF_HOSTED_REDIRECT_URL = "/self-hosted/redirect"
export const SELF_HOSTED_WELCOME_URL = "/self-hosted"
export const AUTH_LOGIN_URL = "/enter"

View File

@@ -27,9 +27,6 @@ export async function getCurrencyRate(currencyCodeFrom: string, currencyCodeTo:
export async function fetchHistoricalCurrencyRates(currency: string = "USD", date: Date): Promise<HistoricRate[]> { export async function fetchHistoricalCurrencyRates(currency: string = "USD", date: Date): Promise<HistoricRate[]> {
const formattedDate = format(date, "yyyy-MM-dd") const formattedDate = format(date, "yyyy-MM-dd")
console.log("DATE", formattedDate)
console.log("QUERY", encodeURIComponent(`https://www.xe.com/currencytables/?from=${currency}&date=${formattedDate}`))
const url = `https://corsproxy.io/?url=${encodeURIComponent( const url = `https://corsproxy.io/?url=${encodeURIComponent(
`https://www.xe.com/currencytables/?from=${currency}&date=${formattedDate}` `https://www.xe.com/currencytables/?from=${currency}&date=${formattedDate}`
)}` )}`

View File

@@ -2,14 +2,15 @@ import { NewsletterWelcomeEmail } from "@/components/emails/newsletter-welcome-e
import { OTPEmail } from "@/components/emails/otp-email" import { OTPEmail } from "@/components/emails/otp-email"
import React from "react" import React from "react"
import { Resend } from "resend" import { Resend } from "resend"
import config from "./config"
export const resend = new Resend(process.env.RESEND_API_KEY) export const resend = new Resend(config.email.apiKey)
export async function sendOTPCodeEmail({ email, otp }: { email: string; otp: string }) { export async function sendOTPCodeEmail({ email, otp }: { email: string; otp: string }) {
const html = React.createElement(OTPEmail, { otp }) const html = React.createElement(OTPEmail, { otp })
return await resend.emails.send({ return await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL!, from: config.email.from,
to: email, to: email,
subject: "Your TaxHacker verification code", subject: "Your TaxHacker verification code",
react: html, react: html,
@@ -20,7 +21,7 @@ export async function sendNewsletterWelcomeEmail(email: string) {
const html = React.createElement(NewsletterWelcomeEmail) const html = React.createElement(NewsletterWelcomeEmail)
return await resend.emails.send({ return await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL as string, from: config.email.from,
to: email, to: email,
subject: "Welcome to TaxHacker Newsletter!", subject: "Welcome to TaxHacker Newsletter!",
react: html, react: html,

View File

@@ -1,15 +1,15 @@
import { default as globalConfig } from "@/lib/config"
import { getSessionCookie } from "better-auth/cookies" import { getSessionCookie } from "better-auth/cookies"
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { AUTH_LOGIN_URL, IS_SELF_HOSTED_MODE } from "./lib/constants"
export default async function middleware(request: NextRequest) { export default async function middleware(request: NextRequest) {
if (IS_SELF_HOSTED_MODE) { if (globalConfig.selfHosted.isEnabled) {
return NextResponse.next() return NextResponse.next()
} }
const sessionCookie = getSessionCookie(request, { cookiePrefix: "taxhacker" }) const sessionCookie = getSessionCookie(request, { cookiePrefix: "taxhacker" })
if (!sessionCookie) { if (!sessionCookie) {
return NextResponse.redirect(new URL(AUTH_LOGIN_URL, request.url)) return NextResponse.redirect(new URL(globalConfig.auth.loginUrl, request.url))
} }
return NextResponse.next() return NextResponse.next()
} }

View File

@@ -3,8 +3,8 @@ import { prisma } from "@/lib/db"
type BackupSetting = { type BackupSetting = {
filename: string filename: string
model: any model: any
recordToBackup: (userId: string, row: any) => Record<string, any> backup: (userId: string, row: any) => Record<string, any>
backupToRecord: (userId: string, json: Record<string, any>) => any restore: (userId: string, json: Record<string, any>) => any
} }
// Ordering is important here // Ordering is important here
@@ -12,7 +12,7 @@ export const MODEL_BACKUP: BackupSetting[] = [
{ {
filename: "settings.json", filename: "settings.json",
model: prisma.setting, model: prisma.setting,
recordToBackup: (userId: string, row: any) => { backup: (userId: string, row: any) => {
return { return {
id: row.id, id: row.id,
code: row.code, code: row.code,
@@ -21,9 +21,8 @@ export const MODEL_BACKUP: BackupSetting[] = [
value: row.value, value: row.value,
} }
}, },
backupToRecord: (userId: string, json: any) => { restore: (userId: string, json: any) => {
return { return {
id: json.id,
code: json.code, code: json.code,
name: json.name, name: json.name,
description: json.description, description: json.description,
@@ -39,16 +38,15 @@ export const MODEL_BACKUP: BackupSetting[] = [
{ {
filename: "currencies.json", filename: "currencies.json",
model: prisma.currency, model: prisma.currency,
recordToBackup: (userId: string, row: any) => { backup: (userId: string, row: any) => {
return { return {
id: row.id, id: row.id,
code: row.code, code: row.code,
name: row.name, name: row.name,
} }
}, },
backupToRecord: (userId: string, json: any) => { restore: (userId: string, json: any) => {
return { return {
id: json.id,
code: json.code, code: json.code,
name: json.name, name: json.name,
user: { user: {
@@ -62,7 +60,7 @@ export const MODEL_BACKUP: BackupSetting[] = [
{ {
filename: "categories.json", filename: "categories.json",
model: prisma.category, model: prisma.category,
recordToBackup: (userId: string, row: any) => { backup: (userId: string, row: any) => {
return { return {
id: row.id, id: row.id,
code: row.code, code: row.code,
@@ -72,9 +70,8 @@ export const MODEL_BACKUP: BackupSetting[] = [
createdAt: row.createdAt, createdAt: row.createdAt,
} }
}, },
backupToRecord: (userId: string, json: any) => { restore: (userId: string, json: any) => {
return { return {
id: json.id,
code: json.code, code: json.code,
name: json.name, name: json.name,
color: json.color, color: json.color,
@@ -91,7 +88,7 @@ export const MODEL_BACKUP: BackupSetting[] = [
{ {
filename: "projects.json", filename: "projects.json",
model: prisma.project, model: prisma.project,
recordToBackup: (userId: string, row: any) => { backup: (userId: string, row: any) => {
return { return {
id: row.id, id: row.id,
code: row.code, code: row.code,
@@ -101,9 +98,8 @@ export const MODEL_BACKUP: BackupSetting[] = [
createdAt: row.createdAt, createdAt: row.createdAt,
} }
}, },
backupToRecord: (userId: string, json: any) => { restore: (userId: string, json: any) => {
return { return {
id: json.id,
code: json.code, code: json.code,
name: json.name, name: json.name,
color: json.color, color: json.color,
@@ -120,7 +116,7 @@ export const MODEL_BACKUP: BackupSetting[] = [
{ {
filename: "fields.json", filename: "fields.json",
model: prisma.field, model: prisma.field,
recordToBackup: (userId: string, row: any) => { backup: (userId: string, row: any) => {
return { return {
id: row.id, id: row.id,
code: row.code, code: row.code,
@@ -134,9 +130,8 @@ export const MODEL_BACKUP: BackupSetting[] = [
isExtra: row.isExtra, isExtra: row.isExtra,
} }
}, },
backupToRecord: (userId: string, json: any) => { restore: (userId: string, json: any) => {
return { return {
id: json.id,
code: json.code, code: json.code,
name: json.name, name: json.name,
type: json.type, type: json.type,
@@ -157,7 +152,7 @@ export const MODEL_BACKUP: BackupSetting[] = [
{ {
filename: "files.json", filename: "files.json",
model: prisma.file, model: prisma.file,
recordToBackup: (userId: string, row: any) => { backup: (userId: string, row: any) => {
return { return {
id: row.id, id: row.id,
filename: row.filename, filename: row.filename,
@@ -168,7 +163,7 @@ export const MODEL_BACKUP: BackupSetting[] = [
createdAt: row.createdAt, createdAt: row.createdAt,
} }
}, },
backupToRecord: (userId: string, json: any) => { restore: (userId: string, json: any) => {
return { return {
id: json.id, id: json.id,
filename: json.filename, filename: json.filename,
@@ -187,7 +182,7 @@ export const MODEL_BACKUP: BackupSetting[] = [
{ {
filename: "transactions.json", filename: "transactions.json",
model: prisma.transaction, model: prisma.transaction,
recordToBackup: (userId: string, row: any) => { backup: (userId: string, row: any) => {
return { return {
id: row.id, id: row.id,
name: row.name, name: row.name,
@@ -209,7 +204,7 @@ export const MODEL_BACKUP: BackupSetting[] = [
text: row.text, text: row.text,
} }
}, },
backupToRecord: (userId: string, json: any) => { restore: (userId: string, json: any) => {
return { return {
id: json.id, id: json.id,
name: json.name, name: json.name,
@@ -244,21 +239,25 @@ export const MODEL_BACKUP: BackupSetting[] = [
}, },
] ]
export async function modelToJSON(userId: string, backup: BackupSetting): Promise<string> { export async function modelToJSON(userId: string, backupSettings: BackupSetting): Promise<string> {
const data = await backup.model.findMany({ where: { userId } }) const data = await backupSettings.model.findMany({ where: { userId } })
if (!data || data.length === 0) { if (!data || data.length === 0) {
return "[]" return "[]"
} }
return JSON.stringify( return JSON.stringify(
data.map((row: any) => backup.recordToBackup(userId, row)), data.map((row: any) => backupSettings.backup(userId, row)),
null, null,
2 2
) )
} }
export async function modelFromJSON(userId: string, backup: BackupSetting, jsonContent: string): Promise<number> { export async function modelFromJSON(
userId: string,
backupSettings: BackupSetting,
jsonContent: string
): Promise<number> {
if (!jsonContent) return 0 if (!jsonContent) return 0
try { try {
@@ -273,8 +272,8 @@ export async function modelFromJSON(userId: string, backup: BackupSetting, jsonC
const record = preprocessRowData(rawRecord) const record = preprocessRowData(rawRecord)
try { try {
const data = await backup.backupToRecord(userId, record) const data = await backupSettings.restore(userId, record)
await backup.model.create({ data }) await backupSettings.model.create({ data })
} catch (error) { } catch (error) {
console.error(`Error importing record:`, error) console.error(`Error importing record:`, error)
} }