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/package.json b/package.json index c139cfe2cd1..350f6f2a55e 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "db:push": "bun run --cwd packages/db push", "db:migrate": "bun run --cwd packages/db migrate", "db:seed-dev": "NODE_ENV=development bun run packages/auth/src/seed-dev.ts", + "db:seed-review": "bun run packages/auth/src/seed-review-account.ts", "db:generate:desktop": "bun run --cwd packages/local-db generate" }, "type": "module", diff --git a/packages/auth/src/seed-review-account.ts b/packages/auth/src/seed-review-account.ts new file mode 100644 index 00000000000..6c6d1edcd81 --- /dev/null +++ b/packages/auth/src/seed-review-account.ts @@ -0,0 +1,102 @@ +import { db } from "@superset/db/client"; +import { accounts, members, subscriptions, users } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; +import { auth } from "./server"; + +/** Seeds the App Store review demo account: credential sign-in, onboarded, + * and a Pro-subscribed personal org (subscription row only — no Stripe + * charge). Idempotent; safe to re-run to rotate the password. Sign-up stays + * disabled in production, so this raw path is the only way the account can + * exist there. + * + * APP_REVIEW_EMAIL=... APP_REVIEW_PASSWORD=... bun run db:seed-review + */ +async function seedReviewAccount(): Promise { + const email = process.env.APP_REVIEW_EMAIL; + const password = process.env.APP_REVIEW_PASSWORD; + if (!email || !password) { + throw new Error("APP_REVIEW_EMAIL and APP_REVIEW_PASSWORD are required"); + } + if (password.length < 8) { + throw new Error("APP_REVIEW_PASSWORD must be at least 8 characters"); + } + + const context = await auth.$context; + + let user = await db.query.users.findFirst({ where: eq(users.email, email) }); + if (!user) { + [user] = await db + .insert(users) + .values({ name: "App Review", email, emailVerified: true }) + .returning(); + console.log(`Created review user: ${email}`); + } + if (!user) throw new Error("review user was not created"); + + const passwordHash = await context.password.hash(password); + const credential = await db.query.accounts.findFirst({ + where: and( + eq(accounts.userId, user.id), + eq(accounts.providerId, "credential"), + ), + }); + if (credential) { + await context.internalAdapter.updatePassword(user.id, passwordHash); + } else { + await context.internalAdapter.createAccount({ + userId: user.id, + providerId: "credential", + accountId: user.id, + password: passwordHash, + }); + } + + await db + .update(users) + .set({ onboardedAt: new Date(), deletionRequestedAt: null }) + .where(eq(users.id, user.id)); + + let membership = await db.query.members.findFirst({ + where: eq(members.userId, user.id), + }); + if (!membership) { + // Raw user inserts bypass the user-create database hook, so the + // personal org has to be created explicitly. + await auth.api.createOrganization({ + body: { + name: "App Review Team", + slug: `${user.id.slice(0, 8)}-review`, + userId: user.id, + }, + }); + membership = await db.query.members.findFirst({ + where: eq(members.userId, user.id), + }); + } + if (!membership) throw new Error("review user has no organization"); + + const activeSubscription = await db.query.subscriptions.findFirst({ + where: and( + eq(subscriptions.referenceId, membership.organizationId), + eq(subscriptions.status, "active"), + ), + }); + if (!activeSubscription) { + await db.insert(subscriptions).values({ + plan: "pro", + referenceId: membership.organizationId, + status: "active", + billingInterval: "monthly", + seats: 1, + }); + } + + console.log(`Review account ready: ${email} (onboarded, pro)`); +} + +seedReviewAccount() + .then(() => process.exit(0)) + .catch((error) => { + console.error("seed-review-account failed:", error); + process.exit(1); + }); diff --git a/packages/auth/src/server.ts b/packages/auth/src/server.ts index d7cbfff9965..6fc5f41caea 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;