From a01ebcc7141627d82dee501f77763849c9a2e37c Mon Sep 17 00:00:00 2001 From: Stephanie Gredell Date: Thu, 11 Dec 2025 00:39:57 -0800 Subject: [PATCH] enable and disable apps based on magic code --- .../src/controllers/magicCode.controller.ts | 16 +++- .../settingsProfiles.controller.ts | 75 ++++++++++++++++--- frontend/src/App.tsx | 11 ++- frontend/src/components/AppRouteGuard.tsx | 35 +++++++++ frontend/src/pages/LandingPage.tsx | 28 +++---- .../src/pages/SettingsProfilesAdminPage.tsx | 62 ++++++++++++++- frontend/src/services/apiClient.ts | 4 +- frontend/src/services/magicCodeService.ts | 2 + frontend/src/types/api.ts | 1 + frontend/src/utils/appFilter.ts | 35 +++++++++ 10 files changed, 238 insertions(+), 31 deletions(-) create mode 100644 frontend/src/components/AppRouteGuard.tsx create mode 100644 frontend/src/utils/appFilter.ts diff --git a/backend/src/controllers/magicCode.controller.ts b/backend/src/controllers/magicCode.controller.ts index 35551f6..d12828d 100644 --- a/backend/src/controllers/magicCode.controller.ts +++ b/backend/src/controllers/magicCode.controller.ts @@ -52,17 +52,31 @@ export async function getSettingsByCode(req: Request, res: Response) { // Parse numeric values if (key === 'daily_time_limit_minutes') { settings[key] = parseInt(value, 10); + } else if (key === 'enabled_apps') { + // Parse JSON array + try { + settings[key] = JSON.parse(value); + } catch (e) { + console.warn('Failed to parse enabled_apps:', e); + settings[key] = []; + } } else { settings[key] = value; } } + let enabledApps: string[] = []; + if (settings.enabled_apps && Array.isArray(settings.enabled_apps)) { + enabledApps = settings.enabled_apps; + } + res.json({ success: true, data: { magicCode: profile.magic_code, settings, - dailyTimeLimit: settings.daily_time_limit_minutes || null + dailyTimeLimit: settings.daily_time_limit_minutes || null, + enabledApps } }); } catch (error: any) { diff --git a/backend/src/controllers/settingsProfiles.controller.ts b/backend/src/controllers/settingsProfiles.controller.ts index 57af8a1..ac04987 100644 --- a/backend/src/controllers/settingsProfiles.controller.ts +++ b/backend/src/controllers/settingsProfiles.controller.ts @@ -60,12 +60,24 @@ export async function getAllProfiles(req: AuthRequest, res: Response) { } } - const profiles = Array.from(profilesMap.values()).map(profile => ({ - ...profile, - dailyTimeLimit: profile.settings.daily_time_limit_minutes - ? parseInt(profile.settings.daily_time_limit_minutes, 10) - : null - })); + const profiles = Array.from(profilesMap.values()).map(profile => { + let enabledApps: string[] = []; + if (profile.settings.enabled_apps) { + try { + enabledApps = JSON.parse(profile.settings.enabled_apps); + } catch (e) { + console.warn('Failed to parse enabled_apps for profile', profile.id); + } + } + + return { + ...profile, + dailyTimeLimit: profile.settings.daily_time_limit_minutes + ? parseInt(profile.settings.daily_time_limit_minutes, 10) + : null, + enabledApps + }; + }); res.json({ success: true, @@ -135,6 +147,15 @@ export async function getProfile(req: AuthRequest, res: Response) { settings[row.setting_key as string] = row.setting_value as string; } + let enabledApps: string[] = []; + if (settings.enabled_apps) { + try { + enabledApps = JSON.parse(settings.enabled_apps); + } catch (e) { + console.warn('Failed to parse enabled_apps for profile', profileId); + } + } + res.json({ success: true, data: { @@ -148,7 +169,8 @@ export async function getProfile(req: AuthRequest, res: Response) { settings, dailyTimeLimit: settings.daily_time_limit_minutes ? parseInt(settings.daily_time_limit_minutes, 10) - : null + : null, + enabledApps } }); } catch (error: any) { @@ -175,7 +197,7 @@ export async function createProfile(req: AuthRequest, res: Response) { }); } - const { name, description, dailyTimeLimit } = req.body; + const { name, description, dailyTimeLimit, enabledApps } = req.body; if (!name || typeof name !== 'string' || name.trim().length === 0) { return res.status(400).json({ @@ -220,6 +242,17 @@ export async function createProfile(req: AuthRequest, res: Response) { args: [profileId, 'daily_time_limit_minutes', dailyTimeLimit.toString()] }); + // Add enabled apps if provided + if (enabledApps && Array.isArray(enabledApps)) { + await db.execute({ + sql: ` + INSERT INTO settings_profile_values (profile_id, setting_key, setting_value) + VALUES (?, ?, ?) + `, + args: [profileId, 'enabled_apps', JSON.stringify(enabledApps)] + }); + } + // Get created profile const createdProfile = await db.execute({ sql: 'SELECT * FROM settings_profiles WHERE id = ?', @@ -453,7 +486,7 @@ export async function updateProfileSettings(req: AuthRequest, res: Response) { }); } - const { dailyTimeLimit } = req.body; + const { dailyTimeLimit, enabledApps } = req.body; // Verify ownership const existing = await db.execute({ @@ -495,6 +528,30 @@ export async function updateProfileSettings(req: AuthRequest, res: Response) { }); } + if (enabledApps !== undefined) { + if (!Array.isArray(enabledApps)) { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_ENABLED_APPS', + message: 'enabledApps must be an array' + } + }); + } + + // Update or insert setting + await db.execute({ + sql: ` + INSERT INTO settings_profile_values (profile_id, setting_key, setting_value, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(profile_id, setting_key) DO UPDATE SET + setting_value = excluded.setting_value, + updated_at = excluded.updated_at + `, + args: [profileId, 'enabled_apps', JSON.stringify(enabledApps), new Date().toISOString()] + }); + } + // Get updated settings const settingsResult = await db.execute({ sql: 'SELECT setting_key, setting_value FROM settings_profile_values WHERE profile_id = ?', diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 73feee5..01a2451 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { ErrorBoundary } from './components/ErrorBoundary'; import { Navbar } from './components/Navbar/Navbar'; import { Footer } from './components/Footer/Footer'; import { ProtectedRoute } from './components/ProtectedRoute'; +import { AppRouteGuard } from './components/AppRouteGuard'; import { LandingPage } from './pages/LandingPage'; import { APPS } from './config/apps'; import { startConnectionTracking, stopConnectionTracking } from './services/connectionTracker'; @@ -50,15 +51,17 @@ function App() { }> } /> - {/* Dynamically generate routes for enabled apps */} + {/* Dynamically generate routes for apps */} {APPS.filter(app => !app.disabled).map(app => ( }> - - + + }> + + + } /> ))} diff --git a/frontend/src/components/AppRouteGuard.tsx b/frontend/src/components/AppRouteGuard.tsx new file mode 100644 index 0000000..1a2327d --- /dev/null +++ b/frontend/src/components/AppRouteGuard.tsx @@ -0,0 +1,35 @@ +import { Navigate } from 'react-router-dom'; +import { isAppEnabled } from '../utils/appFilter'; + +interface AppRouteGuardProps { + appId: string; + children: React.ReactNode; +} + +/** + * Guards app routes based on magic code settings + * - Videos app: falls back to disabled if no magic code + * - Other apps: always enabled unless magic code disables them + */ +export function AppRouteGuard({ appId, children }: AppRouteGuardProps) { + if (!isAppEnabled(appId)) { + return ( +
+
+

App Not Available

+

+ This app is not enabled for your current settings. +

+ + Go Home + +
+
+ ); + } + + return <>{children}; +} diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index 830b11f..ec7fd75 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -1,9 +1,9 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; -import { APPS } from '../config/apps'; import { OptimizedImage } from '../components/OptimizedImage/OptimizedImage'; import { MagicCodeInput } from '../components/MagicCodeInput/MagicCodeInput'; import { getAppliedMagicCode } from '../services/magicCodeService'; +import { getEnabledApps } from '../utils/appFilter'; const categoryEmojis: { [key: string]: string } = { videos: '📺', @@ -29,14 +29,23 @@ const colorMap: { [key: string]: string } = { export function LandingPage() { const [showMagicCodeModal, setShowMagicCodeModal] = useState(false); + const [enabledApps, setEnabledApps] = useState(getEnabledApps()); const appliedCode = getAppliedMagicCode(); + // Re-check enabled apps when magic code is applied/cleared + useEffect(() => { + setEnabledApps(getEnabledApps()); + }, [appliedCode]); + return (
{showMagicCodeModal && (
setShowMagicCodeModal(false)} + onApplied={() => { + setShowMagicCodeModal(false); + setEnabledApps(getEnabledApps()); + }} onClose={() => setShowMagicCodeModal(false)} />
@@ -56,22 +65,15 @@ export function LandingPage() { )} {/* First card is likely LCP element - prioritize it */}
- {APPS.map(app => { + {enabledApps.map(app => { const color = categoryColors[app.id] || 'pink'; const emoji = categoryEmojis[app.id] || '🎮'; return ( { - if (app.disabled) { - e.preventDefault(); - } - }} + to={app.link} + className={`${colorMap[color]} w-full p-6 rounded-3xl font-semibold text-foreground transition-all active:scale-95 hover:shadow-lg flex flex-col items-center text-center`} >
{app.id === 'videos' ? ( diff --git a/frontend/src/pages/SettingsProfilesAdminPage.tsx b/frontend/src/pages/SettingsProfilesAdminPage.tsx index f51c5c4..aa83542 100644 --- a/frontend/src/pages/SettingsProfilesAdminPage.tsx +++ b/frontend/src/pages/SettingsProfilesAdminPage.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { settingsProfilesApi } from '../services/apiClient'; import { SettingsProfile } from '../types/api'; +import { APPS } from '../config/apps'; export function SettingsProfilesAdminPage() { const [profiles, setProfiles] = useState([]); @@ -140,6 +141,7 @@ export function SettingsProfilesAdminPage() { Name Magic Code Time Limit + Enabled Apps Status Created Actions @@ -166,6 +168,22 @@ export function SettingsProfilesAdminPage() { {formatTime(profile.dailyTimeLimit)} + + {profile.enabledApps && profile.enabledApps.length > 0 ? ( +
+ {profile.enabledApps.map(appId => { + const app = APPS.find(a => a.id === appId); + return app ? ( + + {app.name} + + ) : null; + })} +
+ ) : ( + All apps + )} + (profile?.enabledApps || []); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const toggleApp = (appId: string) => { + setEnabledApps(prev => + prev.includes(appId) + ? prev.filter(id => id !== appId) + : [...prev, appId] + ); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); @@ -265,10 +292,10 @@ function SettingsProfileFormModal({ if (profile) { // Update existing profile await settingsProfilesApi.update(profile.id, { name, description }); - await settingsProfilesApi.updateSettings(profile.id, { dailyTimeLimit: limit }); + await settingsProfilesApi.updateSettings(profile.id, { dailyTimeLimit: limit, enabledApps }); } else { // Create new profile - await settingsProfilesApi.create({ name, description, dailyTimeLimit: limit }); + await settingsProfilesApi.create({ name, description, dailyTimeLimit: limit, enabledApps }); } onSuccess(); } catch (err: any) { @@ -330,6 +357,37 @@ function SettingsProfileFormModal({

+
+ +

+ Select which apps children can access. Leave all unchecked to allow all apps (including videos). +

+
+ {APPS.filter(app => !app.disabled).map(app => ( + + ))} +
+ {enabledApps.length === 0 && ( +

+ All apps will be enabled (including videos) +

+ )} +
+ {profile && (