diff --git a/docs/user-guide/onboarding.md b/docs/user-guide/onboarding.md new file mode 100644 index 00000000..a49b487f --- /dev/null +++ b/docs/user-guide/onboarding.md @@ -0,0 +1,211 @@ +# Onboarding Wizard + +The Routstr onboarding wizard is an interactive setup guide that helps new users configure their AI routing system quickly and easily. + +## Overview + +The onboarding wizard automatically appears when: +- No admin password has been set, **OR** +- No upstream providers are configured + +This ensures that essential setup steps are completed before the system is used. + +## Features + +### Multi-Step Setup Process + +The wizard guides users through five key steps: + +#### 1. Welcome Screen +- Introduces Routstr and its capabilities +- Explains what will be configured during onboarding +- Shows key features: + - Admin access setup + - AI provider connections + - Node configuration + +#### 2. Set Admin Password +- Secure the admin dashboard with a password +- Password must be at least 8 characters +- Confirms password to prevent typos +- Automatically logs in the user after setup + +#### 3. Add AI Provider +- Connect the first AI provider to start routing requests +- Supports multiple provider types: + - OpenRouter (default) + - OpenAI + - Anthropic + - Azure + - Groq + - Perplexity + - xAI + - Fireworks + - And more... +- Provides direct links to get API keys +- Validates API key presence before proceeding + +#### 4. Optional Settings +- Configure node name and description +- Can be skipped if desired +- Settings can be changed later in the Settings page + +#### 5. Completion +- Confirms successful setup +- Provides next steps: + - Add more providers + - Configure models + - Monitor usage +- Redirects to dashboard + +## User Experience + +### Visual Design +- Progress bar shows completion percentage +- Step counter (e.g., "Step 2 of 5") +- Consistent iconography for each step +- Clear navigation with Back/Next buttons + +### Skip Option +- "Skip for now" button on the welcome screen +- Allows advanced users to configure manually +- Marks onboarding as completed + +### Persistence +- Uses localStorage to track completion +- Won't show again once completed +- Can be reset by clearing browser storage + +## Technical Implementation + +### Files Created/Modified + +1. **`/ui/components/onboarding-wizard.tsx`** + - Main onboarding component + - Multi-step wizard logic + - Form validation and submission + - API integration + +2. **`/ui/lib/onboarding.ts`** + - Onboarding status checking + - Persistence management + - Determines if onboarding is needed + +3. **`/ui/app/page.tsx`** + - Integration point on dashboard + - Status check on load + - Shows wizard when needed + +4. **`/ui/lib/api/client.ts`** + - Added `setAuthToken` method + - Handles authentication after setup + +### API Endpoints Used + +- `POST /admin/api/setup` - Initial password setup (no auth required) +- `POST /admin/api/login` - Login after setup +- `GET /admin/api/provider-types` - List available providers +- `POST /admin/api/upstream-providers` - Create provider +- `PATCH /admin/api/settings` - Update node settings + +### State Management + +The wizard uses React Query for: +- Fetching provider types +- Creating providers +- Updating settings +- Managing loading states + +### Error Handling + +- Validates password length and match +- Requires API key for provider setup +- Shows user-friendly error messages +- Handles API failures gracefully + +## Security Considerations + +1. **Password Requirements** + - Minimum 8 characters + - Must match confirmation + - Stored securely in backend + +2. **API Key Storage** + - Keys are sent securely to backend + - Never stored in localStorage + - Transmitted over HTTPS in production + +3. **Authentication Flow** + - Setup endpoint doesn't require auth + - Subsequent steps use JWT token + - Token stored with expiration + +## Customization + +### Adding New Steps + +To add a step to the wizard: + +1. Add step definition to `steps` array: +```typescript +{ + id: 'mystep', + title: 'My Step Title', + description: 'Step description', + icon: , +} +``` + +2. Add case to `renderStepContent()` switch +3. Add validation logic to `canProceed()` +4. Add submission logic to `handleNext()` + +### Modifying Conditions + +To change when onboarding appears, edit `checkOnboardingStatus()` in `/ui/lib/onboarding.ts`. + +### Resetting Onboarding + +To force onboarding to appear again: +```javascript +localStorage.removeItem('onboardingCompleted'); +``` + +Or use the helper: +```typescript +import { resetOnboarding } from '@/lib/onboarding'; +resetOnboarding(); +``` + +## Testing + +### Manual Testing Checklist + +- [ ] Onboarding appears on first visit +- [ ] Password validation works correctly +- [ ] Provider creation succeeds with valid API key +- [ ] Optional settings can be skipped +- [ ] Completion redirects to dashboard +- [ ] Skip button works on welcome screen +- [ ] Back button navigates correctly +- [ ] Progress bar updates accurately +- [ ] Error messages display properly +- [ ] Authentication persists after setup + +### Testing Fresh Installation + +1. Clear browser localStorage +2. Ensure no admin password is set in backend +3. Ensure no providers exist +4. Visit dashboard +5. Verify onboarding appears automatically + +## Future Enhancements + +Potential improvements: +- Add model configuration step +- Include wallet setup guidance +- Provide API testing step +- Add video tutorials +- Support multiple languages +- Enable custom provider configurations diff --git a/ui/app/page.tsx b/ui/app/page.tsx index da8ffba3..c2d2b60d 100644 --- a/ui/app/page.tsx +++ b/ui/app/page.tsx @@ -8,11 +8,14 @@ import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { DetailedWalletBalance } from '@/components/detailed-wallet-balance'; import { TemporaryBalances } from '@/components/temporary-balances'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import { OnboardingWizard } from '@/components/onboarding-wizard'; import type { DisplayUnit } from '@/lib/types/units'; import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate'; +import { checkOnboardingStatus } from '@/lib/onboarding'; export default function Page() { const [displayUnit, setDisplayUnit] = useState('sat'); + const [showOnboarding, setShowOnboarding] = useState(false); const { data: btcUsdPrice } = useQuery({ queryKey: ['btc-usd-price'], @@ -21,6 +24,13 @@ export default function Page() { staleTime: 60_000, }); + const { data: onboardingStatus } = useQuery({ + queryKey: ['onboarding-status'], + queryFn: checkOnboardingStatus, + refetchOnWindowFocus: false, + retry: false, + }); + const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null; useEffect(() => { @@ -29,60 +39,75 @@ export default function Page() { } }, [displayUnit, usdPerSat]); - return ( - - - - -
-
-
-

- Admin Dashboard -

-

- Monitor and manage wallet balances -

-
-
- { - if (value) { - setDisplayUnit(value as DisplayUnit); - } - }} - variant='outline' - size='sm' - > - mSAT - sat - - USD - - -
-
+ useEffect(() => { + if (onboardingStatus?.needsOnboarding) { + setShowOnboarding(true); + } + }, [onboardingStatus]); -
-
- + return ( + <> + { + setShowOnboarding(false); + window.location.reload(); + }} + /> + + + + +
+
+
+

+ Admin Dashboard +

+

+ Monitor and manage wallet balances +

+
+
+ { + if (value) { + setDisplayUnit(value as DisplayUnit); + } + }} + variant='outline' + size='sm' + > + mSAT + sat + + USD + + +
-
- + +
+
+ +
+
+ +
-
-
-
+ + + ); } diff --git a/ui/components/onboarding-wizard.tsx b/ui/components/onboarding-wizard.tsx new file mode 100644 index 00000000..8671a206 --- /dev/null +++ b/ui/components/onboarding-wizard.tsx @@ -0,0 +1,633 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Progress } from '@/components/ui/progress'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + AlertCircle, + CheckCircle2, + ChevronRight, + ChevronLeft, + Rocket, + Server, + Lock, + Settings as SettingsIcon, + Sparkles, +} from 'lucide-react'; +import { AdminService, CreateUpstreamProvider, ProviderType } from '@/lib/api/services/admin'; +import { apiClient } from '@/lib/api/client'; +import { toast } from 'sonner'; + +interface OnboardingStep { + id: string; + title: string; + description: string; + icon: React.ReactNode; +} + +const steps: OnboardingStep[] = [ + { + id: 'welcome', + title: 'Welcome to Routstr!', + description: 'Let\'s get your AI routing system set up in just a few steps', + icon: , + }, + { + id: 'password', + title: 'Set Admin Password', + description: 'Secure your admin dashboard with a strong password', + icon: , + }, + { + id: 'provider', + title: 'Add AI Provider', + description: 'Connect your first AI provider to start routing requests', + icon: , + }, + { + id: 'settings', + title: 'Optional Settings', + description: 'Configure additional settings for your node', + icon: , + }, + { + id: 'complete', + title: 'All Set!', + description: 'Your Routstr node is ready to route AI requests', + icon: , + }, +]; + +interface OnboardingWizardProps { + open: boolean; + onComplete: () => void; +} + +export function OnboardingWizard({ open, onComplete }: OnboardingWizardProps) { + const queryClient = useQueryClient(); + const [currentStep, setCurrentStep] = useState(0); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [passwordError, setPasswordError] = useState(''); + + const [providerData, setProviderData] = useState({ + provider_type: 'openrouter', + base_url: 'https://openrouter.ai/api/v1', + api_key: '', + api_version: null, + enabled: true, + }); + + const [nodeName, setNodeName] = useState(''); + const [nodeDescription, setNodeDescription] = useState(''); + + const { data: providerTypes = [] } = useQuery({ + queryKey: ['provider-types'], + queryFn: () => AdminService.getProviderTypes(), + enabled: open, + }); + + const setupPasswordMutation = useMutation({ + mutationFn: async (pwd: string) => { + return await apiClient.post<{ ok: boolean; token?: string; expires_in?: number }>( + '/admin/api/setup', + { password: pwd } + ); + }, + onSuccess: async (data) => { + if (data.token) { + const expiresIn = data.expires_in || 3600; + apiClient.setAuthToken(data.token, expiresIn); + } + + try { + const loginData = await AdminService.login(password); + if (loginData.token) { + apiClient.setAuthToken(loginData.token, loginData.expires_in); + } + } catch (error) { + console.error('Login after setup failed:', error); + } + + toast.success('Admin password set successfully'); + setCurrentStep((prev) => prev + 1); + }, + onError: (error: Error) => { + setPasswordError(error.message); + toast.error(`Failed to set password: ${error.message}`); + }, + }); + + const createProviderMutation = useMutation({ + mutationFn: (data: CreateUpstreamProvider) => + AdminService.createUpstreamProvider(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['upstream-providers'] }); + toast.success('Provider added successfully'); + setCurrentStep((prev) => prev + 1); + }, + onError: (error: Error) => { + toast.error(`Failed to add provider: ${error.message}`); + }, + }); + + const updateSettingsMutation = useMutation({ + mutationFn: async (settings: Record) => { + return await AdminService.updateSettings(settings); + }, + onSuccess: () => { + toast.success('Settings saved successfully'); + setCurrentStep((prev) => prev + 1); + }, + onError: (error: Error) => { + toast.error(`Failed to save settings: ${error.message}`); + }, + }); + + const handlePasswordSubmit = () => { + setPasswordError(''); + + if (password.length < 8) { + setPasswordError('Password must be at least 8 characters'); + return; + } + + if (password !== confirmPassword) { + setPasswordError('Passwords do not match'); + return; + } + + setupPasswordMutation.mutate(password); + }; + + const handleProviderSubmit = () => { + if (!providerData.api_key.trim()) { + toast.error('API key is required'); + return; + } + createProviderMutation.mutate(providerData); + }; + + const handleSettingsSubmit = () => { + const settings: Record = {}; + if (nodeName.trim()) { + settings.name = nodeName; + } + if (nodeDescription.trim()) { + settings.description = nodeDescription; + } + + if (Object.keys(settings).length > 0) { + updateSettingsMutation.mutate(settings); + } else { + setCurrentStep((prev) => prev + 1); + } + }; + + const handleComplete = () => { + localStorage.setItem('onboardingCompleted', 'true'); + onComplete(); + }; + + const getDefaultBaseUrl = (type: string): string => { + const providerType = providerTypes.find((pt: ProviderType) => pt.id === type); + return providerType?.default_base_url || ''; + }; + + const hasFixedBaseUrl = (type: string): boolean => { + const providerType = providerTypes.find((pt: ProviderType) => pt.id === type); + return providerType?.fixed_base_url || false; + }; + + const getPlatformUrl = (type: string): string | null => { + const providerType = providerTypes.find((pt: ProviderType) => pt.id === type); + return providerType?.platform_url || null; + }; + + const progress = ((currentStep + 1) / steps.length) * 100; + + const renderStepContent = () => { + const step = steps[currentStep]; + + switch (step.id) { + case 'welcome': + return ( +
+
{step.icon}
+
+

+ Welcome to Routstr - Your AI Request Router +

+

+ Routstr is a powerful payment-enabled proxy that routes AI requests to + various providers. This wizard will help you: +

+
+
+ +
+

Set up admin access

+

+ Secure your dashboard with a password +

+
+
+
+ +
+

Connect AI providers

+

+ Link OpenRouter, OpenAI, Anthropic, and more +

+
+
+
+ +
+

Configure your node

+

+ Customize settings to match your needs +

+
+
+
+
+
+ ); + + case 'password': + return ( +
+
{step.icon}
+
+
+

+ Create Admin Password +

+

+ This password will be used to access the admin dashboard +

+
+ + {passwordError && ( + + + {passwordError} + + )} + +
+
+ + setPassword(e.target.value)} + placeholder="Enter password (min 8 characters)" + /> +
+
+ + setConfirmPassword(e.target.value)} + placeholder="Re-enter password" + /> +
+
+ + + + + Keep this password safe. You'll need it to access the admin panel. + + +
+
+ ); + + case 'provider': + return ( +
+
{step.icon}
+
+
+

+ Connect Your First AI Provider +

+

+ Add an API provider to start routing AI requests +

+
+ +
+
+ + +
+ +
+ + + setProviderData({ ...providerData, base_url: e.target.value }) + } + placeholder="https://api.example.com/v1" + disabled={hasFixedBaseUrl(providerData.provider_type)} + /> +
+ +
+
+ + {getPlatformUrl(providerData.provider_type) && ( + + Get Your API Key → + + )} +
+ + setProviderData({ ...providerData, api_key: e.target.value }) + } + placeholder="sk-..." + /> +
+ + {providerData.provider_type === 'azure' && ( +
+ + + setProviderData({ + ...providerData, + api_version: e.target.value || null, + }) + } + placeholder="2024-02-15-preview" + /> +
+ )} +
+ + + + + Your API key is stored securely and only used to authenticate with the + provider. You can add more providers later from the Providers page. + + +
+
+ ); + + case 'settings': + return ( +
+
{step.icon}
+
+
+

+ Customize Your Node (Optional) +

+

+ Give your node a name and description - you can change these later +

+
+ +
+
+ + setNodeName(e.target.value)} + placeholder="My Routstr Node" + /> +
+ +
+ + setNodeDescription(e.target.value)} + placeholder="AI request router with payment support" + /> +
+
+ + + + + These settings are optional. You can configure more advanced options + like Nostr integration and Cashu mints later in the Settings page. + + +
+
+ ); + + case 'complete': + return ( +
+
{step.icon}
+
+

You're All Set! 🎉

+

+ Your Routstr node is configured and ready to route AI requests. Here's + what you can do next: +

+
+ + + Add More Providers + + Visit the Providers page to add more AI providers + + + + + + Configure Models + + Customize model pricing and settings in the Models page + + + + + + Monitor Usage + + Track balances and transactions from the dashboard + + + +
+
+
+ ); + + default: + return null; + } + }; + + const canProceed = () => { + const step = steps[currentStep]; + + switch (step.id) { + case 'welcome': + return true; + case 'password': + return password.length >= 8 && password === confirmPassword; + case 'provider': + return providerData.api_key.trim().length > 0; + case 'settings': + return true; + case 'complete': + return true; + default: + return false; + } + }; + + const handleNext = () => { + const step = steps[currentStep]; + + switch (step.id) { + case 'welcome': + setCurrentStep((prev) => prev + 1); + break; + case 'password': + handlePasswordSubmit(); + break; + case 'provider': + handleProviderSubmit(); + break; + case 'settings': + handleSettingsSubmit(); + break; + case 'complete': + handleComplete(); + break; + } + }; + + const isLoading = + setupPasswordMutation.isPending || + createProviderMutation.isPending || + updateSettingsMutation.isPending; + + return ( + {}}> + e.preventDefault()}> + + {steps[currentStep].title} + {steps[currentStep].description} + + +
+ +
+ Step {currentStep + 1} of {steps.length} +
+
+ + {renderStepContent()} + + +
+ + {currentStep === 0 && ( + + )} +
+ +
+
+
+ ); +} diff --git a/ui/lib/api/client.ts b/ui/lib/api/client.ts index c7b45794..3413ce5f 100644 --- a/ui/lib/api/client.ts +++ b/ui/lib/api/client.ts @@ -10,6 +10,10 @@ class ApiClient { return ConfigurationService.getAuthHeaders(); } + setAuthToken(token: string, expiresIn: number = 3600): void { + ConfigurationService.setToken(token, expiresIn); + } + private handleAuthError(error: unknown): void { if (axios.isAxiosError(error)) { const axiosError = error as AxiosError; diff --git a/ui/lib/onboarding.ts b/ui/lib/onboarding.ts new file mode 100644 index 00000000..29520685 --- /dev/null +++ b/ui/lib/onboarding.ts @@ -0,0 +1,68 @@ +import { AdminService } from './api/services/admin'; +import axios from 'axios'; + +export interface OnboardingStatus { + needsOnboarding: boolean; + hasAdminPassword: boolean; + hasProviders: boolean; + onboardingDismissed: boolean; +} + +export async function checkOnboardingStatus(): Promise { + const onboardingDismissed = + typeof window !== 'undefined' + ? localStorage.getItem('onboardingCompleted') === 'true' + : false; + + if (onboardingDismissed) { + return { + needsOnboarding: false, + hasAdminPassword: true, + hasProviders: true, + onboardingDismissed: true, + }; + } + + let hasAdminPassword = true; + let hasProviders = false; + + try { + const providers = await AdminService.getUpstreamProviders(); + hasProviders = providers.length > 0; + } catch (error) { + if (axios.isAxiosError(error)) { + const statusCode = error.response?.status; + const errorData = error.response?.data; + + if (statusCode === 500 && + (errorData?.detail === 'Admin password not configured' || + errorData?.message === 'Admin password not configured')) { + hasAdminPassword = false; + } else if (statusCode === 401 || statusCode === 403) { + hasAdminPassword = false; + } + } + hasProviders = false; + } + + const needsOnboarding = !hasAdminPassword || !hasProviders; + + return { + needsOnboarding, + hasAdminPassword, + hasProviders, + onboardingDismissed: false, + }; +} + +export function dismissOnboarding(): void { + if (typeof window !== 'undefined') { + localStorage.setItem('onboardingCompleted', 'true'); + } +} + +export function resetOnboarding(): void { + if (typeof window !== 'undefined') { + localStorage.removeItem('onboardingCompleted'); + } +}