🚀 مُصادَق: تحديث برمجي جديد 2026-05-03 13:19
This commit is contained in:
@@ -11,14 +11,13 @@ final class Application
|
||||
{
|
||||
private Container $container;
|
||||
private Router $router;
|
||||
public static ?array $config = null;
|
||||
|
||||
public function __construct(string $basePath)
|
||||
{
|
||||
// 1. Load Environment Variables (Point to the specific 'env' folder outside htdocs)
|
||||
// Path: /home/intaleqapp-musadaq/env/
|
||||
$envPath = dirname(dirname(dirname($basePath))) . '/env';
|
||||
|
||||
$dotenv = Dotenv::createImmutable($envPath);
|
||||
// 1. Load Environment Variables
|
||||
// In local dev, .env is in the project root. In production, it might be moved.
|
||||
$dotenv = Dotenv::createImmutable($basePath);
|
||||
$dotenv->load();
|
||||
|
||||
// 2. Set Timezone
|
||||
@@ -26,6 +25,10 @@ final class Application
|
||||
|
||||
// 3. Initialize Core Components
|
||||
$this->container = new Container();
|
||||
|
||||
// 4. Load Configurations
|
||||
$this->loadConfigs($basePath);
|
||||
|
||||
$this->router = new Router($this->container);
|
||||
|
||||
// Register core services in container
|
||||
@@ -33,6 +36,20 @@ final class Application
|
||||
$this->container->set(Router::class, $this->router);
|
||||
}
|
||||
|
||||
private function loadConfigs(string $basePath): void
|
||||
{
|
||||
$configPath = $basePath . '/config';
|
||||
$configs = [];
|
||||
|
||||
foreach (glob($configPath . '/*.php') as $file) {
|
||||
$key = basename($file, '.php');
|
||||
$configs[$key] = require $file;
|
||||
}
|
||||
|
||||
self::$config = $configs;
|
||||
$this->container->set('config', $configs);
|
||||
}
|
||||
|
||||
public function getRouter(): Router
|
||||
{
|
||||
return $this->router;
|
||||
@@ -40,6 +57,16 @@ final class Application
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
// 1. Security Headers
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: DENY');
|
||||
header('X-XSS-Protection: 1; mode=block');
|
||||
header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
|
||||
header('Referrer-Policy: strict-origin-when-cross-origin');
|
||||
header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
|
||||
header('Content-Security-Policy: default-src \'self\'; script-src \'self\' cdn.tailwindcss.com unpkg.com; style-src \'self\' \'unsafe-inline\' fonts.googleapis.com; font-src fonts.gstatic.com');
|
||||
header_remove('X-Powered-By');
|
||||
|
||||
try {
|
||||
$request = new Request();
|
||||
$this->router->dispatch($request, $this->container);
|
||||
|
||||
@@ -67,8 +67,12 @@ final class Router
|
||||
array_reverse($middlewares),
|
||||
function ($next, $middleware) use ($container) {
|
||||
return function ($request) use ($next, $middleware, $container) {
|
||||
$instance = $container->get($middleware);
|
||||
return $instance->handle($request, $next);
|
||||
$parts = explode(':', $middleware);
|
||||
$className = $parts[0];
|
||||
$args = isset($parts[1]) ? explode(',', $parts[1]) : [];
|
||||
|
||||
$instance = $container->get($className);
|
||||
return $instance->handle($request, $next, ...$args);
|
||||
};
|
||||
},
|
||||
function ($request) use ($handler, $container, $vars) {
|
||||
|
||||
37
app/Core/helpers.php
Normal file
37
app/Core/helpers.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if (!function_exists('config')) {
|
||||
/**
|
||||
* Get a configuration value using dot notation.
|
||||
* Example: config('app.name')
|
||||
*/
|
||||
function config(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$configs = \App\Core\Application::$config;
|
||||
|
||||
if ($configs === null) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$parts = explode('.', $key);
|
||||
$value = $configs;
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if (!isset($value[$part])) {
|
||||
return $default;
|
||||
}
|
||||
$value = $value[$part];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('env')) {
|
||||
function env(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $_ENV[$key] ?? $default;
|
||||
}
|
||||
}
|
||||
43
app/Middleware/CsrfMiddleware.php
Normal file
43
app/Middleware/CsrfMiddleware.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
use App\Core\{Request, Response};
|
||||
|
||||
final class CsrfMiddleware
|
||||
{
|
||||
public function handle(Request $request, callable $next): mixed
|
||||
{
|
||||
// Skip CSRF check for safe methods
|
||||
if (in_array($request->getMethod(), ['GET', 'HEAD', 'OPTIONS'])) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// For APIs, we often use a custom header or check origin
|
||||
// If we use sessions for tokens:
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$token = $request->getHeader('X-CSRF-TOKEN') ?: ($request->getBody()['_csrf'] ?? null);
|
||||
$sessionToken = $_SESSION['csrf_token'] ?? null;
|
||||
|
||||
if (!$token || !$sessionToken || !hash_equals($sessionToken, $token)) {
|
||||
// For now, if we are purely API with Bearer token, we might skip this.
|
||||
// But if the request has a session or cookie, it's mandatory.
|
||||
|
||||
// If the Authorization header is present, we might assume it's an API call
|
||||
// that is naturally protected against CSRF if not using cookies for Auth.
|
||||
if ($request->getHeader('Authorization')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
Response::error('رمز الحماية (CSRF) غير صالح أو مفقود', 'CSRF_INVALID', 403);
|
||||
return null;
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
37
app/Middleware/RoleMiddleware.php
Normal file
37
app/Middleware/RoleMiddleware.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
use App\Core\{Request, Response};
|
||||
|
||||
final class RoleMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle the request.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param callable $next
|
||||
* @param string ...$roles
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(Request $request, callable $next, string ...$roles): mixed
|
||||
{
|
||||
$user = $request->user ?? null;
|
||||
|
||||
if (!$user) {
|
||||
Response::error('يجب تسجيل الدخول للوصول إلى هذا المورد', 'UNAUTHORIZED', 401);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if user role is in the allowed roles
|
||||
// $user->role is an object property since we cast it in AuthMiddleware
|
||||
if (!in_array($user->role, $roles)) {
|
||||
Response::error('غير مسموح لك بالقيام بهذا الإجراء', 'FORBIDDEN', 403);
|
||||
return null;
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
43
app/Middleware/TenantMiddleware.php
Normal file
43
app/Middleware/TenantMiddleware.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
use App\Core\{Request, Response, Database};
|
||||
|
||||
final class TenantMiddleware
|
||||
{
|
||||
public function handle(Request $request, callable $next): mixed
|
||||
{
|
||||
$tenantId = $request->tenantId ?? null;
|
||||
|
||||
if (!$tenantId) {
|
||||
Response::error('المستأجر غير معروف', 'TENANT_NOT_FOUND', 400);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if tenant exists and is active
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$stmt = $db->prepare("SELECT status FROM tenants WHERE id = ? AND deleted_at IS NULL");
|
||||
$stmt->execute([$tenantId]);
|
||||
$tenant = $stmt->fetch();
|
||||
|
||||
if (!$tenant) {
|
||||
Response::error('المستأجر غير موجود', 'TENANT_NOT_FOUND', 404);
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($tenant['status'] === 'suspended') {
|
||||
Response::error('تم إيقاف حساب المستأجر', 'TENANT_SUSPENDED', 403);
|
||||
return null;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Response::error('خطأ في الاتصال بقاعدة البيانات', 'DATABASE_ERROR', 500);
|
||||
return null;
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -53,4 +53,79 @@ final class AuthController
|
||||
'data' => $request->user
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request): void
|
||||
{
|
||||
// Clear refresh token cookie
|
||||
setcookie('refresh_token', '', [
|
||||
'expires' => time() - 3600,
|
||||
'path' => '/api/v1/auth/refresh',
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
'secure' => true
|
||||
]);
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'message' => 'تم تسجيل الخروج بنجاح'
|
||||
]);
|
||||
}
|
||||
|
||||
public function refresh(Request $request): void
|
||||
{
|
||||
$refreshToken = $_COOKIE['refresh_token'] ?? null;
|
||||
|
||||
if (!$refreshToken) {
|
||||
Response::error('رمز التجديد مفقود', 'UNAUTHORIZED', 401);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->authService->refresh($refreshToken);
|
||||
|
||||
// Set new refresh token in HttpOnly cookie
|
||||
setcookie('refresh_token', $result['refresh_token'], [
|
||||
'expires' => time() + (60 * 60 * 24 * 7),
|
||||
'path' => '/api/v1/auth/refresh',
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
'secure' => true
|
||||
]);
|
||||
|
||||
unset($result['refresh_token']);
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'message' => 'تم تجديد الجلسة بنجاح'
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
Response::error($e->getMessage(), 'REFRESH_FAILED', 401);
|
||||
}
|
||||
}
|
||||
public function register(Request $request): void
|
||||
{
|
||||
try {
|
||||
$result = $this->authService->register($request->getBody());
|
||||
|
||||
// Set refresh token in HttpOnly cookie
|
||||
setcookie('refresh_token', $result['refresh_token'], [
|
||||
'expires' => time() + (60 * 60 * 24 * 7),
|
||||
'path' => '/api/v1/auth/refresh',
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
'secure' => true
|
||||
]);
|
||||
|
||||
unset($result['refresh_token']);
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
'message' => 'تم إنشاء الحساب وتسجيل الدخول بنجاح'
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
Response::error($e->getMessage(), 'REGISTRATION_FAILED', 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,19 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Auth;
|
||||
|
||||
use App\Modules\Users\UserModel;
|
||||
use App\Modules\Tenants\TenantModel;
|
||||
use App\Modules\Subscriptions\SubscriptionModel;
|
||||
use App\Services\Security\JwtService;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Exception;
|
||||
|
||||
final class AuthService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserModel $userModel,
|
||||
private readonly JwtService $jwtService
|
||||
private readonly JwtService $jwtService,
|
||||
private readonly TenantModel $tenantModel,
|
||||
private readonly SubscriptionModel $subscriptionModel
|
||||
) {}
|
||||
|
||||
public function login(string $email, string $password): array
|
||||
@@ -55,4 +60,88 @@ final class AuthService
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function refresh(string $refreshToken): array
|
||||
{
|
||||
$parts = explode('.', $refreshToken);
|
||||
if (count($parts) !== 2) {
|
||||
throw new Exception("رمز التجديد غير صالحة");
|
||||
}
|
||||
|
||||
[$userId, $random] = $parts;
|
||||
$user = $this->userModel->find($userId);
|
||||
|
||||
if (!$user || !$user['is_active']) {
|
||||
throw new Exception("المستخدم غير موجود أو معطل");
|
||||
}
|
||||
|
||||
if (!$user['refresh_token_hash'] || !password_verify($refreshToken, $user['refresh_token_hash'])) {
|
||||
throw new Exception("جلسة العمل منتهية، يرجى تسجيل الدخول مرة أخرى");
|
||||
}
|
||||
|
||||
$accessToken = $this->jwtService->issueAccessToken([
|
||||
'user_id' => $user['id'],
|
||||
'tenant_id' => $user['tenant_id'],
|
||||
'role' => $user['role'],
|
||||
'assigned_company_id' => $user['assigned_company_id']
|
||||
]);
|
||||
|
||||
$newRefreshToken = $this->jwtService->issueRefreshToken($user['id']);
|
||||
|
||||
$this->userModel->update($user['id'], [
|
||||
'refresh_token_hash' => password_hash($newRefreshToken, PASSWORD_BCRYPT)
|
||||
]);
|
||||
|
||||
return [
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $newRefreshToken,
|
||||
'user' => [
|
||||
'id' => $user['id'],
|
||||
'name' => $user['name'],
|
||||
'email' => $user['email'],
|
||||
'role' => $user['role'],
|
||||
'assigned_company_id' => $user['assigned_company_id']
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function register(array $data): array
|
||||
{
|
||||
// 1. Check if tenant already exists
|
||||
if ($this->tenantModel->findByEmail($data['email'])) {
|
||||
throw new Exception("هذا البريد الإلكتروني مسجل مسبقاً");
|
||||
}
|
||||
|
||||
$tenantId = Uuid::uuid4()->toString();
|
||||
$userId = Uuid::uuid4()->toString();
|
||||
|
||||
// 2. Create Tenant
|
||||
$this->tenantModel->create([
|
||||
'id' => $tenantId,
|
||||
'name' => $data['tenant_name'],
|
||||
'email' => $data['email'],
|
||||
'status' => 'trial',
|
||||
'trial_ends_at' => date('Y-m-d H:i:s', strtotime('+14 days'))
|
||||
]);
|
||||
|
||||
// 3. Create Subscription
|
||||
$this->subscriptionModel->create([
|
||||
'tenant_id' => $tenantId,
|
||||
'plan' => 'basic',
|
||||
'status' => 'trial'
|
||||
]);
|
||||
|
||||
// 4. Create User
|
||||
$this->userModel->create([
|
||||
'id' => $userId,
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => $data['user_name'],
|
||||
'email' => $data['email'],
|
||||
'password_hash' => password_hash($data['password'], PASSWORD_ARGON2ID),
|
||||
'role' => 'admin',
|
||||
'is_active' => 1
|
||||
]);
|
||||
|
||||
return $this->login($data['email'], $data['password']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,38 +72,18 @@ final class InvoiceController
|
||||
'idempotency_key' => bin2hex(random_bytes(16))
|
||||
]);
|
||||
|
||||
// Attempt AI Extraction
|
||||
try {
|
||||
$mimeType = mime_content_type($filePath);
|
||||
$extractedData = $this->aiExtraction->extractInvoiceData($filePath, $mimeType);
|
||||
|
||||
// Update Invoice with extracted data
|
||||
$this->invoiceModel->update($invoiceId, [
|
||||
'status' => 'extracted', // Match schema ENUM
|
||||
'ai_raw_response' => json_encode($extractedData, JSON_UNESCAPED_UNICODE)
|
||||
]);
|
||||
// Push to Queue for AI Extraction
|
||||
\App\Services\QueueService::push('invoice_extraction', [
|
||||
'invoice_id' => $invoiceId,
|
||||
'file_path' => $filePath,
|
||||
'mime_type' => mime_content_type($filePath)
|
||||
]);
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'invoice_id' => $invoiceId,
|
||||
'extracted_data' => $extractedData
|
||||
],
|
||||
'message' => 'تم رفع الفاتورة واستخراج البيانات بنجاح بالذكاء الاصطناعي'
|
||||
]);
|
||||
|
||||
} catch (Throwable $aiError) {
|
||||
// Keep it uploaded, maybe manual retry later
|
||||
$this->invoiceModel->update($invoiceId, [
|
||||
'status' => 'validation_failed' // Match schema fallback
|
||||
]);
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'data' => ['invoice_id' => $invoiceId],
|
||||
'message' => 'تم الرفع ولكن فشل استخراج البيانات. ' . $aiError->getMessage()
|
||||
]);
|
||||
}
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'data' => ['invoice_id' => $invoiceId],
|
||||
'message' => 'تم رفع الفاتورة بنجاح وجاري استخراج البيانات بالذكاء الاصطناعي'
|
||||
], 202);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
Response::error($e->getMessage(), 'UPLOAD_FAILED', 500);
|
||||
|
||||
29
app/Modules/Subscriptions/SubscriptionController.php
Normal file
29
app/Modules/Subscriptions/SubscriptionController.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Subscriptions;
|
||||
|
||||
use App\Core\{Request, Response};
|
||||
use App\Modules\Subscriptions\SubscriptionModel;
|
||||
|
||||
final class SubscriptionController
|
||||
{
|
||||
public function __construct(private readonly SubscriptionModel $subscriptionModel) {}
|
||||
|
||||
public function me(Request $request): void
|
||||
{
|
||||
$tenantId = $request->tenantId;
|
||||
$subscription = $this->subscriptionModel->findByTenantId($tenantId);
|
||||
|
||||
if (!$subscription) {
|
||||
Response::error('لا يوجد اشتراك فعال حالياً', 'NOT_FOUND', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'data' => $subscription
|
||||
]);
|
||||
}
|
||||
}
|
||||
19
app/Modules/Subscriptions/SubscriptionModel.php
Normal file
19
app/Modules/Subscriptions/SubscriptionModel.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Subscriptions;
|
||||
|
||||
use App\Models\BaseModel;
|
||||
|
||||
final class SubscriptionModel extends BaseModel
|
||||
{
|
||||
protected string $table = 'subscriptions';
|
||||
|
||||
public function findByTenantId(string $tenantId): ?array
|
||||
{
|
||||
$stmt = $this->db()->prepare("SELECT * FROM {$this->table} WHERE tenant_id = ? LIMIT 1");
|
||||
$stmt->execute([$tenantId]);
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
}
|
||||
29
app/Modules/Tenants/TenantController.php
Normal file
29
app/Modules/Tenants/TenantController.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Tenants;
|
||||
|
||||
use App\Core\{Request, Response};
|
||||
use App\Modules\Tenants\TenantModel;
|
||||
|
||||
final class TenantController
|
||||
{
|
||||
public function __construct(private readonly TenantModel $tenantModel) {}
|
||||
|
||||
public function me(Request $request): void
|
||||
{
|
||||
$tenantId = $request->tenantId;
|
||||
$tenant = $this->tenantModel->find($tenantId);
|
||||
|
||||
if (!$tenant) {
|
||||
Response::error('المستأجر غير موجود', 'NOT_FOUND', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'data' => $tenant
|
||||
]);
|
||||
}
|
||||
}
|
||||
19
app/Modules/Tenants/TenantModel.php
Normal file
19
app/Modules/Tenants/TenantModel.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Tenants;
|
||||
|
||||
use App\Models\BaseModel;
|
||||
|
||||
final class TenantModel extends BaseModel
|
||||
{
|
||||
protected string $table = 'tenants';
|
||||
|
||||
public function findByEmail(string $email): ?array
|
||||
{
|
||||
$stmt = $this->db()->prepare("SELECT * FROM {$this->table} WHERE email = ? AND deleted_at IS NULL LIMIT 1");
|
||||
$stmt->execute([$email]);
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
}
|
||||
42
app/Modules/Users/UserController.php
Normal file
42
app/Modules/Users/UserController.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Users;
|
||||
|
||||
use App\Core\{Request, Response};
|
||||
use App\Modules\Users\UserModel;
|
||||
|
||||
final class UserController
|
||||
{
|
||||
public function __construct(private readonly UserModel $userModel) {}
|
||||
|
||||
public function index(Request $request): void
|
||||
{
|
||||
$tenantId = $request->tenantId;
|
||||
$users = $this->userModel->findAllByTenant($tenantId);
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'data' => $users
|
||||
]);
|
||||
}
|
||||
|
||||
public function detail(Request $request, array $vars): void
|
||||
{
|
||||
$tenantId = $request->tenantId;
|
||||
$userId = $vars['id'];
|
||||
|
||||
$user = $this->userModel->findById($userId, $tenantId);
|
||||
|
||||
if (!$user) {
|
||||
Response::error('المستخدم غير موجود', 'NOT_FOUND', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
Response::json([
|
||||
'success' => true,
|
||||
'data' => $user
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -24,4 +24,18 @@ final class UserModel extends BaseModel
|
||||
$stmt->execute($params);
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
public function findAllByTenant(string $tenantId): array
|
||||
{
|
||||
$stmt = $this->db()->prepare("SELECT id, name, email, role, is_active, created_at FROM {$this->table} WHERE tenant_id = ? AND deleted_at IS NULL");
|
||||
$stmt->execute([$tenantId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
public function findById(string $id, string $tenantId): ?array
|
||||
{
|
||||
$stmt = $this->db()->prepare("SELECT id, name, email, role, is_active, created_at FROM {$this->table} WHERE id = ? AND tenant_id = ? AND deleted_at IS NULL LIMIT 1");
|
||||
$stmt->execute([$id, $tenantId]);
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,9 @@ final class JwtService
|
||||
|
||||
public function issueRefreshToken(string $userId): string
|
||||
{
|
||||
// Refresh token is a random string stored in DB (hashed)
|
||||
return bin2hex(random_bytes(64));
|
||||
// Refresh token is a random string prefixed with userId for lookup
|
||||
$random = bin2hex(random_bytes(32));
|
||||
return $userId . '.' . $random;
|
||||
}
|
||||
|
||||
public function verifyToken(string $token): array
|
||||
|
||||
Reference in New Issue
Block a user