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)}
+
+ )}
+
+
+ )}
+
+
+
+ );
+}
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.
+
+
+ ) : (
+
+
+
+
+ | Name |
+ Magic Code |
+ Time Limit |
+ Status |
+ Created |
+ Actions |
+
+
+
+ {profiles.map((profile) => (
+
+ | {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'}
+
+
+
+
+
+ );
+}
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;