feat(v5): authorize creates, deep-link selection, list apps in v4

Restrict V5 application and resource-connection creation to team admins.
Resolve project, environment, and application from query params and keep
session selection in sync. Surface V5 apps on the v4 resource index and
count them for project/environment emptiness. Create the flux data dir
on install and upgrade.
This commit is contained in:
Andras Bacsai
2026-07-18 15:55:47 +02:00
parent 772093928f
commit a4ab69df56
23 changed files with 299 additions and 38 deletions
@@ -51,6 +51,7 @@ class ApplicationController extends Controller
public function store(Request $request): JsonResponse
{
$currentTeam = $this->currentTeamOrFail($request);
$this->authorize('create', [V5Application::class, $currentTeam]);
$projects = $this->projects($currentTeam);
[$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects);
@@ -34,8 +34,8 @@ trait ResolvesProjectSelection
*/
protected function selectedProjectAndEnvironment(Request $request, array $projects): array
{
$selectedProjectUuid = $request->session()->get(self::SELECTED_PROJECT_SESSION_KEY);
$selectedEnvironmentUuid = $request->session()->get(self::SELECTED_ENVIRONMENT_SESSION_KEY);
$selectedProjectUuid = $request->query('project', $request->session()->get(self::SELECTED_PROJECT_SESSION_KEY));
$selectedEnvironmentUuid = $request->query('environment', $request->session()->get(self::SELECTED_ENVIRONMENT_SESSION_KEY));
$selectedProject = null;
foreach ($projects as $project) {
@@ -59,6 +59,13 @@ trait ResolvesProjectSelection
$selectedEnvironment ??= $selectedProject['environments'][0] ?? null;
if ($request->query->has('project') || $request->query->has('environment')) {
$request->session()->put([
self::SELECTED_PROJECT_SESSION_KEY => $selectedProject['uuid'] ?? null,
self::SELECTED_ENVIRONMENT_SESSION_KEY => $selectedEnvironment['uuid'] ?? null,
]);
}
return [$selectedProject, $selectedEnvironment];
}
@@ -33,17 +33,23 @@ class DashboardController extends Controller
$currentTeam = $request->attributes->get('v5.currentTeam');
$projects = $this->projects($currentTeam);
[$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects);
$applications = $this->applications($currentTeam, $selectedProject, $selectedEnvironment);
$requestedApplicationUuid = $request->query('application');
$selectedApplicationUuid = collect($applications)->contains(
fn (array $application): bool => $application['id'] === $requestedApplicationUuid
) ? $requestedApplicationUuid : null;
return Inertia::render('Dashboard', [
'currentTeam' => $this->serializeCurrentTeam($currentTeam),
'flux' => $fluxHealth->check(),
'applications' => $this->applications($currentTeam, $selectedProject, $selectedEnvironment),
'applications' => $applications,
'caddyIngresses' => $this->caddyIngresses($currentTeam),
'resourceConnections' => $this->resourceConnections($currentTeam, $selectedProject, $selectedEnvironment),
'nginxServers' => $this->nginxServers($currentTeam),
'projects' => $projects,
'selectedProjectUuid' => $selectedProject['uuid'] ?? null,
'selectedEnvironmentUuid' => $selectedEnvironment['uuid'] ?? null,
'selectedApplicationUuid' => $selectedApplicationUuid,
]);
}
@@ -35,6 +35,7 @@ class ResourceConnectionController extends Controller
public function store(Request $request): JsonResponse
{
$currentTeam = $this->currentTeamOrFail($request);
$this->authorize('create', [ResourceConnection::class, $currentTeam]);
$projects = $this->projects($currentTeam);
[$selectedProject, $selectedEnvironment] = $this->selectedProjectAndEnvironment($request, $projects);
+25 -3
View File
@@ -4,6 +4,7 @@ namespace App\Livewire\Project\Resource;
use App\Models\Environment;
use App\Models\Project;
use App\Models\V5\Application as V5Application;
use Illuminate\Support\Collection;
use Livewire\Component;
@@ -61,6 +62,7 @@ class Index extends Component
->select('id', 'uuid', 'name', 'project_id')
->with([
'applications:id,uuid,name,environment_id',
'v5Applications:id,uuid,name,environment_id,status',
'services:id,uuid,name,environment_id',
'postgresqls:id,uuid,name,environment_id',
'redis:id,uuid,name,environment_id',
@@ -103,6 +105,23 @@ class Index extends Component
return $application;
});
$this->applications = $this->applications
->merge(V5Application::query()
->where('team_id', currentTeam()->id)
->where('project_id', $this->project->id)
->where('environment_id', $this->environment->id)
->with('server:id,name')
->get()
->map(function (V5Application $application) use ($projectUuid, $environmentUuid) {
$application->hrefLink = route('v5.dashboard', [
'project' => $projectUuid,
'environment' => $environmentUuid,
'application' => $application->uuid,
]);
return $application;
}))
->sortBy('name');
// Load all database resources in a single query per type
$databaseTypes = [
@@ -180,16 +199,19 @@ class Index extends Component
'uuid' => $item->uuid,
'name' => $item->name,
'fqdn' => $item->fqdn ?? null,
'description' => $item->description ?? null,
'description' => $item instanceof V5Application ? 'Managed by Coolify V5' : ($item->description ?? null),
'status' => $item->status ?? '',
'version' => $item instanceof V5Application ? 'v5' : 'v4',
'server_status' => $item->server_status ?? null,
'hrefLink' => $item->hrefLink ?? '',
'destination' => [
'server' => [
'name' => $item->destination?->server?->name ?? 'Unknown',
'name' => $item instanceof V5Application
? ($item->server?->name ?? 'Unknown')
: ($item->destination?->server?->name ?? 'Unknown'),
],
],
'tags' => $item->tags->map(fn ($tag) => [
'tags' => ($item instanceof V5Application ? collect() : $item->tags)->map(fn ($tag) => [
'id' => $tag->id,
'name' => $tag->name,
])->values()->toArray(),
+10 -1
View File
@@ -2,6 +2,8 @@
namespace App\Models;
use App\Models\V5\Application as V5Application;
use App\Models\V5\ResourceConnection as V5ResourceConnection;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -54,7 +56,9 @@ class Environment extends BaseModel
public function isEmpty()
{
return $this->applications()->count() == 0 &&
return ! V5Application::query()->where('environment_id', $this->id)->exists() &&
! V5ResourceConnection::query()->where('environment_id', $this->id)->exists() &&
$this->applications()->count() == 0 &&
$this->redis()->count() == 0 &&
$this->postgresqls()->count() == 0 &&
$this->mysqls()->count() == 0 &&
@@ -76,6 +80,11 @@ class Environment extends BaseModel
return $this->hasMany(Application::class);
}
public function v5Applications()
{
return $this->hasMany(V5Application::class);
}
public function postgresqls()
{
return $this->hasMany(StandalonePostgresql::class);
+5 -1
View File
@@ -2,6 +2,8 @@
namespace App\Models;
use App\Models\V5\Application as V5Application;
use App\Models\V5\ResourceConnection as V5ResourceConnection;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -144,7 +146,9 @@ class Project extends BaseModel
public function isEmpty()
{
return $this->applications()->count() == 0 &&
return ! V5Application::query()->where('project_id', $this->id)->exists() &&
! V5ResourceConnection::query()->where('project_id', $this->id)->exists() &&
$this->applications()->count() == 0 &&
$this->redis()->count() == 0 &&
$this->postgresqls()->count() == 0 &&
$this->mysqls()->count() == 0 &&
+7
View File
@@ -9,6 +9,13 @@ use Illuminate\Auth\Access\Response;
class ApplicationPolicy
{
public function create(User $user, Team $team): Response
{
return $user->isAdminOfTeam($team->id)
? Response::allow()
: Response::deny('You do not have permission to manage applications in this team.');
}
/**
* Determine whether the user can view the application within the current team.
*
@@ -9,6 +9,13 @@ use Illuminate\Auth\Access\Response;
class ResourceConnectionPolicy
{
public function create(User $user, Team $team): Response
{
return $user->isAdminOfTeam($team->id)
? Response::allow()
: Response::deny('You do not have permission to manage resource connections in this team.');
}
/**
* Determine whether the user can update the connection within the current team.
*/
+2 -1
View File
@@ -228,11 +228,12 @@ if [ "$WARNING_SPACE" = true ]; then
sleep 5
fi
mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel}
mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel,flux}
mkdir -p /data/coolify/ssh/{keys,mux}
mkdir -p /data/coolify/proxy/dynamic
chown -R 9999:root /data/coolify
chown -R 9999:root /data/coolify/flux
chmod -R 700 /data/coolify
INSTALLATION_LOG_WITH_DATE="/data/coolify/source/installation-${DATE}.log"
+3
View File
@@ -171,6 +171,9 @@ else
log "Network 'coolify' already exists"
fi
mkdir -p /data/coolify/flux
chown -R 9999:root /data/coolify/flux
# Check if Docker config file exists
DOCKER_CONFIG_MOUNT=""
if [ -f /root/.docker/config.json ]; then
+37 -21
View File
@@ -135,7 +135,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -740,7 +739,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -789,7 +787,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -997,12 +994,36 @@
"@noble/ciphers": "^1.0.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -1244,7 +1265,6 @@
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -2082,7 +2102,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -2167,7 +2188,6 @@
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2178,7 +2198,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -2337,8 +2356,7 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/accepts": {
"version": "2.0.0",
@@ -2425,6 +2443,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -2445,6 +2464,7 @@
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"dequal": "^2.0.3"
}
@@ -2600,7 +2620,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -3172,6 +3191,7 @@
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
@@ -3200,7 +3220,8 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/dot-prop": {
"version": "6.0.1",
@@ -3523,7 +3544,6 @@
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -3973,7 +3993,6 @@
"integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -4871,6 +4890,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -5622,6 +5642,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -5777,7 +5798,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5787,7 +5807,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -5800,7 +5819,8 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/react-refresh": {
"version": "0.18.0",
@@ -6501,8 +6521,7 @@
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.0",
@@ -6718,7 +6737,6 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6841,7 +6859,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -7465,7 +7482,6 @@
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+13 -3
View File
@@ -90,6 +90,7 @@ export default function Dashboard({
projects = [],
selectedProjectUuid = null,
selectedEnvironmentUuid = null,
selectedApplicationUuid = null,
}: V5DashboardProps) {
const [applications, setApplications] = useState<V5Application[]>(initialApplications);
const [ingresses, setIngresses] = useState<V5CaddyIngress[]>(caddyIngresses);
@@ -175,11 +176,20 @@ export default function Dashboard({
setIngresses(settledResources.ingresses);
resetConnections(initialResourceConnections);
setSelectedNginxServerId((currentServerId) => currentServerId || nginxServers[0]?.id || '');
setSelectedApplicationId(null);
setSelectedInspectorApplicationId(null);
const linkedApplicationExists = settledResources.applications.some((application) => application.id === selectedApplicationUuid);
setSelectedApplicationId(linkedApplicationExists ? selectedApplicationUuid : null);
setSelectedInspectorApplicationId(linkedApplicationExists ? selectedApplicationUuid : null);
centerOnCanvasNodes(settledResources.applications, settledResources.ingresses);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialApplications, caddyIngresses, initialResourceConnections, nginxServers[0]?.id, selectedProjectUuid, selectedEnvironmentUuid]);
}, [
initialApplications,
caddyIngresses,
initialResourceConnections,
nginxServers[0]?.id,
selectedProjectUuid,
selectedEnvironmentUuid,
selectedApplicationUuid,
]);
useCanvasResourceMerge({
teamId: currentTeam?.id ?? null,
+1
View File
@@ -145,6 +145,7 @@ export type V5DashboardProps = {
projects?: V5Project[];
selectedProjectUuid?: string | null;
selectedEnvironmentUuid?: string | null;
selectedApplicationUuid?: string | null;
};
export type SelectItemOption = {
@@ -91,6 +91,7 @@
->merge($env->clickhouses ?? collect());
$envResources = collect()
->merge($env->applications->map(fn($app) => ['type' => 'application', 'resource' => $app]))
->merge($env->v5Applications->map(fn($app) => ['type' => 'v5-application', 'resource' => $app]))
->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]))
->merge($env->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]))
->sortBy(fn($item) => strtolower($item['resource']->name));
@@ -140,6 +141,7 @@
->merge($env->clickhouses ?? collect());
$envResources = collect()
->merge($env->applications->map(fn($app) => ['type' => 'application', 'resource' => $app]))
->merge($env->v5Applications->map(fn($app) => ['type' => 'v5-application', 'resource' => $app]))
->merge($envDatabases->map(fn($db) => ['type' => 'database', 'resource' => $db]))
->merge($env->services->map(fn($svc) => ['type' => 'service', 'resource' => $svc]));
@endphp
@@ -157,6 +159,11 @@
$resType = $envResource['type'];
$res = $envResource['resource'];
$resRoute = match ($resType) {
'v5-application' => route('v5.dashboard', [
'project' => $project->uuid,
'environment' => $env->uuid,
'application' => $res->uuid,
]),
'application' => route('project.application.configuration', [
'project_uuid' => $project->uuid,
'environment_uuid' => $env->uuid,
@@ -233,10 +240,15 @@
class="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2 xl:grid-cols-3">
<template x-for="item in filteredApplications" :key="item.uuid">
<span>
<a class="h-24 coolbox group" :href="item.hrefLink" {{ wireNavigate() }}>
<a class="h-24 coolbox group" :href="item.hrefLink"
@click="if (item.version === 'v5') { $event.preventDefault(); window.location.assign(item.hrefLink) }"
{{ wireNavigate() }}>
<div class="flex flex-col w-full">
<div class="flex gap-2 px-4">
<div class="pb-2 truncate box-title" x-text="item.name"></div>
<template x-if="item.version === 'v5'">
<span class="badge">V5</span>
</template>
<div class="flex-1"></div>
<template x-if="item.status.startsWith('running')">
<div title="running" class="bg-success badge-dashboard"></div>
@@ -271,9 +283,9 @@
<a :href="`/tags/${tag.name}`" class="tag" x-text="tag.name">
</a>
</template>
<a :href="`${item.hrefLink}/tags`" class="add-tag">
Add tag
</a>
<template x-if="item.version !== 'v5'">
<a :href="`${item.hrefLink}/tags`" class="add-tag">Add tag</a>
</template>
</div>
</span>
</template>
+2 -1
View File
@@ -228,11 +228,12 @@ if [ "$WARNING_SPACE" = true ]; then
sleep 5
fi
mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel}
mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,sentinel,flux}
mkdir -p /data/coolify/ssh/{keys,mux}
mkdir -p /data/coolify/proxy/dynamic
chown -R 9999:root /data/coolify
chown -R 9999:root /data/coolify/flux
chmod -R 700 /data/coolify
INSTALLATION_LOG_WITH_DATE="/data/coolify/source/installation-${DATE}.log"
+3
View File
@@ -171,6 +171,9 @@ else
log "Network 'coolify' already exists"
fi
mkdir -p /data/coolify/flux
chown -R 9999:root /data/coolify/flux
# Fix SSH directory ownership if not owned by container user UID 9999 (fixes #6621)
# Only changes owner — preserves existing group to respect custom setups
SSH_OWNER=$(stat -c '%u' /data/coolify/ssh 2>/dev/null || echo "unknown")
@@ -3,6 +3,7 @@
use App\Events\V5RealtimeTestEvent;
use App\Models\Team;
use App\Models\User;
use App\Models\V5\Application;
use App\Models\V5\Application as V5Application;
use App\Models\V5\Server as V5Server;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
@@ -356,6 +357,36 @@ it('shares existing projects and environments with the v5 dashboard page', funct
->assertDontSee($otherEnvironment->uuid);
});
it('opens a linked v5 application in its project and environment', function () {
$this->withoutVite();
fakeFluxHealth();
createSharedUserAndTeamTables();
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'linked-project', 'production');
$application = Application::query()->create([
'team_id' => $team->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'created_by_user_id' => $user->id,
'name' => 'linked-nginx',
'image' => 'nginx:alpine',
'container_name' => 'linked-v5-nginx',
]);
$this->actingAs($user)
->withSession(['currentTeam' => $team])
->get(route('v5.dashboard', [
'project' => $project->uuid,
'environment' => $environment->uuid,
'application' => $application->uuid,
]))
->assertSuccessful()
->assertSee('"selectedProjectUuid":"'.$project->uuid.'"', false)
->assertSee('"selectedEnvironmentUuid":"'.$environment->uuid.'"', false)
->assertSee('"selectedApplicationUuid":"'.$application->uuid.'"', false);
});
it('persists the selected v5 project and environment in the session', function () {
$this->withoutVite();
fakeFluxHealth();
@@ -0,0 +1,33 @@
<?php
use App\Models\V5\Application as V5Application;
use App\Models\V5\ResourceConnection;
beforeEach(function () {
resetV5DashboardTestState();
createSharedUserAndTeamTables();
});
it('prevents team members from creating v5 applications', function () {
[$user, $team] = createV5UserWithTeam();
$user->teams()->updateExistingPivot($team->id, ['role' => 'member']);
$this->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/applications/nginx')
->assertForbidden();
expect(V5Application::query()->count())->toBe(0);
});
it('prevents team members from creating v5 resource connections', function () {
[$user, $team] = createV5UserWithTeam();
$user->teams()->updateExistingPivot($team->id, ['role' => 'member']);
$this->actingAs($user)
->withSession(['currentTeam' => $team])
->postJson('/v5/resource-connections')
->assertForbidden();
expect(ResourceConnection::query()->count())->toBe(0);
});
@@ -0,0 +1,47 @@
<?php
use App\Models\V5\Application as V5Application;
use App\Models\V5\ResourceConnection;
beforeEach(function () {
resetV5DashboardTestState();
createSharedUserAndTeamTables();
});
it('does not consider projects or environments with v5 applications empty', function () {
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production');
V5Application::query()->create([
'team_id' => $team->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'created_by_user_id' => $user->id,
'name' => 'nginx',
'image' => 'nginx:alpine',
'container_name' => 'v5-nginx',
]);
expect($project->isEmpty())->toBeFalse()
->and($environment->isEmpty())->toBeFalse();
});
it('does not consider projects or environments with v5 resource connections empty', function () {
[$user, $team] = createV5UserWithTeam();
[$project, $environment] = createV5ProjectWithEnvironment($team, 'Project', 'production');
ResourceConnection::query()->create([
'team_id' => $team->id,
'project_id' => $project->id,
'environment_id' => $environment->id,
'resource_one_type' => V5Application::class,
'resource_one_id' => 1,
'resource_two_type' => V5Application::class,
'resource_two_id' => 2,
'resource_pair_key' => 'application:1|application:2',
'created_by_user_id' => $user->id,
]);
expect($project->isEmpty())->toBeFalse()
->and($environment->isEmpty())->toBeFalse();
});
+13
View File
@@ -56,6 +56,19 @@ it('generates a dedicated flux laravel api token during install and upgrade', fu
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('creates a writable flux storage directory during install and upgrade', function (string $path) {
$script = file_get_contents(getcwd().'/'.$path);
expect($script)
->toContain('/data/coolify/flux')
->toContain('chown -R 9999:root /data/coolify/flux');
})->with([
'stable install' => 'scripts/install.sh',
'nightly install' => 'other/nightly/install.sh',
'stable upgrade' => 'scripts/upgrade.sh',
'nightly upgrade' => 'other/nightly/upgrade.sh',
]);
it('uses the selected registry url when extracting upgrade images', function (string $path) {
$script = file_get_contents(getcwd().'/'.$path);
+12
View File
@@ -230,6 +230,8 @@ it('denies application mutations for a member of the current team as forbidden',
$member = v5PolicyMemberUser();
$policy = new ApplicationPolicy;
expect($policy->create($member, $team)->denied())->toBeTrue();
foreach (['update', 'updateIngress', 'delete'] as $ability) {
$response = $policy->{$ability}($member, $application, $team);
@@ -244,6 +246,8 @@ it('denies resource connection mutations for a member of the current team as for
$member = v5PolicyMemberUser();
$policy = new ResourceConnectionPolicy;
expect($policy->create($member, $team)->denied())->toBeTrue();
foreach (['update', 'delete'] as $ability) {
$response = $policy->{$ability}($member, $connection, $team);
@@ -251,3 +255,11 @@ it('denies resource connection mutations for a member of the current team as for
->and($response->status())->toBeNull();
}
});
it('allows admins to create applications and resource connections', function () {
$team = v5PolicyTeam(10);
$admin = v5PolicyUser('admin');
expect((new ApplicationPolicy)->create($admin, $team)->allowed())->toBeTrue()
->and((new ResourceConnectionPolicy)->create($admin, $team)->allowed())->toBeTrue();
});
@@ -0,0 +1,14 @@
<?php
it('includes v5 applications in the v4 resource index and links them to v5', function () {
$component = file_get_contents(app_path('Livewire/Project/Resource/Index.php'));
$view = file_get_contents(resource_path('views/livewire/project/resource/index.blade.php'));
expect($component)
->toContain('V5Application')
->toContain("route('v5.dashboard'")
->toContain("? 'v5' : 'v4'")
->and($view)
->toContain("item.version === 'v5'")
->toContain('V5');
});