This commit is contained in:
subhajit 2026-06-12 18:22:10 +05:30
parent 688b0307b8
commit cb86f8e362
94 changed files with 1156 additions and 670 deletions

View 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

View File

@ -27,7 +27,7 @@ ## Basic Usage
## 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

View File

@ -27,7 +27,7 @@ ## Basic Usage
## 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

View File

@ -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.
- php - 8.5
- php - 8.4
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v13
- 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.
- 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.
- To check environment variables, read the `.env` file directly.
## Tinker

View File

@ -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.
- php - 8.5
- php - 8.4
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v13
- 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.
- 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.
- To check environment variables, read the `.env` file directly.
## Tinker

View 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');
}
}

View File

@ -59,6 +59,7 @@ protected function casts(): array
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'keka_ms_token_expires_at' => 'datetime',
];
}

View 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();
}
}

View File

@ -1,5 +1,9 @@
{
"agents": ["claude_code", "copilot", "opencode"],
"agents": [
"claude_code",
"copilot",
"opencode"
],
"cloud": false,
"guidelines": true,
"mcp": true,
@ -8,6 +12,7 @@
"skills": [
"fortify-development",
"laravel-best-practices",
"fluxui-development",
"livewire-development",
"pest-testing",
"tailwindcss-development"

View File

@ -9,7 +9,7 @@
],
"license": "MIT",
"require": {
"php": "^8.5",
"php": "^8.3",
"gehrisandro/tailwind-merge-laravel": "^1.4",
"laravel/fortify": "^1.34",
"laravel/framework": "^13.7",

1131
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -125,4 +125,21 @@
'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
View 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']
];

View File

@ -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',
]);
});
}
};

View 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>

View File

@ -12,6 +12,7 @@
use App\Http\Controllers\{ResolveDashboardRouteController, SamlIdpController};
use Illuminate\Support\Facades\Route;
use Spatie\Permission\Middleware\{PermissionMiddleware, RoleMiddleware};
use App\Http\Controllers\KekaOutlookController;
Route::redirect('/', '/dashboard')->name('home');
@ -94,6 +95,14 @@
// Disconnect / revoke saved token
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';