diff --git a/app/Http/Controllers/Settings/ApiKeyController.php b/app/Http/Controllers/Settings/ApiKeyController.php
index bff15e0..cf66494 100644
--- a/app/Http/Controllers/Settings/ApiKeyController.php
+++ b/app/Http/Controllers/Settings/ApiKeyController.php
@@ -66,4 +66,32 @@ public function destroy(Request $request): RedirectResponse
return redirect()->route('api-keys.edit')->with('success', 'API key deleted successfully.');
}
+
+ /**
+ * Store the OpenAI API key (used by onboarding)
+ */
+ public function store(Request $request)
+ {
+ $request->validate([
+ 'api_key' => ['required', 'string', 'min:20'],
+ ]);
+
+ $apiKey = $request->input('api_key');
+
+ // Validate the API key
+ if (!$this->apiKeyService->validateApiKey($apiKey)) {
+ return response()->json([
+ 'success' => false,
+ 'message' => 'The provided API key is invalid. Please check and try again.',
+ ], 422);
+ }
+
+ // Store the API key
+ $this->apiKeyService->setApiKey($apiKey);
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'API key saved successfully.',
+ ]);
+ }
}
diff --git a/app/Http/Middleware/CheckOnboarding.php b/app/Http/Middleware/CheckOnboarding.php
new file mode 100644
index 0000000..1952f69
--- /dev/null
+++ b/app/Http/Middleware/CheckOnboarding.php
@@ -0,0 +1,57 @@
+apiKeyService = $apiKeyService;
+ }
+
+ /**
+ * Handle an incoming request.
+ *
+ * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
+ */
+ public function handle(Request $request, Closure $next): Response
+ {
+ // Routes that should be accessible without API key
+ $excludedRoutes = [
+ 'onboarding',
+ 'api-keys.edit',
+ 'api-keys.update',
+ 'api-keys.destroy',
+ 'api.openai.status',
+ 'api.openai.api-key.store',
+ 'appearance',
+ ];
+
+ // Skip check for excluded routes
+ if ($request->route() && in_array($request->route()->getName(), $excludedRoutes)) {
+ return $next($request);
+ }
+
+ // Skip if API request (they handle their own errors)
+ if ($request->is('api/*')) {
+ return $next($request);
+ }
+
+ // Check if API key exists
+ if (!$this->apiKeyService->hasApiKey()) {
+ // If not on onboarding page and no API key, redirect to onboarding
+ if ($request->route() && $request->route()->getName() !== 'onboarding') {
+ return redirect()->route('onboarding');
+ }
+ }
+
+ return $next($request);
+ }
+}
\ No newline at end of file
diff --git a/bootstrap/app.php b/bootstrap/app.php
index 57cea11..d07f444 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -1,5 +1,6 @@
encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->web(append: [
+ CheckOnboarding::class,
HandleAppearance::class,
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,
diff --git a/resources/js/pages/Onboarding.vue b/resources/js/pages/Onboarding.vue
new file mode 100644
index 0000000..6287612
--- /dev/null
+++ b/resources/js/pages/Onboarding.vue
@@ -0,0 +1,292 @@
+
+
+
+
+
+
+
+
Welcome to Clueless
+
AI-powered meeting assistant
+
+
+
+
+
+
+
+
+
+
+
Setup OpenAI API
+
+
+
+
+
+
+
+ {{ apiKeyError }}
+
+
+ ✓ Valid API key format
+
+
+
+
+
+
+ Need an API key?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Support Clueless
+
+ If you find Clueless helpful, please consider starring our repository on GitHub. It helps others discover the
+ project!
+
+
+
+
+
+ ✓ Thank you for your support!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/pages/RealtimeAgent/Main.vue b/resources/js/pages/RealtimeAgent/Main.vue
index e958c03..d7f3c0f 100644
--- a/resources/js/pages/RealtimeAgent/Main.vue
+++ b/resources/js/pages/RealtimeAgent/Main.vue
@@ -2657,7 +2657,7 @@ onMounted(async () => {
try {
const response = await axios.get('/api/openai/status');
hasApiKey.value = response.data.hasApiKey;
-
+
if (!hasApiKey.value) {
// Redirect to settings page if no API key
window.location.href = '/settings/api-keys';
diff --git a/resources/js/pages/Welcome.vue b/resources/js/pages/Welcome.vue
index 69cc5ce..f3e0d5d 100644
--- a/resources/js/pages/Welcome.vue
+++ b/resources/js/pages/Welcome.vue
@@ -1,5 +1,5 @@
diff --git a/resources/js/pages/settings/ApiKeys.vue b/resources/js/pages/settings/ApiKeys.vue
index dcdd8ca..5eef08a 100644
--- a/resources/js/pages/settings/ApiKeys.vue
+++ b/resources/js/pages/settings/ApiKeys.vue
@@ -124,7 +124,10 @@ const deleteApiKey = () => {
-
+
{{ form.errors.openai_api_key }}
diff --git a/routes/settings.php b/routes/settings.php
index 7065612..78d52c7 100644
--- a/routes/settings.php
+++ b/routes/settings.php
@@ -9,6 +9,9 @@
Route::put('settings/api-keys', [ApiKeyController::class, 'update'])->name('api-keys.update');
Route::delete('settings/api-keys', [ApiKeyController::class, 'destroy'])->name('api-keys.destroy');
+// API endpoint for saving API key (used by onboarding)
+Route::post('/api/openai/api-key', [ApiKeyController::class, 'store'])->name('api.openai.api-key.store');
+
Route::get('settings/appearance', function () {
return Inertia::render('settings/Appearance');
})->name('appearance');
diff --git a/routes/web.php b/routes/web.php
index a37499d..9b7c3ae 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -14,6 +14,11 @@
return Inertia::render('Welcome');
})->name('home');
+// Onboarding Route
+Route::get('/onboarding', function () {
+ return Inertia::render('Onboarding');
+})->name('onboarding');
+
Route::get('dashboard', function () {
return Inertia::render('Dashboard');
})->name('dashboard');
@@ -40,6 +45,21 @@
]);
})->name('api.openai.status');
+// Open external URL in default browser (for NativePHP)
+Route::post('/api/open-external', function (\Illuminate\Http\Request $request) {
+ $url = $request->input('url');
+
+ // Validate URL
+ if (!filter_var($url, FILTER_VALIDATE_URL)) {
+ return response()->json(['error' => 'Invalid URL'], 400);
+ }
+
+ // Use NativePHP Shell to open in default browser
+ \Native\Laravel\Facades\Shell::openExternal($url);
+
+ return response()->json(['success' => true]);
+})->name('api.open-external');
+
// Template Routes
Route::get('/templates', [\App\Http\Controllers\TemplateController::class, 'index'])
->name('templates.index');
diff --git a/tests/Feature/ApiKeyStoreTest.php b/tests/Feature/ApiKeyStoreTest.php
new file mode 100644
index 0000000..393d6e6
--- /dev/null
+++ b/tests/Feature/ApiKeyStoreTest.php
@@ -0,0 +1,146 @@
+app->instance(ApiKeyService::class, $mockApiKeyService);
+
+ $validApiKey = 'sk-1234567890abcdef1234567890abcdef1234567890abcdef';
+
+ $mockApiKeyService->shouldReceive('validateApiKey')
+ ->once()
+ ->with($validApiKey)
+ ->andReturn(true);
+
+ $mockApiKeyService->shouldReceive('setApiKey')
+ ->once()
+ ->with($validApiKey);
+
+ $response = $this->postJson('/api/openai/api-key', [
+ 'api_key' => $validApiKey
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJson([
+ 'success' => true,
+ 'message' => 'API key saved successfully.'
+ ]);
+ }
+
+ public function test_rejects_invalid_api_key(): void
+ {
+ $mockApiKeyService = Mockery::mock(ApiKeyService::class);
+ $this->app->instance(ApiKeyService::class, $mockApiKeyService);
+
+ $invalidApiKey = 'sk-1234567890abcdef1234567890abcdef1234567890invalid';
+
+ $mockApiKeyService->shouldReceive('validateApiKey')
+ ->once()
+ ->with($invalidApiKey)
+ ->andReturn(false);
+
+ $mockApiKeyService->shouldNotReceive('setApiKey');
+
+ $response = $this->postJson('/api/openai/api-key', [
+ 'api_key' => $invalidApiKey
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJson([
+ 'success' => false,
+ 'message' => 'The provided API key is invalid. Please check and try again.'
+ ]);
+ }
+
+ public function test_validates_required_api_key_field(): void
+ {
+ $response = $this->postJson('/api/openai/api-key', []);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['api_key']);
+ }
+
+ public function test_validates_api_key_minimum_length(): void
+ {
+ $response = $this->postJson('/api/openai/api-key', [
+ 'api_key' => 'sk-short'
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['api_key']);
+ }
+
+ public function test_validates_api_key_is_string(): void
+ {
+ $response = $this->postJson('/api/openai/api-key', [
+ 'api_key' => 123456
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['api_key']);
+ }
+
+ public function test_handles_api_key_service_exception(): void
+ {
+ $mockApiKeyService = Mockery::mock(ApiKeyService::class);
+ $this->app->instance(ApiKeyService::class, $mockApiKeyService);
+
+ $validApiKey = 'sk-1234567890abcdef1234567890abcdef1234567890abcdef';
+
+ $mockApiKeyService->shouldReceive('validateApiKey')
+ ->once()
+ ->with($validApiKey)
+ ->andThrow(new \Exception('Service error'));
+
+ $response = $this->postJson('/api/openai/api-key', [
+ 'api_key' => $validApiKey
+ ]);
+
+ // Controller doesn't handle exceptions, so it returns 500
+ $response->assertStatus(500);
+ }
+
+ public function test_api_key_endpoint_accessible_without_existing_api_key(): void
+ {
+ // This test ensures the middleware allows access to the API key store endpoint
+ // even when no API key is configured
+
+ $mockApiKeyService = Mockery::mock(ApiKeyService::class);
+ $this->app->instance(ApiKeyService::class, $mockApiKeyService);
+
+ $validApiKey = 'sk-1234567890abcdef1234567890abcdef1234567890abcdef';
+
+ $mockApiKeyService->shouldReceive('validateApiKey')
+ ->once()
+ ->andReturn(true);
+
+ $mockApiKeyService->shouldReceive('setApiKey')
+ ->once();
+
+ $response = $this->postJson('/api/openai/api-key', [
+ 'api_key' => $validApiKey
+ ]);
+
+ $response->assertStatus(200);
+ }
+}
\ No newline at end of file
diff --git a/tests/Feature/Controllers/ConversationControllerTest.php b/tests/Feature/Controllers/ConversationControllerTest.php
index d7103a4..0764cb6 100644
--- a/tests/Feature/Controllers/ConversationControllerTest.php
+++ b/tests/Feature/Controllers/ConversationControllerTest.php
@@ -3,8 +3,14 @@
use App\Models\ConversationSession;
use App\Models\ConversationTranscript;
use App\Models\ConversationInsight;
+use App\Services\ApiKeyService;
beforeEach(function () {
+ // Mock API key service to return true (API key exists) for all conversation tests
+ $mockApiKeyService = Mockery::mock(ApiKeyService::class);
+ $mockApiKeyService->shouldReceive('hasApiKey')->andReturn(true);
+ $this->app->instance(ApiKeyService::class, $mockApiKeyService);
+
// Create a test conversation session for some tests
$this->session = ConversationSession::create([
'user_id' => null,
diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php
index b649d39..e62dcca 100644
--- a/tests/Feature/DashboardTest.php
+++ b/tests/Feature/DashboardTest.php
@@ -1,11 +1,23 @@
shouldReceive('hasApiKey')->andReturn(true);
+ $this->app->instance(ApiKeyService::class, $mockApiKeyService);
+
$response = $this->get('/dashboard');
$response->assertStatus(200);
});
test('realtime agent page is accessible', function () {
+ // Mock API key service to return true (API key exists)
+ $mockApiKeyService = Mockery::mock(ApiKeyService::class);
+ $mockApiKeyService->shouldReceive('hasApiKey')->andReturn(true);
+ $this->app->instance(ApiKeyService::class, $mockApiKeyService);
+
$response = $this->get('/realtime-agent');
$response->assertStatus(200);
});
diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php
index 8b5843f..4e79405 100644
--- a/tests/Feature/ExampleTest.php
+++ b/tests/Feature/ExampleTest.php
@@ -1,6 +1,13 @@
shouldReceive('hasApiKey')->andReturn(true);
+ $this->app->instance(ApiKeyService::class, $mockApiKeyService);
+
$response = $this->get('/');
$response->assertStatus(200);
diff --git a/tests/Feature/OpenExternalTest.php b/tests/Feature/OpenExternalTest.php
new file mode 100644
index 0000000..5bd076a
--- /dev/null
+++ b/tests/Feature/OpenExternalTest.php
@@ -0,0 +1,179 @@
+once()
+ ->with('https://github.com/vijaythecoder/clueless');
+
+ $response = $this->postJson('/api/open-external', [
+ 'url' => 'https://github.com/vijaythecoder/clueless'
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJson([
+ 'success' => true
+ ]);
+ }
+
+ public function test_opens_openai_url_successfully(): void
+ {
+ Shell::shouldReceive('openExternal')
+ ->once()
+ ->with('https://platform.openai.com/api-keys');
+
+ $response = $this->postJson('/api/open-external', [
+ 'url' => 'https://platform.openai.com/api-keys'
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJson([
+ 'success' => true
+ ]);
+ }
+
+ public function test_rejects_invalid_url(): void
+ {
+ $response = $this->postJson('/api/open-external', [
+ 'url' => 'not-a-valid-url'
+ ]);
+
+ $response->assertStatus(400)
+ ->assertJson([
+ 'error' => 'Invalid URL'
+ ]);
+ }
+
+ public function test_rejects_malicious_urls(): void
+ {
+ $maliciousUrls = [
+ 'javascript:alert("xss")',
+ 'data:text/html,',
+ 'not-a-url'
+ ];
+
+ foreach ($maliciousUrls as $url) {
+ $response = $this->postJson('/api/open-external', [
+ 'url' => $url
+ ]);
+
+ $response->assertStatus(400)
+ ->assertJson([
+ 'error' => 'Invalid URL'
+ ]);
+ }
+ }
+
+ public function test_requires_url_parameter(): void
+ {
+ $response = $this->postJson('/api/open-external', []);
+
+ $response->assertStatus(400);
+ }
+
+ public function test_handles_empty_url(): void
+ {
+ $response = $this->postJson('/api/open-external', [
+ 'url' => ''
+ ]);
+
+ $response->assertStatus(400)
+ ->assertJson([
+ 'error' => 'Invalid URL'
+ ]);
+ }
+
+ public function test_handles_null_url(): void
+ {
+ $response = $this->postJson('/api/open-external', [
+ 'url' => null
+ ]);
+
+ $response->assertStatus(400)
+ ->assertJson([
+ 'error' => 'Invalid URL'
+ ]);
+ }
+
+ public function test_allows_https_urls(): void
+ {
+ $validUrls = [
+ 'https://github.com',
+ 'https://platform.openai.com',
+ 'https://www.example.com',
+ 'https://subdomain.example.com/path?query=value'
+ ];
+
+ foreach ($validUrls as $url) {
+ Shell::shouldReceive('openExternal')
+ ->once()
+ ->with($url);
+
+ $response = $this->postJson('/api/open-external', [
+ 'url' => $url
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJson([
+ 'success' => true
+ ]);
+ }
+ }
+
+ public function test_allows_http_urls(): void
+ {
+ Shell::shouldReceive('openExternal')
+ ->once()
+ ->with('http://example.com');
+
+ $response = $this->postJson('/api/open-external', [
+ 'url' => 'http://example.com'
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJson([
+ 'success' => true
+ ]);
+ }
+
+ public function test_handles_shell_exception(): void
+ {
+ Shell::shouldReceive('openExternal')
+ ->once()
+ ->with('https://github.com')
+ ->andThrow(new \Exception('Shell error'));
+
+ $response = $this->postJson('/api/open-external', [
+ 'url' => 'https://github.com'
+ ]);
+
+ // The route doesn't handle exceptions explicitly, so it would return 500
+ // In a real implementation, you might want to catch and handle this
+ $response->assertStatus(500);
+ }
+}
\ No newline at end of file
diff --git a/tests/Unit/Middleware/CheckOnboardingTest.php b/tests/Unit/Middleware/CheckOnboardingTest.php
new file mode 100644
index 0000000..6663dd5
--- /dev/null
+++ b/tests/Unit/Middleware/CheckOnboardingTest.php
@@ -0,0 +1,144 @@
+mockApiKeyService = Mockery::mock(ApiKeyService::class);
+ $this->middleware = new CheckOnboarding($this->mockApiKeyService);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ public function test_allows_onboarding_route_without_api_key(): void
+ {
+ $request = Request::create('/onboarding', 'GET');
+ $route = new Route(['GET'], '/onboarding', []);
+ $route->name('onboarding');
+ $request->setRouteResolver(fn() => $route);
+
+ $this->mockApiKeyService->shouldNotReceive('hasApiKey');
+
+ $response = $this->middleware->handle($request, function ($req) {
+ return new Response('OK');
+ });
+
+ $this->assertEquals('OK', $response->getContent());
+ }
+
+ public function test_allows_api_key_settings_routes_without_api_key(): void
+ {
+ $excludedRoutes = [
+ 'api-keys.edit',
+ 'api-keys.update',
+ 'api-keys.destroy',
+ 'api.openai.status',
+ 'api.openai.api-key.store',
+ 'appearance'
+ ];
+
+ foreach ($excludedRoutes as $routeName) {
+ $request = Request::create('/test', 'GET');
+ $route = new Route(['GET'], '/test', []);
+ $route->name($routeName);
+ $request->setRouteResolver(fn() => $route);
+
+ $this->mockApiKeyService->shouldNotReceive('hasApiKey');
+
+ $response = $this->middleware->handle($request, function ($req) {
+ return new Response('OK');
+ });
+
+ $this->assertEquals('OK', $response->getContent(), "Route {$routeName} should be excluded");
+ }
+ }
+
+ public function test_allows_api_routes_without_api_key(): void
+ {
+ $request = Request::create('/api/some-endpoint', 'POST');
+ $route = new Route(['POST'], '/api/some-endpoint', []);
+ $route->name('api.some-endpoint');
+ $request->setRouteResolver(fn() => $route);
+
+ $this->mockApiKeyService->shouldNotReceive('hasApiKey');
+
+ $response = $this->middleware->handle($request, function ($req) {
+ return new Response('OK');
+ });
+
+ $this->assertEquals('OK', $response->getContent());
+ }
+
+ public function test_redirects_to_onboarding_when_no_api_key(): void
+ {
+ $request = Request::create('/dashboard', 'GET');
+ $route = new Route(['GET'], '/dashboard', []);
+ $route->name('dashboard');
+ $request->setRouteResolver(fn() => $route);
+
+ $this->mockApiKeyService->shouldReceive('hasApiKey')
+ ->once()
+ ->andReturn(false);
+
+ $response = $this->middleware->handle($request, function ($req) {
+ return new Response('Should not reach here');
+ });
+
+ $this->assertEquals(302, $response->getStatusCode());
+ $this->assertTrue(str_contains($response->headers->get('Location'), '/onboarding'));
+ }
+
+ public function test_allows_access_when_api_key_exists(): void
+ {
+ $request = Request::create('/dashboard', 'GET');
+ $route = new Route(['GET'], '/dashboard', []);
+ $route->name('dashboard');
+ $request->setRouteResolver(fn() => $route);
+
+ $this->mockApiKeyService->shouldReceive('hasApiKey')
+ ->once()
+ ->andReturn(true);
+
+ $response = $this->middleware->handle($request, function ($req) {
+ return new Response('Dashboard content');
+ });
+
+ $this->assertEquals('Dashboard content', $response->getContent());
+ }
+
+ public function test_handles_request_without_route(): void
+ {
+ $request = Request::create('/some-path', 'GET');
+ // No route set - route() returns null
+
+ $this->mockApiKeyService->shouldReceive('hasApiKey')
+ ->once()
+ ->andReturn(false);
+
+ $response = $this->middleware->handle($request, function ($req) {
+ return new Response('OK');
+ });
+
+ // Without a route, middleware allows the request to continue
+ $this->assertEquals(200, $response->getStatusCode());
+ $this->assertEquals('OK', $response->getContent());
+ }
+}
\ No newline at end of file