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