From afe85843136ecba6b3c0ac4f968783a2050f25e1 Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Fri, 2 Oct 2026 12:08:28 -0700 Subject: [PATCH 1/3] Add container ownership transfer Allow container owners and admins to transfer ownership to another active user from the edit page. The API now validates transfer targets, removes any collaborator grant for the new owner, moves resource requests to the new owner, and returns clearer ownership-specific responses. Also adds UI support, API docs updates, admin docs, and coverage for owner/admin/collaborator transfer behavior. --- .../containers/TransferOwnership.tsx | 142 ++++++++++++++++++ create-a-container/client/src/lib/queries.ts | 5 + .../pages/containers/ContainerFormPage.tsx | 16 ++ create-a-container/openapi.v1.yaml | 11 +- .../api/v1/__tests__/containers.owner.test.js | 115 ++++++++++++++ .../routers/api/v1/containers.js | 38 ++++- .../docs/admins/core-concepts/containers.md | 12 ++ 7 files changed, 329 insertions(+), 10 deletions(-) create mode 100644 create-a-container/client/src/components/containers/TransferOwnership.tsx create mode 100644 create-a-container/routers/api/v1/__tests__/containers.owner.test.js diff --git a/create-a-container/client/src/components/containers/TransferOwnership.tsx b/create-a-container/client/src/components/containers/TransferOwnership.tsx new file mode 100644 index 00000000..c4dba20f --- /dev/null +++ b/create-a-container/client/src/components/containers/TransferOwnership.tsx @@ -0,0 +1,142 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Button, + Input, + Modal, + ModalBody, + ModalClose, + ModalFooter, + ModalHeader, + ModalTitle, + useToast, +} from '@mieweb/ui'; +import { UserRoundCog } from 'lucide-react'; +import { ApiError } from '@/lib/api'; +import { useSession } from '@/lib/auth'; +import { keys, queries } from '@/lib/queries'; + +/** + * Transfers a container to another user (owner or admin only — the server + * enforces this). A non-admin owner loses access once the transfer succeeds, + * so they are sent back to the containers list. + */ +export function TransferOwnership({ + siteId, + containerId, + hostname, + owner, +}: { + siteId: string; + containerId: number; + hostname: string; + owner: string; +}) { + const navigate = useNavigate(); + const qc = useQueryClient(); + const toast = useToast(); + const { data: session } = useSession(); + const [value, setValue] = useState(''); + const [confirmOpen, setConfirmOpen] = useState(false); + const [error, setError] = useState(null); + const newOwner = value.trim(); + + const transfer = useMutation({ + mutationFn: () => queries.transferContainerOwnership(siteId, containerId, newOwner), + onSuccess: (result) => { + setConfirmOpen(false); + setError(null); + setValue(''); + toast.success(result.message); + qc.invalidateQueries({ queryKey: keys.containers(siteId) }); + if (session?.isAdmin) { + qc.invalidateQueries({ queryKey: keys.container(siteId, containerId) }); + } else { + navigate(`/sites/${siteId}/containers`); + } + }, + onError: (err: ApiError) => { + setConfirmOpen(false); + setError(err.message); + }, + }); + + const canSubmit = !!newOwner && newOwner !== owner; + + return ( +
+

+ Current owner: {owner} +

+
+
+ { + setValue(e.target.value); + setError(null); + }} + onKeyDown={(e) => { + // Keep Enter from submitting the enclosing container form. + if (e.key === 'Enter') { + e.preventDefault(); + if (canSubmit) setConfirmOpen(true); + } + }} + /> +
+ +
+ + + + Transfer ownership? + + + +

+ {hostname} will be owned by {newOwner}. Its approved + resources and volume data move with it. + {!session?.isAdmin && ' You will lose access unless the new owner shares it with you.'} +

+
+ + + + +
+
+ ); +} diff --git a/create-a-container/client/src/lib/queries.ts b/create-a-container/client/src/lib/queries.ts index 1a0ad3a0..da4d006e 100644 --- a/create-a-container/client/src/lib/queries.ts +++ b/create-a-container/client/src/lib/queries.ts @@ -114,6 +114,11 @@ export const queries = { api.delete<{ collaborators: string[] }>( `/api/v1/sites/${siteId}/containers/${id}/collaborators/${encodeURIComponent(username)}`, ), + transferContainerOwnership: (siteId: number | string, id: number | string, username: string) => + api.put<{ containerId: number; message: string }>( + `/api/v1/sites/${siteId}/containers/${id}`, + { username }, + ), containerBootstrap: (siteId: number | string) => api.get(`/api/v1/sites/${siteId}/containers/new`), containerMetadata: (siteId: number | string, image: string) => diff --git a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx index 32fe76be..571104d1 100644 --- a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx +++ b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx @@ -34,6 +34,7 @@ import { FormPageHeader } from '@/components/FormPageHeader'; import { randomHostname } from '@/lib/randomHostname'; import { ResourcesSection } from '@/components/containers/ResourcesSection'; import { AddCollaboratorField, CollaboratorChips, CollaboratorsManager } from '@/components/containers/CollaboratorsManager'; +import { TransferOwnership } from '@/components/containers/TransferOwnership'; import type { ContainerCreateResult, ContainerMetadata } from '@/lib/types'; function useDebouncedValue(value: T, delay = 500): T { @@ -983,6 +984,21 @@ export function ContainerFormPage() { })} + {isEdit && container && !isReadOnly && ( + + + Ownership + + + + + + )} Sharing diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index 81f6e935..2711c65e 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -923,7 +923,11 @@ paths: properties: username: type: string - description: '(Admin only) Reassign ownership of this container to the specified existing user' + description: >- + Transfer ownership to another active user (owner or admin). + The new owner's sharing grant is removed and the container's + resource requests move with it; the previous owner loses access + unless an admin. services: type: object description: >- @@ -962,8 +966,9 @@ paths: pendingRestart: { type: boolean, description: 'true when env/entrypoint changes were saved but no restart was requested — they apply on the next restart' } message: { type: string } '400': { $ref: '#/components/responses/BadRequest' } - '403': { description: 'forbidden — only the owner/admin may edit (collaborators have a read-only view); non-admins may not reassign ownership', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } - '404': { $ref: '#/components/responses/NotFound' } + '403': { description: 'forbidden — only the owner/admin may edit (collaborators have a read-only view)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '404': { description: 'not_found (container) or user_not_found (transfer target)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '422': { description: 'user_inactive — transfer target is not an active user', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } delete: operationId: delete_container tags: [Containers] diff --git a/create-a-container/routers/api/v1/__tests__/containers.owner.test.js b/create-a-container/routers/api/v1/__tests__/containers.owner.test.js new file mode 100644 index 00000000..40d7b041 --- /dev/null +++ b/create-a-container/routers/api/v1/__tests__/containers.owner.test.js @@ -0,0 +1,115 @@ +/** + * PUT /api/v1/sites/:siteId/containers/:id with `username` — ownership + * transfer by the owner or an admin. + */ + +const request = require('supertest'); +const { buildApp, bearer } = require('../../../../tests/helpers/app'); +const { resetDb, closeDb, createUser, createApiKey } = require('../../../../tests/helpers/db'); +const { Site, Node, Container, ContainerCollaborator, ResourceRequest } = require('../../../../models'); + +describe('PUT container ownership transfer', () => { + let app; + let site; + let container; + let adminKey; + let owner; + let ownerKey; + let other; + let otherKey; + + beforeEach(async () => { + await resetDb(); + app = buildApp(); + // First user after resetDb() is auto-promoted to sysadmins. + const admin = await createUser({ admin: true }); + ({ plainKey: adminKey } = await createApiKey(admin)); + owner = await createUser(); + ({ plainKey: ownerKey } = await createApiKey(owner)); + other = await createUser(); + ({ plainKey: otherKey } = await createApiKey(other)); + site = await Site.create({ name: 's', internalDomain: 'ex.test' }); + const node = await Node.create({ siteId: site.id, name: 'n', nodeType: 'dummy' }); + container = await Container.create({ + hostname: 'box', + username: owner.uid, + nodeId: node.id, + siteId: site.id, + }); + }); + + afterAll(async () => { + await closeDb(); + }); + + function put(key, body) { + return request(app) + .put(`/api/v1/sites/${site.id}/containers/${container.id}`) + .set(...bearer(key)) + .send(body); + } + + test('the owner can transfer ownership and then loses access', async () => { + const res = await put(ownerKey, { username: other.uid }); + expect(res.status).toBe(200); + expect(res.body.data.message).toBe(`Ownership transferred to ${other.uid}`); + await container.reload(); + expect(container.username).toBe(other.uid); + + const after = await request(app) + .get(`/api/v1/sites/${site.id}/containers/${container.id}`) + .set(...bearer(ownerKey)); + expect(after.status).toBe(404); + }); + + test('an admin can transfer a container they do not own', async () => { + const res = await put(adminKey, { username: other.uid }); + expect(res.status).toBe(200); + await container.reload(); + expect(container.username).toBe(other.uid); + }); + + test('a collaborator cannot transfer ownership', async () => { + await ContainerCollaborator.create({ containerId: container.id, username: other.uid }); + const res = await put(otherKey, { username: other.uid }); + expect(res.status).toBe(403); + await container.reload(); + expect(container.username).toBe(owner.uid); + }); + + test('rejects an unknown user', async () => { + const res = await put(ownerKey, { username: 'nobody' }); + expect(res.status).toBe(404); + expect(res.body.error.code).toBe('user_not_found'); + }); + + test('rejects an inactive user', async () => { + const inactive = await createUser({ status: 'pending' }); + const res = await put(ownerKey, { username: inactive.uid }); + expect(res.status).toBe(422); + expect(res.body.error.code).toBe('user_inactive'); + }); + + test("removes the new owner's sharing grant", async () => { + await ContainerCollaborator.create({ containerId: container.id, username: other.uid }); + const res = await put(ownerKey, { username: other.uid }); + expect(res.status).toBe(200); + expect(await ContainerCollaborator.count({ where: { containerId: container.id } })).toBe(0); + }); + + test('moves resource requests to the new owner', async () => { + await ResourceRequest.create({ + siteId: site.id, + hostname: 'box', + username: owner.uid, + resourceType: 'memory', + value: 16384, + status: 'approved', + reviewedAt: new Date(), + }); + const res = await put(adminKey, { username: other.uid }); + expect(res.status).toBe(200); + expect(await ResourceRequest.getApprovedResources(site.id, 'box', other.uid)).toEqual({ memory: 16384 }); + expect(await ResourceRequest.getApprovedResources(site.id, 'box', owner.uid)).toEqual({}); + }); +}); diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js index 97b243d0..37170231 100644 --- a/create-a-container/routers/api/v1/containers.js +++ b/create-a-container/routers/api/v1/containers.js @@ -15,7 +15,9 @@ const { Site, ExternalDomain, Job, + ResourceRequest, Setting, + User, Volume, Sequelize, sequelize, @@ -804,17 +806,23 @@ router.put( } } - // Admins may reassign the container to another user by passing `username`. - // Non-admins may not pass a different username — that is a 403. + // The owner or an admin (requireManage above) may transfer ownership to + // another active user by passing `username`. let newOwnerUsername = null; if (bodyUsername !== undefined) { if (typeof bodyUsername !== 'string' || !bodyUsername.trim()) { throw new ApiError(400, 'invalid_request', 'username must be a non-empty string'); } - if (!req.session.isAdmin) { - throw new ApiError(403, 'forbidden', 'only admins may reassign container ownership'); - } newOwnerUsername = bodyUsername.trim(); + if (newOwnerUsername !== container.username) { + const newOwner = await User.findOne({ where: { uid: newOwnerUsername }, attributes: ['status'] }); + if (!newOwner) { + throw new ApiError(404, 'user_not_found', `User "${newOwnerUsername}" does not exist`); + } + if (newOwner.status !== 'active') { + throw new ApiError(422, 'user_inactive', `User "${newOwnerUsername}" is not active`); + } + } } // Only recompute env/entrypoint when the request actually carried the key; @@ -829,7 +837,8 @@ router.put( ? entrypoint.trim() : null : container.entrypoint; - const ownerChanged = newOwnerUsername !== null && newOwnerUsername !== container.username; + const previousOwner = container.username; + const ownerChanged = newOwnerUsername !== null && newOwnerUsername !== previousOwner; const envChanged = envProvided && container.environmentVars !== envVarsJson; const entrypointChanged = entrypointProvided && container.entrypoint !== newEntrypoint; const volumesChanged = volumeAttaches.length > 0 || volumeDetachIds.length > 0; @@ -857,6 +866,19 @@ router.put( { transaction: t }, ); } + if (ownerChanged) { + // The new owner no longer needs a sharing grant on their own container. + await ContainerCollaborator.destroy({ + where: { containerId: container.id, username: newOwnerUsername }, + transaction: t, + }); + // Resource requests are keyed by (site, hostname, owner); move them so + // approved resources keep following the container. + await ResourceRequest.update( + { username: newOwnerUsername }, + { where: { siteId: site.id, hostname: container.hostname, username: previousOwner }, transaction: t }, + ); + } if (needsReconfigureJob) { restartJob = await Job.create( { @@ -1009,7 +1031,9 @@ router.put( : 'Container is restarting' : pendingRestart ? 'Container updated — changes take effect on the next restart' - : 'Container updated'; + : ownerChanged + ? `Ownership transferred to ${newOwnerUsername}` + : 'Container updated'; return ok(res, { containerId: container.id, jobId: restartJob ? restartJob.id : null, diff --git a/mie-opensource-landing/docs/admins/core-concepts/containers.md b/mie-opensource-landing/docs/admins/core-concepts/containers.md index e15a9d4c..d66f5ebd 100644 --- a/mie-opensource-landing/docs/admins/core-concepts/containers.md +++ b/mie-opensource-landing/docs/admins/core-concepts/containers.md @@ -16,6 +16,18 @@ Users in the **ldapusers** group can SSH into any container using their cluster | **Creating** | Being provisioned | | **Failed** | Creation or startup failed | +## Ownership Transfer + +The owner or an admin can hand a container to another active user from the container's edit page (**Ownership** → **Transfer**), e.g. when the owner leaves the organization. + +| Moves to the new owner | Not changed | +|---|---| +| Container record and Proxmox owner tag | Running container (no restart) | +| Approved/pending resource requests | Volume data (host paths are fixed at creation) | +| | Other collaborators | + +The new owner's sharing grant, if any, is removed. A non-admin previous owner loses access unless the new owner shares it back. API: `PUT /api/v1/sites/{siteId}/containers/{id}` with `{ "username": "" }`. + ## Volumes Containers can attach persistent **[volumes](volumes.md)** — bind-mount From 83a6ea3f16b55bfc71c3c20d44fe93f84649a0cc Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Fri, 2 Oct 2026 15:46:25 -0700 Subject: [PATCH 2/3] Restrict container transfers to admins Make container ownership reassignment admin-only across the API, UI, tests, OpenAPI spec, and admin docs. The ownership transfer card now only appears for admins, non-admin transfer attempts return 403, and related messaging/docs were updated to reflect that previous owners lose access after a transfer. --- .../containers/TransferOwnership.tsx | 20 ++++------------- .../pages/containers/ContainerFormPage.tsx | 2 +- create-a-container/openapi.v1.yaml | 9 ++++---- .../api/v1/__tests__/containers.owner.test.js | 22 +++++++++---------- .../routers/api/v1/containers.js | 6 +++-- .../docs/admins/core-concepts/containers.md | 4 ++-- 6 files changed, 26 insertions(+), 37 deletions(-) diff --git a/create-a-container/client/src/components/containers/TransferOwnership.tsx b/create-a-container/client/src/components/containers/TransferOwnership.tsx index c4dba20f..b6802ae9 100644 --- a/create-a-container/client/src/components/containers/TransferOwnership.tsx +++ b/create-a-container/client/src/components/containers/TransferOwnership.tsx @@ -1,5 +1,4 @@ import { useState } from 'react'; -import { useNavigate } from 'react-router'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Button, @@ -14,14 +13,9 @@ import { } from '@mieweb/ui'; import { UserRoundCog } from 'lucide-react'; import { ApiError } from '@/lib/api'; -import { useSession } from '@/lib/auth'; import { keys, queries } from '@/lib/queries'; -/** - * Transfers a container to another user (owner or admin only — the server - * enforces this). A non-admin owner loses access once the transfer succeeds, - * so they are sent back to the containers list. - */ +/** Admin-only control to transfer a container to another user (server-enforced). */ export function TransferOwnership({ siteId, containerId, @@ -33,10 +27,8 @@ export function TransferOwnership({ hostname: string; owner: string; }) { - const navigate = useNavigate(); const qc = useQueryClient(); const toast = useToast(); - const { data: session } = useSession(); const [value, setValue] = useState(''); const [confirmOpen, setConfirmOpen] = useState(false); const [error, setError] = useState(null); @@ -50,11 +42,7 @@ export function TransferOwnership({ setValue(''); toast.success(result.message); qc.invalidateQueries({ queryKey: keys.containers(siteId) }); - if (session?.isAdmin) { - qc.invalidateQueries({ queryKey: keys.container(siteId, containerId) }); - } else { - navigate(`/sites/${siteId}/containers`); - } + qc.invalidateQueries({ queryKey: keys.container(siteId, containerId) }); }, onError: (err: ApiError) => { setConfirmOpen(false); @@ -113,8 +101,8 @@ export function TransferOwnership({

{hostname} will be owned by {newOwner}. Its approved - resources and volume data move with it. - {!session?.isAdmin && ' You will lose access unless the new owner shares it with you.'} + resources and volume data move with it. {owner} will lose access unless the new owner + shares it with them.

diff --git a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx index 571104d1..6959cdd8 100644 --- a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx +++ b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx @@ -984,7 +984,7 @@ export function ContainerFormPage() { })}
- {isEdit && container && !isReadOnly && ( + {isEdit && container && session?.isAdmin && ( Ownership diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index 2711c65e..076bc17e 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -924,10 +924,9 @@ paths: username: type: string description: >- - Transfer ownership to another active user (owner or admin). - The new owner's sharing grant is removed and the container's - resource requests move with it; the previous owner loses access - unless an admin. + (Admin only) Transfer ownership to another active user. The + new owner's sharing grant is removed and the container's + resource requests move with it. services: type: object description: >- @@ -966,7 +965,7 @@ paths: pendingRestart: { type: boolean, description: 'true when env/entrypoint changes were saved but no restart was requested — they apply on the next restart' } message: { type: string } '400': { $ref: '#/components/responses/BadRequest' } - '403': { description: 'forbidden — only the owner/admin may edit (collaborators have a read-only view)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '403': { description: 'forbidden — only the owner/admin may edit (collaborators have a read-only view); non-admins may not reassign ownership', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } '404': { description: 'not_found (container) or user_not_found (transfer target)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } '422': { description: 'user_inactive — transfer target is not an active user', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } delete: diff --git a/create-a-container/routers/api/v1/__tests__/containers.owner.test.js b/create-a-container/routers/api/v1/__tests__/containers.owner.test.js index 40d7b041..3c4e3b4e 100644 --- a/create-a-container/routers/api/v1/__tests__/containers.owner.test.js +++ b/create-a-container/routers/api/v1/__tests__/containers.owner.test.js @@ -1,6 +1,6 @@ /** - * PUT /api/v1/sites/:siteId/containers/:id with `username` — ownership - * transfer by the owner or an admin. + * PUT /api/v1/sites/:siteId/containers/:id with `username` — admin-only + * ownership transfer. */ const request = require('supertest'); @@ -49,8 +49,8 @@ describe('PUT container ownership transfer', () => { .send(body); } - test('the owner can transfer ownership and then loses access', async () => { - const res = await put(ownerKey, { username: other.uid }); + test('an admin can transfer ownership and the previous owner loses access', async () => { + const res = await put(adminKey, { username: other.uid }); expect(res.status).toBe(200); expect(res.body.data.message).toBe(`Ownership transferred to ${other.uid}`); await container.reload(); @@ -62,11 +62,11 @@ describe('PUT container ownership transfer', () => { expect(after.status).toBe(404); }); - test('an admin can transfer a container they do not own', async () => { - const res = await put(adminKey, { username: other.uid }); - expect(res.status).toBe(200); + test('the owner cannot transfer ownership', async () => { + const res = await put(ownerKey, { username: other.uid }); + expect(res.status).toBe(403); await container.reload(); - expect(container.username).toBe(other.uid); + expect(container.username).toBe(owner.uid); }); test('a collaborator cannot transfer ownership', async () => { @@ -78,21 +78,21 @@ describe('PUT container ownership transfer', () => { }); test('rejects an unknown user', async () => { - const res = await put(ownerKey, { username: 'nobody' }); + const res = await put(adminKey, { username: 'nobody' }); expect(res.status).toBe(404); expect(res.body.error.code).toBe('user_not_found'); }); test('rejects an inactive user', async () => { const inactive = await createUser({ status: 'pending' }); - const res = await put(ownerKey, { username: inactive.uid }); + const res = await put(adminKey, { username: inactive.uid }); expect(res.status).toBe(422); expect(res.body.error.code).toBe('user_inactive'); }); test("removes the new owner's sharing grant", async () => { await ContainerCollaborator.create({ containerId: container.id, username: other.uid }); - const res = await put(ownerKey, { username: other.uid }); + const res = await put(adminKey, { username: other.uid }); expect(res.status).toBe(200); expect(await ContainerCollaborator.count({ where: { containerId: container.id } })).toBe(0); }); diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js index 37170231..128d4756 100644 --- a/create-a-container/routers/api/v1/containers.js +++ b/create-a-container/routers/api/v1/containers.js @@ -806,13 +806,15 @@ router.put( } } - // The owner or an admin (requireManage above) may transfer ownership to - // another active user by passing `username`. + // Admins may transfer ownership to another active user by passing `username`. let newOwnerUsername = null; if (bodyUsername !== undefined) { if (typeof bodyUsername !== 'string' || !bodyUsername.trim()) { throw new ApiError(400, 'invalid_request', 'username must be a non-empty string'); } + if (!req.session.isAdmin) { + throw new ApiError(403, 'forbidden', 'only admins may reassign container ownership'); + } newOwnerUsername = bodyUsername.trim(); if (newOwnerUsername !== container.username) { const newOwner = await User.findOne({ where: { uid: newOwnerUsername }, attributes: ['status'] }); diff --git a/mie-opensource-landing/docs/admins/core-concepts/containers.md b/mie-opensource-landing/docs/admins/core-concepts/containers.md index d66f5ebd..68661155 100644 --- a/mie-opensource-landing/docs/admins/core-concepts/containers.md +++ b/mie-opensource-landing/docs/admins/core-concepts/containers.md @@ -18,7 +18,7 @@ Users in the **ldapusers** group can SSH into any container using their cluster ## Ownership Transfer -The owner or an admin can hand a container to another active user from the container's edit page (**Ownership** → **Transfer**), e.g. when the owner leaves the organization. +Admins can hand a container to another active user from the container's edit page (**Ownership** → **Transfer**), e.g. when the owner leaves the organization. | Moves to the new owner | Not changed | |---|---| @@ -26,7 +26,7 @@ The owner or an admin can hand a container to another active user from the conta | Approved/pending resource requests | Volume data (host paths are fixed at creation) | | | Other collaborators | -The new owner's sharing grant, if any, is removed. A non-admin previous owner loses access unless the new owner shares it back. API: `PUT /api/v1/sites/{siteId}/containers/{id}` with `{ "username": "" }`. +The new owner's sharing grant, if any, is removed. The previous owner loses access unless the new owner shares it back. API (admin only): `PUT /api/v1/sites/{siteId}/containers/{id}` with `{ "username": "" }`. ## Volumes From dbe5fb14cfc5aa3419eaba2af6afdd05faa7bee6 Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Fri, 2 Oct 2026 15:53:04 -0700 Subject: [PATCH 3/3] Update ResourceRequest query to include status filter Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- create-a-container/routers/api/v1/containers.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js index 128d4756..06471f6c 100644 --- a/create-a-container/routers/api/v1/containers.js +++ b/create-a-container/routers/api/v1/containers.js @@ -878,7 +878,15 @@ router.put( // approved resources keep following the container. await ResourceRequest.update( { username: newOwnerUsername }, - { where: { siteId: site.id, hostname: container.hostname, username: previousOwner }, transaction: t }, + { + where: { + siteId: site.id, + hostname: container.hostname, + username: previousOwner, + status: { [Sequelize.Op.in]: ['pending', 'approved'] }, + }, + transaction: t, + }, ); } if (needsReconfigureJob) {