"use client"; import { Suspense, useEffect, useState } from "react"; import Image from "next/image"; import { useSearchParams } from "next/navigation"; import { AlertTriangle, Loader2 } from "lucide-react"; import { getLinkedRepoInvite, type LinkedRepoInviteInfo } from "@/lib/api"; import { describeScope } from "@/lib/linked-repo-scopes"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from "@/components/ui/empty"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || ""; function LinkStartInner() { const searchParams = useSearchParams(); const token = searchParams.get("token") ?? ""; const [info, setInfo] = useState(null); // Distinguishes "the server told us this token is dead" from "we couldn't // even reach the server" — the former has no useful retry, the latter // might just be a network blip. const [fetchFailed, setFetchFailed] = useState(false); const [handle, setHandle] = useState(""); const [retryCount, setRetryCount] = useState(0); useEffect(() => { if (!token) return; let cancelled = false; // Deliberately no synchronous setState reset here — React's compiler flags // that as a cascading render. The retry button clears `fetchFailed` itself // before bumping `retryCount`, which is the only way this effect re-runs. getLinkedRepoInvite(token) .then((data) => { if (!cancelled) setInfo(data); }) .catch(() => { if (!cancelled) setFetchFailed(true); }); return () => { cancelled = true; }; }, [token, retryCount]); function handleContinue() { if (!info) return; const params = new URLSearchParams({ token }); const chosenHandle = info.pinned_identifier ?? handle.trim(); if (chosenHandle) params.set("handle", chosenHandle); window.location.assign( `${BASE_PATH}/auth/linked-repo/start?${params.toString()}`, ); } // No token at all — this link can never resolve to anything, so treat it // the same as a token the server has already rejected. if (!token || (info && !info.valid)) { return ( This link is no longer usable It may have expired, already been used, or been revoked. Ask whoever sent it to you for a new one — retrying this one won't help. ); } if (fetchFailed) { return ( Couldn't load this link Something went wrong reaching the server. Check your connection and try again. ); } if (!info) { return (
); } const descriptions = info.scopes.map(describeScope); const capabilities = descriptions.filter((d) => !d.quiet); const hasBasicAccess = descriptions.some((d) => d.quiet); const canContinue = Boolean(info.pinned_identifier) || handle.trim().length > 0; return (
{info.logo_url && ( )}
{info.app_name} is asking for access to your ATProto repo
{info.reason && (

Reason

{/* Stacked rather than inline: this is the admin's own prose and can run to several lines, so it needs room to breathe. `whitespace- pre-line` keeps any line breaks they typed. */}

{info.reason}

)}

What this will let {info.app_name} do

{capabilities.length === 0 ? (

No specific record or file access was requested.

) : (
    {capabilities.map((cap) => (
  • {cap.text} {cap.raw}
  • ))}
)} {hasBasicAccess && (

Basic account access is also included — that only identifies which account is linked, it isn't a separate permission.

)}
{info.pinned_identifier ? ( <>

{info.pinned_identifier}

This link is only valid for this account.

) : ( <> setHandle(e.target.value)} placeholder="you.bsky.social" autoComplete="off" /> )}
{info.expires_at && (

This link expires {new Date(info.expires_at).toLocaleString()}.

)}

Continuing takes you to your own PDS to approve this. {info.app_name}{" "} never sees your password.

); } function LinkStartFallback() { return ( Loading… ); } export default function LinkStartPage() { return (
}>
); }