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 @@
+
+
+