fix(wizard): fork 409 — auto-unique slug + editable name/slug in fork step
Bug: forking a template POSTed /v1/servers with slug = trySlug(template.title),
a fixed value. If the user had any server with that slug already (e.g. forking
the same template twice, or a name collision), the create returned 409
slug_taken — and the fork wizard skips Step 1, so there was no slug field to
fix it with. The user was stuck (8 repeated 409s in the report).
Fix:
- On fork setup, after the fork call, GET /v1/servers and auto-unique the
default slug: echo-demo-template -> echo-demo-template-2 -> -3 ... against the
user's existing slugs. Lookup failure is non-fatal (slug field is editable).
- Fork step 2 now renders editable Name + Slug fields in the fork banner
('must be unique in your workspace' hint) — the normal wizard has these in
Step 1, which the fork flow skips, so they belong here.
- slug_taken build error now reads 'The slug "x" is already used by one of
your servers — change the Slug field above' instead of the raw code.
Note: the SES lockdown-install.js and content.js 'query' errors in the report
are browser extensions, not the app.
Verified: forked echo-demo-template (whose base slug was already taken) — slug
auto-filled echo-demo-template-2, build succeeded, container live on :4111,
template fork_count incremented to 2.
This commit is contained in:
parent
414903f16d
commit
c78420e0be
@ -130,8 +130,27 @@ export default function NewServerPage() {
|
|||||||
};
|
};
|
||||||
}>(`/v1/templates/${templateSlug}/fork`, { method: 'POST', body: '{}' });
|
}>(`/v1/templates/${templateSlug}/fork`, { method: 'POST', body: '{}' });
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
|
// Auto-unique the slug against the user's existing servers so the
|
||||||
|
// default fork doesn't 409 (e.g. forking the same template twice).
|
||||||
|
let uniqueSlug = trySlug(res.template.title);
|
||||||
|
try {
|
||||||
|
const own = await apiFetch<{ servers: { slug: string }[] }>('/v1/servers');
|
||||||
|
const taken = new Set(own.servers.map((s) => s.slug));
|
||||||
|
if (taken.has(uniqueSlug)) {
|
||||||
|
const base = uniqueSlug;
|
||||||
|
let n = 2;
|
||||||
|
while (taken.has(`${base}-${n}`)) n++;
|
||||||
|
uniqueSlug = `${base}-${n}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If the lookup fails we still proceed; the slug field is editable
|
||||||
|
// and the build surfaces a clear slug_taken error.
|
||||||
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
setName(res.template.title);
|
setName(res.template.title);
|
||||||
setSlug(trySlug(res.template.title));
|
setSlug(uniqueSlug);
|
||||||
setPrompt(`Fork of "${res.template.title}" template.`);
|
setPrompt(`Fork of "${res.template.title}" template.`);
|
||||||
setForkedTemplateId(res.templateId);
|
setForkedTemplateId(res.templateId);
|
||||||
setForkedTemplateTitle(res.template.title);
|
setForkedTemplateTitle(res.template.title);
|
||||||
@ -325,7 +344,12 @@ export default function NewServerPage() {
|
|||||||
setStep('building');
|
setStep('building');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const detail = (e as { detail?: { error?: string; detail?: unknown } }).detail;
|
const detail = (e as { detail?: { error?: string; detail?: unknown } }).detail;
|
||||||
setError(detail?.error ?? (e as Error).message);
|
const code = detail?.error;
|
||||||
|
setError(
|
||||||
|
code === 'slug_taken'
|
||||||
|
? `The slug "${slug}" is already used by one of your servers — change the Slug field above.`
|
||||||
|
: (code ?? (e as Error).message),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -424,19 +448,44 @@ export default function NewServerPage() {
|
|||||||
{step === 'confirm' && preview && editable && (
|
{step === 'confirm' && preview && editable && (
|
||||||
<div className="mt-7 space-y-6">
|
<div className="mt-7 space-y-6">
|
||||||
{forkedTemplateTitle && (
|
{forkedTemplateTitle && (
|
||||||
<div className="panel-subtle p-3 flex items-center justify-between">
|
<div className="panel-subtle p-3 space-y-3">
|
||||||
<div className="text-[12.5px]">
|
<div className="flex items-center justify-between">
|
||||||
Forking <span className="mono font-semibold">{forkedTemplateTitle}</span> — fill in
|
<div className="text-[12.5px]">
|
||||||
your own credentials below. The template author never sees them.
|
Forking <span className="mono font-semibold">{forkedTemplateTitle}</span> — name
|
||||||
|
your copy and fill in your own credentials. The template author never sees them.
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`/templates/${templateSlug}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="shrink-0 text-[11.5px] text-[--color-fg-muted] underline hover:text-[--color-fg]"
|
||||||
|
>
|
||||||
|
Template ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="fork-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="fork-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setName(e.target.value);
|
||||||
|
if (!slug || slug === trySlug(name)) setSlug(trySlug(e.target.value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="fork-slug" hint="must be unique in your workspace">
|
||||||
|
Slug
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="fork-slug"
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => setSlug(trySlug(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a
|
|
||||||
href={`/templates/${templateSlug}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-[11.5px] text-[--color-fg-muted] underline hover:text-[--color-fg]"
|
|
||||||
>
|
|
||||||
Template ↗
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="panel p-4">
|
<div className="panel p-4">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user