diff --git a/create-a-container/client/package-lock.json b/create-a-container/client/package-lock.json index d0517d00..1d0ec772 100644 --- a/create-a-container/client/package-lock.json +++ b/create-a-container/client/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@hookform/resolvers": "^3.9.1", - "@mieweb/ui": "*", + "@mieweb/ui": "latest", "@tanstack/react-query": "^5.62.0", "lucide-react": "^0.460.0", "react": "^19.0.0", diff --git a/create-a-container/client/src/components/containers/CollaboratorsManager.tsx b/create-a-container/client/src/components/containers/CollaboratorsManager.tsx new file mode 100644 index 00000000..65d28746 --- /dev/null +++ b/create-a-container/client/src/components/containers/CollaboratorsManager.tsx @@ -0,0 +1,163 @@ +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Button, Input, ServiceBadge, useToast } from '@mieweb/ui'; +import { UserPlus } from 'lucide-react'; +import { ApiError } from '@/lib/api'; +import { keys, queries } from '@/lib/queries'; + +/** + * Presentational list of collaborator usernames rendered as removable chips. + * Shared by the live manager and the create-form's collaborators field so + * both surfaces look identical. + */ +export function CollaboratorChips({ + usernames, + onRemove, + disabled, + emptyText = 'Not shared with anyone yet.', +}: { + usernames: string[]; + onRemove: (username: string) => void; + disabled?: boolean; + emptyText?: string; +}) { + if (usernames.length === 0) { + return

{emptyText}

; + } + return ( + + ); +} + +/** + * An accessible username input + add button. Surfaces a validation/error + * message (e.g. "user does not exist") inline beneath the field. + */ +export function AddCollaboratorField({ + onAdd, + pending, + error, + label = 'Username', +}: { + onAdd: (username: string) => void; + pending?: boolean; + error?: string | null; + label?: string; +}) { + const [value, setValue] = useState(''); + + const submit = () => { + const username = value.trim(); + if (!username) return; + onAdd(username); + setValue(''); + }; + + return ( +
+
+ setValue(e.target.value)} + onKeyDown={(e) => { + // Submit on Enter without bubbling to an enclosing form. + if (e.key === 'Enter') { + e.preventDefault(); + submit(); + } + }} + /> +
+ +
+ ); +} + +/** + * Live sharing manager for an existing container: lists current collaborators + * and lets the owner/admin add or remove them via the API. Used both in the + * list page's share dialog and the edit form's Sharing section. + */ +export function CollaboratorsManager({ + siteId, + containerId, + collaborators, +}: { + siteId: string; + containerId: number; + collaborators: string[]; +}) { + const qc = useQueryClient(); + const toast = useToast(); + const [addError, setAddError] = useState(null); + + const invalidate = () => { + qc.invalidateQueries({ queryKey: keys.container(siteId, containerId) }); + qc.invalidateQueries({ queryKey: keys.containers(siteId) }); + }; + + const add = useMutation({ + mutationFn: (username: string) => queries.shareContainer(siteId, containerId, username), + onSuccess: (_data, username) => { + setAddError(null); + toast.success(`Shared with ${username}`); + invalidate(); + }, + onError: (err: ApiError) => setAddError(err.message), + }); + + const remove = useMutation({ + mutationFn: (username: string) => queries.unshareContainer(siteId, containerId, username), + onSuccess: (_data, username) => { + toast.success(`Stopped sharing with ${username}`); + invalidate(); + }, + onError: (err: ApiError) => toast.error(err.message), + }); + + return ( +
+ remove.mutate(u)} + disabled={remove.isPending} + /> + add.mutate(u)} + pending={add.isPending} + error={addError} + /> +
+ ); +} diff --git a/create-a-container/client/src/components/containers/ContainerFilters.tsx b/create-a-container/client/src/components/containers/ContainerFilters.tsx new file mode 100644 index 00000000..4695fee8 --- /dev/null +++ b/create-a-container/client/src/components/containers/ContainerFilters.tsx @@ -0,0 +1,173 @@ +import { useState } from 'react'; +import { Button, Dropdown, DropdownItem, Input } from '@mieweb/ui'; +import { ChevronDown, Filter, User as UserIcon, X } from 'lucide-react'; + +export interface FilterOption { + value: string; + label: string; +} + +/** + * A compact multiselect built on @mieweb/ui's Dropdown (multiSelect mode). + * Selected values are controlled by the parent so filter state stays in the URL. + */ +function MultiSelect({ + label, + emptyLabel, + icon, + options, + selected, + onChange, + searchable = false, + showSelectAll = false, +}: { + label: string; + /** Trigger summary shown when nothing is selected. */ + emptyLabel: string; + icon?: React.ReactElement; + options: FilterOption[]; + selected: string[]; + onChange: (next: string[]) => void; + searchable?: boolean; + showSelectAll?: boolean; +}) { + const summary = + selected.length === 0 + ? emptyLabel + : selected.length === 1 + ? (options.find((o) => o.value === selected[0])?.label ?? selected[0]) + : `${selected.length} selected`; + + return ( + + {label}: + {summary} + + ); +} + +export interface ContainerFiltersProps { + userOptions: FilterOption[]; + selectedUsers: string[]; + onUsersChange: (next: string[]) => void; + statusOptions: FilterOption[]; + selectedStatuses: string[]; + onStatusesChange: (next: string[]) => void; + templateOptions: FilterOption[]; + selectedTemplates: string[]; + onTemplatesChange: (next: string[]) => void; + hostname: string; + onHostnameChange: (next: string) => void; + onClearAll: () => void; +} + +/** + * Filter bar for the containers list. "User" is always visible and drives the + * server query; the remaining filters (status, template, hostname) live behind + * a "More filters" disclosure and filter the loaded rows client-side. + */ +export function ContainerFilters({ + userOptions, + selectedUsers, + onUsersChange, + statusOptions, + selectedStatuses, + onStatusesChange, + templateOptions, + selectedTemplates, + onTemplatesChange, + hostname, + onHostnameChange, + onClearAll, +}: ContainerFiltersProps) { + const [showMore, setShowMore] = useState(false); + const extraCount = + selectedStatuses.length + selectedTemplates.length + (hostname ? 1 : 0); + const activeCount = selectedUsers.length + extraCount; + + return ( +
+
+ } + options={userOptions} + selected={selectedUsers} + onChange={onUsersChange} + searchable + showSelectAll + /> + + {activeCount > 0 && ( + + )} +
+ {showMore && ( +
+ + + onHostnameChange(e.target.value)} + placeholder="Search hostname…" + aria-label="Search by hostname" + className="h-8 w-48" + /> +
+ )} +
+ ); +} diff --git a/create-a-container/client/src/components/containers/HttpLinks.tsx b/create-a-container/client/src/components/containers/HttpLinks.tsx new file mode 100644 index 00000000..2880b409 --- /dev/null +++ b/create-a-container/client/src/components/containers/HttpLinks.tsx @@ -0,0 +1,31 @@ +import { ExternalLink } from 'lucide-react'; +import type { Container } from '@/lib/types'; +import { linkClass } from './shared'; + +/** External links for a container's HTTP services, optionally capped at `limit`. */ +export function HttpLinks({ c, limit }: { c: Container; limit?: number }) { + if (c.httpEntries.length === 0) return ; + const entries = limit ? c.httpEntries.slice(0, limit) : c.httpEntries; + return ( + + {entries.map((h) => + h.externalUrl ? ( + + + ) : ( + + :{h.port} + + ), + )} + + ); +} diff --git a/create-a-container/client/src/components/containers/Meta.tsx b/create-a-container/client/src/components/containers/Meta.tsx new file mode 100644 index 00000000..be74c6bd --- /dev/null +++ b/create-a-container/client/src/components/containers/Meta.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react'; + +/** Inline labelled value used for container card metadata (Node, User, HTTP, …). */ +export function Meta({ label, children }: { label: string; children: ReactNode }) { + return ( + + + {label} + + {children} + + ); +} diff --git a/create-a-container/client/src/components/containers/NodeLink.tsx b/create-a-container/client/src/components/containers/NodeLink.tsx new file mode 100644 index 00000000..498af3b3 --- /dev/null +++ b/create-a-container/client/src/components/containers/NodeLink.tsx @@ -0,0 +1,18 @@ +import type { Container } from '@/lib/types'; +import { linkClass } from './shared'; + +/** Node name linked to the node's Proxmox web UI (LXC view when provisioned). */ +export function NodeLink({ c }: { c: Container }) { + if (!c.nodeApiUrl) return <>{c.nodeName || '—'}; + return ( + + {c.nodeName || c.nodeApiUrl} + + ); +} diff --git a/create-a-container/client/src/pages/containers/ResourcesSection.tsx b/create-a-container/client/src/components/containers/ResourcesSection.tsx similarity index 100% rename from create-a-container/client/src/pages/containers/ResourcesSection.tsx rename to create-a-container/client/src/components/containers/ResourcesSection.tsx diff --git a/create-a-container/client/src/components/containers/RowActions.tsx b/create-a-container/client/src/components/containers/RowActions.tsx new file mode 100644 index 00000000..0a21add2 --- /dev/null +++ b/create-a-container/client/src/components/containers/RowActions.tsx @@ -0,0 +1,68 @@ +import { Link } from 'react-router'; +import { Button } from '@mieweb/ui'; +import { Pencil, Share2, Trash2 } from 'lucide-react'; +import type { Container } from '@/lib/types'; + +/** Per-row actions for the containers list: logs, share, edit, delete. */ +export function RowActions({ + c, + siteId, + onDelete, + deleting, + canShare, + onShare, +}: { + c: Container; + siteId?: string; + onDelete: (id: number) => void; + deleting: boolean; + canShare: boolean; + onShare: (c: Container) => void; +}) { + return ( + <> + {c.creationJobId && ( + + + + )} + {canShare && ( + + )} + + + + + + ); +} diff --git a/create-a-container/client/src/components/containers/SshLinks.tsx b/create-a-container/client/src/components/containers/SshLinks.tsx new file mode 100644 index 00000000..9e6bae2a --- /dev/null +++ b/create-a-container/client/src/components/containers/SshLinks.tsx @@ -0,0 +1,39 @@ +import { Code2, Terminal } from 'lucide-react'; +import type { Container } from '@/lib/types'; +import { linkClass } from './shared'; + +/** A container's SSH endpoint with VS Code Remote and terminal deep links. */ +export function SshLinks({ c, sessionUser }: { c: Container; sessionUser?: string }) { + if (!c.sshHost || !c.sshPort) return ; + return ( + + + {c.sshHost}:{c.sshPort} + + {sessionUser && ( + <> + + + + + + )} + + ); +} diff --git a/create-a-container/client/src/components/containers/StatusBadge.tsx b/create-a-container/client/src/components/containers/StatusBadge.tsx new file mode 100644 index 00000000..f9ef7058 --- /dev/null +++ b/create-a-container/client/src/components/containers/StatusBadge.tsx @@ -0,0 +1,35 @@ +import { Badge } from '@mieweb/ui'; +import type { ContainerStatus } from '@/lib/types'; + +// Human-readable labels for the live status values. +export const STATUS_LABELS: Record = { + running: 'Running', + offline: 'Offline', + creating: 'Creating', + failed: 'Failed', + missing: 'Missing', + unknown: 'Unknown', +}; + +function statusVariant( + s: ContainerStatus, +): 'default' | 'success' | 'warning' | 'danger' | 'secondary' { + switch (s) { + case 'running': + return 'success'; + case 'creating': + return 'warning'; + case 'failed': + return 'danger'; + case 'offline': + case 'missing': + return 'secondary'; + default: + return 'default'; + } +} + +/** Status badge. The status is the live value embedded in the list response. */ +export function StatusBadge({ status }: { status: ContainerStatus }) { + return {STATUS_LABELS[status] ?? status}; +} diff --git a/create-a-container/client/src/components/containers/shared.ts b/create-a-container/client/src/components/containers/shared.ts new file mode 100644 index 00000000..1ff74f6d --- /dev/null +++ b/create-a-container/client/src/components/containers/shared.ts @@ -0,0 +1,8 @@ +/** Link styling shared by the container link components. */ +export const linkClass = 'text-(--color-primary,#1d4ed8) hover:underline'; + +/** Shorten a full image ref to just its name+tag, e.g. ghcr.io/mieweb/base:latest -> base:latest */ +export function templateTitle(template: string | null): string { + if (!template) return '—'; + return template.split('/').pop() || template; +} diff --git a/create-a-container/client/src/lib/queries.ts b/create-a-container/client/src/lib/queries.ts index c413027e..1177f3f5 100644 --- a/create-a-container/client/src/lib/queries.ts +++ b/create-a-container/client/src/lib/queries.ts @@ -26,7 +26,7 @@ export const keys = { nodes: (siteId: number | string) => ['sites', String(siteId), 'nodes'] as const, node: (siteId: number | string, id: number | string) => ['sites', String(siteId), 'nodes', String(id)] as const, - containers: (siteId: number | string, params?: Record) => + containers: (siteId: number | string, params?: Record) => ['sites', String(siteId), 'containers', params ?? {}] as const, container: (siteId: number | string, id: number | string) => ['sites', String(siteId), 'containers', String(id)] as const, @@ -63,13 +63,13 @@ export const queries = { // Containers listContainers: ( siteId: number | string, - params?: { user?: string; nodeId?: string; hostname?: string }, + params?: { user?: string[]; nodeId?: string; hostname?: string }, ) => { const qs = new URLSearchParams(); - // `user` is sent verbatim, including '*' (all owners, admin only) and '' - // (own containers). Only omit it when undefined so the default still maps - // to the requesting user server-side. - if (params?.user !== undefined) qs.set('user', params.user); + // `user` is sent in bracket notation (user[0]=..., user[1]=...) so the + // server's 'extended' query parser always receives an array. Omitted/empty + // returns everything the caller may see server-side. + params?.user?.forEach((u, i) => qs.set(`user[${i}]`, u)); if (params?.nodeId) qs.set('nodeId', params.nodeId); if (params?.hostname) qs.set('hostname', params.hostname); const suffix = qs.toString() ? `?${qs.toString()}` : ''; @@ -77,6 +77,17 @@ export const queries = { }, getContainer: (siteId: number | string, id: number | string) => api.get(`/api/v1/sites/${siteId}/containers/${id}`), + // Container sharing (collaborators). Both return the updated collaborator + // username list. + shareContainer: (siteId: number | string, id: number | string, username: string) => + api.post<{ collaborators: string[] }>( + `/api/v1/sites/${siteId}/containers/${id}/collaborators`, + { username }, + ), + unshareContainer: (siteId: number | string, id: number | string, username: string) => + api.delete<{ collaborators: string[] }>( + `/api/v1/sites/${siteId}/containers/${id}/collaborators/${encodeURIComponent(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/lib/types.ts b/create-a-container/client/src/lib/types.ts index dcd62bfe..357da690 100644 --- a/create-a-container/client/src/lib/types.ts +++ b/create-a-container/client/src/lib/types.ts @@ -74,6 +74,8 @@ export interface Container { containerId: number | null; hostname: string; owner: string; + /** Usernames this container is shared with (collaborators). */ + collaborators: string[]; ipv4Address: string | null; macAddress: string | null; status: ContainerStatus; diff --git a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx index 87ec7ebe..a05c5d4b 100644 --- a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx +++ b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx @@ -25,7 +25,8 @@ import { api, ApiError } from '@/lib/api'; import { keys, queries } from '@/lib/queries'; import { FormPageHeader } from '@/components/FormPageHeader'; import { randomHostname } from '@/lib/randomHostname'; -import { ResourcesSection } from './ResourcesSection'; +import { ResourcesSection } from '@/components/containers/ResourcesSection'; +import { AddCollaboratorField, CollaboratorChips, CollaboratorsManager } from '@/components/containers/CollaboratorsManager'; import type { ContainerCreateResult, ContainerMetadata } from '@/lib/types'; function useDebouncedValue(value: T, delay = 500): T { @@ -85,6 +86,9 @@ const schema = z.object({ restart: z.boolean().optional(), services: z.array(serviceSchema), environmentVars: z.array(envVarSchema), + // Usernames to share a new container with (collaborators). Existence is + // validated server-side on submit. Unused in edit mode (live manager instead). + collaborators: z.array(z.string()), }); type FormData = z.infer; @@ -121,12 +125,15 @@ export function ContainerFormPage() { defaultValues: { services: [], environmentVars: [], + collaborators: [], nvidiaRequested: false, restart: false, }, }); const services = useFieldArray({ control, name: 'services' }); const envVars = useFieldArray({ control, name: 'environmentVars' }); + // Guards the one-time form initialization from the loaded container (edit). + const initializedRef = useRef(false); // Default external domain for new HTTP services. The bootstrap endpoint // returns domains already sorted so the site's default domains come first, @@ -143,9 +150,15 @@ export function ContainerFormPage() { const hostname = watch('hostname'); const debouncedHostname = useDebouncedValue(hostname || '', 500); const customTemplate = watch('customTemplate'); + const collaborators = watch('collaborators') || []; useEffect(() => { - if (container && isEdit) { + if (container && isEdit && !initializedRef.current) { + // Initialize the form from the loaded container exactly once. Re-running + // reset on every `container` change would wipe unsaved edits whenever the + // container query refetches (e.g. after the Sharing manager adds/removes a + // collaborator and invalidates this query). + initializedRef.current = true; reset({ hostname: container.hostname, template: container.template || '', @@ -173,6 +186,7 @@ export function ContainerFormPage() { key, value, })), + collaborators: [], }); } }, [container, isEdit, reset]); @@ -271,6 +285,8 @@ export function ContainerFormPage() { services: servicesObj, environmentVars: values.environmentVars.filter((e) => e.key.trim()), restart: values.restart, + // Only meaningful on create; the edit form manages sharing live. + collaborators: values.collaborators, }; type UpdateResult = { containerId: number; @@ -666,6 +682,51 @@ export function ContainerFormPage() { ))} + + + + Sharing + + + {isEdit && container ? ( + <> +

+ Share this container with other users for collaboration. They will see it in + their All containers tab. +

+ + + ) : ( + <> +

+ Optionally add other users as collaborators. They will see this container in + their All containers tab once it is created. +

+ + setValue( + 'collaborators', + collaborators.filter((x) => x !== u), + ) + } + /> + { + if (!collaborators.includes(u)) { + setValue('collaborators', [...collaborators, u]); + } + }} + /> + + )} +
- - )} - - - - - - ); -} - -function Meta({ label, children }: { label: string; children: React.ReactNode }) { - return ( - - - {label} - - {children} - - ); -} - export function ContainersListPage() { const { siteId } = useParams<{ siteId: string }>(); const qc = useQueryClient(); @@ -218,24 +56,62 @@ export function ContainersListPage() { const sessionUser = session?.user; const isAdmin = !!session?.isAdmin; const location = useLocation(); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const nodeId = searchParams.get('nodeId') || undefined; - // `user=*` lists every owner on the site for admins; for non-admins the - // server scopes it back to their own containers, so the toggle is available - // to everyone (ahead of future shareable/collaborative containers). The param - // is the single source of truth so the view is bookmarkable and slots in - // beside the existing hostname/nodeId filters. - const isAll = searchParams.get('user') === '*'; - const userFilter = isAll ? '*' : undefined; - // Only admins ever see more than one owner in the `*` view, so the owner - // column is meaningful for them alone. - const showOwner = isAdmin && isAll; + // The URL is the single source of truth for the filter bar, so views are + // bookmarkable and shareable. `user` is a comma-separated owner list that + // drives the server query; an empty list returns everything the caller may + // see (all owners for admins; own + shared for non-admins). + // Status/template/hostname refine the loaded rows client-side. + const parseList = (v: string | null) => (v ? v.split(',').filter(Boolean) : []); + const selectedUsers = parseList(searchParams.get('user')); + const selectedStatuses = parseList(searchParams.get('status')); + const selectedTemplates = parseList(searchParams.get('template')); + const hostnameQuery = searchParams.get('q') ?? ''; + // A row may belong to another owner unless the filter is narrowed to just + // the caller, so the owner column only disappears then. + const showOwner = selectedUsers.length === 0 || selectedUsers.some((u) => u !== sessionUser); const dnsWarnings = (location.state as { dnsWarnings?: string[] } | null)?.dnsWarnings; + const setListParam = (key: string, values: string[]) => + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (values.length > 0) next.set(key, values.join(',')); + else next.delete(key); + return next; + }, + { replace: true }, + ); + const setTextParam = (key: string, value: string) => + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (value) next.set(key, value); + else next.delete(key); + return next; + }, + { replace: true }, + ); + // The unfiltered default already shows everything the caller may see; + // select-all in the dropdown is just an explicit form of the same thing. + const clearAllFilters = () => + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + ['user', 'status', 'template', 'q'].forEach((k) => next.delete(k)); + return next; + }, + { replace: true }, + ); + const [view, setView] = useState(() => { const stored = typeof window !== 'undefined' ? window.localStorage.getItem(VIEW_STORAGE_KEY) : null; return stored === 'table' ? 'table' : 'cards'; }); + // Container whose sharing dialog is open. Tracked by id so the dialog reflects + // live collaborator changes after the list query refetches. + const [shareTargetId, setShareTargetId] = useState(null); const changeView = (next: ViewMode) => { setView(next); try { @@ -250,12 +126,30 @@ export function ContainersListPage() { queryFn: () => queries.getSite(siteId!), enabled: !!siteId, }); + // Empty selection returns everything the caller may see server-side. + const userParam = selectedUsers.length > 0 ? selectedUsers : undefined; const { data, isLoading, error } = useQuery({ - queryKey: keys.containers(siteId!, { user: userFilter, nodeId }), - queryFn: () => queries.listContainers(siteId!, { user: userFilter, nodeId }), + queryKey: keys.containers(siteId!, { user: userParam, nodeId }), + queryFn: () => queries.listContainers(siteId!, { user: userParam, nodeId }), enabled: !!siteId, }); + // Options for the "User" filter. Admins may filter by any user; non-admins + // may only narrow to owners who have shared a container with them, so their + // option list is derived from the unfiltered list (own + shared). With no + // owner filter selected this is the same query as the list above, so React + // Query dedupes it into a single fetch. + const { data: allUsers } = useQuery({ + queryKey: keys.users(), + queryFn: queries.listUsers, + enabled: !!siteId && isAdmin, + }); + const { data: visibleContainers } = useQuery({ + queryKey: keys.containers(siteId!, {}), + queryFn: () => queries.listContainers(siteId!), + enabled: !!siteId && !isAdmin, + }); + const del = useMutation({ mutationFn: (id: number) => api.delete(`/api/v1/sites/${siteId}/containers/${id}`), onSuccess: () => { @@ -265,33 +159,73 @@ export function ContainersListPage() { onError: (err: ApiError) => toast.error(err.message), }); - const hasContainers = !!data && data.length > 0; + // Client-side refinement of the loaded rows by status/template/hostname. + const visible = useMemo(() => { + const q = hostnameQuery.trim().toLowerCase(); + return (data ?? []).filter( + (c) => + (selectedStatuses.length === 0 || selectedStatuses.includes(c.status)) && + (selectedTemplates.length === 0 || + (c.template != null && selectedTemplates.includes(c.template))) && + (q === '' || c.hostname.toLowerCase().includes(q)), + ); + }, [data, selectedStatuses, selectedTemplates, hostnameQuery]); + + const userOptions = useMemo(() => { + const byLabel = (a: FilterOption, b: FilterOption) => a.label.localeCompare(b.label); + if (isAdmin) { + const opts = (allUsers ?? []).map((u) => ({ + value: u.uid, + label: u.uid === sessionUser ? `${u.cn} (me)` : u.cn, + })); + return opts.sort(byLabel); + } + const owners = new Set(); + if (sessionUser) owners.add(sessionUser); + (visibleContainers ?? []).forEach((c) => owners.add(c.owner)); + const opts = [...owners].map((o) => ({ + value: o, + label: o === sessionUser ? `${o} (me)` : o, + })); + return opts.sort(byLabel); + }, [isAdmin, allUsers, visibleContainers, sessionUser]); + + const statusOptions = useMemo( + () => + (Object.keys(STATUS_LABELS) as ContainerStatus[]).map((s) => ({ + value: s, + label: STATUS_LABELS[s], + })), + [], + ); + + const templateOptions = useMemo(() => { + const seen = new Map(); + (data ?? []).forEach((c) => { + if (c.template) seen.set(c.template, templateTitle(c.template)); + }); + return [...seen.entries()] + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [data]); + + const hasContainers = visible.length > 0; + const serverHasContainers = !!data && data.length > 0; + + // Sharing is owner/admin only. Derive the open dialog's container from the + // live list so its collaborator chips update after add/remove. + const canShareContainer = (c: Container) => isAdmin || c.owner === sessionUser; + const shareTarget = data?.find((c) => c.id === shareTargetId) ?? null; return (
} actions={
-
- - - - - - -
- {hasContainers && ( + {serverHasContainers && (
+ setListParam('user', v)} + statusOptions={statusOptions} + selectedStatuses={selectedStatuses} + onStatusesChange={(v) => setListParam('status', v)} + templateOptions={templateOptions} + selectedTemplates={selectedTemplates} + onTemplatesChange={(v) => setListParam('template', v)} + hostname={hostnameQuery} + onHostnameChange={(v) => setTextParam('q', v)} + onClearAll={clearAllFilters} + /> + {error && ( {(error as ApiError).message} @@ -359,16 +308,22 @@ export function ContainersListPage() { No containers - {showOwner - ? 'No containers exist on this site yet.' + {selectedUsers.length > 0 + ? 'No containers match the selected users.' : 'Create your first container with the button above.'} )} + {serverHasContainers && !hasContainers && ( + + No matches + No containers match the current filters. + + )} {hasContainers && view === 'cards' && (
- {data.map((c: Container) => ( + {visible.map((c: Container) => (
- + setShareTargetId(target.id)} + />
@@ -429,7 +391,7 @@ export function ContainersListPage() { - {data.map((c: Container) => ( + {visible.map((c: Container) => ( {c.hostname} @@ -456,13 +418,46 @@ export function ContainersListPage() { - + setShareTargetId(target.id)} + /> ))} )} + + !open && setShareTargetId(null)} + size="md" + > + + + {shareTarget ? `Share ${shareTarget.hostname}` : 'Share container'} + + + + +

+ Share this container with other users for collaboration. Shared users can find it + by filtering the containers list by your username. +

+ {shareTarget && siteId && ( + + )} +
+
); } diff --git a/create-a-container/migrations/20260630120000-create-container-collaborators.js b/create-a-container/migrations/20260630120000-create-container-collaborators.js new file mode 100644 index 00000000..05bdf058 --- /dev/null +++ b/create-a-container/migrations/20260630120000-create-container-collaborators.js @@ -0,0 +1,52 @@ +'use strict'; + +/** + * Container sharing: a join table between containers and the additional users + * (collaborators) who have been granted access to a container. Membership is + * keyed by `username` (the user's `uid`) to mirror how a container records its + * primary owner (`Containers.username`). + * + * @type {import('sequelize-cli').Migration} + */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('ContainerCollaborators', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER, + }, + containerId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { model: 'Containers', key: 'id' }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + username: { + type: Sequelize.STRING(255), + allowNull: false, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + }, + }); + + // A user can be a collaborator on a given container at most once. + await queryInterface.addConstraint('ContainerCollaborators', { + fields: ['containerId', 'username'], + type: 'unique', + name: 'container_collaborators_container_username_unique', + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('ContainerCollaborators'); + }, +}; diff --git a/create-a-container/models/container-collaborator.js b/create-a-container/models/container-collaborator.js new file mode 100644 index 00000000..5ec49f39 --- /dev/null +++ b/create-a-container/models/container-collaborator.js @@ -0,0 +1,44 @@ +'use strict'; +const { Model } = require('sequelize'); + +module.exports = (sequelize, DataTypes) => { + /** + * A grant giving a user (the `username`/uid) access to a container they do + * not own — i.e. a shared/collaborative container. The container's primary + * owner is still `Container.username`; this table only records the extra + * collaborators. + */ + class ContainerCollaborator extends Model { + static associate(models) { + ContainerCollaborator.belongsTo(models.Container, { + foreignKey: 'containerId', + as: 'container', + }); + } + } + ContainerCollaborator.init( + { + containerId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { model: 'Containers', key: 'id' }, + }, + username: { + type: DataTypes.STRING(255), + allowNull: false, + }, + }, + { + sequelize, + modelName: 'ContainerCollaborator', + indexes: [ + { + name: 'container_collaborators_container_username_unique', + unique: true, + fields: ['containerId', 'username'], + }, + ], + }, + ); + return ContainerCollaborator; +}; diff --git a/create-a-container/models/container.js b/create-a-container/models/container.js index b6bfc83e..2f36acc7 100644 --- a/create-a-container/models/container.js +++ b/create-a-container/models/container.js @@ -18,6 +18,36 @@ module.exports = (sequelize, DataTypes) => { Container.belongsTo(models.Site, { foreignKey: 'siteId', as: 'site' }); // a container may have a creation job Container.belongsTo(models.Job, { foreignKey: 'creationJobId', as: 'creationJob' }); + // a container may be shared with additional users (collaborators) + Container.hasMany(models.ContainerCollaborator, { + foreignKey: 'containerId', + as: 'collaborators', + onDelete: 'CASCADE', + }); + } + + /** + * Whether a user may view/use this container: its primary owner or a + * collaborator it is shared with. Requires the `collaborators` association + * to be eager-loaded. Admin overrides live at the route layer (admin status + * is a session property, not derivable from a username here). + * @param {string} username - The candidate user's uid. + * @returns {boolean} + */ + canView(username) { + if (this.username === username) return true; + return (this.collaborators || []).some((c) => c.username === username); + } + + /** + * Whether a user may edit this container — manage its sharing (add/remove + * collaborators) or delete it: only its primary owner. Collaborators can + * use a shared container but cannot re-share or delete it. + * @param {string} username - The candidate user's uid. + * @returns {boolean} + */ + canEdit(username) { + return this.username === username; } /** diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index 22e57415..043dcab8 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -314,13 +314,17 @@ paths: - { in: query, name: nodeId, schema: { type: integer }, description: Filter to a single node belonging to the site } - in: query name: user - schema: { type: string } + schema: + type: array + items: { type: string } description: >- - Owner filter. Omitted/empty returns the requesting user's own - containers (default). `*` returns every owner on the site for admins, - or just your own containers for non-admins. A specific username - returns that owner's containers and is admin-only (except requesting - yourself), yielding 403 otherwise. + Owner filter, sent in bracket notation (`user[0]=alice&user[1]=bob`) + so it always parses as a list. Omitted/empty returns everything the + caller may see: every container on the site for admins, or their own + plus any shared with them for non-admins. A list of usernames + returns those owners' containers for admins; for non-admins the list + is intersected with what they may already see (own plus shared), so + it can only narrow visibility. responses: '200': { description: Array of containers } '403': { description: 'Admin access required', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } @@ -339,6 +343,10 @@ paths: customTemplate: { type: string } entrypoint: { type: string } nvidiaRequested: { type: boolean } + collaborators: + type: array + items: { type: string } + description: Usernames to share the new container with (must exist) environmentVars: type: array items: { type: object, properties: { key: { type: string }, value: { type: string } } } @@ -369,6 +377,38 @@ paths: get: { tags: [Containers], responses: { '200': { description: Container } } } put: { tags: [Containers], responses: { '200': { description: Updated, optional restart job } } } delete: { tags: [Containers], responses: { '200': { description: Deleted, with DNS cleanup warnings } } } + /sites/{siteId}/containers/{id}/collaborators: + parameters: + - { in: path, name: siteId, required: true, schema: { type: integer } } + - { in: path, name: id, required: true, schema: { type: integer } } + get: + tags: [Containers] + summary: List users a container is shared with (owner/collaborator/admin) + responses: { '200': { description: '{ collaborators: string[] }' } } + post: + tags: [Containers] + summary: Share a container with another user (owner/admin) + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [username] + properties: { username: { type: string } } + responses: + '201': { description: '{ collaborators: string[] }' } + '404': { description: 'user_not_found when the username does not exist', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '409': { description: 'already_shared / already_owner', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + /sites/{siteId}/containers/{id}/collaborators/{username}: + delete: + tags: [Containers] + summary: Stop sharing a container with a user (owner/admin) + parameters: + - { in: path, name: siteId, required: true, schema: { type: integer } } + - { in: path, name: id, required: true, schema: { type: integer } } + - { in: path, name: username, required: true, schema: { type: string } } + responses: { '200': { description: '{ collaborators: string[] }' } } /sites/{siteId}/nodes: parameters: [{ in: path, name: siteId, required: true, schema: { type: integer } }] diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js index a80fd3fe..6084b095 100644 --- a/create-a-container/routers/api/v1/containers.js +++ b/create-a-container/routers/api/v1/containers.js @@ -6,6 +6,7 @@ const express = require('express'); const { Container, + ContainerCollaborator, Service, HTTPService, TransportService, @@ -15,6 +16,7 @@ const { ExternalDomain, Job, Setting, + User, Sequelize, sequelize, } = require('../../../models'); @@ -85,8 +87,13 @@ function normalizeDockerRef(ref) { return `${host}/${org}/${image}:${tag}`; } -async function loadSite(req) { - const site = await Site.findByPk(parseInt(req.params.siteId, 10)); +/** + * Load a site by id (typically `req.params.siteId`) or 404. + * @param {*} siteId - Candidate site id; parsed as a base-10 integer. + * @returns {Promise} The Site instance. + */ +async function loadSite(siteId) { + const site = await Site.findByPk(parseInt(siteId, 10)); if (!site) throw new ApiError(404, 'site_not_found', 'Site not found'); return site; } @@ -117,6 +124,11 @@ function serializeContainer(c, site, status) { // "all containers" view can show a User column; for the per-user list it // simply equals the requesting user. owner: c.username, + // Additional users this container is shared with. Sorted for a stable UI; + // present on every payload so consumers can render/manage sharing. + collaborators: (c.collaborators || []) + .map((x) => x.username) + .sort((a, b) => a.localeCompare(b)), ipv4Address: c.ipv4Address, macAddress: c.macAddress, // Live status computed from Proxmox + jobs + config (see utils/container-status). @@ -176,18 +188,34 @@ const CONTAINER_INCLUDE = [ { association: 'node' }, // Eager-load the create job so status resolution needs no per-container query. { association: 'creationJob' }, + // Users the container is shared with, for the serializer's `collaborators`. + { association: 'collaborators' }, ]; /** - * Build the `where` clause for a container list query, scoped to the given - * site's nodes and narrowed by the supported query-string filters - * (`hostname`, `nodeId`). The `nodeId` filter is intersected with the site's - * own nodes so it can never widen the result set beyond the site. + * Build the `where` clause for a container list query — the single arbiter of + * what a list request may return. Scopes to the given site's nodes, narrows by + * the supported query-string filters (`hostname`, `nodeId`, `user`), and + * enforces ownership visibility, folding in shared containers. + * + * The `nodeId` filter is intersected with the site's own nodes so it can never + * widen the result set beyond the site. + * + * `query.user` must be an array (callers default it with `req.query.user ??= + * []`; clients send bracket notation, e.g. `user[0]=alice`, which the + * 'extended' query parser coerces to an array). Empty -> everything the caller + * may see: every container on the site for admins, their own plus any shared + * with them for non-admins. A list of names -> those owners for admins; for + * non-admins the same list intersected with what they may already see (own + * plus shared), so a non-admin can only narrow down to owners who have shared + * a container with them and never widen their visibility. + * * @param {object} query - req.query * @param {number[]} nodeIds - IDs of the nodes belonging to the site + * @param {object} session - req.session ({ user, isAdmin }) * @returns {object} Sequelize where clause */ -function buildContainerListWhere(query, nodeIds) { +function buildContainerListWhere(query, nodeIds, session) { const where = { nodeId: nodeIds }; if (query.hostname) where.hostname = query.hostname; if (query.nodeId) { @@ -196,34 +224,106 @@ function buildContainerListWhere(query, nodeIds) { // otherwise force an empty result rather than leaking other sites' nodes. where.nodeId = Number.isInteger(nodeId) && nodeIds.includes(nodeId) ? nodeId : -1; } + + const names = query.user; + if (session.isAdmin) { + // Admins may see every owner on the site; a name list simply narrows it. + if (names.length > 0) where.username = names; + return where; + } + // Non-admins may only see their own containers plus any shared with them. + // The shared set is expressed as an `IN (SELECT …)` subquery so the whole + // visibility check resolves inside the main containers query — a single + // round trip — instead of a separate lookup. (A plain JOIN on the + // eager-loaded `collaborators` association would filter the joined rows and + // truncate the serialized collaborator list, so a subquery is used.) It is + // built with the dialect's query generator so identifier quoting and value + // escaping stay correct across sqlite/mysql/postgres; selectQuery emits a + // trailing ';' which is invalid inside IN (…), hence the slice. + const shared = sequelize.dialect.queryGenerator + .selectQuery( + ContainerCollaborator.getTableName(), + { attributes: ['containerId'], where: { username: session.user } }, + ContainerCollaborator, + ) + .slice(0, -1); + const visible = [ + { username: session.user }, + { id: { [Sequelize.Op.in]: Sequelize.literal(`(${shared})`) } }, + ]; + if (names.length === 0) { + where[Sequelize.Op.or] = visible; + } else { + // Intersect the requested owners with what the caller may already see. + where[Sequelize.Op.and] = [{ [Sequelize.Op.or]: visible }, { username: names }]; + } return where; } /** - * Resolve the `user` list filter into a Sequelize `username` constraint, - * enforcing authorization. The rules mirror the existing `hostname` filter in - * shape (a plain query param) but add ownership scoping: - * - absent/empty -> the requesting user's own containers (default for all) - * - '*' -> every owner on the site (admin), or just your own (non-admin) - * - '' -> that specific owner (admin only, unless it's yourself) - * Non-admins may use `*`, but it is scoped to their own containers rather than - * returning a 403. This keeps the "All" toggle usable for everyone today and - * leaves room for future shareable/collaborative containers to widen what a - * non-admin sees here. Requesting a specific *other* owner still 403s. - * @param {string|undefined} requestedUser - req.query.user - * @param {{ user: string, isAdmin: boolean }} session - req.session - * @returns {string|undefined} username to filter by, or undefined for "all" + * Load a container by `:id` scoped to the request's site and authorize the + * session against it. Returns 404 (not 403) on any failure so the route never + * leaks the existence of containers the caller may not see. + * @param {object} req - Express request. + * @param {object} [opts] + * @param {boolean} [opts.requireManage=false] - Require owner/admin (sharing, + * delete) rather than the looser view/edit access. + * @param {Array} [opts.include] - Override the eager-load graph. + * @returns {Promise<{site: object, container: object}>} */ -function resolveUsernameFilter(requestedUser, session) { - if (!requestedUser) return session.user; - if (requestedUser === '*') { - // Admins see every owner; non-admins are scoped to their own containers. - return session.isAdmin ? undefined : session.user; +async function loadContainerForSession(req, { requireManage = false, include } = {}) { + const site = await loadSite(req.params.siteId); + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id <= 0) { + throw new ApiError(404, 'not_found', 'Container not found'); } - if (requestedUser !== session.user && !session.isAdmin) { - throw new ApiError(403, 'forbidden', 'Admin access required'); + const container = await Container.findByPk(id, { + include: include || [{ association: 'node' }, { association: 'collaborators' }], + }); + if (!container || !container.node || container.node.siteId !== site.id) { + throw new ApiError(404, 'not_found', 'Container not found'); + } + const authorized = + req.session.isAdmin || + (requireManage + ? container.canEdit(req.session.user) + : container.canView(req.session.user)); + if (!authorized) throw new ApiError(404, 'not_found', 'Container not found'); + return { site, container }; +} + +/** + * Validate a username to share a container with. Confirms the user exists and + * is not already the container's primary owner, and returns the canonical uid. + * @param {*} rawUsername - Candidate username from the request body. + * @param {object} container - The container being shared. + * @param {object} [opts] - Optional { transaction } for the lookup. + * @returns {Promise} The canonical uid to store as a collaborator. + */ +async function resolveShareUsername(rawUsername, container, { transaction } = {}) { + const username = typeof rawUsername === 'string' ? rawUsername.trim() : ''; + if (!username) throw new ApiError(400, 'invalid_request', 'A username is required'); + const user = await User.findOne({ where: { uid: username }, transaction }); + if (!user) throw new ApiError(404, 'user_not_found', `User "${username}" does not exist`); + if (user.uid === container.username) { + throw new ApiError(409, 'already_owner', `${user.uid} already owns this container`); } - return requestedUser; + return user.uid; +} + +/** + * Load the current collaborator usernames for a container, sorted for a stable + * UI. Shared shape returned by the share/unshare endpoints. + * @param {number} containerId + * @returns {Promise} + */ +async function listCollaboratorUsernames(containerId) { + const rows = await ContainerCollaborator.findAll({ + where: { containerId }, + attributes: ['username'], + order: [['username', 'ASC']], + }); + return rows.map((r) => r.username); } // GET /containers/metadata?image=... @@ -250,7 +350,7 @@ router.get( router.get( '/new', asyncHandler(async (req, res) => { - const site = await loadSite(req); + const site = await loadSite(req.params.siteId); const externalDomains = await site.getSortedExternalDomains(); const nvidiaAvailable = (await Node.count({ where: { siteId: site.id, nvidiaAvailable: true } })) > 0; @@ -262,16 +362,15 @@ router.get( router.get( '/', asyncHandler(async (req, res) => { - const site = await loadSite(req); + const site = await loadSite(req.params.siteId); const nodes = await Node.findAll({ where: { siteId: site.id }, attributes: ['id'] }); const nodeIds = nodes.map((n) => n.id); - const where = buildContainerListWhere(req.query, nodeIds); - // `user` filter: defaults to the requesting user, so everyone (incl. admins) - // sees their own containers by default. `user=*` lists every owner for admins - // and stays scoped to the requester for non-admins; `user=` targets a - // specific owner (admin-only). undefined means "all owners". - const username = resolveUsernameFilter(req.query.user, req.session); - if (username !== undefined) where.username = username; + // `user` filter backs the list page's User filter: omitted, it returns + // everything the caller may see (all owners for admins; own + shared for + // non-admins); `user[0]=` narrows to specific owners. See + // buildContainerListWhere. + req.query.user ??= []; + const where = buildContainerListWhere(req.query, nodeIds, req.session); const rows = await Container.findAll({ where, include: CONTAINER_INCLUDE }); // Resolve live statuses for the whole page in one pass: one Proxmox snapshot // per node (shared), and no per-container DB queries (create job is loaded above). @@ -287,7 +386,7 @@ router.get( router.get( '/:id', asyncHandler(async (req, res) => { - const site = await loadSite(req); + const site = await loadSite(req.params.siteId); // Guard against non-numeric ids (e.g. "undefined", "NaN"): parseInt would // yield NaN and reach the DB as an invalid integer comparison, surfacing as // a 500. Treat anything non-numeric as "not found". @@ -296,10 +395,15 @@ router.get( throw new ApiError(404, 'not_found', 'Container not found'); } const c = await Container.findOne({ - where: { id: containerId, username: req.session.user }, + where: { id: containerId }, include: CONTAINER_INCLUDE, }); - if (!c || !c.node || c.node.siteId !== site.id) { + if ( + !c || + !c.node || + c.node.siteId !== site.id || + !(req.session.isAdmin || c.canView(req.session.user)) + ) { throw new ApiError(404, 'not_found', 'Container not found'); } const status = await computeContainerStatus({ container: c, Job }); @@ -311,7 +415,7 @@ router.get( router.post( '/', asyncHandler(async (req, res) => { - const site = await loadSite(req); + const site = await loadSite(req.params.siteId); const t = await sequelize.transaction(); try { let { @@ -322,6 +426,7 @@ router.post( environmentVars, entrypoint, nvidiaRequested, + collaborators, } = req.body || {}; if (!hostname || !hostname.trim()) throw new ApiError(400, 'invalid_request', 'hostname is required'); @@ -385,6 +490,25 @@ router.post( { transaction: t }, ); + // Collaborators the creator chose to share with. Each must be an existing + // user and is validated up front so a typo fails the whole create (rolled + // back) rather than silently dropping a share. The creator is the owner + // already, so skip them if they list themselves. + if (Array.isArray(collaborators) && collaborators.length > 0) { + const seen = new Set(); + for (const raw of collaborators) { + const trimmed = typeof raw === 'string' ? raw.trim() : ''; + if (!trimmed || trimmed === container.username) continue; + const username = await resolveShareUsername(raw, container, { transaction: t }); + if (seen.has(username)) continue; + seen.add(username); + await ContainerCollaborator.create( + { containerId: container.id, username }, + { transaction: t }, + ); + } + } + if (services && typeof services === 'object') { for (const key in services) { const svc = services[key]; @@ -461,12 +585,17 @@ router.post( router.put( '/:id', asyncHandler(async (req, res) => { - const site = await loadSite(req); + const site = await loadSite(req.params.siteId); const container = await Container.findOne({ - where: { id: parseInt(req.params.id, 10), username: req.session.user }, - include: [{ model: Node, as: 'node', where: { siteId: site.id } }], + where: { id: parseInt(req.params.id, 10) }, + include: [ + { model: Node, as: 'node', where: { siteId: site.id } }, + { association: 'collaborators' }, + ], }); - if (!container) throw new ApiError(404, 'not_found', 'Container not found'); + if (!container || !(req.session.isAdmin || container.canView(req.session.user))) { + throw new ApiError(404, 'not_found', 'Container not found'); + } const { services, environmentVars, entrypoint } = req.body || {}; const forceRestart = req.body?.restart === true || req.body?.restart === 'true'; @@ -611,11 +740,12 @@ router.put( router.delete( '/:id', asyncHandler(async (req, res) => { - const site = await loadSite(req); + const site = await loadSite(req.params.siteId); const container = await Container.findOne({ - where: { id: parseInt(req.params.id, 10), username: req.session.user }, + where: { id: parseInt(req.params.id, 10) }, include: [ { model: Node, as: 'node' }, + { association: 'collaborators' }, { model: Service, as: 'services', @@ -632,6 +762,11 @@ router.delete( if (!container || !container.node || container.node.siteId !== site.id) { throw new ApiError(404, 'not_found', 'Container not found'); } + // Deleting is owner/admin only; collaborators may use but not destroy a + // shared container. + if (!(req.session.isAdmin || container.canEdit(req.session.user))) { + throw new ApiError(404, 'not_found', 'Container not found'); + } const node = container.node; let dnsWarnings = []; const httpServices = (container.services || []) @@ -664,6 +799,9 @@ router.delete( console.log(`Node-side deletion skipped or failed: ${err.message}`); } } + // Remove sharing grants explicitly so the rows are gone regardless of + // whether the DB enforces the ON DELETE CASCADE foreign key. + await ContainerCollaborator.destroy({ where: { containerId: container.id } }); await container.destroy(); // Remove the VM from NetBox if the integration is configured @@ -675,4 +813,44 @@ router.delete( }), ); +// GET /containers/:id/collaborators — list users a container is shared with. +// Visible to anyone who can access the container (owner, collaborator, admin). +router.get( + '/:id/collaborators', + asyncHandler(async (req, res) => { + const { container } = await loadContainerForSession(req); + return ok(res, { collaborators: await listCollaboratorUsernames(container.id) }); + }), +); + +// POST /containers/:id/collaborators — share with another user (owner/admin). +// Body: { username }. 404 user_not_found if the username doesn't exist. +router.post( + '/:id/collaborators', + asyncHandler(async (req, res) => { + const { container } = await loadContainerForSession(req, { requireManage: true }); + const username = await resolveShareUsername(req.body?.username, container); + const [, isNew] = await ContainerCollaborator.findOrCreate({ + where: { containerId: container.id, username }, + }); + if (!isNew) { + throw new ApiError(409, 'already_shared', `Already shared with ${username}`); + } + return created(res, { collaborators: await listCollaboratorUsernames(container.id) }); + }), +); + +// DELETE /containers/:id/collaborators/:username — stop sharing (owner/admin). +router.delete( + '/:id/collaborators/:username', + asyncHandler(async (req, res) => { + const { container } = await loadContainerForSession(req, { requireManage: true }); + const removed = await ContainerCollaborator.destroy({ + where: { containerId: container.id, username: req.params.username }, + }); + if (!removed) throw new ApiError(404, 'not_found', 'Collaborator not found'); + return ok(res, { collaborators: await listCollaboratorUsernames(container.id) }); + }), +); + module.exports = router; diff --git a/create-a-container/server.js b/create-a-container/server.js index 650384b3..145d903f 100644 --- a/create-a-container/server.js +++ b/create-a-container/server.js @@ -39,6 +39,9 @@ async function main() { app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); app.set('trust proxy', 1); + // Parse query strings with qs so bracket notation (e.g. `user[0]=alice`) + // yields real arrays. Express 5 defaults to the 'simple' parser. + app.set('query parser', 'extended'); // setup middleware const accessLogStream = process.env.ACCESS_LOG