diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..4b05a27 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,74 @@ +# Production Deployment & Data Persistence Guide + +To ensure that user data (and other settings/services) is not lost when you deploy updates to the application, follow these guidelines. + +--- + +## 1. Do Not Use SQLite in Ephemeral Environments +By default, the application is configured to use **SQLite** (`database/database.sqlite`). + +SQLite is a file-based database. In modern hosting environments—such as **Heroku**, **AWS ECS**, **Docker containers**, or **digital ocean app platform**—the local container storage is ephemeral (destroyed and recreated on every deployment or restart). + +### Recommended: Switch to a Managed Database (MySQL / PostgreSQL) +For production deployments, change your database connection in your server's `.env` file to a persistent RDBMS like MySQL or PostgreSQL: + +```ini +DB_CONNECTION=mysql +DB_HOST=your-production-db-host.com +DB_PORT=3306 +DB_DATABASE=singlelogin +DB_USERNAME=admin +DB_PASSWORD=your_secure_password +``` + +--- + +## 2. If You Must Use SQLite in Production +If you deploy to a Virtual Private Server (VPS) like **DigitalOcean Droplet**, **Linode**, or **AWS EC2**, or if you use Docker with persistent volumes, you can continue using SQLite securely: + +### Step A: Move the SQLite Database File to a Shared/Persistent Directory +Do not leave the `database.sqlite` file inside the deployment root directory (which gets overwritten/cleaned on deployment). Instead, place it in a persistent folder (e.g. `/var/www/shared/` or `/data/`): + +1. On your production server, create a directory for persistent data: + ```bash + mkdir -p /var/www/shared + ``` +2. Move your existing database file there (so you don't lose current users): + ```bash + mv /var/www/singlelogin/database/database.sqlite /var/www/shared/database.sqlite + ``` +3. Set the correct permissions so the web server can read and write to the database and its parent folder: + ```bash + chown -R www-data:www-data /var/www/shared + chmod -R 775 /var/www/shared + ``` + +### Step B: Configure the Absolute Path in `.env` +Update the `DB_DATABASE` environment variable in your production `.env` file to point to this absolute persistent path: + +```ini +DB_CONNECTION=sqlite +DB_DATABASE=/var/www/shared/database.sqlite +``` + +--- + +## 3. Safe Migration Commands on Deployment +When deploying updates, make sure your deployment scripts run migrations safely: + +* **DO RUN**: + ```bash + php artisan migrate --force + ``` + This command runs only new, pending migration files and keeps all existing data safe. The `--force` flag allows it to run in production without prompting. + +* **NEVER RUN**: + ```bash + php artisan migrate:fresh --seed + ``` + This command drops all database tables, destroying all registered users and data, and then rebuilds the schema from scratch. Do not use this in production. + +--- + +## 4. Git Configurations +The SQLite database file `database.sqlite` is already ignored in git (`database/.gitignore`). Do not force-add or commit database files to your git repository, as doing so would cause your local database to overwrite your production database during deployment. diff --git a/README.md b/README.md index 5ad1377..72d39d1 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ ## Code of Conduct In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). +## Deployment & Persistence + +To configure the application for production and ensure that no user data is lost during deployment, please refer to the [DEPLOYMENT.md](file:///D:/projects/singlelogin/DEPLOYMENT.md) guide. + ## Security Vulnerabilities If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php index 6ad566f..383293c 100644 --- a/app/Http/Controllers/AdminController.php +++ b/app/Http/Controllers/AdminController.php @@ -39,7 +39,9 @@ public function index() 'roles_count' => Role::count(), ]; - return view('admin.dashboard', compact('admin', 'users', 'apps', 'roles', 'overrides', 'stats')); + $defaultRoleName = \App\Models\Setting::get('default_role', 'Developer'); + + return view('admin.dashboard', compact('admin', 'users', 'apps', 'roles', 'overrides', 'stats', 'defaultRoleName')); } /** @@ -269,4 +271,56 @@ public function toggleBlockUser(Request $request, $id) $status = $user->is_blocked ? 'blocked' : 'unblocked'; return redirect()->route('admin.dashboard')->with('success', "User account has been {$status} successfully."); } + + /** + * Update the default role for new users. + */ + public function updateDefaultRole(Request $request) + { + $request->validate([ + 'default_role' => 'required|string|exists:roles,name', + ]); + + \App\Models\Setting::set('default_role', $request->default_role); + + return redirect()->route('admin.dashboard')->with('success', 'Default role for new users updated successfully.'); + } + + /** + * Toggle the admin privilege of a user. + */ + public function toggleAdminPrivilege(Request $request, $id) + { + $user = User::findOrFail($id); + + // Security: Prevent modifying own admin privileges + if ($user->id === Auth::id()) { + return redirect()->route('admin.dashboard')->with('error', 'Security violation: You cannot modify your own administrator privilege.'); + } + + if ($user->role === 'admin') { + $user->role = 'user'; + + // Detach Admin role from pivot if it exists + $adminRole = Role::where('name', 'Admin')->first(); + if ($adminRole) { + $user->roles()->detach($adminRole->id); + } + $user->save(); + $status = 'revoked'; + } else { + $user->role = 'admin'; + + // Attach Admin role to pivot (create if missing) + $adminRole = Role::firstOrCreate( + ['name' => 'Admin'], + ['description' => 'Administrator access role.'] + ); + $user->roles()->syncWithoutDetaching([$adminRole->id]); + $user->save(); + $status = 'granted'; + } + + return redirect()->route('admin.dashboard')->with('success', "Admin privilege has been {$status} for {$user->name} successfully."); + } } diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 4fe1cb9..e5ad7a7 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')); } /** @@ -36,10 +40,14 @@ public function launchSSO($id) return redirect()->route('dashboard')->with('error', 'Access denied. You do not have permission to access this service.'); } + $url = $app->url; + if (str_contains(strtolower($url), 'convexcrm.convexsol.co')) { + $url = rtrim($url, '/') . '/admin/authentication/microsoft_login'; + } if ($user->microsoft_id) { // Save target app URL in session so callback knows where to redirect - session(['sso_target_url' => $app->url]); + session(['sso_target_url' => $url]); try { // Determine domain hint based on user's email domain to skip account type prompt @@ -148,4 +156,135 @@ 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.'); + } + + /** + * Launch a personal app using SSO. + */ + public function launchPersonalSSO($id) + { + $user = Auth::user(); + $pApp = $user->personalApps()->findOrFail($id); + + $url = $pApp->url; + if (str_contains(strtolower($url), 'convexcrm.convexsol.co')) { + $url = rtrim($url, '/') . '/admin/authentication/microsoft_login'; + } + + if ($user->microsoft_id) { + // Save target app URL in session so callback knows where to redirect + session(['sso_target_url' => $url]); + + try { + // Determine domain hint based on user's email domain to skip account type prompt + $domainHint = (str_ends_with($user->email, '.onmicrosoft.com') || (str_contains($user->email, '@') && !str_ends_with($user->email, 'outlook.com') && !str_ends_with($user->email, 'hotmail.com') && !str_ends_with($user->email, 'live.com'))) + ? 'organizations' + : 'consumers'; + + // Redirect to Microsoft OAuth authorize page with login_hint, domain_hint, prompt=none and custom callback + return Socialite::driver('microsoft') + ->redirectUrl(route('sso.callback')) + ->with([ + 'login_hint' => $user->email, + 'domain_hint' => $domainHint, + 'prompt' => 'none' + ]) + ->redirect(); + } catch (\Exception $e) { + return redirect($url); + } + } + + // For non-Microsoft or non-Microsoft-linked users, redirect directly + return redirect($url); + } } diff --git a/app/Http/Controllers/LoginController.php b/app/Http/Controllers/LoginController.php index 6d832af..5c90a6e 100644 --- a/app/Http/Controllers/LoginController.php +++ b/app/Http/Controllers/LoginController.php @@ -124,6 +124,16 @@ public function handleMicrosoftCallback(Request $request) 'microsoft_refresh_token' => $microsoftUser->refreshToken ?? $user->microsoft_refresh_token, 'avatar' => $microsoftUser->getAvatar() ?? $user->avatar, ]); + + // Assign default role if they have no roles assigned + if ($user->roles()->count() === 0) { + $defaultRoleName = \App\Models\Setting::get('default_role', 'Developer'); + $defaultRole = \App\Models\Role::firstOrCreate( + ['name' => $defaultRoleName], + ['description' => 'Default application role.'] + ); + $user->roles()->sync([$defaultRole->id]); + } } else { // Block registration if domain is not allowed if (!$isSentientGeeks) { @@ -140,6 +150,14 @@ public function handleMicrosoftCallback(Request $request) 'avatar' => $microsoftUser->getAvatar(), 'role' => 'user', ]); + + // Assign configured default role + $defaultRoleName = \App\Models\Setting::get('default_role', 'Developer'); + $defaultRole = \App\Models\Role::firstOrCreate( + ['name' => $defaultRoleName], + ['description' => 'Default application role.'] + ); + $user->roles()->sync([$defaultRole->id]); } Auth::login($user, true); diff --git a/app/Http/Middleware/EnsureRole.php b/app/Http/Middleware/EnsureRole.php index 47892a7..52a8a88 100644 --- a/app/Http/Middleware/EnsureRole.php +++ b/app/Http/Middleware/EnsureRole.php @@ -26,11 +26,16 @@ public function handle(Request $request, Closure $next, string $role): Response return redirect()->route('login')->with('error', 'Your account has been blocked. Please contact the administrator.'); } - if (auth()->user()->role !== $role) { - if (auth()->user()->role === 'admin') { - if ($role === 'user') { - return $next($request); - } + $user = auth()->user(); + $hasRole = ($user->role === $role) || $user->hasRole($role); + + if (!$hasRole) { + $isAdmin = ($user->role === 'admin') || $user->hasRole('admin'); + if ($role === 'user' && $isAdmin) { + return $next($request); + } + + if ($isAdmin) { return redirect()->route('admin.dashboard'); } return redirect()->route('dashboard')->with('error', 'Unauthorized access.'); 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/Setting.php b/app/Models/Setting.php new file mode 100644 index 0000000..fc8dec9 --- /dev/null +++ b/app/Models/Setting.php @@ -0,0 +1,38 @@ +first(); + return $setting ? $setting->value : $default; + } + + /** + * Set a setting value. + * + * @param string $key + * @param mixed $value + * @return \App\Models\Setting + */ + public static function set(string $key, $value) + { + return self::updateOrCreate( + ['key' => $key], + ['value' => $value] + ); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index b53d204..e07425f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -22,7 +22,22 @@ class User extends Authenticatable */ public function isAdmin(): bool { - return $this->role === 'admin'; + return $this->role === 'admin' || $this->hasRole('admin'); + } + + /** + * Check if the user has a specific role by name. + */ + public function hasRole(string $roleName): bool + { + if ($this->roles->isEmpty()) { + $defaultRoleName = \App\Models\Setting::get('default_role', 'Developer'); + return strtolower($roleName) === strtolower($defaultRoleName); + } + + return $this->roles->contains(function ($r) use ($roleName) { + return strtolower($r->name) === strtolower($roleName); + }); } /** @@ -41,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. */ @@ -52,14 +75,23 @@ public function authorizedApps() $roleIds = $this->roles()->pluck('roles.id')->toArray(); + // If no roles are assigned, fallback to configured default role first + if (empty($roleIds)) { + $defaultRoleName = \App\Models\Setting::get('default_role', 'Developer'); + $defaultRole = \App\Models\Role::where('name', $defaultRoleName)->first(); + if ($defaultRole) { + $roleIds = [$defaultRole->id]; + } + } + // Get apps associated with user's roles $roleAppIds = \DB::table('app_role') ->whereIn('role_id', $roleIds) ->pluck('app_id') ->toArray(); - // If no roles are assigned, user gets default access to Outlook email and Drive apps if registered - if (empty($roleIds)) { + // If still no roles/apps resolved, user gets default access to Outlook email and Drive apps if registered + if (empty($roleAppIds)) { $defaultAppIds = App::where(function ($query) { $query->whereRaw('LOWER(name) LIKE ?', ['%outlook%']) ->orWhereRaw('LOWER(name) LIKE ?', ['%mail%']) diff --git a/database/migrations/2026_07_15_000000_create_settings_table.php b/database/migrations/2026_07_15_000000_create_settings_table.php new file mode 100644 index 0000000..ce3b578 --- /dev/null +++ b/database/migrations/2026_07_15_000000_create_settings_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('key')->unique(); + $table->text('value')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('settings'); + } +}; 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/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index e214217..7a842ea 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -149,5 +149,11 @@ public function run(): void ['user_id' => $user1->id, 'app_id' => $keka->id], ['type' => 'allow'] ); + + // Seed default role setting + \App\Models\Setting::updateOrCreate( + ['key' => 'default_role'], + ['value' => 'Developer'] + ); } } diff --git a/public/Screenshot_10.png b/public/Screenshot_10.png new file mode 100644 index 0000000..1667d6e Binary files /dev/null and b/public/Screenshot_10.png differ diff --git a/public/Screenshot_2.png b/public/Screenshot_2.png index b1f497d..7df07e7 100644 Binary files a/public/Screenshot_2.png and b/public/Screenshot_2.png differ diff --git a/public/Screenshot_9.png b/public/Screenshot_9.png new file mode 100644 index 0000000..362380d Binary files /dev/null and b/public/Screenshot_9.png differ 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/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index 288aa2e..f1d5ac3 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -9,6 +9,9 @@ + + +