From 44853f1153aece1a3f45286071f3061cd287bbbe Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 12 Aug 2026 20:51:40 -0700 Subject: [PATCH 1/3] feat(mobile,trpc,auth): gate mobile behind a paid plan (take two) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reinstates the pro gate for review: paywall tier + screen, one mobile plan middleware on the x-superset-client header, credential sign-IN in production for the review demo account (sign-up stays dev/preview-only), quiet email sign-in link, gated store.config review details with env-injected demo credentials. Unlike the first pass, the seed script is NOT committed — the review account gets provisioned by a one-off uncommitted run. --- apps/mobile/app/paywall.tsx | 3 + .../hooks/useDeleteAccount/index.ts | 0 .../useDeleteAccount/useDeleteAccount.ts | 0 apps/mobile/lib/trpc/client.ts | 4 + .../screens/(auth)/sign-in/SignInScreen.tsx | 2 + .../EmailSignInLink/EmailSignInLink.tsx | 52 +++++++ .../components/EmailSignInLink/index.ts | 1 + .../account/AccountSettingsScreen.tsx | 2 +- apps/mobile/screens/RootLayout/RootLayout.tsx | 10 +- apps/mobile/screens/paywall/PaywallScreen.tsx | 127 ++++++++++++++++++ apps/mobile/screens/paywall/index.ts | 1 + apps/mobile/store.config.js | 8 +- packages/auth/src/server.ts | 10 +- packages/trpc/src/trpc.ts | 23 ++++ 14 files changed, 236 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/app/paywall.tsx rename apps/mobile/{screens/(authenticated)/settings => }/hooks/useDeleteAccount/index.ts (100%) rename apps/mobile/{screens/(authenticated)/settings => }/hooks/useDeleteAccount/useDeleteAccount.ts (100%) create mode 100644 apps/mobile/screens/(auth)/sign-in/components/EmailSignInLink/EmailSignInLink.tsx create mode 100644 apps/mobile/screens/(auth)/sign-in/components/EmailSignInLink/index.ts create mode 100644 apps/mobile/screens/paywall/PaywallScreen.tsx create mode 100644 apps/mobile/screens/paywall/index.ts diff --git a/apps/mobile/app/paywall.tsx b/apps/mobile/app/paywall.tsx new file mode 100644 index 00000000000..0888023ddc0 --- /dev/null +++ b/apps/mobile/app/paywall.tsx @@ -0,0 +1,3 @@ +import { PaywallScreen } from "@/screens/paywall"; + +export default PaywallScreen; diff --git a/apps/mobile/screens/(authenticated)/settings/hooks/useDeleteAccount/index.ts b/apps/mobile/hooks/useDeleteAccount/index.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/settings/hooks/useDeleteAccount/index.ts rename to apps/mobile/hooks/useDeleteAccount/index.ts diff --git a/apps/mobile/screens/(authenticated)/settings/hooks/useDeleteAccount/useDeleteAccount.ts b/apps/mobile/hooks/useDeleteAccount/useDeleteAccount.ts similarity index 100% rename from apps/mobile/screens/(authenticated)/settings/hooks/useDeleteAccount/useDeleteAccount.ts rename to apps/mobile/hooks/useDeleteAccount/useDeleteAccount.ts diff --git a/apps/mobile/lib/trpc/client.ts b/apps/mobile/lib/trpc/client.ts index 95b9909b4e7..b20df5c80a7 100644 --- a/apps/mobile/lib/trpc/client.ts +++ b/apps/mobile/lib/trpc/client.ts @@ -1,9 +1,12 @@ import type { AppRouter } from "@superset/trpc"; import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"; +import * as Application from "expo-application"; import superjson from "superjson"; import { authClient, getJwt } from "../auth/client"; import { env } from "../env"; +const clientVersionHeader = `mobile/${Application.nativeApplicationVersion ?? "0.0.0"}`; + export const apiClient = createTRPCProxyClient({ links: [ httpBatchLink({ @@ -12,6 +15,7 @@ export const apiClient = createTRPCProxyClient({ const cookies = authClient.getCookie(); const jwt = getJwt(); return { + "x-superset-client": clientVersionHeader, ...(cookies ? { Cookie: cookies } : {}), ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), }; diff --git a/apps/mobile/screens/(auth)/sign-in/SignInScreen.tsx b/apps/mobile/screens/(auth)/sign-in/SignInScreen.tsx index d3fc8effddc..80f1f2257f8 100644 --- a/apps/mobile/screens/(auth)/sign-in/SignInScreen.tsx +++ b/apps/mobile/screens/(auth)/sign-in/SignInScreen.tsx @@ -8,6 +8,7 @@ import { signIn } from "@/lib/auth/client"; import { env } from "@/lib/env"; import { DevSignInOptions } from "./components/DevSignInOptions"; +import { EmailSignInLink } from "./components/EmailSignInLink"; import type { SocialProvider } from "./components/SocialButton"; import { SocialButton } from "./components/SocialButton"; @@ -115,6 +116,7 @@ export function SignInScreen() { onPress={() => handleSignIn("google")} className="w-4/5" /> + {(__DEV__ || env.EXPO_PUBLIC_E2E === "1") && } diff --git a/apps/mobile/screens/(auth)/sign-in/components/EmailSignInLink/EmailSignInLink.tsx b/apps/mobile/screens/(auth)/sign-in/components/EmailSignInLink/EmailSignInLink.tsx new file mode 100644 index 00000000000..258fe58ec97 --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/components/EmailSignInLink/EmailSignInLink.tsx @@ -0,0 +1,52 @@ +import { prompt } from "@superset/alert-prompt"; +import { useState } from "react"; +import { Text } from "@/components/ui/text"; +import { signIn } from "@/lib/auth/client"; + +/** Quiet credential sign-in for accounts with a password set (App Store + * review demo account; sign-up stays disabled in production). */ +export function EmailSignInLink({ + onError, +}: { + onError: (message: string) => void; +}) { + const [isLoading, setIsLoading] = useState(false); + + const handlePress = async () => { + const email = ( + await prompt({ + title: "Sign in with email", + message: "Email", + confirmText: "Next", + }) + )?.trim(); + if (!email) return; + + const password = await prompt({ + title: "Sign in with email", + message: `Password for ${email}`, + confirmText: "Sign in", + }); + if (!password) return; + + setIsLoading(true); + try { + const res = await signIn.email({ email, password }); + if (res.error) throw new Error(res.error.message); + } catch (err) { + console.error("[sign-in] Email error:", err); + onError(err instanceof Error ? err.message : "Something went wrong"); + } finally { + setIsLoading(false); + } + }; + + return ( + void handlePress()} + > + Sign in with email + + ); +} diff --git a/apps/mobile/screens/(auth)/sign-in/components/EmailSignInLink/index.ts b/apps/mobile/screens/(auth)/sign-in/components/EmailSignInLink/index.ts new file mode 100644 index 00000000000..b6f1e721b1d --- /dev/null +++ b/apps/mobile/screens/(auth)/sign-in/components/EmailSignInLink/index.ts @@ -0,0 +1 @@ +export { EmailSignInLink } from "./EmailSignInLink"; diff --git a/apps/mobile/screens/(authenticated)/settings/account/AccountSettingsScreen.tsx b/apps/mobile/screens/(authenticated)/settings/account/AccountSettingsScreen.tsx index 40f7176b4b4..149ff87c65f 100644 --- a/apps/mobile/screens/(authenticated)/settings/account/AccountSettingsScreen.tsx +++ b/apps/mobile/screens/(authenticated)/settings/account/AccountSettingsScreen.tsx @@ -2,12 +2,12 @@ import Ionicons from "@expo/vector-icons/Ionicons"; import { ACCOUNT_DELETION_GRACE_DAYS } from "@superset/shared/constants"; import { Alert, ScrollView, View } from "react-native"; import { Text } from "@/components/ui/text"; +import { useDeleteAccount } from "@/hooks/useDeleteAccount"; import { useTheme } from "@/hooks/useTheme"; import { useSession } from "@/lib/auth/client"; import { ListRow } from "@/screens/(authenticated)/components/ListRow"; import { ListRowValue } from "@/screens/(authenticated)/components/ListRowValue"; import { UserAvatar } from "../components/UserAvatar"; -import { useDeleteAccount } from "../hooks/useDeleteAccount"; export function AccountSettingsScreen() { const theme = useTheme(); diff --git a/apps/mobile/screens/RootLayout/RootLayout.tsx b/apps/mobile/screens/RootLayout/RootLayout.tsx index ba29225dc20..c9ffeff6e19 100644 --- a/apps/mobile/screens/RootLayout/RootLayout.tsx +++ b/apps/mobile/screens/RootLayout/RootLayout.tsx @@ -20,6 +20,7 @@ export function RootLayout() { if (isPending) return null; const pendingDeletion = !!session?.user.deletionRequestedAt; + const hasPaidPlan = !!session?.session.plan; return ( @@ -27,9 +28,16 @@ export function RootLayout() { - + + + + diff --git a/apps/mobile/screens/paywall/PaywallScreen.tsx b/apps/mobile/screens/paywall/PaywallScreen.tsx new file mode 100644 index 00000000000..49eedec20ab --- /dev/null +++ b/apps/mobile/screens/paywall/PaywallScreen.tsx @@ -0,0 +1,127 @@ +import Ionicons from "@expo/vector-icons/Ionicons"; +import { COMPANY } from "@superset/shared/constants"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { Alert, Linking, View } from "react-native"; +import { Button } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; +import { useDeleteAccount } from "@/hooks/useDeleteAccount"; +import { useSignOut } from "@/hooks/useSignOut"; +import { useTheme } from "@/hooks/useTheme"; +import { authClient, useSession } from "@/lib/auth/client"; +import { apiClient } from "@/lib/trpc/client"; + +export function PaywallScreen() { + const theme = useTheme(); + const { data: session, refetch } = useSession(); + const { signOut, isSigningOut } = useSignOut(); + const { deleteAccount, isDeleting } = useDeleteAccount(); + const [isSwitching, setIsSwitching] = useState(false); + + const activeOrganizationId = session?.session.activeOrganizationId; + const { data: organizations } = useQuery({ + queryKey: ["paywall", "organizations"], + queryFn: () => apiClient.user.myOrganizations.query(), + }); + const otherOrganizations = (organizations ?? []).filter( + (organization) => organization.id !== activeOrganizationId, + ); + + const handleSwitchOrganization = async (organizationId: string) => { + setIsSwitching(true); + try { + await authClient.organization.setActive({ organizationId }); + await refetch(); + } catch (error) { + console.error("[paywall] Failed to switch organization:", error); + } finally { + setIsSwitching(false); + } + }; + + const handleDeleteAccount = () => { + Alert.alert( + "Delete account?", + "All of your data will be permanently deleted after 30 days — sign back in before then to restore your account.", + [ + { style: "cancel", text: "Cancel" }, + { + style: "destructive", + text: "Delete account", + onPress: () => { + deleteAccount().catch(() => { + Alert.alert( + "Could not delete account", + "Something went wrong. Try again, or contact support@superset.sh.", + ); + }); + }, + }, + ], + ); + }; + + return ( + + + + + + + + Superset Mobile is part of Pro + + + Manage your plan from the Superset desktop app, then refresh. + + + + + {otherOrganizations.map((organization) => ( + + ))} + + + + + + + Delete account + + + ); +} diff --git a/apps/mobile/screens/paywall/index.ts b/apps/mobile/screens/paywall/index.ts new file mode 100644 index 00000000000..6e8c648baa7 --- /dev/null +++ b/apps/mobile/screens/paywall/index.ts @@ -0,0 +1 @@ +export { PaywallScreen } from "./PaywallScreen"; diff --git a/apps/mobile/store.config.js b/apps/mobile/store.config.js index fa55e38ef9a..2a2806d0eb8 100644 --- a/apps/mobile/store.config.js +++ b/apps/mobile/store.config.js @@ -1,3 +1,5 @@ +// Dynamic so the demo-account credentials never enter the repo: +// set APP_REVIEW_EMAIL / APP_REVIEW_PASSWORD when running `eas metadata:push`. module.exports = { configVersion: 0, apple: { @@ -34,9 +36,11 @@ module.exports = { lastName: "Patel", email: "support@superset.sh", phone: "+1 510 519 1602", - demoRequired: false, + demoRequired: true, + demoUsername: process.env.APP_REVIEW_EMAIL ?? "", + demoPassword: process.env.APP_REVIEW_PASSWORD ?? "", notes: - "Sign in with Apple, GitHub, or Google. Signing in with Apple creates a free account instantly, so no demo credentials are needed. Superset is a companion app for the Superset desktop product (https://superset.sh): it lets developers monitor and chat with AI coding agents running in their own workspaces. A fresh account with no connected desktop host will show an empty state after onboarding. Account deletion is available in Settings > Account.", + "Superset Mobile is a companion app for the Superset desktop product (https://superset.sh): developers monitor and chat with AI coding agents running in their own workspaces. Access requires a Superset Pro subscription purchased outside the app (multiplatform service; there are no in-app purchases). Please use the provided demo account (sign in via the 'Sign in with email' link) to access a Pro workspace with sample data. Sign in with Apple, GitHub, or Google also works and creates a free account instantly, which shows the subscription-required screen. Account deletion is available from Settings > Account and from the subscription screen.", }, }, }; diff --git a/packages/auth/src/server.ts b/packages/auth/src/server.ts index 08492c5c1b7..1d7c4ededbb 100644 --- a/packages/auth/src/server.ts +++ b/packages/auth/src/server.ts @@ -165,10 +165,14 @@ export const auth = betterAuth({ generateId: false, }, }, + // Credential sign-IN stays available in production for the App Store + // review demo account (see seed-review-account.ts); sign-UP remains + // dev/preview-only. emailAndPassword: { - enabled: - process.env.NODE_ENV === "development" || - process.env.VERCEL_ENV === "preview", + enabled: true, + disableSignUp: + process.env.NODE_ENV !== "development" && + process.env.VERCEL_ENV !== "preview", autoSignIn: true, }, socialProviders: { diff --git a/packages/trpc/src/trpc.ts b/packages/trpc/src/trpc.ts index 0647e74ceb1..f6bf6ddf067 100644 --- a/packages/trpc/src/trpc.ts +++ b/packages/trpc/src/trpc.ts @@ -98,6 +98,16 @@ const PENDING_DELETION_ALLOWED_PROCEDURES = new Set([ "user.reactivateAccount", ]); +/** Mobile is a paid-plan surface; unpaid mobile sessions may only manage the + * account itself and switch to an org that has a plan. Other clients are + * never gated here. */ +const MOBILE_PAYWALL_ALLOWED_PROCEDURES = new Set([ + "user.me", + "user.deleteAccount", + "user.reactivateAccount", + "user.myOrganizations", +]); + export const protectedProcedure = t.procedure .use(clientTelemetry) .use(async ({ ctx, next }) => { @@ -122,6 +132,19 @@ export const protectedProcedure = t.procedure } return next(); }) + .use(async ({ ctx, path, next }) => { + if ( + ctx.client?.product === "mobile" && + !ctx.session.session.plan && + !MOBILE_PAYWALL_ALLOWED_PROCEDURES.has(path) + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Superset Mobile requires a paid plan.", + }); + } + return next(); + }) .use(async ({ ctx, next }) => { const sessionOrgId = ctx.session.session.activeOrganizationId ?? null; const headerOrgId = ctx.headers.get(ORGANIZATION_HEADER)?.trim() || null; From 3409b31df8be7cf9669ed631cfc9d859536c2f1c Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 12 Aug 2026 20:58:29 -0700 Subject: [PATCH 2/3] refactor(mobile): gate home content instead of the whole shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The paywall renders as the home screen's content when the active org has no plan, reusing the real header org-switcher sheet — which also carries the Settings entry, so org switching, plan visibility, and account deletion all flow through the normal surfaces with no duplicated affordances. The trpc middleware remains the actual wall; deep-linked product routes surface its FORBIDDEN as error states. --- .../app/(authenticated)/(home)/index.tsx | 10 +- apps/mobile/app/paywall.tsx | 3 - .../(home)/home-paywall/HomePaywallScreen.tsx | 77 +++++++++++ .../(home)/home-paywall/index.ts | 1 + apps/mobile/screens/RootLayout/RootLayout.tsx | 10 +- apps/mobile/screens/paywall/PaywallScreen.tsx | 127 ------------------ apps/mobile/screens/paywall/index.ts | 1 - 7 files changed, 88 insertions(+), 141 deletions(-) delete mode 100644 apps/mobile/app/paywall.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/home-paywall/HomePaywallScreen.tsx create mode 100644 apps/mobile/screens/(authenticated)/(home)/home-paywall/index.ts delete mode 100644 apps/mobile/screens/paywall/PaywallScreen.tsx delete mode 100644 apps/mobile/screens/paywall/index.ts diff --git a/apps/mobile/app/(authenticated)/(home)/index.tsx b/apps/mobile/app/(authenticated)/(home)/index.tsx index d6ffd508e2e..1f916b63116 100644 --- a/apps/mobile/app/(authenticated)/(home)/index.tsx +++ b/apps/mobile/app/(authenticated)/(home)/index.tsx @@ -1,3 +1,11 @@ +import { useSession } from "@/lib/auth/client"; import { HomeScreen } from "@/screens/(authenticated)/(home)/home"; +import { HomePaywallScreen } from "@/screens/(authenticated)/(home)/home-paywall"; -export default HomeScreen; +export default function HomeIndex() { + const { data: session } = useSession(); + if (session && !session.session.plan) { + return ; + } + return ; +} diff --git a/apps/mobile/app/paywall.tsx b/apps/mobile/app/paywall.tsx deleted file mode 100644 index 0888023ddc0..00000000000 --- a/apps/mobile/app/paywall.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { PaywallScreen } from "@/screens/paywall"; - -export default PaywallScreen; diff --git a/apps/mobile/screens/(authenticated)/(home)/home-paywall/HomePaywallScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/home-paywall/HomePaywallScreen.tsx new file mode 100644 index 00000000000..001761cb6fa --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/home-paywall/HomePaywallScreen.tsx @@ -0,0 +1,77 @@ +import Ionicons from "@expo/vector-icons/Ionicons"; +import * as Haptics from "expo-haptics"; +import { useState } from "react"; +import { useWindowDimensions, View } from "react-native"; +import { Button } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; +import { useTheme } from "@/hooks/useTheme"; +import { useSession } from "@/lib/auth/client"; +import { useOrganizations } from "@/screens/(authenticated)/hooks/useOrganizations"; +import { OrganizationHeaderButton } from "../home/components/OrganizationHeaderButton"; +import { OrganizationSwitcherSheet } from "../home/components/OrganizationSwitcherSheet"; + +/** Home content for accounts whose active org has no paid plan. The shell + * stays fully navigable — the org switcher sheet also carries the Settings + * entry, so plan changes, org switches, and account deletion all use the + * normal surfaces. The trpc middleware is the actual wall. */ +export function HomePaywallScreen() { + const theme = useTheme(); + const { width } = useWindowDimensions(); + const { refetch } = useSession(); + const { + organizations, + activeOrganization, + activeOrganizationId, + switchOrganization, + } = useOrganizations(); + const [sheetOpen, setSheetOpen] = useState(false); + + const handleSwitchOrganization = (organizationId: string) => { + setSheetOpen(false); + void switchOrganization(organizationId).then(() => refetch()); + }; + + return ( + <> + { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + setSheetOpen(true); + }} + /> + + + + + + + + Superset Mobile is part of Pro + + + {activeOrganization + ? `${activeOrganization.name} is on the free plan. Manage your plan from the Superset desktop app, then refresh.` + : "Manage your plan from the Superset desktop app, then refresh."} + + + + + + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/(home)/home-paywall/index.ts b/apps/mobile/screens/(authenticated)/(home)/home-paywall/index.ts new file mode 100644 index 00000000000..7479881e0b8 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/(home)/home-paywall/index.ts @@ -0,0 +1 @@ +export { HomePaywallScreen } from "./HomePaywallScreen"; diff --git a/apps/mobile/screens/RootLayout/RootLayout.tsx b/apps/mobile/screens/RootLayout/RootLayout.tsx index c9ffeff6e19..ba29225dc20 100644 --- a/apps/mobile/screens/RootLayout/RootLayout.tsx +++ b/apps/mobile/screens/RootLayout/RootLayout.tsx @@ -20,7 +20,6 @@ export function RootLayout() { if (isPending) return null; const pendingDeletion = !!session?.user.deletionRequestedAt; - const hasPaidPlan = !!session?.session.plan; return ( @@ -28,16 +27,9 @@ export function RootLayout() { - + - - - diff --git a/apps/mobile/screens/paywall/PaywallScreen.tsx b/apps/mobile/screens/paywall/PaywallScreen.tsx deleted file mode 100644 index 49eedec20ab..00000000000 --- a/apps/mobile/screens/paywall/PaywallScreen.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import Ionicons from "@expo/vector-icons/Ionicons"; -import { COMPANY } from "@superset/shared/constants"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; -import { Alert, Linking, View } from "react-native"; -import { Button } from "@/components/ui/button"; -import { Text } from "@/components/ui/text"; -import { useDeleteAccount } from "@/hooks/useDeleteAccount"; -import { useSignOut } from "@/hooks/useSignOut"; -import { useTheme } from "@/hooks/useTheme"; -import { authClient, useSession } from "@/lib/auth/client"; -import { apiClient } from "@/lib/trpc/client"; - -export function PaywallScreen() { - const theme = useTheme(); - const { data: session, refetch } = useSession(); - const { signOut, isSigningOut } = useSignOut(); - const { deleteAccount, isDeleting } = useDeleteAccount(); - const [isSwitching, setIsSwitching] = useState(false); - - const activeOrganizationId = session?.session.activeOrganizationId; - const { data: organizations } = useQuery({ - queryKey: ["paywall", "organizations"], - queryFn: () => apiClient.user.myOrganizations.query(), - }); - const otherOrganizations = (organizations ?? []).filter( - (organization) => organization.id !== activeOrganizationId, - ); - - const handleSwitchOrganization = async (organizationId: string) => { - setIsSwitching(true); - try { - await authClient.organization.setActive({ organizationId }); - await refetch(); - } catch (error) { - console.error("[paywall] Failed to switch organization:", error); - } finally { - setIsSwitching(false); - } - }; - - const handleDeleteAccount = () => { - Alert.alert( - "Delete account?", - "All of your data will be permanently deleted after 30 days — sign back in before then to restore your account.", - [ - { style: "cancel", text: "Cancel" }, - { - style: "destructive", - text: "Delete account", - onPress: () => { - deleteAccount().catch(() => { - Alert.alert( - "Could not delete account", - "Something went wrong. Try again, or contact support@superset.sh.", - ); - }); - }, - }, - ], - ); - }; - - return ( - - - - - - - - Superset Mobile is part of Pro - - - Manage your plan from the Superset desktop app, then refresh. - - - - - {otherOrganizations.map((organization) => ( - - ))} - - - - - - - Delete account - - - ); -} diff --git a/apps/mobile/screens/paywall/index.ts b/apps/mobile/screens/paywall/index.ts deleted file mode 100644 index 6e8c648baa7..00000000000 --- a/apps/mobile/screens/paywall/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PaywallScreen } from "./PaywallScreen"; From e138666b89ea590ddd1539a2abab543b963ffc58 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 13 Aug 2026 13:02:13 -0700 Subject: [PATCH 3/3] feat(mobile): flatten settings and make the paywall a UI-only gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings is now one profile page: avatar, name, email, and plan/joined meta, then Organization, Plan, Support, More, and Danger Zone groups with sign-out and account deletion inline. The account and billing sub-screens and the useActivePlan query are gone — plan comes from the session — and Manage Plan opens a disclosure alert linking to web billing. A version footer sits above the home-indicator inset. The paywall no longer has a server half: the trpc mobile middleware, its shared error constant, the client link that watched for it, and the session-refresh registry are removed. HomeIndex plus the authenticated layout guard keep unpaid sessions on home and settings, so sign-out, org switching, and account deletion stay reachable for App Review. Also: canonical simple-icons Apple mark so provider icons match, dev sign-in buttons above the review email link, and search moved to placement="stacked" — integratedButton evicted the org switcher a few seconds after mount on iOS 26. --- apps/mobile/app/(authenticated)/_layout.tsx | 26 +- .../app/(authenticated)/settings/account.tsx | 3 - .../app/(authenticated)/settings/billing.tsx | 3 - .../screens/(auth)/sign-in/SignInScreen.tsx | 2 +- .../components/SocialButton/SocialButton.tsx | 25 +- .../(home)/home-paywall/HomePaywallScreen.tsx | 4 +- .../(home)/home/HomeScreen.tsx | 10 +- .../settings/SettingsScreen.tsx | 366 ++++++++++++------ .../account/AccountSettingsScreen.tsx | 83 ---- .../(authenticated)/settings/account/index.ts | 1 - .../billing/BillingSettingsScreen.tsx | 66 ---- .../(authenticated)/settings/billing/index.ts | 1 - .../SettingsSection/SettingsSection.tsx | 22 ++ .../components/SettingsSection/index.ts | 1 + .../settings/hooks/useActivePlan/index.ts | 1 - .../hooks/useActivePlan/useActivePlan.ts | 21 - packages/trpc/src/trpc.ts | 23 -- 17 files changed, 321 insertions(+), 337 deletions(-) delete mode 100644 apps/mobile/app/(authenticated)/settings/account.tsx delete mode 100644 apps/mobile/app/(authenticated)/settings/billing.tsx delete mode 100644 apps/mobile/screens/(authenticated)/settings/account/AccountSettingsScreen.tsx delete mode 100644 apps/mobile/screens/(authenticated)/settings/account/index.ts delete mode 100644 apps/mobile/screens/(authenticated)/settings/billing/BillingSettingsScreen.tsx delete mode 100644 apps/mobile/screens/(authenticated)/settings/billing/index.ts create mode 100644 apps/mobile/screens/(authenticated)/settings/components/SettingsSection/SettingsSection.tsx create mode 100644 apps/mobile/screens/(authenticated)/settings/components/SettingsSection/index.ts delete mode 100644 apps/mobile/screens/(authenticated)/settings/hooks/useActivePlan/index.ts delete mode 100644 apps/mobile/screens/(authenticated)/settings/hooks/useActivePlan/useActivePlan.ts diff --git a/apps/mobile/app/(authenticated)/_layout.tsx b/apps/mobile/app/(authenticated)/_layout.tsx index 2cac79c628b..e65a89107aa 100644 --- a/apps/mobile/app/(authenticated)/_layout.tsx +++ b/apps/mobile/app/(authenticated)/_layout.tsx @@ -1,7 +1,8 @@ import { isLiquidGlassAvailable } from "expo-glass-effect"; -import { Stack } from "expo-router"; +import { Redirect, Stack, usePathname } from "expo-router"; import { PromptInputProvider } from "@/components/ai-elements/prompt-input"; import { usePrimeRelayUrl } from "@/hooks/usePrimeRelayUrl"; +import { useSession } from "@/lib/auth/client"; const settingsScreenOptions = (title: string) => ({ headerShown: true, @@ -25,6 +26,17 @@ const glassHeaderOptions = { export default function AuthenticatedLayout() { usePrimeRelayUrl(); + const { data: session } = useSession(); + const pathname = usePathname(); + + // Unpaid sessions may only see home (which renders the paywall) and + // settings — App Review requires sign-out, org switching, and account + // deletion to stay reachable behind a gate. + const unpaid = !!session && !session.session.plan; + if (unpaid && pathname !== "/" && !pathname.startsWith("/settings")) { + return ; + } + return ( @@ -36,10 +48,6 @@ export default function AuthenticatedLayout() { name="settings/index" options={settingsScreenOptions("Settings")} /> - - + handleSignIn("google")} className="w-4/5" /> - {(__DEV__ || env.EXPO_PUBLIC_E2E === "1") && } + {error && ( diff --git a/apps/mobile/screens/(auth)/sign-in/components/SocialButton/SocialButton.tsx b/apps/mobile/screens/(auth)/sign-in/components/SocialButton/SocialButton.tsx index d1a32996b67..edf1827b792 100644 --- a/apps/mobile/screens/(auth)/sign-in/components/SocialButton/SocialButton.tsx +++ b/apps/mobile/screens/(auth)/sign-in/components/SocialButton/SocialButton.tsx @@ -1,4 +1,4 @@ -import { useColorScheme } from "react-native"; +import { useColorScheme, View } from "react-native"; import Svg, { Path } from "react-native-svg"; import type { ButtonProps } from "@/components/ui/button"; import { Button } from "@/components/ui/button"; @@ -12,10 +12,11 @@ export interface SocialButtonProps extends Omit { } function AppleIcon({ color }: { color: string }) { + // simple-icons "apple": normalized to fill the 24-grid like the GitHub mark. return ( @@ -74,17 +75,19 @@ export function SocialButton({ ); } diff --git a/apps/mobile/screens/(authenticated)/(home)/home-paywall/HomePaywallScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/home-paywall/HomePaywallScreen.tsx index 001761cb6fa..75e1f14df5f 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home-paywall/HomePaywallScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/home-paywall/HomePaywallScreen.tsx @@ -55,8 +55,8 @@ export function HomePaywallScreen() { {activeOrganization - ? `${activeOrganization.name} is on the free plan. Manage your plan from the Superset desktop app, then refresh.` - : "Manage your plan from the Superset desktop app, then refresh."} + ? `${activeOrganization.name} is on the Free plan. Superset Mobile is available for organizations on Pro.` + : "This organization is on the Free plan. Superset Mobile is available for organizations on Pro."} diff --git a/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx b/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx index 36ebe8f9cc1..7f3c2577bdc 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/home/HomeScreen.tsx @@ -339,10 +339,14 @@ export function HomeScreen() { setSheetOpen(true); }} /> + {/* placement="integratedButton" + allowToolbarIntegration={false} + evicts the custom left toolbar view (org switcher) a few seconds + after mount on iOS 26 — "stacked" is the only placement that + coexists with it. */} { + Alert.alert("Could not open link", url); + }); +} + +function ExternalIcon({ color }: { color: string }) { + return ; +} + +function formatJoined(createdAt?: Date | string | null) { + if (!createdAt) return null; + const date = new Date(createdAt); + if (Number.isNaN(date.getTime())) return null; + return date.toLocaleDateString(undefined, { + month: "long", + year: "numeric", + }); +} export function SettingsScreen() { const router = useRouter(); const theme = useTheme(); + const insets = useSafeAreaInsets(); const { data: session } = useSession(); const { activeOrganization } = useOrganizations(); const { signOut, isSigningOut } = useSignOut(); + const { deleteAccount, isDeleting } = useDeleteAccount(); - const plan = useActivePlan()?.plan; const user = session?.user; + const plan = session?.session.plan; + const planLabel = plan ? plan[0].toUpperCase() + plan.slice(1) : "Free"; + const joined = formatJoined(user?.createdAt); const handleSignOut = () => { Alert.alert("Log out?", undefined, [ @@ -32,116 +67,229 @@ export function SettingsScreen() { ]); }; + const handleManagePlan = () => { + Alert.alert( + "Manage Plan on the Web", + `You can't change your plan in the app because it's managed on the web at ${COMPANY.DOMAIN}.`, + [ + { style: "cancel", text: "Dismiss" }, + { + text: `Manage on ${COMPANY.DOMAIN}`, + onPress: () => openUrl(BILLING_URL), + }, + ], + ); + }; + + const handleDeleteAccount = () => { + Alert.alert( + "Delete account?", + `All of your data will be permanently deleted after ${ACCOUNT_DELETION_GRACE_DAYS} days. Sign back in before then to restore your account.`, + [ + { style: "cancel", text: "Cancel" }, + { + style: "destructive", + text: "Delete account", + onPress: () => { + deleteAccount().catch(() => { + Alert.alert( + "Could not delete account", + "Something went wrong. Try again, or contact support@superset.sh.", + ); + }); + }, + }, + ], + ); + }; + return ( - } - label={user?.name ?? "Account"} - subtitle={user?.email} - trailing={ - - } - onPress={() => router.push("/(authenticated)/settings/account")} - /> - - } - label="Organization" - trailing={ - - ) : undefined - } - /> - } - onPress={() => router.push("/(authenticated)/settings/organization")} - /> - - } - label="Hosts" - trailing={ - - } - onPress={() => router.push("/(authenticated)/settings/hosts")} - /> - - } - label="Agent presets" - trailing={ - - } - onPress={() => router.push("/(authenticated)/settings/presets")} - /> - - } - label="Billing" - trailing={ - - } - onPress={() => router.push("/(authenticated)/settings/billing")} - isLast - /> - - - } - label="Log out" - destructive - onPress={isSigningOut ? undefined : handleSignOut} - isLast - /> + + + + + + {user?.name} + + + {user?.email} + + + {joined ? `${planLabel} · Joined ${joined}` : planLabel} + + + + + + } + label="Organization" + trailing={ + + ) : undefined + } + /> + } + onPress={() => router.push("/(authenticated)/settings/organization")} + /> + + } + label="Hosts" + trailing={ + + } + onPress={() => router.push("/(authenticated)/settings/hosts")} + /> + + } + label="Agent presets" + trailing={ + + } + onPress={() => router.push("/(authenticated)/settings/presets")} + isLast + /> + + + + + } + label="Manage Plan" + trailing={} + onPress={handleManagePlan} + isLast + /> + + + + + } + label="Help & Docs" + trailing={} + onPress={() => openUrl(COMPANY.DOCS_URL)} + /> + + } + label="Contact Support" + trailing={} + onPress={() => openUrl(COMPANY.MAIL_TO)} + /> + + } + label="Community" + trailing={} + onPress={() => openUrl(COMPANY.DISCORD_URL)} + isLast + /> + + + + + } + label="Sign out" + onPress={isSigningOut ? undefined : handleSignOut} + isLast + /> + + + + + } + label="Delete Account" + destructive + onPress={isDeleting ? undefined : handleDeleteAccount} + isLast + /> + + + + {`${COMPANY.NAME} v${Application.nativeApplicationVersion ?? "0.0.0"} (${Application.nativeBuildVersion ?? "0"})`} + ); } diff --git a/apps/mobile/screens/(authenticated)/settings/account/AccountSettingsScreen.tsx b/apps/mobile/screens/(authenticated)/settings/account/AccountSettingsScreen.tsx deleted file mode 100644 index 149ff87c65f..00000000000 --- a/apps/mobile/screens/(authenticated)/settings/account/AccountSettingsScreen.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import Ionicons from "@expo/vector-icons/Ionicons"; -import { ACCOUNT_DELETION_GRACE_DAYS } from "@superset/shared/constants"; -import { Alert, ScrollView, View } from "react-native"; -import { Text } from "@/components/ui/text"; -import { useDeleteAccount } from "@/hooks/useDeleteAccount"; -import { useTheme } from "@/hooks/useTheme"; -import { useSession } from "@/lib/auth/client"; -import { ListRow } from "@/screens/(authenticated)/components/ListRow"; -import { ListRowValue } from "@/screens/(authenticated)/components/ListRowValue"; -import { UserAvatar } from "../components/UserAvatar"; - -export function AccountSettingsScreen() { - const theme = useTheme(); - const { data: session } = useSession(); - const user = session?.user; - const { deleteAccount, isDeleting } = useDeleteAccount(); - - const handleDeleteAccount = () => { - Alert.alert( - "Delete account?", - `All of your data will be permanently deleted after ${ACCOUNT_DELETION_GRACE_DAYS} days — sign back in before then to restore your account.`, - [ - { style: "cancel", text: "Cancel" }, - { - style: "destructive", - text: "Delete account", - onPress: () => { - deleteAccount().catch(() => { - Alert.alert( - "Could not delete account", - "Something went wrong. Try again, or contact support@superset.sh.", - ); - }); - }, - }, - ], - ); - }; - - return ( - - - - - {user?.name} - - - {user?.email} - - - } - /> - } - isLast - /> - - - } - label="Delete account" - destructive - onPress={isDeleting ? undefined : handleDeleteAccount} - isLast - /> - - ); -} diff --git a/apps/mobile/screens/(authenticated)/settings/account/index.ts b/apps/mobile/screens/(authenticated)/settings/account/index.ts deleted file mode 100644 index 45638c6b591..00000000000 --- a/apps/mobile/screens/(authenticated)/settings/account/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AccountSettingsScreen } from "./AccountSettingsScreen"; diff --git a/apps/mobile/screens/(authenticated)/settings/billing/BillingSettingsScreen.tsx b/apps/mobile/screens/(authenticated)/settings/billing/BillingSettingsScreen.tsx deleted file mode 100644 index 6d0deced09b..00000000000 --- a/apps/mobile/screens/(authenticated)/settings/billing/BillingSettingsScreen.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { ScrollView, Text, View } from "react-native"; -import { useTheme } from "@/hooks/useTheme"; -import { ListRow } from "@/screens/(authenticated)/components/ListRow"; -import { ListRowValue } from "@/screens/(authenticated)/components/ListRowValue"; -import { useActivePlan } from "../hooks/useActivePlan"; - -export function BillingSettingsScreen() { - const theme = useTheme(); - const activePlan = useActivePlan(); - const isPaid = activePlan != null && activePlan.plan !== "free"; - - return ( - - {isPaid ? ( - <> - - } - /> - {activePlan.status ? ( - - } - /> - ) : null} - {activePlan.periodEnd ? ( - - } - isLast - /> - ) : null} - - ) : ( - - - Free plan - - - )} - - Manage billing from the desktop app. - - - ); -} diff --git a/apps/mobile/screens/(authenticated)/settings/billing/index.ts b/apps/mobile/screens/(authenticated)/settings/billing/index.ts deleted file mode 100644 index e9b819077c3..00000000000 --- a/apps/mobile/screens/(authenticated)/settings/billing/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { BillingSettingsScreen } from "./BillingSettingsScreen"; diff --git a/apps/mobile/screens/(authenticated)/settings/components/SettingsSection/SettingsSection.tsx b/apps/mobile/screens/(authenticated)/settings/components/SettingsSection/SettingsSection.tsx new file mode 100644 index 00000000000..29822212753 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/settings/components/SettingsSection/SettingsSection.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from "react"; +import { View } from "react-native"; +import { Text } from "@/components/ui/text"; +import { useTheme } from "@/hooks/useTheme"; + +export function SettingsSection({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + const theme = useTheme(); + return ( + + + {label} + + {children} + + ); +} diff --git a/apps/mobile/screens/(authenticated)/settings/components/SettingsSection/index.ts b/apps/mobile/screens/(authenticated)/settings/components/SettingsSection/index.ts new file mode 100644 index 00000000000..82be358dd0c --- /dev/null +++ b/apps/mobile/screens/(authenticated)/settings/components/SettingsSection/index.ts @@ -0,0 +1 @@ +export { SettingsSection } from "./SettingsSection"; diff --git a/apps/mobile/screens/(authenticated)/settings/hooks/useActivePlan/index.ts b/apps/mobile/screens/(authenticated)/settings/hooks/useActivePlan/index.ts deleted file mode 100644 index 10521fc10a1..00000000000 --- a/apps/mobile/screens/(authenticated)/settings/hooks/useActivePlan/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { type ActivePlan, useActivePlan } from "./useActivePlan"; diff --git a/apps/mobile/screens/(authenticated)/settings/hooks/useActivePlan/useActivePlan.ts b/apps/mobile/screens/(authenticated)/settings/hooks/useActivePlan/useActivePlan.ts deleted file mode 100644 index 35bb70a7633..00000000000 --- a/apps/mobile/screens/(authenticated)/settings/hooks/useActivePlan/useActivePlan.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { RouterOutputs } from "@superset/trpc"; -import { useQuery } from "@tanstack/react-query"; -import { useSession } from "@/lib/auth/client"; -import { apiClient } from "@/lib/trpc/client"; - -export type ActivePlan = RouterOutputs["billing"]["activePlan"]; - -/** The active organization's plan; `plan` is "free" when nothing is subscribed. */ -export function useActivePlan(): ActivePlan | undefined { - const { data: session } = useSession(); - const organizationId = session?.session?.activeOrganizationId ?? null; - - const query = useQuery({ - queryKey: ["cloud", "billing", "activePlan", organizationId], - enabled: organizationId !== null, - queryFn: () => apiClient.billing.activePlan.query(), - staleTime: 30_000, - }); - - return query.data; -} diff --git a/packages/trpc/src/trpc.ts b/packages/trpc/src/trpc.ts index f6bf6ddf067..0647e74ceb1 100644 --- a/packages/trpc/src/trpc.ts +++ b/packages/trpc/src/trpc.ts @@ -98,16 +98,6 @@ const PENDING_DELETION_ALLOWED_PROCEDURES = new Set([ "user.reactivateAccount", ]); -/** Mobile is a paid-plan surface; unpaid mobile sessions may only manage the - * account itself and switch to an org that has a plan. Other clients are - * never gated here. */ -const MOBILE_PAYWALL_ALLOWED_PROCEDURES = new Set([ - "user.me", - "user.deleteAccount", - "user.reactivateAccount", - "user.myOrganizations", -]); - export const protectedProcedure = t.procedure .use(clientTelemetry) .use(async ({ ctx, next }) => { @@ -132,19 +122,6 @@ export const protectedProcedure = t.procedure } return next(); }) - .use(async ({ ctx, path, next }) => { - if ( - ctx.client?.product === "mobile" && - !ctx.session.session.plan && - !MOBILE_PAYWALL_ALLOWED_PROCEDURES.has(path) - ) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Superset Mobile requires a paid plan.", - }); - } - return next(); - }) .use(async ({ ctx, next }) => { const sessionOrgId = ctx.session.session.activeOrganizationId ?? null; const headerOrgId = ctx.headers.get(ORGANIZATION_HEADER)?.trim() || null;