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
14 changes: 3 additions & 11 deletions src/components/layout/VaultHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,6 @@ export function MembersStack({
</button>
)}

{/* The Share verb (issue #68). This affordance already opened the share
sheet; it just never said so — a bare "+" with an "Invite member"
tooltip. Naming it is the whole of the explicit verb: no second entry
point, and no second surface to keep in step with this one. */}
<button
type="button"
onClick={toggleOpen}
Expand Down Expand Up @@ -134,9 +130,7 @@ export function MembersStack({
onClose={() => setOpen(false)}
anchorRef={ref}
width={320}
// The default 320 was sized for a short members list. This surface now
// carries three tabs, and at 320 the Links tab's Create button sat
// below the fold behind a scroll.
// 320 leaves the Links tab's Create button below the fold.
maxHeight={480}
align="right"
gap={4}
Expand Down Expand Up @@ -299,10 +293,8 @@ export default function VaultHeader() {

{/* Right zone: online members */}
<div className="flex items-center justify-end min-w-0">
{/* Shown for a private vault too. The sheet's own `!teamId` branch is
the conversion consent gate, so hiding the verb until a team already
exists put it everywhere except the one place it matters most. A
private vault simply has no avatar stack to show beside it. */}
{/* Shown for a private vault too: the sheet's `!teamId` branch is the
conversion consent gate. */}
{activeVaultId && (accountMode === "server" || team) && (
<MembersStack
members={members ?? []}
Expand Down
3 changes: 0 additions & 3 deletions src/components/terminal/DeepLinkConfirmModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -375,8 +375,6 @@ test("a plugin-install link naming a disabled source installs nothing", async ()
expect(fetchManifestMock).not.toHaveBeenCalled();
});

// ─── vault-join (issue #68) ───────────────────────────────────────────────────

const acceptVaultJoin = async () => {
await waitFor(() => expect(acceptButton("members.joinLinks.confirm.action").disabled).toBe(false));
await userEvent.click(acceptButton("members.joinLinks.confirm.action"));
Expand All @@ -394,7 +392,6 @@ test("the sheet names the team, the role and the inviter before accept does anyt
render(<DeepLinkConfirmModal />);
await waitFor(() => expect(screen.getByText("members.joinLinks.confirm.title")).toBeTruthy());
expect(screen.getByText("members.joinLinks.confirm.bodyNamed")).toBeTruthy();
// The one thing a joiner must not assume: the link is not the key.
expect(screen.getByText("members.joinLinks.confirm.keyFollowsLater")).toBeTruthy();
});

Expand Down
17 changes: 4 additions & 13 deletions src/components/terminal/deepLinkConfirmSpecs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,7 @@ export interface ConfirmLoad {
"vault-join": JoinGrantPreview;
}

/**
* A refused grant is matched on the error's code, never on its message: the
* message is translated, and a translated string is not a protocol.
*/
/** Matched on code, never on the translated message. */
function joinGrantErrorMessage(e: unknown, fallbackKey: string): TranslatableMessage {
if (!(e instanceof JoinGrantError)) return { key: fallbackKey };
switch (e.code) {
Expand Down Expand Up @@ -269,9 +266,7 @@ export const CONFIRM_SPECS: { [K in ConfirmRoute]: ConfirmSpec<K, ConfirmLoad[K]
acceptLabelKey: "members.joinLinks.confirm.action",
errorKey: "members.joinLinks.confirm.failed",
errorMessage: joinGrantErrorMessage,
// Preview consumes no use, so a sheet the user closes costs the link
// nothing. The server re-checks revocation, expiry and exhaustion again
// inside the redemption transaction; this result is copy, not a decision.
// Consumes no use; the server re-validates inside the redeem transaction.
load: ({ grantId, secret }) => previewJoinGrant(grantId, secret),
details: (_intent, loaded, t) => ({
title: loaded
Expand All @@ -286,15 +281,11 @@ export const CONFIRM_SPECS: { [K in ConfirmRoute]: ConfirmSpec<K, ConfirmLoad[K]
})
: t("members.joinLinks.confirm.body", { team: loaded.team_name, role: loaded.role })
: t("members.joinLinks.confirm.loading"),
// The one thing the sheet must not let the user assume. A link confers
// membership; the vault key is wrapped per member with X25519 and only
// arrives once an online key-holder wraps it.
// A link confers membership only; the key follows separately.
note: loaded ? t("members.joinLinks.confirm.keyFollowsLater") : undefined,
}),
accept: async (intent, _loaded) => {
// Sent so the roster row a key-holder reads is wrappable immediately.
// The server fills a NULL only and never overwrites — overwriting would
// orphan every vault key already wrapped to the old key.
// Sent so a key-holder can wrap for this member at once.
const publicKey = await getMyX25519Keypair()
.then(({ publicKey }) => publicKey)
.catch(() => null);
Expand Down
7 changes: 0 additions & 7 deletions src/components/vault-share/ChoiceChip.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
/**
* The one selectable chip of the share surface — roles, use counts, lifetimes.
* Extracted because the same markup, the same accent-mix background and the same
* `aria-pressed` had been written three times across this folder and would
* otherwise drift a fourth time.
*/
export function ChoiceChip({
label,
selected,
Expand All @@ -13,7 +7,6 @@ export function ChoiceChip({
label: string;
selected: boolean;
onClick: () => void;
/** Role names are stored lowercase but read as proper nouns in the UI. */
capitalize?: boolean;
}) {
return (
Expand Down
6 changes: 0 additions & 6 deletions src/components/vault-share/JoinLinksTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ vi.mock("@/services/teamJoinGrants", async (importOriginal) => ({
createJoinGrant: h.create,
revokeJoinGrant: h.revoke,
}));
// Passes the action straight through: the toast wrapper is not what these
// tests are about, and mocking it keeps the notification store out of them.
vi.mock("@/services/teamActionFeedback", () => ({
runTeamAction: ({ run }: { run: () => Promise<unknown> }) => run(),
}));
Expand All @@ -31,15 +29,13 @@ import { JoinLinksTab } from "./JoinLinksTab";
const ROLES = [
{ id: "r1", team_id: "t1", name: "member", permissions: 0, is_builtin: true, position: 2, created_at: "" },
{ id: "r2", team_id: "t1", name: "connect-only", permissions: 0, is_builtin: true, position: 3, created_at: "" },
// Never offered by a link: the server answers 400 for it.
{ id: "r0", team_id: "t1", name: "owner", permissions: 0, is_builtin: true, position: 0, created_at: "" },
];

const inADay = () => new Date(Date.now() + 24 * 3600_000).toISOString();

afterEach(() => { cleanup(); h.grants = []; vi.clearAllMocks(); });

/** The mint form is folded away behind "New link"; open it. */
const openForm = async () => {
await waitFor(() => expect(screen.getByText("members.joinLinks.newLink")).toBeTruthy());
fireEvent.click(screen.getByText("members.joinLinks.newLink"));
Expand Down Expand Up @@ -76,14 +72,12 @@ test("the freshly minted grant is the only one that offers its link", async () =
const { rerender } = render(<JoinLinksTab teamId="t1" roles={ROLES} canMint />);
await openForm();

// The reload after minting is what surfaces the row the link belongs to.
h.grants = [{ id: "g1", role: "member", max_uses: 1, uses: 0, expires_at: inADay(), created_by: "u1" }];
fireEvent.click(screen.getByText("members.joinLinks.create"));
rerender(<JoinLinksTab teamId="t1" roles={ROLES} canMint />);

const field = await waitFor(() => screen.getByDisplayValue(new RegExp(secret)));
expect((field as HTMLInputElement).value).toContain("#vault-join?g=g1&k=");
// The secret rides in the fragment, so it never reaches a web server log.
expect((field as HTMLInputElement).value.split("#")[0]).not.toContain(secret);
});

Expand Down
28 changes: 4 additions & 24 deletions src/components/vault-share/JoinLinksTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,29 +25,14 @@ interface Props {
canMint: boolean;
}

/**
* Open join links for a team vault (issue #68).
*
* Two honesty rules drive the whole layout:
*
* 1. A link confers **membership, not vault access**. The vault key is wrapped
* per member with X25519, so it cannot travel in a link — the joiner waits
* in `awaiting_key` until an online key-holder wraps it. The tab says so
* rather than letting a manager assume the link is a key.
* 2. The secret is returned by the mint call and **never again** — only its
* sha256 is stored. So a listed grant has no copyable URL, and this tab must
* not render a Copy button that would produce a broken link. Only the grant
* minted in this session, still held in memory, offers one.
*/
export function JoinLinksTab({ teamId, roles, canMint }: Props) {
const { t } = useTranslation();
const [grants, setGrants] = useState<JoinGrant[] | null>(null);
const [error, setError] = useState("");
const [minting, setMinting] = useState(false);
const [creating, setCreating] = useState(false);

// The URL for a grant minted in this session, keyed by grant id. Never
// persisted: writing a live join secret to disk would outlive the tab.
// In memory only: a live join secret must not outlive the tab.
const [freshLinks, setFreshLinks] = useState<Record<string, string>>({});
const [copiedId, setCopiedId] = useState<string | null>(null);

Expand All @@ -57,8 +42,7 @@ export function JoinLinksTab({ teamId, roles, canMint }: Props) {
const [ttlSecs, setTtlSecs] = useState<number>(TTL_PRESETS[2].secs);

useEffect(() => {
// Least privileged of what this team actually offers, never a hardcoded
// "member": a link is unattended credential material.
// Least privileged available, never a hardcoded "member".
if (!role && options.length > 0) setRole(options[options.length - 1].name as GrantableRole);
}, [options, role]);

Expand Down Expand Up @@ -93,12 +77,10 @@ export function JoinLinksTab({ teamId, roles, canMint }: Props) {
...prev,
[grant.id]: buildDeepLink({ route: "vault-join", grantId: grant.id, secret: grant.secret }),
}));
// Folded away again so the new link, which is only readable now, is what
// the tab shows rather than the form that made it.
setCreating(false);
reload();
} catch {
// runTeamAction already toasted the reason; the tab stays as it was.
// Already toasted.
} finally {
setMinting(false);
}
Expand Down Expand Up @@ -158,7 +140,6 @@ export function JoinLinksTab({ teamId, roles, canMint }: Props) {
</span>
<span
className="text-[11px] text-(--t-text-secondary) flex-1 min-w-0"
// The friendly count is rounded; this is the exact moment.
title={new Date(grant.expires_at).toLocaleString()}
>
{t("members.joinLinks.usesLeft", {
Expand Down Expand Up @@ -199,8 +180,7 @@ export function JoinLinksTab({ teamId, roles, canMint }: Props) {
</button>
</div>
) : (
// Not a failure — the server keeps only the secret's hash, so
// there is nothing to copy. Saying so beats a dead button.
// Only the hash is stored, so there is nothing to copy.
<span className="text-[10px] text-(--t-text-dim)">{t("members.joinLinks.secretNotRecoverable")}</span>
)}
</div>
Expand Down
15 changes: 4 additions & 11 deletions src/components/vault-share/PeopleList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export type Person = {
online: boolean;
state: "member" | "pending" | "awaiting_key";
invitationId?: string;
/** The member's X25519 public key, so "Grant now" can wrap the vault key. */
/** X25519 public key, so "Grant now" can wrap the vault key. */
publicKey?: string;
};

Expand All @@ -28,22 +28,15 @@ export function PeopleList({ people, canManage, onRemove, onRevoke, onGrantKey,
return (
<div className="flex flex-col gap-1.5">
{people.map((p) => (
// Two lines rather than one. At the popover's 320px, an untruncated
// handle plus a role chip plus "Grant now" plus remove squeezed the
// handle into a three-line column and the waiting note into a six-line
// one. The identity line now owns the full width; the waiting note and
// its action sit under it, and only for someone actually waiting.
<div
key={p.invitationId ?? p.userId}
className="flex flex-col gap-1 px-3 py-2 rounded-lg bg-(--t-bg-card)"
>
{/* Wraps rather than squeezes. An email address is long enough that
flexing it against the chips shredded it into a five-line column
at the popover's width; here the chips drop to the next line and
the identity keeps a readable floor. */}
{/* Wraps rather than squeezes: at the popover's width, flexing a long
handle against the chips shredded it into a five-line column. */}
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<PresenceAvatar handle={p.handle} size={26} online={p.online} />
{/* No truncation: the handle is what an owner reads before granting access. */}
{/* No truncation: read before granting access. */}
<span className="text-xs text-(--t-text-primary) break-all flex-1 basis-32 min-w-0">
{p.handle}
</span>
Expand Down
8 changes: 1 addition & 7 deletions src/components/vault-share/VaultShareSheet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,12 @@ const h = vi.hoisted(() => ({
revokeInvitation: vi.fn().mockResolvedValue(undefined),
grantVaultKeyToMember: vi.fn().mockResolvedValue(undefined),
writeClipboard: vi.fn().mockResolvedValue(undefined),
/** User ids the server reports as already holding a wrapped vault key. */
keyHolders: [] as string[],
}));

vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
vi.mock("@iconify/react", () => ({ Icon: () => null }));
// Exposes each row action as a button, so a handler wired to `() => {}` — which
// is how all four of these shipped — fails the test rather than passing it.
// A button per handler, so a no-op handler fails rather than passes.
vi.mock("./PeopleList", () => ({
PeopleList: ({
people,
Expand Down Expand Up @@ -139,7 +137,6 @@ test("Remove and Grant now run the real calls, not a no-op", () => {
teamId: "t1",
userId: "u1",
handle: "bob",
// Carried through from the roster, so granting needs no second fetch.
publicKey: "pk-bob",
});
});
Expand All @@ -157,7 +154,6 @@ test("Revoke runs against the invitation id, and does nothing without one", () =
name: "carol@example.com",
});

// A member row has no invitation to revoke.
fireEvent.click(screen.getByText("revoke:u1"));
expect(h.revokeInvitation).toHaveBeenCalledTimes(1);
});
Expand All @@ -168,8 +164,6 @@ test("only members missing from the key-holder list read as waiting", async () =
{ user_id: "u1", handle: "bob", role_ids: [], public_key: "pk1" },
{ user_id: "u2", handle: "carol", role_ids: [], public_key: "pk2" },
];
// The old code read the *viewer's* own vault status and applied it to
// everyone, so a reader who was waiting saw the whole roster as waiting.
h.keyHolders = ["u1"];
render(<VaultShareSheet vaultId="v1" variant="full" />);
await waitFor(() => expect(screen.getByText("waiting:u2")).toBeTruthy());
Expand Down
Loading