diff --git a/apps/web/src/components/ShareDialog.test.tsx b/apps/web/src/components/ShareDialog.test.tsx new file mode 100644 index 000000000..b3f873589 --- /dev/null +++ b/apps/web/src/components/ShareDialog.test.tsx @@ -0,0 +1,146 @@ +/** + * Tests for ShareDialog's failure-mode UX (exploration 0290): the no-hub + * dead-end becomes a "Connect a hub…" CTA that opens the status bar's + * connection panel, and private-hub links are labelled "Local only" with a + * confirm step before Copy/QR. + */ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryNodeStorageAdapter } from '@xnetjs/data' +import { generateIdentity } from '@xnetjs/identity' +import { XNetProvider } from '@xnetjs/react' +import React, { type ReactNode } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OPEN_SYNC_STATUS_EVENT } from '../workbench/SyncStatus' +import { ShareDialog } from './ShareDialog' + +function Wrapper({ children, hubUrl }: { children: ReactNode; hubUrl?: string }) { + const { identity, privateKey } = generateIdentity() + return ( + + {children} + + ) +} + +const LINK = { + linkId: 'lnk_local1', + docId: 'doc-1', + docType: 'page', + role: 'read', + label: 'LAN handoff', + expiresAt: 0, + maxUses: 0, + useCount: 0, + disabled: false, + createdBy: 'did:key:zCreator', + createdAt: 1_700_000_000_000 +} + +/** Hub stub serving one existing link and empty grants. */ +function stubHub() { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => { + const body = url.includes('/shares/links') + ? { links: [LINK] } + : url.includes('/shares/grants') + ? { grants: [] } + : {} + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + }) + ) +} + +afterEach(() => { + vi.unstubAllGlobals() + localStorage.clear() +}) + +describe('ShareDialog with no hub connected', () => { + it('offers a "Connect a hub…" CTA that closes the dialog and opens the sync panel', () => { + const onClose = vi.fn() + const onPanelOpen = vi.fn() + window.addEventListener(OPEN_SYNC_STATUS_EVENT, onPanelOpen) + try { + render( + + + + ) + + expect(screen.getByText(/no hub connected/i)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: /connect a hub/i })) + expect(onClose).toHaveBeenCalledTimes(1) + expect(onPanelOpen).toHaveBeenCalledTimes(1) + } finally { + window.removeEventListener(OPEN_SYNC_STATUS_EVENT, onPanelOpen) + } + }) +}) + +describe('ShareDialog on a private hub', () => { + const renderPrivate = async () => { + stubHub() + // The link URL (with secret) is only known from the creating device's cache. + localStorage.setItem(`xnet:share-link-url:${LINK.linkId}`, 'http://localhost:4444/s/x#s=y') + render( + + + + ) + await waitFor(() => expect(screen.getByText('LAN handoff')).toBeTruthy()) + } + + it('labels link URLs "Local only"', async () => { + await renderPrivate() + expect(screen.getByText('Local only')).toBeTruthy() + }) + + it('requires a confirm click before copying', async () => { + await renderPrivate() + const writeText = vi.fn().mockResolvedValue(undefined) + Object.assign(navigator, { clipboard: { writeText } }) + + const copy = screen.getByRole('button', { name: /copy/i }) + fireEvent.click(copy) + // First click arms the button instead of copying. + expect(writeText).not.toHaveBeenCalled() + expect(screen.getByText('Copy anyway')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: /copy anyway/i })) + expect(writeText).toHaveBeenCalledWith('http://localhost:4444/s/x#s=y') + await waitFor(() => expect(screen.getByText('Copied')).toBeTruthy()) + }) +}) + +describe('ShareDialog on a public hub', () => { + it('copies without a confirm step and shows no "Local only" label', async () => { + stubHub() + localStorage.setItem(`xnet:share-link-url:${LINK.linkId}`, 'https://hub.example/s/x#s=y') + render( + + + + ) + await waitFor(() => expect(screen.getByText('LAN handoff')).toBeTruthy()) + expect(screen.queryByText('Local only')).toBeNull() + + const writeText = vi.fn().mockResolvedValue(undefined) + Object.assign(navigator, { clipboard: { writeText } }) + fireEvent.click(screen.getByRole('button', { name: /copy/i })) + expect(writeText).toHaveBeenCalledWith('https://hub.example/s/x#s=y') + await waitFor(() => expect(screen.getByText('Copied')).toBeTruthy()) + }) +}) diff --git a/apps/web/src/components/ShareDialog.tsx b/apps/web/src/components/ShareDialog.tsx index 1c9e077f4..0b8105964 100644 --- a/apps/web/src/components/ShareDialog.tsx +++ b/apps/web/src/components/ShareDialog.tsx @@ -28,6 +28,7 @@ import { type ShareRole } from '../hooks/useShareLinks' import { isPrivateHubHost } from '../lib/share-links' +import { openSyncStatusPanel } from '../workbench/SyncStatus' import { PermissionMatrixPanel } from './PermissionMatrixPanel' interface ShareDialogProps { @@ -76,24 +77,62 @@ function RoleChip({ role }: { role: ShareRole }): JSX.Element { ) } -function CopyButton({ value }: { value: string }): JSX.Element { +/** Amber "Local only" badge for links minted on a private/LAN hub (0290). */ +function LocalOnlyChip(): JSX.Element { + return ( + + Local only + + ) +} + +/** + * `requireConfirm` (private-hub links, 0290) arms the button on the first + * click — "Copy anyway" — so a local-only URL isn't copied as if it were + * shareable, while deliberate LAN/in-person handoff stays one extra click. + */ +function CopyButton({ + value, + requireConfirm = false +}: { + value: string + requireConfirm?: boolean +}): JSX.Element { const [copied, setCopied] = useState(false) + const [armed, setArmed] = useState(false) return ( ) } @@ -125,6 +164,8 @@ function ShareDialogBody({ const [actionError, setActionError] = useState(null) const [freshUrl, setFreshUrl] = useState(null) const [qrDataUrl, setQrDataUrl] = useState(null) + // Private-hub QR needs the same "are you sure" arming as Copy (0290). + const [qrArmed, setQrArmed] = useState(false) // In-person / P2P handoff: render the same URL as a QR code so a phone // camera can claim without any messaging channel. @@ -236,7 +277,24 @@ function ShareDialogBody({
{!ready && tab !== 'permissions' && ( -

Connect to a hub to create share links.

+
+

+ Share links are claimed on a hub, so recipients can open them even when this device + is offline. You're running local-first with no hub connected. +

+ +
)} {ready && privateHub && ( @@ -306,8 +364,11 @@ function ShareDialogBody({ {freshUrl && (
-

- Link created — copy it now. The secret is only stored on this device. +

+ + Link created — copy it now. The secret is only stored on this device. + + {privateHub && }

(e.target as HTMLInputElement).select()} className="flex-1 px-2 py-1 text-[11px] font-mono bg-secondary border border-border rounded text-foreground" /> - +
{qrDataUrl && ( @@ -370,6 +447,7 @@ function ShareDialogBody({ {link.label || `Link ${link.linkId.slice(0, 6)}`} + {privateHub && link.url && !link.disabled && }

{link.useCount} @@ -379,7 +457,9 @@ function ShareDialogBody({ {link.disabled ? ' · disabled' : ''}

- {link.url && !link.disabled && } + {link.url && !link.disabled && ( + + )} + + {invalid && Enter a ws(s):// or http(s):// hub URL} + + ) +} + /** * The deep detail panel shared by the desktop popover and the mobile sheet. * Presentation over the sync vitals + durable-storage status, plus Reconcile. @@ -89,6 +161,8 @@ export function SystemInfoDetails({ vitals }: { vitals: SyncVitals }) { {storageValue && } + {(vitals.hub === 'disconnected' || !vitals.hasSyncManager) && } + {vitals.integrityAlert && vitals.verificationFailure && (
@@ -121,13 +195,15 @@ export function SyncStatus() { const vitals = useSyncVitals() const [open, setOpen] = useState(false) const containerRef = useRef(null) + const popoverRef = useRef(null) + useOpenSyncStatusEvent(setOpen) useEffect(() => { if (!open) return const onPointerDown = (event: PointerEvent) => { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setOpen(false) - } + const target = event.target as Node + if (containerRef.current?.contains(target) || popoverRef.current?.contains(target)) return + setOpen(false) } const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') setOpen(false) @@ -181,18 +257,51 @@ export function SyncStatus() { )} {open && ( -
- -
+ )}
) } +/** + * The desktop detail popover, portaled out of the status bar. The floating + * islands shell (0286) clips the bar island with `overflow-hidden`, which + * swallowed an in-place `absolute bottom-full` popover entirely — so this + * renders fixed-position above the chip instead. Portal target is the shell + * root, not `body`: dark-mode tokens resolve via `.dark .wb-root` descendant + * selectors (0286) and would go dead outside it. + */ +function SyncStatusPopover({ + anchorRef, + popoverRef, + vitals +}: { + anchorRef: React.RefObject + popoverRef: React.MutableRefObject + vitals: SyncVitals +}) { + const [anchor] = useState(() => { + const rect = anchorRef.current?.getBoundingClientRect() + return rect + ? { left: rect.left, bottom: window.innerHeight - rect.top + 6 } + : { left: 8, bottom: 44 } + }) + const portalTarget = document.querySelector('.wb-root') ?? document.body + + return createPortal( +
+ +
, + portalTarget + ) +} + /** * Mobile health glyph for the top bar: the same coarse dot, tapping it opens a * bottom Sheet with the shared detail content. Mobile has no status bar, so this @@ -201,6 +310,7 @@ export function SyncStatus() { export function MobileSyncGlyph() { const vitals = useSyncVitals() const [open, setOpen] = useState(false) + useOpenSyncStatusEvent(setOpen) const chip = CHIP[vitals.state] return ( diff --git a/docs/explorations/0290_[_]_SHARE_LINK_GENERATION_FAILURE_MODES.md b/docs/explorations/0290_[_]_SHARE_LINK_GENERATION_FAILURE_MODES.md index e535cab30..d3a334487 100644 --- a/docs/explorations/0290_[_]_SHARE_LINK_GENERATION_FAILURE_MODES.md +++ b/docs/explorations/0290_[_]_SHARE_LINK_GENERATION_FAILURE_MODES.md @@ -18,7 +18,7 @@ no errors. The real failures are elsewhere: the edge, **before** the hub's `cors()` runs, so it carries **no `Access-Control-Allow-Origin` header** — which is precisely what turns a CORS-preflighted `POST /shares/links` into a browser `TypeError: Failed to - fetch`. Fix is operational (restart/redeploy the hub), not code. See +fetch`. Fix is operational (restart/redeploy the hub), not code. See [Production Outage](#production-outage-hubxnetfyi-returns-502--the-failed-to-fetch-report). 1. **Sharing a _workspace/bench_ is broken** — the client sends `docType: 'workspace'`, which the hub rejects with `400 INVALID_BODY` @@ -32,15 +32,15 @@ no errors. The real failures are elsewhere: ## Executive Summary -| Scenario | Result | Root cause | -| --- | --- | --- | -| **`xnet.fyi/app` → `hub.xnet.fyi` (the reported case)** | ❌ **`Failed to fetch`** | **Hub is down — Railway edge returns 502 with no CORS headers** | -| Share a **page** (hub connected, you own the doc) | ✅ works | — | -| Share a **database / canvas / dashboard / view / space** | ✅ works | — | -| Share a **workspace / bench** | ❌ `400 INVALID_BODY` | Hub `SHARE_DOC_TYPES` omits `'workspace'` | -| Share **anything with no hub connected** | ⚠️ blocked, no CTA | Local-first default; dialog dead-ends | -| Share from a **`localhost`/LAN hub** | ⚠️ link unusable off-machine | Private hub host in the URL | -| Share a doc **owned by another DID** (search-index recorded owner) | ❌ `403 FORBIDDEN` | `canManageShares` owner check | +| Scenario | Result | Root cause | +| ------------------------------------------------------------------ | ---------------------------- | --------------------------------------------------------------- | +| **`xnet.fyi/app` → `hub.xnet.fyi` (the reported case)** | ❌ **`Failed to fetch`** | **Hub is down — Railway edge returns 502 with no CORS headers** | +| Share a **page** (hub connected, you own the doc) | ✅ works | — | +| Share a **database / canvas / dashboard / view / space** | ✅ works | — | +| Share a **workspace / bench** | ❌ `400 INVALID_BODY` | Hub `SHARE_DOC_TYPES` omits `'workspace'` | +| Share **anything with no hub connected** | ⚠️ blocked, no CTA | Local-first default; dialog dead-ends | +| Share from a **`localhost`/LAN hub** | ⚠️ link unusable off-machine | Private hub host in the URL | +| Share a doc **owned by another DID** (search-index recorded owner) | ❌ `403 FORBIDDEN` | `canManageShares` owner check | I verified the production row and rows 2–6 directly (curl against `hub.xnet.fyi`; Playwright against the running app; Node probes against a @@ -116,7 +116,7 @@ active user filled the 500 MB Railway volume with >1 GB of `node_changes` data — and a full SQLite volume crashes the hub on the next write/boot, producing exactly this 502. Restart, but also **truncate the demo volume** and fix the guardrails. Full analysis and fixes: -[0291_[_]_DEMO_HUB_RUNAWAY_STORAGE…](0291_%5B_%5D_DEMO_HUB_RUNAWAY_STORAGE_QUOTA_AND_EVICTION_NOT_ENFORCED.md). +[0291*[*]\_DEMO_HUB_RUNAWAY_STORAGE…](0291_%5B_%5D_DEMO_HUB_RUNAWAY_STORAGE_QUOTA_AND_EVICTION_NOT_ENFORCED.md). ### What this changes about the diagnosis @@ -224,7 +224,7 @@ client, never completed on the hub or the claim/route side). (`title="Hub: disconnected · local-ready"`, opens a dialog), but the Share dialog never points there. - If a token is somehow empty, `hubApiFetch` still fires `Authorization: - Bearer ` and the hub returns `401 UNAUTHORIZED` +Bearer ` and the hub returns `401 UNAUTHORIZED` (`packages/hub/src/auth/ucan.ts`). ### The private-hub link (bug #3) @@ -310,33 +310,33 @@ flowchart TD ### Bug #1 — `workspace` docType -| Option | What | Tradeoff | -| --- | --- | --- | +| Option | What | Tradeoff | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **A. Add `'workspace'` to the hub allow-list** (recommended) | Add to `SHARE_DOC_TYPES` (`share-links.ts:35`), the claim `ShareClaimResult['docType']` union, and a `docRouteFor` case. | Small, closes the drift; must also confirm the claim → route → sync path resolves a workspace node. Protocol-surface change → **minor** changeset for `@xnetjs/hub`? No: the hub isn't a publishable lib boundary here, but the accepted-values change is consumer-visible — bump per the diff. | -| **B. Remove `'workspace'` from the client** | Drop the union member + the `WorkspaceSwitcher` Share entry until the hub supports it. | Fastest to stop the error, but removes a feature 0280 intended to ship. | -| **C. Generic "shareable node" type** | Collapse doc-type validation to "is this a shareable node id?" and carry type as advisory. | Bigger refactor; loses the type-scoped routing/role semantics (esp. `space` subtree grants). | +| **B. Remove `'workspace'` from the client** | Drop the union member + the `WorkspaceSwitcher` Share entry until the hub supports it. | Fastest to stop the error, but removes a feature 0280 intended to ship. | +| **C. Generic "shareable node" type** | Collapse doc-type validation to "is this a shareable node id?" and carry type as advisory. | Bigger refactor; loses the type-scoped routing/role semantics (esp. `space` subtree grants). | ### Bug #2 — no-hub dead-end -| Option | What | Tradeoff | -| --- | --- | --- | -| **A. In-dialog "Connect a hub" CTA** (recommended) | When `!ready`, render a button that opens the existing hub-connection dialog (the status-bar chip target). | Small UX add; turns a dead-end into a path. | -| **B. Ship a default hub** | Point `VITE_HUB_URL` at a managed hub for hosted builds. | Changes the local-first promise; only for the hosted app, gated on consent. | -| **C. Explain-only** | Improve copy ("Sharing needs a hub because links are claimed server-side…"). | Cheap, but still no action. | +| Option | What | Tradeoff | +| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| **A. In-dialog "Connect a hub" CTA** (recommended) | When `!ready`, render a button that opens the existing hub-connection dialog (the status-bar chip target). | Small UX add; turns a dead-end into a path. | +| **B. Ship a default hub** | Point `VITE_HUB_URL` at a managed hub for hosted builds. | Changes the local-first promise; only for the hosted app, gated on consent. | +| **C. Explain-only** | Improve copy ("Sharing needs a hub because links are claimed server-side…"). | Cheap, but still no action. | ### Bug #3 — private-hub links -| Option | What | Tradeoff | -| --- | --- | --- | +| Option | What | Tradeoff | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | | **A. Escalate the warning + gate copy** (recommended) | Keep generating (LAN sharing is legitimate) but label the URL "Local only" and require a confirm to copy/QR. | Preserves LAN use; prevents "why doesn't my link work" confusion. | -| **B. Block generation on private hubs** | Refuse to mint when `isPrivateHubHost`. | Breaks legitimate same-LAN/in-person QR handoff. | +| **B. Block generation on private hubs** | Refuse to mint when `isPrivateHubHost`. | Breaks legitimate same-LAN/in-person QR handoff. | ### Bug #4 — ownership edges -| Option | What | Tradeoff | -| --- | --- | --- | -| **A. Record `ownerDid` on first node write, not just index** | Have node-relay stamp `docMeta.ownerDid` when a doc is first seen. | Closes the "anyone can mint links for any docId" hole; must handle legacy docs and multi-writer docs carefully. | -| **B. Better `403` copy + self-heal for identity rotation** | Detect owner-mismatch and offer recovery-phrase re-link / admin-grant path. | Narrow; doesn't fix the over-permissive default. | +| Option | What | Tradeoff | +| ------------------------------------------------------------ | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| **A. Record `ownerDid` on first node write, not just index** | Have node-relay stamp `docMeta.ownerDid` when a doc is first seen. | Closes the "anyone can mint links for any docId" hole; must handle legacy docs and multi-writer docs carefully. | +| **B. Better `403` copy + self-heal for identity rotation** | Detect owner-mismatch and offer recovery-phrase re-link / admin-grant path. | Narrow; doesn't fix the over-permissive default. | ## Recommendation @@ -369,7 +369,13 @@ Bug #1 — hub allow-list (`packages/hub/src/routes/share-links.ts:35`): ```ts // Add 'workspace' (saved shell layouts / benches — exploration 0280). const SHARE_DOC_TYPES = [ - 'page', 'database', 'canvas', 'dashboard', 'view', 'space', 'workspace' + 'page', + 'database', + 'canvas', + 'dashboard', + 'view', + 'space', + 'workspace' ] as const ``` @@ -391,13 +397,20 @@ case 'workspace': Bug #2 — Share dialog CTA (`apps/web/src/components/ShareDialog.tsx:238`): ```tsx -{!ready && tab !== 'permissions' && ( -
-

Share links are claimed on a hub — connect one to create links.

- -
-)} +{ + !ready && tab !== 'permissions' && ( +
+

Share links are claimed on a hub — connect one to create links.

+ +
+ ) +} ``` ## Risks And Open Questions @@ -427,8 +440,8 @@ Bug #2 — Share dialog CTA (`apps/web/src/components/ShareDialog.tsx:238`): - [x] Extend `ShareClaimResult['docType']` union in `apps/web/src/lib/share-links.ts`. - [x] Add a `'workspace'` case to `docRouteFor` (route target for a claimed bench). - [ ] Verify the recipient's claim opens the workspace node (sync + route) end-to-end. -- [ ] Add an in-dialog "Connect a hub" CTA to `ShareDialog` when `!ready`, wired to the existing hub-connection dialog. -- [ ] Label private-hub links "Local only" and add a copy/QR confirm (keep LAN sharing). +- [x] Add an in-dialog "Connect a hub" CTA to `ShareDialog` when `!ready`, wired to the existing hub-connection dialog (the status-bar connection panel, which now carries an inline hub-URL connect form). +- [x] Label private-hub links "Local only" and add a copy/QR confirm (keep LAN sharing). - [ ] (Follow-up) Decide `ownerDid`-on-first-write vs. permissive default; write a hub test for the chosen behaviour. - [x] Add a hub test asserting every `ShareDocType` value returns 200 from `POST /shares/links`. - [x] Write the changeset(s) reflecting the `docType`/union contract change. diff --git a/site/src/data/changelog/2026-07-10-share-dialog-connect-a-hub-in-place-and-.json b/site/src/data/changelog/2026-07-10-share-dialog-connect-a-hub-in-place-and-.json new file mode 100644 index 000000000..0c3da791b --- /dev/null +++ b/site/src/data/changelog/2026-07-10-share-dialog-connect-a-hub-in-place-and-.json @@ -0,0 +1,11 @@ +{ + "id": "2026-07-10-share-dialog-connect-a-hub-in-place-and-", + "date": "July 10, 2026", + "title": "Share dialog: connect a hub in place, and clearer local-only links", + "summary": "With no hub connected, the Share dialog now offers a Connect a hub button that opens the status bar's connection panel — which can now actually connect one. Links minted on a localhost/LAN hub are labelled Local only, and Copy/QR ask for a confirming click so a link that only works on your network isn't shared as if it were public.", + "highlights": [ + "Connect a hub straight from the Share dialog", + "Local-only links are labelled and confirm before copy/QR" + ], + "tags": ["app", "sync"] +}