Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function App
created: 2026-10-15T03:38:04Z
norm: '1'
sig: 063d0f00fea02511
body_hash: 7ffeaa95733dc612
raw_hash: a73f79d41002c2a0
vouched: 2026-10-19T20:54:33Z
body_hash: 59c41863ebaa7c91
raw_hash: 6b011c6b27c22f32
vouched: 2026-10-19T21:10:15Z
lines: 235-824
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5AZPYQQ7YXS2BFDB4FXJ3GX
anchor: function ShareProject
created: 2026-10-19T21:05:06Z
norm: '1'
sig: a96e2f31f9340d23
body_hash: 9a107214354231b2
raw_hash: f7ef23f3a8598330
lines: 61-221
---

granting a share always confirms first and names whose channels it exposes, since a share opens every person's channels in the project
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function main
created: 2026-10-15T04:47:23Z
norm: '1'
sig: 7d5d3f63a3bddde4
body_hash: 2274b654de30a8ac
raw_hash: 23940716838db5e6
vouched: 2026-10-19T20:55:05Z
body_hash: 1a9df6a60adae3f7
raw_hash: 7454a2968598e917
vouched: 2026-10-19T21:10:15Z
lines: 253-482
---

Expand Down
15 changes: 15 additions & 0 deletions apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { present } from "./presence";
import { type AppProject, label, projects, title } from "./projects";
import { type Section, Settings } from "./settings";
import { SetupProject } from "./setup-project";
import { ShareProject, useCanShare } from "./share-project";
import { UpdateNotice } from "./updates";
import { useWindowStorage } from "./window-storage";

Expand Down Expand Up @@ -291,6 +292,7 @@ export function App() {
const [draft, setDraft] = useState<ChannelDraft>();
const [renaming, setRenaming] = useState<{ id: string; name: string }>();
const [deleting, setDeleting] = useState<{ id: string; name: string }>();
const [sharing, setSharing] = useState(false);
const [moving, setMoving] = useState<{ item: SidebarRow; target: MoveTarget }>();
const [left, setLeft] = useLocalStorage("panel:left", true);
const [width, setWidth] = useLocalStorage("panel:left:width", 200);
Expand Down Expand Up @@ -357,6 +359,7 @@ export function App() {
);
const connected = status === "open" && !!hello.host;
const { compute } = useCompute();
const canShare = useCanShare(allProjects ? undefined : current?.id, connected);
const local = current?.host === hello.host;
const needsGit = local && current?.git === false;
// GitHub projects can be cloned here; other folders exist only on the hosts that hold them.
Expand Down Expand Up @@ -788,6 +791,9 @@ export function App() {
onArchiveInactive={!allProjects && local && connected
? () => void archiveInactive(current.path)
: undefined}
onShareProject={!allProjects && canShare && connected
? () => setSharing(true)
: undefined}
onCompute={connected
? (item, action) =>
void (action === "stop" ? stopCompute : startCompute)({
Expand Down Expand Up @@ -983,6 +989,15 @@ export function App() {
onCancel={() => setRenaming(undefined)}
/>
)}
{sharing && current && (
<ShareProject
project={current.id}
name={label(current, hello.host)}
user={hello.user}
channels={visible}
onClose={() => setSharing(false)}
/>
)}
{deleting && (
<DeleteChannel
name={deleting.name}
Expand Down
221 changes: 221 additions & 0 deletions apps/app/src/share-project.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import { useEffect, useState } from "react";

import { type Listing, type Person, type ProjectAccess, TEAM } from "@ace/host/protocol";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Switch,
toast,
} from "@ace/ui";

import { host } from "./host";

/** Whether this host's owner can share `project`, which only direct access allows. */
export function useCanShare(project: string | undefined, connected: boolean): boolean {
const [direct, setDirect] = useState<{ project: string; direct: boolean }>();
useEffect(() => {
if (!project || !connected) return;
let current = true;
host.request<ProjectAccess>({ op: "project-access", project }).then(
(access) => current && setDirect({ project, direct: access.direct }),
() => current && setDirect({ project, direct: false }),
);
return () => {
current = false;
};
}, [project, connected]);
return direct?.project === project && direct?.direct === true;
}

const VIA: Record<NonNullable<Person["via"]>, string> = {
github: "Reads it on GitHub",
folder: "Their folder",
team: "Shared with the team",
share: "Shared with them",
};

function list(names: string[]): string {
if (names.length < 2) return names.join("");
return `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
}

type Props = {
project: string;
name: string;
/** This host owner's Tailscale login. */
user: string;
/** The project's channels, whose owners a share exposes. */
channels: Listing[];
onClose: () => void;
};

/**
* A share opens the whole project: every person's channels in it, now and later (docs/terms.md),
* so granting one always says whose channels it exposes first.
*/
export function ShareProject({ project, name, user, channels, onClose }: Props) {
const [access, setAccess] = useState<ProjectAccess>();
const [pending, setPending] = useState<{ person: string; name: string }>();
const [busy, setBusy] = useState(false);

async function load() {
try {
setAccess(await host.request<ProjectAccess>({ op: "project-access", project }));
} catch (error) {
toast.error("Could not read who can see this project", {
description: (error as Error).message,
});
onClose();
}
}

useEffect(() => {
void load();
}, [project]);

async function change(person: string, shared: boolean) {
setBusy(true);
try {
await host.request({ op: "project-share", project, person, shared });
setPending(undefined);
await load();
} catch (error) {
toast.error(shared ? "Could not share the project" : "Could not unshare the project", {
description: (error as Error).message,
});
} finally {
setBusy(false);
}
}

const shared = new Set(access?.shares.map((share) => share.person));
const github = (login: string) =>
access?.people.find((value) => value.person === login)?.github || login;
const others = access?.people.filter((value) => value.person !== user) || [];

function exposed(person: string): string {
const owners = new Set(channels.map((channel) => channel.owner));
owners.delete(person);
const names = [...owners].filter((owner) => owner !== user).map((owner) =>
`${github(owner)}'s`
);
if (owners.has(user)) names.unshift("yours");
return names.length ? `, including ${list(names)},` : "";
}

if (pending) {
const team = pending.person === TEAM;
return (
<Dialog open onOpenChange={(next) => next || busy ? undefined : setPending(undefined)}>
<DialogContent className="gap-3 sm:max-w-96" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="break-all">
Share {name} with {team ? "the whole team" : pending.name}?
</DialogTitle>
<DialogDescription>
{team ? "Everyone on the team" : pending.name} will see every channel in it
{exposed(pending.person)}{" "}
and any created later, and can post in them. Whether they can invoke a channel's agent
stays that channel's agent access setting.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => setPending(undefined)}
>
Cancel
</Button>
<Button
type="button"
disabled={busy}
onClick={() => void change(pending.person, true)}
>
{busy ? "Sharing…" : "Share"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

return (
<Dialog open onOpenChange={(next) => next || busy ? undefined : onClose()}>
<DialogContent className="gap-4 sm:max-w-md">
<DialogHeader>
<DialogTitle className="break-all">Share {name}</DialogTitle>
<DialogDescription>
People with access see every channel in this project, everyone's, and can post in them.
People who can read it on GitHub always have access.
</DialogDescription>
</DialogHeader>
{!access
? <p className="text-sm text-muted-foreground">Checking who can see it…</p>
: (
<div className="flex flex-col gap-3 text-sm">
{!access.direct && (
<p className="text-muted-foreground">
Only people who can read it on GitHub, or whose folder it is, can share it.
</p>
)}
<label className="flex items-center justify-between gap-3">
<span>Everyone on the team</span>
<Switch
checked={shared.has(TEAM)}
disabled={!access.direct || busy}
onCheckedChange={(next) =>
next
? setPending({ person: TEAM, name: "the team" })
: void change(TEAM, false)}
/>
</label>
<ul className="flex flex-col gap-2">
{others.map((value) => {
const login = value.github || value.person;
return (
<li key={value.person} className="flex items-center gap-2">
<img
src={`https://github.com/${value.github || "ghost"}.png?size=40`}
alt=""
className="size-5 rounded-full"
/>
<span className="min-w-0 flex-1 truncate">{login}</span>
<span className="text-xs text-muted-foreground">
{value.via ? VIA[value.via] : "No access"}
</span>
{access.direct && (value.via === "share" || !value.via) && (
<Button
type="button"
size="sm"
variant="outline"
disabled={busy}
onClick={() =>
value.via
? void change(value.person, false)
: setPending({ person: value.person, name: login })}
>
{value.via ? "Unshare" : "Share"}
</Button>
)}
</li>
);
})}
</ul>
{!others.length && (
<p className="text-muted-foreground">
No teammates found yet. They appear once their Ace is running on the tailnet.
</p>
)}
</div>
)}
</DialogContent>
</Dialog>
);
}
13 changes: 7 additions & 6 deletions apps/host/src/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,17 @@ function team(): Set<string> {
return people;
}

/** Who on the team can see a project and why, for its owner's Share dialog. */
/** Everyone on the team this host knows and how they can see a project, for its Share dialog. */
export async function describe(project: string): Promise<ProjectAccess> {
const people = await Promise.all([...team()].map(async (person): Promise<Person | undefined> => {
const reason = await via(person, project) || shared(person, project);
return reason && { person, github: githubOf(person), via: reason };
}));
const people = await Promise.all([...team()].map(async (person): Promise<Person> => ({
person,
github: githubOf(person),
via: await via(person, project) || shared(person, project),
})));
return {
direct: await isDirect(catalog.user, project),
shares: directory.read().shares.filter((share) => share.project === project),
people: people.filter((value) => !!value),
people,
};
}

Expand Down
Loading