From f4f8bc8e779c47ae5d73b4bc24ed03fad3f0d56f Mon Sep 17 00:00:00 2001 From: F4ilji Date: Sat, 4 Jul 2026 12:53:28 +0500 Subject: [PATCH] =?UTF-8?q?feat(vikon):=20rewrite=20VikonIntegration=20fro?= =?UTF-8?q?m=20scratch=20=E2=80=94=20OAuth=20+=20core=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HttpTask: Laravel Http facade with SSL enabled, retry, timeout - ValidateTokenTask: token check via auth.db-nica.ru - RefreshTokenTask: token refresh via db-nica.ru - FilesystemTask: path traversal protection, blocked extensions (PHP/ASP/etc) - AuthenticateAction: OAuth2 code→token exchange - CheckAccessAction: token validation + filesystem writability check - CheckVersionAction: version comparison against remote API - UpdateCoreAction: ZIP download, Zip Slip protection, atomic sync with rollback - VikonController: thin controller, 7 endpoints with Session-based token storage - Routes: access-check, dashboard.auth, throttle:30,1 middleware - Vue 3 Composition API frontend with progress bar - Config: all secrets in .env via config/vikon.php - vikon_core kept as fallback (not deleted) --- .../Actions/Auth/AuthenticateAction.php | 39 +++ .../Actions/CheckAccessAction.php | 47 ++++ .../Actions/CheckVersionAction.php | 36 +++ .../Actions/UpdateCoreAction.php | 160 ++++++++++++ .../Providers/VikonServiceProvider.php | 71 ++++++ .../VikonIntegration/Tasks/FilesystemTask.php | 86 +++++++ .../VikonIntegration/Tasks/HttpTask.php | 77 ++++++ .../Tasks/RefreshTokenTask.php | 36 +++ .../Tasks/ValidateTokenTask.php | 27 ++ .../UI/WEB/Controllers/VikonController.php | 117 +++++++++ .../UI/WEB/Requests/AuthenticateRequest.php | 18 ++ .../UI/WEB/Requests/UpdateModuleRequest.php | 18 ++ .../VikonIntegration/UI/WEB/Routes/web.php | 17 ++ config/app.php | 2 +- .../js/Pages/Dashboard/VikonUpdates/Index.vue | 240 ++++++++++++++++++ 15 files changed, 990 insertions(+), 1 deletion(-) create mode 100644 app/Containers/VikonIntegration/Actions/Auth/AuthenticateAction.php create mode 100644 app/Containers/VikonIntegration/Actions/CheckAccessAction.php create mode 100644 app/Containers/VikonIntegration/Actions/CheckVersionAction.php create mode 100644 app/Containers/VikonIntegration/Actions/UpdateCoreAction.php create mode 100644 app/Containers/VikonIntegration/Providers/VikonServiceProvider.php create mode 100644 app/Containers/VikonIntegration/Tasks/FilesystemTask.php create mode 100644 app/Containers/VikonIntegration/Tasks/HttpTask.php create mode 100644 app/Containers/VikonIntegration/Tasks/RefreshTokenTask.php create mode 100644 app/Containers/VikonIntegration/Tasks/ValidateTokenTask.php create mode 100644 app/Containers/VikonIntegration/UI/WEB/Controllers/VikonController.php create mode 100644 app/Containers/VikonIntegration/UI/WEB/Requests/AuthenticateRequest.php create mode 100644 app/Containers/VikonIntegration/UI/WEB/Requests/UpdateModuleRequest.php create mode 100644 app/Containers/VikonIntegration/UI/WEB/Routes/web.php create mode 100644 resources/js/Pages/Dashboard/VikonUpdates/Index.vue diff --git a/app/Containers/VikonIntegration/Actions/Auth/AuthenticateAction.php b/app/Containers/VikonIntegration/Actions/Auth/AuthenticateAction.php new file mode 100644 index 0000000..7e55b0d --- /dev/null +++ b/app/Containers/VikonIntegration/Actions/Auth/AuthenticateAction.php @@ -0,0 +1,39 @@ +http->post('oauth2/authorize/token', [ + 'code' => $code, + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'redirect_uri' => $redirectUri, + 'grant_type' => 'authorization_code', + ], 'auth'); + + $body = $response->json(); + + if (!isset($body['access_token'], $body['refresh_token'])) { + throw new \RuntimeException('Auth failed: ' . ($body['message'] ?? 'Unknown error')); + } + + return [ + 'access_token' => $body['access_token'], + 'refresh_token' => $body['refresh_token'], + ]; + } +} diff --git a/app/Containers/VikonIntegration/Actions/CheckAccessAction.php b/app/Containers/VikonIntegration/Actions/CheckAccessAction.php new file mode 100644 index 0000000..b634319 --- /dev/null +++ b/app/Containers/VikonIntegration/Actions/CheckAccessAction.php @@ -0,0 +1,47 @@ +validateToken->run($accessToken)) { + return ['has_access' => false, 'error' => 'Токен недействителен. Выполните повторную авторизацию.']; + } + + $nonWritable = $this->findNonWritable($this->publicPath); + if (!empty($nonWritable)) { + return [ + 'has_access' => false, + 'error' => 'Нет прав на запись: ' . implode(', ', array_slice($nonWritable, 0, 3)), + ]; + } + + return ['has_access' => true, 'error' => null]; + } + + private function findNonWritable(string $path, int $depth = 0): array + { + if ($depth > 3 || !is_dir($path)) return []; + if (!is_writable($path)) { + return [str_replace(base_path() . '/', '', $path)]; + } + $result = []; + foreach (File::directories($path) as $dir) { + $result = array_merge($result, $this->findNonWritable($dir, $depth + 1)); + } + return $result; + } +} diff --git a/app/Containers/VikonIntegration/Actions/CheckVersionAction.php b/app/Containers/VikonIntegration/Actions/CheckVersionAction.php new file mode 100644 index 0000000..7b084b5 --- /dev/null +++ b/app/Containers/VikonIntegration/Actions/CheckVersionAction.php @@ -0,0 +1,36 @@ +http->getWithToken('pull_updates/getLatestVersion', $accessToken); + $body = $response->json(); + $latest = $body['version'] ?? null; + return [ + 'current_version' => $this->currentVersion, + 'latest_version' => $latest, + 'has_update' => $latest && version_compare($latest, $this->currentVersion, '>'), + ]; + } catch (\Throwable $e) { + Log::warning('Version check failed', ['error' => $e->getMessage()]); + return [ + 'current_version' => $this->currentVersion, + 'latest_version' => null, + 'has_update' => false, + 'error' => 'Не удалось проверить обновления', + ]; + } + } +} diff --git a/app/Containers/VikonIntegration/Actions/UpdateCoreAction.php b/app/Containers/VikonIntegration/Actions/UpdateCoreAction.php new file mode 100644 index 0000000..3d89057 --- /dev/null +++ b/app/Containers/VikonIntegration/Actions/UpdateCoreAction.php @@ -0,0 +1,160 @@ +modulesConfig[$moduleId] ?? throw new \RuntimeException('Неизвестный модуль'); + $modulePath = $this->basePath . '/' . $config['path']; + $tempPath = $this->storagePath . '/temp/' . $config['path']; + + try { + Log::info('Vikon: downloading module core', ['module' => $moduleId]); + $zipContent = $this->http->downloadWithToken( + 'pull_updates/generateEmptyModuleCore/' . $moduleId, + $accessToken + ); + + if (File::exists($tempPath)) File::deleteDirectory($tempPath); + File::makeDirectory($tempPath, 0755, true, true); + + $zipFile = $tempPath . '/module.zip'; + file_put_contents($zipFile, $zipContent); + + $this->extractZip($zipFile, $tempPath); + + $blocked = $this->fs->validateFileTypes($tempPath); + if (!empty($blocked)) { + throw new \RuntimeException( + 'Запрещённые файлы: ' . implode(', ', $blocked) . '. Обновление отклонено.' + ); + } + + $vikonCorePath = $tempPath . '/vikon_core'; + if (File::isDirectory($vikonCorePath)) { + File::deleteDirectory($vikonCorePath); + } + + File::delete($zipFile); + + $this->syncFiles($tempPath, $modulePath); + $this->cleanModule($modulePath, $config['allowed_folders']); + File::put($modulePath . '/.vikon', date('Y-m-d H:i:s')); + File::deleteDirectory($tempPath); + + Log::info('Vikon: module updated', ['module' => $config['name']]); + return 'Модуль "' . $config['name'] . '" обновлён.'; + + } catch (\Throwable $e) { + Log::error('Vikon update failed', ['module' => $moduleId, 'error' => $e->getMessage()]); + $this->rollback($modulePath); + throw new \RuntimeException('Ошибка обновления: ' . $e->getMessage()); + } + } + + private function extractZip(string $zipPath, string $destination): void + { + $zip = new ZipArchive; + if ($zip->open($zipPath) !== true) { + throw new \RuntimeException('Не удалось открыть ZIP'); + } + + $realDest = realpath($destination); + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if (str_contains($name, '..')) { + $zip->close(); + throw new \RuntimeException("Zip Slip: {$name}"); + } + $full = realpath($realDest . '/' . $name); + if ($full !== false && !str_starts_with($full, $realDest)) { + $zip->close(); + throw new \RuntimeException("Path escape: {$name}"); + } + } + + $zip->extractTo($destination); + $zip->close(); + } + + private function syncFiles(string $source, string $target): void + { + foreach (File::files($source) as $file) { + $name = $file->getFilename(); + $targetPath = $target . '/' . $name; + + if (File::exists($targetPath)) { + $oldPath = $targetPath . self::OLD_SUFFIX; + File::delete($oldPath); + rename($targetPath, $oldPath); + } + copy($file->getPathname(), $targetPath); + } + + foreach (File::directories($source) as $dir) { + $name = basename($dir); + $targetPath = $target . '/' . $name; + + if (File::exists($targetPath)) { + $oldPath = $targetPath . self::OLD_SUFFIX; + File::deleteDirectory($oldPath); + File::move($targetPath, $oldPath); + } + + File::copyDirectory($dir, $targetPath); + } + } + + private function cleanModule(string $modulePath, array $allowed): void + { + foreach (File::directories($modulePath) as $dir) { + $name = basename($dir); + if (!in_array($name, $allowed, true) && !is_link($dir)) { + File::deleteDirectory($dir); + } + } + foreach (File::files($modulePath) as $file) { + $name = $file->getFilename(); + if (!in_array($name, $allowed, true) && !in_array($name, ['.vikon', '.htaccess'], true)) { + File::delete($file); + } + } + } + + private function rollback(string $modulePath): void + { + foreach (File::directories($modulePath) as $dir) { + $old = $dir . self::OLD_SUFFIX; + if (File::exists($old)) { + File::deleteDirectory($dir); + File::move($old, $dir); + } + } + foreach (File::files($modulePath) as $file) { + $old = $file->getPathname() . self::OLD_SUFFIX; + if (File::exists($old)) { + File::delete($file); + rename($old, $file->getPathname()); + } + } + } +} diff --git a/app/Containers/VikonIntegration/Providers/VikonServiceProvider.php b/app/Containers/VikonIntegration/Providers/VikonServiceProvider.php new file mode 100644 index 0000000..5a8f75a --- /dev/null +++ b/app/Containers/VikonIntegration/Providers/VikonServiceProvider.php @@ -0,0 +1,71 @@ +mergeConfigFrom(config_path('vikon.php'), 'vikon'); + + $this->app->singleton(HttpTask::class, fn () => new HttpTask( + apiDomain: config('vikon.api_domain'), + authDomain: config('vikon.auth_domain'), + filemanagerDomain: config('vikon.filemanager_domain'), + timeout: config('vikon.http_timeout', 60), + retries: config('vikon.http_retries', 3), + )); + + $this->app->singleton(ValidateTokenTask::class, fn ($app) => new ValidateTokenTask( + http: $app->make(HttpTask::class), + )); + + $this->app->singleton(RefreshTokenTask::class, fn ($app) => new RefreshTokenTask( + http: $app->make(HttpTask::class), + clientId: config('vikon.client_id'), + clientSecret: config('vikon.client_secret'), + )); + + $this->app->singleton(FilesystemTask::class, fn () => new FilesystemTask); + + $this->app->singleton(AuthenticateAction::class, fn ($app) => new AuthenticateAction( + http: $app->make(HttpTask::class), + clientId: config('vikon.client_id'), + clientSecret: config('vikon.client_secret'), + )); + + $this->app->singleton(CheckAccessAction::class, fn ($app) => new CheckAccessAction( + validateToken: $app->make(ValidateTokenTask::class), + http: $app->make(HttpTask::class), + publicPath: public_path(), + )); + + $this->app->singleton(CheckVersionAction::class, fn ($app) => new CheckVersionAction( + http: $app->make(HttpTask::class), + currentVersion: config('vikon.current_version', '1.0.0'), + )); + + $this->app->singleton(UpdateCoreAction::class, fn ($app) => new UpdateCoreAction( + http: $app->make(HttpTask::class), + fs: $app->make(FilesystemTask::class), + modulesConfig: config('vikon.modules'), + storagePath: config('vikon.storage_path'), + basePath: public_path(), + )); + } + + public function boot(): void + { + $this->loadRoutesFrom(app_path('Containers/VikonIntegration/UI/WEB/Routes/web.php')); + } +} diff --git a/app/Containers/VikonIntegration/Tasks/FilesystemTask.php b/app/Containers/VikonIntegration/Tasks/FilesystemTask.php new file mode 100644 index 0000000..317f3d0 --- /dev/null +++ b/app/Containers/VikonIntegration/Tasks/FilesystemTask.php @@ -0,0 +1,86 @@ +isPathSafe($path, $base)) { + Log::warning('Path traversal blocked', ['path' => $path, 'base' => $base]); + return false; + } + if (!file_exists($path)) return true; + if (!is_dir($path)) return File::delete($path); + return File::deleteDirectory($path); + } + + public function safeMkdir(string $path): bool + { + if (File::isDirectory($path)) return true; + return File::makeDirectory($path, 0755, true, true); + } + + public function replaceDirectory(string $source, string $target, string $base): bool + { + if (!$this->isPathSafe($source, $base) || !$this->isPathSafe($target, $base)) { + return false; + } + if (!File::exists($source)) return false; + if (File::exists($target)) return false; + + $parent = dirname($target); + if (!is_writable($parent)) return false; + + if (!File::makeDirectory($target, 0755, true, true)) return false; + + foreach (File::allFiles($source) as $file) { + $relative = ltrim(str_replace($source, '', $file->getPathname()), '/'); + $dest = $target . '/' . $relative; + $destDir = dirname($dest); + if (!File::isDirectory($destDir)) { + File::makeDirectory($destDir, 0755, true, true); + } + if (!copy($file->getPathname(), $dest)) return false; + } + + return File::deleteDirectory($source); + } + + public function validateFileTypes(string $directory): array + { + $blocked = [ + 'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'phps', + 'asp', 'aspx', 'jsp', 'jspx', 'cfm', + 'pl', 'py', 'rb', 'cgi', 'sh', 'bash', 'bat', 'cmd', 'exe', + 'ps1', 'htaccess', 'htpasswd', + ]; + $found = []; + + foreach (File::allFiles($directory) as $file) { + $ext = strtolower($file->getExtension()); + if (in_array($ext, $blocked, true)) { + $found[] = str_replace(base_path() . '/', '', $file->getPathname()); + } + } + + return $found; + } +} diff --git a/app/Containers/VikonIntegration/Tasks/HttpTask.php b/app/Containers/VikonIntegration/Tasks/HttpTask.php new file mode 100644 index 0000000..5913cb1 --- /dev/null +++ b/app/Containers/VikonIntegration/Tasks/HttpTask.php @@ -0,0 +1,77 @@ +url($endpoint, $service); + return $this->client()->get($url, $params); + } + + public function post(string $endpoint, array $data = [], string $service = 'api'): \Illuminate\Http\Client\Response + { + $url = $this->url($endpoint, $service); + return $this->client()->post($url, $data); + } + + public function getWithToken(string $endpoint, string $token, string $service = 'api'): \Illuminate\Http\Client\Response + { + $url = $this->url($endpoint, $service); + return $this->client() + ->withToken($token) + ->get($url); + } + + public function postWithToken(string $endpoint, string $token, array $data = [], string $service = 'api'): \Illuminate\Http\Client\Response + { + $url = $this->url($endpoint, $service); + return $this->client() + ->withToken($token) + ->post($url, $data); + } + + public function downloadWithToken(string $endpoint, string $token, string $service = 'api'): string + { + $url = $this->url($endpoint, $service); + $response = $this->client() + ->withToken($token) + ->withHeaders(['Accept-Encoding' => 'zip, gzip']) + ->get($url); + + if ($response->failed()) { + throw new \RuntimeException('Download failed: HTTP ' . $response->status()); + } + + return $response->body(); + } + + private function client(): PendingRequest + { + return Http::timeout($this->timeout) + ->retry($this->retries, 500) + ->withHeaders(['Accept' => 'application/json']); + } + + private function url(string $endpoint, string $service): string + { + $base = match ($service) { + 'auth' => $this->authDomain, + 'filemanager' => $this->filemanagerDomain, + default => $this->apiDomain, + }; + return rtrim($base, '/') . '/' . ltrim($endpoint, '/'); + } +} diff --git a/app/Containers/VikonIntegration/Tasks/RefreshTokenTask.php b/app/Containers/VikonIntegration/Tasks/RefreshTokenTask.php new file mode 100644 index 0000000..fb08f39 --- /dev/null +++ b/app/Containers/VikonIntegration/Tasks/RefreshTokenTask.php @@ -0,0 +1,36 @@ +http->post('oauth2/RefreshToken', [ + 'refresh_token' => $refreshToken, + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'grant_type' => 'refresh_token', + ]); + + $body = $response->json(); + + if (!isset($body['access_token'], $body['refresh_token'])) { + throw new \RuntimeException('Token refresh failed: ' . ($body['message'] ?? 'Unknown')); + } + + return [ + 'access_token' => $body['access_token'], + 'refresh_token' => $body['refresh_token'], + ]; + } +} diff --git a/app/Containers/VikonIntegration/Tasks/ValidateTokenTask.php b/app/Containers/VikonIntegration/Tasks/ValidateTokenTask.php new file mode 100644 index 0000000..d1a211e --- /dev/null +++ b/app/Containers/VikonIntegration/Tasks/ValidateTokenTask.php @@ -0,0 +1,27 @@ +http->getWithToken( + 'api/profile_applicant/check_access_token', + $accessToken, + 'auth' + ); + return $response->successful(); + } catch (\Throwable $e) { + Log::warning('Vikon token validation failed', ['error' => $e->getMessage()]); + return false; + } + } +} diff --git a/app/Containers/VikonIntegration/UI/WEB/Controllers/VikonController.php b/app/Containers/VikonIntegration/UI/WEB/Controllers/VikonController.php new file mode 100644 index 0000000..dd66ed1 --- /dev/null +++ b/app/Containers/VikonIntegration/UI/WEB/Controllers/VikonController.php @@ -0,0 +1,117 @@ +validateToken->run($token) : false; + + return inertia()->render('Dashboard/VikonUpdates/Index', [ + 'is_authenticated' => $isAuth, + 'current_version' => config('vikon.current_version'), + 'modules' => config('vikon.modules'), + 'vikon_auth_domain' => config('vikon.auth_domain'), + 'vikon_client_id' => config('vikon.client_id'), + ]); + } + + public function authenticate(AuthenticateRequest $request): JsonResponse + { + try { + $tokens = $this->auth->run( + $request->validated('code'), + $request->validated('redirect_uri') + ); + Session::put('vikon_access_token', $tokens['access_token']); + Session::put('vikon_refresh_token', $tokens['refresh_token']); + + return response()->json(['success' => true]); + } catch (\Throwable $e) { + return response()->json(['success' => false, 'message' => 'Ошибка авторизации'], 422); + } + } + + public function refreshToken(): JsonResponse + { + $refreshToken = Session::get('vikon_refresh_token'); + if (!$refreshToken) { + return response()->json(['success' => false, 'message' => 'Нет refresh токена'], 401); + } + + try { + $tokens = $this->refreshToken->run($refreshToken); + Session::put('vikon_access_token', $tokens['access_token']); + Session::put('vikon_refresh_token', $tokens['refresh_token']); + return response()->json(['success' => true]); + } catch (\Throwable $e) { + Session::forget(['vikon_access_token', 'vikon_refresh_token']); + return response()->json(['success' => false, 'message' => 'Токен истёк'], 401); + } + } + + public function checkAccess(): JsonResponse + { + $token = Session::get('vikon_access_token'); + if (!$token) { + return response()->json(['success' => false, 'requires_auth' => true], 401); + } + + $result = $this->checkAccess->run($token); + return response()->json($result); + } + + public function checkVersion(): JsonResponse + { + $token = Session::get('vikon_access_token'); + if (!$token) { + return response()->json(['success' => false, 'requires_auth' => true], 401); + } + + return response()->json($this->checkVersion->run($token)); + } + + public function updateModule(UpdateModuleRequest $request): JsonResponse + { + $token = Session::get('vikon_access_token'); + if (!$token) { + return response()->json(['success' => false, 'requires_auth' => true], 401); + } + + try { + $message = $this->updateCore->run($request->validated('module_id'), $token); + return response()->json(['success' => true, 'message' => $message]); + } catch (\Throwable $e) { + return response()->json(['success' => false, 'message' => 'Ошибка обновления'], 500); + } + } + + public function logout(): JsonResponse + { + Session::forget(['vikon_access_token', 'vikon_refresh_token']); + return response()->json(['success' => true]); + } +} diff --git a/app/Containers/VikonIntegration/UI/WEB/Requests/AuthenticateRequest.php b/app/Containers/VikonIntegration/UI/WEB/Requests/AuthenticateRequest.php new file mode 100644 index 0000000..5d6cec3 --- /dev/null +++ b/app/Containers/VikonIntegration/UI/WEB/Requests/AuthenticateRequest.php @@ -0,0 +1,18 @@ +check(); } + + public function rules(): array + { + return [ + 'code' => ['required', 'string', 'max:255'], + 'redirect_uri' => ['required', 'url'], + ]; + } +} diff --git a/app/Containers/VikonIntegration/UI/WEB/Requests/UpdateModuleRequest.php b/app/Containers/VikonIntegration/UI/WEB/Requests/UpdateModuleRequest.php new file mode 100644 index 0000000..35acab5 --- /dev/null +++ b/app/Containers/VikonIntegration/UI/WEB/Requests/UpdateModuleRequest.php @@ -0,0 +1,18 @@ +check(); } + + public function rules(): array + { + return [ + 'module_id' => ['required', 'integer', Rule::in([1, 2, 6])], + ]; + } +} diff --git a/app/Containers/VikonIntegration/UI/WEB/Routes/web.php b/app/Containers/VikonIntegration/UI/WEB/Routes/web.php new file mode 100644 index 0000000..d4d4934 --- /dev/null +++ b/app/Containers/VikonIntegration/UI/WEB/Routes/web.php @@ -0,0 +1,17 @@ +name('dashboard.vikon-updates.') + ->middleware(['access-check', 'dashboard.auth', 'throttle:30,1']) + ->group(function () { + Route::get('/', [VikonController::class, 'index'])->name('index'); + Route::post('/authenticate', [VikonController::class, 'authenticate'])->name('authenticate'); + Route::post('/refresh-token', [VikonController::class, 'refreshToken'])->name('refresh-token'); + Route::post('/check-access', [VikonController::class, 'checkAccess'])->name('check-access'); + Route::post('/check-version', [VikonController::class, 'checkVersion'])->name('check-version'); + Route::post('/update-module', [VikonController::class, 'updateModule'])->name('update-module'); + Route::post('/logout', [VikonController::class, 'logout'])->name('logout'); + }); diff --git a/config/app.php b/config/app.php index 2f0f0aa..247f3e1 100755 --- a/config/app.php +++ b/config/app.php @@ -172,7 +172,7 @@ return [ // App\Providers\Filament\DashboardPanelProvider::class, App\Providers\RouteServiceProvider::class, \App\Providers\ForceHttpsServiceProvider::class, - // \App\Containers\VikonIntegration\Providers\VikonServiceProvider::class, // will be re-added + \App\Containers\VikonIntegration\Providers\VikonServiceProvider::class, ])->toArray(), /* diff --git a/resources/js/Pages/Dashboard/VikonUpdates/Index.vue b/resources/js/Pages/Dashboard/VikonUpdates/Index.vue new file mode 100644 index 0000000..d181b01 --- /dev/null +++ b/resources/js/Pages/Dashboard/VikonUpdates/Index.vue @@ -0,0 +1,240 @@ + + +