Fix nginx error, startup script, add skip
This commit is contained in:
parent
73ba089e46
commit
387920e82b
14 changed files with 600 additions and 71 deletions
|
|
@ -11,6 +11,7 @@
|
||||||
use App\Models\PlatformAccount;
|
use App\Models\PlatformAccount;
|
||||||
use App\Models\PlatformChannel;
|
use App\Models\PlatformChannel;
|
||||||
use App\Models\PlatformInstance;
|
use App\Models\PlatformInstance;
|
||||||
|
use App\Models\Setting;
|
||||||
use App\Services\Auth\LemmyAuthService;
|
use App\Services\Auth\LemmyAuthService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
@ -32,7 +33,11 @@ public function status(): JsonResponse
|
||||||
$hasFeed = Feed::where('is_active', true)->exists();
|
$hasFeed = Feed::where('is_active', true)->exists();
|
||||||
$hasChannel = PlatformChannel::where('is_active', true)->exists();
|
$hasChannel = PlatformChannel::where('is_active', true)->exists();
|
||||||
|
|
||||||
$needsOnboarding = !$hasPlatformAccount || !$hasFeed || !$hasChannel;
|
// Check if onboarding was explicitly skipped
|
||||||
|
$onboardingSkipped = Setting::where('key', 'onboarding_skipped')->value('value') === 'true';
|
||||||
|
|
||||||
|
// User needs onboarding if they don't have the required components AND haven't skipped it
|
||||||
|
$needsOnboarding = (!$hasPlatformAccount || !$hasFeed || !$hasChannel) && !$onboardingSkipped;
|
||||||
|
|
||||||
// Determine current step
|
// Determine current step
|
||||||
$currentStep = null;
|
$currentStep = null;
|
||||||
|
|
@ -52,6 +57,7 @@ public function status(): JsonResponse
|
||||||
'has_platform_account' => $hasPlatformAccount,
|
'has_platform_account' => $hasPlatformAccount,
|
||||||
'has_feed' => $hasFeed,
|
'has_feed' => $hasFeed,
|
||||||
'has_channel' => $hasChannel,
|
'has_channel' => $hasChannel,
|
||||||
|
'onboarding_skipped' => $onboardingSkipped,
|
||||||
], 'Onboarding status retrieved successfully.');
|
], 'Onboarding status retrieved successfully.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,10 +86,12 @@ public function options(): JsonResponse
|
||||||
public function createPlatform(Request $request): JsonResponse
|
public function createPlatform(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validator = Validator::make($request->all(), [
|
$validator = Validator::make($request->all(), [
|
||||||
'instance_url' => 'required|url|max:255',
|
'instance_url' => 'required|string|max:255|regex:/^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$/',
|
||||||
'username' => 'required|string|max:255',
|
'username' => 'required|string|max:255',
|
||||||
'password' => 'required|string|min:6',
|
'password' => 'required|string|min:6',
|
||||||
'platform' => 'required|in:lemmy',
|
'platform' => 'required|in:lemmy',
|
||||||
|
], [
|
||||||
|
'instance_url.regex' => 'Please enter a valid domain name (e.g., lemmy.world, belgae.social)'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($validator->fails()) {
|
if ($validator->fails()) {
|
||||||
|
|
@ -92,19 +100,23 @@ public function createPlatform(Request $request): JsonResponse
|
||||||
|
|
||||||
$validated = $validator->validated();
|
$validated = $validator->validated();
|
||||||
|
|
||||||
|
// Normalize the instance URL - prepend https:// if needed
|
||||||
|
$instanceDomain = $validated['instance_url'];
|
||||||
|
$fullInstanceUrl = 'https://' . $instanceDomain;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create or get platform instance
|
// Create or get platform instance
|
||||||
$platformInstance = PlatformInstance::firstOrCreate([
|
$platformInstance = PlatformInstance::firstOrCreate([
|
||||||
'url' => $validated['instance_url'],
|
'url' => $fullInstanceUrl,
|
||||||
'platform' => $validated['platform'],
|
'platform' => $validated['platform'],
|
||||||
], [
|
], [
|
||||||
'name' => parse_url($validated['instance_url'], PHP_URL_HOST) ?? 'Lemmy Instance',
|
'name' => ucfirst($instanceDomain),
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Authenticate with Lemmy API
|
// Authenticate with Lemmy API using the full URL
|
||||||
$authResponse = $this->lemmyAuthService->authenticate(
|
$authResponse = $this->lemmyAuthService->authenticate(
|
||||||
$validated['instance_url'],
|
$fullInstanceUrl,
|
||||||
$validated['username'],
|
$validated['username'],
|
||||||
$validated['password']
|
$validated['password']
|
||||||
);
|
);
|
||||||
|
|
@ -112,7 +124,7 @@ public function createPlatform(Request $request): JsonResponse
|
||||||
// Create platform account with the current schema
|
// Create platform account with the current schema
|
||||||
$platformAccount = PlatformAccount::create([
|
$platformAccount = PlatformAccount::create([
|
||||||
'platform' => $validated['platform'],
|
'platform' => $validated['platform'],
|
||||||
'instance_url' => $validated['instance_url'],
|
'instance_url' => $fullInstanceUrl,
|
||||||
'username' => $validated['username'],
|
'username' => $validated['username'],
|
||||||
'password' => $validated['password'],
|
'password' => $validated['password'],
|
||||||
'api_token' => $authResponse['jwt'] ?? null,
|
'api_token' => $authResponse['jwt'] ?? null,
|
||||||
|
|
@ -232,4 +244,33 @@ public function complete(): JsonResponse
|
||||||
'Onboarding completed successfully.'
|
'Onboarding completed successfully.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skip onboarding - user can access the app without completing setup
|
||||||
|
*/
|
||||||
|
public function skip(): JsonResponse
|
||||||
|
{
|
||||||
|
Setting::updateOrCreate(
|
||||||
|
['key' => 'onboarding_skipped'],
|
||||||
|
['value' => 'true']
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
['skipped' => true],
|
||||||
|
'Onboarding skipped successfully.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset onboarding skip status - force user back to onboarding
|
||||||
|
*/
|
||||||
|
public function resetSkip(): JsonResponse
|
||||||
|
{
|
||||||
|
Setting::where('key', 'onboarding_skipped')->delete();
|
||||||
|
|
||||||
|
return $this->sendResponse(
|
||||||
|
['reset' => true],
|
||||||
|
'Onboarding skip status reset successfully.'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -12,10 +12,25 @@ class LemmyRequest
|
||||||
|
|
||||||
public function __construct(string $instance, ?string $token = null)
|
public function __construct(string $instance, ?string $token = null)
|
||||||
{
|
{
|
||||||
$this->instance = $instance;
|
// Handle both full URLs and just domain names
|
||||||
|
$this->instance = $this->normalizeInstance($instance);
|
||||||
$this->token = $token;
|
$this->token = $token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize instance URL to just the domain name
|
||||||
|
*/
|
||||||
|
private function normalizeInstance(string $instance): string
|
||||||
|
{
|
||||||
|
// Remove protocol if present
|
||||||
|
$instance = preg_replace('/^https?:\/\//', '', $instance);
|
||||||
|
|
||||||
|
// Remove trailing slash if present
|
||||||
|
$instance = rtrim($instance, '/');
|
||||||
|
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $params
|
* @param array<string, mixed> $params
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@
|
||||||
Route::post('/onboarding/feed', [OnboardingController::class, 'createFeed'])->name('api.onboarding.feed');
|
Route::post('/onboarding/feed', [OnboardingController::class, 'createFeed'])->name('api.onboarding.feed');
|
||||||
Route::post('/onboarding/channel', [OnboardingController::class, 'createChannel'])->name('api.onboarding.channel');
|
Route::post('/onboarding/channel', [OnboardingController::class, 'createChannel'])->name('api.onboarding.channel');
|
||||||
Route::post('/onboarding/complete', [OnboardingController::class, 'complete'])->name('api.onboarding.complete');
|
Route::post('/onboarding/complete', [OnboardingController::class, 'complete'])->name('api.onboarding.complete');
|
||||||
|
Route::post('/onboarding/skip', [OnboardingController::class, 'skip'])->name('api.onboarding.skip');
|
||||||
|
Route::post('/onboarding/reset-skip', [OnboardingController::class, 'resetSkip'])->name('api.onboarding.reset-skip');
|
||||||
|
|
||||||
// Dashboard stats
|
// Dashboard stats
|
||||||
Route::get('/dashboard/stats', [DashboardController::class, 'stats'])->name('api.dashboard.stats');
|
Route::get('/dashboard/stats', [DashboardController::class, 'stats'])->name('api.dashboard.stats');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,364 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Models\Language;
|
||||||
|
use App\Models\PlatformAccount;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
|
use App\Models\PlatformInstance;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Services\Auth\LemmyAuthService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class OnboardingControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// Create a language for testing
|
||||||
|
Language::factory()->create([
|
||||||
|
'id' => 1,
|
||||||
|
'short_code' => 'en',
|
||||||
|
'name' => 'English',
|
||||||
|
'native_name' => 'English',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_status_shows_needs_onboarding_when_no_components_exist()
|
||||||
|
{
|
||||||
|
$response = $this->getJson('/api/v1/onboarding/status');
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJson([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'needs_onboarding' => true,
|
||||||
|
'current_step' => 'platform',
|
||||||
|
'has_platform_account' => false,
|
||||||
|
'has_feed' => false,
|
||||||
|
'has_channel' => false,
|
||||||
|
'onboarding_skipped' => false,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_status_shows_feed_step_when_platform_account_exists()
|
||||||
|
{
|
||||||
|
PlatformAccount::factory()->create(['is_active' => true]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/v1/onboarding/status');
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJson([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'needs_onboarding' => true,
|
||||||
|
'current_step' => 'feed',
|
||||||
|
'has_platform_account' => true,
|
||||||
|
'has_feed' => false,
|
||||||
|
'has_channel' => false,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_status_shows_channel_step_when_platform_account_and_feed_exist()
|
||||||
|
{
|
||||||
|
PlatformAccount::factory()->create(['is_active' => true]);
|
||||||
|
Feed::factory()->create(['is_active' => true]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/v1/onboarding/status');
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJson([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'needs_onboarding' => true,
|
||||||
|
'current_step' => 'channel',
|
||||||
|
'has_platform_account' => true,
|
||||||
|
'has_feed' => true,
|
||||||
|
'has_channel' => false,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_status_shows_no_onboarding_needed_when_all_components_exist()
|
||||||
|
{
|
||||||
|
PlatformAccount::factory()->create(['is_active' => true]);
|
||||||
|
Feed::factory()->create(['is_active' => true]);
|
||||||
|
PlatformChannel::factory()->create(['is_active' => true]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/v1/onboarding/status');
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJson([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'needs_onboarding' => false,
|
||||||
|
'current_step' => null,
|
||||||
|
'has_platform_account' => true,
|
||||||
|
'has_feed' => true,
|
||||||
|
'has_channel' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_status_shows_no_onboarding_needed_when_skipped()
|
||||||
|
{
|
||||||
|
// No components exist but onboarding is skipped
|
||||||
|
Setting::create([
|
||||||
|
'key' => 'onboarding_skipped',
|
||||||
|
'value' => 'true',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/v1/onboarding/status');
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJson([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'needs_onboarding' => false,
|
||||||
|
'current_step' => null,
|
||||||
|
'has_platform_account' => false,
|
||||||
|
'has_feed' => false,
|
||||||
|
'has_channel' => false,
|
||||||
|
'onboarding_skipped' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_options_returns_languages_and_platform_instances()
|
||||||
|
{
|
||||||
|
PlatformInstance::factory()->create([
|
||||||
|
'platform' => 'lemmy',
|
||||||
|
'url' => 'https://lemmy.world',
|
||||||
|
'name' => 'Lemmy World',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/v1/onboarding/options');
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJsonStructure([
|
||||||
|
'success',
|
||||||
|
'data' => [
|
||||||
|
'languages' => [
|
||||||
|
'*' => ['id', 'short_code', 'name', 'native_name', 'is_active']
|
||||||
|
],
|
||||||
|
'platform_instances' => [
|
||||||
|
'*' => ['id', 'platform', 'url', 'name', 'description', 'is_active']
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_feed_validates_required_fields()
|
||||||
|
{
|
||||||
|
$response = $this->postJson('/api/v1/onboarding/feed', []);
|
||||||
|
|
||||||
|
$response->assertStatus(422)
|
||||||
|
->assertJsonValidationErrors(['name', 'url', 'type', 'language_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_feed_creates_feed_successfully()
|
||||||
|
{
|
||||||
|
$feedData = [
|
||||||
|
'name' => 'Test Feed',
|
||||||
|
'url' => 'https://example.com/rss',
|
||||||
|
'type' => 'rss',
|
||||||
|
'language_id' => 1,
|
||||||
|
'description' => 'Test description',
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v1/onboarding/feed', $feedData);
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJson([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'name' => 'Test Feed',
|
||||||
|
'url' => 'https://example.com/rss',
|
||||||
|
'type' => 'rss',
|
||||||
|
'is_active' => true,
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('feeds', [
|
||||||
|
'name' => 'Test Feed',
|
||||||
|
'url' => 'https://example.com/rss',
|
||||||
|
'type' => 'rss',
|
||||||
|
'language_id' => 1,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_channel_validates_required_fields()
|
||||||
|
{
|
||||||
|
$response = $this->postJson('/api/v1/onboarding/channel', []);
|
||||||
|
|
||||||
|
$response->assertStatus(422)
|
||||||
|
->assertJsonValidationErrors(['name', 'platform_instance_id', 'language_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_channel_creates_channel_successfully()
|
||||||
|
{
|
||||||
|
$platformInstance = PlatformInstance::factory()->create();
|
||||||
|
|
||||||
|
$channelData = [
|
||||||
|
'name' => 'test_community',
|
||||||
|
'platform_instance_id' => $platformInstance->id,
|
||||||
|
'language_id' => 1,
|
||||||
|
'description' => 'Test community description',
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v1/onboarding/channel', $channelData);
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJson([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'name' => 'test_community',
|
||||||
|
'display_name' => 'Test_community',
|
||||||
|
'channel_id' => 'test_community',
|
||||||
|
'is_active' => true,
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('platform_channels', [
|
||||||
|
'name' => 'test_community',
|
||||||
|
'channel_id' => 'test_community',
|
||||||
|
'platform_instance_id' => $platformInstance->id,
|
||||||
|
'language_id' => 1,
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_complete_onboarding_returns_success()
|
||||||
|
{
|
||||||
|
$response = $this->postJson('/api/v1/onboarding/complete');
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJson([
|
||||||
|
'success' => true,
|
||||||
|
'data' => ['completed' => true]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_skip_onboarding_creates_setting()
|
||||||
|
{
|
||||||
|
$response = $this->postJson('/api/v1/onboarding/skip');
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJson([
|
||||||
|
'success' => true,
|
||||||
|
'data' => ['skipped' => true]
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('settings', [
|
||||||
|
'key' => 'onboarding_skipped',
|
||||||
|
'value' => 'true',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_skip_onboarding_updates_existing_setting()
|
||||||
|
{
|
||||||
|
// Create existing setting with false value
|
||||||
|
Setting::create([
|
||||||
|
'key' => 'onboarding_skipped',
|
||||||
|
'value' => 'false',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v1/onboarding/skip');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('settings', [
|
||||||
|
'key' => 'onboarding_skipped',
|
||||||
|
'value' => 'true',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Ensure only one setting exists
|
||||||
|
$this->assertEquals(1, Setting::where('key', 'onboarding_skipped')->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_reset_skip_removes_setting()
|
||||||
|
{
|
||||||
|
// Create skipped setting
|
||||||
|
Setting::create([
|
||||||
|
'key' => 'onboarding_skipped',
|
||||||
|
'value' => 'true',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v1/onboarding/reset-skip');
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJson([
|
||||||
|
'success' => true,
|
||||||
|
'data' => ['reset' => true]
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('settings', [
|
||||||
|
'key' => 'onboarding_skipped',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_reset_skip_works_when_no_setting_exists()
|
||||||
|
{
|
||||||
|
$response = $this->postJson('/api/v1/onboarding/reset-skip');
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJson([
|
||||||
|
'success' => true,
|
||||||
|
'data' => ['reset' => true]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_platform_validates_instance_url_format()
|
||||||
|
{
|
||||||
|
$response = $this->postJson('/api/v1/onboarding/platform', [
|
||||||
|
'instance_url' => 'invalid.domain.with.spaces and symbols!',
|
||||||
|
'username' => 'testuser',
|
||||||
|
'password' => 'password123',
|
||||||
|
'platform' => 'lemmy',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(422)
|
||||||
|
->assertJsonValidationErrors(['instance_url']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_platform_validates_required_fields()
|
||||||
|
{
|
||||||
|
$response = $this->postJson('/api/v1/onboarding/platform', []);
|
||||||
|
|
||||||
|
$response->assertStatus(422)
|
||||||
|
->assertJsonValidationErrors(['instance_url', 'username', 'password', 'platform']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_onboarding_flow_integration()
|
||||||
|
{
|
||||||
|
// 1. Initial status - needs onboarding
|
||||||
|
$response = $this->getJson('/api/v1/onboarding/status');
|
||||||
|
$response->assertJson(['data' => ['needs_onboarding' => true, 'current_step' => 'platform']]);
|
||||||
|
|
||||||
|
// 2. Skip onboarding
|
||||||
|
$response = $this->postJson('/api/v1/onboarding/skip');
|
||||||
|
$response->assertJson(['data' => ['skipped' => true]]);
|
||||||
|
|
||||||
|
// 3. Status after skip - no longer needs onboarding
|
||||||
|
$response = $this->getJson('/api/v1/onboarding/status');
|
||||||
|
$response->assertJson(['data' => ['needs_onboarding' => false, 'onboarding_skipped' => true]]);
|
||||||
|
|
||||||
|
// 4. Reset skip
|
||||||
|
$response = $this->postJson('/api/v1/onboarding/reset-skip');
|
||||||
|
$response->assertJson(['data' => ['reset' => true]]);
|
||||||
|
|
||||||
|
// 5. Status after reset - needs onboarding again
|
||||||
|
$response = $this->getJson('/api/v1/onboarding/status');
|
||||||
|
$response->assertJson(['data' => ['needs_onboarding' => true, 'onboarding_skipped' => false]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
APP_NAME="FFR Development"
|
APP_NAME="FFR Development"
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=base64:YOUR_APP_KEY_HERE
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_TIMEZONE=UTC
|
APP_TIMEZONE=UTC
|
||||||
APP_URL=http://localhost:8000
|
APP_URL=http://localhost:8000
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,17 @@
|
||||||
# Copy development environment configuration to backend
|
# Copy development environment configuration to backend
|
||||||
cp /var/www/html/docker/dev/podman/.env.dev /var/www/html/backend/.env
|
cp /var/www/html/docker/dev/podman/.env.dev /var/www/html/backend/.env
|
||||||
|
|
||||||
# Setup nginx configuration
|
# Setup nginx configuration for development
|
||||||
cp /var/www/html/docker/nginx.conf /etc/nginx/sites-available/default
|
cp /var/www/html/docker/dev/podman/nginx.conf /etc/nginx/sites-available/default
|
||||||
|
|
||||||
# Install/update dependencies
|
# Install/update dependencies
|
||||||
echo "Installing PHP dependencies..."
|
echo "Installing PHP dependencies..."
|
||||||
cd /var/www/html/backend
|
cd /var/www/html/backend
|
||||||
composer install --no-interaction
|
composer install --no-interaction
|
||||||
|
|
||||||
# Generate app key if not set or empty
|
# Generate application key
|
||||||
if grep -q "APP_KEY=base64:YOUR_APP_KEY_HERE" /var/www/html/backend/.env || ! grep -q "APP_KEY=base64:" /var/www/html/backend/.env; then
|
|
||||||
echo "Generating application key..."
|
echo "Generating application key..."
|
||||||
php artisan key:generate --force
|
php artisan key:generate --force
|
||||||
fi
|
|
||||||
|
|
||||||
# Wait for database to be ready
|
# Wait for database to be ready
|
||||||
echo "Waiting for database..."
|
echo "Waiting for database..."
|
||||||
|
|
@ -39,6 +37,10 @@ fi
|
||||||
# Start services
|
# Start services
|
||||||
echo "Starting services..."
|
echo "Starting services..."
|
||||||
|
|
||||||
|
# Start React dev server
|
||||||
|
cd /var/www/html/frontend
|
||||||
|
npm run dev -- --host 0.0.0.0 --port 5173 &
|
||||||
|
|
||||||
# Start Laravel backend
|
# Start Laravel backend
|
||||||
cd /var/www/html/backend
|
cd /var/www/html/backend
|
||||||
php artisan serve --host=127.0.0.1 --port=8000 &
|
php artisan serve --host=127.0.0.1 --port=8000 &
|
||||||
|
|
|
||||||
87
docker/dev/podman/nginx.conf
Normal file
87
docker/dev/podman/nginx.conf
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# Proxy API requests to Laravel backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_redirect off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Serve Laravel public assets (images, etc.)
|
||||||
|
location /images/ {
|
||||||
|
alias /var/www/html/backend/public/images/;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy Vite dev server assets
|
||||||
|
location /@vite/ {
|
||||||
|
proxy_pass http://127.0.0.1:5173;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_redirect off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy Vite HMR WebSocket
|
||||||
|
location /@vite/client {
|
||||||
|
proxy_pass http://127.0.0.1:5173;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_redirect off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy node_modules for Vite deps
|
||||||
|
location /node_modules/ {
|
||||||
|
proxy_pass http://127.0.0.1:5173;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_redirect off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy /src/ for Vite source files
|
||||||
|
location /src/ {
|
||||||
|
proxy_pass http://127.0.0.1:5173;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_redirect off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy React dev server for development (catch-all)
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:5173;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# WebSocket support for HMR
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_redirect off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
add_header X-Frame-Options DENY;
|
||||||
|
add_header X-XSS-Protection "1; mode=block";
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||||
|
}
|
||||||
|
|
@ -10,14 +10,12 @@ echo "🚀 Starting FFR development environment with Podman..."
|
||||||
if [ ! -f .env ]; then
|
if [ ! -f .env ]; then
|
||||||
echo "📋 Creating .env file from .env.example..."
|
echo "📋 Creating .env file from .env.example..."
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
echo "⚠️ Please update your .env file with appropriate values, especially APP_KEY"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if podman-compose is available
|
# Check if podman-compose is available
|
||||||
if ! command -v podman-compose &> /dev/null; then
|
if ! command -v podman-compose &> /dev/null; then
|
||||||
echo "❌ podman-compose not found. Installing..."
|
echo "❌ podman-compose not found."
|
||||||
pip3 install --user podman-compose
|
exit
|
||||||
echo "✅ podman-compose installed"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Start services
|
# Start services
|
||||||
|
|
@ -28,22 +26,32 @@ podman-compose -f docker/dev/podman/docker-compose.yml up -d
|
||||||
echo "⏳ Waiting for database to be ready..."
|
echo "⏳ Waiting for database to be ready..."
|
||||||
sleep 10
|
sleep 10
|
||||||
|
|
||||||
# Check if APP_KEY is set
|
# Install/update dependencies if needed
|
||||||
if grep -q "APP_KEY=base64:YOUR_APP_KEY_HERE" .env || grep -q "APP_KEY=$" .env; then
|
echo "📦 Installing dependencies..."
|
||||||
echo "🔑 Generating application key..."
|
podman exec ffr-dev-app bash -c "cd /var/www/html/backend && composer install"
|
||||||
podman exec ffr-dev-app php artisan key:generate
|
podman exec ffr-dev-app bash -c "cd /var/www/html/frontend && npm install"
|
||||||
fi
|
|
||||||
|
|
||||||
# Run migrations and seeders
|
# Run migrations and seeders
|
||||||
echo "🗃️ Running database migrations..."
|
echo "🗃️ Running database migrations..."
|
||||||
podman exec ffr-dev-app php artisan migrate --force
|
podman exec ffr-dev-app bash -c "cd /var/www/html/backend && php artisan migrate --force"
|
||||||
echo "🌱 Running database seeders..."
|
echo "🌱 Running database seeders..."
|
||||||
podman exec ffr-dev-app php artisan db:seed --force
|
podman exec ffr-dev-app bash -c "cd /var/www/html/backend && php artisan db:seed --force"
|
||||||
|
|
||||||
# Install/update dependencies if needed
|
# Wait for container services to be fully ready
|
||||||
echo "📦 Installing dependencies..."
|
echo "⏳ Waiting for container services to initialize..."
|
||||||
podman exec ffr-dev-app composer install
|
sleep 5
|
||||||
podman exec ffr-dev-app npm install
|
|
||||||
|
# Start React dev server if not already running
|
||||||
|
echo "🚀 Starting React dev server..."
|
||||||
|
podman exec -d ffr-dev-app bash -c "cd /var/www/html/frontend && npm run dev -- --host 0.0.0.0 --port 5173 > /dev/null 2>&1 &"
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# Verify Vite is running
|
||||||
|
if podman exec ffr-dev-app bash -c "curl -s http://localhost:5173 > /dev/null 2>&1"; then
|
||||||
|
echo "✅ Vite dev server is running"
|
||||||
|
else
|
||||||
|
echo "⚠️ Vite dev server may not have started properly"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "✅ Development environment is ready!"
|
echo "✅ Development environment is ready!"
|
||||||
echo "🌐 Application: http://localhost:8000"
|
echo "🌐 Application: http://localhost:8000"
|
||||||
|
|
|
||||||
|
|
@ -6,22 +6,8 @@ import Articles from './pages/Articles';
|
||||||
import Feeds from './pages/Feeds';
|
import Feeds from './pages/Feeds';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
import OnboardingWizard from './pages/onboarding/OnboardingWizard';
|
import OnboardingWizard from './pages/onboarding/OnboardingWizard';
|
||||||
import { OnboardingProvider, useOnboarding } from './contexts/OnboardingContext';
|
|
||||||
|
|
||||||
const AppContent: React.FC = () => {
|
|
||||||
const { isLoading } = useOnboarding();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|
||||||
<p className="text-gray-600">Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const App: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Onboarding routes - outside of main layout */}
|
{/* Onboarding routes - outside of main layout */}
|
||||||
|
|
@ -44,12 +30,4 @@ const AppContent: React.FC = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const App: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<OnboardingProvider>
|
|
||||||
<AppContent />
|
|
||||||
</OnboardingProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,8 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||||
<h1 className="text-lg font-medium text-gray-900">FFR</h1>
|
<h1 className="text-lg font-medium text-gray-900">FFR</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -28,22 +28,17 @@ export const OnboardingProvider: React.FC<OnboardingProviderProps> = ({ children
|
||||||
const needsOnboarding = onboardingStatus?.needs_onboarding ?? false;
|
const needsOnboarding = onboardingStatus?.needs_onboarding ?? false;
|
||||||
const isOnOnboardingPage = location.pathname.startsWith('/onboarding');
|
const isOnOnboardingPage = location.pathname.startsWith('/onboarding');
|
||||||
|
|
||||||
// Redirect logic
|
// Redirect logic - only redirect if user explicitly navigates to a protected route
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isLoading) return;
|
if (isLoading) return;
|
||||||
|
|
||||||
if (needsOnboarding && !isOnOnboardingPage) {
|
// Only redirect if user doesn't need onboarding but is on onboarding pages
|
||||||
// User needs onboarding but is not on onboarding pages
|
if (!needsOnboarding && isOnOnboardingPage) {
|
||||||
const targetStep = onboardingStatus?.current_step;
|
|
||||||
if (targetStep) {
|
|
||||||
navigate(`/onboarding/${targetStep}`, { replace: true });
|
|
||||||
} else {
|
|
||||||
navigate('/onboarding', { replace: true });
|
|
||||||
}
|
|
||||||
} else if (!needsOnboarding && isOnOnboardingPage) {
|
|
||||||
// User doesn't need onboarding but is on onboarding pages
|
|
||||||
navigate('/dashboard', { replace: true });
|
navigate('/dashboard', { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't auto-redirect to onboarding - let user navigate manually or via links
|
||||||
|
// This prevents the app from being "stuck" in onboarding mode
|
||||||
}, [onboardingStatus, isLoading, needsOnboarding, isOnOnboardingPage, navigate]);
|
}, [onboardingStatus, isLoading, needsOnboarding, isOnOnboardingPage, navigate]);
|
||||||
|
|
||||||
const value: OnboardingContextValue = {
|
const value: OnboardingContextValue = {
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,7 @@ export interface OnboardingStatus {
|
||||||
has_platform_account: boolean;
|
has_platform_account: boolean;
|
||||||
has_feed: boolean;
|
has_feed: boolean;
|
||||||
has_channel: boolean;
|
has_channel: boolean;
|
||||||
|
onboarding_skipped: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OnboardingOptions {
|
export interface OnboardingOptions {
|
||||||
|
|
@ -288,6 +289,14 @@ class ApiClient {
|
||||||
async completeOnboarding(): Promise<void> {
|
async completeOnboarding(): Promise<void> {
|
||||||
await axios.post('/onboarding/complete');
|
await axios.post('/onboarding/complete');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async skipOnboarding(): Promise<void> {
|
||||||
|
await axios.post('/onboarding/skip');
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetOnboardingSkip(): Promise<void> {
|
||||||
|
await axios.post('/onboarding/reset-skip');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = new ApiClient();
|
export const apiClient = new ApiClient();
|
||||||
|
|
@ -65,17 +65,18 @@ const PlatformStep: React.FC = () => {
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="instance_url" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="instance_url" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Lemmy Instance URL
|
Lemmy Instance Domain
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="text"
|
||||||
id="instance_url"
|
id="instance_url"
|
||||||
value={formData.instance_url}
|
value={formData.instance_url}
|
||||||
onChange={(e) => handleChange('instance_url', e.target.value)}
|
onChange={(e) => handleChange('instance_url', e.target.value)}
|
||||||
placeholder="https://lemmy.world"
|
placeholder="lemmy.world"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Enter just the domain name (e.g., lemmy.world, belgae.social)</p>
|
||||||
{errors.instance_url && (
|
{errors.instance_url && (
|
||||||
<p className="text-red-600 text-sm mt-1">{errors.instance_url[0]}</p>
|
<p className="text-red-600 text-sm mt-1">{errors.instance_url[0]}</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,24 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '../../../lib/api';
|
||||||
|
|
||||||
const WelcomeStep: React.FC = () => {
|
const WelcomeStep: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const skipMutation = useMutation({
|
||||||
|
mutationFn: () => apiClient.skipOnboarding(),
|
||||||
|
onSuccess: () => {
|
||||||
|
navigate('/dashboard');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSkip = () => {
|
||||||
|
if (confirm('Are you sure you want to skip the setup? You can configure FFR later from the settings page.')) {
|
||||||
|
skipMutation.mutate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Welcome to FFR</h1>
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">Welcome to FFR</h1>
|
||||||
|
|
@ -28,13 +45,21 @@ const WelcomeStep: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8 space-y-3">
|
||||||
<Link
|
<Link
|
||||||
to="/onboarding/platform"
|
to="/onboarding/platform"
|
||||||
className="w-full bg-blue-600 text-white py-3 px-4 rounded-md hover:bg-blue-700 transition duration-200 inline-block"
|
className="w-full bg-blue-600 text-white py-3 px-4 rounded-md hover:bg-blue-700 transition duration-200 inline-block"
|
||||||
>
|
>
|
||||||
Get Started
|
Get Started
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSkip}
|
||||||
|
disabled={skipMutation.isPending}
|
||||||
|
className="w-full text-gray-500 hover:text-gray-700 py-2 px-4 text-sm transition duration-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{skipMutation.isPending ? 'Skipping...' : 'Skip for now'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue