16 changed files with 1553 additions and 3 deletions
@ -0,0 +1,78 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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