diff --git a/backend/app/Controllers/AuthController.php b/backend/app/Controllers/AuthController.php
index 7bbf246..fa7d6c8 100644
--- a/backend/app/Controllers/AuthController.php
+++ b/backend/app/Controllers/AuthController.php
@@ -8,199 +8,355 @@ use App\Core\Database;
use App\Core\Security;
use App\Core\Validator;
use App\Core\RedisClient;
+use App\Services\NabehService;
class AuthController
{
/**
- * Register a new user using Phone Number
- */
- public function register(Request $request, Response $response): void
- {
- $body = $request->getBody();
-
- $validator = new Validator();
- $isValid = $validator->validate($body, [
- 'full_name' => 'required',
- 'phone_number' => 'required',
- 'password' => 'required|min:8',
- 'role' => 'required'
- ]);
-
- if (!$isValid) {
- $response->status(400)->json([
- 'status' => 'error',
- 'message' => 'Validation failed',
- 'errors' => $validator->getErrors()
- ]);
- return;
- }
-
- $phone = $body['phone_number'];
- $role = $body['role'];
-
- if (!in_array($role, ['student', 'teacher', 'guardian'])) {
- $response->status(400)->json([
- 'status' => 'error',
- 'message' => 'Invalid role specified'
- ]);
- return;
- }
-
- $phoneHash = Security::blindIndex($phone);
- $existing = Database::selectOne("SELECT id FROM users WHERE phone_hash = ? LIMIT 1", [$phoneHash]);
-
- if ($existing) {
- $response->status(409)->json([
- 'status' => 'error',
- 'message' => 'Phone number is already registered'
- ]);
- return;
- }
-
- $passwordHash = Security::hashPassword($body['password']);
-
- // Generate UUID
- $uuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
- mt_rand(0, 0xffff), mt_rand(0, 0xffff),
- mt_rand(0, 0xffff),
- mt_rand(0, 0x0fff) | 0x4000,
- mt_rand(0, 0x3fff) | 0x8000,
- mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
- );
-
- $userId = Database::insert(
- "INSERT INTO users (uuid, full_name, phone_number, phone_hash, password_hash, role, status) VALUES (?, ?, ?, ?, ?, ?, 'active')",
- [$uuid, $body['full_name'], $phone, $phoneHash, $passwordHash, $role]
- );
-
- $this->generateSessionAndRespond($userId, $uuid, $role, $response, "User registered successfully");
- }
-
- /**
- * Login using Phone Number and Password
- */
- public function login(Request $request, Response $response): void
- {
- $body = $request->getBody();
- $validator = new Validator();
-
- if (!$validator->validate($body, [
- 'phone_number' => 'required',
- 'password' => 'required'
- ])) {
- $response->status(400)->json([
- 'status' => 'error',
- 'message' => 'Validation failed',
- 'errors' => $validator->getErrors()
- ]);
- return;
- }
-
- $phoneHash = Security::blindIndex($body['phone_number']);
- $user = Database::selectOne("SELECT * FROM users WHERE phone_hash = ? LIMIT 1", [$phoneHash]);
-
- if (!$user || !Security::verifyPassword($body['password'], $user['password_hash'])) {
- $response->status(401)->json([
- 'status' => 'error',
- 'message' => 'Invalid phone number or password'
- ]);
- return;
- }
-
- if ($user['status'] === 'suspended') {
- $response->status(403)->json([
- 'status' => 'error',
- 'message' => 'Account is suspended'
- ]);
- return;
- }
-
- $this->generateSessionAndRespond($user['id'], $user['uuid'], $user['role'], $response, "Login successful");
- }
-
- /**
- * Request OTP for phone verification
+ * Request OTP via WhatsApp (Nabeh Gateway)
+ * POST /api/auth/otp/request
*/
public function requestOtp(Request $request, Response $response): void
{
$body = $request->getBody();
- if (empty($body['phone_number'])) {
- $response->status(400)->json(['status' => 'error', 'message' => 'Phone number is required']);
+ $validator = new Validator();
+
+ if (!$validator->validate($body, ['phone_number' => 'required'])) {
+ $response->status(400)->json([
+ 'status' => 'error',
+ 'message' => 'رقم الهاتف مطلوب',
+ 'errors' => $validator->getErrors()
+ ]);
return;
}
- $phone = $body['phone_number'];
+ $rawPhone = trim((string)$body['phone_number']);
+ $cleanPhone = preg_replace('/\D+/', '', $rawPhone);
+
+ // Normalize Jordanian numbers (e.g. 079XXXXXXX -> 96279XXXXXXX)
+ if (str_starts_with($cleanPhone, '07')) {
+ $cleanPhone = '962' . substr($cleanPhone, 1);
+ } elseif (str_starts_with($cleanPhone, '7') && strlen($cleanPhone) === 9) {
+ $cleanPhone = '962' . $cleanPhone;
+ }
+
+ if (strlen($cleanPhone) < 9 || strlen($cleanPhone) > 15) {
+ $response->status(400)->json([
+ 'status' => 'error',
+ 'message' => 'صيغة رقم الهاتف غير صحيحة'
+ ]);
+ return;
+ }
+
+ $role = $body['role'] ?? 'student';
+ if (!in_array($role, ['student', 'teacher', 'guardian', 'school_admin', 'super_admin'], true)) {
+ $role = 'student';
+ }
+
+ $appName = ($role === 'teacher') ? 'صَقِل للمعلمين' : 'منصة صَقِل التعليمية';
+
+ // 1. Rate Limiting via Redis (Max 3 OTP requests per 5 minutes per phone)
+ $phoneHash = Security::blindIndex($cleanPhone);
+ try {
+ $redis = RedisClient::getInstance();
+ $rateKey = "otp_rate:{$phoneHash}";
+ $attempts = (int)$redis->incr($rateKey);
+ if ($attempts === 1) {
+ $redis->expire($rateKey, 300); // 5 minutes window
+ }
+ if ($attempts > 3) {
+ $ttl = $redis->ttl($rateKey);
+ $response->status(429)->json([
+ 'status' => 'error',
+ 'message' => "تم تجاوز الحد المسموح. يرجى المحاولة بعد {$ttl} ثانية."
+ ]);
+ return;
+ }
+ } catch (\Exception $e) {
+ error_log("Redis rate limit warning: " . $e->getMessage());
+ }
+
+ // 2. Generate 6-digit OTP
$otp = (string)random_int(100000, 999999);
-
- // Save OTP to Redis for 5 minutes
- $redis = RedisClient::getInstance();
- $redis->setex('otp:' . $phone, 300, $otp);
- // TODO: Integrate SMS gateway here to actually send the OTP via SMS
+ // 3. Save OTP in Redis (TTL: 300s / 5 minutes)
+ try {
+ $redis = RedisClient::getInstance();
+ $otpKey = "otp:{$phoneHash}";
+ $redis->setex($otpKey, 300, password_hash($otp, PASSWORD_BCRYPT));
+ } catch (\Exception $e) {
+ error_log("Redis OTP store error: " . $e->getMessage());
+ }
- $response->json([
- 'status' => 'success',
- 'message' => 'OTP sent successfully (Simulated: ' . $otp . ')'
- ]);
+ // 4. Send OTP via Nabeh Service
+ $nabeh = new NabehService();
+ $sent = $nabeh->sendOtp($cleanPhone, $otp, 'image', $appName);
+
+ $isDebug = filter_var(getenv('APP_DEBUG') ?: true, FILTER_VALIDATE_BOOLEAN);
+
+ $resData = [
+ 'status' => 'success',
+ 'message' => 'تم إرسال رمز التحقق بنجاح عبر الواتساب',
+ 'data' => [
+ 'phone_masked' => substr($cleanPhone, 0, 3) . '****' . substr($cleanPhone, -3),
+ 'expires_in' => 300,
+ ]
+ ];
+
+ // Expose OTP only in debug mode for seamless local testing
+ if ($isDebug || !$sent) {
+ $resData['debug_otp'] = $otp;
+ if (!$sent) {
+ $resData['message'] = 'تم توليد رمز التحقق (بيئة التطوير / محاكاة الإرسال)';
+ }
+ }
+
+ $response->json($resData);
}
/**
- * Common method to generate JWT and save session to Redis
+ * Verify OTP and Login / Register User
+ * POST /api/auth/otp/verify
*/
- private function generateSessionAndRespond(int $userId, string $uuid, string $role, Response $response, string $msg): void
+ public function verifyOtp(Request $request, Response $response): void
{
- $payload = [
- 'user_id' => $userId,
- 'uuid' => $uuid,
- 'role' => $role
- ];
-
- $token = Security::generateJWT($payload);
+ $body = $request->getBody();
+ $validator = new Validator();
- // Store session in Redis (Active for 30 days)
+ if (!$validator->validate($body, [
+ 'phone_number' => 'required',
+ 'otp' => 'required'
+ ])) {
+ $response->status(400)->json([
+ 'status' => 'error',
+ 'message' => 'رقم الهاتف ورمز التحقق مطلوبان',
+ 'errors' => $validator->getErrors()
+ ]);
+ return;
+ }
+
+ $rawPhone = trim((string)$body['phone_number']);
+ $cleanPhone = preg_replace('/\D+/', '', $rawPhone);
+ if (str_starts_with($cleanPhone, '07')) {
+ $cleanPhone = '962' . substr($cleanPhone, 1);
+ } elseif (str_starts_with($cleanPhone, '7') && strlen($cleanPhone) === 9) {
+ $cleanPhone = '962' . $cleanPhone;
+ }
+
+ $inputOtp = trim((string)$body['otp']);
+ $role = $body['role'] ?? 'student';
+ $fullName = trim((string)($body['full_name'] ?? ''));
+ $deviceFingerprint = trim((string)($body['device_fingerprint'] ?? 'browser_default'));
+
+ $phoneHash = Security::blindIndex($cleanPhone);
+
+ // 1. Verify OTP against Redis
+ $redis = RedisClient::getInstance();
+ $otpKey = "otp:{$phoneHash}";
+ $storedHash = $redis->get($otpKey);
+
+ $isValidOtp = false;
+ if ($storedHash && password_verify($inputOtp, $storedHash)) {
+ $isValidOtp = true;
+ $redis->del($otpKey); // Invalidate OTP after success
+ } elseif (getenv('APP_DEBUG') && $inputOtp === '123456') {
+ // Master debug OTP
+ $isValidOtp = true;
+ }
+
+ if (!$isValidOtp) {
+ $response->status(401)->json([
+ 'status' => 'error',
+ 'message' => 'رمز التحقق غير صحيح أو انتهت صلاحيته'
+ ]);
+ return;
+ }
+
+ // 2. Find or Create User
+ $user = Database::selectOne("SELECT * FROM users WHERE phone_hash = ? LIMIT 1", [$phoneHash]);
+
+ if (!$user) {
+ // New user registration
+ $uuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
+ mt_rand(0, 0xffff), mt_rand(0, 0xffff),
+ mt_rand(0, 0xffff),
+ mt_rand(0, 0x0fff) | 0x4000,
+ mt_rand(0, 0x3fff) | 0x8000,
+ mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
+ );
+
+ $encryptedPhone = Security::encrypt($cleanPhone);
+ $encryptedName = Security::encrypt($fullName ?: ($role === 'teacher' ? 'معلم جديد' : 'طالب جديد'));
+
+ $userId = Database::insert(
+ "INSERT INTO users (uuid, full_name, phone_number, phone_hash, role, status, token_version) VALUES (?, ?, ?, ?, ?, 'active', 1)",
+ [$uuid, $encryptedName, $encryptedPhone, $phoneHash, $role]
+ );
+
+ $user = [
+ 'id' => $userId,
+ 'uuid' => $uuid,
+ 'full_name' => $encryptedName,
+ 'role' => $role,
+ 'status' => 'active',
+ 'token_version' => 1,
+ 'school_id' => null,
+ ];
+ } else {
+ // Verify role access if logging into specific portal
+ if ($role === 'teacher' && $user['role'] !== 'teacher' && $user['role'] !== 'super_admin') {
+ $response->status(403)->json([
+ 'status' => 'error',
+ 'message' => 'هذا الحساب مسجل كطالب وليس معلماً. يرجى الدخول من بوابة الطالب.'
+ ]);
+ return;
+ }
+
+ if ($user['status'] === 'suspended') {
+ $response->status(403)->json([
+ 'status' => 'error',
+ 'message' => 'هذا الحساب معطل. يرجى مراجعة إدارة المنصة.'
+ ]);
+ return;
+ }
+
+ // If name provided on existing profile, update if needed
+ if ($fullName && (empty($user['full_name']) || Security::decrypt($user['full_name']) === 'طالب جديد')) {
+ Database::query("UPDATE users SET full_name = ? WHERE id = ?", [Security::encrypt($fullName), $user['id']]);
+ }
+ }
+
+ // 3. Register / Update Device Fingerprint in user_devices
+ try {
+ Database::query(
+ "INSERT INTO user_devices (user_id, device_fingerprint, platform, is_active, last_active_at)
+ VALUES (?, ?, 'web', 1, NOW())
+ ON DUPLICATE KEY UPDATE last_active_at = NOW(), is_active = 1",
+ [$user['id'], $deviceFingerprint]
+ );
+ } catch (\Exception $e) {
+ error_log("Device recording notice: " . $e->getMessage());
+ }
+
+ // 4. Issue JWT and Bind Single Session in Redis
+ $displayName = Security::decrypt($user['full_name']) ?: 'مستخدم صَقِل';
+ $this->generateSessionAndRespond(
+ (int)$user['id'],
+ $user['uuid'],
+ $user['role'],
+ $deviceFingerprint,
+ $cleanPhone,
+ $displayName,
+ $response,
+ 'تم تسجيل الدخول بنجاح'
+ );
+ }
+
+ /**
+ * Generate Secure JWT and Enforce Single Active Session in Redis
+ */
+ private function generateSessionAndRespond(
+ int $userId,
+ string $uuid,
+ string $role,
+ string $deviceFingerprint,
+ string $cleanPhone,
+ string $displayName,
+ Response $response,
+ string $msg
+ ): void {
+ $payload = [
+ 'user_id' => $userId,
+ 'uuid' => $uuid,
+ 'role' => $role,
+ 'device_fingerprint' => $deviceFingerprint,
+ 'phone' => $cleanPhone,
+ 'name' => $displayName,
+ ];
+
+ // 30 days token expiry
+ $token = Security::generateJWT($payload, 30 * 86400);
+
+ // Single Session Enforcement: Store active session in Redis
try {
$redis = RedisClient::getInstance();
- $redis->setex("session:{$userId}:{$token}", 30 * 86400, "active");
+ $sessionKey = "active_session:{$userId}";
+ $redis->setex($sessionKey, 30 * 86400, json_encode([
+ 'token_signature' => substr($token, -32),
+ 'device_fingerprint' => $deviceFingerprint,
+ 'logged_at' => date('Y-m-d H:i:s'),
+ 'ip' => $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1',
+ ]));
} catch (\Exception $e) {
- error_log("Failed to save session to Redis: " . $e->getMessage());
+ error_log("Failed to save active session in Redis: " . $e->getMessage());
}
$response->status(200)->json([
- 'status' => 'success',
+ 'status' => 'success',
'message' => $msg,
- 'data' => [
+ 'data' => [
'token' => $token,
- 'user' => [
- 'uuid' => $uuid,
- 'role' => $role
+ 'user' => [
+ 'uuid' => $uuid,
+ 'name' => $displayName,
+ 'phone' => $cleanPhone,
+ 'role' => $role,
+ 'is_student' => ($role === 'student'),
+ 'is_teacher' => ($role === 'teacher'),
]
]
]);
}
/**
- * Get Current User Data
+ * Get Current Authenticated User Data
+ * GET /api/auth/me
*/
public function me(Request $request, Response $response): void
{
$userId = $request->user_id;
$user = Database::selectOne(
- "SELECT uuid, full_name, role, status, created_at FROM users WHERE id = ? LIMIT 1",
+ "SELECT uuid, full_name, phone_number, role, grade_level, stream, status, created_at FROM users WHERE id = ? LIMIT 1",
[$userId]
);
if (!$user) {
$response->status(404)->json([
- 'status' => 'error',
- 'message' => 'User not found'
+ 'status' => 'error',
+ 'message' => 'المستخدم غير موجود'
]);
return;
}
+ $user['full_name'] = Security::decrypt($user['full_name']);
+ $user['phone_number'] = Security::decrypt($user['phone_number']);
+
$response->json([
'status' => 'success',
- 'data' => $user
+ 'data' => $user
+ ]);
+ }
+
+ /**
+ * Logout and destroy Redis active session
+ * POST /api/auth/logout
+ */
+ public function logout(Request $request, Response $response): void
+ {
+ $userId = $request->user_id;
+ if ($userId) {
+ try {
+ $redis = RedisClient::getInstance();
+ $redis->del("active_session:{$userId}");
+ } catch (\Exception $e) {
+ error_log("Logout Redis error: " . $e->getMessage());
+ }
+ }
+
+ $response->json([
+ 'status' => 'success',
+ 'message' => 'تم تسجيل الخروج بنجاح'
]);
}
}
diff --git a/backend/app/Services/NabehService.php b/backend/app/Services/NabehService.php
new file mode 100644
index 0000000..70ddb8b
--- /dev/null
+++ b/backend/app/Services/NabehService.php
@@ -0,0 +1,158 @@
+authUrl = getenv('NABEH_AUTH_URL') ?: 'https://nabeh.intaleqapp.com/api/auth/login';
+ $this->sendUrl = getenv('NABEH_SEND_URL') ?: 'https://nabeh.intaleqapp.com/api/otp/send';
+ $this->email = getenv('NABEH_EMAIL') ?: null;
+ $this->password = getenv('NABEH_PASSWORD') ?: null;
+ }
+
+ /**
+ * Retrieve Nabeh JWT Bearer Token, caching it in Redis for 24 hours.
+ */
+ public function getBearerToken(): ?string
+ {
+ // 1. Try fetching from Redis first
+ try {
+ $redis = RedisClient::getInstance();
+ $cachedToken = $redis->get('nabeh_bearer_token');
+ if ($cachedToken) {
+ return (string)$cachedToken;
+ }
+ } catch (\Exception $e) {
+ error_log("⚠️ [Nabeh Auth Redis] Error reading token: " . $e->getMessage());
+ }
+
+ // 2. Token not cached, authenticate via Nabeh Login API
+ if (!$this->email || !$this->password) {
+ error_log("⚠️ [Nabeh Auth] Missing NABEH_EMAIL or NABEH_PASSWORD environment variables.");
+ return null;
+ }
+
+ $payload = json_encode([
+ 'email' => $this->email,
+ 'password' => $this->password,
+ ]);
+
+ $ch = curl_init($this->authUrl);
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => $payload,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 10,
+ CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
+ ]);
+
+ $response = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($httpCode === 200 && $response) {
+ $decoded = json_decode($response, true);
+ $token = $decoded['token'] ?? $decoded['message']['token'] ?? $decoded['jwt'] ?? $decoded['access_token'] ?? null;
+
+ if ($token) {
+ // Cache token in Redis for 24h
+ try {
+ $redis = RedisClient::getInstance();
+ $redis->setex('nabeh_bearer_token', 86400, (string)$token);
+ error_log("[Nabeh Auth] Token cached in Redis successfully.");
+ } catch (\Exception $e) {
+ error_log("⚠️ [Nabeh Auth Redis Cache Save] Error saving token: " . $e->getMessage());
+ }
+ return (string)$token;
+ }
+ }
+
+ error_log("❌ [Nabeh Auth Login Failed] Response: " . $response);
+ return null;
+ }
+
+ /**
+ * Send OTP via Nabeh JWT Auth Gateway (WhatsApp Image/Text OTP)
+ */
+ public function sendOtp(string $receiver, string $otp, string $method = 'image', string $appName = 'منصة صَقِل'): bool
+ {
+ $bearerToken = $this->getBearerToken();
+ if (!$bearerToken) {
+ error_log("⚠️ [Nabeh OTP] Failed to obtain dynamic JWT Bearer token.");
+ return false;
+ }
+
+ $phoneRaw = preg_replace('/\D+/', '', $receiver);
+ $type = in_array($method, ['text', 'voice', 'image'], true) ? $method : 'image';
+
+ // 1. First attempt with image
+ $success = $this->attemptSend($phoneRaw, $type, $otp, $appName, $bearerToken);
+ if ($success) {
+ return true;
+ }
+
+ // 2. Fallback to text if image fails
+ if ($type === 'image') {
+ error_log("ℹ️ [Nabeh OTP Fallback] Image failed, retrying with text type...");
+ return $this->attemptSend($phoneRaw, 'text', $otp, $appName, $bearerToken);
+ }
+
+ return false;
+ }
+
+ private function attemptSend(string $phone, string $type, string $otp, string $appName, string $bearerToken): bool
+ {
+ $payload = json_encode([
+ 'phone' => $phone,
+ 'type' => $type,
+ 'code' => $otp,
+ 'message' => "رمز التحقق الخاص بك لمنصة {$appName} هو: *{$otp}* \n الرجاء عدم مشاركته مع أي شخص لحماية حسابك.",
+ ]);
+
+ $ch = curl_init($this->sendUrl);
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => $payload,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => 10,
+ CURLOPT_HTTPHEADER => [
+ 'Content-Type: application/json',
+ "Authorization: Bearer {$bearerToken}",
+ ],
+ ]);
+
+ $response = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($httpCode === 200 && $response) {
+ $decoded = json_decode($response, true);
+ if ($decoded) {
+ $statusStr = strtolower((string)($decoded['status'] ?? ''));
+ $msgStr = strtolower((string)($decoded['message'] ?? ''));
+ if (
+ !empty($decoded['success']) ||
+ in_array($statusStr, ['success', 'ok', 'true', '200', 'sent', 'queued', '1'], true) ||
+ ($decoded['status'] ?? false) === true ||
+ str_contains($msgStr, 'success') ||
+ str_contains($msgStr, 'sent') ||
+ str_contains($msgStr, 'تم')
+ ) {
+ return true;
+ }
+ }
+ }
+
+ error_log("❌ [Nabeh OTP Attempt Failed] Code: {$httpCode} Response: {$response}");
+ return false;
+ }
+}
diff --git a/backend/app/Views/StudentPortal.php b/backend/app/Views/StudentPortal.php
new file mode 100644
index 0000000..b070800
--- /dev/null
+++ b/backend/app/Views/StudentPortal.php
@@ -0,0 +1,508 @@
+
+
+
+
+
+ بوابة الطالب — منصة صَقِل التعليمية
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
ادخل لعالم التمكين والفهم
+
تسجيل دخول آمن وسريع عبر رمز الواتساب
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
تم إرسال الرمز إلى:
+
+
+
+
+
+
+
+
+
+
اتصال مشفر بتكنولوجيا Zero-Trust وبصمة الجهاز
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ دفعة التوجيهي 2007/2008
+
+
+
رحلتك لصقل الفهم وتحقيق أعلى معدل وزاري تبدأ هنا.
+
+
+
+
+
+ 84%
+
+
+ مؤشر الجاهزية للوزاري
+ ممتاز — مسارك مستقر
+
+
+
+
+
+
+
+
+
+
+ علمي
+ 18 درس • كويزات تفاعلية
+
+
+
شرح تفاعلي لمفاهيم الاشتقاق والنهايات مع أسئلة وزارية محاكية.
+
+ متابعة الدرس 4 ←
+ مكتمل 45%
+
+
+
+
+
+
+ علمي
+ 14 درس • تجارب تفاعلية
+
+
+
صقل مفاهيم الزخم الخطي والتصادمات وتطبيقاتها في الامتحانات.
+
+ بدء التعلم ←
+ جديد
+
+
+
+
+
+
+ ميزة ذكية
+ Whisper AI
+
+
+
سجل أي ملاحظة بصوتك أثناء الحصة، والذكاء الاصطناعي يفرغها لنص فوري.
+
+ دفتر الملاحظات (3) ←
+
+
+
+
+
+
+
+
+ تجربة تفاعلية (Coursera Model)
+
+
+
يتوقف الفيديو تلقائياً عند لحظة قياس الفهم. إذا أخطأت، يعيدك المشغل 45 ثانية لمشاهدة شرح المفهوم مجدداً.
+
+
+
+
سؤال اللحظة (الدقيقة 08:30 من درس الاشتقاق):
+
ما هو مشتق اقتران الجيب $f(x) = \sin(x)$ بالنسبة لـ $x$؟
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+HTML;
+ }
+}
diff --git a/backend/app/Views/TeacherPortal.php b/backend/app/Views/TeacherPortal.php
new file mode 100644
index 0000000..c9682fd
--- /dev/null
+++ b/backend/app/Views/TeacherPortal.php
@@ -0,0 +1,465 @@
+
+
+
+
+
+ بوابة المعلم — استوديو صَقِل التعليمي
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ استوديو المعلمين
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
استوديو صَقِل للمعلمين
+
منصة تصنع المعلم الرقمي الأول — حماية محتواك وأرباح متنامية
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
تم إرسال الرمز للواتساب:
+
+
+
+
+
+
+
+
+ حماية محتوى DRM كاملة + تقاسم أرباح 45%–50% شفاف ومؤتمت
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ استوديو المعلم المعتمد
+
+
+
إدارة دوراتك، إدراج كويزات الفيديو التفاعلية، ومتابعة نمو طلابك.
+
+
+
+
+
+ إجمالي الطلاب
+ 1,240
+
+
+ أرباحك المتراكمة
+ 19,530 د.أ
+
+
+
+
+
+
+
+
+
+
إضافة كويز تفاعلي داخل الفيديو 🎬
+
حدد الثانية الزمنية في الفيديو التي سيتوقف عندها الشرح لفحص فهم الطالب.
+
+
Bunny Stream Synced
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+HTML;
+ }
+}
diff --git a/backend/public/index.php b/backend/public/index.php
index 970a692..3dd7d60 100644
--- a/backend/public/index.php
+++ b/backend/public/index.php
@@ -19,7 +19,22 @@ $router = new Router();
// 3. Define Global Middleware
$router->use(\App\Middlewares\SecurityMiddleware::class);
-// 4. Define API Routes
+// 4. Define Web and API Routes
+
+// Root Redirect to Student Portal
+$router->get('/', function ($request, $response) {
+ header('Location: /student');
+ exit;
+});
+
+// Web Portals (HTML + Alpine.js)
+$router->get('/student', function ($request, $response) {
+ $response->html(\App\Views\StudentPortal::render());
+});
+
+$router->get('/teacher', function ($request, $response) {
+ $response->html(\App\Views\TeacherPortal::render());
+});
// Health Check
$router->get('/api/health', function ($request, $response) {
@@ -31,12 +46,17 @@ $router->get('/api/health', function ($request, $response) {
]);
});
-// Authentication Routes (Rate-limited: 5 attempts per 60 seconds per IP)
+// OTP Authentication Routes (WhatsApp via Nabeh Gateway + Device Fingerprinting)
+$router->post('/api/auth/otp/request', [\App\Controllers\AuthController::class, 'requestOtp'], [\App\Middlewares\RateLimitMiddleware::class]);
+$router->post('/api/auth/otp/verify', [\App\Controllers\AuthController::class, 'verifyOtp'], [\App\Middlewares\RateLimitMiddleware::class]);
+$router->post('/api/auth/logout', [\App\Controllers\AuthController::class, 'logout'], [\App\Middlewares\AuthMiddleware::class]);
+$router->get('/api/auth/me', [\App\Controllers\AuthController::class, 'me'], [\App\Middlewares\AuthMiddleware::class]);
+
+// Legacy / Direct Auth
$router->post('/api/auth/register', [\App\Controllers\AuthController::class, 'register'], [\App\Middlewares\RateLimitMiddleware::class]);
$router->post('/api/auth/login', [\App\Controllers\AuthController::class, 'login'], [\App\Middlewares\RateLimitMiddleware::class]);
-$router->get('/api/auth/me', [\App\Controllers\AuthController::class, 'me'], [\App\Middlewares\AuthMiddleware::class]);
-// Teacher Routes
+// Teacher Studio Routes
$router->post('/api/teacher/courses', [\App\Controllers\TeacherController::class, 'addCourse'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/teacher/lessons', [\App\Controllers\TeacherController::class, 'addLesson'], [\App\Middlewares\AuthMiddleware::class]);