mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-22 12:22:20 +00:00
Compare commits
1 Commits
fix-swappi
...
cursor/onb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02de14b41d |
211
docs/user-guide/onboarding.md
Normal file
211
docs/user-guide/onboarding.md
Normal file
@@ -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: <MyIcon className="h-12 w-12 text-color-500" />,
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
127
ui/app/page.tsx
127
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<DisplayUnit>('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 (
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
<SidebarInset className='p-0'>
|
||||
<SiteHeader />
|
||||
<div className='container max-w-6xl px-4 py-8 md:px-6 lg:px-8'>
|
||||
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-bold tracking-tight'>
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
Monitor and manage wallet balances
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
<ToggleGroup
|
||||
type='single'
|
||||
value={displayUnit}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setDisplayUnit(value as DisplayUnit);
|
||||
}
|
||||
}}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
>
|
||||
<ToggleGroupItem value='msat'>mSAT</ToggleGroupItem>
|
||||
<ToggleGroupItem value='sat'>sat</ToggleGroupItem>
|
||||
<ToggleGroupItem value='usd' disabled={!usdPerSat}>
|
||||
USD
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
useEffect(() => {
|
||||
if (onboardingStatus?.needsOnboarding) {
|
||||
setShowOnboarding(true);
|
||||
}
|
||||
}, [onboardingStatus]);
|
||||
|
||||
<div className='grid gap-6'>
|
||||
<div className='col-span-full'>
|
||||
<DetailedWalletBalance
|
||||
refreshInterval={30000}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<OnboardingWizard
|
||||
open={showOnboarding}
|
||||
onComplete={() => {
|
||||
setShowOnboarding(false);
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
<SidebarProvider>
|
||||
<AppSidebar variant='inset' />
|
||||
<SidebarInset className='p-0'>
|
||||
<SiteHeader />
|
||||
<div className='container max-w-6xl px-4 py-8 md:px-6 lg:px-8'>
|
||||
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-bold tracking-tight'>
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
Monitor and manage wallet balances
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
<ToggleGroup
|
||||
type='single'
|
||||
value={displayUnit}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setDisplayUnit(value as DisplayUnit);
|
||||
}
|
||||
}}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
>
|
||||
<ToggleGroupItem value='msat'>mSAT</ToggleGroupItem>
|
||||
<ToggleGroupItem value='sat'>sat</ToggleGroupItem>
|
||||
<ToggleGroupItem value='usd' disabled={!usdPerSat}>
|
||||
USD
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div className='col-span-full'>
|
||||
<TemporaryBalances
|
||||
refreshInterval={60000}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
|
||||
<div className='grid gap-6'>
|
||||
<div className='col-span-full'>
|
||||
<DetailedWalletBalance
|
||||
refreshInterval={30000}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-full'>
|
||||
<TemporaryBalances
|
||||
refreshInterval={60000}
|
||||
displayUnit={displayUnit}
|
||||
usdPerSat={usdPerSat}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
633
ui/components/onboarding-wizard.tsx
Normal file
633
ui/components/onboarding-wizard.tsx
Normal file
@@ -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: <Rocket className="h-12 w-12 text-blue-500" />,
|
||||
},
|
||||
{
|
||||
id: 'password',
|
||||
title: 'Set Admin Password',
|
||||
description: 'Secure your admin dashboard with a strong password',
|
||||
icon: <Lock className="h-12 w-12 text-green-500" />,
|
||||
},
|
||||
{
|
||||
id: 'provider',
|
||||
title: 'Add AI Provider',
|
||||
description: 'Connect your first AI provider to start routing requests',
|
||||
icon: <Server className="h-12 w-12 text-purple-500" />,
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
title: 'Optional Settings',
|
||||
description: 'Configure additional settings for your node',
|
||||
icon: <SettingsIcon className="h-12 w-12 text-orange-500" />,
|
||||
},
|
||||
{
|
||||
id: 'complete',
|
||||
title: 'All Set!',
|
||||
description: 'Your Routstr node is ready to route AI requests',
|
||||
icon: <Sparkles className="h-12 w-12 text-yellow-500" />,
|
||||
},
|
||||
];
|
||||
|
||||
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<CreateUpstreamProvider>({
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown> = {};
|
||||
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 (
|
||||
<div className="space-y-6 py-6">
|
||||
<div className="flex justify-center">{step.icon}</div>
|
||||
<div className="space-y-4 text-center">
|
||||
<h3 className="text-lg font-semibold">
|
||||
Welcome to Routstr - Your AI Request Router
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
Routstr is a powerful payment-enabled proxy that routes AI requests to
|
||||
various providers. This wizard will help you:
|
||||
</p>
|
||||
<div className="space-y-3 text-left">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Set up admin access</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Secure your dashboard with a password
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Connect AI providers</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Link OpenRouter, OpenAI, Anthropic, and more
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Configure your node</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Customize settings to match your needs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'password':
|
||||
return (
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="flex justify-center">{step.icon}</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-center text-lg font-semibold">
|
||||
Create Admin Password
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-center text-sm">
|
||||
This password will be used to access the admin dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{passwordError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{passwordError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password (min 8 characters)"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Re-enter password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Keep this password safe. You'll need it to access the admin panel.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'provider':
|
||||
return (
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="flex justify-center">{step.icon}</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-center text-lg font-semibold">
|
||||
Connect Your First AI Provider
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-center text-sm">
|
||||
Add an API provider to start routing AI requests
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="provider_type">Provider Type</Label>
|
||||
<Select
|
||||
value={providerData.provider_type}
|
||||
onValueChange={(value) => {
|
||||
setProviderData({
|
||||
...providerData,
|
||||
provider_type: value,
|
||||
base_url: getDefaultBaseUrl(value),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerTypes.map((type) => (
|
||||
<SelectItem key={type.id} value={type.id}>
|
||||
{type.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="base_url">Base URL</Label>
|
||||
<Input
|
||||
id="base_url"
|
||||
value={providerData.base_url}
|
||||
onChange={(e) =>
|
||||
setProviderData({ ...providerData, base_url: e.target.value })
|
||||
}
|
||||
placeholder="https://api.example.com/v1"
|
||||
disabled={hasFixedBaseUrl(providerData.provider_type)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="api_key">API Key</Label>
|
||||
{getPlatformUrl(providerData.provider_type) && (
|
||||
<a
|
||||
href={getPlatformUrl(providerData.provider_type)!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
Get Your API Key →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id="api_key"
|
||||
type="password"
|
||||
value={providerData.api_key}
|
||||
onChange={(e) =>
|
||||
setProviderData({ ...providerData, api_key: e.target.value })
|
||||
}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{providerData.provider_type === 'azure' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api_version">API Version</Label>
|
||||
<Input
|
||||
id="api_version"
|
||||
value={providerData.api_version || ''}
|
||||
onChange={(e) =>
|
||||
setProviderData({
|
||||
...providerData,
|
||||
api_version: e.target.value || null,
|
||||
})
|
||||
}
|
||||
placeholder="2024-02-15-preview"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Your API key is stored securely and only used to authenticate with the
|
||||
provider. You can add more providers later from the Providers page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'settings':
|
||||
return (
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="flex justify-center">{step.icon}</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-center text-lg font-semibold">
|
||||
Customize Your Node (Optional)
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-center text-sm">
|
||||
Give your node a name and description - you can change these later
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nodeName">Node Name</Label>
|
||||
<Input
|
||||
id="nodeName"
|
||||
value={nodeName}
|
||||
onChange={(e) => setNodeName(e.target.value)}
|
||||
placeholder="My Routstr Node"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nodeDescription">Description</Label>
|
||||
<Input
|
||||
id="nodeDescription"
|
||||
value={nodeDescription}
|
||||
onChange={(e) => setNodeDescription(e.target.value)}
|
||||
placeholder="AI request router with payment support"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
These settings are optional. You can configure more advanced options
|
||||
like Nostr integration and Cashu mints later in the Settings page.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'complete':
|
||||
return (
|
||||
<div className="space-y-6 py-6">
|
||||
<div className="flex justify-center">{step.icon}</div>
|
||||
<div className="space-y-4 text-center">
|
||||
<h3 className="text-lg font-semibold">You're All Set! 🎉</h3>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
Your Routstr node is configured and ready to route AI requests. Here's
|
||||
what you can do next:
|
||||
</p>
|
||||
<div className="space-y-3 text-left">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Add More Providers</CardTitle>
|
||||
<CardDescription>
|
||||
Visit the Providers page to add more AI providers
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Configure Models</CardTitle>
|
||||
<CardDescription>
|
||||
Customize model pricing and settings in the Models page
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Monitor Usage</CardTitle>
|
||||
<CardDescription>
|
||||
Track balances and transactions from the dashboard
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent className="max-w-2xl" onPointerDownOutside={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl">{steps[currentStep].title}</DialogTitle>
|
||||
<DialogDescription>{steps[currentStep].description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Progress value={progress} className="h-2" />
|
||||
<div className="text-muted-foreground text-center text-sm">
|
||||
Step {currentStep + 1} of {steps.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderStepContent()}
|
||||
|
||||
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentStep((prev) => Math.max(0, prev - 1))}
|
||||
disabled={currentStep === 0 || isLoading}
|
||||
>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
{currentStep === 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleComplete}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed() || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
'Processing...'
|
||||
) : currentStep === steps.length - 1 ? (
|
||||
'Get Started'
|
||||
) : (
|
||||
<>
|
||||
Next
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
68
ui/lib/onboarding.ts
Normal file
68
ui/lib/onboarding.ts
Normal file
@@ -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<OnboardingStatus> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user