Browse Source

magic code creation

drawing-pad
Stephanie Gredell 1 month ago
parent
commit
93f0318631
  1. 78
      backend/src/controllers/magicCode.controller.ts
  2. 593
      backend/src/controllers/settingsProfiles.controller.ts
  3. 55
      backend/src/db/migrate.ts
  4. 4
      backend/src/index.ts
  5. 9
      backend/src/routes/magicCode.routes.ts
  6. 25
      backend/src/routes/settingsProfiles.routes.ts
  7. 44
      backend/src/utils/magicCodeGenerator.ts
  8. 9
      frontend/src/App.tsx
  9. 159
      frontend/src/components/MagicCodeInput/MagicCodeInput.tsx
  10. 15
      frontend/src/pages/AdminPage.tsx
  11. 25
      frontend/src/pages/LandingPage.tsx
  12. 385
      frontend/src/pages/SettingsProfilesAdminPage.tsx
  13. 25
      frontend/src/services/apiClient.ts
  14. 86
      frontend/src/services/magicCodeService.ts
  15. 33
      frontend/src/services/timeLimitService.ts
  16. 11
      frontend/src/types/api.ts

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

@ -0,0 +1,78 @@
import { Response } from 'express';
import { Request } from 'express';
import { db } from '../config/database.js';
/**
* Public endpoint to get settings by magic code
* No authentication required - children use this to apply settings
*/
export async function getSettingsByCode(req: Request, res: Response) {
try {
const code = req.params.code?.toUpperCase().trim();
if (!code || code.length > 7) {
return res.status(400).json({
success: false,
error: {
code: 'INVALID_CODE_FORMAT',
message: 'Invalid magic code format'
}
});
}
// Get profile by magic code
const profileResult = await db.execute({
sql: 'SELECT * FROM settings_profiles WHERE magic_code = ? AND is_active = 1',
args: [code]
});
if (!profileResult.rows.length) {
return res.status(404).json({
success: false,
error: {
code: 'CODE_NOT_FOUND',
message: 'Magic code not found or inactive'
}
});
}
const profile = profileResult.rows[0];
// Get settings
const settingsResult = await db.execute({
sql: 'SELECT setting_key, setting_value FROM settings_profile_values WHERE profile_id = ?',
args: [profile.id]
});
const settings: Record<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'
}
});
}
}

593
backend/src/controllers/settingsProfiles.controller.ts

@ -0,0 +1,593 @@
import { Response } from 'express';
import { AuthRequest } from '../types/index.js';
import { db } from '../config/database.js';
import { generateMagicCode } from '../utils/magicCodeGenerator.js';
export async function getAllProfiles(req: AuthRequest, res: Response) {
try {
if (!req.userId) {
return res.status(401).json({
success: false,
error: {
code: 'UNAUTHORIZED',
message: 'Authentication required'
}
});
}
const result = await db.execute({
sql: `
SELECT
sp.id,
sp.magic_code,
sp.name,
sp.description,
sp.created_at,
sp.updated_at,
sp.is_active,
spv.setting_key,
spv.setting_value
FROM settings_profiles sp
LEFT JOIN settings_profile_values spv ON sp.id = spv.profile_id
WHERE sp.created_by = ?
ORDER BY sp.created_at DESC
`,
args: [req.userId]
});
// Group settings by profile
const profilesMap = new Map<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'
}
});
}
}

55
backend/src/db/migrate.ts

@ -242,6 +242,61 @@ const migrations = [
throw error; throw error;
} }
} }
},
{
id: 6,
name: 'create_settings_profiles',
up: async () => {
// Check if tables already exist
const profilesTableCheck = await db.execute(`
SELECT name FROM sqlite_master
WHERE type='table' AND name='settings_profiles'
`);
if (profilesTableCheck.rows.length === 0) {
// Create settings_profiles table
await db.execute(`
CREATE TABLE settings_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
magic_code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
description TEXT,
created_by INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Create indexes
await db.execute('CREATE INDEX IF NOT EXISTS idx_settings_profiles_magic_code ON settings_profiles(magic_code)');
await db.execute('CREATE INDEX IF NOT EXISTS idx_settings_profiles_created_by ON settings_profiles(created_by)');
await db.execute('CREATE INDEX IF NOT EXISTS idx_settings_profiles_is_active ON settings_profiles(is_active)');
// Create settings_profile_values table
await db.execute(`
CREATE TABLE settings_profile_values (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER NOT NULL,
setting_key TEXT NOT NULL,
setting_value TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (profile_id) REFERENCES settings_profiles(id) ON DELETE CASCADE,
UNIQUE(profile_id, setting_key)
)
`);
// Create indexes
await db.execute('CREATE INDEX IF NOT EXISTS idx_settings_profile_values_profile_id ON settings_profile_values(profile_id)');
await db.execute('CREATE INDEX IF NOT EXISTS idx_settings_profile_values_key ON settings_profile_values(setting_key)');
console.log('✓ Created settings_profiles and settings_profile_values tables');
} else {
console.log('✓ Settings profiles tables already exist, skipping');
}
}
} }
]; ];

4
backend/src/index.ts

@ -11,6 +11,8 @@ import videoRoutes from './routes/videos.routes.js';
import settingsRoutes from './routes/settings.routes.js'; import settingsRoutes from './routes/settings.routes.js';
import wordGroupsRoutes from './routes/wordGroups.routes.js'; import wordGroupsRoutes from './routes/wordGroups.routes.js';
import usersRoutes from './routes/users.routes.js'; import usersRoutes from './routes/users.routes.js';
import settingsProfilesRoutes from './routes/settingsProfiles.routes.js';
import magicCodeRoutes from './routes/magicCode.routes.js';
import { errorHandler } from './middleware/errorHandler.js'; import { errorHandler } from './middleware/errorHandler.js';
import { apiLimiter } from './middleware/rateLimiter.js'; import { apiLimiter } from './middleware/rateLimiter.js';
import { createWebSocketServer } from './services/websocket.service.js'; import { createWebSocketServer } from './services/websocket.service.js';
@ -52,6 +54,8 @@ async function startServer() {
app.use('/api/settings', settingsRoutes); app.use('/api/settings', settingsRoutes);
app.use('/api/word-groups', wordGroupsRoutes); app.use('/api/word-groups', wordGroupsRoutes);
app.use('/api/users', usersRoutes); app.use('/api/users', usersRoutes);
app.use('/api/settings-profiles', settingsProfilesRoutes);
app.use('/api/magic-code', magicCodeRoutes);
// Error handling // Error handling
app.use(errorHandler); app.use(errorHandler);

9
backend/src/routes/magicCode.routes.ts

@ -0,0 +1,9 @@
import { Router } from 'express';
import { getSettingsByCode } from '../controllers/magicCode.controller.js';
const router = Router();
// Public route - no authentication required
router.get('/:code', getSettingsByCode);
export default router;

25
backend/src/routes/settingsProfiles.routes.ts

@ -0,0 +1,25 @@
import { Router } from 'express';
import {
getAllProfiles,
getProfile,
createProfile,
updateProfile,
deleteProfile,
updateProfileSettings,
regenerateMagicCode
} from '../controllers/settingsProfiles.controller.js';
import { authMiddleware } from '../middleware/auth.js';
import { adminMiddleware } from '../middleware/admin.js';
const router = Router();
// All routes require admin authentication
router.get('/', authMiddleware, adminMiddleware, getAllProfiles);
router.post('/', authMiddleware, adminMiddleware, createProfile);
router.get('/:id', authMiddleware, adminMiddleware, getProfile);
router.put('/:id', authMiddleware, adminMiddleware, updateProfile);
router.delete('/:id', authMiddleware, adminMiddleware, deleteProfile);
router.put('/:id/settings', authMiddleware, adminMiddleware, updateProfileSettings);
router.post('/:id/regenerate-code', authMiddleware, adminMiddleware, regenerateMagicCode);
export default router;

44
backend/src/utils/magicCodeGenerator.ts

@ -0,0 +1,44 @@
import { db } from '../config/database.js';
import crypto from 'crypto';
/**
* Generate a unique 7-character alphanumeric magic code
* Format: A-Z (uppercase), 0-9
* Examples: "ABC1234", "XYZ7890", "KID2024"
*/
export async function generateMagicCode(): Promise<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');
}

9
frontend/src/App.tsx

@ -16,6 +16,7 @@ const VideosAdminPage = lazy(() => import('./pages/VideosAdminPage').then(module
const SpeechSoundsAdminPage = lazy(() => import('./pages/SpeechSoundsAdminPage').then(module => ({ default: module.SpeechSoundsAdminPage }))); const SpeechSoundsAdminPage = lazy(() => import('./pages/SpeechSoundsAdminPage').then(module => ({ default: module.SpeechSoundsAdminPage })));
const StatsAdminPage = lazy(() => import('./pages/StatsAdminPage').then(module => ({ default: module.StatsAdminPage }))); const StatsAdminPage = lazy(() => import('./pages/StatsAdminPage').then(module => ({ default: module.StatsAdminPage })));
const UsersAdminPage = lazy(() => import('./pages/UsersAdminPage').then(module => ({ default: module.UsersAdminPage }))); const UsersAdminPage = lazy(() => import('./pages/UsersAdminPage').then(module => ({ default: module.UsersAdminPage })));
const SettingsProfilesAdminPage = lazy(() => import('./pages/SettingsProfilesAdminPage').then(module => ({ default: module.SettingsProfilesAdminPage })));
const LoginPage = lazy(() => import('./pages/LoginPage').then(module => ({ default: module.LoginPage }))); const LoginPage = lazy(() => import('./pages/LoginPage').then(module => ({ default: module.LoginPage })));
// Loading fallback component // Loading fallback component
@ -103,6 +104,14 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/admin/settings-profiles"
element={
<ProtectedRoute requireAdmin={true}>
<SettingsProfilesAdminPage />
</ProtectedRoute>
}
/>
</Routes> </Routes>
</Suspense> </Suspense>
</main> </main>

159
frontend/src/components/MagicCodeInput/MagicCodeInput.tsx

@ -0,0 +1,159 @@
import { useState } from 'react';
import { applyMagicCode, clearMagicCode, getAppliedMagicCode, getMagicCodeSettings } from '../../services/magicCodeService';
interface MagicCodeInputProps {
onApplied?: () => void;
onClose?: () => void;
}
export function MagicCodeInput({ onApplied, onClose }: MagicCodeInputProps) {
const [code, setCode] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<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>
);
}

15
frontend/src/pages/AdminPage.tsx

@ -79,6 +79,21 @@ export function AdminPage() {
Manage admin and user accounts Manage admin and user accounts
</p> </p>
</Link> </Link>
<Link
to="/admin/settings-profiles"
className="bg-amber-100 hover:bg-amber-200 w-full p-6 rounded-3xl font-semibold text-foreground transition-all active:scale-95 hover:shadow-lg flex flex-col items-center text-center no-underline"
>
<div className="mb-3">
<div className="w-20 h-20 flex items-center justify-center text-4xl">
</div>
</div>
<h2 className="text-xl font-bold mb-1">Settings Profiles</h2>
<p className="text-sm opacity-75">
Create magic codes for child settings
</p>
</Link>
</div> </div>
</div> </div>
); );

25
frontend/src/pages/LandingPage.tsx

@ -1,6 +1,9 @@
import { useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { APPS } from '../config/apps'; import { APPS } from '../config/apps';
import { OptimizedImage } from '../components/OptimizedImage/OptimizedImage'; import { OptimizedImage } from '../components/OptimizedImage/OptimizedImage';
import { MagicCodeInput } from '../components/MagicCodeInput/MagicCodeInput';
import { getAppliedMagicCode } from '../services/magicCodeService';
const categoryEmojis: { [key: string]: string } = { const categoryEmojis: { [key: string]: string } = {
videos: '📺', videos: '📺',
@ -25,10 +28,32 @@ const colorMap: { [key: string]: string } = {
}; };
export function LandingPage() { export function LandingPage() {
const [showMagicCodeModal, setShowMagicCodeModal] = useState(false);
const appliedCode = getAppliedMagicCode();
return ( return (
<div className="bg-background"> <div className="bg-background">
{showMagicCodeModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<MagicCodeInput
onApplied={() => setShowMagicCodeModal(false)}
onClose={() => setShowMagicCodeModal(false)}
/>
</div>
)}
<section className="px-4 py-8"> <section className="px-4 py-8">
<div className="max-w-5xl mx-auto"> <div className="max-w-5xl mx-auto">
{!appliedCode && (
<div className="mb-6 text-center">
<button
onClick={() => setShowMagicCodeModal(true)}
className="inline-flex items-center gap-2 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"
>
Enter Magic Code
</button>
</div>
)}
{/* First card is likely LCP element - prioritize it */} {/* First card is likely LCP element - prioritize it */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-12"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
{APPS.map(app => { {APPS.map(app => {

385
frontend/src/pages/SettingsProfilesAdminPage.tsx

@ -0,0 +1,385 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { settingsProfilesApi } from '../services/apiClient';
import { SettingsProfile } from '../types/api';
export function SettingsProfilesAdminPage() {
const [profiles, setProfiles] = useState<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>
);
}

25
frontend/src/services/apiClient.ts

@ -171,3 +171,28 @@ export const usersApi = {
api.put(`/users/${id}/password`, { password }) api.put(`/users/${id}/password`, { password })
}; };
// Settings Profiles API (admin only)
export const settingsProfilesApi = {
getAll: () => api.get('/settings-profiles'),
getById: (id: number) => api.get(`/settings-profiles/${id}`),
create: (data: { name: string; description?: string; dailyTimeLimit: number }) =>
api.post('/settings-profiles', data),
update: (id: number, data: { name?: string; description?: string; isActive?: boolean }) =>
api.put(`/settings-profiles/${id}`, data),
delete: (id: number) => api.delete(`/settings-profiles/${id}`),
updateSettings: (id: number, settings: { dailyTimeLimit: number }) =>
api.put(`/settings-profiles/${id}/settings`, settings),
regenerateCode: (id: number) => api.post(`/settings-profiles/${id}/regenerate-code`)
};
// Magic Code API (public)
export const magicCodeApi = {
getSettingsByCode: (code: string) => api.get(`/magic-code/${code}`)
};

86
frontend/src/services/magicCodeService.ts

@ -0,0 +1,86 @@
const MAGIC_CODE_KEY = 'magic_code';
const MAGIC_CODE_SETTINGS_KEY = 'magic_code_settings';
export interface MagicCodeSettings {
dailyTimeLimit: number | null;
appliedAt: string;
}
/**
* Get the currently applied magic code from localStorage
*/
export function getAppliedMagicCode(): string | null {
try {
return localStorage.getItem(MAGIC_CODE_KEY);
} catch (e) {
console.warn('Failed to get magic code from localStorage', e);
return null;
}
}
/**
* Get the settings from the currently applied magic code
*/
export function getMagicCodeSettings(): MagicCodeSettings | null {
try {
const stored = localStorage.getItem(MAGIC_CODE_SETTINGS_KEY);
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.warn('Failed to parse magic code settings from localStorage', e);
}
return null;
}
/**
* Check if a magic code is currently applied
*/
export function hasMagicCode(): boolean {
return getAppliedMagicCode() !== null;
}
/**
* Apply a magic code by fetching settings and storing them locally
*/
export async function applyMagicCode(code: string): Promise<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);
}
}

33
frontend/src/services/timeLimitService.ts

@ -81,10 +81,21 @@ function resetIfNeeded(): void {
} }
/** /**
* Get current time limit configuration from server * Get current time limit configuration
* Falls back to cached value or default if server unavailable * Priority: Magic code settings > Server settings > Cached > Default
*/ */
export async function getDailyLimit(): Promise<number> { export async function getDailyLimit(): Promise<number> {
// Check magic code settings first (highest priority)
try {
const { getMagicCodeSettings } = await import('./magicCodeService');
const magicCodeSettings = getMagicCodeSettings();
if (magicCodeSettings?.dailyTimeLimit !== null && magicCodeSettings.dailyTimeLimit !== undefined) {
return magicCodeSettings.dailyTimeLimit;
}
} catch (error) {
console.warn('Failed to check magic code settings:', error);
}
// Return cached value if still valid // Return cached value if still valid
const now = Date.now(); const now = Date.now();
if (cachedDailyLimit !== null && (now - limitCacheTime) < LIMIT_CACHE_DURATION) { if (cachedDailyLimit !== null && (now - limitCacheTime) < LIMIT_CACHE_DURATION) {
@ -121,9 +132,25 @@ export async function setDailyLimit(minutes: number): Promise<void> {
} }
/** /**
* Synchronous version for use in hooks (uses cached value) * Synchronous version for use in hooks (uses cached value or magic code settings)
* Note: Magic code settings are checked synchronously from localStorage
*/ */
export function getDailyLimitSync(): number { export function getDailyLimitSync(): number {
// Check magic code settings first (highest priority)
// We can access localStorage synchronously
try {
const MAGIC_CODE_SETTINGS_KEY = 'magic_code_settings';
const stored = localStorage.getItem(MAGIC_CODE_SETTINGS_KEY);
if (stored) {
const magicCodeSettings = JSON.parse(stored);
if (magicCodeSettings?.dailyTimeLimit !== null && magicCodeSettings.dailyTimeLimit !== undefined) {
return magicCodeSettings.dailyTimeLimit;
}
}
} catch (error) {
// Ignore errors in sync context
}
return cachedDailyLimit ?? DEFAULT_DAILY_LIMIT; return cachedDailyLimit ?? DEFAULT_DAILY_LIMIT;
} }

11
frontend/src/types/api.ts

@ -42,6 +42,17 @@ export interface AdminUser {
lastLogin?: string; lastLogin?: string;
} }
export interface SettingsProfile {
id: number;
magicCode: string;
name: string;
description?: string;
createdAt: string;
updatedAt: string;
isActive: boolean;
dailyTimeLimit: number | null;
}
export interface ApiResponse<T = any> { export interface ApiResponse<T = any> {
success: boolean; success: boolean;
data?: T; data?: T;

Loading…
Cancel
Save