update
This commit is contained in:
parent
688b0307b8
commit
cb86f8e362
81
.agents/skills/fluxui-development/SKILL.md
Normal file
81
.agents/skills/fluxui-development/SKILL.md
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
---
|
||||||
|
name: fluxui-development
|
||||||
|
description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with <flux:*> components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Flux UI Development
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Use `search-docs` for detailed Flux UI patterns and documentation.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components.
|
||||||
|
|
||||||
|
Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize.
|
||||||
|
|
||||||
|
Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs.
|
||||||
|
|
||||||
|
<!-- Basic Button -->
|
||||||
|
```blade
|
||||||
|
<flux:button variant="primary">Click me</flux:button>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Components (Free Edition)
|
||||||
|
|
||||||
|
Available: avatar, badge, brand, breadcrumbs, button, callout, card, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, pagination, profile, progress, radio, select, separator, skeleton, switch, table, text, textarea, toast, tooltip
|
||||||
|
|
||||||
|
## Icons
|
||||||
|
|
||||||
|
Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names.
|
||||||
|
|
||||||
|
<!-- Icon Button -->
|
||||||
|
```blade
|
||||||
|
<flux:button icon="arrow-down-tray">Export</flux:button>
|
||||||
|
```
|
||||||
|
|
||||||
|
For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan flux:icon crown grip-vertical github
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Form Fields
|
||||||
|
|
||||||
|
<!-- Form Field -->
|
||||||
|
```blade
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>Email</flux:label>
|
||||||
|
<flux:input type="email" wire:model="email" />
|
||||||
|
<flux:error name="email" />
|
||||||
|
</flux:field>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modals
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
```blade
|
||||||
|
<flux:modal wire:model="showModal">
|
||||||
|
<flux:heading>Title</flux:heading>
|
||||||
|
<p>Content</p>
|
||||||
|
</flux:modal>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Check component renders correctly
|
||||||
|
2. Test interactive states
|
||||||
|
3. Verify mobile responsiveness
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
- Trying to use Pro-only components in the free edition
|
||||||
|
- Not checking if a Flux component exists before creating custom implementations
|
||||||
|
- Forgetting to use the `search-docs` tool for component-specific documentation
|
||||||
|
- Not following existing project patterns for Flux usage
|
||||||
@ -148,4 +148,4 @@ ## Key Endpoints
|
|||||||
| Passkey Confirm | POST | `/passkeys/confirm` |
|
| Passkey Confirm | POST | `/passkeys/confirm` |
|
||||||
| Passkey Options | GET | `/user/passkeys/options` |
|
| Passkey Options | GET | `/user/passkeys/options` |
|
||||||
| Register Passkey | POST | `/user/passkeys` |
|
| Register Passkey | POST | `/user/passkeys` |
|
||||||
| Delete Passkey | DELETE | `/user/passkeys/{passkey}` |
|
| Delete Passkey | DELETE | `/user/passkeys/{passkey}` |
|
||||||
|
|||||||
@ -187,4 +187,4 @@ ## How to Apply
|
|||||||
|
|
||||||
1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10)
|
1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10)
|
||||||
2. Check sibling files for existing patterns — follow those first per Consistency First
|
2. Check sibling files for existing patterns — follow those first per Consistency First
|
||||||
3. Verify API syntax with `search-docs` for the installed Laravel version
|
3. Verify API syntax with `search-docs` for the installed Laravel version
|
||||||
|
|||||||
@ -103,4 +103,4 @@ ## Use Correlated Subqueries for Has-Many Ordering
|
|||||||
->take(1)
|
->take(1)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -199,4 +199,4 @@ ## Convention Over Configuration
|
|||||||
return $this->belongsToMany(Role::class);
|
return $this->belongsToMany(Role::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -33,4 +33,4 @@ ## Use Blade Fragments for Partial Re-Renders (htmx/Turbo)
|
|||||||
|
|
||||||
## Use `@aware` for Deeply Nested Component Props
|
## Use `@aware` for Deeply Nested Component Props
|
||||||
|
|
||||||
Avoids re-passing parent props through every level of nested components.
|
Avoids re-passing parent props through every level of nested components.
|
||||||
|
|||||||
@ -67,4 +67,4 @@ ## Configure Failover Cache Stores in Production
|
|||||||
|
|
||||||
```php
|
```php
|
||||||
'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
|
'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
|
||||||
```
|
```
|
||||||
|
|||||||
@ -41,4 +41,4 @@ ## Use `#[CollectedBy]` for Custom Collection Classes
|
|||||||
```php
|
```php
|
||||||
#[CollectedBy(UserCollection::class)]
|
#[CollectedBy(UserCollection::class)]
|
||||||
class User extends Model {}
|
class User extends Model {}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -70,4 +70,4 @@ ## Use Constants and Language Files
|
|||||||
```php
|
```php
|
||||||
// Only when lang files already exist in the project
|
// Only when lang files already exist in the project
|
||||||
return back()->with('message', __('app.article_added'));
|
return back()->with('message', __('app.article_added'));
|
||||||
```
|
```
|
||||||
|
|||||||
@ -189,4 +189,4 @@ ## No Queries in Blade Templates
|
|||||||
@foreach ($users as $user)
|
@foreach ($users as $user)
|
||||||
{{ $user->profile->name }}
|
{{ $user->profile->name }}
|
||||||
@endforeach
|
@endforeach
|
||||||
```
|
```
|
||||||
|
|||||||
@ -145,4 +145,4 @@ ## Avoid Hardcoded Table Names in Queries
|
|||||||
|
|
||||||
Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable.
|
Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable.
|
||||||
|
|
||||||
**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration.
|
**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration.
|
||||||
|
|||||||
@ -69,4 +69,4 @@ ## Add Context to Exception Classes
|
|||||||
return ['order_id' => $this->orderId];
|
return ['order_id' => $this->orderId];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -49,4 +49,4 @@ ## Use On-Demand Notifications for Non-User Recipients
|
|||||||
|
|
||||||
## Implement `HasLocalePreference` on Notifiable Models
|
## Implement `HasLocalePreference` on Notifiable Models
|
||||||
|
|
||||||
Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.
|
Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.
|
||||||
|
|||||||
@ -157,4 +157,4 @@ ## Fake HTTP Calls in Tests
|
|||||||
Http::fake([
|
Http::fake([
|
||||||
'api.example.com/*' => Http::failedConnection(),
|
'api.example.com/*' => Http::failedConnection(),
|
||||||
]);
|
]);
|
||||||
```
|
```
|
||||||
|
|||||||
@ -24,4 +24,4 @@ ## Separate Content Tests from Sending Tests
|
|||||||
|
|
||||||
Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
|
Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
|
||||||
Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
|
Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
|
||||||
Don't mix them — it conflates concerns and makes tests brittle.
|
Don't mix them — it conflates concerns and makes tests brittle.
|
||||||
|
|||||||
@ -118,4 +118,4 @@ ## Keep Migrations Focused
|
|||||||
|
|
||||||
// Migration 2: seed_default_settings
|
// Migration 2: seed_default_settings
|
||||||
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
|
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
|
||||||
```
|
```
|
||||||
|
|||||||
@ -141,4 +141,4 @@ ## Use Horizon for Complex Queue Scenarios
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
```
|
```
|
||||||
|
|||||||
@ -96,4 +96,4 @@ ## Type-Hint Form Requests
|
|||||||
|
|
||||||
return redirect()->route('posts.index');
|
return redirect()->route('posts.index');
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -36,4 +36,4 @@ ## Use Schedule Groups for Shared Configuration
|
|||||||
Schedule::command('emails:send --force');
|
Schedule::command('emails:send --force');
|
||||||
Schedule::command('emails:prune');
|
Schedule::command('emails:prune');
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|||||||
@ -195,4 +195,4 @@ ## Encrypt Sensitive Database Fields
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -122,4 +122,4 @@ ## No Unnecessary Comments
|
|||||||
Correct:
|
Correct:
|
||||||
```php
|
```php
|
||||||
if ($this->hasJoins())
|
if ($this->hasJoins())
|
||||||
```
|
```
|
||||||
|
|||||||
@ -40,4 +40,4 @@ ## Use `recycle()` to Share Relationship Instances Across Factories
|
|||||||
Ticket::factory()
|
Ticket::factory()
|
||||||
->recycle(Airline::factory()->create())
|
->recycle(Airline::factory()->create())
|
||||||
->create();
|
->create();
|
||||||
```
|
```
|
||||||
|
|||||||
@ -72,4 +72,4 @@ ## Use the `after()` Method for Custom Validation
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -172,4 +172,4 @@ ## Common Pitfalls
|
|||||||
- Expecting `wire:model` real-time → use `wire:model.live`
|
- Expecting `wire:model` real-time → use `wire:model.live`
|
||||||
- Unclosed component tags → syntax errors in v4
|
- Unclosed component tags → syntax errors in v4
|
||||||
- Using deprecated config keys or JS hooks
|
- Using deprecated config keys or JS hooks
|
||||||
- Including Alpine.js separately (already bundled in Livewire 4)
|
- Including Alpine.js separately (already bundled in Livewire 4)
|
||||||
|
|||||||
@ -36,4 +36,4 @@ ### Component-Scoped Interceptors
|
|||||||
## Magic Properties
|
## Magic Properties
|
||||||
|
|
||||||
- `$errors` - Access validation errors from JavaScript
|
- `$errors` - Access validation errors from JavaScript
|
||||||
- `$intercept` - Component-scoped interceptors
|
- `$intercept` - Component-scoped interceptors
|
||||||
|
|||||||
@ -163,4 +163,4 @@ ## Common Pitfalls
|
|||||||
- Forgetting datasets for repetitive validation tests
|
- Forgetting datasets for repetitive validation tests
|
||||||
- Deleting tests without approval
|
- Deleting tests without approval
|
||||||
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
||||||
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
|
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
|
||||||
|
|||||||
@ -116,4 +116,4 @@ ## Common Pitfalls
|
|||||||
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
||||||
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
||||||
- Using margins for spacing between siblings instead of gap utilities
|
- Using margins for spacing between siblings instead of gap utilities
|
||||||
- Forgetting to add dark mode variants when the project uses dark mode
|
- Forgetting to add dark mode variants when the project uses dark mode
|
||||||
|
|||||||
@ -27,7 +27,7 @@ ## Basic Usage
|
|||||||
|
|
||||||
## Available Components (Free Edition)
|
## Available Components (Free Edition)
|
||||||
|
|
||||||
Available: avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, profile, radio, select, separator, skeleton, switch, text, textarea, tooltip
|
Available: avatar, badge, brand, breadcrumbs, button, callout, card, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, pagination, profile, progress, radio, select, separator, skeleton, switch, table, text, textarea, toast, tooltip
|
||||||
|
|
||||||
## Icons
|
## Icons
|
||||||
|
|
||||||
@ -78,4 +78,4 @@ ## Common Pitfalls
|
|||||||
- Trying to use Pro-only components in the free edition
|
- Trying to use Pro-only components in the free edition
|
||||||
- Not checking if a Flux component exists before creating custom implementations
|
- Not checking if a Flux component exists before creating custom implementations
|
||||||
- Forgetting to use the `search-docs` tool for component-specific documentation
|
- Forgetting to use the `search-docs` tool for component-specific documentation
|
||||||
- Not following existing project patterns for Flux usage
|
- Not following existing project patterns for Flux usage
|
||||||
|
|||||||
@ -148,4 +148,4 @@ ## Key Endpoints
|
|||||||
| Passkey Confirm | POST | `/passkeys/confirm` |
|
| Passkey Confirm | POST | `/passkeys/confirm` |
|
||||||
| Passkey Options | GET | `/user/passkeys/options` |
|
| Passkey Options | GET | `/user/passkeys/options` |
|
||||||
| Register Passkey | POST | `/user/passkeys` |
|
| Register Passkey | POST | `/user/passkeys` |
|
||||||
| Delete Passkey | DELETE | `/user/passkeys/{passkey}` |
|
| Delete Passkey | DELETE | `/user/passkeys/{passkey}` |
|
||||||
|
|||||||
@ -187,4 +187,4 @@ ## How to Apply
|
|||||||
|
|
||||||
1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10)
|
1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10)
|
||||||
2. Check sibling files for existing patterns — follow those first per Consistency First
|
2. Check sibling files for existing patterns — follow those first per Consistency First
|
||||||
3. Verify API syntax with `search-docs` for the installed Laravel version
|
3. Verify API syntax with `search-docs` for the installed Laravel version
|
||||||
|
|||||||
@ -103,4 +103,4 @@ ## Use Correlated Subqueries for Has-Many Ordering
|
|||||||
->take(1)
|
->take(1)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -199,4 +199,4 @@ ## Convention Over Configuration
|
|||||||
return $this->belongsToMany(Role::class);
|
return $this->belongsToMany(Role::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -33,4 +33,4 @@ ## Use Blade Fragments for Partial Re-Renders (htmx/Turbo)
|
|||||||
|
|
||||||
## Use `@aware` for Deeply Nested Component Props
|
## Use `@aware` for Deeply Nested Component Props
|
||||||
|
|
||||||
Avoids re-passing parent props through every level of nested components.
|
Avoids re-passing parent props through every level of nested components.
|
||||||
|
|||||||
@ -67,4 +67,4 @@ ## Configure Failover Cache Stores in Production
|
|||||||
|
|
||||||
```php
|
```php
|
||||||
'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
|
'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
|
||||||
```
|
```
|
||||||
|
|||||||
@ -41,4 +41,4 @@ ## Use `#[CollectedBy]` for Custom Collection Classes
|
|||||||
```php
|
```php
|
||||||
#[CollectedBy(UserCollection::class)]
|
#[CollectedBy(UserCollection::class)]
|
||||||
class User extends Model {}
|
class User extends Model {}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -70,4 +70,4 @@ ## Use Constants and Language Files
|
|||||||
```php
|
```php
|
||||||
// Only when lang files already exist in the project
|
// Only when lang files already exist in the project
|
||||||
return back()->with('message', __('app.article_added'));
|
return back()->with('message', __('app.article_added'));
|
||||||
```
|
```
|
||||||
|
|||||||
@ -189,4 +189,4 @@ ## No Queries in Blade Templates
|
|||||||
@foreach ($users as $user)
|
@foreach ($users as $user)
|
||||||
{{ $user->profile->name }}
|
{{ $user->profile->name }}
|
||||||
@endforeach
|
@endforeach
|
||||||
```
|
```
|
||||||
|
|||||||
@ -145,4 +145,4 @@ ## Avoid Hardcoded Table Names in Queries
|
|||||||
|
|
||||||
Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable.
|
Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable.
|
||||||
|
|
||||||
**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration.
|
**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration.
|
||||||
|
|||||||
@ -69,4 +69,4 @@ ## Add Context to Exception Classes
|
|||||||
return ['order_id' => $this->orderId];
|
return ['order_id' => $this->orderId];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -49,4 +49,4 @@ ## Use On-Demand Notifications for Non-User Recipients
|
|||||||
|
|
||||||
## Implement `HasLocalePreference` on Notifiable Models
|
## Implement `HasLocalePreference` on Notifiable Models
|
||||||
|
|
||||||
Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.
|
Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.
|
||||||
|
|||||||
@ -157,4 +157,4 @@ ## Fake HTTP Calls in Tests
|
|||||||
Http::fake([
|
Http::fake([
|
||||||
'api.example.com/*' => Http::failedConnection(),
|
'api.example.com/*' => Http::failedConnection(),
|
||||||
]);
|
]);
|
||||||
```
|
```
|
||||||
|
|||||||
@ -24,4 +24,4 @@ ## Separate Content Tests from Sending Tests
|
|||||||
|
|
||||||
Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
|
Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
|
||||||
Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
|
Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
|
||||||
Don't mix them — it conflates concerns and makes tests brittle.
|
Don't mix them — it conflates concerns and makes tests brittle.
|
||||||
|
|||||||
@ -118,4 +118,4 @@ ## Keep Migrations Focused
|
|||||||
|
|
||||||
// Migration 2: seed_default_settings
|
// Migration 2: seed_default_settings
|
||||||
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
|
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
|
||||||
```
|
```
|
||||||
|
|||||||
@ -141,4 +141,4 @@ ## Use Horizon for Complex Queue Scenarios
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
```
|
```
|
||||||
|
|||||||
@ -96,4 +96,4 @@ ## Type-Hint Form Requests
|
|||||||
|
|
||||||
return redirect()->route('posts.index');
|
return redirect()->route('posts.index');
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -36,4 +36,4 @@ ## Use Schedule Groups for Shared Configuration
|
|||||||
Schedule::command('emails:send --force');
|
Schedule::command('emails:send --force');
|
||||||
Schedule::command('emails:prune');
|
Schedule::command('emails:prune');
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|||||||
@ -195,4 +195,4 @@ ## Encrypt Sensitive Database Fields
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -122,4 +122,4 @@ ## No Unnecessary Comments
|
|||||||
Correct:
|
Correct:
|
||||||
```php
|
```php
|
||||||
if ($this->hasJoins())
|
if ($this->hasJoins())
|
||||||
```
|
```
|
||||||
|
|||||||
@ -40,4 +40,4 @@ ## Use `recycle()` to Share Relationship Instances Across Factories
|
|||||||
Ticket::factory()
|
Ticket::factory()
|
||||||
->recycle(Airline::factory()->create())
|
->recycle(Airline::factory()->create())
|
||||||
->create();
|
->create();
|
||||||
```
|
```
|
||||||
|
|||||||
@ -72,4 +72,4 @@ ## Use the `after()` Method for Custom Validation
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -172,4 +172,4 @@ ## Common Pitfalls
|
|||||||
- Expecting `wire:model` real-time → use `wire:model.live`
|
- Expecting `wire:model` real-time → use `wire:model.live`
|
||||||
- Unclosed component tags → syntax errors in v4
|
- Unclosed component tags → syntax errors in v4
|
||||||
- Using deprecated config keys or JS hooks
|
- Using deprecated config keys or JS hooks
|
||||||
- Including Alpine.js separately (already bundled in Livewire 4)
|
- Including Alpine.js separately (already bundled in Livewire 4)
|
||||||
|
|||||||
@ -36,4 +36,4 @@ ### Component-Scoped Interceptors
|
|||||||
## Magic Properties
|
## Magic Properties
|
||||||
|
|
||||||
- `$errors` - Access validation errors from JavaScript
|
- `$errors` - Access validation errors from JavaScript
|
||||||
- `$intercept` - Component-scoped interceptors
|
- `$intercept` - Component-scoped interceptors
|
||||||
|
|||||||
@ -163,4 +163,4 @@ ## Common Pitfalls
|
|||||||
- Forgetting datasets for repetitive validation tests
|
- Forgetting datasets for repetitive validation tests
|
||||||
- Deleting tests without approval
|
- Deleting tests without approval
|
||||||
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
||||||
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
|
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
|
||||||
|
|||||||
@ -116,4 +116,4 @@ ## Common Pitfalls
|
|||||||
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
||||||
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
||||||
- Using margins for spacing between siblings instead of gap utilities
|
- Using margins for spacing between siblings instead of gap utilities
|
||||||
- Forgetting to add dark mode variants when the project uses dark mode
|
- Forgetting to add dark mode variants when the project uses dark mode
|
||||||
|
|||||||
4
.github/skills/fluxui-development/SKILL.md
vendored
4
.github/skills/fluxui-development/SKILL.md
vendored
@ -27,7 +27,7 @@ ## Basic Usage
|
|||||||
|
|
||||||
## Available Components (Free Edition)
|
## Available Components (Free Edition)
|
||||||
|
|
||||||
Available: avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, profile, radio, select, separator, skeleton, switch, text, textarea, tooltip
|
Available: avatar, badge, brand, breadcrumbs, button, callout, card, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, pagination, profile, progress, radio, select, separator, skeleton, switch, table, text, textarea, toast, tooltip
|
||||||
|
|
||||||
## Icons
|
## Icons
|
||||||
|
|
||||||
@ -78,4 +78,4 @@ ## Common Pitfalls
|
|||||||
- Trying to use Pro-only components in the free edition
|
- Trying to use Pro-only components in the free edition
|
||||||
- Not checking if a Flux component exists before creating custom implementations
|
- Not checking if a Flux component exists before creating custom implementations
|
||||||
- Forgetting to use the `search-docs` tool for component-specific documentation
|
- Forgetting to use the `search-docs` tool for component-specific documentation
|
||||||
- Not following existing project patterns for Flux usage
|
- Not following existing project patterns for Flux usage
|
||||||
|
|||||||
2
.github/skills/fortify-development/SKILL.md
vendored
2
.github/skills/fortify-development/SKILL.md
vendored
@ -148,4 +148,4 @@ ## Key Endpoints
|
|||||||
| Passkey Confirm | POST | `/passkeys/confirm` |
|
| Passkey Confirm | POST | `/passkeys/confirm` |
|
||||||
| Passkey Options | GET | `/user/passkeys/options` |
|
| Passkey Options | GET | `/user/passkeys/options` |
|
||||||
| Register Passkey | POST | `/user/passkeys` |
|
| Register Passkey | POST | `/user/passkeys` |
|
||||||
| Delete Passkey | DELETE | `/user/passkeys/{passkey}` |
|
| Delete Passkey | DELETE | `/user/passkeys/{passkey}` |
|
||||||
|
|||||||
@ -187,4 +187,4 @@ ## How to Apply
|
|||||||
|
|
||||||
1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10)
|
1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10)
|
||||||
2. Check sibling files for existing patterns — follow those first per Consistency First
|
2. Check sibling files for existing patterns — follow those first per Consistency First
|
||||||
3. Verify API syntax with `search-docs` for the installed Laravel version
|
3. Verify API syntax with `search-docs` for the installed Laravel version
|
||||||
|
|||||||
@ -103,4 +103,4 @@ ## Use Correlated Subqueries for Has-Many Ordering
|
|||||||
->take(1)
|
->take(1)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -199,4 +199,4 @@ ## Convention Over Configuration
|
|||||||
return $this->belongsToMany(Role::class);
|
return $this->belongsToMany(Role::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -33,4 +33,4 @@ ## Use Blade Fragments for Partial Re-Renders (htmx/Turbo)
|
|||||||
|
|
||||||
## Use `@aware` for Deeply Nested Component Props
|
## Use `@aware` for Deeply Nested Component Props
|
||||||
|
|
||||||
Avoids re-passing parent props through every level of nested components.
|
Avoids re-passing parent props through every level of nested components.
|
||||||
|
|||||||
@ -67,4 +67,4 @@ ## Configure Failover Cache Stores in Production
|
|||||||
|
|
||||||
```php
|
```php
|
||||||
'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
|
'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
|
||||||
```
|
```
|
||||||
|
|||||||
@ -41,4 +41,4 @@ ## Use `#[CollectedBy]` for Custom Collection Classes
|
|||||||
```php
|
```php
|
||||||
#[CollectedBy(UserCollection::class)]
|
#[CollectedBy(UserCollection::class)]
|
||||||
class User extends Model {}
|
class User extends Model {}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -70,4 +70,4 @@ ## Use Constants and Language Files
|
|||||||
```php
|
```php
|
||||||
// Only when lang files already exist in the project
|
// Only when lang files already exist in the project
|
||||||
return back()->with('message', __('app.article_added'));
|
return back()->with('message', __('app.article_added'));
|
||||||
```
|
```
|
||||||
|
|||||||
@ -189,4 +189,4 @@ ## No Queries in Blade Templates
|
|||||||
@foreach ($users as $user)
|
@foreach ($users as $user)
|
||||||
{{ $user->profile->name }}
|
{{ $user->profile->name }}
|
||||||
@endforeach
|
@endforeach
|
||||||
```
|
```
|
||||||
|
|||||||
@ -145,4 +145,4 @@ ## Avoid Hardcoded Table Names in Queries
|
|||||||
|
|
||||||
Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable.
|
Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable.
|
||||||
|
|
||||||
**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration.
|
**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration.
|
||||||
|
|||||||
@ -69,4 +69,4 @@ ## Add Context to Exception Classes
|
|||||||
return ['order_id' => $this->orderId];
|
return ['order_id' => $this->orderId];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -49,4 +49,4 @@ ## Use On-Demand Notifications for Non-User Recipients
|
|||||||
|
|
||||||
## Implement `HasLocalePreference` on Notifiable Models
|
## Implement `HasLocalePreference` on Notifiable Models
|
||||||
|
|
||||||
Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.
|
Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.
|
||||||
|
|||||||
@ -157,4 +157,4 @@ ## Fake HTTP Calls in Tests
|
|||||||
Http::fake([
|
Http::fake([
|
||||||
'api.example.com/*' => Http::failedConnection(),
|
'api.example.com/*' => Http::failedConnection(),
|
||||||
]);
|
]);
|
||||||
```
|
```
|
||||||
|
|||||||
@ -24,4 +24,4 @@ ## Separate Content Tests from Sending Tests
|
|||||||
|
|
||||||
Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
|
Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
|
||||||
Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
|
Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
|
||||||
Don't mix them — it conflates concerns and makes tests brittle.
|
Don't mix them — it conflates concerns and makes tests brittle.
|
||||||
|
|||||||
@ -118,4 +118,4 @@ ## Keep Migrations Focused
|
|||||||
|
|
||||||
// Migration 2: seed_default_settings
|
// Migration 2: seed_default_settings
|
||||||
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
|
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
|
||||||
```
|
```
|
||||||
|
|||||||
@ -141,4 +141,4 @@ ## Use Horizon for Complex Queue Scenarios
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
```
|
```
|
||||||
|
|||||||
@ -96,4 +96,4 @@ ## Type-Hint Form Requests
|
|||||||
|
|
||||||
return redirect()->route('posts.index');
|
return redirect()->route('posts.index');
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -36,4 +36,4 @@ ## Use Schedule Groups for Shared Configuration
|
|||||||
Schedule::command('emails:send --force');
|
Schedule::command('emails:send --force');
|
||||||
Schedule::command('emails:prune');
|
Schedule::command('emails:prune');
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|||||||
@ -195,4 +195,4 @@ ## Encrypt Sensitive Database Fields
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -122,4 +122,4 @@ ## No Unnecessary Comments
|
|||||||
Correct:
|
Correct:
|
||||||
```php
|
```php
|
||||||
if ($this->hasJoins())
|
if ($this->hasJoins())
|
||||||
```
|
```
|
||||||
|
|||||||
@ -40,4 +40,4 @@ ## Use `recycle()` to Share Relationship Instances Across Factories
|
|||||||
Ticket::factory()
|
Ticket::factory()
|
||||||
->recycle(Airline::factory()->create())
|
->recycle(Airline::factory()->create())
|
||||||
->create();
|
->create();
|
||||||
```
|
```
|
||||||
|
|||||||
@ -72,4 +72,4 @@ ## Use the `after()` Method for Custom Validation
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
2
.github/skills/livewire-development/SKILL.md
vendored
2
.github/skills/livewire-development/SKILL.md
vendored
@ -172,4 +172,4 @@ ## Common Pitfalls
|
|||||||
- Expecting `wire:model` real-time → use `wire:model.live`
|
- Expecting `wire:model` real-time → use `wire:model.live`
|
||||||
- Unclosed component tags → syntax errors in v4
|
- Unclosed component tags → syntax errors in v4
|
||||||
- Using deprecated config keys or JS hooks
|
- Using deprecated config keys or JS hooks
|
||||||
- Including Alpine.js separately (already bundled in Livewire 4)
|
- Including Alpine.js separately (already bundled in Livewire 4)
|
||||||
|
|||||||
@ -36,4 +36,4 @@ ### Component-Scoped Interceptors
|
|||||||
## Magic Properties
|
## Magic Properties
|
||||||
|
|
||||||
- `$errors` - Access validation errors from JavaScript
|
- `$errors` - Access validation errors from JavaScript
|
||||||
- `$intercept` - Component-scoped interceptors
|
- `$intercept` - Component-scoped interceptors
|
||||||
|
|||||||
2
.github/skills/pest-testing/SKILL.md
vendored
2
.github/skills/pest-testing/SKILL.md
vendored
@ -163,4 +163,4 @@ ## Common Pitfalls
|
|||||||
- Forgetting datasets for repetitive validation tests
|
- Forgetting datasets for repetitive validation tests
|
||||||
- Deleting tests without approval
|
- Deleting tests without approval
|
||||||
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
||||||
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
|
- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
|
||||||
|
|||||||
@ -116,4 +116,4 @@ ## Common Pitfalls
|
|||||||
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
||||||
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
||||||
- Using margins for spacing between siblings instead of gap utilities
|
- Using margins for spacing between siblings instead of gap utilities
|
||||||
- Forgetting to add dark mode variants when the project uses dark mode
|
- Forgetting to add dark mode variants when the project uses dark mode
|
||||||
|
|||||||
@ -9,7 +9,7 @@ ## Foundational Context
|
|||||||
|
|
||||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||||
|
|
||||||
- php - 8.5
|
- php - 8.4
|
||||||
- laravel/fortify (FORTIFY) - v1
|
- laravel/fortify (FORTIFY) - v1
|
||||||
- laravel/framework (LARAVEL) - v13
|
- laravel/framework (LARAVEL) - v13
|
||||||
- laravel/prompts (PROMPTS) - v0
|
- laravel/prompts (PROMPTS) - v0
|
||||||
@ -87,7 +87,6 @@ ## Artisan
|
|||||||
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
||||||
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
|
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
|
||||||
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
|
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
|
||||||
- To check environment variables, read the `.env` file directly.
|
|
||||||
|
|
||||||
## Tinker
|
## Tinker
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ ## Foundational Context
|
|||||||
|
|
||||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||||
|
|
||||||
- php - 8.5
|
- php - 8.4
|
||||||
- laravel/fortify (FORTIFY) - v1
|
- laravel/fortify (FORTIFY) - v1
|
||||||
- laravel/framework (LARAVEL) - v13
|
- laravel/framework (LARAVEL) - v13
|
||||||
- laravel/prompts (PROMPTS) - v0
|
- laravel/prompts (PROMPTS) - v0
|
||||||
@ -87,7 +87,6 @@ ## Artisan
|
|||||||
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
||||||
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
|
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
|
||||||
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
|
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
|
||||||
- To check environment variables, read the `.env` file directly.
|
|
||||||
|
|
||||||
## Tinker
|
## Tinker
|
||||||
|
|
||||||
|
|||||||
101
app/Http/Controllers/KekaOutlookController.php
Normal file
101
app/Http/Controllers/KekaOutlookController.php
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Services\KekaOutlookService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class KekaOutlookController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private KekaOutlookService $kekaOutlook) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /keka/connect
|
||||||
|
*
|
||||||
|
* If the user already has a stored refresh token, we don't show
|
||||||
|
* Microsoft's login screen at all — just confirm/refresh silently
|
||||||
|
* and send them straight back to Keka.
|
||||||
|
*
|
||||||
|
* If not connected yet, redirect to Microsoft login (one-time).
|
||||||
|
*/
|
||||||
|
public function connect(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($this->kekaOutlook->isConnected($user)) {
|
||||||
|
try {
|
||||||
|
// Ensures token is fresh; throws if refresh token is invalid/revoked.
|
||||||
|
$this->kekaOutlook->getValidToken($user);
|
||||||
|
|
||||||
|
return redirect()->away($this->kekaWebsiteUrl());
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
// Refresh failed -> fall through to re-login below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->away($this->kekaOutlook->getAuthUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /keka/callback
|
||||||
|
*
|
||||||
|
* Microsoft redirects here after login with ?code=... or ?error=...
|
||||||
|
*/
|
||||||
|
public function callback(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if ($request->filled('error')) {
|
||||||
|
return redirect()
|
||||||
|
->route('dashboard')
|
||||||
|
->with('error', 'Microsoft login was cancelled or failed: '.$request->get('error_description', $request->get('error')));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->kekaOutlook->isValidState($request->get('state'))) {
|
||||||
|
return redirect()
|
||||||
|
->route('dashboard')
|
||||||
|
->with('error', 'Invalid login session. Please try connecting again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = $request->get('code');
|
||||||
|
|
||||||
|
if (! $code) {
|
||||||
|
return redirect()
|
||||||
|
->route('dashboard')
|
||||||
|
->with('error', 'No authorization code received from Microsoft.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->kekaOutlook->handleCallback($request->user(), $code);
|
||||||
|
|
||||||
|
if (! $result['success']) {
|
||||||
|
return redirect()
|
||||||
|
->route('dashboard')
|
||||||
|
->with('error', 'Failed to connect: '.($result['message'] ?? 'Unknown error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->away($this->kekaWebsiteUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /keka/disconnect
|
||||||
|
*
|
||||||
|
* Wipes stored tokens. User must go through Microsoft login again next time.
|
||||||
|
*/
|
||||||
|
public function disconnect(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->kekaOutlook->disconnect($request->user());
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('dashboard')
|
||||||
|
->with('success', 'Keka has been disconnected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Keka website URL the user lands on after a successful (silent) connection.
|
||||||
|
*/
|
||||||
|
private function kekaWebsiteUrl(): string
|
||||||
|
{
|
||||||
|
return (string) config('microsoft.keka_website_url', 'https://app.keka.com');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -59,6 +59,7 @@ protected function casts(): array
|
|||||||
return [
|
return [
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
|
'keka_ms_token_expires_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
224
app/Services/KekaOutlookService.php
Normal file
224
app/Services/KekaOutlookService.php
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles Microsoft OAuth (delegated, authorization code flow) for Keka-Outlook integration.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* - connect() -> build Microsoft login URL
|
||||||
|
* - handleCallback() -> exchange code for tokens, store on user
|
||||||
|
* - getValidToken() -> return a valid access token, refreshing silently if expired
|
||||||
|
* - disconnect() -> wipe stored tokens for the user
|
||||||
|
*/
|
||||||
|
final class KekaOutlookService
|
||||||
|
{
|
||||||
|
private string $clientId;
|
||||||
|
|
||||||
|
private string $clientSecret;
|
||||||
|
|
||||||
|
private string $tenantId;
|
||||||
|
|
||||||
|
private string $redirectUri;
|
||||||
|
|
||||||
|
private array $scopes;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->clientId = (string) config('microsoft.client_id');
|
||||||
|
$this->clientSecret = (string) config('microsoft.client_secret');
|
||||||
|
$this->tenantId = (string) config('microsoft.tenant_id');
|
||||||
|
$this->redirectUri = (string) config('microsoft.keka_redirect_uri');
|
||||||
|
$this->scopes = config('microsoft.keka_scopes', [
|
||||||
|
'openid',
|
||||||
|
'profile',
|
||||||
|
'email',
|
||||||
|
'offline_access',
|
||||||
|
'User.Read',
|
||||||
|
'Mail.Read',
|
||||||
|
'Mail.ReadWrite',
|
||||||
|
'Mail.Send',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the Microsoft login URL the user is redirected to.
|
||||||
|
* A random "state" value is generated and stored in the session to prevent CSRF.
|
||||||
|
*/
|
||||||
|
public function getAuthUrl(): string
|
||||||
|
{
|
||||||
|
$state = Str::random(40);
|
||||||
|
session(['keka_ms_oauth_state' => $state]);
|
||||||
|
|
||||||
|
$query = http_build_query([
|
||||||
|
'client_id' => $this->clientId,
|
||||||
|
'response_type' => 'code',
|
||||||
|
'redirect_uri' => $this->redirectUri,
|
||||||
|
'response_mode' => 'query',
|
||||||
|
'scope' => implode(' ', $this->scopes),
|
||||||
|
'state' => $state,
|
||||||
|
// prompt=select_account forces account chooser on first connect.
|
||||||
|
'prompt' => 'select_account',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize?{$query}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the "state" returned by Microsoft against the one stored in session.
|
||||||
|
*/
|
||||||
|
public function isValidState(?string $state): bool
|
||||||
|
{
|
||||||
|
$expected = session('keka_ms_oauth_state');
|
||||||
|
|
||||||
|
if (! $expected || ! $state) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash_equals($expected, $state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange the authorization code for access + refresh tokens and store them on the user.
|
||||||
|
*
|
||||||
|
* @return array{success: bool, message?: string}
|
||||||
|
*/
|
||||||
|
public function handleCallback(User $user, string $code): array
|
||||||
|
{
|
||||||
|
$response = Http::asForm()->post(
|
||||||
|
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
|
||||||
|
[
|
||||||
|
'client_id' => $this->clientId,
|
||||||
|
'client_secret' => $this->clientSecret,
|
||||||
|
'grant_type' => 'authorization_code',
|
||||||
|
'code' => $code,
|
||||||
|
'redirect_uri' => $this->redirectUri,
|
||||||
|
'scope' => implode(' ', $this->scopes),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'message' => $response->json('error_description', 'Failed to exchange code for token.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
$this->storeTokens($user, $data);
|
||||||
|
|
||||||
|
// Fetch the connected mailbox's email address for display purposes.
|
||||||
|
$profile = Http::withToken($data['access_token'])
|
||||||
|
->get('https://graph.microsoft.com/v1.0/me');
|
||||||
|
|
||||||
|
if ($profile->successful()) {
|
||||||
|
$user->keka_ms_email = $profile->json('mail') ?? $profile->json('userPrincipalName');
|
||||||
|
$user->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
session()->forget('keka_ms_oauth_state');
|
||||||
|
|
||||||
|
return ['success' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist tokens + expiry on the user model.
|
||||||
|
*/
|
||||||
|
private function storeTokens(User $user, array $tokenData): void
|
||||||
|
{
|
||||||
|
$user->keka_ms_access_token = $tokenData['access_token'];
|
||||||
|
$user->keka_ms_token_expires_at = now()->addSeconds((int) $tokenData['expires_in']);
|
||||||
|
|
||||||
|
// Microsoft only returns refresh_token if 'offline_access' scope was granted.
|
||||||
|
// If a new one isn't returned on refresh, keep the existing one.
|
||||||
|
if (! empty($tokenData['refresh_token'])) {
|
||||||
|
$user->keka_ms_refresh_token = $tokenData['refresh_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the user has connected Keka/Outlook (has a refresh token stored).
|
||||||
|
*/
|
||||||
|
public function isConnected(User $user): bool
|
||||||
|
{
|
||||||
|
return ! empty($user->keka_ms_refresh_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a valid access token for the user.
|
||||||
|
* Silently refreshes via refresh_token if the current access token has expired.
|
||||||
|
*
|
||||||
|
* @throws \RuntimeException if the user has not connected or refresh fails.
|
||||||
|
*/
|
||||||
|
public function getValidToken(User $user): string
|
||||||
|
{
|
||||||
|
if (! $this->isConnected($user)) {
|
||||||
|
throw new \RuntimeException('User has not connected Keka/Outlook.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still valid? (1 minute buffer)
|
||||||
|
if (
|
||||||
|
$user->keka_ms_access_token
|
||||||
|
&& $user->keka_ms_token_expires_at
|
||||||
|
&& now()->lt($user->keka_ms_token_expires_at->subMinute())
|
||||||
|
) {
|
||||||
|
return $user->keka_ms_access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->refreshToken($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use the stored refresh_token to obtain a new access_token silently.
|
||||||
|
*
|
||||||
|
* @throws \RuntimeException if Microsoft rejects the refresh token (user must reconnect).
|
||||||
|
*/
|
||||||
|
private function refreshToken(User $user): string
|
||||||
|
{
|
||||||
|
$response = Http::asForm()->post(
|
||||||
|
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
|
||||||
|
[
|
||||||
|
'client_id' => $this->clientId,
|
||||||
|
'client_secret' => $this->clientSecret,
|
||||||
|
'grant_type' => 'refresh_token',
|
||||||
|
'refresh_token' => $user->keka_ms_refresh_token,
|
||||||
|
'scope' => implode(' ', $this->scopes),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
// Refresh token expired/revoked -> force user to reconnect.
|
||||||
|
$this->disconnect($user);
|
||||||
|
|
||||||
|
throw new \RuntimeException(
|
||||||
|
$response->json('error_description', 'Session expired. Please reconnect Keka.')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
$this->storeTokens($user, $data);
|
||||||
|
|
||||||
|
return $data['access_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect: wipe stored tokens. User must go through login flow again to reconnect.
|
||||||
|
*/
|
||||||
|
public function disconnect(User $user): void
|
||||||
|
{
|
||||||
|
$user->keka_ms_access_token = null;
|
||||||
|
$user->keka_ms_refresh_token = null;
|
||||||
|
$user->keka_ms_token_expires_at = null;
|
||||||
|
$user->keka_ms_email = null;
|
||||||
|
$user->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"agents": ["claude_code", "copilot", "opencode"],
|
"agents": [
|
||||||
|
"claude_code",
|
||||||
|
"copilot",
|
||||||
|
"opencode"
|
||||||
|
],
|
||||||
"cloud": false,
|
"cloud": false,
|
||||||
"guidelines": true,
|
"guidelines": true,
|
||||||
"mcp": true,
|
"mcp": true,
|
||||||
@ -8,6 +12,7 @@
|
|||||||
"skills": [
|
"skills": [
|
||||||
"fortify-development",
|
"fortify-development",
|
||||||
"laravel-best-practices",
|
"laravel-best-practices",
|
||||||
|
"fluxui-development",
|
||||||
"livewire-development",
|
"livewire-development",
|
||||||
"pest-testing",
|
"pest-testing",
|
||||||
"tailwindcss-development"
|
"tailwindcss-development"
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.5",
|
"php": "^8.3",
|
||||||
"gehrisandro/tailwind-merge-laravel": "^1.4",
|
"gehrisandro/tailwind-merge-laravel": "^1.4",
|
||||||
"laravel/fortify": "^1.34",
|
"laravel/fortify": "^1.34",
|
||||||
"laravel/framework": "^13.7",
|
"laravel/framework": "^13.7",
|
||||||
|
|||||||
1131
composer.lock
generated
1131
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@ -125,4 +125,21 @@
|
|||||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Redirect URI registered in Azure: http://localhost:8000/keka/callback
|
||||||
|
'keka_redirect_uri' => env('KEKA_MS_REDIRECT_URI', 'http://localhost:8000/keka/callback'),
|
||||||
|
|
||||||
|
// Where to send the user after a successful (or silent) connection.
|
||||||
|
'keka_website_url' => env('KEKA_WEBSITE_URL', 'https://app.keka.com'),
|
||||||
|
|
||||||
|
'keka_scopes' => [
|
||||||
|
'openid',
|
||||||
|
'profile',
|
||||||
|
'email',
|
||||||
|
'offline_access', // REQUIRED for refresh_token / silent reconnect
|
||||||
|
'User.Read',
|
||||||
|
'Mail.Read',
|
||||||
|
'Mail.ReadWrite',
|
||||||
|
'Mail.Send',
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
16
config/microsoft.php
Normal file
16
config/microsoft.php
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'client_id' => env('MICROSOFT_GRAPH_CLIENT_ID'),
|
||||||
|
'client_secret' => env('MICROSOFT_GRAPH_CLIENT_SECRET'),
|
||||||
|
'tenant_id' => env('MICROSOFT_GRAPH_TENANT_ID'),
|
||||||
|
'keka_redirect_uri' => env('KEKA_MS_REDIRECT_URI', 'http://localhost:8000/keka/callback'),
|
||||||
|
'keka_scopes' => ['openid',
|
||||||
|
'profile',
|
||||||
|
'email',
|
||||||
|
'offline_access',
|
||||||
|
'User.Read',
|
||||||
|
'Mail.Read',
|
||||||
|
'Mail.ReadWrite',
|
||||||
|
'Mail.Send']
|
||||||
|
];
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->text('keka_ms_access_token')->nullable();
|
||||||
|
$table->text('keka_ms_refresh_token')->nullable();
|
||||||
|
$table->timestamp('keka_ms_token_expires_at')->nullable();
|
||||||
|
$table->string('keka_ms_email')->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn([
|
||||||
|
'keka_ms_access_token',
|
||||||
|
'keka_ms_refresh_token',
|
||||||
|
'keka_ms_token_expires_at',
|
||||||
|
'keka_ms_email',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
37
resources/views/test/keka.blade.php
Normal file
37
resources/views/test/keka.blade.php
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Keka</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
|
||||||
|
<div class="bg-white rounded-2xl shadow-lg p-10 w-full max-w-md text-center space-y-6">
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<div class="w-14 h-14 rounded-xl flex items-center justify-center" style="background:#FF6C37">
|
||||||
|
<span class="text-white font-bold text-2xl">K</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-800">Keka HR</h1>
|
||||||
|
<p class="text-gray-500 text-sm mt-1">Open Keka via Microsoft SSO</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="{{ route('keka.open') }}" target="_blank" rel="noopener"
|
||||||
|
class="flex items-center justify-center gap-2 w-full text-white font-semibold py-3 px-6 rounded-xl transition duration-150"
|
||||||
|
style="background:#FF6C37">
|
||||||
|
Open Keka
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
Opens Keka in a new tab. If you're already signed into Microsoft in this browser, you'll be logged in automatically.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -12,6 +12,7 @@
|
|||||||
use App\Http\Controllers\{ResolveDashboardRouteController, SamlIdpController};
|
use App\Http\Controllers\{ResolveDashboardRouteController, SamlIdpController};
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Spatie\Permission\Middleware\{PermissionMiddleware, RoleMiddleware};
|
use Spatie\Permission\Middleware\{PermissionMiddleware, RoleMiddleware};
|
||||||
|
use App\Http\Controllers\KekaOutlookController;
|
||||||
|
|
||||||
Route::redirect('/', '/dashboard')->name('home');
|
Route::redirect('/', '/dashboard')->name('home');
|
||||||
|
|
||||||
@ -94,6 +95,14 @@
|
|||||||
|
|
||||||
// Disconnect / revoke saved token
|
// Disconnect / revoke saved token
|
||||||
Route::delete('disconnect', [EntraController::class, 'disconnect'])->name('disconnect');
|
Route::delete('disconnect', [EntraController::class, 'disconnect'])->name('disconnect');
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::middleware(['auth', 'verified'])->prefix('keka')->name('keka.')->group(function (): void {
|
||||||
|
Route::get('/connect', [KekaOutlookController::class, 'connect'])->name('connect');
|
||||||
|
Route::get('/callback', [KekaOutlookController::class, 'callback'])->name('callback');
|
||||||
|
Route::post('/disconnect', [KekaOutlookController::class, 'disconnect'])->name('disconnect');
|
||||||
});
|
});
|
||||||
|
|
||||||
require __DIR__.'/settings.php';
|
require __DIR__.'/settings.php';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user