From 3a9ab87a6d64c4607a7f75e6c30d7cf98e04120e Mon Sep 17 00:00:00 2001
From: myrmidex
Date: Thu, 7 Aug 2025 21:28:47 +0200
Subject: [PATCH 01/48] Fix logo path
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 577b087..d8fed45 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# Fedi Feed Router
-

+
`ffr` is a self-hosted tool for routing content from RSS/Atom feeds to the fediverse.
--
2.45.2
From c17a858e639351e0690f2fecac73f209f43c8a73 Mon Sep 17 00:00:00 2001
From: myrmidex
Date: Fri, 8 Aug 2025 21:54:22 +0200
Subject: [PATCH 02/48] Remove previous onboarding implementation
---
.../Services/OnboardingRedirectService.php | 20 -------------------
1 file changed, 20 deletions(-)
delete mode 100644 backend/app/Services/OnboardingRedirectService.php
diff --git a/backend/app/Services/OnboardingRedirectService.php b/backend/app/Services/OnboardingRedirectService.php
deleted file mode 100644
index 6382fbc..0000000
--- a/backend/app/Services/OnboardingRedirectService.php
+++ /dev/null
@@ -1,20 +0,0 @@
-input('redirect_to');
-
- if ($redirectTo) {
- return redirect($redirectTo)->with('success', $successMessage);
- }
-
- return redirect()->route($defaultRoute)->with('success', $successMessage);
- }
-}
\ No newline at end of file
--
2.45.2
From 17320ad05a441790c819f0ed3518d53dc7aa9774 Mon Sep 17 00:00:00 2001
From: myrmidex
Date: Sat, 9 Aug 2025 00:03:45 +0200
Subject: [PATCH 03/48] Add onboarding
---
.../Api/V1/OnboardingController.php | 222 ++++++++++++++++++
.../app/Services/Auth/LemmyAuthService.php | 33 +++
backend/routes/api.php | 33 +--
frontend/src/App.tsx | 51 +++-
frontend/src/contexts/OnboardingContext.tsx | 68 ++++++
frontend/src/lib/api.ts | 83 +++++++
.../src/pages/onboarding/OnboardingLayout.tsx | 17 ++
.../src/pages/onboarding/OnboardingWizard.tsx | 25 ++
.../pages/onboarding/steps/ChannelStep.tsx | 178 ++++++++++++++
.../pages/onboarding/steps/CompleteStep.tsx | 86 +++++++
.../src/pages/onboarding/steps/FeedStep.tsx | 193 +++++++++++++++
.../pages/onboarding/steps/PlatformStep.tsx | 138 +++++++++++
.../pages/onboarding/steps/WelcomeStep.tsx | 43 ++++
13 files changed, 1145 insertions(+), 25 deletions(-)
create mode 100644 backend/app/Http/Controllers/Api/V1/OnboardingController.php
create mode 100644 frontend/src/contexts/OnboardingContext.tsx
create mode 100644 frontend/src/pages/onboarding/OnboardingLayout.tsx
create mode 100644 frontend/src/pages/onboarding/OnboardingWizard.tsx
create mode 100644 frontend/src/pages/onboarding/steps/ChannelStep.tsx
create mode 100644 frontend/src/pages/onboarding/steps/CompleteStep.tsx
create mode 100644 frontend/src/pages/onboarding/steps/FeedStep.tsx
create mode 100644 frontend/src/pages/onboarding/steps/PlatformStep.tsx
create mode 100644 frontend/src/pages/onboarding/steps/WelcomeStep.tsx
diff --git a/backend/app/Http/Controllers/Api/V1/OnboardingController.php b/backend/app/Http/Controllers/Api/V1/OnboardingController.php
new file mode 100644
index 0000000..a1c3b1b
--- /dev/null
+++ b/backend/app/Http/Controllers/Api/V1/OnboardingController.php
@@ -0,0 +1,222 @@
+exists();
+ $hasFeed = Feed::where('is_active', true)->exists();
+ $hasChannel = PlatformChannel::where('is_active', true)->exists();
+
+ $needsOnboarding = !$hasPlatformAccount || !$hasFeed || !$hasChannel;
+
+ // Determine current step
+ $currentStep = null;
+ if ($needsOnboarding) {
+ if (!$hasPlatformAccount) {
+ $currentStep = 'platform';
+ } elseif (!$hasFeed) {
+ $currentStep = 'feed';
+ } elseif (!$hasChannel) {
+ $currentStep = 'channel';
+ }
+ }
+
+ return $this->sendResponse([
+ 'needs_onboarding' => $needsOnboarding,
+ 'current_step' => $currentStep,
+ 'has_platform_account' => $hasPlatformAccount,
+ 'has_feed' => $hasFeed,
+ 'has_channel' => $hasChannel,
+ ], 'Onboarding status retrieved successfully.');
+ }
+
+ /**
+ * Get onboarding options (languages, platform instances)
+ */
+ public function options(): JsonResponse
+ {
+ $languages = Language::where('is_active', true)
+ ->orderBy('name')
+ ->get(['id', 'short_code', 'name', 'native_name', 'is_active']);
+
+ $platformInstances = PlatformInstance::where('is_active', true)
+ ->orderBy('name')
+ ->get(['id', 'platform', 'url', 'name', 'description', 'is_active']);
+
+ return $this->sendResponse([
+ 'languages' => $languages,
+ 'platform_instances' => $platformInstances,
+ ], 'Onboarding options retrieved successfully.');
+ }
+
+ /**
+ * Create platform account for onboarding
+ */
+ public function createPlatform(Request $request): JsonResponse
+ {
+ $validator = Validator::make($request->all(), [
+ 'instance_url' => 'required|url|max:255',
+ 'username' => 'required|string|max:255',
+ 'password' => 'required|string|min:6',
+ 'platform' => 'required|in:lemmy',
+ ]);
+
+ if ($validator->fails()) {
+ throw new ValidationException($validator);
+ }
+
+ $validated = $validator->validated();
+
+ try {
+ // Create or get platform instance
+ $platformInstance = PlatformInstance::firstOrCreate([
+ 'url' => $validated['instance_url'],
+ 'platform' => $validated['platform'],
+ ], [
+ 'name' => parse_url($validated['instance_url'], PHP_URL_HOST) ?? 'Lemmy Instance',
+ 'is_active' => true,
+ ]);
+
+ // Authenticate with Lemmy API
+ $authResponse = $this->lemmyAuthService->authenticate(
+ $validated['instance_url'],
+ $validated['username'],
+ $validated['password']
+ );
+
+ // Create platform account with the current schema
+ $platformAccount = PlatformAccount::create([
+ 'platform' => $validated['platform'],
+ 'instance_url' => $validated['instance_url'],
+ 'username' => $validated['username'],
+ 'password' => $validated['password'],
+ 'api_token' => $authResponse['jwt'] ?? null,
+ 'settings' => [
+ 'display_name' => $authResponse['person_view']['person']['display_name'] ?? null,
+ 'description' => $authResponse['person_view']['person']['bio'] ?? null,
+ 'person_id' => $authResponse['person_view']['person']['id'] ?? null,
+ 'platform_instance_id' => $platformInstance->id,
+ ],
+ 'is_active' => true,
+ 'status' => 'active',
+ ]);
+
+ return $this->sendResponse(
+ new PlatformAccountResource($platformAccount),
+ 'Platform account created successfully.'
+ );
+
+ } catch (\Exception $e) {
+ return $this->sendError('Failed to create platform account: ' . $e->getMessage(), [], 422);
+ }
+ }
+
+ /**
+ * Create feed for onboarding
+ */
+ public function createFeed(Request $request): JsonResponse
+ {
+ $validator = Validator::make($request->all(), [
+ 'name' => 'required|string|max:255',
+ 'url' => 'required|url|max:500',
+ 'type' => 'required|in:website,rss',
+ 'language_id' => 'required|exists:languages,id',
+ 'description' => 'nullable|string|max:1000',
+ ]);
+
+ if ($validator->fails()) {
+ throw new ValidationException($validator);
+ }
+
+ $validated = $validator->validated();
+
+ $feed = Feed::create([
+ 'name' => $validated['name'],
+ 'url' => $validated['url'],
+ 'type' => $validated['type'],
+ 'language_id' => $validated['language_id'],
+ 'description' => $validated['description'] ?? null,
+ 'is_active' => true,
+ ]);
+
+ return $this->sendResponse(
+ new FeedResource($feed->load('language')),
+ 'Feed created successfully.'
+ );
+ }
+
+ /**
+ * Create channel for onboarding
+ */
+ public function createChannel(Request $request): JsonResponse
+ {
+ $validator = Validator::make($request->all(), [
+ 'name' => 'required|string|max:255',
+ 'platform_instance_id' => 'required|exists:platform_instances,id',
+ 'language_id' => 'required|exists:languages,id',
+ 'description' => 'nullable|string|max:1000',
+ ]);
+
+ if ($validator->fails()) {
+ throw new ValidationException($validator);
+ }
+
+ $validated = $validator->validated();
+
+ $channel = PlatformChannel::create([
+ 'platform_instance_id' => $validated['platform_instance_id'],
+ 'channel_id' => $validated['name'], // For Lemmy, this is the community name
+ 'name' => $validated['name'],
+ 'display_name' => ucfirst($validated['name']),
+ 'description' => $validated['description'] ?? null,
+ 'language_id' => $validated['language_id'],
+ 'is_active' => true,
+ ]);
+
+ return $this->sendResponse(
+ new PlatformChannelResource($channel->load(['platformInstance', 'language'])),
+ 'Channel created successfully.'
+ );
+ }
+
+ /**
+ * Mark onboarding as complete
+ */
+ public function complete(): JsonResponse
+ {
+ // In a real implementation, you might want to update a user preference
+ // or create a setting that tracks onboarding completion
+ // For now, we'll just return success since the onboarding status
+ // is determined by the existence of platform accounts, feeds, and channels
+
+ return $this->sendResponse(
+ ['completed' => true],
+ 'Onboarding completed successfully.'
+ );
+ }
+}
\ No newline at end of file
diff --git a/backend/app/Services/Auth/LemmyAuthService.php b/backend/app/Services/Auth/LemmyAuthService.php
index 1ee58b2..6c0afda 100644
--- a/backend/app/Services/Auth/LemmyAuthService.php
+++ b/backend/app/Services/Auth/LemmyAuthService.php
@@ -6,6 +6,7 @@
use App\Exceptions\PlatformAuthException;
use App\Models\PlatformAccount;
use App\Modules\Lemmy\Services\LemmyApiService;
+use Exception;
use Illuminate\Support\Facades\Cache;
class LemmyAuthService
@@ -38,4 +39,36 @@ public static function getToken(PlatformAccount $account): string
return $token;
}
+
+ /**
+ * Authenticate with Lemmy API and return user data with JWT
+ * @throws PlatformAuthException
+ */
+ public function authenticate(string $instanceUrl, string $username, string $password): array
+ {
+ try {
+ $api = new LemmyApiService($instanceUrl);
+ $token = $api->login($username, $password);
+
+ if (!$token) {
+ throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for user: ' . $username);
+ }
+
+ // Get user info with the token
+ // For now, we'll return a basic response structure
+ // In a real implementation, you might want to fetch user details
+ return [
+ 'jwt' => $token,
+ 'person_view' => [
+ 'person' => [
+ 'id' => 0, // Would need API call to get actual user info
+ 'display_name' => null,
+ 'bio' => null,
+ ]
+ ]
+ ];
+ } catch (Exception $e) {
+ throw new PlatformAuthException(PlatformEnum::LEMMY, 'Authentication failed: ' . $e->getMessage());
+ }
+ }
}
diff --git a/backend/routes/api.php b/backend/routes/api.php
index 4b3b990..f89d445 100644
--- a/backend/routes/api.php
+++ b/backend/routes/api.php
@@ -5,11 +5,11 @@
use App\Http\Controllers\Api\V1\DashboardController;
use App\Http\Controllers\Api\V1\FeedsController;
use App\Http\Controllers\Api\V1\LogsController;
+use App\Http\Controllers\Api\V1\OnboardingController;
use App\Http\Controllers\Api\V1\PlatformAccountsController;
use App\Http\Controllers\Api\V1\PlatformChannelsController;
use App\Http\Controllers\Api\V1\RoutingController;
use App\Http\Controllers\Api\V1\SettingsController;
-use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
/*
@@ -27,23 +27,29 @@
// Public authentication routes
Route::post('/auth/login', [AuthController::class, 'login'])->name('api.auth.login');
Route::post('/auth/register', [AuthController::class, 'register'])->name('api.auth.register');
-
+
// Protected authentication routes
Route::middleware('auth:sanctum')->group(function () {
Route::post('/auth/logout', [AuthController::class, 'logout'])->name('api.auth.logout');
Route::get('/auth/me', [AuthController::class, 'me'])->name('api.auth.me');
});
- // For demo purposes, making most endpoints public. In production, wrap in auth:sanctum middleware
- // Route::middleware('auth:sanctum')->group(function () {
+ // Onboarding
+ Route::get('/onboarding/status', [OnboardingController::class, 'status'])->name('api.onboarding.status');
+ Route::get('/onboarding/options', [OnboardingController::class, 'options'])->name('api.onboarding.options');
+ Route::post('/onboarding/platform', [OnboardingController::class, 'createPlatform'])->name('api.onboarding.platform');
+ 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/complete', [OnboardingController::class, 'complete'])->name('api.onboarding.complete');
+
// Dashboard stats
Route::get('/dashboard/stats', [DashboardController::class, 'stats'])->name('api.dashboard.stats');
-
+
// Articles
Route::get('/articles', [ArticlesController::class, 'index'])->name('api.articles.index');
Route::post('/articles/{article}/approve', [ArticlesController::class, 'approve'])->name('api.articles.approve');
Route::post('/articles/{article}/reject', [ArticlesController::class, 'reject'])->name('api.articles.reject');
-
+
// Platform Accounts
Route::apiResource('platform-accounts', PlatformAccountsController::class)->names([
'index' => 'api.platform-accounts.index',
@@ -54,7 +60,7 @@
]);
Route::post('/platform-accounts/{platformAccount}/set-active', [PlatformAccountsController::class, 'setActive'])
->name('api.platform-accounts.set-active');
-
+
// Platform Channels
Route::apiResource('platform-channels', PlatformChannelsController::class)->names([
'index' => 'api.platform-channels.index',
@@ -65,7 +71,7 @@
]);
Route::post('/platform-channels/{channel}/toggle', [PlatformChannelsController::class, 'toggle'])
->name('api.platform-channels.toggle');
-
+
// Feeds
Route::apiResource('feeds', FeedsController::class)->names([
'index' => 'api.feeds.index',
@@ -75,7 +81,7 @@
'destroy' => 'api.feeds.destroy',
]);
Route::post('/feeds/{feed}/toggle', [FeedsController::class, 'toggle'])->name('api.feeds.toggle');
-
+
// Routing
Route::get('/routing', [RoutingController::class, 'index'])->name('api.routing.index');
Route::post('/routing', [RoutingController::class, 'store'])->name('api.routing.store');
@@ -83,14 +89,11 @@
Route::put('/routing/{feed}/{channel}', [RoutingController::class, 'update'])->name('api.routing.update');
Route::delete('/routing/{feed}/{channel}', [RoutingController::class, 'destroy'])->name('api.routing.destroy');
Route::post('/routing/{feed}/{channel}/toggle', [RoutingController::class, 'toggle'])->name('api.routing.toggle');
-
+
// Settings
Route::get('/settings', [SettingsController::class, 'index'])->name('api.settings.index');
Route::put('/settings', [SettingsController::class, 'update'])->name('api.settings.update');
-
+
// Logs
Route::get('/logs', [LogsController::class, 'index'])->name('api.logs.index');
-
- // Close the auth:sanctum middleware group when ready
- // });
-});
\ No newline at end of file
+});
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d523794..04a0e4b 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -5,19 +5,50 @@ import Dashboard from './pages/Dashboard';
import Articles from './pages/Articles';
import Feeds from './pages/Feeds';
import Settings from './pages/Settings';
+import OnboardingWizard from './pages/onboarding/OnboardingWizard';
+import { OnboardingProvider, useOnboarding } from './contexts/OnboardingContext';
+
+const AppContent: React.FC = () => {
+ const { isLoading } = useOnboarding();
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* Onboarding routes - outside of main layout */}
+ } />
+
+ {/* Main app routes - with layout */}
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ } />
+
+ );
+};
const App: React.FC = () => {
return (
-
-
- } />
- } />
- } />
- } />
- } />
- } />
-
-
+
+
+
);
};
diff --git a/frontend/src/contexts/OnboardingContext.tsx b/frontend/src/contexts/OnboardingContext.tsx
new file mode 100644
index 0000000..47b9287
--- /dev/null
+++ b/frontend/src/contexts/OnboardingContext.tsx
@@ -0,0 +1,68 @@
+import React, { createContext, useContext, type ReactNode } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useNavigate, useLocation } from 'react-router-dom';
+import { apiClient, type OnboardingStatus } from '../lib/api';
+
+interface OnboardingContextValue {
+ onboardingStatus: OnboardingStatus | undefined;
+ isLoading: boolean;
+ needsOnboarding: boolean;
+}
+
+const OnboardingContext = createContext(null);
+
+interface OnboardingProviderProps {
+ children: ReactNode;
+}
+
+export const OnboardingProvider: React.FC = ({ children }) => {
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ const { data: onboardingStatus, isLoading } = useQuery({
+ queryKey: ['onboarding-status'],
+ queryFn: () => apiClient.getOnboardingStatus(),
+ retry: 1,
+ });
+
+ const needsOnboarding = onboardingStatus?.needs_onboarding ?? false;
+ const isOnOnboardingPage = location.pathname.startsWith('/onboarding');
+
+ // Redirect logic
+ React.useEffect(() => {
+ if (isLoading) return;
+
+ if (needsOnboarding && !isOnOnboardingPage) {
+ // User needs onboarding but is not on onboarding pages
+ 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 });
+ }
+ }, [onboardingStatus, isLoading, needsOnboarding, isOnOnboardingPage, navigate]);
+
+ const value: OnboardingContextValue = {
+ onboardingStatus,
+ isLoading,
+ needsOnboarding,
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useOnboarding = () => {
+ const context = useContext(OnboardingContext);
+ if (!context) {
+ throw new Error('useOnboarding must be used within an OnboardingProvider');
+ }
+ return context;
+};
\ No newline at end of file
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 1404909..b2fd143 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -127,6 +127,59 @@ export interface DashboardStats {
current_period: string;
}
+// Onboarding types
+export interface Language {
+ id: number;
+ short_code: string;
+ name: string;
+ native_name: string;
+ is_active: boolean;
+}
+
+export interface PlatformInstance {
+ id: number;
+ platform: 'lemmy';
+ url: string;
+ name: string;
+ description: string | null;
+ is_active: boolean;
+}
+
+export interface OnboardingStatus {
+ needs_onboarding: boolean;
+ current_step: 'platform' | 'feed' | 'channel' | 'complete' | null;
+ has_platform_account: boolean;
+ has_feed: boolean;
+ has_channel: boolean;
+}
+
+export interface OnboardingOptions {
+ languages: Language[];
+ platform_instances: PlatformInstance[];
+}
+
+export interface PlatformAccountRequest {
+ instance_url: string;
+ username: string;
+ password: string;
+ platform: 'lemmy';
+}
+
+export interface FeedRequest {
+ name: string;
+ url: string;
+ type: 'rss' | 'website';
+ language_id: number;
+ description?: string;
+}
+
+export interface ChannelRequest {
+ name: string;
+ platform_instance_id: number;
+ language_id: number;
+ description?: string;
+}
+
// API Client class
class ApiClient {
constructor() {
@@ -205,6 +258,36 @@ class ApiClient {
const response = await axios.put>('/settings', data);
return response.data.data;
}
+
+ // Onboarding endpoints
+ async getOnboardingStatus(): Promise {
+ const response = await axios.get>('/onboarding/status');
+ return response.data.data;
+ }
+
+ async getOnboardingOptions(): Promise {
+ const response = await axios.get>('/onboarding/options');
+ return response.data.data;
+ }
+
+ async createPlatformAccount(data: PlatformAccountRequest): Promise {
+ const response = await axios.post>('/onboarding/platform', data);
+ return response.data.data;
+ }
+
+ async createFeedForOnboarding(data: FeedRequest): Promise {
+ const response = await axios.post>('/onboarding/feed', data);
+ return response.data.data;
+ }
+
+ async createChannelForOnboarding(data: ChannelRequest): Promise {
+ const response = await axios.post>('/onboarding/channel', data);
+ return response.data.data;
+ }
+
+ async completeOnboarding(): Promise {
+ await axios.post('/onboarding/complete');
+ }
}
export const apiClient = new ApiClient();
\ No newline at end of file
diff --git a/frontend/src/pages/onboarding/OnboardingLayout.tsx b/frontend/src/pages/onboarding/OnboardingLayout.tsx
new file mode 100644
index 0000000..4985823
--- /dev/null
+++ b/frontend/src/pages/onboarding/OnboardingLayout.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+
+interface OnboardingLayoutProps {
+ children: React.ReactNode;
+}
+
+const OnboardingLayout: React.FC = ({ children }) => {
+ return (
+
+ );
+};
+
+export default OnboardingLayout;
\ No newline at end of file
diff --git a/frontend/src/pages/onboarding/OnboardingWizard.tsx b/frontend/src/pages/onboarding/OnboardingWizard.tsx
new file mode 100644
index 0000000..b8379bb
--- /dev/null
+++ b/frontend/src/pages/onboarding/OnboardingWizard.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import { Routes, Route, Navigate } from 'react-router-dom';
+import OnboardingLayout from './OnboardingLayout';
+import WelcomeStep from './steps/WelcomeStep';
+import PlatformStep from './steps/PlatformStep';
+import FeedStep from './steps/FeedStep';
+import ChannelStep from './steps/ChannelStep';
+import CompleteStep from './steps/CompleteStep';
+
+const OnboardingWizard: React.FC = () => {
+ return (
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+};
+
+export default OnboardingWizard;
\ No newline at end of file
diff --git a/frontend/src/pages/onboarding/steps/ChannelStep.tsx b/frontend/src/pages/onboarding/steps/ChannelStep.tsx
new file mode 100644
index 0000000..b15b244
--- /dev/null
+++ b/frontend/src/pages/onboarding/steps/ChannelStep.tsx
@@ -0,0 +1,178 @@
+import React, { useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { apiClient, type ChannelRequest, type Language, type PlatformInstance } from '../../../lib/api';
+
+const ChannelStep: React.FC = () => {
+ const navigate = useNavigate();
+ const [formData, setFormData] = useState({
+ name: '',
+ platform_instance_id: 0,
+ language_id: 0,
+ description: ''
+ });
+ const [errors, setErrors] = useState>({});
+
+ // Get onboarding options (languages, platform instances)
+ const { data: options, isLoading: optionsLoading } = useQuery({
+ queryKey: ['onboarding-options'],
+ queryFn: () => apiClient.getOnboardingOptions()
+ });
+
+ const createChannelMutation = useMutation({
+ mutationFn: (data: ChannelRequest) => apiClient.createChannelForOnboarding(data),
+ onSuccess: () => {
+ navigate('/onboarding/complete');
+ },
+ onError: (error: any) => {
+ if (error.response?.data?.errors) {
+ setErrors(error.response.data.errors);
+ } else {
+ setErrors({ general: [error.response?.data?.message || 'An error occurred'] });
+ }
+ }
+ });
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ setErrors({});
+ createChannelMutation.mutate(formData);
+ };
+
+ const handleChange = (field: keyof ChannelRequest, value: string | number) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ // Clear field error when user starts typing
+ if (errors[field]) {
+ setErrors(prev => ({ ...prev, [field]: [] }));
+ }
+ };
+
+ if (optionsLoading) {
+ return Loading...
;
+ }
+
+ return (
+
+
Configure Your Channel
+
+ Set up a Lemmy community where articles will be posted
+
+
+ {/* Progress indicator */}
+
+
+
+
+ );
+};
+
+export default ChannelStep;
\ No newline at end of file
diff --git a/frontend/src/pages/onboarding/steps/CompleteStep.tsx b/frontend/src/pages/onboarding/steps/CompleteStep.tsx
new file mode 100644
index 0000000..9662af5
--- /dev/null
+++ b/frontend/src/pages/onboarding/steps/CompleteStep.tsx
@@ -0,0 +1,86 @@
+import React from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { useMutation } from '@tanstack/react-query';
+import { apiClient } from '../../../lib/api';
+
+const CompleteStep: React.FC = () => {
+ const navigate = useNavigate();
+
+ const completeOnboardingMutation = useMutation({
+ mutationFn: () => apiClient.completeOnboarding(),
+ onSuccess: () => {
+ navigate('/dashboard');
+ },
+ onError: (error) => {
+ console.error('Failed to complete onboarding:', error);
+ // Still navigate to dashboard even if completion fails
+ navigate('/dashboard');
+ }
+ });
+
+ const handleFinish = () => {
+ completeOnboardingMutation.mutate();
+ };
+
+ return (
+
+
+
+
Setup Complete!
+
+ Great! You've successfully configured FFR. Your feeds will now be monitored and articles will be automatically posted to your configured channels.
+
+
+
+ {/* Progress indicator */}
+
+
+
+
+
What happens next?
+
+ - • Your feeds will be checked regularly for new articles
+ - • New articles will be automatically posted to your channels
+ - • You can monitor activity in the Articles and other sections
+
+
+
+
+
Want more control?
+
+ You can add more feeds, channels, and configure settings from the dashboard.
+
+
+
+
+
+
+
+
+ View Articles
+ {' • '}
+ Manage Feeds
+ {' • '}
+ Settings
+
+
+
+ );
+};
+
+export default CompleteStep;
\ No newline at end of file
diff --git a/frontend/src/pages/onboarding/steps/FeedStep.tsx b/frontend/src/pages/onboarding/steps/FeedStep.tsx
new file mode 100644
index 0000000..4fcb253
--- /dev/null
+++ b/frontend/src/pages/onboarding/steps/FeedStep.tsx
@@ -0,0 +1,193 @@
+import React, { useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { apiClient, type FeedRequest, type Language } from '../../../lib/api';
+
+const FeedStep: React.FC = () => {
+ const navigate = useNavigate();
+ const [formData, setFormData] = useState({
+ name: '',
+ url: '',
+ type: 'rss',
+ language_id: 0,
+ description: ''
+ });
+ const [errors, setErrors] = useState>({});
+
+ // Get onboarding options (languages)
+ const { data: options, isLoading: optionsLoading } = useQuery({
+ queryKey: ['onboarding-options'],
+ queryFn: () => apiClient.getOnboardingOptions()
+ });
+
+ const createFeedMutation = useMutation({
+ mutationFn: (data: FeedRequest) => apiClient.createFeedForOnboarding(data),
+ onSuccess: () => {
+ navigate('/onboarding/channel');
+ },
+ onError: (error: any) => {
+ if (error.response?.data?.errors) {
+ setErrors(error.response.data.errors);
+ } else {
+ setErrors({ general: [error.response?.data?.message || 'An error occurred'] });
+ }
+ }
+ });
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ setErrors({});
+ createFeedMutation.mutate(formData);
+ };
+
+ const handleChange = (field: keyof FeedRequest, value: string | number) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ // Clear field error when user starts typing
+ if (errors[field]) {
+ setErrors(prev => ({ ...prev, [field]: [] }));
+ }
+ };
+
+ if (optionsLoading) {
+ return Loading...
;
+ }
+
+ return (
+
+
Add Your First Feed
+
+ Add a RSS feed or website to monitor for new articles
+
+
+ {/* Progress indicator */}
+
+
+
+
+ );
+};
+
+export default FeedStep;
\ No newline at end of file
diff --git a/frontend/src/pages/onboarding/steps/PlatformStep.tsx b/frontend/src/pages/onboarding/steps/PlatformStep.tsx
new file mode 100644
index 0000000..9d13b52
--- /dev/null
+++ b/frontend/src/pages/onboarding/steps/PlatformStep.tsx
@@ -0,0 +1,138 @@
+import React, { useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { useMutation } from '@tanstack/react-query';
+import { apiClient, type PlatformAccountRequest } from '../../../lib/api';
+
+const PlatformStep: React.FC = () => {
+ const navigate = useNavigate();
+ const [formData, setFormData] = useState({
+ instance_url: '',
+ username: '',
+ password: '',
+ platform: 'lemmy'
+ });
+ const [errors, setErrors] = useState>({});
+
+ const createPlatformMutation = useMutation({
+ mutationFn: (data: PlatformAccountRequest) => apiClient.createPlatformAccount(data),
+ onSuccess: () => {
+ navigate('/onboarding/feed');
+ },
+ onError: (error: any) => {
+ if (error.response?.data?.errors) {
+ setErrors(error.response.data.errors);
+ } else {
+ setErrors({ general: [error.response?.data?.message || 'An error occurred'] });
+ }
+ }
+ });
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ setErrors({});
+ createPlatformMutation.mutate(formData);
+ };
+
+ const handleChange = (field: keyof PlatformAccountRequest, value: string) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ // Clear field error when user starts typing
+ if (errors[field]) {
+ setErrors(prev => ({ ...prev, [field]: [] }));
+ }
+ };
+
+ return (
+
+
Connect Your Lemmy Account
+
+ Enter your Lemmy instance details and login credentials
+
+
+ {/* Progress indicator */}
+
+
+
+
+ );
+};
+
+export default PlatformStep;
\ No newline at end of file
diff --git a/frontend/src/pages/onboarding/steps/WelcomeStep.tsx b/frontend/src/pages/onboarding/steps/WelcomeStep.tsx
new file mode 100644
index 0000000..c4a39d1
--- /dev/null
+++ b/frontend/src/pages/onboarding/steps/WelcomeStep.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+const WelcomeStep: React.FC = () => {
+ return (
+
+
Welcome to FFR
+
+ Let's get you set up! We'll help you configure your Lemmy account, add your first feed, and create a channel for posting.
+
+
+
+
+
1
+
Connect your Lemmy account
+
+
+
2
+
Add your first feed
+
+
+
3
+
Configure a channel
+
+
+
4
+
You're ready to go!
+
+
+
+
+
+ Get Started
+
+
+
+ );
+};
+
+export default WelcomeStep;
\ No newline at end of file
--
2.45.2
From 73ba089e46e9277ee6300e52bf28c7ced7d72af8 Mon Sep 17 00:00:00 2001
From: myrmidex
Date: Sat, 9 Aug 2025 00:52:14 +0200
Subject: [PATCH 04/48] Fix auth error message
---
.../Controllers/Api/V1/OnboardingController.php | 15 ++++++++++++++-
backend/app/Services/Auth/LemmyAuthService.php | 9 +++++++--
2 files changed, 21 insertions(+), 3 deletions(-)
diff --git a/backend/app/Http/Controllers/Api/V1/OnboardingController.php b/backend/app/Http/Controllers/Api/V1/OnboardingController.php
index a1c3b1b..d77057b 100644
--- a/backend/app/Http/Controllers/Api/V1/OnboardingController.php
+++ b/backend/app/Http/Controllers/Api/V1/OnboardingController.php
@@ -131,8 +131,21 @@ public function createPlatform(Request $request): JsonResponse
'Platform account created successfully.'
);
+ } catch (\App\Exceptions\PlatformAuthException $e) {
+ // Handle authentication-specific errors with cleaner messages
+ return $this->sendError('Invalid username or password. Please check your credentials and try again.', [], 422);
} catch (\Exception $e) {
- return $this->sendError('Failed to create platform account: ' . $e->getMessage(), [], 422);
+ // Handle other errors (network, instance not found, etc.)
+ $message = 'Unable to connect to the Lemmy instance. Please check the URL and try again.';
+
+ // If it's a network/connection issue, provide a more specific message
+ if (str_contains(strtolower($e->getMessage()), 'connection') ||
+ str_contains(strtolower($e->getMessage()), 'network') ||
+ str_contains(strtolower($e->getMessage()), 'timeout')) {
+ $message = 'Connection failed. Please check the instance URL and your internet connection.';
+ }
+
+ return $this->sendError($message, [], 422);
}
}
diff --git a/backend/app/Services/Auth/LemmyAuthService.php b/backend/app/Services/Auth/LemmyAuthService.php
index 6c0afda..b3b4fe1 100644
--- a/backend/app/Services/Auth/LemmyAuthService.php
+++ b/backend/app/Services/Auth/LemmyAuthService.php
@@ -51,7 +51,8 @@ public function authenticate(string $instanceUrl, string $username, string $pass
$token = $api->login($username, $password);
if (!$token) {
- throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for user: ' . $username);
+ // Throw a clean exception that will be caught and handled by the controller
+ throw new PlatformAuthException(PlatformEnum::LEMMY, 'Invalid credentials');
}
// Get user info with the token
@@ -67,8 +68,12 @@ public function authenticate(string $instanceUrl, string $username, string $pass
]
]
];
+ } catch (PlatformAuthException $e) {
+ // Re-throw PlatformAuthExceptions as-is to avoid nesting
+ throw $e;
} catch (Exception $e) {
- throw new PlatformAuthException(PlatformEnum::LEMMY, 'Authentication failed: ' . $e->getMessage());
+ // For other exceptions, throw a clean PlatformAuthException
+ throw new PlatformAuthException(PlatformEnum::LEMMY, 'Connection failed');
}
}
}
--
2.45.2
From 387920e82b5436ba40ba08a824698610aa4efc98 Mon Sep 17 00:00:00 2001
From: myrmidex
Date: Sat, 9 Aug 2025 02:51:18 +0200
Subject: [PATCH 05/48] Fix nginx error, startup script, add skip
---
.../Api/V1/OnboardingController.php | 55 ++-
backend/app/Modules/Lemmy/LemmyRequest.php | 17 +-
backend/routes/api.php | 2 +
.../Api/V1/OnboardingControllerTest.php | 364 ++++++++++++++++++
docker/dev/podman/.env.dev | 2 +-
docker/dev/podman/container-start.sh | 16 +-
docker/dev/podman/nginx.conf | 87 +++++
docker/dev/podman/start-dev.sh | 40 +-
frontend/src/App.tsx | 24 +-
frontend/src/components/Layout.tsx | 2 +
frontend/src/contexts/OnboardingContext.tsx | 17 +-
frontend/src/lib/api.ts | 9 +
.../pages/onboarding/steps/PlatformStep.tsx | 7 +-
.../pages/onboarding/steps/WelcomeStep.tsx | 29 +-
14 files changed, 600 insertions(+), 71 deletions(-)
create mode 100644 backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php
create mode 100644 docker/dev/podman/nginx.conf
diff --git a/backend/app/Http/Controllers/Api/V1/OnboardingController.php b/backend/app/Http/Controllers/Api/V1/OnboardingController.php
index d77057b..2553b33 100644
--- a/backend/app/Http/Controllers/Api/V1/OnboardingController.php
+++ b/backend/app/Http/Controllers/Api/V1/OnboardingController.php
@@ -11,6 +11,7 @@
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\PlatformInstance;
+use App\Models\Setting;
use App\Services\Auth\LemmyAuthService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -31,8 +32,12 @@ public function status(): JsonResponse
$hasPlatformAccount = PlatformAccount::where('is_active', true)->exists();
$hasFeed = Feed::where('is_active', true)->exists();
$hasChannel = PlatformChannel::where('is_active', true)->exists();
+
+ // Check if onboarding was explicitly skipped
+ $onboardingSkipped = Setting::where('key', 'onboarding_skipped')->value('value') === 'true';
- $needsOnboarding = !$hasPlatformAccount || !$hasFeed || !$hasChannel;
+ // User needs onboarding if they don't have the required components AND haven't skipped it
+ $needsOnboarding = (!$hasPlatformAccount || !$hasFeed || !$hasChannel) && !$onboardingSkipped;
// Determine current step
$currentStep = null;
@@ -52,6 +57,7 @@ public function status(): JsonResponse
'has_platform_account' => $hasPlatformAccount,
'has_feed' => $hasFeed,
'has_channel' => $hasChannel,
+ 'onboarding_skipped' => $onboardingSkipped,
], 'Onboarding status retrieved successfully.');
}
@@ -80,10 +86,12 @@ public function options(): JsonResponse
public function createPlatform(Request $request): JsonResponse
{
$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',
'password' => 'required|string|min:6',
'platform' => 'required|in:lemmy',
+ ], [
+ 'instance_url.regex' => 'Please enter a valid domain name (e.g., lemmy.world, belgae.social)'
]);
if ($validator->fails()) {
@@ -92,19 +100,23 @@ public function createPlatform(Request $request): JsonResponse
$validated = $validator->validated();
+ // Normalize the instance URL - prepend https:// if needed
+ $instanceDomain = $validated['instance_url'];
+ $fullInstanceUrl = 'https://' . $instanceDomain;
+
try {
// Create or get platform instance
$platformInstance = PlatformInstance::firstOrCreate([
- 'url' => $validated['instance_url'],
+ 'url' => $fullInstanceUrl,
'platform' => $validated['platform'],
], [
- 'name' => parse_url($validated['instance_url'], PHP_URL_HOST) ?? 'Lemmy Instance',
+ 'name' => ucfirst($instanceDomain),
'is_active' => true,
]);
- // Authenticate with Lemmy API
+ // Authenticate with Lemmy API using the full URL
$authResponse = $this->lemmyAuthService->authenticate(
- $validated['instance_url'],
+ $fullInstanceUrl,
$validated['username'],
$validated['password']
);
@@ -112,7 +124,7 @@ public function createPlatform(Request $request): JsonResponse
// Create platform account with the current schema
$platformAccount = PlatformAccount::create([
'platform' => $validated['platform'],
- 'instance_url' => $validated['instance_url'],
+ 'instance_url' => $fullInstanceUrl,
'username' => $validated['username'],
'password' => $validated['password'],
'api_token' => $authResponse['jwt'] ?? null,
@@ -232,4 +244,33 @@ public function complete(): JsonResponse
'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.'
+ );
+ }
}
\ No newline at end of file
diff --git a/backend/app/Modules/Lemmy/LemmyRequest.php b/backend/app/Modules/Lemmy/LemmyRequest.php
index 5bdb5d8..1c6cdc2 100644
--- a/backend/app/Modules/Lemmy/LemmyRequest.php
+++ b/backend/app/Modules/Lemmy/LemmyRequest.php
@@ -12,10 +12,25 @@ class LemmyRequest
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;
}
+ /**
+ * 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 $params
*/
diff --git a/backend/routes/api.php b/backend/routes/api.php
index f89d445..3966f2d 100644
--- a/backend/routes/api.php
+++ b/backend/routes/api.php
@@ -41,6 +41,8 @@
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/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
Route::get('/dashboard/stats', [DashboardController::class, 'stats'])->name('api.dashboard.stats');
diff --git a/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php b/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php
new file mode 100644
index 0000000..dc98eb2
--- /dev/null
+++ b/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php
@@ -0,0 +1,364 @@
+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]]);
+ }
+}
\ No newline at end of file
diff --git a/docker/dev/podman/.env.dev b/docker/dev/podman/.env.dev
index f0df808..f81be47 100644
--- a/docker/dev/podman/.env.dev
+++ b/docker/dev/podman/.env.dev
@@ -1,6 +1,6 @@
APP_NAME="FFR Development"
APP_ENV=local
-APP_KEY=base64:YOUR_APP_KEY_HERE
+APP_KEY=
APP_DEBUG=true
APP_TIMEZONE=UTC
APP_URL=http://localhost:8000
diff --git a/docker/dev/podman/container-start.sh b/docker/dev/podman/container-start.sh
index 580feb7..175e412 100755
--- a/docker/dev/podman/container-start.sh
+++ b/docker/dev/podman/container-start.sh
@@ -3,19 +3,17 @@
# Copy development environment configuration to backend
cp /var/www/html/docker/dev/podman/.env.dev /var/www/html/backend/.env
-# Setup nginx configuration
-cp /var/www/html/docker/nginx.conf /etc/nginx/sites-available/default
+# Setup nginx configuration for development
+cp /var/www/html/docker/dev/podman/nginx.conf /etc/nginx/sites-available/default
# Install/update dependencies
echo "Installing PHP dependencies..."
cd /var/www/html/backend
composer install --no-interaction
-# Generate app key if not set or empty
-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..."
- php artisan key:generate --force
-fi
+# Generate application key
+echo "Generating application key..."
+php artisan key:generate --force
# Wait for database to be ready
echo "Waiting for database..."
@@ -39,6 +37,10 @@ fi
# Start 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
cd /var/www/html/backend
php artisan serve --host=127.0.0.1 --port=8000 &
diff --git a/docker/dev/podman/nginx.conf b/docker/dev/podman/nginx.conf
new file mode 100644
index 0000000..ef692f5
--- /dev/null
+++ b/docker/dev/podman/nginx.conf
@@ -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;
+}
\ No newline at end of file
diff --git a/docker/dev/podman/start-dev.sh b/docker/dev/podman/start-dev.sh
index 29681b8..00c9060 100755
--- a/docker/dev/podman/start-dev.sh
+++ b/docker/dev/podman/start-dev.sh
@@ -10,14 +10,12 @@ echo "🚀 Starting FFR development environment with Podman..."
if [ ! -f .env ]; then
echo "📋 Creating .env file from .env.example..."
cp .env.example .env
- echo "⚠️ Please update your .env file with appropriate values, especially APP_KEY"
fi
# Check if podman-compose is available
if ! command -v podman-compose &> /dev/null; then
- echo "❌ podman-compose not found. Installing..."
- pip3 install --user podman-compose
- echo "✅ podman-compose installed"
+ echo "❌ podman-compose not found."
+ exit
fi
# Start services
@@ -28,22 +26,32 @@ podman-compose -f docker/dev/podman/docker-compose.yml up -d
echo "⏳ Waiting for database to be ready..."
sleep 10
-# Check if APP_KEY is set
-if grep -q "APP_KEY=base64:YOUR_APP_KEY_HERE" .env || grep -q "APP_KEY=$" .env; then
- echo "🔑 Generating application key..."
- podman exec ffr-dev-app php artisan key:generate
-fi
+# Install/update dependencies if needed
+echo "📦 Installing dependencies..."
+podman exec ffr-dev-app bash -c "cd /var/www/html/backend && composer install"
+podman exec ffr-dev-app bash -c "cd /var/www/html/frontend && npm install"
# Run migrations and seeders
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..."
-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
-echo "📦 Installing dependencies..."
-podman exec ffr-dev-app composer install
-podman exec ffr-dev-app npm install
+# Wait for container services to be fully ready
+echo "⏳ Waiting for container services to initialize..."
+sleep 5
+
+# 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 "🌐 Application: http://localhost:8000"
@@ -55,4 +63,4 @@ echo "📋 Useful commands:"
echo " Stop: podman-compose -f docker/dev/podman/docker-compose.yml down"
echo " Logs: podman-compose -f docker/dev/podman/docker-compose.yml logs -f"
echo " Exec: podman exec -it ffr-dev-app bash"
-echo " Tests: podman exec ffr-dev-app php artisan test"
\ No newline at end of file
+echo " Tests: podman exec ffr-dev-app php artisan test"
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 04a0e4b..179022a 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -6,22 +6,8 @@ import Articles from './pages/Articles';
import Feeds from './pages/Feeds';
import Settings from './pages/Settings';
import OnboardingWizard from './pages/onboarding/OnboardingWizard';
-import { OnboardingProvider, useOnboarding } from './contexts/OnboardingContext';
-
-const AppContent: React.FC = () => {
- const { isLoading } = useOnboarding();
-
- if (isLoading) {
- return (
-
- );
- }
+const App: React.FC = () => {
return (
{/* Onboarding routes - outside of main layout */}
@@ -44,12 +30,4 @@ const AppContent: React.FC = () => {
);
};
-const App: React.FC = () => {
- return (
-
-
-
- );
-};
-
export default App;
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
index 161cc32..5f07b0d 100644
--- a/frontend/src/components/Layout.tsx
+++ b/frontend/src/components/Layout.tsx
@@ -129,6 +129,8 @@ const Layout: React.FC = ({ children }) => {
FFR
+
+
{children}
diff --git a/frontend/src/contexts/OnboardingContext.tsx b/frontend/src/contexts/OnboardingContext.tsx
index 47b9287..b5a1525 100644
--- a/frontend/src/contexts/OnboardingContext.tsx
+++ b/frontend/src/contexts/OnboardingContext.tsx
@@ -28,22 +28,17 @@ export const OnboardingProvider: React.FC = ({ children
const needsOnboarding = onboardingStatus?.needs_onboarding ?? false;
const isOnOnboardingPage = location.pathname.startsWith('/onboarding');
- // Redirect logic
+ // Redirect logic - only redirect if user explicitly navigates to a protected route
React.useEffect(() => {
if (isLoading) return;
- if (needsOnboarding && !isOnOnboardingPage) {
- // User needs onboarding but is not on onboarding pages
- 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
+ // Only redirect if user doesn't need onboarding but is on onboarding pages
+ if (!needsOnboarding && isOnOnboardingPage) {
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]);
const value: OnboardingContextValue = {
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index b2fd143..01a004a 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -151,6 +151,7 @@ export interface OnboardingStatus {
has_platform_account: boolean;
has_feed: boolean;
has_channel: boolean;
+ onboarding_skipped: boolean;
}
export interface OnboardingOptions {
@@ -288,6 +289,14 @@ class ApiClient {
async completeOnboarding(): Promise {
await axios.post('/onboarding/complete');
}
+
+ async skipOnboarding(): Promise {
+ await axios.post('/onboarding/skip');
+ }
+
+ async resetOnboardingSkip(): Promise {
+ await axios.post('/onboarding/reset-skip');
+ }
}
export const apiClient = new ApiClient();
\ No newline at end of file
diff --git a/frontend/src/pages/onboarding/steps/PlatformStep.tsx b/frontend/src/pages/onboarding/steps/PlatformStep.tsx
index 9d13b52..95b4a3f 100644
--- a/frontend/src/pages/onboarding/steps/PlatformStep.tsx
+++ b/frontend/src/pages/onboarding/steps/PlatformStep.tsx
@@ -65,17 +65,18 @@ const PlatformStep: React.FC = () => {
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"
required
/>
+
Enter just the domain name (e.g., lemmy.world, belgae.social)
{errors.instance_url && (
{errors.instance_url[0]}
)}
diff --git a/frontend/src/pages/onboarding/steps/WelcomeStep.tsx b/frontend/src/pages/onboarding/steps/WelcomeStep.tsx
index c4a39d1..793c1af 100644
--- a/frontend/src/pages/onboarding/steps/WelcomeStep.tsx
+++ b/frontend/src/pages/onboarding/steps/WelcomeStep.tsx
@@ -1,7 +1,24 @@
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 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 (
Welcome to FFR
@@ -28,13 +45,21 @@ const WelcomeStep: React.FC = () => {
-
+
Get Started
+
+
);
--
2.45.2
From 2a68895ba953eccff4901555c0a6bbc5ce7a92af Mon Sep 17 00:00:00 2001
From: myrmidex
Date: Sat, 9 Aug 2025 13:48:25 +0200
Subject: [PATCH 06/48] Minor fixes
---
backend/.env.broken | 62 +++++++
.../Api/V1/OnboardingController.php | 62 ++++++-
backend/app/Modules/Lemmy/LemmyRequest.php | 53 ++++--
.../Lemmy/Services/LemmyApiService.php | 66 +++++--
.../app/Services/Auth/LemmyAuthService.php | 4 +
.../app/Services/DashboardStatsService.php | 17 +-
backend/database/seeders/DatabaseSeeder.php | 5 +-
backend/database/seeders/LanguageSeeder.php | 29 +++
backend/routes/api.php | 1 +
.../Api/V1/OnboardingControllerTest.php | 70 +++++++
docker/dev/podman/container-start.sh | 29 ++-
frontend/src/contexts/OnboardingContext.tsx | 10 +-
frontend/src/lib/api.ts | 33 +++-
frontend/src/main.tsx | 5 +-
frontend/src/pages/Dashboard.tsx | 126 ++++++-------
.../src/pages/onboarding/OnboardingWizard.tsx | 2 +
.../pages/onboarding/steps/ChannelStep.tsx | 7 +-
.../pages/onboarding/steps/CompleteStep.tsx | 10 +-
.../src/pages/onboarding/steps/FeedStep.tsx | 4 +-
.../src/pages/onboarding/steps/RouteStep.tsx | 174 ++++++++++++++++++
.../pages/onboarding/steps/WelcomeStep.tsx | 4 +
21 files changed, 650 insertions(+), 123 deletions(-)
create mode 100644 backend/.env.broken
create mode 100644 backend/database/seeders/LanguageSeeder.php
create mode 100644 frontend/src/pages/onboarding/steps/RouteStep.tsx
diff --git a/backend/.env.broken b/backend/.env.broken
new file mode 100644
index 0000000..cb03412
--- /dev/null
+++ b/backend/.env.broken
@@ -0,0 +1,62 @@
+APP_NAME="FFR Development"
+APP_ENV=local
+APP_KEY=
+APP_DEBUG=true
+APP_TIMEZONE=UTC
+APP_URL=http://localhost:8000
+
+APP_LOCALE=en
+APP_FALLBACK_LOCALE=en
+APP_FAKER_LOCALE=en_US
+
+APP_MAINTENANCE_DRIVER=file
+APP_MAINTENANCE_STORE=database
+
+BCRYPT_ROUNDS=12
+
+LOG_CHANNEL=stack
+LOG_STACK=single
+LOG_DEPRECATIONS_CHANNEL=null
+LOG_LEVEL=debug
+
+DB_CONNECTION=mysql
+DB_HOST=db
+DB_PORT=3306
+DB_DATABASE=ffr_dev
+DB_USERNAME=ffr_user
+DB_PASSWORD=ffr_password
+
+SESSION_DRIVER=redis
+SESSION_LIFETIME=120
+SESSION_ENCRYPT=false
+SESSION_PATH=/
+SESSION_DOMAIN=null
+
+BROADCAST_CONNECTION=log
+FILESYSTEM_DISK=local
+QUEUE_CONNECTION=redis
+
+CACHE_STORE=redis
+CACHE_PREFIX=
+
+REDIS_CLIENT=phpredis
+REDIS_HOST=redis
+REDIS_PASSWORD=null
+REDIS_PORT=6379
+
+MAIL_MAILER=log
+MAIL_HOST=127.0.0.1
+MAIL_PORT=2525
+MAIL_USERNAME=null
+MAIL_PASSWORD=null
+MAIL_ENCRYPTION=null
+MAIL_FROM_ADDRESS="hello@example.com"
+MAIL_FROM_NAME="${APP_NAME}"
+
+AWS_ACCESS_KEY_ID=
+AWS_SECRET_ACCESS_KEY=
+AWS_DEFAULT_REGION=us-east-1
+AWS_BUCKET=
+AWS_USE_PATH_STYLE_ENDPOINT=false
+
+VITE_APP_NAME="${APP_NAME}"
diff --git a/backend/app/Http/Controllers/Api/V1/OnboardingController.php b/backend/app/Http/Controllers/Api/V1/OnboardingController.php
index 2553b33..c092109 100644
--- a/backend/app/Http/Controllers/Api/V1/OnboardingController.php
+++ b/backend/app/Http/Controllers/Api/V1/OnboardingController.php
@@ -6,11 +6,13 @@
use App\Http\Resources\FeedResource;
use App\Http\Resources\PlatformAccountResource;
use App\Http\Resources\PlatformChannelResource;
+use App\Http\Resources\RouteResource;
use App\Models\Feed;
use App\Models\Language;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\PlatformInstance;
+use App\Models\Route;
use App\Models\Setting;
use App\Services\Auth\LemmyAuthService;
use Illuminate\Http\JsonResponse;
@@ -32,12 +34,13 @@ public function status(): JsonResponse
$hasPlatformAccount = PlatformAccount::where('is_active', true)->exists();
$hasFeed = Feed::where('is_active', true)->exists();
$hasChannel = PlatformChannel::where('is_active', true)->exists();
+ $hasRoute = Route::where('is_active', true)->exists();
// 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;
+ $needsOnboarding = (!$hasPlatformAccount || !$hasFeed || !$hasChannel || !$hasRoute) && !$onboardingSkipped;
// Determine current step
$currentStep = null;
@@ -48,6 +51,8 @@ public function status(): JsonResponse
$currentStep = 'feed';
} elseif (!$hasChannel) {
$currentStep = 'channel';
+ } elseif (!$hasRoute) {
+ $currentStep = 'route';
}
}
@@ -57,6 +62,7 @@ public function status(): JsonResponse
'has_platform_account' => $hasPlatformAccount,
'has_feed' => $hasFeed,
'has_channel' => $hasChannel,
+ 'has_route' => $hasRoute,
'onboarding_skipped' => $onboardingSkipped,
], 'Onboarding status retrieved successfully.');
}
@@ -74,9 +80,21 @@ public function options(): JsonResponse
->orderBy('name')
->get(['id', 'platform', 'url', 'name', 'description', 'is_active']);
+ // Get existing feeds and channels for route creation
+ $feeds = Feed::where('is_active', true)
+ ->orderBy('name')
+ ->get(['id', 'name', 'url', 'type']);
+
+ $platformChannels = PlatformChannel::where('is_active', true)
+ ->with(['platformInstance:id,name,url'])
+ ->orderBy('name')
+ ->get(['id', 'platform_instance_id', 'name', 'display_name', 'description']);
+
return $this->sendResponse([
'languages' => $languages,
'platform_instances' => $platformInstances,
+ 'feeds' => $feeds,
+ 'platform_channels' => $platformChannels,
], 'Onboarding options retrieved successfully.');
}
@@ -127,12 +145,12 @@ public function createPlatform(Request $request): JsonResponse
'instance_url' => $fullInstanceUrl,
'username' => $validated['username'],
'password' => $validated['password'],
- 'api_token' => $authResponse['jwt'] ?? null,
'settings' => [
'display_name' => $authResponse['person_view']['person']['display_name'] ?? null,
'description' => $authResponse['person_view']['person']['bio'] ?? null,
'person_id' => $authResponse['person_view']['person']['id'] ?? null,
'platform_instance_id' => $platformInstance->id,
+ 'api_token' => $authResponse['jwt'] ?? null, // Store JWT in settings for now
],
'is_active' => true,
'status' => 'active',
@@ -144,10 +162,14 @@ public function createPlatform(Request $request): JsonResponse
);
} catch (\App\Exceptions\PlatformAuthException $e) {
- // Handle authentication-specific errors with cleaner messages
+ // Check if it's a rate limit error
+ if (str_contains($e->getMessage(), 'Rate limited by')) {
+ return $this->sendError($e->getMessage(), [], 429);
+ }
+
return $this->sendError('Invalid username or password. Please check your credentials and try again.', [], 422);
} catch (\Exception $e) {
- // Handle other errors (network, instance not found, etc.)
+
$message = 'Unable to connect to the Lemmy instance. Please check the URL and try again.';
// If it's a network/connection issue, provide a more specific message
@@ -229,6 +251,38 @@ public function createChannel(Request $request): JsonResponse
);
}
+ /**
+ * Create route for onboarding
+ */
+ public function createRoute(Request $request): JsonResponse
+ {
+ $validator = Validator::make($request->all(), [
+ 'feed_id' => 'required|exists:feeds,id',
+ 'platform_channel_id' => 'required|exists:platform_channels,id',
+ 'priority' => 'nullable|integer|min:1|max:100',
+ 'filters' => 'nullable|array',
+ ]);
+
+ if ($validator->fails()) {
+ throw new ValidationException($validator);
+ }
+
+ $validated = $validator->validated();
+
+ $route = Route::create([
+ 'feed_id' => $validated['feed_id'],
+ 'platform_channel_id' => $validated['platform_channel_id'],
+ 'priority' => $validated['priority'] ?? 50,
+ 'filters' => $validated['filters'] ?? [],
+ 'is_active' => true,
+ ]);
+
+ return $this->sendResponse(
+ new RouteResource($route->load(['feed', 'platformChannel'])),
+ 'Route created successfully.'
+ );
+ }
+
/**
* Mark onboarding as complete
*/
diff --git a/backend/app/Modules/Lemmy/LemmyRequest.php b/backend/app/Modules/Lemmy/LemmyRequest.php
index 1c6cdc2..461b60d 100644
--- a/backend/app/Modules/Lemmy/LemmyRequest.php
+++ b/backend/app/Modules/Lemmy/LemmyRequest.php
@@ -7,28 +7,37 @@
class LemmyRequest
{
- private string $instance;
+ private string $host;
+ private string $scheme;
private ?string $token;
public function __construct(string $instance, ?string $token = null)
{
// Handle both full URLs and just domain names
- $this->instance = $this->normalizeInstance($instance);
+ [$this->scheme, $this->host] = $this->parseInstance($instance);
$this->token = $token;
}
/**
- * Normalize instance URL to just the domain name
+ * Parse instance into scheme and host. Defaults to https when scheme missing.
+ *
+ * @return array{0:string,1:string} [scheme, host]
*/
- private function normalizeInstance(string $instance): string
+ private function parseInstance(string $instance): array
{
+ $scheme = 'https';
+
+ // If instance includes a scheme, honor it
+ if (preg_match('/^(https?):\/\//i', $instance, $m)) {
+ $scheme = strtolower($m[1]);
+ }
+
// Remove protocol if present
- $instance = preg_replace('/^https?:\/\//', '', $instance);
-
+ $host = preg_replace('/^https?:\/\//i', '', $instance);
// Remove trailing slash if present
- $instance = rtrim($instance, '/');
-
- return $instance;
+ $host = rtrim($host ?? '', '/');
+
+ return [$scheme, $host];
}
/**
@@ -36,14 +45,14 @@ private function normalizeInstance(string $instance): string
*/
public function get(string $endpoint, array $params = []): Response
{
- $url = "https://{$this->instance}/api/v3/{$endpoint}";
-
+ $url = sprintf('%s://%s/api/v3/%s', $this->scheme, $this->host, ltrim($endpoint, '/'));
+
$request = Http::timeout(30);
-
+
if ($this->token) {
$request = $request->withToken($this->token);
}
-
+
return $request->get($url, $params);
}
@@ -52,14 +61,14 @@ public function get(string $endpoint, array $params = []): Response
*/
public function post(string $endpoint, array $data = []): Response
{
- $url = "https://{$this->instance}/api/v3/{$endpoint}";
-
+ $url = sprintf('%s://%s/api/v3/%s', $this->scheme, $this->host, ltrim($endpoint, '/'));
+
$request = Http::timeout(30);
-
+
if ($this->token) {
$request = $request->withToken($this->token);
}
-
+
return $request->post($url, $data);
}
@@ -68,4 +77,14 @@ public function withToken(string $token): self
$this->token = $token;
return $this;
}
+
+ /**
+ * Return a cloned request with a different scheme (http or https)
+ */
+ public function withScheme(string $scheme): self
+ {
+ $clone = clone $this;
+ $clone->scheme = strtolower($scheme) === 'http' ? 'http' : 'https';
+ return $clone;
+ }
}
diff --git a/backend/app/Modules/Lemmy/Services/LemmyApiService.php b/backend/app/Modules/Lemmy/Services/LemmyApiService.php
index 108c431..3329703 100644
--- a/backend/app/Modules/Lemmy/Services/LemmyApiService.php
+++ b/backend/app/Modules/Lemmy/Services/LemmyApiService.php
@@ -18,27 +18,61 @@ public function __construct(string $instance)
public function login(string $username, string $password): ?string
{
- try {
- $request = new LemmyRequest($this->instance);
- $response = $request->post('user/login', [
- 'username_or_email' => $username,
- 'password' => $password,
- ]);
+ // Try HTTPS first; on failure, optionally retry with HTTP to support dev instances
+ $schemesToTry = [];
+ if (preg_match('/^https?:\/\//i', $this->instance)) {
+ // Preserve user-provided scheme as first try
+ $schemesToTry[] = strtolower(str_starts_with($this->instance, 'http://') ? 'http' : 'https');
+ } else {
+ // Default order: https then http
+ $schemesToTry = ['https', 'http'];
+ }
- if (!$response->successful()) {
- logger()->error('Lemmy login failed', [
- 'status' => $response->status(),
- 'body' => $response->body()
+ foreach ($schemesToTry as $idx => $scheme) {
+ try {
+ $request = new LemmyRequest($this->instance);
+ // ensure scheme used matches current attempt
+ $request = $request->withScheme($scheme);
+
+ $response = $request->post('user/login', [
+ 'username_or_email' => $username,
+ 'password' => $password,
]);
+
+ if (!$response->successful()) {
+ $responseBody = $response->body();
+ logger()->error('Lemmy login failed', [
+ 'status' => $response->status(),
+ 'body' => $responseBody,
+ 'scheme' => $scheme,
+ ]);
+
+ // Check if it's a rate limit error
+ if (str_contains($responseBody, 'rate_limit_error')) {
+ throw new Exception('Rate limited by Lemmy instance. Please wait a moment and try again.');
+ }
+
+ // If first attempt failed and there is another scheme to try, continue loop
+ if ($idx === 0 && count($schemesToTry) > 1) {
+ continue;
+ }
+
+ return null;
+ }
+
+ $data = $response->json();
+ return $data['jwt'] ?? null;
+ } catch (Exception $e) {
+ logger()->error('Lemmy login exception', ['error' => $e->getMessage(), 'scheme' => $scheme]);
+ // If this was the first attempt and HTTPS, try HTTP next
+ if ($idx === 0 && in_array('http', $schemesToTry, true)) {
+ continue;
+ }
return null;
}
-
- $data = $response->json();
- return $data['jwt'] ?? null;
- } catch (Exception $e) {
- logger()->error('Lemmy login exception', ['error' => $e->getMessage()]);
- return null;
}
+
+ return null;
}
public function getCommunityId(string $communityName, string $token): int
diff --git a/backend/app/Services/Auth/LemmyAuthService.php b/backend/app/Services/Auth/LemmyAuthService.php
index b3b4fe1..c9f53a2 100644
--- a/backend/app/Services/Auth/LemmyAuthService.php
+++ b/backend/app/Services/Auth/LemmyAuthService.php
@@ -72,6 +72,10 @@ public function authenticate(string $instanceUrl, string $username, string $pass
// Re-throw PlatformAuthExceptions as-is to avoid nesting
throw $e;
} catch (Exception $e) {
+ // Check if it's a rate limit error
+ if (str_contains($e->getMessage(), 'Rate limited by')) {
+ throw new PlatformAuthException(PlatformEnum::LEMMY, $e->getMessage());
+ }
// For other exceptions, throw a clean PlatformAuthException
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Connection failed');
}
diff --git a/backend/app/Services/DashboardStatsService.php b/backend/app/Services/DashboardStatsService.php
index 02b095f..a0fb821 100644
--- a/backend/app/Services/DashboardStatsService.php
+++ b/backend/app/Services/DashboardStatsService.php
@@ -85,10 +85,17 @@ public function getSystemStats(): array
')
->first();
+ $accountStats = DB::table('platform_accounts')
+ ->selectRaw('
+ COUNT(*) as total_platform_accounts,
+ SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_platform_accounts
+ ')
+ ->first();
+
$channelStats = DB::table('platform_channels')
->selectRaw('
- COUNT(*) as total_channels,
- SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_channels
+ COUNT(*) as total_platform_channels,
+ SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active_platform_channels
')
->first();
@@ -102,8 +109,10 @@ public function getSystemStats(): array
return [
'total_feeds' => $feedStats->total_feeds,
'active_feeds' => $feedStats->active_feeds,
- 'total_channels' => $channelStats->total_channels,
- 'active_channels' => $channelStats->active_channels,
+ 'total_platform_accounts' => $accountStats->total_platform_accounts,
+ 'active_platform_accounts' => $accountStats->active_platform_accounts,
+ 'total_platform_channels' => $channelStats->total_platform_channels,
+ 'active_platform_channels' => $channelStats->active_platform_channels,
'total_routes' => $routeStats->total_routes,
'active_routes' => $routeStats->active_routes,
];
diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php
index 264345a..d074784 100644
--- a/backend/database/seeders/DatabaseSeeder.php
+++ b/backend/database/seeders/DatabaseSeeder.php
@@ -8,6 +8,9 @@ class DatabaseSeeder extends Seeder
{
public function run(): void
{
- $this->call(SettingsSeeder::class);
+ $this->call([
+ SettingsSeeder::class,
+ LanguageSeeder::class,
+ ]);
}
}
diff --git a/backend/database/seeders/LanguageSeeder.php b/backend/database/seeders/LanguageSeeder.php
new file mode 100644
index 0000000..26cde8a
--- /dev/null
+++ b/backend/database/seeders/LanguageSeeder.php
@@ -0,0 +1,29 @@
+ 'en', 'name' => 'English', 'native_name' => 'English', 'is_active' => true],
+ ['short_code' => 'nl', 'name' => 'Dutch', 'native_name' => 'Nederlands', 'is_active' => true],
+ ['short_code' => 'fr', 'name' => 'French', 'native_name' => 'Français', 'is_active' => true],
+ ['short_code' => 'de', 'name' => 'German', 'native_name' => 'Deutsch', 'is_active' => true],
+ ['short_code' => 'es', 'name' => 'Spanish', 'native_name' => 'Español', 'is_active' => true],
+ ['short_code' => 'it', 'name' => 'Italian', 'native_name' => 'Italiano', 'is_active' => true],
+ ];
+
+ foreach ($languages as $lang) {
+ Language::updateOrCreate(
+ ['short_code' => $lang['short_code']],
+ $lang
+ );
+ }
+ }
+}
diff --git a/backend/routes/api.php b/backend/routes/api.php
index 3966f2d..56d4e75 100644
--- a/backend/routes/api.php
+++ b/backend/routes/api.php
@@ -40,6 +40,7 @@
Route::post('/onboarding/platform', [OnboardingController::class, 'createPlatform'])->name('api.onboarding.platform');
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/route', [OnboardingController::class, 'createRoute'])->name('api.onboarding.route');
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');
diff --git a/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php b/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php
index dc98eb2..8bb2e60 100644
--- a/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php
+++ b/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php
@@ -7,6 +7,7 @@
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\PlatformInstance;
+use App\Models\Route;
use App\Models\Setting;
use App\Services\Auth\LemmyAuthService;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -43,6 +44,7 @@ public function test_status_shows_needs_onboarding_when_no_components_exist()
'has_platform_account' => false,
'has_feed' => false,
'has_channel' => false,
+ 'has_route' => false,
'onboarding_skipped' => false,
],
]);
@@ -63,6 +65,7 @@ public function test_status_shows_feed_step_when_platform_account_exists()
'has_platform_account' => true,
'has_feed' => false,
'has_channel' => false,
+ 'has_route' => false,
],
]);
}
@@ -83,6 +86,29 @@ public function test_status_shows_channel_step_when_platform_account_and_feed_ex
'has_platform_account' => true,
'has_feed' => true,
'has_channel' => false,
+ 'has_route' => false,
+ ],
+ ]);
+ }
+
+ public function test_status_shows_route_step_when_platform_account_feed_and_channel_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' => true,
+ 'current_step' => 'route',
+ 'has_platform_account' => true,
+ 'has_feed' => true,
+ 'has_channel' => true,
+ 'has_route' => false,
],
]);
}
@@ -92,6 +118,7 @@ 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]);
+ Route::factory()->create(['is_active' => true]);
$response = $this->getJson('/api/v1/onboarding/status');
@@ -104,6 +131,7 @@ public function test_status_shows_no_onboarding_needed_when_all_components_exist
'has_platform_account' => true,
'has_feed' => true,
'has_channel' => true,
+ 'has_route' => true,
],
]);
}
@@ -127,6 +155,7 @@ public function test_status_shows_no_onboarding_needed_when_skipped()
'has_platform_account' => false,
'has_feed' => false,
'has_channel' => false,
+ 'has_route' => false,
'onboarding_skipped' => true,
],
]);
@@ -238,6 +267,47 @@ public function test_create_channel_creates_channel_successfully()
]);
}
+ public function test_create_route_validates_required_fields()
+ {
+ $response = $this->postJson('/api/v1/onboarding/route', []);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['feed_id', 'platform_channel_id']);
+ }
+
+ public function test_create_route_creates_route_successfully()
+ {
+ $feed = Feed::factory()->create();
+ $platformChannel = PlatformChannel::factory()->create();
+
+ $routeData = [
+ 'feed_id' => $feed->id,
+ 'platform_channel_id' => $platformChannel->id,
+ 'priority' => 75,
+ 'filters' => ['keyword' => 'test'],
+ ];
+
+ $response = $this->postJson('/api/v1/onboarding/route', $routeData);
+
+ $response->assertStatus(200)
+ ->assertJson([
+ 'success' => true,
+ 'data' => [
+ 'feed_id' => $feed->id,
+ 'platform_channel_id' => $platformChannel->id,
+ 'priority' => 75,
+ 'is_active' => true,
+ ]
+ ]);
+
+ $this->assertDatabaseHas('routes', [
+ 'feed_id' => $feed->id,
+ 'platform_channel_id' => $platformChannel->id,
+ 'priority' => 75,
+ 'is_active' => true,
+ ]);
+ }
+
public function test_complete_onboarding_returns_success()
{
$response = $this->postJson('/api/v1/onboarding/complete');
diff --git a/docker/dev/podman/container-start.sh b/docker/dev/podman/container-start.sh
index 175e412..2f95f04 100755
--- a/docker/dev/podman/container-start.sh
+++ b/docker/dev/podman/container-start.sh
@@ -11,9 +11,27 @@ echo "Installing PHP dependencies..."
cd /var/www/html/backend
composer install --no-interaction
-# Generate application key
-echo "Generating application key..."
-php artisan key:generate --force
+# Ensure APP_KEY is set in backend/.env
+ENV_APP_KEY="${APP_KEY}"
+if [ -n "$ENV_APP_KEY" ]; then
+ echo "Using APP_KEY from environment"
+ sed -i "s|^APP_KEY=.*|APP_KEY=${ENV_APP_KEY}|" /var/www/html/backend/.env || true
+fi
+
+# Generate application key if still missing
+CURRENT_APP_KEY=$(grep "^APP_KEY=" /var/www/html/backend/.env | cut -d'=' -f2)
+if [ -z "$CURRENT_APP_KEY" ]; then
+ echo "Generating application key..."
+ php artisan key:generate --force
+fi
+
+# Verify APP_KEY
+APP_KEY=$(grep "^APP_KEY=" /var/www/html/backend/.env | cut -d'=' -f2)
+if [ -n "$APP_KEY" ]; then
+ echo "✅ APP_KEY successfully set."
+else
+ echo "❌ ERROR: APP_KEY not set!"
+fi
# Wait for database to be ready
echo "Waiting for database..."
@@ -45,8 +63,11 @@ npm run dev -- --host 0.0.0.0 --port 5173 &
cd /var/www/html/backend
php artisan serve --host=127.0.0.1 --port=8000 &
+# Start Horizon (manages queue workers in dev)
+php artisan horizon &
+
# Start nginx
nginx -g "daemon off;" &
# Wait for background processes
-wait
\ No newline at end of file
+wait
diff --git a/frontend/src/contexts/OnboardingContext.tsx b/frontend/src/contexts/OnboardingContext.tsx
index b5a1525..9557078 100644
--- a/frontend/src/contexts/OnboardingContext.tsx
+++ b/frontend/src/contexts/OnboardingContext.tsx
@@ -28,17 +28,19 @@ export const OnboardingProvider: React.FC = ({ children
const needsOnboarding = onboardingStatus?.needs_onboarding ?? false;
const isOnOnboardingPage = location.pathname.startsWith('/onboarding');
- // Redirect logic - only redirect if user explicitly navigates to a protected route
+ // Redirect logic
React.useEffect(() => {
if (isLoading) return;
- // Only redirect if user doesn't need onboarding but is on onboarding pages
+ // If user doesn't need onboarding but is on onboarding pages, redirect to dashboard
if (!needsOnboarding && isOnOnboardingPage) {
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
+ // If user needs onboarding but is not on onboarding pages, redirect to onboarding
+ if (needsOnboarding && !isOnOnboardingPage) {
+ navigate('/onboarding', { replace: true });
+ }
}, [onboardingStatus, isLoading, needsOnboarding, isOnOnboardingPage, navigate]);
const value: OnboardingContextValue = {
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 01a004a..8b28dcf 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -147,16 +147,19 @@ export interface PlatformInstance {
export interface OnboardingStatus {
needs_onboarding: boolean;
- current_step: 'platform' | 'feed' | 'channel' | 'complete' | null;
+ current_step: 'platform' | 'feed' | 'channel' | 'route' | 'complete' | null;
has_platform_account: boolean;
has_feed: boolean;
has_channel: boolean;
+ has_route: boolean;
onboarding_skipped: boolean;
}
export interface OnboardingOptions {
languages: Language[];
platform_instances: PlatformInstance[];
+ feeds: Feed[];
+ platform_channels: PlatformChannel[];
}
export interface PlatformAccountRequest {
@@ -181,6 +184,25 @@ export interface ChannelRequest {
description?: string;
}
+export interface Route {
+ feed_id: number;
+ platform_channel_id: number;
+ is_active: boolean;
+ priority: number;
+ filters: Record;
+ created_at: string;
+ updated_at: string;
+ feed?: Feed;
+ platform_channel?: PlatformChannel;
+}
+
+export interface RouteRequest {
+ feed_id: number;
+ platform_channel_id: number;
+ priority?: number;
+ filters?: Record;
+}
+
// API Client class
class ApiClient {
constructor() {
@@ -226,8 +248,8 @@ class ApiClient {
// Feeds endpoints
async getFeeds(): Promise {
- const response = await axios.get>('/feeds');
- return response.data.data;
+ const response = await axios.get>('/feeds');
+ return response.data.data.feeds;
}
async createFeed(data: Partial): Promise {
@@ -286,6 +308,11 @@ class ApiClient {
return response.data.data;
}
+ async createRouteForOnboarding(data: RouteRequest): Promise {
+ const response = await axios.post>('/onboarding/route', data);
+ return response.data.data;
+ }
+
async completeOnboarding(): Promise {
await axios.post('/onboarding/complete');
}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 2e18edd..1dec957 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import './index.css';
import App from './App';
+import { OnboardingProvider } from './contexts/OnboardingContext';
// Create React Query client
const queryClient = new QueryClient({
@@ -19,7 +20,9 @@ createRoot(document.getElementById('root')!).render(
-
+
+
+
,
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index 24a130e..9deb1b8 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -49,70 +49,8 @@ const Dashboard: React.FC = () => {
- {/* Article Statistics */}
-
-
Article Statistics
-
-
-
-
-
-
-
-
Articles Today
-
- {articleStats?.total_today || 0}
-
-
-
-
-
-
-
-
-
-
-
-
Articles This Week
-
- {articleStats?.total_week || 0}
-
-
-
-
-
-
-
-
-
-
-
-
Approved Today
-
- {articleStats?.approved_today || 0}
-
-
-
-
-
-
-
-
-
-
-
-
Approval Rate
-
- {articleStats?.approval_percentage_today?.toFixed(1) || 0}%
-
-
-
-
-
-
-
{/* System Statistics */}
-
+
System Overview
@@ -184,6 +122,68 @@ const Dashboard: React.FC = () => {
+
+ {/* Article Statistics */}
+
+
Article Statistics
+
+
+
+
+
+
+
+
Articles Today
+
+ {articleStats?.total_today || 0}
+
+
+
+
+
+
+
+
+
+
+
+
Articles This Week
+
+ {articleStats?.total_week || 0}
+
+
+
+
+
+
+
+
+
+
+
+
Approved Today
+
+ {articleStats?.approved_today || 0}
+
+
+
+
+
+
+
+
+
+
+
+
Approval Rate
+
+ {articleStats?.approval_percentage_today?.toFixed(1) || 0}%
+
+
+
+
+
+
);
};
diff --git a/frontend/src/pages/onboarding/OnboardingWizard.tsx b/frontend/src/pages/onboarding/OnboardingWizard.tsx
index b8379bb..20929da 100644
--- a/frontend/src/pages/onboarding/OnboardingWizard.tsx
+++ b/frontend/src/pages/onboarding/OnboardingWizard.tsx
@@ -5,6 +5,7 @@ import WelcomeStep from './steps/WelcomeStep';
import PlatformStep from './steps/PlatformStep';
import FeedStep from './steps/FeedStep';
import ChannelStep from './steps/ChannelStep';
+import RouteStep from './steps/RouteStep';
import CompleteStep from './steps/CompleteStep';
const OnboardingWizard: React.FC = () => {
@@ -15,6 +16,7 @@ const OnboardingWizard: React.FC = () => {
} />
} />
} />
+ } />
} />
} />
diff --git a/frontend/src/pages/onboarding/steps/ChannelStep.tsx b/frontend/src/pages/onboarding/steps/ChannelStep.tsx
index b15b244..24dcf9e 100644
--- a/frontend/src/pages/onboarding/steps/ChannelStep.tsx
+++ b/frontend/src/pages/onboarding/steps/ChannelStep.tsx
@@ -1,10 +1,11 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
-import { useMutation, useQuery } from '@tanstack/react-query';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient, type ChannelRequest, type Language, type PlatformInstance } from '../../../lib/api';
const ChannelStep: React.FC = () => {
const navigate = useNavigate();
+ const queryClient = useQueryClient();
const [formData, setFormData] = useState({
name: '',
platform_instance_id: 0,
@@ -22,7 +23,9 @@ const ChannelStep: React.FC = () => {
const createChannelMutation = useMutation({
mutationFn: (data: ChannelRequest) => apiClient.createChannelForOnboarding(data),
onSuccess: () => {
- navigate('/onboarding/complete');
+ // Invalidate onboarding status cache
+ queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
+ navigate('/onboarding/route');
},
onError: (error: any) => {
if (error.response?.data?.errors) {
diff --git a/frontend/src/pages/onboarding/steps/CompleteStep.tsx b/frontend/src/pages/onboarding/steps/CompleteStep.tsx
index 9662af5..c5be797 100644
--- a/frontend/src/pages/onboarding/steps/CompleteStep.tsx
+++ b/frontend/src/pages/onboarding/steps/CompleteStep.tsx
@@ -1,19 +1,25 @@
import React from 'react';
import { Link, useNavigate } from 'react-router-dom';
-import { useMutation } from '@tanstack/react-query';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../../../lib/api';
const CompleteStep: React.FC = () => {
const navigate = useNavigate();
+ const queryClient = useQueryClient();
const completeOnboardingMutation = useMutation({
mutationFn: () => apiClient.completeOnboarding(),
onSuccess: () => {
+ // Invalidate onboarding status cache to ensure proper redirect logic
+ queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
+ queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] });
navigate('/dashboard');
},
onError: (error) => {
console.error('Failed to complete onboarding:', error);
- // Still navigate to dashboard even if completion fails
+ // Still invalidate cache and navigate to dashboard even if completion fails
+ queryClient.invalidateQueries({ queryKey: ['onboarding-status'] });
+ queryClient.invalidateQueries({ queryKey: ['dashboard-stats'] });
navigate('/dashboard');
}
});
diff --git a/frontend/src/pages/onboarding/steps/FeedStep.tsx b/frontend/src/pages/onboarding/steps/FeedStep.tsx
index 4fcb253..79fcd4f 100644
--- a/frontend/src/pages/onboarding/steps/FeedStep.tsx
+++ b/frontend/src/pages/onboarding/steps/FeedStep.tsx
@@ -136,8 +136,8 @@ const FeedStep: React.FC = () => {