diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 4fe1cb9..69277b4 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -5,6 +5,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use App\Models\PersonalApp; use Laravel\Socialite\Facades\Socialite; class DashboardController extends Controller @@ -19,7 +20,10 @@ public function index() // Get authorized apps for this user $services = $user->authorizedApps(); - return view('dashboard', compact('user', 'services')); + // Get personal custom apps for this user + $personalApps = $user->personalApps()->orderBy('name', 'asc')->get(); + + return view('dashboard', compact('user', 'services', 'personalApps')); } /** @@ -148,4 +152,94 @@ public function launchApp(Request $request) return view('sso.launch-app', compact('name', 'url', 'protocol')); } + + /** + * Store a new user custom personal app. + */ + public function storePersonalApp(Request $request) + { + $request->validate([ + 'name' => 'required|string|max:255', + 'url' => 'required|url|max:255', + 'logo' => 'nullable|file|mimes:jpg,jpeg,png,webp,svg|max:2048', + 'color' => 'nullable|string|max:20', + 'desc' => 'nullable|string', + 'tag' => 'nullable|string|max:255', + ]); + + $logoPath = null; + if ($request->hasFile('logo')) { + $file = $request->file('logo'); + $filename = time() . '_' . uniqid() . '.' . $file->getClientOriginalExtension(); + $file->move(public_path('uploads/logos'), $filename); + $logoPath = 'uploads/logos/' . $filename; + } + + Auth::user()->personalApps()->create([ + 'name' => $request->name, + 'url' => $request->url, + 'logo' => $logoPath, + 'color' => $request->color ?? '#4f46e5', + 'desc' => $request->desc, + 'tag' => $request->tag ?? 'Personal', + ]); + + return redirect()->route('dashboard')->with('success', 'Custom app added successfully.'); + } + + /** + * Update an existing user custom personal app. + */ + public function updatePersonalApp(Request $request, $id) + { + $app = Auth::user()->personalApps()->findOrFail($id); + + $request->validate([ + 'name' => 'required|string|max:255', + 'url' => 'required|url|max:255', + 'logo' => 'nullable|file|mimes:jpg,jpeg,png,webp,svg|max:2048', + 'color' => 'nullable|string|max:20', + 'desc' => 'nullable|string', + 'tag' => 'nullable|string|max:255', + ]); + + $logoPath = $app->logo; + if ($request->hasFile('logo')) { + // Delete old file + if ($app->logo && file_exists(public_path($app->logo))) { + @unlink(public_path($app->logo)); + } + $file = $request->file('logo'); + $filename = time() . '_' . uniqid() . '.' . $file->getClientOriginalExtension(); + $file->move(public_path('uploads/logos'), $filename); + $logoPath = 'uploads/logos/' . $filename; + } + + $app->update([ + 'name' => $request->name, + 'url' => $request->url, + 'logo' => $logoPath, + 'color' => $request->color ?? '#4f46e5', + 'desc' => $request->desc, + 'tag' => $request->tag ?? 'Personal', + ]); + + return redirect()->route('dashboard')->with('success', 'Custom app updated successfully.'); + } + + /** + * Delete a user custom personal app. + */ + public function destroyPersonalApp($id) + { + $app = Auth::user()->personalApps()->findOrFail($id); + + if ($app->logo && file_exists(public_path($app->logo))) { + @unlink(public_path($app->logo)); + } + + $app->delete(); + + return redirect()->route('dashboard')->with('success', 'Custom app deleted successfully.'); + } } diff --git a/app/Models/PersonalApp.php b/app/Models/PersonalApp.php new file mode 100644 index 0000000..badb87a --- /dev/null +++ b/app/Models/PersonalApp.php @@ -0,0 +1,18 @@ +belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 24d6b75..e07425f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -56,6 +56,14 @@ public function overrides() return $this->hasMany(UserAppOverride::class); } + /** + * Get the personal custom apps for this user. + */ + public function personalApps() + { + return $this->hasMany(PersonalApp::class); + } + /** * Get the authorized apps for this user, computed from roles and overrides. */ diff --git a/database/migrations/2026_07_16_114330_create_personal_apps_table.php b/database/migrations/2026_07_16_114330_create_personal_apps_table.php new file mode 100644 index 0000000..6936bd0 --- /dev/null +++ b/database/migrations/2026_07_16_114330_create_personal_apps_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('user_id')->constrained('users')->onDelete('cascade'); + $table->string('name'); + $table->string('url'); + $table->string('logo')->nullable(); + $table->string('color')->default('#4f46e5'); + $table->text('desc')->nullable(); + $table->string('tag')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_apps'); + } +}; diff --git a/public/uploads/logos/1784202607_6a58c56fef7f9.png b/public/uploads/logos/1784202607_6a58c56fef7f9.png new file mode 100644 index 0000000..58afdb4 Binary files /dev/null and b/public/uploads/logos/1784202607_6a58c56fef7f9.png differ diff --git a/public/uploads/logos/1784202977_6a58c6e1eb311.png b/public/uploads/logos/1784202977_6a58c6e1eb311.png new file mode 100644 index 0000000..58afdb4 Binary files /dev/null and b/public/uploads/logos/1784202977_6a58c6e1eb311.png differ diff --git a/public/uploads/logos/1784203121_6a58c77104b2d.png b/public/uploads/logos/1784203121_6a58c77104b2d.png new file mode 100644 index 0000000..58afdb4 Binary files /dev/null and b/public/uploads/logos/1784203121_6a58c77104b2d.png differ diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index da0407b..8f1556f 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -22,6 +22,138 @@ --info-color: #06b6d4; } + .personal-app-card:hover .personal-app-actions { + opacity: 1 !important; + pointer-events: auto !important; + } + + /* Personal Modal Styling */ + .personal-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(11, 15, 25, 0.85); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + opacity: 0; + pointer-events: none; + transition: all 0.3s ease; + } + + .personal-modal.active { + opacity: 1; + pointer-events: all; + } + + .personal-modal-content { + background-color: rgba(25, 30, 45, 0.9); + border: 1px solid var(--card-border); + border-radius: 20px; + padding: 30px; + width: 90%; + max-width: 500px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4); + transform: scale(0.9); + transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); + max-height: 90vh; + overflow-y: auto; + } + + .personal-modal.active .personal-modal-content { + transform: scale(1); + } + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + border-bottom: 1px solid rgba(255,255,255,0.08); + padding-bottom: 12px; + } + + .modal-title { + font-family: 'Outfit', sans-serif; + font-size: 1.2rem; + font-weight: 700; + color: #a5b4fc; + } + + .modal-close { + background: none; + border: none; + color: var(--text-muted); + font-size: 1.5rem; + cursor: pointer; + transition: color 0.2s; + } + + .modal-close:hover { + color: white; + } + + .form-group { + margin-bottom: 16px; + } + + .form-label { + display: block; + font-size: 0.75rem; + color: var(--text-muted); + margin-bottom: 6px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.3px; + } + + .form-input { + width: 100%; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--card-border); + border-radius: 8px; + color: white; + outline: none; + font-size: 0.85rem; + transition: all 0.2s; + } + + .form-input:focus { + border-color: var(--accent-primary); + background: rgba(255, 255, 255, 0.06); + } + + .modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 24px; + border-top: 1px solid rgba(255,255,255,0.08); + padding-top: 16px; + } + + .btn-cancel { + padding: 8px 16px; + border-radius: 8px; + border: 1px solid var(--card-border); + background: transparent; + color: var(--text-main); + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; + } + + .btn-cancel:hover { + background: rgba(255,255,255,0.05); + } + * { box-sizing: border-box; margin: 0; @@ -690,6 +822,71 @@ + +
+
+

My Custom Apps

+ +
+ +
+ @forelse($personalApps as $pApp) +
+ + +
+ +
+ +
+ @csrf + @method('DELETE') + +
+
+ + +
+ @if($pApp->logo) + {{ $pApp->name }} + @else +
+ {{ strtoupper(substr($pApp->name, 0, 1)) }} +
+ @endif +
+
+ + +

{{ strtoupper($pApp->name) }}

+ @if($pApp->tag) +
{{ $pApp->tag }}
+ @endif +
+ @empty +
+ No custom apps added yet. Click "+ Add Custom App" to add your own shortcuts! +
+ @endforelse +
+
+ @@ -707,6 +904,96 @@ + +
+
+ +
+ @csrf +
+ + +
+
+ + +
+
+ + +
Supported: PNG, JPG, WEBP, SVG (Max 2MB)
+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+ +
+ @csrf +
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+ +
+
+
+ diff --git a/routes/web.php b/routes/web.php index 8cf42a1..b2e9c16 100644 --- a/routes/web.php +++ b/routes/web.php @@ -27,6 +27,11 @@ Route::get('/sso/launch/{id}', [DashboardController::class, 'launchSSO'])->name('sso.launch'); Route::get('/sso/callback', [DashboardController::class, 'handleSSOCallback'])->name('sso.callback'); Route::get('/sso/launch-app', [DashboardController::class, 'launchApp'])->name('sso.launch-app'); + + // Personal custom apps management + Route::post('/dashboard/personal-apps', [DashboardController::class, 'storePersonalApp'])->name('dashboard.personal-apps.store'); + Route::post('/dashboard/personal-apps/{id}/update', [DashboardController::class, 'updatePersonalApp'])->name('dashboard.personal-apps.update'); + Route::delete('/dashboard/personal-apps/{id}', [DashboardController::class, 'destroyPersonalApp'])->name('dashboard.personal-apps.destroy'); }); diff --git a/tests/Feature/SecurityAndAdminTest.php b/tests/Feature/SecurityAndAdminTest.php index 3cba3ce..47883bf 100644 --- a/tests/Feature/SecurityAndAdminTest.php +++ b/tests/Feature/SecurityAndAdminTest.php @@ -434,4 +434,87 @@ public function test_admin_toggle_privileges(): void $response3->assertRedirect(route('admin.dashboard')); $this->assertTrue($admin->fresh()->isAdmin()); } + + /** + * Test user personal custom apps CRUD and isolation. + */ + public function test_user_personal_custom_apps(): void + { + $user1 = User::create([ + 'name' => 'User One', + 'email' => 'user1@sentientgeeks.com', + 'role' => 'user', + ]); + + $user2 = User::create([ + 'name' => 'User Two', + 'email' => 'user2@sentientgeeks.com', + 'role' => 'user', + ]); + + $this->withoutMiddleware(\Illuminate\Foundation\Http\Middleware\PreventRequestForgery::class); + + // 1. Create personal app + $response = $this->actingAs($user1) + ->post(route('dashboard.personal-apps.store'), [ + 'name' => 'My Test App', + 'url' => 'https://example.com/test', + 'color' => '#123456', + 'tag' => 'Testing', + 'desc' => 'Some description' + ]); + + $response->assertRedirect(route('dashboard')); + $this->assertDatabaseHas('personal_apps', [ + 'user_id' => $user1->id, + 'name' => 'My Test App', + 'url' => 'https://example.com/test', + 'color' => '#123456' + ]); + + $personalApp = \App\Models\PersonalApp::where('name', 'My Test App')->first(); + $this->assertNotNull($personalApp); + + // 2. View dashboard and see the custom app + $viewResponse = $this->actingAs($user1)->get('/dashboard'); + $viewResponse->assertStatus(200); + $viewResponse->assertSee('MY TEST APP'); // Displayed upper case in UI + + // 3. Update personal app + $updateResponse = $this->actingAs($user1) + ->post(route('dashboard.personal-apps.update', $personalApp->id), [ + 'name' => 'Updated Test App', + 'url' => 'https://example.com/updated', + 'color' => '#654321', + 'tag' => 'UpdatedTag', + 'desc' => 'New description' + ]); + + $updateResponse->assertRedirect(route('dashboard')); + $this->assertDatabaseHas('personal_apps', [ + 'id' => $personalApp->id, + 'name' => 'Updated Test App', + 'url' => 'https://example.com/updated', + 'color' => '#654321' + ]); + + // 4. Security: User 2 cannot update User 1's custom app + $hackerResponse = $this->actingAs($user2) + ->post(route('dashboard.personal-apps.update', $personalApp->id), [ + 'name' => 'Hacked App', + 'url' => 'https://hacked.com', + ]); + + // Let's assert a 404 status because user2 doesn't own it (Auth::user()->personalApps()->findOrFail($id) throws ModelNotFoundException which is rendered as 404) + $hackerResponse->assertStatus(404); + + // 5. Delete personal app + $deleteResponse = $this->actingAs($user1) + ->delete(route('dashboard.personal-apps.destroy', $personalApp->id)); + + $deleteResponse->assertRedirect(route('dashboard')); + $this->assertDatabaseMissing('personal_apps', [ + 'id' => $personalApp->id + ]); + } }