Enhance API with OIDC support, including login and callback endpoints. Update environment variables for OIDC configuration in .env.example. Add new features to the catalog service for listing software families, Minecraft versions, and deployment regions. Implement server management actions such as kill, delete, and update in the servers module. Integrate feature flags for maintenance mode in server operations. Update pnpm-lock.yaml with new dependencies and versions.
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 12s
CI / Go — node-agent tests (push) Failing after 9s
CI / Go — edge-gateway build (push) Successful in 17s

This commit is contained in:
TheOnlyMace
2026-07-05 18:39:53 +02:00
parent bf36cb3159
commit 50cd4b3ffd
225 changed files with 17824 additions and 436 deletions

View File

@@ -0,0 +1,290 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Link, useRouter } from "@/i18n/navigation";
import { ApiError } from "@/lib/api/auth";
import { createServer, listPlans } from "@/lib/api/servers";
import {
createServerSchema,
MINECRAFT_VERSIONS,
SOFTWARE_FAMILIES,
type CreateServerFormValues,
} from "@/lib/schemas/server";
const inputClassName =
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
const STEPS = ["basics", "software", "review"] as const;
type WizardStep = (typeof STEPS)[number];
export function CreateServerWizard() {
const t = useTranslations("servers");
const tw = useTranslations("servers.wizard");
const tv = useTranslations("validation");
const router = useRouter();
const [step, setStep] = useState<WizardStep>("basics");
const [apiError, setApiError] = useState<string | null>(null);
const { data: plansData, isLoading: plansLoading } = useQuery({
queryKey: ["plans"],
queryFn: listPlans,
retry: false,
});
const schema = createServerSchema({
nameRequired: tv("serverNameRequired"),
nameMax: tv("serverNameMax"),
planRequired: tv("planRequired"),
planInvalid: tv("planInvalid"),
versionRequired: tv("versionRequired"),
softwareRequired: tv("softwareRequired"),
eulaRequired: tv("eulaRequired"),
});
const form = useForm<CreateServerFormValues>({
resolver: zodResolver(schema),
defaultValues: {
name: "",
planId: "",
minecraftVersion: MINECRAFT_VERSIONS[0],
softwareFamily: "VANILLA",
eulaAccepted: false,
},
mode: "onChange",
});
const {
register,
handleSubmit,
trigger,
watch,
formState: { errors, isSubmitting },
} = form;
const createMutation = useMutation({
mutationFn: createServer,
onSuccess: (server) => {
router.push(`/servers/${server.id}`);
},
onError: (error: unknown) => {
setApiError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.generic"));
},
});
const plans = plansData?.plans ?? [];
const values = watch();
const stepIndex = STEPS.indexOf(step);
async function goNext() {
setApiError(null);
if (step === "basics") {
const valid = await trigger(["name", "planId"]);
if (valid) setStep("software");
return;
}
if (step === "software") {
const valid = await trigger(["softwareFamily", "minecraftVersion"]);
if (valid) setStep("review");
}
}
function goBack() {
setApiError(null);
if (step === "software") setStep("basics");
if (step === "review") setStep("software");
}
async function onSubmit(formValues: CreateServerFormValues) {
setApiError(null);
await createMutation.mutateAsync({
...formValues,
eulaAccepted: true,
});
}
const selectedPlan = plans.find((plan) => plan.id === values.planId);
return (
<Card className="max-w-xl">
<CardHeader>
<CardTitle>{t("createTitle")}</CardTitle>
<CardDescription>{t("createDescription")}</CardDescription>
<ol className="mt-4 flex gap-2" aria-label={tw("stepsLabel")}>
{STEPS.map((stepKey, index) => (
<li
key={stepKey}
className={[
"flex-1 rounded-full py-1 text-center text-xs font-medium",
index <= stepIndex
? "bg-sky-600 text-white dark:bg-sky-500"
: "bg-zinc-200 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400",
].join(" ")}
>
{tw(`steps.${stepKey}`)}
</li>
))}
</ol>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
{step === "basics" ? (
<>
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("name")}
</label>
<input id="name" type="text" className={inputClassName} {...register("name")} />
{errors.name ? (
<p className="text-sm text-red-600 dark:text-red-400">{errors.name.message}</p>
) : null}
</div>
<div className="space-y-2">
<label htmlFor="planId" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("plan")}
</label>
<select
id="planId"
className={inputClassName}
disabled={plansLoading}
{...register("planId")}
>
<option value="">{plansLoading ? t("loading") : t("selectPlan")}</option>
{plans.map((plan) => (
<option key={plan.id} value={plan.id}>
{plan.name} ({plan.maxRamMb} MB)
</option>
))}
</select>
{errors.planId ? (
<p className="text-sm text-red-600 dark:text-red-400">{errors.planId.message}</p>
) : null}
{!plansLoading && plans.length === 0 ? (
<p className="text-sm text-amber-700 dark:text-amber-300">{t("noPlans")}</p>
) : null}
</div>
</>
) : null}
{step === "software" ? (
<>
<div className="space-y-2">
<label
htmlFor="softwareFamily"
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
>
{t("software")}
</label>
<select id="softwareFamily" className={inputClassName} {...register("softwareFamily")}>
{SOFTWARE_FAMILIES.map((family) => (
<option key={family} value={family}>
{t(`softwareOptions.${family}`)}
</option>
))}
</select>
{errors.softwareFamily ? (
<p className="text-sm text-red-600 dark:text-red-400">
{errors.softwareFamily.message}
</p>
) : null}
</div>
<div className="space-y-2">
<label
htmlFor="minecraftVersion"
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
>
{t("version")}
</label>
<select id="minecraftVersion" className={inputClassName} {...register("minecraftVersion")}>
{MINECRAFT_VERSIONS.map((version) => (
<option key={version} value={version}>
{version}
</option>
))}
</select>
{errors.minecraftVersion ? (
<p className="text-sm text-red-600 dark:text-red-400">
{errors.minecraftVersion.message}
</p>
) : null}
</div>
</>
) : null}
{step === "review" ? (
<>
<dl className="space-y-2 rounded-lg border border-zinc-200 p-4 text-sm dark:border-zinc-800">
<div className="flex justify-between gap-4">
<dt className="text-zinc-600 dark:text-zinc-400">{t("name")}</dt>
<dd className="font-medium text-zinc-900 dark:text-zinc-50">{values.name}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-zinc-600 dark:text-zinc-400">{t("plan")}</dt>
<dd className="font-medium text-zinc-900 dark:text-zinc-50">
{selectedPlan?.name ?? "—"}
</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-zinc-600 dark:text-zinc-400">{t("software")}</dt>
<dd className="font-medium text-zinc-900 dark:text-zinc-50">
{t(`softwareOptions.${values.softwareFamily}`)}
</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-zinc-600 dark:text-zinc-400">{t("version")}</dt>
<dd className="font-medium text-zinc-900 dark:text-zinc-50">
{values.minecraftVersion}
</dd>
</div>
</dl>
<div className="space-y-2">
<label className="flex items-start gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<input type="checkbox" className="mt-1" {...register("eulaAccepted")} />
<span>{t("eula")}</span>
</label>
{errors.eulaAccepted ? (
<p className="text-sm text-red-600 dark:text-red-400">
{errors.eulaAccepted.message}
</p>
) : null}
</div>
</>
) : null}
{apiError ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{apiError}
</p>
) : null}
<div className="flex gap-2">
{step !== "basics" ? (
<Button type="button" variant="secondary" onClick={goBack}>
{tw("back")}
</Button>
) : null}
{step !== "review" ? (
<Button type="button" onClick={() => void goNext()}>
{tw("next")}
</Button>
) : (
<Button type="submit" isLoading={isSubmitting || createMutation.isPending}>
{t("create")}
</Button>
)}
<Link
href="/servers"
className="inline-flex h-10 items-center rounded-md border border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
{t("cancel")}
</Link>
</div>
</form>
</CardContent>
</Card>
);
}