Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 29 additions & 10 deletions Modules/HR/Observers/EmployeeObserver.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,37 @@ public function created(Employee $employee)
return;
}

$gsuiteUserService = new GSuiteUserService();
$gsuiteUserService->fetch($employee->user->email);
$user = $employee->user;

foreach ($gsuiteUserService->getUsers() as $gsuiteUser) {
$user = User::with('employee')->findByEmail($gsuiteUser->getPrimaryEmail())->first();
if (is_null($user)) {
continue;
// Only fetch from Google Workspace if the user is a Google Workspace user
// External users (provider = 'default') should not be fetched from Google Workspace
$isGSuiteUser = $user->provider === 'google'
|| strpos($user->email, config('constants.gsuite.client-hd')) !== false;

if (! $isGSuiteUser) {
return;
}

try {
$gsuiteUserService = new GSuiteUserService();
$gsuiteUserService->fetch($user->email);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you know what this does? It seems redundant to me but not quite sure why this used to exist.


$gsuiteUsers = $gsuiteUserService->getUsers();
if ($gsuiteUsers) {
foreach ($gsuiteUsers as $gsuiteUser) {
$foundUser = User::with('employee')->findByEmail($gsuiteUser->getPrimaryEmail())->first();
if (is_null($foundUser)) {
continue;
}
$employee->update([
'name' => $gsuiteUser->getName()->fullName,
'joined_on' => Carbon::parse($gsuiteUser->getCreationTime())->format(config('constants.date_format')),
]);
}
Comment on lines +41 to +50
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical bug: calling ->first() on result of findByEmail() + unnecessary complexity.

Multiple issues with this code segment:

  1. Critical: Line 42 calls ->first() on the result of findByEmail(), but findByEmail() already returns a single result via first() (see Modules/User/Entities/User.php line 111-114). This will cause a fatal error trying to call first() on a User object or null.

  2. Major: The loop and findByEmail() lookup are unnecessary. You already have both $user and $employee from the observer context. Since fetch() retrieves only the single user by email, there's no need to loop or look up the user again.

  3. Major: The code fetches data for $user->email (line 37), then loops through results to find the same user again and update the same $employee. This is redundant.

🔎 Proposed simplified fix
 try {
     $gsuiteUserService = new GSuiteUserService();
     $gsuiteUserService->fetch($user->email);
 
     $gsuiteUsers = $gsuiteUserService->getUsers();
-    if ($gsuiteUsers) {
-        foreach ($gsuiteUsers as $gsuiteUser) {
-            $foundUser = User::with('employee')->findByEmail($gsuiteUser->getPrimaryEmail())->first();
-            if (is_null($foundUser)) {
-                continue;
-            }
-            $employee->update([
-                'name' => $gsuiteUser->getName()->fullName,
-                'joined_on' => Carbon::parse($gsuiteUser->getCreationTime())->format(config('constants.date_format')),
-            ]);
-        }
+    if ($gsuiteUsers && count($gsuiteUsers) > 0) {
+        $gsuiteUser = $gsuiteUsers[0];
+        $employee->update([
+            'name' => $gsuiteUser->getName()->fullName,
+            'joined_on' => Carbon::parse($gsuiteUser->getCreationTime())->format(config('constants.date_format')),
+        ]);
     }
 } catch (\Exception $e) {
     // Silently handle Google API errors (e.g., user not found in Google Workspace)
     // This prevents errors when creating external users or when Google API is unavailable
 }

This simplified version:

  • Removes the unnecessary loop (we only fetch one user)
  • Removes the redundant findByEmail() lookup (we already have the employee)
  • Fixes the double ->first() bug
  • Updates the employee directly with GSuite data
🤖 Prompt for AI Agents
In Modules/HR/Observers/EmployeeObserver.php around lines 41 to 50, the code
incorrectly calls ->first() on the result of findByEmail() (which already
returns a single model) and uses an unnecessary loop and lookup; replace the
loop with a direct use of the single fetched GSuite user result and update the
existing $employee directly (remove the extra findByEmail() call and the
foreach), parse the creation time and set name and joined_on on $employee using
the fetched GSuite user's properties.

}
$employee->update([
'name' => $gsuiteUser->getName()->fullName,
'joined_on' => Carbon::parse($gsuiteUser->getCreationTime())->format(config('constants.date_format')),
]);
} catch (\Exception $e) {
// Silently handle Google API errors (e.g., user not found in Google Workspace)
// This prevents errors when creating external users or when Google API is unavailable
}
}
}
343 changes: 343 additions & 0 deletions docs/sop-create-external-user.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,343 @@
# Standard Operating Procedure: Creating External Users via Command Line

## Overview

This SOP provides step-by-step instructions for System Administrators to create user accounts for external users (users who do not have GSuite email addresses) using PHP Artisan Tinker. This is necessary because the standard UI only supports user creation through GSuite authentication.

## Prerequisites

- System Administrator access to the server/application
- SSH access to the application server
- Access to the application's root directory
- Understanding of the application's roles and permissions system

## Required Information

Before creating a user, gather the following information:

1. **User's Full Name** - The display name for the user
2. **User's Email Address** - Must be a valid, unique email address
3. **User's Password** - A secure password (minimum 6 characters recommended)
4. **User's Role(s)** - One or more roles to assign (see Available Roles section)

## Available Roles

The following roles are available in the system:

- `super-admin` - Super Admin (full system access)
- `admin` - Admin (all permissions except adding new Admin)
- `employee` - Employee (basic employee actions)
- `hr-manager` - HR Manager (HR responsibilities)
- `finance-manager` - Finance Manager (finance management)
- `intern` - Intern (training role)
- `contractor` - Contractor (contract-based personnel)
- `support-staff` - Support Staff (non-billing support team)

**Note:** For external users, typical roles would be `contractor`, `support-staff`, or custom roles as per business requirements. Avoid assigning `super-admin` or `admin` roles to external users unless explicitly required.

## Step-by-Step Instructions

### Step 1: Access the Application Directory

Navigate to the application's root directory:

```bash
cd /path/to/application
```

### Step 2: Launch PHP Artisan Tinker

Start the Tinker console:

```bash
php artisan tinker
```

You should see a prompt that looks like:
```
Psy Shell v0.x.x (PHP 8.x.x — cli) by Justin Hileman
>>>
```

### Step 3: Import Required Classes

Import the necessary classes:

```php
use Modules\User\Entities\User;
use Illuminate\Support\Facades\Hash;
use Spatie\Permission\Models\Role;
```

### Step 4: Verify Email Uniqueness (Optional but Recommended)

Check if the email already exists:

```php
$existingUser = User::where('email', 'user@example.com')->first();
if ($existingUser) {
echo "User with this email already exists!\n";
} else {
echo "Email is available.\n";
}
```

Replace `user@example.com` with the actual email address.

### Step 5: Create the User

Create a new user with the following command structure:

```php
$user = User::create([
'name' => 'John Doe',
'email' => 'john.doe@external-company.com',
'password' => Hash::make('SecurePassword123!'),
'provider' => 'default',
'provider_id' => 'external-' . time(),
Comment on lines +96 to +97
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Replace time() with a collision-resistant unique identifier.

Using time() for provider_id can cause collisions if multiple external users are created within the same second. In a production environment with concurrent user creation, this could lead to database constraint violations.

🔎 Proposed fix using uniqid or UUID
 $user = User::create([
     'name' => 'John Doe',
     'email' => 'john.doe@external-company.com',
     'password' => Hash::make('SecurePassword123!'),
     'provider' => 'default',
-    'provider_id' => 'external-' . time(),
+    'provider_id' => 'external-' . uniqid('', true),
     'avatar' => null
 ]);

Alternatively, use a UUID for even stronger guarantees:

use Illuminate\Support\Str;

$user = User::create([
    'name' => 'John Doe',
    'email' => 'john.doe@external-company.com',
    'password' => Hash::make('SecurePassword123!'),
    'provider' => 'default',
    'provider_id' => 'external-' . Str::uuid(),
    'avatar' => null
]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
'provider' => 'default',
'provider_id' => 'external-' . time(),
$user = User::create([
'name' => 'John Doe',
'email' => 'john.doe@external-company.com',
'password' => Hash::make('SecurePassword123!'),
'provider' => 'default',
'provider_id' => 'external-' . uniqid('', true),
'avatar' => null
]);
🤖 Prompt for AI Agents
In docs/sop-create-external-user.md around lines 96-97, the example uses time()
to build provider_id which can collide under concurrent creation; replace it
with a collision-resistant identifier (preferably a UUID) by concatenating a
stable prefix like "external-" with Str::uuid(), and note to import
Illuminate\Support\Str at the top of the example so the generated provider_id is
unique across concurrent requests.

'avatar' => null
]);
```

**Important Notes:**
- Replace `'John Doe'` with the user's actual full name
- Replace `'john.doe@external-company.com'` with the user's actual email
- Replace `'SecurePassword123!'` with the user's desired password (will be hashed automatically)
- The `provider` field is set to `'default'` to distinguish from GSuite users (which use `'google'`)
- The `provider_id` should be unique; using `'external-' . time()` ensures uniqueness
- `avatar` can be set to `null` or a URL if available

### Step 6: Verify User Creation

Confirm the user was created successfully:

```php
echo "User created with ID: " . $user->id . "\n";
echo "Email: " . $user->email . "\n";
echo "Name: " . $user->name . "\n";
```

### Step 7: Assign Role(s) to the User

Assign one or more roles to the user. You can use either role names or role IDs.

#### Option A: Assign by Role Name (Recommended)

```php
$user->assignRole('contractor');
```

To assign multiple roles:

```php
$user->assignRole('contractor', 'support-staff');
```

#### Option B: Assign by Role ID

First, find the role ID:

```php
$role = Role::where('name', 'contractor')->first();
echo "Role ID: " . $role->id . "\n";
```

Then assign:

```php
$user->assignRole($role->id);
```

#### Option C: Sync Roles (Replace All Existing Roles)

If you need to replace all existing roles with new ones:

```php
$user->syncRoles(['contractor']);
```

### Step 8: Verify Role Assignment

Confirm the roles were assigned correctly:

```php
echo "User roles: " . $user->getRoleNames()->implode(', ') . "\n";
```

Or to see detailed role information:

```php
$user->load('roles');
foreach ($user->roles as $role) {
echo "Role: " . $role->name . " (ID: " . $role->id . ")\n";
}
```

### Step 9: Exit Tinker

Exit the Tinker console:

```php
exit
```

Or press `Ctrl + D` (or `Cmd + D` on Mac).

## Complete Example

Here's a complete example that creates a user and assigns a role in one session:

```php
use Modules\User\Entities\User;
use Illuminate\Support\Facades\Hash;
use Spatie\Permission\Models\Role;

// Check if user exists
$email = 'external.user@example.com';
$existingUser = User::where('email', $email)->first();
if ($existingUser) {
echo "ERROR: User with email {$email} already exists!\n";
exit;
}

// Create the user
$user = User::create([
'name' => 'External User Name',
'email' => $email,
'password' => Hash::make('TemporaryPassword123!'),
'provider' => 'default',
'provider_id' => 'external-' . time(),
Comment on lines +208 to +209
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Replace time() with a collision-resistant unique identifier.

Same issue as in Step 5: using time() for provider_id can cause collisions if multiple users are created concurrently.

🔎 Proposed fix
 $user = User::create([
     'name' => 'External User Name',
     'email' => $email,
     'password' => Hash::make('TemporaryPassword123!'),
     'provider' => 'default',
-    'provider_id' => 'external-' . time(),
+    'provider_id' => 'external-' . uniqid('', true),
     'avatar' => null
 ]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
'provider' => 'default',
'provider_id' => 'external-' . time(),
$user = User::create([
'name' => 'External User Name',
'email' => $email,
'password' => Hash::make('TemporaryPassword123!'),
'provider' => 'default',
'provider_id' => 'external-' . uniqid('', true),
'avatar' => null
]);
🤖 Prompt for AI Agents
In docs/sop-create-external-user.md around lines 208 to 209, the example uses
time() to build provider_id which is prone to collisions under concurrent user
creation; replace time() with a collision-resistant unique identifier (e.g.,
UUID v4 or a cryptographically-random hex string) and update the example and any
related text to show generating a stable, unique provider_id (use your project's
standard UUID/guid helper or secure random generator) so concurrent creations
cannot produce duplicate provider_id values.

'avatar' => null
]);

echo "User created successfully!\n";
echo "User ID: {$user->id}\n";
echo "Email: {$user->email}\n";

// Assign role
$user->assignRole('contractor');

// Verify role assignment
echo "Assigned roles: " . $user->getRoleNames()->implode(', ') . "\n";

// Exit
exit
```

## Troubleshooting

### Error: "Email already exists"

**Solution:** The email address is already in use. Either:
- Use a different email address
- Check if the user already exists and update their information instead
- If the user was soft-deleted, restore them: `$user->restore()`

### Error: "Role not found"

**Solution:** Verify the role name is correct:
```php
$roles = Role::all();
foreach ($roles as $role) {
echo $role->name . "\n";
}
```

### User Cannot Login

**Possible Causes:**
1. Password was not hashed correctly - ensure you used `Hash::make()`
2. User was soft-deleted - check with: `User::withTrashed()->where('email', 'user@example.com')->first()`
3. Role permissions issue - verify roles are assigned correctly

**Solution:**
```php
// Check if user exists (including soft-deleted)
$user = User::withTrashed()->where('email', 'user@example.com')->first();

// If soft-deleted, restore
if ($user->trashed()) {
$user->restore();
}

// Reset password
$user->password = Hash::make('NewPassword123!');
$user->save();
```

### Update Existing User Password

If you need to update a user's password:

```php
$user = User::where('email', 'user@example.com')->first();
$user->password = Hash::make('NewPassword123!');
$user->save();
echo "Password updated successfully!\n";
```

### Update Existing User Roles

To update roles for an existing user:

```php
$user = User::where('email', 'user@example.com')->first();
$user->syncRoles(['contractor', 'support-staff']);
echo "Roles updated: " . $user->getRoleNames()->implode(', ') . "\n";
```

## Security Considerations

1. **Password Security:**
- Use strong passwords (minimum 8 characters, mix of letters, numbers, and symbols)
- Never share passwords in plain text
- Consider using a password generator
- Inform users to change their password on first login (if password reset functionality is available)
Comment on lines +291 to +295
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent password length recommendations.

Line 20 recommends "minimum 6 characters" while Line 292 recommends "minimum 8 characters". Modern security standards suggest at least 12 characters for strong passwords. Please standardize this recommendation throughout the document.

🔎 Suggested updates

Update Line 20:

-3. **User's Password** - A secure password (minimum 6 characters recommended)
+3. **User's Password** - A secure password (minimum 12 characters recommended)

Update Line 292:

-   - Use strong passwords (minimum 8 characters, mix of letters, numbers, and symbols)
+   - Use strong passwords (minimum 12 characters, mix of letters, numbers, and symbols)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
1. **Password Security:**
- Use strong passwords (minimum 8 characters, mix of letters, numbers, and symbols)
- Never share passwords in plain text
- Consider using a password generator
- Inform users to change their password on first login (if password reset functionality is available)
1. **Password Security:**
- Use strong passwords (minimum 12 characters, mix of letters, numbers, and symbols)
- Never share passwords in plain text
- Consider using a password generator
- Inform users to change their password on first login (if password reset functionality is available)
🤖 Prompt for AI Agents
In docs/sop-create-external-user.md around lines 20 and 291–295, the document
gives inconsistent password length guidance (one place says minimum 6, another
says minimum 8); update both recommendations to a single standard of minimum 12
characters and scan the rest of the file to replace any other occurrences of
"minimum 6" or "minimum 8" with "minimum 12" and adjust related phrasing (e.g.,
"strong passwords (minimum 12 characters, mix of letters, numbers, and
symbols)") so the guidance is consistent throughout.


2. **Role Assignment:**
- Only assign necessary roles to external users
- Avoid assigning `super-admin` or `admin` roles unless absolutely necessary
- Review role permissions before assignment

3. **Provider Field:**
- Always use `'default'` for external users (not `'google'`)
- This helps distinguish external users from GSuite users in the system

4. **Audit Trail:**
- Document when and why external users were created
- Keep records of role assignments
- Note any special permissions granted

## Post-Creation Checklist

After creating a user, verify:

- [ ] User can be found in the database
- [ ] Email address is correct and unique
- [ ] Password is set (hashed)
- [ ] Role(s) are assigned correctly
- [ ] Provider is set to `'default'` (not `'google'`)
- [ ] User can log in with email/password (test if possible)
- [ ] User has appropriate permissions for their role

## Additional Resources

- **Laravel Permission Documentation:** [Spatie Laravel Permission](https://spatie.be/docs/laravel-permission/v3/introduction)
- **Laravel Tinker Documentation:** [Laravel Tinker](https://laravel.com/docs/artisan#tinker)
- **Application Authorization Docs:** See `docs/authorization.md` in this repository

## Support

If you encounter issues not covered in this SOP:

1. Check the application logs: `storage/logs/laravel.log`
2. Review the User model: `Modules/User/Entities/User.php`
3. Consult with the development team
4. Review role and permission configurations in the database

---

**Document Version:** 1.0
**Last Updated:** [Current Date]
**Maintained By:** System Administration Team