From 5b613b71074aa0b14ce4ba6c53e2099a5bbdc64c Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:25:25 +0200 Subject: [PATCH] updated bucket settings page --- actions/settings.ts | 76 +++++++++ app/(dashboard)/settings/page.tsx | 12 ++ components/settings/HetznerConfigForm.tsx | 146 ++++++++++++++++++ lib/storage.ts | 49 ++++-- .../migration.sql | 8 + prisma/schema.prisma | 9 ++ 6 files changed, 284 insertions(+), 16 deletions(-) create mode 100644 actions/settings.ts create mode 100644 components/settings/HetznerConfigForm.tsx create mode 100644 prisma/migrations/20260625100000_add_system_config/migration.sql diff --git a/actions/settings.ts b/actions/settings.ts new file mode 100644 index 0000000..07e0bfe --- /dev/null +++ b/actions/settings.ts @@ -0,0 +1,76 @@ +"use server"; + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +const HETZNER_KEYS = [ + "hetzner_endpoint", + "hetzner_access_key", + "hetzner_secret_key", + "hetzner_bucket_name", +] as const; + +type HetznerKey = (typeof HETZNER_KEYS)[number]; + +async function requireAdmin() { + const session = await auth(); + if (!session?.user) throw new Error("Unauthorized"); + if (session.user.role !== "ADMIN") throw new Error("Admin access required"); + return session; +} + +/** Returns the current Hetzner config values (secret key masked for display). */ +export async function getHetznerConfig(): Promise<{ + hetzner_endpoint: string; + hetzner_access_key: string; + hetzner_secret_key: string; + hetzner_bucket_name: string; +}> { + await requireAdmin(); + + const rows = await db.systemConfig.findMany({ + where: { key: { in: [...HETZNER_KEYS] } }, + }); + + const map = Object.fromEntries(rows.map((r) => [r.key, r.value])) as Partial< + Record + >; + + return { + hetzner_endpoint: map.hetzner_endpoint ?? process.env.HETZNER_ENDPOINT ?? "", + hetzner_access_key: map.hetzner_access_key ?? process.env.HETZNER_ACCESS_KEY ?? "", + hetzner_secret_key: map.hetzner_secret_key ?? process.env.HETZNER_SECRET_KEY ?? "", + hetzner_bucket_name: map.hetzner_bucket_name ?? process.env.HETZNER_BUCKET_NAME ?? "", + }; +} + +const hetznerConfigSchema = z.object({ + hetzner_endpoint: z.string().url("Must be a valid URL").or(z.literal("")), + hetzner_access_key: z.string().min(1, "Required"), + hetzner_secret_key: z.string().min(1, "Required"), + hetzner_bucket_name: z.string().min(1, "Required"), +}); + +/** Upserts all four Hetzner config values into system_config. */ +export async function saveHetznerConfig( + data: z.infer +): Promise<{ success: true }> { + await requireAdmin(); + + const parsed = hetznerConfigSchema.parse(data); + + await db.$transaction( + (Object.entries(parsed) as [HetznerKey, string][]).map(([key, value]) => + db.systemConfig.upsert({ + where: { key }, + create: { key, value }, + update: { value }, + }) + ) + ); + + revalidatePath("/settings"); + return { success: true }; +} diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index b56b29c..bbde2d9 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -3,6 +3,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { getInitials } from "@/lib/utils"; import { ChangePasswordForm } from "@/components/settings/ChangePasswordForm"; +import { HetznerConfigForm } from "@/components/settings/HetznerConfigForm"; +import { getHetznerConfig } from "@/actions/settings"; export const metadata = { title: "Settings" }; @@ -10,6 +12,9 @@ export default async function SettingsPage() { const session = await auth(); if (!session?.user) return null; + const isAdmin = session.user.role === "ADMIN"; + const hetznerConfig = isAdmin ? await getHetznerConfig() : null; + return (

Account Settings

@@ -36,6 +41,13 @@ export default async function SettingsPage() { + + {isAdmin && hetznerConfig && ( +
+

Admin — Storage Configuration

+ +
+ )}
); } diff --git a/components/settings/HetznerConfigForm.tsx b/components/settings/HetznerConfigForm.tsx new file mode 100644 index 0000000..7e2a7c9 --- /dev/null +++ b/components/settings/HetznerConfigForm.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { useState } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Eye, EyeOff, HardDrive, CheckCircle2, AlertCircle } from 'lucide-react'; +import { saveHetznerConfig } from '@/actions/settings'; + +interface HetznerConfig { + hetzner_endpoint: string; + hetzner_access_key: string; + hetzner_secret_key: string; + hetzner_bucket_name: string; +} + +interface Props { + initialConfig: HetznerConfig; +} + +export function HetznerConfigForm({ initialConfig }: Props) { + const [form, setForm] = useState(initialConfig); + const [showSecret, setShowSecret] = useState(false); + const [loading, setLoading] = useState(false); + const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle'); + const [errorMsg, setErrorMsg] = useState(null); + + function handleChange(key: keyof HetznerConfig) { + return (e: React.ChangeEvent) => { + setForm((prev) => ({ ...prev, [key]: e.target.value })); + setStatus('idle'); + }; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setStatus('idle'); + setErrorMsg(null); + try { + await saveHetznerConfig(form); + setStatus('success'); + } catch (err: unknown) { + setStatus('error'); + setErrorMsg(err instanceof Error ? err.message : 'Failed to save configuration.'); + } finally { + setLoading(false); + } + } + + return ( + + + + + Hetzner Object Storage + + + Configure the Hetzner S3-compatible bucket used for high-res deliverable storage. + These values override the environment variables and take effect immediately — no rebuild required. + + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ + {status === 'success' && ( +
+ + Configuration saved — changes are live immediately. +
+ )} + + {status === 'error' && ( +
+ + {errorMsg} +
+ )} + + +
+
+
+ ); +} diff --git a/lib/storage.ts b/lib/storage.ts index c6d4c59..959426f 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -20,6 +20,7 @@ import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import fs from "fs"; import path from "path"; import { randomUUID } from "crypto"; +import { db } from "@/lib/db"; export type StorageProvider = "local" | "uploadthing" | "s3" | "r2" | "b2" | "minio"; @@ -222,19 +223,35 @@ function getPublicUrl(key: string, provider: StorageProvider): string { // ── Hetzner Object Storage (dedicated high-res bucket) ─────────────────────── -function buildHetznerClient(): S3Client { - return new S3Client({ - region: "auto", - endpoint: process.env.HETZNER_ENDPOINT!, - credentials: { - accessKeyId: process.env.HETZNER_ACCESS_KEY!, - secretAccessKey: process.env.HETZNER_SECRET_KEY!, - }, +async function getHetznerConfig(): Promise<{ + endpoint: string; + accessKey: string; + secretKey: string; + bucket: string; +}> { + const rows = await db.systemConfig.findMany({ + where: { key: { in: ["hetzner_endpoint", "hetzner_access_key", "hetzner_secret_key", "hetzner_bucket_name"] } }, }); + const map = Object.fromEntries(rows.map((r) => [r.key, r.value])); + return { + endpoint: map["hetzner_endpoint"] ?? process.env.HETZNER_ENDPOINT ?? "", + accessKey: map["hetzner_access_key"] ?? process.env.HETZNER_ACCESS_KEY ?? "", + secretKey: map["hetzner_secret_key"] ?? process.env.HETZNER_SECRET_KEY ?? "", + bucket: map["hetzner_bucket_name"] ?? process.env.HETZNER_BUCKET_NAME ?? "vfx-review", + }; } -function getHetznerBucket(): string { - return process.env.HETZNER_BUCKET_NAME ?? "vfx-review"; +async function buildHetznerClient(): Promise<{ client: S3Client; bucket: string }> { + const cfg = await getHetznerConfig(); + const client = new S3Client({ + region: "auto", + endpoint: cfg.endpoint, + credentials: { + accessKeyId: cfg.accessKey, + secretAccessKey: cfg.secretKey, + }, + }); + return { client, bucket: cfg.bucket }; } /** @@ -248,10 +265,10 @@ export async function uploadToHetzner( folder: string = "highres" ): Promise<{ key: string }> { const key = `${folder}/${randomUUID()}-${fileName}`; - const client = buildHetznerClient(); + const { client, bucket } = await buildHetznerClient(); await client.send( new PutObjectCommand({ - Bucket: getHetznerBucket(), + Bucket: bucket, Key: key, Body: buffer, ContentType: contentType, @@ -269,9 +286,9 @@ export async function generateHetznerDownloadUrl( originalFilename: string, expiresIn: number = 3600 ): Promise { - const client = buildHetznerClient(); + const { client, bucket } = await buildHetznerClient(); const command = new GetObjectCommand({ - Bucket: getHetznerBucket(), + Bucket: bucket, Key: key, ResponseContentDisposition: `attachment; filename="${originalFilename}"`, }); @@ -282,8 +299,8 @@ export async function generateHetznerDownloadUrl( * Delete a file from the Hetzner bucket by key. */ export async function deleteFromHetzner(key: string): Promise { - const client = buildHetznerClient(); + const { client, bucket } = await buildHetznerClient(); await client.send( - new DeleteObjectCommand({ Bucket: getHetznerBucket(), Key: key }) + new DeleteObjectCommand({ Bucket: bucket, Key: key }) ); } diff --git a/prisma/migrations/20260625100000_add_system_config/migration.sql b/prisma/migrations/20260625100000_add_system_config/migration.sql new file mode 100644 index 0000000..0dd2d93 --- /dev/null +++ b/prisma/migrations/20260625100000_add_system_config/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "system_config" ( + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "system_config_pkey" PRIMARY KEY ("key") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8406467..b305ba3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -546,3 +546,12 @@ model ReviewSession { @@map("review_sessions") } + +/// Key-value store for system-wide configuration (e.g. storage credentials) +model SystemConfig { + key String @id + value String @db.Text + updatedAt DateTime @updatedAt + + @@map("system_config") +}