Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/mobile/app/paywall.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { PaywallScreen } from "@/screens/paywall";

export default PaywallScreen;
4 changes: 4 additions & 0 deletions apps/mobile/lib/trpc/client.ts
Original file line number Diff line number Diff line change
@@ -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<AppRouter>({
links: [
httpBatchLink({
Expand All @@ -12,6 +15,7 @@ export const apiClient = createTRPCProxyClient<AppRouter>({
const cookies = authClient.getCookie();
const jwt = getJwt();
return {
"x-superset-client": clientVersionHeader,
...(cookies ? { Cookie: cookies } : {}),
...(jwt ? { Authorization: `Bearer ${jwt}` } : {}),
};
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/screens/(auth)/sign-in/SignInScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -115,6 +116,7 @@ export function SignInScreen() {
onPress={() => handleSignIn("google")}
className="w-4/5"
/>
<EmailSignInLink onError={setError} />
{(__DEV__ || env.EXPO_PUBLIC_E2E === "1") && <DevSignInOptions />}
</View>

Expand Down
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",
});
Comment on lines +25 to +29

Copy link
Copy Markdown
Contributor

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:

#!/bin/bash
set -euo pipefail

fd -t f . apps/mobile/modules/alert-prompt | sort
rg -n -C 5 'PromptOptions|secure|password|prompt\(' apps/mobile/modules/alert-prompt

Repository: superset-sh/superset

Length of output: 3467


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- native prompt implementation ---'
cat -n apps/mobile/modules/alert-prompt/ios/AlertPromptModule.swift

printf '%s\n' '--- TypeScript API ---'
cat -n apps/mobile/modules/alert-prompt/src/index.ts

printf '%s\n' '--- component ---'
cat -n 'apps/mobile/screens/(auth)/sign-in/components/EmailSignInLink/EmailSignInLink.tsx'

printf '%s\n' '--- prompt usages ---'
rg -n -C 6 'prompt\(' apps/mobile --glob '*.{ts,tsx}'

Repository: superset-sh/superset

Length of output: 20811


Mask the password input. AlertPromptModule creates a plain UITextField, and PromptOptions has no secure-input option. Add isSecureTextEntry support and enable it for the password prompt at EmailSignInLink.tsx:25.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/mobile/screens/`(auth)/sign-in/components/EmailSignInLink/EmailSignInLink.tsx
around lines 25 - 29, Update AlertPromptModule and PromptOptions to support an
isSecureTextEntry setting on the created UITextField, then enable that setting
in the password prompt within EmailSignInLink. Preserve existing prompt behavior
for callers that do not request secure input.

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
Expand Up @@ -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();
Expand Down
10 changes: 9 additions & 1 deletion apps/mobile/screens/RootLayout/RootLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,24 @@ export function RootLayout() {
if (isPending) return null;

const pendingDeletion = !!session?.user.deletionRequestedAt;
const hasPaidPlan = !!session?.session.plan;

return (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClient}>
<PostHogProvider>
<ThemeProvider value={NAV_THEME.dark}>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Protected guard={!!session && !pendingDeletion}>
<Stack.Protected
guard={!!session && !pendingDeletion && hasPaidPlan}
>
<Stack.Screen name="(authenticated)" />
</Stack.Protected>
<Stack.Protected
guard={!!session && !pendingDeletion && !hasPaidPlan}
>
<Stack.Screen name="paywall" />
</Stack.Protected>
<Stack.Protected guard={pendingDeletion}>
<Stack.Screen name="account-pending-deletion" />
</Stack.Protected>
Expand Down
127 changes: 127 additions & 0 deletions apps/mobile/screens/paywall/PaywallScreen.tsx
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>
);
}
1 change: 1 addition & 0 deletions apps/mobile/screens/paywall/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { PaywallScreen } from "./PaywallScreen";
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
102 changes: 102 additions & 0 deletions packages/auth/src/seed-review-account.ts
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}`);

Copy link
Copy Markdown
Contributor

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

Do not write the review email address to logs.

APP_REVIEW_EMAIL is personal data. These messages can persist in shared CI and deployment logs. Log the event state without the email address.

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 Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/auth/src/seed-review-account.ts` at line 32, Update the
review-account creation logging in seedReviewAccount to omit the
APP_REVIEW_EMAIL value from both creation and related log messages. Log only a
generic event or status while preserving the existing success and failure
behavior.

Source: 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

findFirst can select any organization when the review user has multiple memberships. The script can then insert a Pro subscription for an unrelated organization. That organization receives paid mobile access because packages/auth/src/server.ts derives session.plan from its active subscription.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/auth/src/seed-review-account.ts` around lines 59 - 92, The
membership lookup in the review-account seeding flow must target the
deterministic review organization instead of any organization owned by the user.
Use the `${user.id.slice(0, 8)}-review` slug to find or create that
organization, query membership for its organization ID, and use that same ID
when checking and inserting the active Pro subscription.


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);
});
10 changes: 7 additions & 3 deletions packages/auth/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading
Loading