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.
This commit is contained in:
290
apps/web/src/components/servers/create-server-wizard.tsx
Normal file
290
apps/web/src/components/servers/create-server-wizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user