import { useQuery } from "@tanstack/react-query"; import { useLocation, useRouter } from "@tanstack/react-router"; import { getAccountNotices } from "~/fns/notices"; import type { AccountNotice, QuotaNotice } from "~/fns/notices"; import { useEffect, useState } from "react"; import { useEmailUnverified, useViewer } from "~/hooks/use-viewer"; import { useAction } from "~/hooks/use-action"; import { resendVerificationEmail } from "~/fns/account-security"; import { VERIFY_EMAIL_PATH } from "~/lib/email-verification"; import { NagBanner } from "~/ui/NagBanner"; import { safeDecode } from "~/lib/safe-decode"; const NOTICE_STALE_MS = 5 * 60 * 1000; export const ACCOUNT_NOTICES_KEY = "account-notices"; function QuotaBanner({ notice }: { notice: QuotaNotice }) { const whose = notice.accountKind === "org" ? notice.accountName : "You"; const title = notice.spend ? `${whose} reached ${notice.accountKind === "org" ? "its" : "your"} monthly spending limit` : `${whose} used all of ${notice.accountKind === "org" ? "its" : "your"} ${notice.plan} ${notice.meterLabel.toLowerCase()} this month`; const body = notice.spend ? "Usage that costs money is paused until next month or until the limit is raised." : notice.canUpgrade ? `Upgrade to Pro for more, or buy prepaid credit${notice.rate ? ` (${notice.rate})` : ""}.` : notice.rate ? `More is ${notice.rate}; add credit or raise the spending limit.` : "It resets at the start of next month."; return ( {body} ); } function Notice({ notice }: { notice: AccountNotice }) { if (notice.kind === "quota") return ; if (notice.kind === "tokens") { return ( Git and the API authenticate with a token, not your password; you have none that is still valid. ); } if (notice.kind === "disabled") { return ( Its repositories are read-only and its hosting is stopped. {notice.message} ); } return ( An authenticator app or a passkey keeps your repositories safe even if your password leaks. ); } /** Persistent until the email is verified: an unverified account can read but not write or pay (AUTH-B1). */ function VerifyEmailBanner({ email }: { email: string }) { const resend = useAction(resendVerificationEmail, { invalidate: false }); const [sent, setSent] = useState(false); const router = useRouter(); // The link is usually opened in another tab; re-read the session when this one regains focus so the banner clears. useEffect(() => { const refresh = () => void router.invalidate(); window.addEventListener("focus", refresh); return () => window.removeEventListener("focus", refresh); }, [router]); return ( setSent(Boolean(await resend.run({ data: { email } }))), } } snoozeDays={0} > {resend.error ?? (sent ? `Sent a new link to ${email}.` : `Until you open the link we sent to ${email}, you can browse, star and follow but not create, comment, push or pay.`)} ); } /** Account-level nags under the site header: unverified email, maxed-out plans and missing two-factor. */ export function AccountNotices() { const viewer = useViewer(); const unverified = useEmailUnverified(); const location = useLocation(); const notices = useQuery({ queryKey: [ACCOUNT_NOTICES_KEY, viewer?.id ?? null, unverified], queryFn: () => getAccountNotices(), enabled: Boolean(viewer), staleTime: NOTICE_STALE_MS, }); const banner = viewer && unverified ? : null; if (!viewer || !notices.data?.length) return banner; const onSecurity = location.pathname.startsWith("/settings/security"); const onTokens = location.pathname.startsWith("/settings/tokens"); const path = safeDecode(location.pathname) ?? location.pathname; return (
{banner} {notices.data .filter( (notice) => !(notice.kind === "mfa" && onSecurity) && !(notice.kind === "tokens" && onTokens) && !(notice.kind === "quota" && (safeDecode(notice.billingPath) ?? notice.billingPath) === path), ) .map((notice) => ( ))}
); }