cynarAI 3c3961f242
fix(api): keep host folders to the deployment owner (#112)
spaces.create linked any workspacePath to the new project, and
computer.files and computer.readFile then listed and read files under
that folder for anyone in the space. On a deployment with signups, any
member could link / or the API's own checkout and read whatever the API
process can read, such as its .env file. Linking also ran git in the
chosen folder and stored its remote URL.

A linked folder lives on the host, and the host is the deployment
owner's machine, the rule the onboarding scan already follows:

- spaces.create refuses a folder from anyone but the deployment owner,
  before the space is created or the folder is touched.
- Project files are listed and read only for the deployment owner, so a
  space a member linked before this change stays closed too. Space
  navigation no longer shows a linked folder's path and git remote to
  other members.
- A linked folder is stored as its real path, and listing and reading
  follow symlinks only while they stay inside it. Symlinks that leave
  the folder or lead nowhere are left out of a listing instead of
  failing it.
- The web New project dialog offers the folder field only to the
  deployment owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EyQw3AEaHYXegvgvTPAsi1
2026-10-03 18:32:48 +02:00

559 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Trans, useLingui } from "@lingui/react/macro";
import type { Bot, BotSection } from "@rakazo/contracts";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
} from "@rakazo/ui-web";
import { FolderOpen, Lock, Users } from "lucide-react";
import { useId, useState } from "react";
import { desktopBridge } from "../../lib/desktop";
/** Each dialog is mounted only while open, so `open` is always true and the
* parent unmounts it from `onCancel`. Escape and backdrop presses are ignored
* while a request is in flight. */
function closeUnlessBusy(busy: boolean, onCancel: () => void) {
return (open: boolean) => {
if (!open && !busy) onCancel();
};
}
export type NewSpaceConfirmInput = {
name: string;
workspacePath?: string;
};
export function NewSpaceDialog({
variant = "space",
canLinkFolder,
onCancel,
onConfirm,
}: {
variant?: "space" | "project";
/** Folders live on the host, which only the deployment owner may link. */
canLinkFolder: boolean;
onCancel: () => void;
onConfirm: (input: NewSpaceConfirmInput) => Promise<void>;
}) {
const { t } = useLingui();
const nameId = useId();
const folderId = useId();
const [name, setName] = useState("");
const [workspacePath, setWorkspacePath] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const isProject = variant === "project";
const showFolder = isProject && canLinkFolder;
const desktop = desktopBridge();
const trimmedWorkspacePath = workspacePath.trim();
const create = () => {
const trimmed = name.trim();
if (!trimmed || saving) return;
setSaving(true);
setError(null);
void onConfirm({
name: trimmed,
...(showFolder && trimmedWorkspacePath ? { workspacePath: trimmedWorkspacePath } : {}),
}).catch((reason: unknown) => {
setError(
reason instanceof Error
? reason.message
: isProject
? t`Could not create project`
: t`Could not create space`,
);
setSaving(false);
});
};
return (
<Dialog open onOpenChange={closeUnlessBusy(saving, onCancel)}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2.5">
<Lock
size={17}
strokeWidth={1.8}
className="text-muted-foreground"
aria-hidden="true"
/>
{isProject ? <Trans>New project</Trans> : <Trans>New space</Trans>}
</DialogTitle>
</DialogHeader>
<label htmlFor={nameId} className="block text-ui-body text-foreground/75">
<Trans>Name</Trans>
<Input
id={nameId}
maxLength={60}
value={name}
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") create();
}}
placeholder={t`Customer support`}
className="mt-2"
/>
</label>
{showFolder ? (
<div className="space-y-2">
<label htmlFor={folderId} className="block text-ui-body text-foreground/75">
<Trans>Project folder</Trans>
<span className="ml-1 text-ui-body-sm text-muted-foreground">
<Trans>(optional)</Trans>
</span>
<div className="mt-2 flex gap-2">
<Input
id={folderId}
value={workspacePath}
onChange={(event) => setWorkspacePath(event.target.value)}
placeholder={t`/Users/you/projects/acme`}
spellCheck={false}
/>
{desktop?.files?.pickDirectory ? (
<Button
type="button"
variant="outline"
disabled={saving}
aria-label={t`Choose folder`}
onClick={() => {
void desktop.files
?.pickDirectory()
.then((picked) => {
if (picked) setWorkspacePath(picked);
})
.catch(() => undefined);
}}
>
<FolderOpen size={16} strokeWidth={1.8} aria-hidden="true" />
</Button>
) : null}
</div>
</label>
<p className="text-ui-body-sm text-muted-foreground">
{desktop?.files?.pickDirectory ? (
<Trans>
Choose an existing folder on this computer. Git remotes are linked automatically
when a repository is present.
</Trans>
) : (
<Trans>
Enter an absolute folder path on the server. Git remotes are linked automatically
when a repository is present.
</Trans>
)}
</p>
</div>
) : null}
{error ? <p className="text-ui-body text-destructive">{error}</p> : null}
<DialogFooter>
<Button variant="outline" disabled={saving} onClick={onCancel}>
<Trans>Cancel</Trans>
</Button>
<Button disabled={saving || !name.trim()} onClick={create}>
{saving ? (
<Trans>Creating…</Trans>
) : isProject ? (
<Trans>Create project</Trans>
) : (
<Trans>Create space</Trans>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function PickerInfoDialog({
topic,
onClose,
}: {
topic: "group" | "space";
onClose: () => void;
}) {
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent data-testid="picker-info-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2.5">
{topic === "group" ? (
<Users
size={17}
strokeWidth={1.8}
className="text-muted-foreground"
aria-hidden="true"
/>
) : (
<Lock
size={17}
strokeWidth={1.8}
className="text-muted-foreground"
aria-hidden="true"
/>
)}
{topic === "group" ? <Trans>Groups</Trans> : <Trans>Spaces</Trans>}
</DialogTitle>
<DialogDescription>
{topic === "group" ? (
<Trans>Shared chat with 2-6 bots. They all reply in the same thread.</Trans>
) : (
<Trans>Private workspace with its own bots and groups.</Trans>
)}
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
);
}
export function NewBotSectionDialog({
bot,
onCancel,
onConfirm,
}: {
bot: Pick<Bot, "name">;
onCancel: () => void;
onConfirm: (name: string) => Promise<void>;
}) {
const { t } = useLingui();
const nameId = useId();
const [name, setName] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
return (
<Dialog open onOpenChange={closeUnlessBusy(saving, onCancel)}>
<DialogContent showCloseButton={false}>
<form
className="contents"
onSubmit={(event) => {
event.preventDefault();
const trimmed = name.trim();
if (!trimmed || saving) return;
setSaving(true);
setError(null);
void onConfirm(trimmed).catch((err: unknown) => {
setError(err instanceof Error ? err.message : t`Could not create section`);
setSaving(false);
});
}}
>
<DialogHeader>
<DialogTitle>
<Trans>New section</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Create a section and move {bot.name} into it.</Trans>
</DialogDescription>
</DialogHeader>
<label htmlFor={nameId} className="block text-ui-body text-foreground/75">
<Trans>Name</Trans>
<Input
id={nameId}
maxLength={60}
value={name}
onChange={(event) => setName(event.target.value)}
className="mt-2"
/>
</label>
{error ? <p className="text-ui-body text-destructive">{error}</p> : null}
<DialogFooter>
<Button type="button" variant="outline" disabled={saving} onClick={onCancel}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={saving || !name.trim()}>
{saving ? <Trans>Creating…</Trans> : <Trans>Create</Trans>}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export function RenameBotSectionDialog({
section,
onCancel,
onConfirm,
}: {
section: Pick<BotSection, "name">;
onCancel: () => void;
onConfirm: (name: string) => Promise<void>;
}) {
const { t } = useLingui();
const nameId = useId();
const [name, setName] = useState(section.name);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const trimmed = name.trim();
const unchanged = trimmed === section.name;
return (
<Dialog open onOpenChange={closeUnlessBusy(saving, onCancel)}>
<DialogContent showCloseButton={false} aria-describedby={undefined}>
<form
className="contents"
onSubmit={(event) => {
event.preventDefault();
if (!trimmed || saving || unchanged) return;
setSaving(true);
setError(null);
void onConfirm(trimmed).catch((err: unknown) => {
setError(err instanceof Error ? err.message : t`Could not rename section`);
setSaving(false);
});
}}
>
<DialogHeader>
<DialogTitle>
<Trans>Rename section</Trans>
</DialogTitle>
</DialogHeader>
<label htmlFor={nameId} className="block text-ui-body text-foreground/75">
<Trans>Name</Trans>
<Input
id={nameId}
maxLength={60}
value={name}
onChange={(event) => setName(event.target.value)}
className="mt-2"
autoFocus
/>
</label>
{error ? <p className="text-ui-body text-destructive">{error}</p> : null}
<DialogFooter>
<Button type="button" variant="outline" disabled={saving} onClick={onCancel}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={saving || !trimmed || unchanged}>
{saving ? <Trans>Saving…</Trans> : <Trans>Save</Trans>}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export function ClearConversationDialog({
bot,
onCancel,
onConfirm,
}: {
bot: Pick<Bot, "name">;
onCancel: () => void;
onConfirm: () => Promise<void>;
}) {
const { t } = useLingui();
const [clearing, setClearing] = useState(false);
const [error, setError] = useState<string | null>(null);
return (
<AlertDialog open onOpenChange={closeUnlessBusy(clearing, onCancel)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="break-words">
<Trans>Clear {bot.name}’s conversation?</Trans>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>
This permanently removes every message and stops current work. The chat remains
available.
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
{error ? <p className="text-ui-body text-destructive">{error}</p> : null}
<AlertDialogFooter>
<AlertDialogCancel disabled={clearing}>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={clearing}
onClick={() => {
setClearing(true);
setError(null);
void onConfirm().catch((err: unknown) => {
setError(err instanceof Error ? err.message : t`Could not clear conversation`);
setClearing(false);
});
}}
>
{clearing ? <Trans>Clearing…</Trans> : <Trans>Clear</Trans>}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export function DeleteBotDialog({
bot,
onCancel,
onConfirm,
}: {
bot: Bot;
onCancel: () => void;
onConfirm: (deleteMemories: boolean) => Promise<void>;
}) {
const { t } = useLingui();
const [deleting, setDeleting] = useState(false);
const [deleteMemories, setDeleteMemories] = useState(false);
const [error, setError] = useState<string | null>(null);
return (
<AlertDialog open onOpenChange={closeUnlessBusy(deleting, onCancel)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="break-words">
<Trans>Delete {bot.name}?</Trans>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>
Its conversation, files, and routines will be permanently deleted. Bots it created
stay in your list.
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<fieldset className="space-y-2">
<legend className="mb-2 text-ui-body text-foreground/75">
<Trans>What about its memories?</Trans>
</legend>
<label className="flex cursor-pointer gap-3 rounded-lg border border-border p-3">
<input
type="radio"
name="delete-memory"
checked={!deleteMemories}
onChange={() => setDeleteMemories(false)}
/>
<span>
<span className="block text-ui-body text-foreground">
<Trans>Keep memories</Trans>
</span>
<span className="mt-0.5 block text-ui-body-sm text-muted-foreground">
<Trans>Move them to your shared memory.</Trans>
</span>
</span>
</label>
<label className="flex cursor-pointer gap-3 rounded-lg border border-border p-3">
<input
type="radio"
name="delete-memory"
checked={deleteMemories}
onChange={() => setDeleteMemories(true)}
/>
<span>
<span className="block text-ui-body text-foreground">
<Trans>Delete memories too</Trans>
</span>
<span className="mt-0.5 block text-ui-body-sm text-muted-foreground">
<Trans>This cannot be undone.</Trans>
</span>
</span>
</label>
</fieldset>
{error ? <p className="text-ui-body text-destructive">{error}</p> : null}
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleting}
onClick={() => {
setDeleting(true);
setError(null);
void onConfirm(deleteMemories).catch((err: unknown) => {
setError(err instanceof Error ? err.message : t`Could not delete bot`);
setDeleting(false);
});
}}
>
{deleting ? <Trans>Deleting…</Trans> : <Trans>Delete</Trans>}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export function DeleteItemDialog({
item,
noun,
description,
onCancel,
onConfirm,
}: {
item: { name: string };
noun: "group" | "routine" | "space";
description?: React.ReactNode;
onCancel: () => void;
onConfirm: () => Promise<void>;
}) {
const { t } = useLingui();
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
return (
<AlertDialog open onOpenChange={closeUnlessBusy(deleting, onCancel)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans>Delete {item.name}?</Trans>
</AlertDialogTitle>
<AlertDialogDescription>
{description ?? <Trans>This cannot be undone.</Trans>}
</AlertDialogDescription>
</AlertDialogHeader>
{error ? <p className="text-ui-body text-destructive">{error}</p> : null}
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleting}
onClick={() => {
setDeleting(true);
setError(null);
void onConfirm().catch((err: unknown) => {
setError(
err instanceof Error
? err.message
: noun === "group"
? t`Could not delete group`
: noun === "space"
? t`Could not delete space`
: t`Could not delete routine`,
);
setDeleting(false);
});
}}
>
{deleting ? <Trans>Deleting…</Trans> : <Trans>Delete</Trans>}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}