Browse Source

enable and disable apps based on magic code

drawing-pad
Stephanie Gredell 1 month ago
parent
commit
a01ebcc714
  1. 16
      backend/src/controllers/magicCode.controller.ts
  2. 69
      backend/src/controllers/settingsProfiles.controller.ts
  3. 5
      frontend/src/App.tsx
  4. 35
      frontend/src/components/AppRouteGuard.tsx
  5. 28
      frontend/src/pages/LandingPage.tsx
  6. 62
      frontend/src/pages/SettingsProfilesAdminPage.tsx
  7. 4
      frontend/src/services/apiClient.ts
  8. 2
      frontend/src/services/magicCodeService.ts
  9. 1
      frontend/src/types/api.ts
  10. 35
      frontend/src/utils/appFilter.ts

16
backend/src/controllers/magicCode.controller.ts

@ -52,17 +52,31 @@ export async function getSettingsByCode(req: Request, res: Response) {
// Parse numeric values // Parse numeric values
if (key === 'daily_time_limit_minutes') { if (key === 'daily_time_limit_minutes') {
settings[key] = parseInt(value, 10); 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 { } else {
settings[key] = value; settings[key] = value;
} }
} }
let enabledApps: string[] = [];
if (settings.enabled_apps && Array.isArray(settings.enabled_apps)) {
enabledApps = settings.enabled_apps;
}
res.json({ res.json({
success: true, success: true,
data: { data: {
magicCode: profile.magic_code, magicCode: profile.magic_code,
settings, settings,
dailyTimeLimit: settings.daily_time_limit_minutes || null dailyTimeLimit: settings.daily_time_limit_minutes || null,
enabledApps
} }
}); });
} catch (error: any) { } catch (error: any) {

69
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 => ({ 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, ...profile,
dailyTimeLimit: profile.settings.daily_time_limit_minutes dailyTimeLimit: profile.settings.daily_time_limit_minutes
? parseInt(profile.settings.daily_time_limit_minutes, 10) ? parseInt(profile.settings.daily_time_limit_minutes, 10)
: null : null,
})); enabledApps
};
});
res.json({ res.json({
success: true, 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; 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({ res.json({
success: true, success: true,
data: { data: {
@ -148,7 +169,8 @@ export async function getProfile(req: AuthRequest, res: Response) {
settings, settings,
dailyTimeLimit: settings.daily_time_limit_minutes dailyTimeLimit: settings.daily_time_limit_minutes
? parseInt(settings.daily_time_limit_minutes, 10) ? parseInt(settings.daily_time_limit_minutes, 10)
: null : null,
enabledApps
} }
}); });
} catch (error: any) { } 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) { if (!name || typeof name !== 'string' || name.trim().length === 0) {
return res.status(400).json({ 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()] 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 // Get created profile
const createdProfile = await db.execute({ const createdProfile = await db.execute({
sql: 'SELECT * FROM settings_profiles WHERE id = ?', 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 // Verify ownership
const existing = await db.execute({ 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 // Get updated settings
const settingsResult = await db.execute({ const settingsResult = await db.execute({
sql: 'SELECT setting_key, setting_value FROM settings_profile_values WHERE profile_id = ?', sql: 'SELECT setting_key, setting_value FROM settings_profile_values WHERE profile_id = ?',

5
frontend/src/App.tsx

@ -5,6 +5,7 @@ import { ErrorBoundary } from './components/ErrorBoundary';
import { Navbar } from './components/Navbar/Navbar'; import { Navbar } from './components/Navbar/Navbar';
import { Footer } from './components/Footer/Footer'; import { Footer } from './components/Footer/Footer';
import { ProtectedRoute } from './components/ProtectedRoute'; import { ProtectedRoute } from './components/ProtectedRoute';
import { AppRouteGuard } from './components/AppRouteGuard';
import { LandingPage } from './pages/LandingPage'; import { LandingPage } from './pages/LandingPage';
import { APPS } from './config/apps'; import { APPS } from './config/apps';
import { startConnectionTracking, stopConnectionTracking } from './services/connectionTracker'; import { startConnectionTracking, stopConnectionTracking } from './services/connectionTracker';
@ -50,15 +51,17 @@ function App() {
<Suspense fallback={<PageLoader />}> <Suspense fallback={<PageLoader />}>
<Routes> <Routes>
<Route path="/" element={<LandingPage />} /> <Route path="/" element={<LandingPage />} />
{/* Dynamically generate routes for enabled apps */} {/* Dynamically generate routes for apps */}
{APPS.filter(app => !app.disabled).map(app => ( {APPS.filter(app => !app.disabled).map(app => (
<Route <Route
key={app.id} key={app.id}
path={app.link} path={app.link}
element={ element={
<AppRouteGuard appId={app.id}>
<Suspense fallback={<PageLoader />}> <Suspense fallback={<PageLoader />}>
<app.component /> <app.component />
</Suspense> </Suspense>
</AppRouteGuard>
} }
/> />
))} ))}

35
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 (
<div className="min-h-[calc(100vh-60px)] flex items-center justify-center bg-background">
<div className="text-center p-6">
<h1 className="text-2xl font-bold text-foreground mb-2">App Not Available</h1>
<p className="text-muted-foreground mb-4">
This app is not enabled for your current settings.
</p>
<a
href="/"
className="inline-block px-4 py-2 bg-primary text-primary-foreground rounded-full font-semibold text-sm hover:bg-primary/90 transition-all"
>
Go Home
</a>
</div>
</div>
);
}
return <>{children}</>;
}

28
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 { Link } from 'react-router-dom';
import { APPS } from '../config/apps';
import { OptimizedImage } from '../components/OptimizedImage/OptimizedImage'; import { OptimizedImage } from '../components/OptimizedImage/OptimizedImage';
import { MagicCodeInput } from '../components/MagicCodeInput/MagicCodeInput'; import { MagicCodeInput } from '../components/MagicCodeInput/MagicCodeInput';
import { getAppliedMagicCode } from '../services/magicCodeService'; import { getAppliedMagicCode } from '../services/magicCodeService';
import { getEnabledApps } from '../utils/appFilter';
const categoryEmojis: { [key: string]: string } = { const categoryEmojis: { [key: string]: string } = {
videos: '📺', videos: '📺',
@ -29,14 +29,23 @@ const colorMap: { [key: string]: string } = {
export function LandingPage() { export function LandingPage() {
const [showMagicCodeModal, setShowMagicCodeModal] = useState(false); const [showMagicCodeModal, setShowMagicCodeModal] = useState(false);
const [enabledApps, setEnabledApps] = useState(getEnabledApps());
const appliedCode = getAppliedMagicCode(); const appliedCode = getAppliedMagicCode();
// Re-check enabled apps when magic code is applied/cleared
useEffect(() => {
setEnabledApps(getEnabledApps());
}, [appliedCode]);
return ( return (
<div className="bg-background"> <div className="bg-background">
{showMagicCodeModal && ( {showMagicCodeModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<MagicCodeInput <MagicCodeInput
onApplied={() => setShowMagicCodeModal(false)} onApplied={() => {
setShowMagicCodeModal(false);
setEnabledApps(getEnabledApps());
}}
onClose={() => setShowMagicCodeModal(false)} onClose={() => setShowMagicCodeModal(false)}
/> />
</div> </div>
@ -56,22 +65,15 @@ export function LandingPage() {
)} )}
{/* First card is likely LCP element - prioritize it */} {/* First card is likely LCP element - prioritize it */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-12"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
{APPS.map(app => { {enabledApps.map(app => {
const color = categoryColors[app.id] || 'pink'; const color = categoryColors[app.id] || 'pink';
const emoji = categoryEmojis[app.id] || '🎮'; const emoji = categoryEmojis[app.id] || '🎮';
return ( return (
<Link <Link
key={app.id} key={app.id}
to={app.disabled ? '#' : app.link} 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 ${ 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.disabled ? 'opacity-50 cursor-not-allowed' : ''
}`}
onClick={(e) => {
if (app.disabled) {
e.preventDefault();
}
}}
> >
<div className="mb-3"> <div className="mb-3">
{app.id === 'videos' ? ( {app.id === 'videos' ? (

62
frontend/src/pages/SettingsProfilesAdminPage.tsx

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { settingsProfilesApi } from '../services/apiClient'; import { settingsProfilesApi } from '../services/apiClient';
import { SettingsProfile } from '../types/api'; import { SettingsProfile } from '../types/api';
import { APPS } from '../config/apps';
export function SettingsProfilesAdminPage() { export function SettingsProfilesAdminPage() {
const [profiles, setProfiles] = useState<SettingsProfile[]>([]); const [profiles, setProfiles] = useState<SettingsProfile[]>([]);
@ -140,6 +141,7 @@ export function SettingsProfilesAdminPage() {
<th className="px-6 py-3 text-left text-xs font-semibold text-foreground uppercase">Name</th> <th className="px-6 py-3 text-left text-xs font-semibold text-foreground uppercase">Name</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-foreground uppercase">Magic Code</th> <th className="px-6 py-3 text-left text-xs font-semibold text-foreground uppercase">Magic Code</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-foreground uppercase">Time Limit</th> <th className="px-6 py-3 text-left text-xs font-semibold text-foreground uppercase">Time Limit</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-foreground uppercase">Enabled Apps</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-foreground uppercase">Status</th> <th className="px-6 py-3 text-left text-xs font-semibold text-foreground uppercase">Status</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-foreground uppercase">Created</th> <th className="px-6 py-3 text-left text-xs font-semibold text-foreground uppercase">Created</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-foreground uppercase">Actions</th> <th className="px-6 py-3 text-right text-xs font-semibold text-foreground uppercase">Actions</th>
@ -166,6 +168,22 @@ export function SettingsProfilesAdminPage() {
<td className="px-6 py-4 text-sm text-muted-foreground"> <td className="px-6 py-4 text-sm text-muted-foreground">
{formatTime(profile.dailyTimeLimit)} {formatTime(profile.dailyTimeLimit)}
</td> </td>
<td className="px-6 py-4 text-sm text-muted-foreground">
{profile.enabledApps && profile.enabledApps.length > 0 ? (
<div className="flex flex-wrap gap-1">
{profile.enabledApps.map(appId => {
const app = APPS.find(a => a.id === appId);
return app ? (
<span key={appId} className="px-2 py-1 bg-primary/10 text-primary rounded text-xs">
{app.name}
</span>
) : null;
})}
</div>
) : (
<span className="text-muted-foreground">All apps</span>
)}
</td>
<td className="px-6 py-4 text-sm"> <td className="px-6 py-4 text-sm">
<span className={`px-2 py-1 rounded-full text-xs font-semibold ${ <span className={`px-2 py-1 rounded-full text-xs font-semibold ${
profile.isActive profile.isActive
@ -242,9 +260,18 @@ function SettingsProfileFormModal({
const [name, setName] = useState(profile?.name || ''); const [name, setName] = useState(profile?.name || '');
const [description, setDescription] = useState(profile?.description || ''); const [description, setDescription] = useState(profile?.description || '');
const [dailyTimeLimit, setDailyTimeLimit] = useState(profile?.dailyTimeLimit?.toString() || '30'); const [dailyTimeLimit, setDailyTimeLimit] = useState(profile?.dailyTimeLimit?.toString() || '30');
const [enabledApps, setEnabledApps] = useState<string[]>(profile?.enabledApps || []);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const toggleApp = (appId: string) => {
setEnabledApps(prev =>
prev.includes(appId)
? prev.filter(id => id !== appId)
: [...prev, appId]
);
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
@ -265,10 +292,10 @@ function SettingsProfileFormModal({
if (profile) { if (profile) {
// Update existing profile // Update existing profile
await settingsProfilesApi.update(profile.id, { name, description }); await settingsProfilesApi.update(profile.id, { name, description });
await settingsProfilesApi.updateSettings(profile.id, { dailyTimeLimit: limit }); await settingsProfilesApi.updateSettings(profile.id, { dailyTimeLimit: limit, enabledApps });
} else { } else {
// Create new profile // Create new profile
await settingsProfilesApi.create({ name, description, dailyTimeLimit: limit }); await settingsProfilesApi.create({ name, description, dailyTimeLimit: limit, enabledApps });
} }
onSuccess(); onSuccess();
} catch (err: any) { } catch (err: any) {
@ -330,6 +357,37 @@ function SettingsProfileFormModal({
</p> </p>
</div> </div>
<div className="mb-6">
<label className="block text-sm font-semibold text-foreground mb-2">
Enabled Apps
</label>
<p className="text-xs text-muted-foreground mb-3">
Select which apps children can access. Leave all unchecked to allow all apps (including videos).
</p>
<div className="space-y-2">
{APPS.filter(app => !app.disabled).map(app => (
<label
key={app.id}
className="flex items-center gap-2 p-3 border border-border rounded-lg hover:bg-muted/50 cursor-pointer"
>
<input
type="checkbox"
checked={enabledApps.includes(app.id)}
onChange={() => toggleApp(app.id)}
className="w-4 h-4 text-primary border-border rounded focus:ring-primary"
/>
<span className="text-sm text-foreground font-medium">{app.name}</span>
<span className="text-xs text-muted-foreground ml-auto">{app.description}</span>
</label>
))}
</div>
{enabledApps.length === 0 && (
<p className="text-xs text-muted-foreground mt-2 italic">
All apps will be enabled (including videos)
</p>
)}
</div>
{profile && ( {profile && (
<div className="mb-6 p-4 bg-muted rounded-lg"> <div className="mb-6 p-4 bg-muted rounded-lg">
<label className="block text-sm font-semibold text-foreground mb-2"> <label className="block text-sm font-semibold text-foreground mb-2">

4
frontend/src/services/apiClient.ts

@ -177,7 +177,7 @@ export const settingsProfilesApi = {
getById: (id: number) => api.get(`/settings-profiles/${id}`), getById: (id: number) => api.get(`/settings-profiles/${id}`),
create: (data: { name: string; description?: string; dailyTimeLimit: number }) => create: (data: { name: string; description?: string; dailyTimeLimit: number; enabledApps?: string[] }) =>
api.post('/settings-profiles', data), api.post('/settings-profiles', data),
update: (id: number, data: { name?: string; description?: string; isActive?: boolean }) => update: (id: number, data: { name?: string; description?: string; isActive?: boolean }) =>
@ -185,7 +185,7 @@ export const settingsProfilesApi = {
delete: (id: number) => api.delete(`/settings-profiles/${id}`), delete: (id: number) => api.delete(`/settings-profiles/${id}`),
updateSettings: (id: number, settings: { dailyTimeLimit: number }) => updateSettings: (id: number, settings: { dailyTimeLimit?: number; enabledApps?: string[] }) =>
api.put(`/settings-profiles/${id}/settings`, settings), api.put(`/settings-profiles/${id}/settings`, settings),
regenerateCode: (id: number) => api.post(`/settings-profiles/${id}/regenerate-code`) regenerateCode: (id: number) => api.post(`/settings-profiles/${id}/regenerate-code`)

2
frontend/src/services/magicCodeService.ts

@ -3,6 +3,7 @@ const MAGIC_CODE_SETTINGS_KEY = 'magic_code_settings';
export interface MagicCodeSettings { export interface MagicCodeSettings {
dailyTimeLimit: number | null; dailyTimeLimit: number | null;
enabledApps: string[] | null;
appliedAt: string; appliedAt: string;
} }
@ -58,6 +59,7 @@ export async function applyMagicCode(code: string): Promise<MagicCodeSettings> {
const settings: MagicCodeSettings = { const settings: MagicCodeSettings = {
dailyTimeLimit: response.data.dailyTimeLimit, dailyTimeLimit: response.data.dailyTimeLimit,
enabledApps: response.data.enabledApps || null,
appliedAt: new Date().toISOString() appliedAt: new Date().toISOString()
}; };

1
frontend/src/types/api.ts

@ -51,6 +51,7 @@ export interface SettingsProfile {
updatedAt: string; updatedAt: string;
isActive: boolean; isActive: boolean;
dailyTimeLimit: number | null; dailyTimeLimit: number | null;
enabledApps?: string[];
} }
export interface ApiResponse<T = any> { export interface ApiResponse<T = any> {

35
frontend/src/utils/appFilter.ts

@ -0,0 +1,35 @@
import { APPS, App } from '../config/apps';
import { getMagicCodeSettings } from '../services/magicCodeService';
/**
* Get enabled apps based on magic code settings
* - If magic code is applied: use enabledApps from magic code (empty array = all apps enabled)
* - If no magic code: videos app falls back to disabled, other apps are enabled
*/
export function getEnabledApps(): App[] {
const magicCodeSettings = getMagicCodeSettings();
// If magic code is applied, use its enabled apps
if (magicCodeSettings?.enabledApps !== null && magicCodeSettings?.enabledApps !== undefined) {
const enabledAppIds = magicCodeSettings.enabledApps;
// Empty array means all apps enabled (including videos)
if (enabledAppIds.length === 0) {
return APPS.filter(app => !app.disabled);
}
// Return only apps that are in the enabled list
return APPS.filter(app => enabledAppIds.includes(app.id) && !app.disabled);
}
// No magic code: videos falls back to disabled, other apps are enabled
return APPS.filter(app => app.id !== 'videos' && !app.disabled);
}
/**
* Check if a specific app is enabled
*/
export function isAppEnabled(appId: string): boolean {
const enabledApps = getEnabledApps();
return enabledApps.some(app => app.id === appId);
}
Loading…
Cancel
Save