since3-portal/modules/admin/ui/components/internal-credential-dialog.tsx
2026-10-11 18:22:32 +02:00

251 lines
12 KiB
TypeScript

"use client"
import { useActionState, useState, type ReactElement } from "react"
import { EyeIcon, EyeOffIcon, PlusIcon, WandSparklesIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import {
createInternalCredential,
updateInternalCredential,
type InternalCredentialState,
} from "@/modules/admin/actions/internal-credentials"
import { BILLING_CYCLES, BILLING_LABELS, PLANS, type Plan } from "@/modules/admin/lib/internal-costs"
import type { InternalCredential } from "@/modules/admin/queries/internal-credentials"
import { SERVICES } from "@/modules/shared/data/services-data"
import { formatAmount } from "@/modules/shared/lib/invoice-math"
import { ServiceLogo } from "@/modules/shared/ui/components/service-logo"
const selectClassName =
"flex h-10 w-full rounded-md border border-input bg-background px-3 text-body outline-none transition-colors disabled:cursor-not-allowed disabled:opacity-50"
const PLAN_LABELS: Record<Plan, string> = { free: "Kostenlos", paid: "Bezahlt" }
// 20 zufällige Zeichen (Web Crypto) ohne leicht verwechselbare Zeichen wie l/1 oder O/0
const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!#$%&*+-=?@_"
function generatePassword(length = 20) {
const values = crypto.getRandomValues(new Uint32Array(length))
return Array.from(values, (value) => ALPHABET[value % ALPHABET.length]).join("")
}
function DialogBody({ credential, onDone }: { credential: InternalCredential | null; onDone: () => void }) {
const [serviceId, setServiceId] = useState(credential?.service ?? SERVICES[0].id)
const [plan, setPlan] = useState<Plan>(credential?.plan ?? "free")
const [password, setPassword] = useState("")
const [visible, setVisible] = useState(false)
async function submit(previous: InternalCredentialState, formData: FormData) {
const action = credential ? updateInternalCredential : createInternalCredential
const result = await action(previous, formData)
if (result && "success" in result) onDone()
return result
}
const [state, formAction, pending] = useActionState(submit, undefined)
return (
<>
<DialogHeader className="mb-4">
<DialogTitle>{credential ? "Zugang bearbeiten" : "Neuer interner Zugang"}</DialogTitle>
</DialogHeader>
<form action={formAction}>
{credential && <input type="hidden" name="id" value={credential.id} />}
<FieldGroup>
<Field>
<FieldLabel htmlFor="internal-service">
Dienst <span className="text-destructive">*</span>
</FieldLabel>
<div className="flex items-center gap-2">
<div className="flex size-10 shrink-0 items-center justify-center rounded-md border border-input bg-background">
<ServiceLogo serviceId={serviceId} size={20} className="size-5 object-contain" />
</div>
<select
id="internal-service"
name="service"
value={serviceId}
onChange={(event) => setServiceId(event.target.value)}
required
className={selectClassName}
>
{SERVICES.map((service) => (
<option key={service.id} value={service.id}>
{service.label}
</option>
))}
</select>
</div>
</Field>
<Field>
<FieldLabel htmlFor="internal-title">Bezeichnung</FieldLabel>
<Input
id="internal-title"
name="title"
defaultValue={credential?.title ?? ""}
placeholder="z. B. Since3 Team-Account (optional)"
className="focus-visible:ring-0"
/>
</Field>
<Field>
<FieldLabel htmlFor="internal-username">
Benutzername <span className="text-destructive">*</span>
</FieldLabel>
<Input
id="internal-username"
name="username"
defaultValue={credential?.username ?? ""}
required
autoComplete="off"
className="focus-visible:ring-0"
/>
</Field>
<Field>
<FieldLabel htmlFor="internal-password">
Passwort {!credential && <span className="text-destructive">*</span>}
</FieldLabel>
<div className="flex items-center gap-2">
<Input
id="internal-password"
name="password"
type={visible ? "text" : "password"}
value={password}
onChange={(event) => setPassword(event.target.value)}
required={!credential}
autoComplete="new-password"
placeholder={credential ? "Leer lassen, um es zu behalten" : undefined}
className="font-mono focus-visible:ring-0"
/>
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
onClick={() => setVisible(!visible)}
aria-label={visible ? "Passwort verbergen" : "Passwort anzeigen"}
>
{visible ? <EyeOffIcon /> : <EyeIcon />}
</Button>
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
onClick={() => {
setPassword(generatePassword())
setVisible(true)
}}
aria-label="Sicheres Passwort erzeugen"
title="Sicheres Passwort erzeugen"
>
<WandSparklesIcon />
</Button>
</div>
</Field>
<Field>
<FieldLabel htmlFor="internal-url">Login-Adresse</FieldLabel>
<Input
id="internal-url"
name="url"
type="url"
defaultValue={credential?.url ?? ""}
placeholder="https:// (optional)"
className="focus-visible:ring-0"
/>
</Field>
<Field>
<FieldLabel>Kosten</FieldLabel>
<div className="grid grid-cols-2 gap-1 rounded-lg border border-input p-1">
{PLANS.map((option) => (
<label
key={option}
className={cn(
"cursor-pointer rounded-md px-3 py-1.5 text-center text-sm transition-colors has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50",
plan === option ? "bg-surface font-medium" : "text-muted-foreground hover:text-foreground"
)}
>
<input
type="radio"
name="plan"
value={option}
checked={plan === option}
onChange={() => setPlan(option)}
className="sr-only"
/>
{PLAN_LABELS[option]}
</label>
))}
</div>
</Field>
{plan === "paid" && (
<div className="grid gap-3 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="internal-price">
Preis in € <span className="text-destructive">*</span>
</FieldLabel>
<Input
id="internal-price"
name="price"
inputMode="decimal"
required
placeholder="z. B. 12,99"
defaultValue={credential?.price != null ? formatAmount(credential.price) : ""}
className="tabular-nums focus-visible:ring-0"
/>
</Field>
<Field>
<FieldLabel htmlFor="internal-billing">Abrechnung</FieldLabel>
<select
id="internal-billing"
name="billingCycle"
defaultValue={credential?.billing_cycle ?? "monthly"}
className={selectClassName}
>
{BILLING_CYCLES.map((cycle) => (
<option key={cycle} value={cycle}>
{BILLING_LABELS[cycle].label}
</option>
))}
</select>
</Field>
</div>
)}
{state && "error" in state && <p className="text-sm text-destructive">{state.error}</p>}
</FieldGroup>
<DialogFooter className="mt-6">
<Button type="submit" disabled={pending}>
{pending ? "Wird gespeichert..." : "Speichern"}
</Button>
</DialogFooter>
</form>
</>
)
}
export function InternalCredentialDialog({ credential, trigger }: { credential?: InternalCredential; trigger?: ReactElement }) {
const [open, setOpen] = useState(false)
const [instance, setInstance] = useState(0)
// Beim Schließen neu aufbauen, damit das Formular beim nächsten Öffnen leer bzw. aktuell ist
function handleOpenChange(next: boolean) {
setOpen(next)
if (!next) setInstance((key) => key + 1)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={trigger ?? <Button variant="outline" className="cursor-pointer" />}>
{!trigger && (
<>
<PlusIcon className="size-4" />
Neuer Zugang
</>
)}
</DialogTrigger>
<DialogContent>
<DialogBody key={instance} credential={credential ?? null} onDone={() => handleOpenChange(false)} />
</DialogContent>
</Dialog>
)
}