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. -

-
- -
+
); }