feat(security): implement dynamic tactical license gate, instant key revocation, and clean hardcoded credentials
This commit is contained in:
@@ -4,7 +4,7 @@ import axios from 'axios';
|
|||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const API_URL = process.env.TEST_API_URL || 'https://map-saas.intaleqapp.com/api/geocoding/search';
|
const API_URL = process.env.TEST_API_URL || 'https://map-saas.intaleqapp.com/api/geocoding/search';
|
||||||
const API_KEY = process.env.TEST_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
const API_KEY = process.env.TEST_API_KEY || process.env.MAP_API_KEY || '';
|
||||||
let DATA_FILE = path.join(__dirname, '../../../data/golden_tests/queries_syria.json');
|
let DATA_FILE = path.join(__dirname, '../../../data/golden_tests/queries_syria.json');
|
||||||
if (!fs.existsSync(DATA_FILE) && fs.existsSync('/data/data/golden_tests/queries_syria.json')) {
|
if (!fs.existsSync(DATA_FILE) && fs.existsSync('/data/data/golden_tests/queries_syria.json')) {
|
||||||
DATA_FILE = '/data/data/golden_tests/queries_syria.json'; // Docker environment fallback
|
DATA_FILE = '/data/data/golden_tests/queries_syria.json'; // Docker environment fallback
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/search';
|
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/search';
|
||||||
const API_KEY = process.env.TEST_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
const API_KEY = process.env.TEST_API_KEY || process.env.MAP_API_KEY || '';
|
||||||
const TOTAL_REQUESTS = 5000;
|
const TOTAL_REQUESTS = 5000;
|
||||||
const CONCURRENCY = 50; // Number of parallel requests
|
const CONCURRENCY = 50; // Number of parallel requests
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/autocomplete';
|
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/autocomplete';
|
||||||
const API_KEY = process.env.TEST_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
const API_KEY = process.env.TEST_API_KEY || process.env.MAP_API_KEY || '';
|
||||||
|
|
||||||
const testQueries = [
|
const testQueries = [
|
||||||
"مستش",
|
"مستش",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/search';
|
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/search';
|
||||||
const API_KEY = process.env.TEST_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
const API_KEY = process.env.TEST_API_KEY || process.env.MAP_API_KEY || '';
|
||||||
|
|
||||||
const testQueries = [
|
const testQueries = [
|
||||||
"السمساني", // Should suggest "الشميساني"
|
"السمساني", // Should suggest "الشميساني"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import axios from 'axios';
|
|||||||
import { Client } from 'pg';
|
import { Client } from 'pg';
|
||||||
|
|
||||||
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/search';
|
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/search';
|
||||||
const API_KEY = process.env.TEST_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
const API_KEY = process.env.TEST_API_KEY || process.env.MAP_API_KEY || '';
|
||||||
const DB_URL = process.env.DATABASE_URL || 'postgresql://mapuser:TestMapPass123!@localhost:5432/mapdb';
|
const DB_URL = process.env.DATABASE_URL || 'postgresql://mapuser:TestMapPass123!@localhost:5432/mapdb';
|
||||||
|
|
||||||
async function testFailedLogging() {
|
async function testFailedLogging() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/search';
|
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/search';
|
||||||
const API_KEY = process.env.TEST_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
const API_KEY = process.env.TEST_API_KEY || process.env.MAP_API_KEY || '';
|
||||||
|
|
||||||
const testQueries = [
|
const testQueries = [
|
||||||
"سيتي مول",
|
"سيتي مول",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import axios from 'axios';
|
|||||||
|
|
||||||
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/search';
|
const API_URL = process.env.TEST_API_URL || 'http://localhost:3200/api/geocoding/search';
|
||||||
const AUTO_URL = process.env.TEST_AUTO_URL || 'http://localhost:3200/api/geocoding/autocomplete';
|
const AUTO_URL = process.env.TEST_AUTO_URL || 'http://localhost:3200/api/geocoding/autocomplete';
|
||||||
const API_KEY = process.env.TEST_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
const API_KEY = process.env.TEST_API_KEY || process.env.MAP_API_KEY || '';
|
||||||
|
|
||||||
const testQueries = [
|
const testQueries = [
|
||||||
"قرب مستشفى",
|
"قرب مستشفى",
|
||||||
|
|||||||
@@ -30,9 +30,10 @@ export class AuthModule implements OnModuleInit {
|
|||||||
* دمج مفتاح الأمان الافتراضي من الإعدادات لمنع توقف الرقابة الحالية
|
* دمج مفتاح الأمان الافتراضي من الإعدادات لمنع توقف الرقابة الحالية
|
||||||
*/
|
*/
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
const defaultKey = this.configService.get<string>('MAP_API_KEY') || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
const defaultKey = this.configService.get<string>('MAP_API_KEY');
|
||||||
await this.authService.seedDefaultKey('Default System', 'admin@intaleq.xyz', defaultKey);
|
if (defaultKey) {
|
||||||
await this.authService.seedDefaultKey('Default Fallback', 'support@intaleq.xyz', 'intaleq_secret_2026');
|
await this.authService.seedDefaultKey('Default System', 'admin@intaleq.xyz', defaultKey);
|
||||||
console.log('✅ System API Keys seeded successfully');
|
console.log('✅ System API Key from environment seeded successfully');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, UnauthorizedException, Logger, NotFoundException, ConflictException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException, Logger, NotFoundException, ConflictException, ForbiddenException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
@@ -126,6 +126,21 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete / Revoke an API key for a tenant with strict IDOR verification
|
||||||
|
*/
|
||||||
|
async deleteApiKey(tenantId: string, keyId: string): Promise<void> {
|
||||||
|
const key = await this.apiKeyRepository.findOne({ where: { id: keyId } });
|
||||||
|
if (!key) {
|
||||||
|
throw new NotFoundException('API Key not found');
|
||||||
|
}
|
||||||
|
if (key.tenantId !== tenantId) {
|
||||||
|
throw new ForbiddenException('Access denied: You cannot delete an API key belonging to another tenant');
|
||||||
|
}
|
||||||
|
await this.redisService.del(`auth:apikey:${key.key}`);
|
||||||
|
await this.apiKeyRepository.delete(keyId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all API keys for a specific tenant
|
* Fetch all API keys for a specific tenant
|
||||||
*/
|
*/
|
||||||
@@ -141,16 +156,16 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
async createApiKey(tenantId: string, name: string, rateLimit?: number, allowedOrigins?: string[]): Promise<ApiKey> {
|
async createApiKey(tenantId: string, name: string, rateLimit?: number, allowedOrigins?: string[]): Promise<ApiKey> {
|
||||||
const existingKeysCount = await this.apiKeyRepository.count({ where: { tenantId } });
|
const existingKeysCount = await this.apiKeyRepository.count({ where: { tenantId } });
|
||||||
if (existingKeysCount >= 1) {
|
if (existingKeysCount >= 5) {
|
||||||
throw new ConflictException('Limit reached: Only 1 API key allowed per developer currently.');
|
throw new ConflictException('Limit reached: Maximum 5 API keys allowed per tenant.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = `in_${createHash('md5').update(Math.random().toString()).digest('hex').substring(0, 24)}`;
|
const key = `in_${createHash('sha256').update(tenantId + Date.now().toString() + Math.random().toString()).digest('hex').substring(0, 28)}`;
|
||||||
|
|
||||||
const apiKey = this.apiKeyRepository.create({
|
const apiKey = this.apiKeyRepository.create({
|
||||||
key,
|
key,
|
||||||
secretHash: this.hashSecret(key),
|
secretHash: this.hashSecret(key),
|
||||||
name,
|
name: name || 'Production Key',
|
||||||
tenantId,
|
tenantId,
|
||||||
rateLimit: rateLimit || 100,
|
rateLimit: rateLimit || 100,
|
||||||
allowedOrigins: allowedOrigins || [],
|
allowedOrigins: allowedOrigins || [],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Post, Body, Param, UseGuards, Req, ForbiddenException } from '@nestjs/common';
|
import { Controller, Get, Post, Delete, Body, Param, UseGuards, Req, ForbiddenException } from '@nestjs/common';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { CreateKeyDto } from './dto/management/create-key.dto';
|
import { CreateKeyDto } from './dto/management/create-key.dto';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
@@ -30,6 +30,21 @@ export class TenantController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('keys/:tenantId')
|
||||||
|
@ApiOperation({ summary: 'Create a new API key for specified tenant (IDOR Protected)' })
|
||||||
|
async createKeyForTenant(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('tenantId') tenantId: string,
|
||||||
|
@Body() dto: CreateKeyDto
|
||||||
|
) {
|
||||||
|
// IDOR Protection: Tenant can only create keys for their own tenant ID
|
||||||
|
if (req.tenant.id !== tenantId) {
|
||||||
|
throw new ForbiddenException('Access denied: Cannot create API keys for another tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.createKey(req, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('keys')
|
@Get('keys')
|
||||||
@ApiOperation({ summary: 'Get all API keys for the authenticated tenant' })
|
@ApiOperation({ summary: 'Get all API keys for the authenticated tenant' })
|
||||||
async getKeys(@Req() req: any) {
|
async getKeys(@Req() req: any) {
|
||||||
@@ -37,9 +52,35 @@ export class TenantController {
|
|||||||
return this.authService.getApiKeys(tenantId);
|
return this.authService.getApiKeys(tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('keys/:tenantId')
|
||||||
|
@ApiOperation({ summary: 'Get all API keys for specified tenant (IDOR Protected)' })
|
||||||
|
async getKeysForTenant(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('tenantId') tenantId: string
|
||||||
|
) {
|
||||||
|
// IDOR Protection: Tenant can only access their own keys
|
||||||
|
if (req.tenant.id !== tenantId) {
|
||||||
|
throw new ForbiddenException('Access denied: Cannot view API keys for another tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.authService.getApiKeys(req.tenant.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('keys/:keyId')
|
||||||
|
@ApiOperation({ summary: 'Revoke / Delete an API key (IDOR Protected)' })
|
||||||
|
async deleteKey(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('keyId') keyId: string
|
||||||
|
) {
|
||||||
|
const tenantId = req.tenant.id;
|
||||||
|
await this.authService.deleteApiKey(tenantId, keyId);
|
||||||
|
return { success: true, message: 'API key revoked successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
@Get('me')
|
@Get('me')
|
||||||
@ApiOperation({ summary: 'Get current authenticated tenant info' })
|
@ApiOperation({ summary: 'Get current authenticated tenant info' })
|
||||||
async getMe(@Req() req: any) {
|
async getMe(@Req() req: any) {
|
||||||
return req.tenant;
|
return req.tenant;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
Req,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
@@ -26,6 +27,21 @@ import { TacticalService } from './tactical.service';
|
|||||||
export class TacticalController {
|
export class TacticalController {
|
||||||
constructor(private readonly tacticalService: TacticalService) {}
|
constructor(private readonly tacticalService: TacticalService) {}
|
||||||
|
|
||||||
|
@Get('verify-license')
|
||||||
|
@ApiOperation({ summary: 'Verify tactical clearance and military license' })
|
||||||
|
async verifyLicense(@Req() req: any) {
|
||||||
|
const tenant = req.tenant;
|
||||||
|
const apiKey = req.apiKey;
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
tenantName: tenant?.name || 'Authorized Tactical Operator',
|
||||||
|
plan: tenant?.plan || 'ENTERPRISE',
|
||||||
|
keyName: apiKey?.name || 'Tactical Defense Key',
|
||||||
|
rateLimit: req.rateLimit || 1000,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Get('line-of-sight')
|
@Get('line-of-sight')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Calculate Tactical Line of Sight & Intervisibility (تبادل الرؤية العسكري)',
|
summary: 'Calculate Tactical Line of Sight & Intervisibility (تبادل الرؤية العسكري)',
|
||||||
|
|||||||
@@ -328,9 +328,14 @@ const app = {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-6 text-right">
|
<td class="px-6 py-6 text-right">
|
||||||
<button class="p-2 text-slate-500 hover:text-white" onclick="app.fetchData()">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
|
<button class="p-2 text-slate-500 hover:text-white" title="Refresh" onclick="app.fetchData()">
|
||||||
</button>
|
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
<button class="p-2 text-slate-500 hover:text-red-400 transition-colors" title="Revoke Key" onclick="app.deleteKey('${key.id}', '${key.name}')">
|
||||||
|
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
@@ -339,6 +344,29 @@ const app = {
|
|||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
deleteKey: async (keyId, keyName) => {
|
||||||
|
if (!confirm(`Are you sure you want to revoke the API key "${keyName || 'Production Key'}"? This action cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/auth/management/keys/${keyId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: auth.getAuthHeader()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
await app.fetchData();
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
alert(data.message || 'Failed to revoke API key');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Delete key failed:', e);
|
||||||
|
alert('Failed to revoke API key');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
toggleKeyVisibility: (id) => {
|
toggleKeyVisibility: (id) => {
|
||||||
app.state.showKeys[id] = !app.state.showKeys[id];
|
app.state.showKeys[id] = !app.state.showKeys[id];
|
||||||
app.renderKeysTable();
|
app.renderKeysTable();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Globe,
|
Globe,
|
||||||
Zap,
|
Zap,
|
||||||
@@ -53,7 +54,7 @@ const DashboardHome = ({ tenant, keys, loading, onRefresh }: DashboardHomeProps)
|
|||||||
const createKey = async (name: string, limit: number) => {
|
const createKey = async (name: string, limit: number) => {
|
||||||
if (!tenant) return;
|
if (!tenant) return;
|
||||||
try {
|
try {
|
||||||
await axios.post(`/api/auth/management/keys/${tenant.id}`, {
|
await axios.post(`/api/auth/management/keys`, {
|
||||||
name,
|
name,
|
||||||
rateLimit: limit
|
rateLimit: limit
|
||||||
});
|
});
|
||||||
@@ -65,6 +66,18 @@ const DashboardHome = ({ tenant, keys, loading, onRefresh }: DashboardHomeProps)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteKey = async (keyId: string, keyName: string) => {
|
||||||
|
if (!window.confirm(`Are you sure you want to revoke the API key "${keyName || 'Production Key'}"? This action cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/auth/management/keys/${keyId}`);
|
||||||
|
onRefresh();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Failed to revoke key');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700">
|
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
@@ -264,9 +277,14 @@ const DashboardHome = ({ tenant, keys, loading, onRefresh }: DashboardHomeProps)
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-6 text-right">
|
<td className="px-6 py-6 text-right">
|
||||||
<button className="p-2 text-slate-500 hover:text-white" onClick={onRefresh}>
|
<div className="flex items-center justify-end gap-1">
|
||||||
<RefreshCw size={16} />
|
<button className="p-2 text-slate-500 hover:text-white" title="Refresh" onClick={onRefresh}>
|
||||||
</button>
|
<RefreshCw size={16} />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 text-slate-500 hover:text-red-400 transition-colors" title="Revoke Key" onClick={() => deleteKey(apiKey.id, apiKey.name)}>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -346,8 +346,8 @@
|
|||||||
CONFIG
|
CONFIG
|
||||||
─────────────────────────────────────────── */
|
─────────────────────────────────────────── */
|
||||||
const API_BASE = window.location.origin; // Dynamically use the current host
|
const API_BASE = window.location.origin; // Dynamically use the current host
|
||||||
const API_KEY = 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
const API_KEY = new URLSearchParams(window.location.search).get('api_key') || localStorage.getItem('map_api_key') || '';
|
||||||
const HEADERS = { 'x-api-key': API_KEY };
|
const HEADERS = API_KEY ? { 'x-api-key': API_KEY } : {};
|
||||||
|
|
||||||
const ROUTES = {
|
const ROUTES = {
|
||||||
LEVANT: {
|
LEVANT: {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { decodePolyline } from './utils/polyline';
|
|||||||
import WeatherPanel from './components/WeatherPanel';
|
import WeatherPanel from './components/WeatherPanel';
|
||||||
import { LineOfSightTool } from './components/LineOfSightTool';
|
import { LineOfSightTool } from './components/LineOfSightTool';
|
||||||
|
|
||||||
const DEFAULT_API_KEY = (import.meta as any).env.VITE_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
const DEFAULT_API_KEY = (import.meta as any).env.VITE_API_KEY || '';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [map, setMap] = useState<any>(null);
|
const [map, setMap] = useState<any>(null);
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ const MapComponent: React.FC<MapProps> = ({
|
|||||||
const fetchWeather = async () => {
|
const fetchWeather = async () => {
|
||||||
try {
|
try {
|
||||||
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
|
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
|
||||||
const apiKey = (import.meta as any).env.VITE_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
const apiKey = (import.meta as any).env.VITE_API_KEY || '';
|
||||||
|
|
||||||
const regionParam = currentRegion ? `?region=${encodeURIComponent(currentRegion)}` : '';
|
const regionParam = currentRegion ? `?region=${encodeURIComponent(currentRegion)}` : '';
|
||||||
const citiesRes = await fetch(`${apiUrl}/weather/cities${regionParam}`, {
|
const citiesRes = await fetch(`${apiUrl}/weather/cities${regionParam}`, {
|
||||||
|
|||||||
@@ -32,7 +32,13 @@ import {
|
|||||||
Compass,
|
Compass,
|
||||||
Check,
|
Check,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Sliders
|
Sliders,
|
||||||
|
Lock,
|
||||||
|
Unlock,
|
||||||
|
Key,
|
||||||
|
ShieldAlert,
|
||||||
|
ShieldCheck,
|
||||||
|
LogOut
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
calculateLineOfSight,
|
calculateLineOfSight,
|
||||||
@@ -78,6 +84,66 @@ export const TacticalDefenseView: React.FC = () => {
|
|||||||
const mapContainer = useRef<HTMLDivElement>(null);
|
const mapContainer = useRef<HTMLDivElement>(null);
|
||||||
const map = useRef<maplibregl.Map | null>(null);
|
const map = useRef<maplibregl.Map | null>(null);
|
||||||
|
|
||||||
|
// Tactical License & Access Clearance State
|
||||||
|
const [authKey, setAuthKey] = useState<string>(() => {
|
||||||
|
return new URLSearchParams(window.location.search).get('key') ||
|
||||||
|
new URLSearchParams(window.location.search).get('api_key') ||
|
||||||
|
sessionStorage.getItem('tactical_api_key') || '';
|
||||||
|
});
|
||||||
|
const [authStatus, setAuthStatus] = useState<'validating' | 'authorized' | 'unauthorized'>('validating');
|
||||||
|
const [authInfo, setAuthInfo] = useState<{ tenantName?: string; plan?: string; keyName?: string } | null>(null);
|
||||||
|
const [authError, setAuthError] = useState<string>('');
|
||||||
|
const [inputKey, setInputKey] = useState<string>('');
|
||||||
|
const [isVerifying, setIsVerifying] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const verifyTacticalLicense = async (keyCandidate: string) => {
|
||||||
|
if (!keyCandidate || !keyCandidate.trim()) {
|
||||||
|
setAuthStatus('unauthorized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsVerifying(true);
|
||||||
|
setAuthError('');
|
||||||
|
try {
|
||||||
|
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
|
||||||
|
const res = await fetch(`${apiUrl}/tactical/verify-license`, {
|
||||||
|
headers: { 'x-api-key': keyCandidate.trim() }
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setAuthInfo(data);
|
||||||
|
setAuthKey(keyCandidate.trim());
|
||||||
|
sessionStorage.setItem('tactical_api_key', keyCandidate.trim());
|
||||||
|
setAuthStatus('authorized');
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem('tactical_api_key');
|
||||||
|
setAuthStatus('unauthorized');
|
||||||
|
setAuthError('مفتاح التصريح التكتيكي غير صالح، أو منتهي الصلاحية، أو تم إلغاؤه من قبل الإدارة.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
sessionStorage.removeItem('tactical_api_key');
|
||||||
|
setAuthStatus('unauthorized');
|
||||||
|
setAuthError('تعذر التحقق من خادم التخويل التكتيكي. يرجى التأكد من الاتصال.');
|
||||||
|
} finally {
|
||||||
|
setIsVerifying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authKey) {
|
||||||
|
verifyTacticalLicense(authKey);
|
||||||
|
} else {
|
||||||
|
setAuthStatus('unauthorized');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRevokeOrLock = () => {
|
||||||
|
sessionStorage.removeItem('tactical_api_key');
|
||||||
|
setAuthKey('');
|
||||||
|
setAuthStatus('unauthorized');
|
||||||
|
setAuthInfo(null);
|
||||||
|
setAuthError('');
|
||||||
|
};
|
||||||
|
|
||||||
const [mode, setMode] = useState<TacticalMode>('terrain');
|
const [mode, setMode] = useState<TacticalMode>('terrain');
|
||||||
const [cursorPos, setCursorPos] = useState({ lat: 31.95, lng: 35.93, elev: 850 });
|
const [cursorPos, setCursorPos] = useState({ lat: 31.95, lng: 35.93, elev: 850 });
|
||||||
const [activePlacement, setActivePlacement] = useState<string | null>(null);
|
const [activePlacement, setActivePlacement] = useState<string | null>(null);
|
||||||
@@ -983,11 +1049,11 @@ export const TacticalDefenseView: React.FC = () => {
|
|||||||
setArtilleryLoading(true);
|
setArtilleryLoading(true);
|
||||||
try {
|
try {
|
||||||
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
|
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
|
||||||
const apiKey = (import.meta as any).env.VITE_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
const keyToSend = authKey || (import.meta as any).env.VITE_API_KEY || '';
|
||||||
|
|
||||||
const res = await fetch(`${apiUrl}/tactical/artillery-fire-mission`, {
|
const res = await fetch(`${apiUrl}/tactical/artillery-fire-mission`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
|
headers: { 'Content-Type': 'application/json', 'x-api-key': keyToSend },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
gunLat: gunPos[0],
|
gunLat: gunPos[0],
|
||||||
gunLng: gunPos[1],
|
gunLng: gunPos[1],
|
||||||
@@ -1026,10 +1092,10 @@ export const TacticalDefenseView: React.FC = () => {
|
|||||||
setHlzLoading(true);
|
setHlzLoading(true);
|
||||||
try {
|
try {
|
||||||
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
|
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
|
||||||
const apiKey = (import.meta as any).env.VITE_API_KEY || 'zP9vL5mK2nQ8xR7jT4wS1yB6hG3fV0cX';
|
const keyToSend = authKey || (import.meta as any).env.VITE_API_KEY || '';
|
||||||
|
|
||||||
const res = await fetch(`${apiUrl}/tactical/hlz-assessment?lat=${hlzCenter[0]}&lng=${hlzCenter[1]}&radius=4000`, {
|
const res = await fetch(`${apiUrl}/tactical/hlz-assessment?lat=${hlzCenter[0]}&lng=${hlzCenter[1]}&radius=4000`, {
|
||||||
headers: { 'x-api-key': apiKey }
|
headers: { 'x-api-key': keyToSend }
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setHlzResult(data);
|
setHlzResult(data);
|
||||||
@@ -1199,11 +1265,57 @@ ${terrainResult.tacticalRecommendations.map((r, i) => `${i + 1}. ${r}`).join('\n
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Realtime Cursor HUD */}
|
{/* Realtime Cursor HUD & Clearance Status */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: '0.72rem', fontFamily: 'monospace', color: '#94a3b8' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, fontSize: '0.72rem', fontFamily: 'monospace', color: '#94a3b8' }}>
|
||||||
<div>LAT: <span style={{ color: '#38bdf8' }}>{cursorPos.lat}</span></div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<div>LNG: <span style={{ color: '#38bdf8' }}>{cursorPos.lng}</span></div>
|
<div>LAT: <span style={{ color: '#38bdf8' }}>{cursorPos.lat}</span></div>
|
||||||
<div>ELEV: <span style={{ color: '#4ade80' }}>{cursorPos.elev}m</span></div>
|
<div>LNG: <span style={{ color: '#38bdf8' }}>{cursorPos.lng}</span></div>
|
||||||
|
<div>ELEV: <span style={{ color: '#4ade80' }}>{cursorPos.elev}m</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{authStatus === 'authorized' && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, borderRight: '1px solid rgba(255,255,255,0.15)', paddingRight: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 5,
|
||||||
|
background: 'rgba(34, 197, 94, 0.15)',
|
||||||
|
border: '1px solid rgba(34, 197, 94, 0.35)',
|
||||||
|
color: '#4ade80',
|
||||||
|
padding: '3px 8px',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: 'inherit'
|
||||||
|
}}>
|
||||||
|
<ShieldCheck size={13} />
|
||||||
|
<span>تصريح: {authInfo?.tenantName || 'Enterprise'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleRevokeOrLock}
|
||||||
|
title="قفل المنظومة التكتيكية / خروج"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(239, 68, 68, 0.15)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||||
|
color: '#f87171',
|
||||||
|
padding: '3px 8px',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
transition: 'all 0.15s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Lock size={12} />
|
||||||
|
<span>قفل</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -2781,6 +2893,176 @@ ${terrainResult.tacticalRecommendations.map((r, i) => `${i + 1}. ${r}`).join('\n
|
|||||||
{/* Tactical Map Container */}
|
{/* Tactical Map Container */}
|
||||||
<div ref={mapContainer} style={{ flex: 1, height: '100%', position: 'relative' }} />
|
<div ref={mapContainer} style={{ flex: 1, height: '100%', position: 'relative' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tactical License Authorization Modal Overlay */}
|
||||||
|
{authStatus !== 'authorized' && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh',
|
||||||
|
background: 'rgba(5, 8, 15, 0.94)',
|
||||||
|
backdropFilter: 'blur(25px)',
|
||||||
|
zIndex: 9999,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
direction: 'rtl',
|
||||||
|
padding: 20
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 520,
|
||||||
|
background: 'linear-gradient(180deg, rgba(15, 23, 42, 0.98), rgba(11, 19, 38, 0.98))',
|
||||||
|
border: '1px solid rgba(56, 189, 248, 0.3)',
|
||||||
|
borderRadius: 16,
|
||||||
|
boxShadow: '0 0 50px rgba(14, 165, 233, 0.2), 0 25px 50px -12px rgba(0, 0, 0, 0.8)',
|
||||||
|
padding: '32px 28px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
textAlign: 'center',
|
||||||
|
position: 'relative'
|
||||||
|
}}>
|
||||||
|
{/* Top Tactical Glowing Badge */}
|
||||||
|
<div style={{
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 20,
|
||||||
|
background: 'linear-gradient(135deg, rgba(14, 165, 233, 0.2), rgba(79, 70, 229, 0.3))',
|
||||||
|
border: '1px solid rgba(56, 189, 248, 0.4)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 18,
|
||||||
|
boxShadow: '0 0 25px rgba(56, 189, 248, 0.35)'
|
||||||
|
}}>
|
||||||
|
{authStatus === 'validating' ? (
|
||||||
|
<Radio size={32} color="#38bdf8" />
|
||||||
|
) : (
|
||||||
|
<ShieldAlert size={32} color="#38bdf8" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style={{ fontSize: '1.35rem', fontWeight: 800, color: '#f8fafc', margin: '0 0 6px 0' }}>
|
||||||
|
منظومة القيادة والسيطرة والتحليل التكتيكي
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: '0.82rem', color: '#94a3b8', marginBottom: 20, lineHeight: 1.5 }}>
|
||||||
|
بوابة التخويل الأمني العسكري — يتطلب استخدام أدوات التحليل الطبوغرافي ورمايات المدفعية وحقول الألغام مفتاح تصريح معتمد (<span style={{ color: '#38bdf8', fontFamily: 'monospace' }}>Enterprise Key</span>).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{authError && (
|
||||||
|
<div style={{
|
||||||
|
width: '100%',
|
||||||
|
background: 'rgba(239, 68, 68, 0.12)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '10px 14px',
|
||||||
|
color: '#f87171',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: 18,
|
||||||
|
textAlign: 'right',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8
|
||||||
|
}}>
|
||||||
|
<AlertTriangle size={16} style={{ flexShrink: 0 }} />
|
||||||
|
<span>{authError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
verifyTacticalLicense(inputKey);
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 14 }}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<label style={{ display: 'block', fontSize: '0.78rem', fontWeight: 700, color: '#cbd5e1', marginBottom: 6 }}>
|
||||||
|
أدخل مفتاح الترخيص والتصريح التكتيكي:
|
||||||
|
</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={inputKey}
|
||||||
|
onChange={(e) => setInputKey(e.target.value)}
|
||||||
|
placeholder="in_xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
background: 'rgba(15, 23, 42, 0.8)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '12px 14px',
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
outline: 'none',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Key size={16} color="#64748b" style={{ position: 'absolute', left: 14, top: 14 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isVerifying || !inputKey.trim()}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
background: 'linear-gradient(135deg, #0284c7, #4f46e5)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '12px',
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: '0.88rem',
|
||||||
|
fontWeight: 800,
|
||||||
|
cursor: isVerifying || !inputKey.trim() ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isVerifying || !inputKey.trim() ? 0.6 : 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
boxShadow: '0 4px 14px rgba(2, 132, 199, 0.4)',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isVerifying ? (
|
||||||
|
<>
|
||||||
|
<Activity size={16} />
|
||||||
|
<span>جاري التحقق من الصلاحيات والتصريح...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ShieldCheck size={16} />
|
||||||
|
<span>تفعيل الدخول والتحقق من الصلاحية</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 22, paddingTop: 16, borderTop: '1px solid rgba(255,255,255,0.08)', width: '100%', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.75rem', color: '#64748b' }}>
|
||||||
|
<span>منصة الخرائط والأنظمة الذكية</span>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
style={{
|
||||||
|
color: '#38bdf8',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: 600,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← العودة إلى الخريطة العامة
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user