feat(dev): switch plan-override to POST /api/dev/set-plan + add Settings debug row

debug.tsx: removed admin-403 special-case, calls /api/dev/set-plan directly.
settings.tsx: new PlanPickerSheetContent (TrueSheet, DEV-only) in debug section
with three plan options; uses same endpoint + invalidateMe().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-11 15:51:14 +02:00
parent 5c9f3f687f
commit 7369912d60
2 changed files with 129 additions and 15 deletions

View File

@ -137,23 +137,13 @@ function PlanOverrideToggle({
if (plan === currentPlan) return;
setLoading(true);
try {
// PATCH /api/admin/users/:id requires admin privileges.
// If the dev-user is not admin, this returns 403 — see alert below.
await apiFetch(`/api/admin/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify({ plan }),
await apiFetch('/api/dev/set-plan', {
method: 'POST',
body: { plan },
});
invalidateMe();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes('403')) {
Alert.alert(
'Kein Admin-Zugriff',
'PATCH /api/admin/users/:id setzt Admin-Rechte voraus. Plan manuell im Admin-Panel flippen.',
);
} else {
Alert.alert('Fehler', msg);
}
Alert.alert('Fehler', e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
@ -196,7 +186,7 @@ function PlanOverrideToggle({
lineHeight: 17,
}}
>
PATCH /api/admin/users/:id braucht Admin-Rechte
POST /api/dev/set-plan nur staging
</Text>
</View>
</View>

View File

@ -1,5 +1,6 @@
import {
Alert,
ActivityIndicator,
Linking,
Platform,
ScrollView,
@ -20,6 +21,8 @@ import { useAuthStore } from '../stores/auth';
import { useThemeStore, type ThemeMode } from '../stores/theme';
import { useLanguageStore, type AppLanguage } from '../stores/language';
import { useUserPlan } from '../hooks/useUserPlan';
import { useMe, invalidateMe, type Plan } from '../hooks/useMe';
import { apiFetch } from '../lib/api';
import { AppHeader } from '../components/AppHeader';
// ─── Subscription Sheet ────────────────────────────────────────────────────
@ -173,9 +176,12 @@ export default function SettingsScreen() {
// For now: picker is wired to local state only, changes are NOT persisted.
const [selectedVoice, setSelectedVoice] = useState('EXAVITQu4vr4xnSDxMaL');
const { me } = useMe();
// TrueSheet ref for Lyra-Voice picker (UISheetPresentationController bottom-sheet)
const voiceSheetRef = useRef<TrueSheet>(null);
const subscriptionSheetRef = useRef<TrueSheet>(null);
const planSheetRef = useRef<TrueSheet>(null);
async function handleSignOut() {
Alert.alert(t('auth.signOut'), '', [
@ -360,6 +366,13 @@ export default function SettingsScreen() {
sublabel: t('settings.debug_tts_desc'),
soon: true,
},
{
icon: 'star-outline',
label: t('settings.debug_plan'),
sublabel: t('settings.debug_plan_desc'),
value: me?.plan ?? '…',
onPress: () => planSheetRef.current?.present(),
},
],
});
}
@ -596,6 +609,23 @@ export default function SettingsScreen() {
<SubscriptionSheet plan={plan} colors={colors} t={t} />
</TrueSheet>
{__DEV__ && (
<TrueSheet
ref={planSheetRef}
detents={['auto']}
cornerRadius={20}
grabber
backgroundColor={colors.surface}
>
<PlanPickerSheetContent
currentPlan={me?.plan ?? 'free'}
colors={colors}
t={t}
onDone={() => planSheetRef.current?.dismiss()}
/>
</TrueSheet>
)}
<TrueSheet
ref={voiceSheetRef}
detents={['auto', 1]}
@ -667,3 +697,97 @@ export default function SettingsScreen() {
</View>
);
}
// ─── Plan-Override Sheet (DEV only) ───────────────────────────────────────
const DEV_PLANS: Plan[] = ['free', 'pro', 'legend'];
const DEV_PLAN_ACCENT: Record<Plan, string> = {
free: '#737373',
pro: '#007AFF',
legend: '#f59e0b',
};
function PlanPickerSheetContent({
currentPlan,
colors,
t,
onDone,
}: {
currentPlan: Plan;
colors: import('../lib/theme').ColorScheme;
t: (key: string) => string;
onDone: () => void;
}) {
const [loading, setLoading] = useState(false);
async function pick(plan: Plan) {
if (plan === currentPlan || loading) return;
setLoading(true);
try {
await apiFetch('/api/dev/set-plan', { method: 'POST', body: { plan } });
invalidateMe();
onDone();
} catch (e: unknown) {
Alert.alert(t('common.error'), e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}
return (
<View style={{ paddingHorizontal: 20, paddingTop: 8, paddingBottom: 32, backgroundColor: colors.surface }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text, marginBottom: 4 }}>
{t('settings.debug_plan')}
</Text>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted, marginBottom: 20, lineHeight: 17 }}>
{t('settings.debug_plan_desc')}
</Text>
{DEV_PLANS.map((plan, idx) => {
const isActive = plan === currentPlan;
const accent = DEV_PLAN_ACCENT[plan];
return (
<TouchableOpacity
key={plan}
onPress={() => pick(plan)}
disabled={loading || isActive}
activeOpacity={0.6}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
borderBottomWidth: idx < DEV_PLANS.length - 1 ? 1 : 0,
borderBottomColor: colors.border,
opacity: loading && !isActive ? 0.5 : 1,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<View style={{ width: 10, height: 10, borderRadius: 5, backgroundColor: accent }} />
<Text
style={{
fontSize: 16,
color: colors.text,
fontFamily: isActive ? 'Nunito_700Bold' : 'Nunito_400Regular',
textTransform: 'capitalize',
}}
>
{plan}
</Text>
</View>
{isActive ? (
loading ? (
<ActivityIndicator size="small" color={accent} />
) : (
<Ionicons name="checkmark" size={20} color={accent} />
)
) : null}
</View>
</TouchableOpacity>
);
})}
</View>
);
}