From 95edf193898ac230b671af466edda268a19563ba Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:01:23 -0400 Subject: [PATCH] feat(invitations): shareable claim links and open-in-app on the claim page (#509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(invitations): always return the claim link so admins can share it directly The claim URL was only surfaced when email sending failed. Admins who want to hand the link over another channel (chat, SMS) had no way to get it — and the raw token exists only in the send/resend response, since the server stores just its hash. The create and resend flows now always include claim_url (additive on /api/v1), and the admin UI keeps the dialog open after either action with the link and a labeled Copy button. Truncation and stacked buttons keep the unbreakable URL from forcing horizontal scroll on phone widths. Co-Authored-By: Claude Fable 5 * feat(web): offer to open invite claims in the Android app The Android app already registers silo://invite?server=...&token=... with a full native claim flow, but nothing ever emitted that link — an https invite always ended in the browser. On Android user agents the claim page now leads with a prominent 'Open in the Silo app' button carrying that deep link, with the web form kept below as the fallback ('or set up in the browser'). The button is a plain anchor: a user-tapped custom-scheme link is the one reliable path, and we never fire it automatically since there is no installed-check and a miss surfaces an OS error. The password field's autofocus is suppressed alongside it so the keyboard doesn't push the button off screen. iOS is excluded until the Apple app registers the scheme. The server origin travels in the server param verbatim, so non-443 ports and plain-http LAN servers need no extra convention. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- internal/api/handlers/admin_invitations.go | 9 +- web/src/api/types.ts | 1 + web/src/lib/appDeepLink.test.ts | 49 ++++++++ web/src/lib/appDeepLink.ts | 43 +++++++ web/src/pages/InviteClaim.tsx | 34 +++++- .../pages/admin-settings/InvitationsTab.tsx | 110 ++++++++++++++---- 6 files changed, 217 insertions(+), 29 deletions(-) create mode 100644 web/src/lib/appDeepLink.test.ts create mode 100644 web/src/lib/appDeepLink.ts diff --git a/internal/api/handlers/admin_invitations.go b/internal/api/handlers/admin_invitations.go index 92c81251..f335b2de 100644 --- a/internal/api/handlers/admin_invitations.go +++ b/internal/api/handlers/admin_invitations.go @@ -57,8 +57,9 @@ type invitationResponse struct { type sendInvitationResponse struct { Invitation invitationResponse `json:"invitation"` EmailSent bool `json:"email_sent"` - // ClaimURL is only populated when email did not send, so the admin can - // deliver the link manually. It embeds the single-use token. + // ClaimURL embeds the single-use token, so this response is the only + // chance to read it — the server keeps just the hash. Returned even when + // the email sent, so the admin can also deliver the link directly. ClaimURL string `json:"claim_url,omitempty"` } @@ -188,9 +189,7 @@ func buildSendResponse(result *invitations.SendResult) sendInvitationResponse { resp := sendInvitationResponse{ Invitation: toInvitationResponse(result.Invitation, time.Now()), EmailSent: result.EmailSent, - } - if !result.EmailSent { - resp.ClaimURL = result.ClaimURL + ClaimURL: result.ClaimURL, } return resp } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 6b54c37d..211ec81e 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -4129,6 +4129,7 @@ export interface CreateInvitationRequest { export interface SendInvitationResponse { invitation: Invitation; email_sent: boolean; + /** Only readable in this response — the server stores just the token hash. */ claim_url?: string; } diff --git a/web/src/lib/appDeepLink.test.ts b/web/src/lib/appDeepLink.test.ts new file mode 100644 index 00000000..45f19144 --- /dev/null +++ b/web/src/lib/appDeepLink.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { buildInviteDeepLink, detectMobilePlatform } from "./appDeepLink"; + +describe("detectMobilePlatform", () => { + it("detects Android", () => { + expect( + detectMobilePlatform("Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36"), + ).toBe("android"); + }); + + it("detects iPhone and iPad", () => { + expect(detectMobilePlatform("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)")).toBe( + "ios", + ); + expect(detectMobilePlatform("Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X)")).toBe("ios"); + }); + + it("returns null for desktop browsers", () => { + expect(detectMobilePlatform("Mozilla/5.0 (Windows NT 10.0; Win64; x64)")).toBeNull(); + expect(detectMobilePlatform("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)")).toBeNull(); + expect(detectMobilePlatform("Mozilla/5.0 (X11; Linux x86_64)")).toBeNull(); + }); +}); + +describe("buildInviteDeepLink", () => { + it("emits the silo://invite contract the Android app registers", () => { + expect(buildInviteDeepLink("https://silo.arkyncdn.net", "wIAUTS99-abc")).toBe( + "silo://invite?server=https%3A%2F%2Fsilo.arkyncdn.net&token=wIAUTS99-abc", + ); + }); + + it("keeps a non-default port inside the server origin", () => { + expect(buildInviteDeepLink("https://silo.example.net:8443", "t")).toBe( + "silo://invite?server=https%3A%2F%2Fsilo.example.net%3A8443&token=t", + ); + }); + + it("carries plain-http LAN origins verbatim", () => { + expect(buildInviteDeepLink("http://192.168.1.10:8090", "t")).toBe( + "silo://invite?server=http%3A%2F%2F192.168.1.10%3A8090&token=t", + ); + }); + + it("rejects unrepresentable origins", () => { + expect(buildInviteDeepLink("not a url", "t")).toBeNull(); + expect(buildInviteDeepLink("ftp://silo.example.net", "t")).toBeNull(); + expect(buildInviteDeepLink("https://user:pw@silo.example.net", "t")).toBeNull(); + }); +}); diff --git a/web/src/lib/appDeepLink.ts b/web/src/lib/appDeepLink.ts new file mode 100644 index 00000000..03d34af0 --- /dev/null +++ b/web/src/lib/appDeepLink.ts @@ -0,0 +1,43 @@ +/** + * Deep links into the native Silo apps via the silo:// custom scheme. + * + * Silo is self-hosted, so the store apps cannot pre-verify every server's + * domain for App Links / Universal Links; a custom scheme is the only + * universal way in. The Android app already registers + * `silo://invite?server=&token=` (see silo-android + * InviteClaimRouteParser.kt and its navDeepLink) — this module emits that + * exact contract, with `server` carrying the full origin so non-443 ports + * and plain-http LAN servers need no extra convention. + * + * Custom-scheme URLs don't linkify in email or SMS and error when the app + * is missing, so they are never sent anywhere: they only back an explicit + * in-page button, rendered on platforms with a native app. + */ + +export type MobilePlatform = "android" | "ios"; + +/** Detects a platform with a native Silo app from the user agent. */ +export function detectMobilePlatform(ua: string): MobilePlatform | null { + // iPadOS 13+ Safari masquerades as macOS; maxTouchPoints tells it apart, + // but that's a live-DOM concern — callers pass a UA and we keep this pure. + if (/android/i.test(ua)) return "android"; + if (/iphone|ipad|ipod/i.test(ua)) return "ios"; + return null; +} + +/** + * Builds the silo:// deep link that opens the native invite claim flow. + * Returns null for origins the apps can't talk to (non-http(s), userinfo). + */ +export function buildInviteDeepLink(pageOrigin: string, token: string): string | null { + let origin: URL; + try { + origin = new URL(pageOrigin); + } catch { + return null; + } + if (origin.username || origin.password) return null; + if (origin.protocol !== "https:" && origin.protocol !== "http:") return null; + const server = encodeURIComponent(origin.origin); + return `silo://invite?server=${server}&token=${encodeURIComponent(token)}`; +} diff --git a/web/src/pages/InviteClaim.tsx b/web/src/pages/InviteClaim.tsx index 41e69908..b74e831a 100644 --- a/web/src/pages/InviteClaim.tsx +++ b/web/src/pages/InviteClaim.tsx @@ -12,6 +12,8 @@ import { Label } from "@/components/ui/label"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { AuthBackground } from "@/components/auth/AuthBackground"; import { clearHouseholdSetupDone, setTourSuppressed } from "@/lib/onboarding"; +import { buildInviteDeepLink, detectMobilePlatform } from "@/lib/appDeepLink"; +import { Smartphone } from "lucide-react"; import { toast } from "sonner"; /** @@ -83,6 +85,15 @@ export default function InviteClaim() { const invitation = lookup.data; + // On Android, offer to continue in the native app — the app registers + // silo://invite and has the full claim flow. A user-tapped custom-scheme + // link is the one context where silo:// works reliably; we never fire it + // automatically (there is no installed-check, and a miss shows an OS + // error). iOS joins once the Apple app registers the scheme. + const platform = detectMobilePlatform(navigator.userAgent); + const appLink = + platform === "android" ? buildInviteDeepLink(window.location.origin, token) : null; + async function handleSubmit(e: FormEvent) { e.preventDefault(); if (password !== confirmPassword) { @@ -130,6 +141,25 @@ export default function InviteClaim() { + {appLink && ( +
+ +

+ Nothing happens? The app isn't installed — just continue below. +

+
+
+ + or set up in the browser + +
+
+
+ )}
@@ -143,7 +173,9 @@ export default function InviteClaim() { value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" - autoFocus + // On mobile, focusing here pops the keyboard over the + // open-in-app button — the primary action when it's shown. + autoFocus={!appLink} required />
diff --git a/web/src/pages/admin-settings/InvitationsTab.tsx b/web/src/pages/admin-settings/InvitationsTab.tsx index e9784770..46cb98af 100644 --- a/web/src/pages/admin-settings/InvitationsTab.tsx +++ b/web/src/pages/admin-settings/InvitationsTab.tsx @@ -43,6 +43,36 @@ import { Copy, MailPlus, RotateCw, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { formatDate } from "@/lib/datetime"; +// The claim-link box shown after create/resend. min-w-0 + overflow-hidden on +// every level matters: the URL is one unbreakable token, and without them it +// forces the dialog wider than the viewport on phones. +function ClaimLinkBox({ + claimUrl, + finePrint, + onCopy, + onDone, +}: { + claimUrl: string; + finePrint: string; + onCopy: (text: string) => void; + onDone: () => void; +}) { + return ( +
+
+ {claimUrl} +
+

{finePrint}

+
+ + +
+
+ ); +} + const STATUS_BADGES: Record = { pending: { label: "Sent", variant: "default" }, accepted: { label: "Accepted", variant: "outline" }, @@ -56,12 +86,23 @@ export default function InvitationsTab() { const revoke = useRevokeInvitation(); const [createOpen, setCreateOpen] = useState(false); const [confirmRevoke, setConfirmRevoke] = useState(null); + // A resend mints a fresh single-use link; the response is the only chance + // to read it, so we offer it for copying right away. + const [resendResult, setResendResult] = useState(null); function handleCopy(text: string) { navigator.clipboard.writeText(text); toast.success("Copied to clipboard"); } + function handleResend(id: number) { + resend.mutate(id, { + onSuccess: (data) => { + if (data.claim_url) setResendResult(data); + }, + }); + } + if (isLoading) return
Loading invitations...
; return ( @@ -81,6 +122,32 @@ export default function InvitationsTab() { }} /> + { + if (!open) setResendResult(null); + }} + > + + + Fresh invitation link + + {resendResult?.email_sent + ? `Emailed to ${resendResult.invitation.email}. You can also copy the link and send it to them directly.` + : "Email isn't configured on this server, so nothing was sent — deliver this link yourself."} + + + {resendResult?.claim_url && ( + setResendResult(null)} + /> + )} + + +

Email someone a personal link. Their access is set here, so all they choose is a password @@ -125,7 +192,7 @@ export default function InvitationsTab() { resend.mutate(inv.id)} + onResend={() => handleResend(inv.id)} onRevoke={() => setConfirmRevoke(inv)} resending={resend.isPending} /> @@ -215,9 +282,10 @@ function CreateInvitationForm({ const [note, setNote] = useState(""); const [createProfile, setCreateProfile] = useState(true); const [showTour, setShowTour] = useState(true); - // When email isn't configured the server returns the claim URL instead of - // sending; we keep the dialog open and show it for manual delivery. - const [manualLink, setManualLink] = useState(null); + // After creation we keep the dialog open to show the claim link — the + // token is only readable in this response, so this is the one chance to + // copy it. emailSent changes the copy: delivered vs deliver-it-yourself. + const [result, setResult] = useState<{ claimUrl: string; emailSent: boolean } | null>(null); const defaultGroup = useMemo(() => accessGroups.find((g) => g.is_default), [accessGroups]); @@ -237,35 +305,31 @@ function CreateInvitationForm({ onSuccess: (data: SendInvitationResponse) => { if (data.email_sent) { toast.success(`Invitation sent to ${data.invitation.email}`); + } + if (data.claim_url) { + setResult({ claimUrl: data.claim_url, emailSent: data.email_sent }); + } else { onClose(); - } else if (data.claim_url) { - setManualLink(data.claim_url); } }, }, ); } - if (manualLink) { + if (result) { return ( -

+

- Email isn't configured on this server, so nothing was sent. The invitation was - created — deliver this link yourself: + {result.emailSent + ? "Invitation emailed. You can also copy the link and send it to them directly:" + : "Email isn't configured on this server, so nothing was sent. The invitation was created — deliver this link yourself:"}

-
- {manualLink} - -
-

- The link works once and expires in 7 days. Resending later mints a fresh link and kills - this one. -

-
- -
+
); }