mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
251 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|