Compare commits
2 Commits
d2f2a922a0
...
6b832510f7
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b832510f7 | |||
| ca837670f9 |
@ -21,6 +21,9 @@ DELETE FROM sessions WHERE id = $1 AND user_id = $2;
|
||||
-- name: ListSessionsByUserID :many
|
||||
SELECT * FROM sessions WHERE user_id = $1 ORDER BY last_seen_at DESC;
|
||||
|
||||
-- name: ListActiveSessionsByUserID :many
|
||||
SELECT * FROM sessions WHERE user_id = $1 AND expires_at > NOW() ORDER BY last_seen_at DESC;
|
||||
|
||||
-- name: DeleteSessionsByUserID :many
|
||||
DELETE FROM sessions WHERE user_id = $1 RETURNING id;
|
||||
|
||||
|
||||
@ -7,8 +7,14 @@ RETURNING *;
|
||||
SELECT * FROM templates WHERE id = $1;
|
||||
|
||||
-- name: GetTemplateByTeam :one
|
||||
-- Platform templates (team_id = 00000000-...) are visible to all teams.
|
||||
SELECT * FROM templates WHERE name = $1 AND (team_id = $2 OR team_id = '00000000-0000-0000-0000-000000000000');
|
||||
-- Platform templates (team_id = 00000000-...) are visible to all teams. When a
|
||||
-- team owns a template whose name also matches a platform template, prefer the
|
||||
-- team's own row so mutation guards act on the caller's template, not the
|
||||
-- shared platform one.
|
||||
SELECT * FROM templates
|
||||
WHERE name = $1 AND (team_id = $2 OR team_id = '00000000-0000-0000-0000-000000000000')
|
||||
ORDER BY (team_id = $2) DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetTemplateByName :one
|
||||
-- Look up a template by team_id and name (exact team match, no global fallback).
|
||||
@ -91,6 +97,16 @@ DELETE FROM templates WHERE team_id = $1;
|
||||
-- name: UpdateTemplateSize :exec
|
||||
UPDATE templates SET size_bytes = $2 WHERE id = $1;
|
||||
|
||||
-- name: RenameTemplate :one
|
||||
-- Rename a template by ID. Clears the published flag so stale
|
||||
-- "<team-slug>/<name>" references from other teams break loudly rather than
|
||||
-- silently resolving to a different template. Callers apply ownership and
|
||||
-- protection guards before calling. A name collision (team-scoped uniqueness or
|
||||
-- the global-template-name trigger) surfaces as a unique_violation.
|
||||
UPDATE templates SET name = @new_name, is_public = FALSE
|
||||
WHERE id = @id
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListTemplatesByTeamOnly :many
|
||||
-- List templates owned by a specific team (NOT including platform templates).
|
||||
SELECT * FROM templates WHERE team_id = $1 ORDER BY created_at DESC;
|
||||
|
||||
@ -105,6 +105,12 @@ export async function listAdminTemplates(): Promise<ApiResult<AdminTemplate[]>>
|
||||
return apiFetch('GET', '/api/v1/admin/templates');
|
||||
}
|
||||
|
||||
export async function renameAdminTemplate(name: string, newName: string): Promise<ApiResult<void>> {
|
||||
return apiFetch('PATCH', `/api/v1/admin/templates/${encodeURIComponent(name)}`, {
|
||||
new_name: newName
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAdminTemplate(name: string): Promise<ApiResult<void>> {
|
||||
return apiFetch('DELETE', `/api/v1/admin/templates/${name}`);
|
||||
}
|
||||
|
||||
@ -134,6 +134,16 @@ export async function deleteSnapshot(name: string): Promise<ApiResult<void>> {
|
||||
return apiFetch('DELETE', `/api/v1/snapshots/${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a template the team owns. Renaming unpublishes it, so any
|
||||
* `<team_slug>/<name>` references other teams held stop resolving.
|
||||
*/
|
||||
export async function renameTemplate(name: string, newName: string): Promise<ApiResult<void>> {
|
||||
return apiFetch('PATCH', `/api/v1/snapshots/${encodeURIComponent(name)}`, {
|
||||
new_name: newName
|
||||
});
|
||||
}
|
||||
|
||||
/** Publish or unpublish a template the team owns. */
|
||||
export async function setTemplateVisibility(
|
||||
name: string,
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
createBuild,
|
||||
listAdminTemplates,
|
||||
deleteAdminTemplate,
|
||||
renameAdminTemplate,
|
||||
type Build,
|
||||
type AdminTemplate
|
||||
} from '$lib/api/builds';
|
||||
@ -40,6 +41,12 @@
|
||||
let deleting = $state(false);
|
||||
let deleteError = $state<string | null>(null);
|
||||
|
||||
// Rename dialog.
|
||||
let renameTarget = $state<AdminTemplate | null>(null);
|
||||
let renameValue = $state('');
|
||||
let renaming = $state(false);
|
||||
let renameError = $state<string | null>(null);
|
||||
|
||||
// Create dialog state
|
||||
let showCreate = $state(false);
|
||||
let createForm = $state({
|
||||
@ -79,7 +86,10 @@
|
||||
|
||||
const PLATFORM_TEAM_ID = 'team-0000000000000000000000000';
|
||||
|
||||
function canDeleteTemplate(tmpl: AdminTemplate): boolean {
|
||||
// Platform, non-system templates are the only ones an admin can rename or
|
||||
// delete here. Other teams' templates and the hardcoded system base
|
||||
// templates are off-limits.
|
||||
function canModifyTemplate(tmpl: AdminTemplate): boolean {
|
||||
if (tmpl.protected) return false;
|
||||
return tmpl.team_id === PLATFORM_TEAM_ID;
|
||||
}
|
||||
@ -177,6 +187,33 @@
|
||||
deleting = false;
|
||||
}
|
||||
|
||||
function openRename(tmpl: AdminTemplate) {
|
||||
renameTarget = tmpl;
|
||||
renameValue = tmpl.name;
|
||||
renameError = null;
|
||||
}
|
||||
|
||||
async function handleRenameTemplate() {
|
||||
if (!renameTarget) return;
|
||||
const oldName = renameTarget.name;
|
||||
const newName = renameValue.trim();
|
||||
if (!newName || newName === oldName) {
|
||||
renameTarget = null;
|
||||
return;
|
||||
}
|
||||
renaming = true;
|
||||
renameError = null;
|
||||
const result = await renameAdminTemplate(oldName, newName);
|
||||
if (result.ok) {
|
||||
templates = templates.map((t) => (t.name === oldName ? { ...t, name: newName } : t));
|
||||
renameTarget = null;
|
||||
toast.success('Template renamed');
|
||||
} else {
|
||||
renameError = result.error;
|
||||
}
|
||||
renaming = false;
|
||||
}
|
||||
|
||||
function openBuild(buildId: string) {
|
||||
goto(`/admin/templates/builds/${buildId}`);
|
||||
}
|
||||
@ -473,16 +510,28 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-3.5 text-right">
|
||||
<button
|
||||
onclick={() => { deleteTarget = tmpl; deleteError = null; }}
|
||||
disabled={!canDeleteTemplate(tmpl)}
|
||||
title={tmpl.protected ? 'System base templates cannot be deleted' : !canDeleteTemplate(tmpl) ? 'Cannot delete templates owned by other teams' : undefined}
|
||||
class="rounded-[var(--radius-button)] px-3 py-1.5 text-meta transition-all duration-150 {canDeleteTemplate(tmpl)
|
||||
? 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-red)]/10 hover:text-[var(--color-red)]'
|
||||
: 'text-[var(--color-text-muted)] cursor-not-allowed opacity-40'}"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onclick={() => openRename(tmpl)}
|
||||
disabled={!canModifyTemplate(tmpl)}
|
||||
title={tmpl.protected ? 'System base templates cannot be renamed' : !canModifyTemplate(tmpl) ? 'Only platform templates can be renamed here' : undefined}
|
||||
class="rounded-[var(--radius-button)] px-3 py-1.5 text-meta transition-all duration-150 {canModifyTemplate(tmpl)
|
||||
? 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)]'
|
||||
: 'text-[var(--color-text-muted)] cursor-not-allowed opacity-40'}"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { deleteTarget = tmpl; deleteError = null; }}
|
||||
disabled={!canModifyTemplate(tmpl)}
|
||||
title={tmpl.protected ? 'System base templates cannot be deleted' : !canModifyTemplate(tmpl) ? 'Cannot delete templates owned by other teams' : undefined}
|
||||
class="rounded-[var(--radius-button)] px-3 py-1.5 text-meta transition-all duration-150 {canModifyTemplate(tmpl)
|
||||
? 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-red)]/10 hover:text-[var(--color-red)]'
|
||||
: 'text-[var(--color-text-muted)] cursor-not-allowed opacity-40'}"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
@ -789,6 +838,75 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Rename Template ──────────────────────────────────────────────── -->
|
||||
{#if renameTarget}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/60"
|
||||
onclick={() => { if (!renaming) renameTarget = null; }}
|
||||
onkeydown={(e) => { if (e.key === 'Escape' && !renaming) renameTarget = null; }}
|
||||
></div>
|
||||
<div
|
||||
class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]"
|
||||
style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)"
|
||||
>
|
||||
<div class="p-6">
|
||||
<h2 class="font-serif text-heading leading-tight text-[var(--color-text-bright)]">
|
||||
Rename Template
|
||||
</h2>
|
||||
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
|
||||
Rename the platform template <code class="rounded bg-[var(--color-bg-4)] px-1.5 py-0.5 font-mono text-[0.8rem] text-[var(--color-text-primary)]">{renameTarget.name}</code>.
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
bind:value={renameValue}
|
||||
autofocus
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
onkeydown={(e) => { if (e.key === 'Enter' && !renaming) handleRenameTemplate(); }}
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-1)] px-3 py-2 font-mono text-ui text-[var(--color-text-primary)] outline-none transition-colors duration-150 focus:border-[var(--color-border-mid)]"
|
||||
/>
|
||||
<p class="mt-1.5 text-meta text-[var(--color-text-tertiary)]">
|
||||
Letters, digits, dot, dash, underscore. Max 64 characters.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if renameError}
|
||||
<div class="mt-3 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
|
||||
{renameError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onclick={() => (renameTarget = null)}
|
||||
disabled={renaming}
|
||||
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleRenameTemplate}
|
||||
disabled={renaming || !renameValue.trim() || renameValue.trim() === renameTarget.name}
|
||||
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
|
||||
>
|
||||
{#if renaming}
|
||||
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
|
||||
Renaming…
|
||||
{:else}
|
||||
Rename
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Delete Template Confirmation ────────────────────────────────── -->
|
||||
{#if deleteTarget}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
listSnapshots,
|
||||
deleteSnapshot,
|
||||
setTemplateVisibility,
|
||||
renameTemplate,
|
||||
createCapsule,
|
||||
type Snapshot
|
||||
} from '$lib/api/capsules';
|
||||
@ -37,6 +38,12 @@
|
||||
let deleting = $state(false);
|
||||
let deleteError = $state<string | null>(null);
|
||||
|
||||
// Rename dialog.
|
||||
let renameTarget = $state<Snapshot | null>(null);
|
||||
let renameValue = $state('');
|
||||
let renaming = $state(false);
|
||||
let renameError = $state<string | null>(null);
|
||||
|
||||
// Row dropdown (split button chevron)
|
||||
let openDropdownName = $state<string | null>(null);
|
||||
let dropdownPos = $state<{ top: number; left: number }>({ top: 0, left: 0 });
|
||||
@ -136,6 +143,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
function openRename(s: Snapshot) {
|
||||
openDropdownName = null;
|
||||
renameTarget = s;
|
||||
renameValue = s.name;
|
||||
renameError = null;
|
||||
}
|
||||
|
||||
async function handleRename() {
|
||||
if (!renameTarget) return;
|
||||
const newName = renameValue.trim();
|
||||
if (!newName || newName === renameTarget.name) {
|
||||
renameTarget = null;
|
||||
return;
|
||||
}
|
||||
renaming = true;
|
||||
renameError = null;
|
||||
const result = await renameTemplate(renameTarget.name, newName);
|
||||
if (result.ok) {
|
||||
renameTarget = null;
|
||||
// Refetch so the row reflects the new name and cleared publish flag.
|
||||
await fetchSnapshots();
|
||||
} else {
|
||||
renameError = result.error;
|
||||
}
|
||||
renaming = false;
|
||||
}
|
||||
|
||||
function openLaunch(snapshot: Snapshot) {
|
||||
launchTarget = snapshot;
|
||||
launchVcpus = snapshot.vcpus ?? 2;
|
||||
@ -599,6 +633,20 @@
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
openRename(dropdownSnapshot);
|
||||
}}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-meta text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-bg-4)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
Rename
|
||||
</button>
|
||||
|
||||
<div class="my-1 h-px bg-[var(--color-border)]"></div>
|
||||
|
||||
<button
|
||||
@ -689,6 +737,89 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Rename Dialog -->
|
||||
{#if renameTarget}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/60"
|
||||
role="presentation"
|
||||
onclick={() => { if (!renaming) renameTarget = null; }}
|
||||
onkeydown={(e) => { if (e.key === 'Escape' && !renaming) renameTarget = null; }}
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]"
|
||||
style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)"
|
||||
>
|
||||
<div class="p-6">
|
||||
<h2 class="font-serif text-heading leading-tight text-[var(--color-text-bright)]">Rename template</h2>
|
||||
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
|
||||
Rename <code class="rounded bg-[var(--color-bg-4)] px-1.5 py-0.5 font-mono text-[0.8rem] text-[var(--color-text-primary)]">{renameTarget.name}</code>.
|
||||
</p>
|
||||
|
||||
{#if renameTarget.public}
|
||||
<div class="mt-3 flex items-start gap-2 rounded-[var(--radius-input)] border border-[var(--color-amber)]/20 bg-[var(--color-amber)]/5 px-3 py-2.5">
|
||||
<svg class="mt-0.5 shrink-0" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--color-amber)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
<p class="text-meta leading-relaxed text-[var(--color-amber)]">
|
||||
This template is published. Renaming unpublishes it — other teams' references stop working and you'll need to publish it again.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
bind:value={renameValue}
|
||||
autofocus
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
onkeydown={(e) => { if (e.key === 'Enter' && !renaming) handleRename(); }}
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-1)] px-3 py-2 font-mono text-ui text-[var(--color-text-primary)] outline-none transition-colors duration-150 focus:border-[var(--color-border-mid)]"
|
||||
/>
|
||||
<p class="mt-1.5 text-meta text-[var(--color-text-tertiary)]">
|
||||
Letters, digits, dot, dash, underscore. Max 64 characters.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if renameError}
|
||||
<div class="mt-3 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
|
||||
{renameError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onclick={() => (renameTarget = null)}
|
||||
disabled={renaming}
|
||||
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleRename}
|
||||
disabled={renaming || !renameValue.trim() || renameValue.trim() === renameTarget.name}
|
||||
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
|
||||
>
|
||||
{#if renaming}
|
||||
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
Renaming...
|
||||
{:else}
|
||||
Rename
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Launch Dialog -->
|
||||
{#if launchTarget}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
|
||||
@ -22,14 +22,15 @@ import (
|
||||
)
|
||||
|
||||
type buildHandler struct {
|
||||
svc *service.BuildService
|
||||
db *db.Queries
|
||||
pool *lifecycle.HostClientPool
|
||||
audit *audit.AuditLogger
|
||||
svc *service.BuildService
|
||||
templateSvc *service.TemplateService
|
||||
db *db.Queries
|
||||
pool *lifecycle.HostClientPool
|
||||
audit *audit.AuditLogger
|
||||
}
|
||||
|
||||
func newBuildHandler(svc *service.BuildService, db *db.Queries, pool *lifecycle.HostClientPool, al *audit.AuditLogger) *buildHandler {
|
||||
return &buildHandler{svc: svc, db: db, pool: pool, audit: al}
|
||||
func newBuildHandler(svc *service.BuildService, templateSvc *service.TemplateService, db *db.Queries, pool *lifecycle.HostClientPool, al *audit.AuditLogger) *buildHandler {
|
||||
return &buildHandler{svc: svc, templateSvc: templateSvc, db: db, pool: pool, audit: al}
|
||||
}
|
||||
|
||||
type createBuildRequest struct {
|
||||
@ -307,6 +308,43 @@ func (h *buildHandler) DeleteTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// RenameTemplate handles PATCH /v1/admin/templates/{name}. Renames a platform
|
||||
// template. The hardcoded system base templates (minimal-ubuntu / -alpine /
|
||||
// -arch / -fedora) cannot be renamed.
|
||||
func (h *buildHandler) RenameTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
name := chi.URLParam(r, "name")
|
||||
if err := validate.SafeName(name); err != nil {
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid template name."))
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
|
||||
var req renameTemplateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, err := h.db.GetPlatformTemplateByName(ctx, name)
|
||||
if err != nil {
|
||||
writeErr(w, r, apperr.TemplateNotFound.Wrap(err))
|
||||
return
|
||||
}
|
||||
if layout.IsSystemTemplate(tmpl.TeamID, tmpl.ID) {
|
||||
writeErr(w, r, apperr.TemplateProtected.New())
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.templateSvc.Rename(ctx, tmpl.ID, req.NewName); err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
ac := auth.MustFromContext(ctx)
|
||||
h.audit.LogTemplateRenameAdmin(ctx, ac, name, strings.TrimSpace(req.NewName))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Cancel handles POST /v1/admin/builds/{id}/cancel.
|
||||
func (h *buildHandler) Cancel(w http.ResponseWriter, r *http.Request) {
|
||||
buildIDStr := chi.URLParam(r, "id")
|
||||
|
||||
@ -252,6 +252,52 @@ func (h *snapshotHandler) SetVisibility(w http.ResponseWriter, r *http.Request)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type renameTemplateRequest struct {
|
||||
NewName string `json:"new_name"`
|
||||
}
|
||||
|
||||
// Rename handles PATCH /v1/snapshots/{name}. Renames a template the team owns.
|
||||
// Renaming clears the published flag (see TemplateService.Rename). Platform and
|
||||
// system templates cannot be renamed here.
|
||||
func (h *snapshotHandler) Rename(w http.ResponseWriter, r *http.Request) {
|
||||
name := chi.URLParam(r, "name")
|
||||
if err := validate.SafeName(name); err != nil {
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid template name."))
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
ac := auth.MustFromContext(ctx)
|
||||
|
||||
var req renameTemplateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, r, apperr.InvalidRequest.WrapMsg(err, "Invalid JSON body."))
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, err := h.db.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: name, TeamID: ac.TeamID})
|
||||
if err != nil {
|
||||
writeErr(w, r, apperr.TemplateNotFound.Wrap(err))
|
||||
return
|
||||
}
|
||||
// Platform templates can only be renamed by admins via /v1/admin/templates.
|
||||
if tmpl.TeamID == id.PlatformTeamID {
|
||||
writeErr(w, r, apperr.TemplateProtected.Msg("Platform templates cannot be renamed here."))
|
||||
return
|
||||
}
|
||||
if layout.IsSystemTemplate(tmpl.TeamID, tmpl.ID) {
|
||||
writeErr(w, r, apperr.TemplateProtected.New())
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.svc.Rename(ctx, tmpl.ID, req.NewName); err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.audit.LogTemplateRename(ctx, ac, name, strings.TrimSpace(req.NewName))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Delete handles DELETE /v1/snapshots/{name}.
|
||||
func (h *snapshotHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
name := chi.URLParam(r, "name")
|
||||
|
||||
@ -1745,6 +1745,53 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
patch:
|
||||
summary: Rename a template
|
||||
operationId: renameTemplate
|
||||
tags: [snapshots]
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
- sessionAuth: []
|
||||
description: |
|
||||
Rename a template your team owns. Renaming unpublishes the template, so
|
||||
any `<your-team-slug>/<name>` references other teams held stop resolving
|
||||
and the owner must republish under the new name. Platform-owned and
|
||||
system base templates cannot be renamed here.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [new_name]
|
||||
properties:
|
||||
new_name:
|
||||
type: string
|
||||
description: New template name (letters, digits, dot, dash, underscore; max 64).
|
||||
responses:
|
||||
"204":
|
||||
description: Template renamed
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"403":
|
||||
description: Cannot rename a platform-owned / system base template
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"404":
|
||||
description: Your team does not own a template with that name
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"409":
|
||||
description: A template with the new name already exists
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
delete:
|
||||
summary: Delete a snapshot template
|
||||
operationId: deleteSnapshot
|
||||
@ -3247,6 +3294,40 @@ paths:
|
||||
$ref: "#/components/schemas/AdminTemplate"
|
||||
|
||||
/v1/admin/templates/{name}:
|
||||
patch:
|
||||
summary: Rename a platform template (admin)
|
||||
operationId: adminRenameTemplate
|
||||
tags: [admin]
|
||||
description: |
|
||||
Rename a platform-owned template. The hardcoded system base templates
|
||||
(minimal-ubuntu / -alpine / -arch / -fedora) cannot be renamed.
|
||||
security:
|
||||
- sessionAuth: []
|
||||
parameters:
|
||||
- name: name
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [new_name]
|
||||
properties:
|
||||
new_name:
|
||||
type: string
|
||||
description: New template name (letters, digits, dot, dash, underscore; max 64).
|
||||
responses:
|
||||
"204":
|
||||
description: Template renamed
|
||||
"403":
|
||||
description: Cannot rename a system base template
|
||||
"404":
|
||||
description: No platform template with that name
|
||||
"409":
|
||||
description: A template with the new name already exists
|
||||
delete:
|
||||
summary: Delete a template (admin)
|
||||
operationId: adminDeleteTemplate
|
||||
|
||||
@ -123,7 +123,7 @@ func New(
|
||||
statsH := newStatsHandler(statsSvc)
|
||||
usageH := newUsageHandler(usageSvc)
|
||||
metricsH := newSandboxMetricsHandler(queries, pool)
|
||||
buildH := newBuildHandler(buildSvc, queries, pool, al)
|
||||
buildH := newBuildHandler(buildSvc, templateSvc, queries, pool, al)
|
||||
buildStreamH := newBuildStreamHandler(queries, buildBroker)
|
||||
channelH := newChannelHandler(channelSvc, al)
|
||||
ptyH := newPtyHandler(queries, pool)
|
||||
@ -274,6 +274,7 @@ func New(
|
||||
r.Post("/", snapshots.Create)
|
||||
r.Get("/", snapshots.List)
|
||||
r.Patch("/{name}/visibility", snapshots.SetVisibility)
|
||||
r.Patch("/{name}", snapshots.Rename)
|
||||
r.Delete("/{name}", snapshots.Delete)
|
||||
})
|
||||
|
||||
@ -348,6 +349,7 @@ func New(
|
||||
r.Put("/users/{id}/admin", usersH.SetUserAdmin)
|
||||
r.Get("/audit-logs", auditH.AdminList)
|
||||
r.Get("/templates", buildH.ListTemplates)
|
||||
r.Patch("/templates/{name}", buildH.RenameTemplate)
|
||||
r.Delete("/templates/{name}", buildH.DeleteTemplate)
|
||||
r.Post("/builds", buildH.Create)
|
||||
r.Get("/builds", buildH.List)
|
||||
|
||||
@ -462,6 +462,16 @@ func (l *AuditLogger) LogTeamSlugChange(ctx context.Context, ac auth.AuthContext
|
||||
l.Log(ctx, newEntry(ac, ac.TeamID, "team", "team", id.FormatTeamID(teamID), "slug_change", "info", map[string]any{"old_slug": oldSlug, "new_slug": newSlug}))
|
||||
}
|
||||
|
||||
// LogTemplateRename records a team renaming one of its own templates.
|
||||
func (l *AuditLogger) LogTemplateRename(ctx context.Context, ac auth.AuthContext, oldName, newName string) {
|
||||
l.Log(ctx, newEntry(ac, ac.TeamID, "team", "template", oldName, "rename", "info", map[string]any{"old_name": oldName, "new_name": newName}))
|
||||
}
|
||||
|
||||
// LogTemplateRenameAdmin records an admin renaming a platform template.
|
||||
func (l *AuditLogger) LogTemplateRenameAdmin(ctx context.Context, ac auth.AuthContext, oldName, newName string) {
|
||||
l.Log(ctx, newAdminEntry(ac, "template", oldName, "rename", "info", map[string]any{"old_name": oldName, "new_name": newName}))
|
||||
}
|
||||
|
||||
func (l *AuditLogger) LogTemplateVisibility(ctx context.Context, ac auth.AuthContext, name string, public bool) {
|
||||
action := "unpublish"
|
||||
if public {
|
||||
|
||||
@ -304,7 +304,7 @@ func (s *Service) RevokeAllForUser(ctx context.Context, userID pgtype.UUID) erro
|
||||
// first. Backed by Postgres directly — the Redis cache is opportunistic and
|
||||
// is not consulted here.
|
||||
func (s *Service) ListForUser(ctx context.Context, userID pgtype.UUID) ([]db.Session, error) {
|
||||
rows, err := s.db.ListSessionsByUserID(ctx, userID)
|
||||
rows, err := s.db.ListActiveSessionsByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sessions: %w", err)
|
||||
}
|
||||
|
||||
@ -129,6 +129,40 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (S
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listActiveSessionsByUserID = `-- name: ListActiveSessionsByUserID :many
|
||||
SELECT id, user_id, team_id, csrf_token, user_agent, ip_address, created_at, last_seen_at, expires_at FROM sessions WHERE user_id = $1 AND expires_at > NOW() ORDER BY last_seen_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListActiveSessionsByUserID(ctx context.Context, userID pgtype.UUID) ([]Session, error) {
|
||||
rows, err := q.db.Query(ctx, listActiveSessionsByUserID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Session
|
||||
for rows.Next() {
|
||||
var i Session
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.TeamID,
|
||||
&i.CsrfToken,
|
||||
&i.UserAgent,
|
||||
&i.IpAddress,
|
||||
&i.CreatedAt,
|
||||
&i.LastSeenAt,
|
||||
&i.ExpiresAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listSessionsByUserID = `-- name: ListSessionsByUserID :many
|
||||
SELECT id, user_id, team_id, csrf_token, user_agent, ip_address, created_at, last_seen_at, expires_at FROM sessions WHERE user_id = $1 ORDER BY last_seen_at DESC
|
||||
`
|
||||
|
||||
@ -146,7 +146,10 @@ func (q *Queries) GetTemplateByName(ctx context.Context, arg GetTemplateByNamePa
|
||||
}
|
||||
|
||||
const getTemplateByTeam = `-- name: GetTemplateByTeam :one
|
||||
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env, metadata, is_public FROM templates WHERE name = $1 AND (team_id = $2 OR team_id = '00000000-0000-0000-0000-000000000000')
|
||||
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env, metadata, is_public FROM templates
|
||||
WHERE name = $1 AND (team_id = $2 OR team_id = '00000000-0000-0000-0000-000000000000')
|
||||
ORDER BY (team_id = $2) DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type GetTemplateByTeamParams struct {
|
||||
@ -154,7 +157,10 @@ type GetTemplateByTeamParams struct {
|
||||
TeamID pgtype.UUID `json:"team_id"`
|
||||
}
|
||||
|
||||
// Platform templates (team_id = 00000000-...) are visible to all teams.
|
||||
// Platform templates (team_id = 00000000-...) are visible to all teams. When a
|
||||
// team owns a template whose name also matches a platform template, prefer the
|
||||
// team's own row so mutation guards act on the caller's template, not the
|
||||
// shared platform one.
|
||||
func (q *Queries) GetTemplateByTeam(ctx context.Context, arg GetTemplateByTeamParams) (Template, error) {
|
||||
row := q.db.QueryRow(ctx, getTemplateByTeam, arg.Name, arg.TeamID)
|
||||
var i Template
|
||||
@ -533,6 +539,42 @@ func (q *Queries) ListVisibleTemplates(ctx context.Context, arg ListVisibleTempl
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const renameTemplate = `-- name: RenameTemplate :one
|
||||
UPDATE templates SET name = $1, is_public = FALSE
|
||||
WHERE id = $2
|
||||
RETURNING name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env, metadata, is_public
|
||||
`
|
||||
|
||||
type RenameTemplateParams struct {
|
||||
NewName string `json:"new_name"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
}
|
||||
|
||||
// Rename a template by ID. Clears the published flag so stale
|
||||
// "<team-slug>/<name>" references from other teams break loudly rather than
|
||||
// silently resolving to a different template. Callers apply ownership and
|
||||
// protection guards before calling. A name collision (team-scoped uniqueness or
|
||||
// the global-template-name trigger) surfaces as a unique_violation.
|
||||
func (q *Queries) RenameTemplate(ctx context.Context, arg RenameTemplateParams) (Template, error) {
|
||||
row := q.db.QueryRow(ctx, renameTemplate, arg.NewName, arg.ID)
|
||||
var i Template
|
||||
err := row.Scan(
|
||||
&i.Name,
|
||||
&i.Type,
|
||||
&i.Vcpus,
|
||||
&i.MemoryMb,
|
||||
&i.SizeBytes,
|
||||
&i.CreatedAt,
|
||||
&i.TeamID,
|
||||
&i.ID,
|
||||
&i.DefaultUser,
|
||||
&i.DefaultEnv,
|
||||
&i.Metadata,
|
||||
&i.IsPublic,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const resolveTemplateForTeam = `-- name: ResolveTemplateForTeam :one
|
||||
SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id, id, default_user, default_env, metadata, is_public FROM templates
|
||||
WHERE name = $1 AND (team_id = $2 OR team_id = '00000000-0000-0000-0000-000000000000')
|
||||
|
||||
@ -599,6 +599,26 @@ func (s *TeamService) LeaveTeam(ctx context.Context, teamID, callerUserID pgtype
|
||||
}); err != nil {
|
||||
return fmt.Errorf("leave team: %w", err)
|
||||
}
|
||||
|
||||
// Revoke the departing member's standing access to this team immediately,
|
||||
// exactly as RemoveMember does. Deleting the membership row alone leaves two
|
||||
// credentials live: the team-scoped API keys they created (resolved by hash
|
||||
// with no membership re-check, never expiring), and any cached session still
|
||||
// holding this team_id. Purge the keys, and drop the session cache so the
|
||||
// next request rehydrates from Postgres (where the membership is now gone).
|
||||
if err := s.DB.DeleteAPIKeysByTeamAndCreator(ctx, db.DeleteAPIKeysByTeamAndCreatorParams{
|
||||
TeamID: teamID,
|
||||
CreatedBy: callerUserID,
|
||||
}); err != nil {
|
||||
slog.Warn("failed to delete API keys for departing member",
|
||||
"team_id", teamID, "user_id", callerUserID, "error", err)
|
||||
}
|
||||
if s.Sessions != nil {
|
||||
if err := s.Sessions.InvalidateCacheForUser(ctx, callerUserID); err != nil {
|
||||
slog.Warn("failed to invalidate session cache for departing member",
|
||||
"user_id", callerUserID, "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -2,12 +2,16 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/apperr"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/validate"
|
||||
)
|
||||
|
||||
// TemplateService provides template/snapshot operations shared between the
|
||||
@ -79,3 +83,29 @@ func (s *TemplateService) SetVisibility(ctx context.Context, teamID pgtype.UUID,
|
||||
}
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
// Rename changes a template's name by ID. It validates the new name, then clears
|
||||
// the published flag (see RenameTemplate) so stale "<team-slug>/<name>"
|
||||
// references break loudly instead of silently resolving elsewhere. Callers must
|
||||
// apply ownership and protection guards before calling. A name collision — with
|
||||
// another of the team's templates or with a reserved platform name — is returned
|
||||
// as a Conflict.
|
||||
func (s *TemplateService) Rename(ctx context.Context, id pgtype.UUID, newName string) (db.Template, error) {
|
||||
newName = strings.TrimSpace(newName)
|
||||
if err := validate.SafeName(newName); err != nil {
|
||||
return db.Template{}, apperr.InvalidRequest.WrapMsg(err, "Invalid template name.")
|
||||
}
|
||||
|
||||
tmpl, err := s.DB.RenameTemplate(ctx, db.RenameTemplateParams{ID: id, NewName: newName})
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return db.Template{}, apperr.Conflict.Msgf("A template named %q already exists.", newName)
|
||||
}
|
||||
if err == pgx.ErrNoRows {
|
||||
return db.Template{}, apperr.TemplateNotFound.New()
|
||||
}
|
||||
return db.Template{}, apperr.Internal.Wrap(err)
|
||||
}
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user