feat(invitations): shareable claim links and open-in-app on the claim page (#509)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-07-28 15:01:23 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 63e18cf37f
commit 95edf19389
6 changed files with 217 additions and 29 deletions
+4 -5
View File
@@ -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
}
+1
View File
@@ -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;
}
+49
View File
@@ -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();
});
});
+43
View File
@@ -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=<url>&token=<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)}`;
}
+33 -1
View File
@@ -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() {
</CardDescription>
</CardHeader>
<CardContent>
{appLink && (
<div className="mb-6 space-y-3">
<Button asChild size="lg" className="h-12 w-full text-base font-semibold">
<a href={appLink}>
<Smartphone className="mr-2 h-5 w-5" /> Open in the Silo app
</a>
</Button>
<p className="text-muted-foreground text-center text-xs">
Nothing happens? The app isn&apos;t installed — just continue below.
</p>
<div className="flex items-center gap-3">
<div className="border-border flex-1 border-t" />
<span className="text-muted-foreground text-xs uppercase">
or set up in the browser
</span>
<div className="border-border flex-1 border-t" />
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="invite-email">Email</Label>
@@ -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
/>
</div>
+87 -23
View File
@@ -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 (
<div className="min-w-0 space-y-4">
<div className="bg-muted min-w-0 overflow-hidden rounded-md p-2.5">
<code className="block truncate text-xs">{claimUrl}</code>
</div>
<p className="text-muted-foreground text-xs">{finePrint}</p>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => onCopy(claimUrl)}>
<Copy className="mr-1.5 h-4 w-4" /> Copy link
</Button>
<Button onClick={onDone}>Done</Button>
</div>
</div>
);
}
const STATUS_BADGES: Record<InvitationStatus, { label: string; variant: "default" | "outline" }> = {
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<Invitation | null>(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<SendInvitationResponse | null>(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 <div>Loading invitations...</div>;
return (
@@ -81,6 +122,32 @@ export default function InvitationsTab() {
}}
/>
<Dialog
open={resendResult !== null}
onOpenChange={(open) => {
if (!open) setResendResult(null);
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Fresh invitation link</DialogTitle>
<DialogDescription>
{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."}
</DialogDescription>
</DialogHeader>
{resendResult?.claim_url && (
<ClaimLinkBox
claimUrl={resendResult.claim_url}
finePrint="The link works once; any previous link for this invitation has stopped working."
onCopy={handleCopy}
onDone={() => setResendResult(null)}
/>
)}
</DialogContent>
</Dialog>
<div className="flex items-start justify-between gap-4">
<p className="text-muted-foreground max-w-xl text-sm">
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() {
<InvitationRow
key={inv.id}
invitation={inv}
onResend={() => 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<string | null>(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 (
<div className="space-y-4">
<div className="min-w-0 space-y-4">
<p className="text-sm">
Email isn&apos;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:"}
</p>
<div className="bg-muted flex items-center gap-2 rounded-md p-2">
<code className="min-w-0 flex-1 truncate text-xs">{manualLink}</code>
<Button variant="ghost" size="sm" onClick={() => onCopy(manualLink)}>
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="text-muted-foreground text-xs">
The link works once and expires in 7 days. Resending later mints a fresh link and kills
this one.
</p>
<div className="flex justify-end">
<Button onClick={onClose}>Done</Button>
</div>
<ClaimLinkBox
claimUrl={result.claimUrl}
finePrint="The link works once and expires in 7 days. Resending later mints a fresh link and kills this one."
onCopy={onCopy}
onDone={onClose}
/>
</div>
);
}