Repository navigation
feat(mobile,trpc,auth): gate mobile behind a paid plan #6411
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| import { PaywallScreen } from "@/screens/paywall"; | ||
|
|
||
| export default PaywallScreen; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <Text | ||
| className="text-sm text-muted-foreground underline" | ||
| onPress={isLoading ? undefined : () => void handlePress()} | ||
| > | ||
| Sign in with email | ||
| </Text> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { EmailSignInLink } from "./EmailSignInLink"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <View className="flex-1 items-center justify-center gap-6 bg-background p-6"> | ||
| <View | ||
| className="size-16 items-center justify-center rounded-full" | ||
| style={{ backgroundColor: theme.muted }} | ||
| > | ||
| <Ionicons name="lock-closed" size={32} color={theme.foreground} /> | ||
| </View> | ||
|
|
||
| <View className="items-center gap-2"> | ||
| <Text className="text-2xl font-semibold text-foreground"> | ||
| Superset Mobile is part of Pro | ||
| </Text> | ||
| <Text className="text-center text-base text-muted-foreground"> | ||
| Manage your plan from the Superset desktop app, then refresh. | ||
| </Text> | ||
| </View> | ||
|
|
||
| <View className="w-full items-center gap-3"> | ||
| {otherOrganizations.map((organization) => ( | ||
| <Button | ||
| key={organization.id} | ||
| variant="outline" | ||
| size="lg" | ||
| className="w-4/5" | ||
| onPress={ | ||
| isSwitching | ||
| ? undefined | ||
| : () => void handleSwitchOrganization(organization.id) | ||
| } | ||
| > | ||
| <Text>{`Switch to ${organization.name}`}</Text> | ||
| </Button> | ||
| ))} | ||
| <Button size="lg" className="w-4/5" onPress={() => void refetch()}> | ||
| <Text>Refresh</Text> | ||
| </Button> | ||
| <Button | ||
| variant="outline" | ||
| size="lg" | ||
| className="w-4/5" | ||
| onPress={() => Linking.openURL(COMPANY.MAIL_TO)} | ||
| > | ||
| <Text>Contact support</Text> | ||
| </Button> | ||
| <Button | ||
| variant="ghost" | ||
| size="lg" | ||
| className="w-4/5" | ||
| onPress={isSigningOut ? undefined : () => void signOut()} | ||
| > | ||
| <Text>Log out</Text> | ||
| </Button> | ||
| </View> | ||
|
|
||
| <Text | ||
| className="text-sm text-destructive underline" | ||
| onPress={isDeleting ? undefined : handleDeleteAccount} | ||
| > | ||
| Delete account | ||
| </Text> | ||
| </View> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { PaywallScreen } from "./PaywallScreen"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void> { | ||
| 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}`); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win Do not write the review email address to logs.
Proposed fix- console.log(`Created review user: ${email}`);
+ console.log("Created review user");
...
- console.log(`Review account ready: ${email} (onboarded, pro)`);
+ console.log("Review account ready (onboarded, pro)");Also applies to: 94-94 🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||
| } | ||
| 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, | ||
| }); | ||
| } | ||
|
Comment on lines
+59
to
+92
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Bind the subscription to the review organization.
Find or create the review organization by its deterministic review slug. Query the membership for that organization only. Insert the subscription with that organization ID. 🤖 Prompt for AI Agents |
||
|
|
||
| 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); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: superset-sh/superset
Length of output: 3467
🏁 Script executed:
Repository: superset-sh/superset
Length of output: 20811
Mask the password input.
AlertPromptModulecreates a plainUITextField, andPromptOptionshas no secure-input option. AddisSecureTextEntrysupport and enable it for the password prompt atEmailSignInLink.tsx:25.🤖 Prompt for AI Agents