Skip to content

Commit 0db769d

Browse files
Merge pull request #5 from vijaythecoder/feature/onboarding-flow
Add clean onboarding flow with API key setup and GitHub star request
2 parents 8fd4440 + 873c408 commit 0db769d

File tree

15 files changed

+902
-3
lines changed

15 files changed

+902
-3
lines changed

app/Http/Controllers/Settings/ApiKeyController.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,32 @@ public function destroy(Request $request): RedirectResponse
6666

6767
return redirect()->route('api-keys.edit')->with('success', 'API key deleted successfully.');
6868
}
69+
70+
/**
71+
* Store the OpenAI API key (used by onboarding)
72+
*/
73+
public function store(Request $request)
74+
{
75+
$request->validate([
76+
'api_key' => ['required', 'string', 'min:20'],
77+
]);
78+
79+
$apiKey = $request->input('api_key');
80+
81+
// Validate the API key
82+
if (!$this->apiKeyService->validateApiKey($apiKey)) {
83+
return response()->json([
84+
'success' => false,
85+
'message' => 'The provided API key is invalid. Please check and try again.',
86+
], 422);
87+
}
88+
89+
// Store the API key
90+
$this->apiKeyService->setApiKey($apiKey);
91+
92+
return response()->json([
93+
'success' => true,
94+
'message' => 'API key saved successfully.',
95+
]);
96+
}
6997
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
namespace App\Http\Middleware;
4+
5+
use App\Services\ApiKeyService;
6+
use Closure;
7+
use Illuminate\Http\Request;
8+
use Symfony\Component\HttpFoundation\Response;
9+
10+
class CheckOnboarding
11+
{
12+
protected ApiKeyService $apiKeyService;
13+
14+
public function __construct(ApiKeyService $apiKeyService)
15+
{
16+
$this->apiKeyService = $apiKeyService;
17+
}
18+
19+
/**
20+
* Handle an incoming request.
21+
*
22+
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
23+
*/
24+
public function handle(Request $request, Closure $next): Response
25+
{
26+
// Routes that should be accessible without API key
27+
$excludedRoutes = [
28+
'onboarding',
29+
'api-keys.edit',
30+
'api-keys.update',
31+
'api-keys.destroy',
32+
'api.openai.status',
33+
'api.openai.api-key.store',
34+
'appearance',
35+
];
36+
37+
// Skip check for excluded routes
38+
if ($request->route() && in_array($request->route()->getName(), $excludedRoutes)) {
39+
return $next($request);
40+
}
41+
42+
// Skip if API request (they handle their own errors)
43+
if ($request->is('api/*')) {
44+
return $next($request);
45+
}
46+
47+
// Check if API key exists
48+
if (!$this->apiKeyService->hasApiKey()) {
49+
// If not on onboarding page and no API key, redirect to onboarding
50+
if ($request->route() && $request->route()->getName() !== 'onboarding') {
51+
return redirect()->route('onboarding');
52+
}
53+
}
54+
55+
return $next($request);
56+
}
57+
}

bootstrap/app.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<?php
22

3+
use App\Http\Middleware\CheckOnboarding;
34
use App\Http\Middleware\HandleAppearance;
45
use App\Http\Middleware\HandleInertiaRequests;
56
use Illuminate\Foundation\Application;
@@ -17,6 +18,7 @@
1718
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
1819

1920
$middleware->web(append: [
21+
CheckOnboarding::class,
2022
HandleAppearance::class,
2123
HandleInertiaRequests::class,
2224
AddLinkHeadersForPreloadedAssets::class,

resources/js/pages/Onboarding.vue

Lines changed: 292 additions & 0 deletions
Large diffs are not rendered by default.

resources/js/pages/RealtimeAgent/Main.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2657,7 +2657,7 @@ onMounted(async () => {
26572657
try {
26582658
const response = await axios.get('/api/openai/status');
26592659
hasApiKey.value = response.data.hasApiKey;
2660-
2660+
26612661
if (!hasApiKey.value) {
26622662
// Redirect to settings page if no API key
26632663
window.location.href = '/settings/api-keys';

resources/js/pages/Welcome.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { Head, Link } from '@inertiajs/vue3';
2+
import { Head } from '@inertiajs/vue3';
33
</script>
44

55
<template>

resources/js/pages/settings/ApiKeys.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,10 @@ const deleteApiKey = () => {
124124
</p>
125125
</div>
126126

127-
<div v-if="form.errors.openai_api_key" class="flex items-center gap-2 rounded-md border border-red-500 bg-red-50 p-3 text-sm text-red-800 dark:border-red-700 dark:bg-red-950 dark:text-red-200">
127+
<div
128+
v-if="form.errors.openai_api_key"
129+
class="flex items-center gap-2 rounded-md border border-red-500 bg-red-50 p-3 text-sm text-red-800 dark:border-red-700 dark:bg-red-950 dark:text-red-200"
130+
>
128131
<AlertCircle class="h-4 w-4" />
129132
<span>{{ form.errors.openai_api_key }}</span>
130133
</div>

routes/settings.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
Route::put('settings/api-keys', [ApiKeyController::class, 'update'])->name('api-keys.update');
1010
Route::delete('settings/api-keys', [ApiKeyController::class, 'destroy'])->name('api-keys.destroy');
1111

12+
// API endpoint for saving API key (used by onboarding)
13+
Route::post('/api/openai/api-key', [ApiKeyController::class, 'store'])->name('api.openai.api-key.store');
14+
1215
Route::get('settings/appearance', function () {
1316
return Inertia::render('settings/Appearance');
1417
})->name('appearance');

routes/web.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414
return Inertia::render('Welcome');
1515
})->name('home');
1616

17+
// Onboarding Route
18+
Route::get('/onboarding', function () {
19+
return Inertia::render('Onboarding');
20+
})->name('onboarding');
21+
1722
Route::get('dashboard', function () {
1823
return Inertia::render('Dashboard');
1924
})->name('dashboard');
@@ -40,6 +45,21 @@
4045
]);
4146
})->name('api.openai.status');
4247

48+
// Open external URL in default browser (for NativePHP)
49+
Route::post('/api/open-external', function (\Illuminate\Http\Request $request) {
50+
$url = $request->input('url');
51+
52+
// Validate URL
53+
if (!filter_var($url, FILTER_VALIDATE_URL)) {
54+
return response()->json(['error' => 'Invalid URL'], 400);
55+
}
56+
57+
// Use NativePHP Shell to open in default browser
58+
\Native\Laravel\Facades\Shell::openExternal($url);
59+
60+
return response()->json(['success' => true]);
61+
})->name('api.open-external');
62+
4363
// Template Routes
4464
Route::get('/templates', [\App\Http\Controllers\TemplateController::class, 'index'])
4565
->name('templates.index');

tests/Feature/ApiKeyStoreTest.php

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Services\ApiKeyService;
6+
use Mockery;
7+
use Tests\TestCase;
8+
9+
class ApiKeyStoreTest extends TestCase
10+
{
11+
12+
protected function setUp(): void
13+
{
14+
parent::setUp();
15+
}
16+
17+
protected function tearDown(): void
18+
{
19+
Mockery::close();
20+
parent::tearDown();
21+
}
22+
23+
public function test_stores_valid_api_key_successfully(): void
24+
{
25+
$mockApiKeyService = Mockery::mock(ApiKeyService::class);
26+
$this->app->instance(ApiKeyService::class, $mockApiKeyService);
27+
28+
$validApiKey = 'sk-1234567890abcdef1234567890abcdef1234567890abcdef';
29+
30+
$mockApiKeyService->shouldReceive('validateApiKey')
31+
->once()
32+
->with($validApiKey)
33+
->andReturn(true);
34+
35+
$mockApiKeyService->shouldReceive('setApiKey')
36+
->once()
37+
->with($validApiKey);
38+
39+
$response = $this->postJson('/api/openai/api-key', [
40+
'api_key' => $validApiKey
41+
]);
42+
43+
$response->assertStatus(200)
44+
->assertJson([
45+
'success' => true,
46+
'message' => 'API key saved successfully.'
47+
]);
48+
}
49+
50+
public function test_rejects_invalid_api_key(): void
51+
{
52+
$mockApiKeyService = Mockery::mock(ApiKeyService::class);
53+
$this->app->instance(ApiKeyService::class, $mockApiKeyService);
54+
55+
$invalidApiKey = 'sk-1234567890abcdef1234567890abcdef1234567890invalid';
56+
57+
$mockApiKeyService->shouldReceive('validateApiKey')
58+
->once()
59+
->with($invalidApiKey)
60+
->andReturn(false);
61+
62+
$mockApiKeyService->shouldNotReceive('setApiKey');
63+
64+
$response = $this->postJson('/api/openai/api-key', [
65+
'api_key' => $invalidApiKey
66+
]);
67+
68+
$response->assertStatus(422)
69+
->assertJson([
70+
'success' => false,
71+
'message' => 'The provided API key is invalid. Please check and try again.'
72+
]);
73+
}
74+
75+
public function test_validates_required_api_key_field(): void
76+
{
77+
$response = $this->postJson('/api/openai/api-key', []);
78+
79+
$response->assertStatus(422)
80+
->assertJsonValidationErrors(['api_key']);
81+
}
82+
83+
public function test_validates_api_key_minimum_length(): void
84+
{
85+
$response = $this->postJson('/api/openai/api-key', [
86+
'api_key' => 'sk-short'
87+
]);
88+
89+
$response->assertStatus(422)
90+
->assertJsonValidationErrors(['api_key']);
91+
}
92+
93+
public function test_validates_api_key_is_string(): void
94+
{
95+
$response = $this->postJson('/api/openai/api-key', [
96+
'api_key' => 123456
97+
]);
98+
99+
$response->assertStatus(422)
100+
->assertJsonValidationErrors(['api_key']);
101+
}
102+
103+
public function test_handles_api_key_service_exception(): void
104+
{
105+
$mockApiKeyService = Mockery::mock(ApiKeyService::class);
106+
$this->app->instance(ApiKeyService::class, $mockApiKeyService);
107+
108+
$validApiKey = 'sk-1234567890abcdef1234567890abcdef1234567890abcdef';
109+
110+
$mockApiKeyService->shouldReceive('validateApiKey')
111+
->once()
112+
->with($validApiKey)
113+
->andThrow(new \Exception('Service error'));
114+
115+
$response = $this->postJson('/api/openai/api-key', [
116+
'api_key' => $validApiKey
117+
]);
118+
119+
// Controller doesn't handle exceptions, so it returns 500
120+
$response->assertStatus(500);
121+
}
122+
123+
public function test_api_key_endpoint_accessible_without_existing_api_key(): void
124+
{
125+
// This test ensures the middleware allows access to the API key store endpoint
126+
// even when no API key is configured
127+
128+
$mockApiKeyService = Mockery::mock(ApiKeyService::class);
129+
$this->app->instance(ApiKeyService::class, $mockApiKeyService);
130+
131+
$validApiKey = 'sk-1234567890abcdef1234567890abcdef1234567890abcdef';
132+
133+
$mockApiKeyService->shouldReceive('validateApiKey')
134+
->once()
135+
->andReturn(true);
136+
137+
$mockApiKeyService->shouldReceive('setApiKey')
138+
->once();
139+
140+
$response = $this->postJson('/api/openai/api-key', [
141+
'api_key' => $validApiKey
142+
]);
143+
144+
$response->assertStatus(200);
145+
}
146+
}

0 commit comments

Comments
 (0)