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/(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")} /> - - + ({ 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..fd264ad6c6a 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"; @@ -116,6 +117,7 @@ export function SignInScreen() { className="w-4/5" /> {(__DEV__ || env.EXPO_PUBLIC_E2E === "1") && } + {error && ( 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/(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 new file mode 100644 index 00000000000..75e1f14df5f --- /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. 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-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/(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 40f7176b4b4..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 { 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(); - 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/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: {