From 93f0318631a3209d2946cbe65c6b212c81d098d6 Mon Sep 17 00:00:00 2001 From: Stephanie Gredell Date: Thu, 11 Dec 2025 00:33:06 -0800 Subject: [PATCH] magic code creation --- .../src/controllers/magicCode.controller.ts | 78 +++ .../settingsProfiles.controller.ts | 593 ++++++++++++++++++ backend/src/db/migrate.ts | 55 ++ backend/src/index.ts | 4 + backend/src/routes/magicCode.routes.ts | 9 + backend/src/routes/settingsProfiles.routes.ts | 25 + backend/src/utils/magicCodeGenerator.ts | 44 ++ frontend/src/App.tsx | 9 + .../MagicCodeInput/MagicCodeInput.tsx | 159 +++++ frontend/src/pages/AdminPage.tsx | 15 + frontend/src/pages/LandingPage.tsx | 25 + .../src/pages/SettingsProfilesAdminPage.tsx | 385 ++++++++++++ frontend/src/services/apiClient.ts | 25 + frontend/src/services/magicCodeService.ts | 86 +++ frontend/src/services/timeLimitService.ts | 33 +- frontend/src/types/api.ts | 11 + 16 files changed, 1553 insertions(+), 3 deletions(-) create mode 100644 backend/src/controllers/magicCode.controller.ts create mode 100644 backend/src/controllers/settingsProfiles.controller.ts create mode 100644 backend/src/routes/magicCode.routes.ts create mode 100644 backend/src/routes/settingsProfiles.routes.ts create mode 100644 backend/src/utils/magicCodeGenerator.ts create mode 100644 frontend/src/components/MagicCodeInput/MagicCodeInput.tsx create mode 100644 frontend/src/pages/SettingsProfilesAdminPage.tsx create mode 100644 frontend/src/services/magicCodeService.ts diff --git a/backend/src/controllers/magicCode.controller.ts b/backend/src/controllers/magicCode.controller.ts new file mode 100644 index 0000000..35551f6 --- /dev/null +++ b/backend/src/controllers/magicCode.controller.ts @@ -0,0 +1,78 @@ +import { Response } from 'express'; +import { Request } from 'express'; +import { db } from '../config/database.js'; + +/** + * Public endpoint to get settings by magic code + * No authentication required - children use this to apply settings + */ +export async function getSettingsByCode(req: Request, res: Response) { + try { + const code = req.params.code?.toUpperCase().trim(); + + if (!code || code.length > 7) { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_CODE_FORMAT', + message: 'Invalid magic code format' + } + }); + } + + // Get profile by magic code + const profileResult = await db.execute({ + sql: 'SELECT * FROM settings_profiles WHERE magic_code = ? AND is_active = 1', + args: [code] + }); + + if (!profileResult.rows.length) { + return res.status(404).json({ + success: false, + error: { + code: 'CODE_NOT_FOUND', + message: 'Magic code not found or inactive' + } + }); + } + + const profile = profileResult.rows[0]; + + // Get settings + const settingsResult = await db.execute({ + sql: 'SELECT setting_key, setting_value FROM settings_profile_values WHERE profile_id = ?', + args: [profile.id] + }); + + const settings: Record = {}; + for (const row of settingsResult.rows) { + const key = row.setting_key as string; + const value = row.setting_value as string; + + // Parse numeric values + if (key === 'daily_time_limit_minutes') { + settings[key] = parseInt(value, 10); + } else { + settings[key] = value; + } + } + + res.json({ + success: true, + data: { + magicCode: profile.magic_code, + settings, + dailyTimeLimit: settings.daily_time_limit_minutes || null + } + }); + } catch (error: any) { + console.error('Get settings by code error:', error); + res.status(500).json({ + success: false, + error: { + code: 'GET_SETTINGS_ERROR', + message: 'Error fetching settings' + } + }); + } +} diff --git a/backend/src/controllers/settingsProfiles.controller.ts b/backend/src/controllers/settingsProfiles.controller.ts new file mode 100644 index 0000000..57af8a1 --- /dev/null +++ b/backend/src/controllers/settingsProfiles.controller.ts @@ -0,0 +1,593 @@ +import { Response } from 'express'; +import { AuthRequest } from '../types/index.js'; +import { db } from '../config/database.js'; +import { generateMagicCode } from '../utils/magicCodeGenerator.js'; + +export async function getAllProfiles(req: AuthRequest, res: Response) { + try { + if (!req.userId) { + return res.status(401).json({ + success: false, + error: { + code: 'UNAUTHORIZED', + message: 'Authentication required' + } + }); + } + + const result = await db.execute({ + sql: ` + SELECT + sp.id, + sp.magic_code, + sp.name, + sp.description, + sp.created_at, + sp.updated_at, + sp.is_active, + spv.setting_key, + spv.setting_value + FROM settings_profiles sp + LEFT JOIN settings_profile_values spv ON sp.id = spv.profile_id + WHERE sp.created_by = ? + ORDER BY sp.created_at DESC + `, + args: [req.userId] + }); + + // Group settings by profile + const profilesMap = new Map(); + + for (const row of result.rows) { + const profileId = row.id as number; + + if (!profilesMap.has(profileId)) { + profilesMap.set(profileId, { + id: profileId, + magicCode: row.magic_code, + name: row.name, + description: row.description, + createdAt: row.created_at, + updatedAt: row.updated_at, + isActive: row.is_active === 1, + settings: {} + }); + } + + const profile = profilesMap.get(profileId)!; + if (row.setting_key) { + profile.settings[row.setting_key] = row.setting_value; + } + } + + 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 + })); + + res.json({ + success: true, + data: profiles + }); + } catch (error: any) { + console.error('Get all profiles error:', error); + res.status(500).json({ + success: false, + error: { + code: 'GET_PROFILES_ERROR', + message: 'Error fetching settings profiles' + } + }); + } +} + +export async function getProfile(req: AuthRequest, res: Response) { + try { + if (!req.userId) { + return res.status(401).json({ + success: false, + error: { + code: 'UNAUTHORIZED', + message: 'Authentication required' + } + }); + } + + const profileId = parseInt(req.params.id); + if (!profileId || isNaN(profileId)) { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_PROFILE_ID', + message: 'Invalid profile ID' + } + }); + } + + // Get profile and verify ownership + const profileResult = await db.execute({ + sql: 'SELECT * FROM settings_profiles WHERE id = ? AND created_by = ?', + args: [profileId, req.userId] + }); + + if (!profileResult.rows.length) { + return res.status(404).json({ + success: false, + error: { + code: 'PROFILE_NOT_FOUND', + message: 'Profile not found or you do not have permission to access it' + } + }); + } + + const profile = profileResult.rows[0]; + + // Get settings + const settingsResult = await db.execute({ + sql: 'SELECT setting_key, setting_value FROM settings_profile_values WHERE profile_id = ?', + args: [profileId] + }); + + const settings: Record = {}; + for (const row of settingsResult.rows) { + settings[row.setting_key as string] = row.setting_value as string; + } + + res.json({ + success: true, + data: { + id: profile.id, + magicCode: profile.magic_code, + name: profile.name, + description: profile.description, + createdAt: profile.created_at, + updatedAt: profile.updated_at, + isActive: profile.is_active === 1, + settings, + dailyTimeLimit: settings.daily_time_limit_minutes + ? parseInt(settings.daily_time_limit_minutes, 10) + : null + } + }); + } catch (error: any) { + console.error('Get profile error:', error); + res.status(500).json({ + success: false, + error: { + code: 'GET_PROFILE_ERROR', + message: 'Error fetching settings profile' + } + }); + } +} + +export async function createProfile(req: AuthRequest, res: Response) { + try { + if (!req.userId) { + return res.status(401).json({ + success: false, + error: { + code: 'UNAUTHORIZED', + message: 'Authentication required' + } + }); + } + + const { name, description, dailyTimeLimit } = req.body; + + if (!name || typeof name !== 'string' || name.trim().length === 0) { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_NAME', + message: 'Name is required' + } + }); + } + + if (!dailyTimeLimit || typeof dailyTimeLimit !== 'number' || dailyTimeLimit < 1) { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_TIME_LIMIT', + message: 'Daily time limit must be a number greater than 0' + } + }); + } + + // Generate unique magic code + const magicCode = await generateMagicCode(); + + // Create profile + const profileResult = await db.execute({ + sql: ` + INSERT INTO settings_profiles (magic_code, name, description, created_by) + VALUES (?, ?, ?, ?) + `, + args: [magicCode, name.trim(), description?.trim() || null, req.userId] + }); + + const profileId = profileResult.lastInsertRowid as number; + + // Add settings + await db.execute({ + sql: ` + INSERT INTO settings_profile_values (profile_id, setting_key, setting_value) + VALUES (?, ?, ?) + `, + args: [profileId, 'daily_time_limit_minutes', dailyTimeLimit.toString()] + }); + + // Get created profile + const createdProfile = await db.execute({ + sql: 'SELECT * FROM settings_profiles WHERE id = ?', + args: [profileId] + }); + + res.status(201).json({ + success: true, + data: { + id: createdProfile.rows[0].id, + magicCode: createdProfile.rows[0].magic_code, + name: createdProfile.rows[0].name, + description: createdProfile.rows[0].description, + createdAt: createdProfile.rows[0].created_at, + updatedAt: createdProfile.rows[0].updated_at, + isActive: createdProfile.rows[0].is_active === 1, + dailyTimeLimit + } + }); + } catch (error: any) { + console.error('Create profile error:', error); + res.status(500).json({ + success: false, + error: { + code: 'CREATE_PROFILE_ERROR', + message: 'Error creating settings profile' + } + }); + } +} + +export async function updateProfile(req: AuthRequest, res: Response) { + try { + if (!req.userId) { + return res.status(401).json({ + success: false, + error: { + code: 'UNAUTHORIZED', + message: 'Authentication required' + } + }); + } + + const profileId = parseInt(req.params.id); + if (!profileId || isNaN(profileId)) { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_PROFILE_ID', + message: 'Invalid profile ID' + } + }); + } + + const { name, description, isActive } = req.body; + + // Verify ownership + const existing = await db.execute({ + sql: 'SELECT id FROM settings_profiles WHERE id = ? AND created_by = ?', + args: [profileId, req.userId] + }); + + if (!existing.rows.length) { + return res.status(404).json({ + success: false, + error: { + code: 'PROFILE_NOT_FOUND', + message: 'Profile not found or you do not have permission to update it' + } + }); + } + + // Build update query + const updates: string[] = []; + const args: any[] = []; + + if (name !== undefined) { + if (typeof name !== 'string' || name.trim().length === 0) { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_NAME', + message: 'Name must be a non-empty string' + } + }); + } + updates.push('name = ?'); + args.push(name.trim()); + } + + if (description !== undefined) { + updates.push('description = ?'); + args.push(description?.trim() || null); + } + + if (isActive !== undefined) { + updates.push('is_active = ?'); + args.push(isActive ? 1 : 0); + } + + if (updates.length === 0) { + return res.status(400).json({ + success: false, + error: { + code: 'NO_UPDATES', + message: 'No fields to update' + } + }); + } + + updates.push('updated_at = ?'); + args.push(new Date().toISOString()); + args.push(profileId); + + await db.execute({ + sql: `UPDATE settings_profiles SET ${updates.join(', ')} WHERE id = ?`, + args + }); + + // Get updated profile + const updated = await db.execute({ + sql: 'SELECT * FROM settings_profiles WHERE id = ?', + args: [profileId] + }); + + res.json({ + success: true, + data: { + id: updated.rows[0].id, + magicCode: updated.rows[0].magic_code, + name: updated.rows[0].name, + description: updated.rows[0].description, + createdAt: updated.rows[0].created_at, + updatedAt: updated.rows[0].updated_at, + isActive: updated.rows[0].is_active === 1 + } + }); + } catch (error: any) { + console.error('Update profile error:', error); + res.status(500).json({ + success: false, + error: { + code: 'UPDATE_PROFILE_ERROR', + message: 'Error updating settings profile' + } + }); + } +} + +export async function deleteProfile(req: AuthRequest, res: Response) { + try { + if (!req.userId) { + return res.status(401).json({ + success: false, + error: { + code: 'UNAUTHORIZED', + message: 'Authentication required' + } + }); + } + + const profileId = parseInt(req.params.id); + if (!profileId || isNaN(profileId)) { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_PROFILE_ID', + message: 'Invalid profile ID' + } + }); + } + + // Verify ownership + const existing = await db.execute({ + sql: 'SELECT id FROM settings_profiles WHERE id = ? AND created_by = ?', + args: [profileId, req.userId] + }); + + if (!existing.rows.length) { + return res.status(404).json({ + success: false, + error: { + code: 'PROFILE_NOT_FOUND', + message: 'Profile not found or you do not have permission to delete it' + } + }); + } + + // Delete profile (cascade will handle settings) + await db.execute({ + sql: 'DELETE FROM settings_profiles WHERE id = ?', + args: [profileId] + }); + + res.json({ + success: true, + data: { message: 'Profile deleted successfully' } + }); + } catch (error: any) { + console.error('Delete profile error:', error); + res.status(500).json({ + success: false, + error: { + code: 'DELETE_PROFILE_ERROR', + message: 'Error deleting settings profile' + } + }); + } +} + +export async function updateProfileSettings(req: AuthRequest, res: Response) { + try { + if (!req.userId) { + return res.status(401).json({ + success: false, + error: { + code: 'UNAUTHORIZED', + message: 'Authentication required' + } + }); + } + + const profileId = parseInt(req.params.id); + if (!profileId || isNaN(profileId)) { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_PROFILE_ID', + message: 'Invalid profile ID' + } + }); + } + + const { dailyTimeLimit } = req.body; + + // Verify ownership + const existing = await db.execute({ + sql: 'SELECT id FROM settings_profiles WHERE id = ? AND created_by = ?', + args: [profileId, req.userId] + }); + + if (!existing.rows.length) { + return res.status(404).json({ + success: false, + error: { + code: 'PROFILE_NOT_FOUND', + message: 'Profile not found or you do not have permission to update it' + } + }); + } + + if (dailyTimeLimit !== undefined) { + if (typeof dailyTimeLimit !== 'number' || dailyTimeLimit < 1) { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_TIME_LIMIT', + message: 'Daily time limit must be a number greater than 0' + } + }); + } + + // 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, 'daily_time_limit_minutes', dailyTimeLimit.toString(), new Date().toISOString()] + }); + } + + // Get updated settings + const settingsResult = await db.execute({ + sql: 'SELECT setting_key, setting_value FROM settings_profile_values WHERE profile_id = ?', + args: [profileId] + }); + + const settings: Record = {}; + for (const row of settingsResult.rows) { + settings[row.setting_key as string] = row.setting_value as string; + } + + res.json({ + success: true, + data: { + dailyTimeLimit: settings.daily_time_limit_minutes + ? parseInt(settings.daily_time_limit_minutes, 10) + : null + } + }); + } catch (error: any) { + console.error('Update profile settings error:', error); + res.status(500).json({ + success: false, + error: { + code: 'UPDATE_SETTINGS_ERROR', + message: 'Error updating profile settings' + } + }); + } +} + +export async function regenerateMagicCode(req: AuthRequest, res: Response) { + try { + if (!req.userId) { + return res.status(401).json({ + success: false, + error: { + code: 'UNAUTHORIZED', + message: 'Authentication required' + } + }); + } + + const profileId = parseInt(req.params.id); + if (!profileId || isNaN(profileId)) { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_PROFILE_ID', + message: 'Invalid profile ID' + } + }); + } + + // Verify ownership + const existing = await db.execute({ + sql: 'SELECT id FROM settings_profiles WHERE id = ? AND created_by = ?', + args: [profileId, req.userId] + }); + + if (!existing.rows.length) { + return res.status(404).json({ + success: false, + error: { + code: 'PROFILE_NOT_FOUND', + message: 'Profile not found or you do not have permission to regenerate its code' + } + }); + } + + // Generate new magic code + const newMagicCode = await generateMagicCode(); + + // Update profile + await db.execute({ + sql: 'UPDATE settings_profiles SET magic_code = ?, updated_at = ? WHERE id = ?', + args: [newMagicCode, new Date().toISOString(), profileId] + }); + + res.json({ + success: true, + data: { + magicCode: newMagicCode + } + }); + } catch (error: any) { + console.error('Regenerate magic code error:', error); + res.status(500).json({ + success: false, + error: { + code: 'REGENERATE_CODE_ERROR', + message: 'Error regenerating magic code' + } + }); + } +} diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index e5270ad..3d1cfd5 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -242,6 +242,61 @@ const migrations = [ throw error; } } + }, + { + id: 6, + name: 'create_settings_profiles', + up: async () => { + // Check if tables already exist + const profilesTableCheck = await db.execute(` + SELECT name FROM sqlite_master + WHERE type='table' AND name='settings_profiles' + `); + + if (profilesTableCheck.rows.length === 0) { + // Create settings_profiles table + await db.execute(` + CREATE TABLE settings_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + magic_code TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + description TEXT, + created_by INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT 1, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + // Create indexes + await db.execute('CREATE INDEX IF NOT EXISTS idx_settings_profiles_magic_code ON settings_profiles(magic_code)'); + await db.execute('CREATE INDEX IF NOT EXISTS idx_settings_profiles_created_by ON settings_profiles(created_by)'); + await db.execute('CREATE INDEX IF NOT EXISTS idx_settings_profiles_is_active ON settings_profiles(is_active)'); + + // Create settings_profile_values table + await db.execute(` + CREATE TABLE settings_profile_values ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + profile_id INTEGER NOT NULL, + setting_key TEXT NOT NULL, + setting_value TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (profile_id) REFERENCES settings_profiles(id) ON DELETE CASCADE, + UNIQUE(profile_id, setting_key) + ) + `); + + // Create indexes + await db.execute('CREATE INDEX IF NOT EXISTS idx_settings_profile_values_profile_id ON settings_profile_values(profile_id)'); + await db.execute('CREATE INDEX IF NOT EXISTS idx_settings_profile_values_key ON settings_profile_values(setting_key)'); + + console.log('✓ Created settings_profiles and settings_profile_values tables'); + } else { + console.log('✓ Settings profiles tables already exist, skipping'); + } + } } ]; diff --git a/backend/src/index.ts b/backend/src/index.ts index e0ff25f..ddcac69 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -11,6 +11,8 @@ import videoRoutes from './routes/videos.routes.js'; import settingsRoutes from './routes/settings.routes.js'; import wordGroupsRoutes from './routes/wordGroups.routes.js'; import usersRoutes from './routes/users.routes.js'; +import settingsProfilesRoutes from './routes/settingsProfiles.routes.js'; +import magicCodeRoutes from './routes/magicCode.routes.js'; import { errorHandler } from './middleware/errorHandler.js'; import { apiLimiter } from './middleware/rateLimiter.js'; import { createWebSocketServer } from './services/websocket.service.js'; @@ -52,6 +54,8 @@ async function startServer() { app.use('/api/settings', settingsRoutes); app.use('/api/word-groups', wordGroupsRoutes); app.use('/api/users', usersRoutes); + app.use('/api/settings-profiles', settingsProfilesRoutes); + app.use('/api/magic-code', magicCodeRoutes); // Error handling app.use(errorHandler); diff --git a/backend/src/routes/magicCode.routes.ts b/backend/src/routes/magicCode.routes.ts new file mode 100644 index 0000000..2c7b79d --- /dev/null +++ b/backend/src/routes/magicCode.routes.ts @@ -0,0 +1,9 @@ +import { Router } from 'express'; +import { getSettingsByCode } from '../controllers/magicCode.controller.js'; + +const router = Router(); + +// Public route - no authentication required +router.get('/:code', getSettingsByCode); + +export default router; diff --git a/backend/src/routes/settingsProfiles.routes.ts b/backend/src/routes/settingsProfiles.routes.ts new file mode 100644 index 0000000..88302a0 --- /dev/null +++ b/backend/src/routes/settingsProfiles.routes.ts @@ -0,0 +1,25 @@ +import { Router } from 'express'; +import { + getAllProfiles, + getProfile, + createProfile, + updateProfile, + deleteProfile, + updateProfileSettings, + regenerateMagicCode +} from '../controllers/settingsProfiles.controller.js'; +import { authMiddleware } from '../middleware/auth.js'; +import { adminMiddleware } from '../middleware/admin.js'; + +const router = Router(); + +// All routes require admin authentication +router.get('/', authMiddleware, adminMiddleware, getAllProfiles); +router.post('/', authMiddleware, adminMiddleware, createProfile); +router.get('/:id', authMiddleware, adminMiddleware, getProfile); +router.put('/:id', authMiddleware, adminMiddleware, updateProfile); +router.delete('/:id', authMiddleware, adminMiddleware, deleteProfile); +router.put('/:id/settings', authMiddleware, adminMiddleware, updateProfileSettings); +router.post('/:id/regenerate-code', authMiddleware, adminMiddleware, regenerateMagicCode); + +export default router; diff --git a/backend/src/utils/magicCodeGenerator.ts b/backend/src/utils/magicCodeGenerator.ts new file mode 100644 index 0000000..a4c39ba --- /dev/null +++ b/backend/src/utils/magicCodeGenerator.ts @@ -0,0 +1,44 @@ +import { db } from '../config/database.js'; +import crypto from 'crypto'; + +/** + * Generate a unique 7-character alphanumeric magic code + * Format: A-Z (uppercase), 0-9 + * Examples: "ABC1234", "XYZ7890", "KID2024" + */ +export async function generateMagicCode(): Promise { + const maxAttempts = 10; + let attempts = 0; + + while (attempts < maxAttempts) { + // Generate 4 random bytes (gives us 8 hex chars, we'll use 7) + const randomBytes = crypto.randomBytes(4); + const hexString = randomBytes.toString('hex').toUpperCase(); + + // Convert to alphanumeric (remove any non-alphanumeric if needed) + // Take first 7 characters and ensure they're alphanumeric + let code = ''; + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + + // Generate 7 random characters from our charset + for (let i = 0; i < 7; i++) { + const randomIndex = crypto.randomInt(0, chars.length); + code += chars[randomIndex]; + } + + // Check if code already exists + const existing = await db.execute({ + sql: 'SELECT id FROM settings_profiles WHERE magic_code = ?', + args: [code] + }); + + if (existing.rows.length === 0) { + return code; + } + + attempts++; + } + + // If we've tried 10 times and still have collisions, something is wrong + throw new Error('Failed to generate unique magic code after multiple attempts'); +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 41e9fb7..73feee5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ const VideosAdminPage = lazy(() => import('./pages/VideosAdminPage').then(module const SpeechSoundsAdminPage = lazy(() => import('./pages/SpeechSoundsAdminPage').then(module => ({ default: module.SpeechSoundsAdminPage }))); const StatsAdminPage = lazy(() => import('./pages/StatsAdminPage').then(module => ({ default: module.StatsAdminPage }))); const UsersAdminPage = lazy(() => import('./pages/UsersAdminPage').then(module => ({ default: module.UsersAdminPage }))); +const SettingsProfilesAdminPage = lazy(() => import('./pages/SettingsProfilesAdminPage').then(module => ({ default: module.SettingsProfilesAdminPage }))); const LoginPage = lazy(() => import('./pages/LoginPage').then(module => ({ default: module.LoginPage }))); // Loading fallback component @@ -103,6 +104,14 @@ function App() { } /> + + + + } + /> diff --git a/frontend/src/components/MagicCodeInput/MagicCodeInput.tsx b/frontend/src/components/MagicCodeInput/MagicCodeInput.tsx new file mode 100644 index 0000000..55e22d3 --- /dev/null +++ b/frontend/src/components/MagicCodeInput/MagicCodeInput.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react'; +import { applyMagicCode, clearMagicCode, getAppliedMagicCode, getMagicCodeSettings } from '../../services/magicCodeService'; + +interface MagicCodeInputProps { + onApplied?: () => void; + onClose?: () => void; +} + +export function MagicCodeInput({ onApplied, onClose }: MagicCodeInputProps) { + const [code, setCode] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const appliedCode = getAppliedMagicCode(); + const settings = getMagicCodeSettings(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setSuccess(false); + + const normalizedCode = code.toUpperCase().trim(); + + if (!normalizedCode || normalizedCode.length === 0) { + setError('Please enter a magic code'); + return; + } + + if (normalizedCode.length > 7) { + setError('Magic code must be 7 characters or less'); + return; + } + + try { + setLoading(true); + const appliedSettings = await applyMagicCode(normalizedCode); + setSuccess(true); + setCode(''); + + if (onApplied) { + onApplied(); + } + + // Auto-close after showing success message + setTimeout(() => { + if (onClose) { + onClose(); + } + }, 2000); + } catch (err: any) { + setError(err.error?.message || 'Invalid magic code. Please check and try again.'); + } finally { + setLoading(false); + } + }; + + const handleClear = () => { + clearMagicCode(); + setCode(''); + setError(null); + setSuccess(false); + if (onApplied) { + onApplied(); + } + }; + + const formatTime = (minutes: number | null) => { + if (!minutes) return 'Not set'; + if (minutes < 60) return `${minutes} minutes`; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return mins > 0 ? `${hours} hour${hours !== 1 ? 's' : ''} ${mins} minute${mins !== 1 ? 's' : ''}` : `${hours} hour${hours !== 1 ? 's' : ''}`; + }; + + return ( +
+

Enter Magic Code

+

+ Ask your parent for a magic code to apply settings +

+ + {appliedCode && settings && ( +
+

✓ Settings Applied

+

+ Code: {appliedCode} +

+ {settings.dailyTimeLimit && ( +

+ Daily time limit: {formatTime(settings.dailyTimeLimit)} +

+ )} + +
+ )} + +
+
+ + { + const value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 7); + setCode(value); + setError(null); + }} + placeholder="ABC1234" + maxLength={7} + className="w-full px-4 py-3 border border-border rounded-lg bg-background text-foreground text-center text-2xl font-mono font-bold focus:outline-none focus:ring-2 focus:ring-primary" + autoFocus + /> +

+ Enter up to 7 characters +

+
+ + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ ✓ Settings applied successfully! +
+ )} + +
+ {onClose && ( + + )} + +
+
+
+ ); +} diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 3ffb16f..3ec97c6 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -79,6 +79,21 @@ export function AdminPage() { Manage admin and user accounts

+ + +
+
+ ✨ +
+
+

Settings Profiles

+

+ Create magic codes for child settings +

+ ); diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index d6bb160..830b11f 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -1,6 +1,9 @@ +import { useState } 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'; const categoryEmojis: { [key: string]: string } = { videos: '📺', @@ -25,10 +28,32 @@ const colorMap: { [key: string]: string } = { }; export function LandingPage() { + const [showMagicCodeModal, setShowMagicCodeModal] = useState(false); + const appliedCode = getAppliedMagicCode(); + return (
+ {showMagicCodeModal && ( +
+ setShowMagicCodeModal(false)} + onClose={() => setShowMagicCodeModal(false)} + /> +
+ )} +
+ {!appliedCode && ( +
+ +
+ )} {/* First card is likely LCP element - prioritize it */}
{APPS.map(app => { diff --git a/frontend/src/pages/SettingsProfilesAdminPage.tsx b/frontend/src/pages/SettingsProfilesAdminPage.tsx new file mode 100644 index 0000000..f51c5c4 --- /dev/null +++ b/frontend/src/pages/SettingsProfilesAdminPage.tsx @@ -0,0 +1,385 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { settingsProfilesApi } from '../services/apiClient'; +import { SettingsProfile } from '../types/api'; + +export function SettingsProfilesAdminPage() { + const [profiles, setProfiles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showCreateModal, setShowCreateModal] = useState(false); + const [editingProfile, setEditingProfile] = useState(null); + + useEffect(() => { + loadProfiles(); + }, []); + + const loadProfiles = async () => { + try { + setLoading(true); + setError(null); + const response: any = await settingsProfilesApi.getAll(); + setProfiles(response.data); + } catch (err: any) { + setError(err.error?.message || 'Failed to load settings profiles'); + } finally { + setLoading(false); + } + }; + + const handleDelete = async (profileId: number) => { + if (!confirm('Are you sure you want to delete this settings profile? The magic code will no longer work.')) { + return; + } + + try { + await settingsProfilesApi.delete(profileId); + await loadProfiles(); + } catch (err: any) { + alert(err.error?.message || 'Failed to delete profile'); + } + }; + + const handleCopyCode = async (code: string) => { + try { + await navigator.clipboard.writeText(code); + alert('Magic code copied to clipboard!'); + } catch (err) { + // Fallback for older browsers + const textArea = document.createElement('textarea'); + textArea.value = code; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + alert('Magic code copied to clipboard!'); + } + }; + + const handleRegenerateCode = async (profileId: number) => { + if (!confirm('Are you sure you want to regenerate the magic code? The old code will stop working.')) { + return; + } + + try { + const response: any = await settingsProfilesApi.regenerateCode(profileId); + await loadProfiles(); + alert(`New magic code: ${response.data.magicCode}`); + } catch (err: any) { + alert(err.error?.message || 'Failed to regenerate code'); + } + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString(); + }; + + const formatTime = (minutes: number | null) => { + if (!minutes) return 'Not set'; + if (minutes < 60) return `${minutes} min`; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; + }; + + if (loading) { + return ( +
+
+
+

Loading settings profiles...

+
+
+ ); + } + + return ( +
+
+ + ← Back to Admin + +

Settings Profiles

+

Create magic codes to apply settings for children without accounts

+
+ +
+
+ +
+ + {error && ( +
+ {error} +
+ )} + + {profiles.length === 0 ? ( +
+

No settings profiles yet.

+ +
+ ) : ( +
+ + + + + + + + + + + + + {profiles.map((profile) => ( + + + + + + + + + ))} + +
NameMagic CodeTime LimitStatusCreatedActions
{profile.name} +
+ + {profile.magicCode} + + +
+
+ {formatTime(profile.dailyTimeLimit)} + + + {profile.isActive ? 'Active' : 'Inactive'} + + {formatDate(profile.createdAt)} +
+ + + +
+
+
+ )} +
+ + {showCreateModal && ( + setShowCreateModal(false)} + onSuccess={() => { + setShowCreateModal(false); + loadProfiles(); + }} + /> + )} + + {editingProfile && ( + setEditingProfile(null)} + onSuccess={() => { + setEditingProfile(null); + loadProfiles(); + }} + /> + )} +
+ ); +} + +function SettingsProfileFormModal({ + profile, + onClose, + onSuccess +}: { + profile?: SettingsProfile; + onClose: () => void; + onSuccess: () => void; +}) { + const [name, setName] = useState(profile?.name || ''); + const [description, setDescription] = useState(profile?.description || ''); + const [dailyTimeLimit, setDailyTimeLimit] = useState(profile?.dailyTimeLimit?.toString() || '30'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!name.trim()) { + setError('Name is required'); + return; + } + + const limit = parseInt(dailyTimeLimit, 10); + if (isNaN(limit) || limit < 1) { + setError('Daily time limit must be at least 1 minute'); + return; + } + + try { + setLoading(true); + if (profile) { + // Update existing profile + await settingsProfilesApi.update(profile.id, { name, description }); + await settingsProfilesApi.updateSettings(profile.id, { dailyTimeLimit: limit }); + } else { + // Create new profile + await settingsProfilesApi.create({ name, description, dailyTimeLimit: limit }); + } + onSuccess(); + } catch (err: any) { + setError(err.error?.message || 'Failed to save profile'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

+ {profile ? 'Edit Settings Profile' : 'Create Settings Profile'} +

+ +
+
+ + setName(e.target.value)} + placeholder="e.g., Emma's iPad" + className="w-full px-4 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary" + required + /> +
+ +
+ + setDescription(e.target.value)} + placeholder="e.g., For daily use" + className="w-full px-4 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+ +
+ + setDailyTimeLimit(e.target.value)} + className="w-full px-4 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary" + required + /> +

+ Maximum minutes per day children can watch videos +

+
+ + {profile && ( +
+ +
+ + {profile.magicCode} + + +
+

+ Share this code with your child to apply these settings +

+
+ )} + + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/services/apiClient.ts b/frontend/src/services/apiClient.ts index 02b2477..20b9a8c 100644 --- a/frontend/src/services/apiClient.ts +++ b/frontend/src/services/apiClient.ts @@ -171,3 +171,28 @@ export const usersApi = { api.put(`/users/${id}/password`, { password }) }; +// Settings Profiles API (admin only) +export const settingsProfilesApi = { + getAll: () => api.get('/settings-profiles'), + + getById: (id: number) => api.get(`/settings-profiles/${id}`), + + create: (data: { name: string; description?: string; dailyTimeLimit: number }) => + api.post('/settings-profiles', data), + + update: (id: number, data: { name?: string; description?: string; isActive?: boolean }) => + api.put(`/settings-profiles/${id}`, data), + + delete: (id: number) => api.delete(`/settings-profiles/${id}`), + + updateSettings: (id: number, settings: { dailyTimeLimit: number }) => + api.put(`/settings-profiles/${id}/settings`, settings), + + regenerateCode: (id: number) => api.post(`/settings-profiles/${id}/regenerate-code`) +}; + +// Magic Code API (public) +export const magicCodeApi = { + getSettingsByCode: (code: string) => api.get(`/magic-code/${code}`) +}; + diff --git a/frontend/src/services/magicCodeService.ts b/frontend/src/services/magicCodeService.ts new file mode 100644 index 0000000..95d9615 --- /dev/null +++ b/frontend/src/services/magicCodeService.ts @@ -0,0 +1,86 @@ +const MAGIC_CODE_KEY = 'magic_code'; +const MAGIC_CODE_SETTINGS_KEY = 'magic_code_settings'; + +export interface MagicCodeSettings { + dailyTimeLimit: number | null; + appliedAt: string; +} + +/** + * Get the currently applied magic code from localStorage + */ +export function getAppliedMagicCode(): string | null { + try { + return localStorage.getItem(MAGIC_CODE_KEY); + } catch (e) { + console.warn('Failed to get magic code from localStorage', e); + return null; + } +} + +/** + * Get the settings from the currently applied magic code + */ +export function getMagicCodeSettings(): MagicCodeSettings | null { + try { + const stored = localStorage.getItem(MAGIC_CODE_SETTINGS_KEY); + if (stored) { + return JSON.parse(stored); + } + } catch (e) { + console.warn('Failed to parse magic code settings from localStorage', e); + } + return null; +} + +/** + * Check if a magic code is currently applied + */ +export function hasMagicCode(): boolean { + return getAppliedMagicCode() !== null; +} + +/** + * Apply a magic code by fetching settings and storing them locally + */ +export async function applyMagicCode(code: string): Promise { + const { magicCodeApi } = await import('./apiClient'); + + // Normalize code (uppercase, trim) + const normalizedCode = code.toUpperCase().trim(); + + if (normalizedCode.length > 7) { + throw new Error('Magic code must be 7 characters or less'); + } + + // Fetch settings from server + const response: any = await magicCodeApi.getSettingsByCode(normalizedCode); + + const settings: MagicCodeSettings = { + dailyTimeLimit: response.data.dailyTimeLimit, + appliedAt: new Date().toISOString() + }; + + // Store in localStorage + try { + localStorage.setItem(MAGIC_CODE_KEY, normalizedCode); + localStorage.setItem(MAGIC_CODE_SETTINGS_KEY, JSON.stringify(settings)); + } catch (e) { + console.warn('Failed to save magic code to localStorage', e); + throw new Error('Failed to save magic code settings'); + } + + return settings; +} + +/** + * Clear the applied magic code and settings + */ +export function clearMagicCode(): void { + try { + localStorage.removeItem(MAGIC_CODE_KEY); + localStorage.removeItem(MAGIC_CODE_SETTINGS_KEY); + } catch (e) { + console.warn('Failed to clear magic code from localStorage', e); + } +} diff --git a/frontend/src/services/timeLimitService.ts b/frontend/src/services/timeLimitService.ts index 5d77db1..42d712e 100644 --- a/frontend/src/services/timeLimitService.ts +++ b/frontend/src/services/timeLimitService.ts @@ -81,10 +81,21 @@ function resetIfNeeded(): void { } /** - * Get current time limit configuration from server - * Falls back to cached value or default if server unavailable + * Get current time limit configuration + * Priority: Magic code settings > Server settings > Cached > Default */ export async function getDailyLimit(): Promise { + // Check magic code settings first (highest priority) + try { + const { getMagicCodeSettings } = await import('./magicCodeService'); + const magicCodeSettings = getMagicCodeSettings(); + if (magicCodeSettings?.dailyTimeLimit !== null && magicCodeSettings.dailyTimeLimit !== undefined) { + return magicCodeSettings.dailyTimeLimit; + } + } catch (error) { + console.warn('Failed to check magic code settings:', error); + } + // Return cached value if still valid const now = Date.now(); if (cachedDailyLimit !== null && (now - limitCacheTime) < LIMIT_CACHE_DURATION) { @@ -121,9 +132,25 @@ export async function setDailyLimit(minutes: number): Promise { } /** - * Synchronous version for use in hooks (uses cached value) + * Synchronous version for use in hooks (uses cached value or magic code settings) + * Note: Magic code settings are checked synchronously from localStorage */ export function getDailyLimitSync(): number { + // Check magic code settings first (highest priority) + // We can access localStorage synchronously + try { + const MAGIC_CODE_SETTINGS_KEY = 'magic_code_settings'; + const stored = localStorage.getItem(MAGIC_CODE_SETTINGS_KEY); + if (stored) { + const magicCodeSettings = JSON.parse(stored); + if (magicCodeSettings?.dailyTimeLimit !== null && magicCodeSettings.dailyTimeLimit !== undefined) { + return magicCodeSettings.dailyTimeLimit; + } + } + } catch (error) { + // Ignore errors in sync context + } + return cachedDailyLimit ?? DEFAULT_DAILY_LIMIT; } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index a2eb30f..c647cb8 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -42,6 +42,17 @@ export interface AdminUser { lastLogin?: string; } +export interface SettingsProfile { + id: number; + magicCode: string; + name: string; + description?: string; + createdAt: string; + updatedAt: string; + isActive: boolean; + dailyTimeLimit: number | null; +} + export interface ApiResponse { success: boolean; data?: T;