Skip to content
11 changes: 11 additions & 0 deletions create-a-container/client/src/lib/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ export const queries = {
},
getContainer: (siteId: number | string, id: number | string) =>
api.get<Container>(`/api/v1/sites/${siteId}/containers/${id}`),
// Container sharing (collaborators / additional owners). 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<ContainerNewBootstrap>(`/api/v1/sites/${siteId}/containers/new`),
containerMetadata: (siteId: number | string, image: string) =>
Expand Down
2 changes: 2 additions & 0 deletions create-a-container/client/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export interface Container {
containerId: number | null;
hostname: string;
owner: string;
/** Usernames this container is shared with (additional owners). */
collaborators: string[];
ipv4Address: string | null;
macAddress: string | null;
status: ContainerStatus;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Badge, Button, Input, useToast } from '@mieweb/ui';
import { UserPlus, X } 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 "additional owners" 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 <p className="text-sm text-muted-foreground">{emptyText}</p>;
}
return (
<ul className="flex flex-wrap gap-2" aria-label="Shared with">
{usernames.map((username) => (
<li key={username}>
<Badge variant="secondary" className="gap-1 pr-1">
<span>{username}</span>
<button
type="button"
onClick={() => onRemove(username)}
disabled={disabled}
aria-label={`Remove ${username}`}
className="inline-flex items-center justify-center rounded-full p-0.5 hover:bg-black/10 disabled:opacity-50"
>
<X className="size-3" aria-hidden="true" />
</button>
</Badge>
Comment thread
runleveldev marked this conversation as resolved.
Outdated
</li>
))}
</ul>
);
}

/**
* 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 (
<div className="flex items-end gap-2">
<div className="flex-1">
<Input
label={label}
placeholder="Enter a username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
value={value}
error={error || undefined}
hasError={!!error}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
// Submit on Enter without bubbling to an enclosing form.
if (e.key === 'Enter') {
e.preventDefault();
submit();
}
}}
/>
</div>
<Button
type="button"
variant="outline"
leftIcon={<UserPlus className="size-4" />}
isLoading={pending}
disabled={!value.trim()}
onClick={submit}
>
Share
</Button>
</div>
);
}

/**
* 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<string | null>(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 (
<div className="flex flex-col gap-4">
<CollaboratorChips
usernames={collaborators}
onRemove={(u) => remove.mutate(u)}
disabled={remove.isPending}
/>
<AddCollaboratorField
onAdd={(u) => add.mutate(u)}
pending={add.isPending}
error={addError}
/>
</div>
);
}
Loading
Loading