16 changed files with 1553 additions and 3 deletions
@ -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<string, any> = {}; |
||||||
|
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' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
@ -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<number, any>(); |
||||||
|
|
||||||
|
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<string, string> = {}; |
||||||
|
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<string, string> = {}; |
||||||
|
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' |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
@ -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; |
||||||
@ -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<string> { |
||||||
|
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'); |
||||||
|
} |
||||||
@ -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<string | null>(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 ( |
||||||
|
<div className="bg-card rounded-xl p-6 border border-border max-w-md w-full"> |
||||||
|
<h2 className="text-xl font-bold text-foreground mb-2">Enter Magic Code</h2> |
||||||
|
<p className="text-sm text-muted-foreground mb-4"> |
||||||
|
Ask your parent for a magic code to apply settings |
||||||
|
</p> |
||||||
|
|
||||||
|
{appliedCode && settings && ( |
||||||
|
<div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg"> |
||||||
|
<p className="text-sm font-semibold text-green-800 mb-1">✓ Settings Applied</p> |
||||||
|
<p className="text-xs text-green-700"> |
||||||
|
Code: <strong>{appliedCode}</strong> |
||||||
|
</p> |
||||||
|
{settings.dailyTimeLimit && ( |
||||||
|
<p className="text-xs text-green-700 mt-1"> |
||||||
|
Daily time limit: <strong>{formatTime(settings.dailyTimeLimit)}</strong> |
||||||
|
</p> |
||||||
|
)} |
||||||
|
<button |
||||||
|
onClick={handleClear} |
||||||
|
className="mt-2 text-xs text-green-700 hover:underline" |
||||||
|
> |
||||||
|
Clear settings |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}> |
||||||
|
<div className="mb-4"> |
||||||
|
<label htmlFor="magic-code" className="block text-sm font-semibold text-foreground mb-2"> |
||||||
|
Magic Code |
||||||
|
</label> |
||||||
|
<input |
||||||
|
id="magic-code" |
||||||
|
type="text" |
||||||
|
value={code} |
||||||
|
onChange={(e) => { |
||||||
|
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 |
||||||
|
/> |
||||||
|
<p className="text-xs text-muted-foreground mt-1 text-center"> |
||||||
|
Enter up to 7 characters |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
{error && ( |
||||||
|
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive text-sm"> |
||||||
|
{error} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{success && ( |
||||||
|
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-green-800 text-sm"> |
||||||
|
✓ Settings applied successfully! |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
<div className="flex gap-3"> |
||||||
|
{onClose && ( |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
onClick={onClose} |
||||||
|
className="flex-1 px-4 py-2 text-sm font-semibold text-foreground hover:bg-muted rounded-lg transition-colors" |
||||||
|
> |
||||||
|
Cancel |
||||||
|
</button> |
||||||
|
)} |
||||||
|
<button |
||||||
|
type="submit" |
||||||
|
disabled={loading || !code.trim()} |
||||||
|
className="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg font-semibold text-sm hover:bg-primary/90 transition-all active:scale-95 shadow-md disabled:opacity-50 disabled:cursor-not-allowed" |
||||||
|
> |
||||||
|
{loading ? 'Applying...' : 'Apply Settings'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -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<SettingsProfile[]>([]); |
||||||
|
const [loading, setLoading] = useState(true); |
||||||
|
const [error, setError] = useState<string | null>(null); |
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false); |
||||||
|
const [editingProfile, setEditingProfile] = useState<SettingsProfile | null>(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 ( |
||||||
|
<div className="min-h-[calc(100vh-60px)] bg-background flex items-center justify-center"> |
||||||
|
<div className="text-center"> |
||||||
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-4"></div> |
||||||
|
<p className="text-muted-foreground">Loading settings profiles...</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="min-h-[calc(100vh-60px)] bg-background"> |
||||||
|
<div className="bg-card border-b border-border py-8 px-6 text-center"> |
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className="inline-block mb-4 px-4 py-2 bg-transparent border border-border rounded-md text-foreground text-sm cursor-pointer transition-colors no-underline hover:bg-muted" |
||||||
|
> |
||||||
|
← Back to Admin |
||||||
|
</Link> |
||||||
|
<h1 className="m-0 mb-2 text-[28px] font-medium text-foreground">Settings Profiles</h1> |
||||||
|
<p className="m-0 text-sm text-muted-foreground">Create magic codes to apply settings for children without accounts</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto p-6"> |
||||||
|
<div className="mb-6 flex justify-end"> |
||||||
|
<button |
||||||
|
onClick={() => setShowCreateModal(true)} |
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-full font-semibold text-sm hover:bg-primary/90 transition-all active:scale-95 shadow-md" |
||||||
|
> |
||||||
|
+ Create Profile |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{error && ( |
||||||
|
<div className="mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive"> |
||||||
|
{error} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{profiles.length === 0 ? ( |
||||||
|
<div className="bg-card rounded-lg border border-border p-12 text-center"> |
||||||
|
<p className="text-muted-foreground mb-4">No settings profiles yet.</p> |
||||||
|
<button |
||||||
|
onClick={() => setShowCreateModal(true)} |
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-full font-semibold text-sm hover:bg-primary/90 transition-all active:scale-95 shadow-md" |
||||||
|
> |
||||||
|
Create Your First Profile |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
) : ( |
||||||
|
<div className="bg-card rounded-lg border border-border overflow-hidden"> |
||||||
|
<table className="w-full"> |
||||||
|
<thead className="bg-muted"> |
||||||
|
<tr> |
||||||
|
<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">Time Limit</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-right text-xs font-semibold text-foreground uppercase">Actions</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody className="divide-y divide-border"> |
||||||
|
{profiles.map((profile) => ( |
||||||
|
<tr key={profile.id} className="hover:bg-muted/50"> |
||||||
|
<td className="px-6 py-4 text-sm font-medium text-foreground">{profile.name}</td> |
||||||
|
<td className="px-6 py-4 text-sm"> |
||||||
|
<div className="flex items-center gap-2"> |
||||||
|
<code className="px-2 py-1 bg-muted rounded font-mono text-sm font-bold"> |
||||||
|
{profile.magicCode} |
||||||
|
</code> |
||||||
|
<button |
||||||
|
onClick={() => handleCopyCode(profile.magicCode)} |
||||||
|
className="text-xs text-primary hover:underline" |
||||||
|
title="Copy code" |
||||||
|
> |
||||||
|
📋 |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</td> |
||||||
|
<td className="px-6 py-4 text-sm text-muted-foreground"> |
||||||
|
{formatTime(profile.dailyTimeLimit)} |
||||||
|
</td> |
||||||
|
<td className="px-6 py-4 text-sm"> |
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-semibold ${ |
||||||
|
profile.isActive
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-gray-100 text-gray-800' |
||||||
|
}`}>
|
||||||
|
{profile.isActive ? 'Active' : 'Inactive'} |
||||||
|
</span> |
||||||
|
</td> |
||||||
|
<td className="px-6 py-4 text-sm text-muted-foreground">{formatDate(profile.createdAt)}</td> |
||||||
|
<td className="px-6 py-4 text-sm text-right"> |
||||||
|
<div className="flex items-center justify-end gap-2"> |
||||||
|
<button |
||||||
|
onClick={() => setEditingProfile(profile)} |
||||||
|
className="px-3 py-1 text-xs font-semibold text-foreground hover:bg-muted rounded-full transition-colors" |
||||||
|
> |
||||||
|
Edit |
||||||
|
</button> |
||||||
|
<button |
||||||
|
onClick={() => handleRegenerateCode(profile.id)} |
||||||
|
className="px-3 py-1 text-xs font-semibold text-primary hover:bg-primary/10 rounded-full transition-colors" |
||||||
|
> |
||||||
|
Regenerate |
||||||
|
</button> |
||||||
|
<button |
||||||
|
onClick={() => handleDelete(profile.id)} |
||||||
|
className="px-3 py-1 text-xs font-semibold text-destructive hover:bg-destructive/10 rounded-full transition-colors" |
||||||
|
> |
||||||
|
Delete |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
))} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
|
||||||
|
{showCreateModal && ( |
||||||
|
<SettingsProfileFormModal |
||||||
|
onClose={() => setShowCreateModal(false)} |
||||||
|
onSuccess={() => { |
||||||
|
setShowCreateModal(false); |
||||||
|
loadProfiles(); |
||||||
|
}} |
||||||
|
/> |
||||||
|
)} |
||||||
|
|
||||||
|
{editingProfile && ( |
||||||
|
<SettingsProfileFormModal |
||||||
|
profile={editingProfile} |
||||||
|
onClose={() => setEditingProfile(null)} |
||||||
|
onSuccess={() => { |
||||||
|
setEditingProfile(null); |
||||||
|
loadProfiles(); |
||||||
|
}} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
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<string | null>(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 ( |
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> |
||||||
|
<div className="bg-card rounded-lg border border-border max-w-md w-full p-6"> |
||||||
|
<h2 className="text-xl font-bold text-foreground mb-4"> |
||||||
|
{profile ? 'Edit Settings Profile' : 'Create Settings Profile'} |
||||||
|
</h2> |
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}> |
||||||
|
<div className="mb-4"> |
||||||
|
<label className="block text-sm font-semibold text-foreground mb-2"> |
||||||
|
Name * |
||||||
|
</label> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
value={name} |
||||||
|
onChange={(e) => 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 |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="mb-4"> |
||||||
|
<label className="block text-sm font-semibold text-foreground mb-2"> |
||||||
|
Description (optional) |
||||||
|
</label> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
value={description} |
||||||
|
onChange={(e) => 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" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="mb-6"> |
||||||
|
<label className="block text-sm font-semibold text-foreground mb-2"> |
||||||
|
Daily Time Limit (minutes) * |
||||||
|
</label> |
||||||
|
<input |
||||||
|
type="number" |
||||||
|
min="1" |
||||||
|
value={dailyTimeLimit} |
||||||
|
onChange={(e) => 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 |
||||||
|
/> |
||||||
|
<p className="text-xs text-muted-foreground mt-1"> |
||||||
|
Maximum minutes per day children can watch videos |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
{profile && ( |
||||||
|
<div className="mb-6 p-4 bg-muted rounded-lg"> |
||||||
|
<label className="block text-sm font-semibold text-foreground mb-2"> |
||||||
|
Magic Code |
||||||
|
</label> |
||||||
|
<div className="flex items-center gap-2"> |
||||||
|
<code className="flex-1 px-3 py-2 bg-background border border-border rounded-lg font-mono text-lg font-bold text-center"> |
||||||
|
{profile.magicCode} |
||||||
|
</code> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
onClick={() => { |
||||||
|
navigator.clipboard.writeText(profile.magicCode); |
||||||
|
alert('Code copied!'); |
||||||
|
}} |
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors" |
||||||
|
> |
||||||
|
Copy |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
<p className="text-xs text-muted-foreground mt-2"> |
||||||
|
Share this code with your child to apply these settings |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{error && ( |
||||||
|
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive text-sm"> |
||||||
|
{error} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
<div className="flex gap-3 justify-end"> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
onClick={onClose} |
||||||
|
className="px-4 py-2 text-sm font-semibold text-foreground hover:bg-muted rounded-full transition-colors" |
||||||
|
> |
||||||
|
Cancel |
||||||
|
</button> |
||||||
|
<button |
||||||
|
type="submit" |
||||||
|
disabled={loading} |
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-full font-semibold text-sm hover:bg-primary/90 transition-all active:scale-95 shadow-md disabled:opacity-50" |
||||||
|
> |
||||||
|
{loading ? 'Saving...' : profile ? 'Update' : 'Create'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -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<MagicCodeSettings> { |
||||||
|
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); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue