Files
ReadMeABook/src/app/setup/page.tsx
T
kikootwo af0eaceb98 Add extensible notification providers + UI/API
Introduce a provider-based notification system and wire it through the API and admin UI. Added INotificationProvider + notification service implementation and providers (apprise, discord, ntfy, pushover), plus a GET /api/admin/notifications/providers endpoint to expose provider metadata. Refactored code to use provider type strings (removed enum coupling), updated masking/encryption calls, and simplified the test notification endpoint to accept backendId or type+config and call sendToBackend directly.

UI: NotificationsTab now fetches provider metadata and renders provider cards and dynamic config forms (fields driven by provider metadata). Added config field rendering, improved backend cards, and edit/delete actions.

APIs: New providers route, updated admin notification CRUD routes to validate provider types dynamically, updated test route schema. Added download-client categories POST API to fetch categories from clients and wired postImportCategory handling in download-client routes.

Other notable changes: BookDate now fetches Claude models dynamically from Anthropic's Models API; added paginated model fetch helper. Added ALLOW_WEAK_PASSWORD flag exposure to auth providers and password change logic. Doc updates and various tests added/updated. File-organization doc clarifies EPERM fix using stream-based copy.
2026-02-10 15:06:20 -05:00

616 lines
19 KiB
TypeScript

/**
* Component: Setup Wizard Page
* Documentation: documentation/setup-wizard.md
*/
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { WizardLayout } from './components/WizardLayout';
import { WelcomeStep } from './steps/WelcomeStep';
import { BackendSelectionStep } from './steps/BackendSelectionStep';
import { AdminAccountStep } from './steps/AdminAccountStep';
import { PlexStep } from './steps/PlexStep';
import { AudiobookshelfStep } from './steps/AudiobookshelfStep';
import { AuthMethodStep } from './steps/AuthMethodStep';
import { OIDCConfigStep } from './steps/OIDCConfigStep';
import { RegistrationSettingsStep } from './steps/RegistrationSettingsStep';
import { ProwlarrStep } from './steps/ProwlarrStep';
import { DownloadClientStep } from './steps/DownloadClientStep';
import { PathsStep } from './steps/PathsStep';
import { BookDateStep } from './steps/BookDateStep';
import { ReviewStep } from './steps/ReviewStep';
import { FinalizeStep } from './steps/FinalizeStep';
import { AudibleRegion } from '@/lib/types/audible';
interface SelectedIndexer {
id: number;
name: string;
protocol: string;
priority: number;
seedingTimeMinutes?: number;
removeAfterProcessing?: boolean;
rssEnabled: boolean;
audiobookCategories: number[];
ebookCategories: number[];
}
interface SetupState {
currentStep: number;
// Backend selection
backendMode: 'plex' | 'audiobookshelf';
audibleRegion: AudibleRegion;
// Admin account (for Plex mode and ABS + Manual mode)
adminUsername: string;
adminPassword: string;
// Plex config (if mode=plex)
plexUrl: string;
plexToken: string;
plexLibraryId: string;
plexTriggerScanAfterImport: boolean;
// Audiobookshelf config (if mode=audiobookshelf)
absUrl: string;
absApiToken: string;
absLibraryId: string;
absTriggerScanAfterImport: boolean;
// Auth config (if mode=audiobookshelf)
authMethod: 'oidc' | 'manual' | 'both';
// OIDC config
oidcProviderName: string;
oidcIssuerUrl: string;
oidcClientId: string;
oidcClientSecret: string;
oidcAccessControlMethod: string;
oidcAccessGroupClaim: string;
oidcAccessGroupValue: string;
oidcAllowedEmails: string;
oidcAllowedUsernames: string;
oidcAdminClaimEnabled: boolean;
oidcAdminClaimName: string;
oidcAdminClaimValue: string;
// Manual registration config
requireAdminApproval: boolean;
// Prowlarr, download client, paths, bookdate (common to both modes)
prowlarrUrl: string;
prowlarrApiKey: string;
prowlarrIndexers: SelectedIndexer[];
downloadClients: any[]; // Array of download client configs
downloadDir: string;
mediaDir: string;
metadataTaggingEnabled: boolean;
chapterMergingEnabled: boolean;
bookdateProvider: string;
bookdateApiKey: string;
bookdateModel: string;
bookdateConfigured: boolean;
// Cached UI state for back-navigation persistence
plexLibraries: { id: string; title: string; type: string }[];
absLibraries: { id: string; name: string; itemCount: number }[];
oidcTested: boolean;
pathsTested: boolean;
bookdateModels: { id: string; name: string }[];
validated: {
plex: boolean;
prowlarr: boolean;
downloadClient: boolean;
paths: boolean;
};
}
export default function SetupWizard() {
const router = useRouter();
const [state, setState] = useState<SetupState>({
currentStep: 1,
// Backend selection
backendMode: 'plex',
audibleRegion: 'us',
// Admin account
adminUsername: 'admin',
adminPassword: '',
// Plex config
plexUrl: '',
plexToken: '',
plexLibraryId: '',
plexTriggerScanAfterImport: false,
// Audiobookshelf config
absUrl: '',
absApiToken: '',
absLibraryId: '',
absTriggerScanAfterImport: false,
// Auth config
authMethod: 'oidc',
// OIDC config
oidcProviderName: 'Authentik',
oidcIssuerUrl: '',
oidcClientId: '',
oidcClientSecret: '',
oidcAccessControlMethod: 'open',
oidcAccessGroupClaim: 'groups',
oidcAccessGroupValue: '',
oidcAllowedEmails: '',
oidcAllowedUsernames: '',
oidcAdminClaimEnabled: false,
oidcAdminClaimName: 'groups',
oidcAdminClaimValue: '',
// Manual registration config
requireAdminApproval: true,
// Common config
prowlarrUrl: '',
prowlarrApiKey: '',
prowlarrIndexers: [],
downloadClients: [], // Empty array - user will add clients in wizard
downloadDir: '/downloads',
mediaDir: '/media/audiobooks',
metadataTaggingEnabled: true,
chapterMergingEnabled: false,
bookdateProvider: 'openai',
bookdateApiKey: '',
bookdateModel: '',
bookdateConfigured: false,
// Cached UI state for back-navigation persistence
plexLibraries: [],
absLibraries: [],
oidcTested: false,
pathsTested: false,
bookdateModels: [],
validated: {
plex: false,
prowlarr: false,
downloadClient: false,
paths: false,
},
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [setupHasAdminTokens, setSetupHasAdminTokens] = useState(false);
// Calculate total steps based on backend mode and auth method
const getTotalSteps = () => {
if (state.backendMode === 'plex') {
// Plex mode: Welcome, Backend, Admin, Plex, Prowlarr, Download, Paths, BookDate, Review, Finalize
return 10;
} else {
// ABS mode: base steps + conditional auth steps
let steps = 10; // Welcome, Backend, ABS, Auth Method, Prowlarr, Download, Paths, BookDate, Review, Finalize
if (state.authMethod === 'oidc' || state.authMethod === 'both') {
steps += 1; // OIDC Config
}
if (state.authMethod === 'manual' || state.authMethod === 'both') {
steps += 2; // Registration Settings + Admin Account
}
return steps;
}
};
const totalSteps = getTotalSteps();
const updateState = (updates: Partial<SetupState>) => {
setState((prev) => ({ ...prev, ...updates }));
};
const updateField = (field: string, value: any) => {
setState((prev) => ({ ...prev, [field]: value }));
};
const goToStep = (step: number) => {
setState((prev) => ({ ...prev, currentStep: step }));
setError(null);
};
const completeSetup = async () => {
setLoading(true);
setError(null);
try {
const payload: any = {
backendMode: state.backendMode,
audibleRegion: state.audibleRegion,
prowlarr: {
url: state.prowlarrUrl,
api_key: state.prowlarrApiKey,
indexers: state.prowlarrIndexers,
},
downloadClient: state.downloadClients, // Send array of clients
paths: {
download_dir: state.downloadDir,
media_dir: state.mediaDir,
metadata_tagging_enabled: state.metadataTaggingEnabled,
chapter_merging_enabled: state.chapterMergingEnabled,
},
bookdate: state.bookdateConfigured ? {
provider: state.bookdateProvider,
apiKey: state.bookdateApiKey,
model: state.bookdateModel,
} : null,
};
if (state.backendMode === 'plex') {
// Plex mode configuration
payload.admin = {
username: state.adminUsername,
password: state.adminPassword,
};
payload.plex = {
url: state.plexUrl,
token: state.plexToken,
audiobook_library_id: state.plexLibraryId,
};
} else {
// Audiobookshelf mode configuration
payload.audiobookshelf = {
server_url: state.absUrl,
api_token: state.absApiToken,
library_id: state.absLibraryId,
};
payload.authMethod = state.authMethod;
// OIDC configuration
if (state.authMethod === 'oidc' || state.authMethod === 'both') {
// Helper function to parse comma-separated strings into JSON arrays
const parseCommaSeparatedToArray = (str: string): string => {
if (!str || str.trim() === '') return '[]';
const items = str.split(',').map(s => s.trim()).filter(s => s.length > 0);
return JSON.stringify(items);
};
payload.oidc = {
provider_name: state.oidcProviderName,
issuer_url: state.oidcIssuerUrl,
client_id: state.oidcClientId,
client_secret: state.oidcClientSecret,
access_control_method: state.oidcAccessControlMethod,
access_group_claim: state.oidcAccessGroupClaim,
access_group_value: state.oidcAccessGroupValue,
allowed_emails: parseCommaSeparatedToArray(state.oidcAllowedEmails),
allowed_usernames: parseCommaSeparatedToArray(state.oidcAllowedUsernames),
admin_claim_enabled: state.oidcAdminClaimEnabled ? 'true' : 'false',
admin_claim_name: state.oidcAdminClaimName,
admin_claim_value: state.oidcAdminClaimValue,
};
}
// Manual registration configuration
if (state.authMethod === 'manual' || state.authMethod === 'both') {
payload.registration = {
enabled: true,
require_admin_approval: state.requireAdminApproval,
};
// Create admin account for manual auth
payload.admin = {
username: state.adminUsername,
password: state.adminPassword,
};
}
}
const response = await fetch('/api/setup/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to complete setup');
}
const data = await response.json();
// Store admin auth tokens (if provided)
if (data.accessToken && data.refreshToken) {
// Clear any old tokens first to avoid conflicts
localStorage.clear();
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
localStorage.setItem('user', JSON.stringify(data.user));
// Mark that we have admin tokens for FinalizeStep
setSetupHasAdminTokens(true);
// Go to finalize step to run initial jobs
goToStep(totalSteps);
} else {
// OIDC-only mode - clear localStorage to remove stale tokens
localStorage.clear();
// Mark that we don't have admin tokens
setSetupHasAdminTokens(false);
// Go to finalize step (will show OIDC-only UI)
goToStep(totalSteps);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Setup failed');
} finally {
setLoading(false);
}
};
const renderStep = () => {
let currentStepNumber = 1;
// Step 1: Welcome
if (state.currentStep === currentStepNumber) {
return <WelcomeStep onNext={() => goToStep(currentStepNumber + 1)} />;
}
currentStepNumber++;
// Step 2: Backend Selection
if (state.currentStep === currentStepNumber) {
return (
<BackendSelectionStep
value={state.backendMode}
onChange={(value) => updateField('backendMode', value)}
audibleRegion={state.audibleRegion}
onAudibleRegionChange={(region) => updateField('audibleRegion', region)}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
// Conditional flow based on backend mode
if (state.backendMode === 'plex') {
// Plex Mode: Admin → Plex → Prowlarr → Download → Paths → BookDate → Review → Finalize
// Step 3: Admin Account
if (state.currentStep === currentStepNumber) {
return (
<AdminAccountStep
adminUsername={state.adminUsername}
adminPassword={state.adminPassword}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
// Step 4: Plex
if (state.currentStep === currentStepNumber) {
return (
<PlexStep
plexUrl={state.plexUrl}
plexToken={state.plexToken}
plexLibraryId={state.plexLibraryId}
plexTriggerScanAfterImport={state.plexTriggerScanAfterImport}
plexLibraries={state.plexLibraries}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
} else {
// Audiobookshelf Mode: ABS → Auth Method → [OIDC Config] → [Registration Settings] → [Admin Account] → Prowlarr → ...
// Step 3: Audiobookshelf
if (state.currentStep === currentStepNumber) {
return (
<AudiobookshelfStep
absUrl={state.absUrl}
absApiToken={state.absApiToken}
absLibraryId={state.absLibraryId}
absTriggerScanAfterImport={state.absTriggerScanAfterImport}
absLibraries={state.absLibraries}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
// Step 4: Auth Method Selection
if (state.currentStep === currentStepNumber) {
return (
<AuthMethodStep
value={state.authMethod}
onChange={(value) => updateField('authMethod', value)}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
// Conditional: OIDC Config (if authMethod is 'oidc' or 'both')
if (state.authMethod === 'oidc' || state.authMethod === 'both') {
if (state.currentStep === currentStepNumber) {
return (
<OIDCConfigStep
oidcProviderName={state.oidcProviderName}
oidcIssuerUrl={state.oidcIssuerUrl}
oidcClientId={state.oidcClientId}
oidcClientSecret={state.oidcClientSecret}
oidcAccessControlMethod={state.oidcAccessControlMethod}
oidcAccessGroupClaim={state.oidcAccessGroupClaim}
oidcAccessGroupValue={state.oidcAccessGroupValue}
oidcAllowedEmails={state.oidcAllowedEmails}
oidcAllowedUsernames={state.oidcAllowedUsernames}
oidcAdminClaimEnabled={state.oidcAdminClaimEnabled}
oidcAdminClaimName={state.oidcAdminClaimName}
oidcAdminClaimValue={state.oidcAdminClaimValue}
oidcTested={state.oidcTested}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
}
// Conditional: Registration Settings (if authMethod is 'manual' or 'both')
if (state.authMethod === 'manual' || state.authMethod === 'both') {
if (state.currentStep === currentStepNumber) {
return (
<RegistrationSettingsStep
requireAdminApproval={state.requireAdminApproval}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
// Step: Admin Account (for manual auth)
if (state.currentStep === currentStepNumber) {
return (
<AdminAccountStep
adminUsername={state.adminUsername}
adminPassword={state.adminPassword}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
}
}
// Common steps for both modes: Prowlarr → Download → Paths → BookDate → Review → Finalize
// Step: Prowlarr
if (state.currentStep === currentStepNumber) {
return (
<ProwlarrStep
prowlarrUrl={state.prowlarrUrl}
prowlarrApiKey={state.prowlarrApiKey}
prowlarrIndexers={state.prowlarrIndexers}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
// Step: Download Client
if (state.currentStep === currentStepNumber) {
return (
<DownloadClientStep
downloadClients={state.downloadClients}
downloadDir={state.downloadDir}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
// Step: Paths
if (state.currentStep === currentStepNumber) {
return (
<PathsStep
downloadDir={state.downloadDir}
mediaDir={state.mediaDir}
metadataTaggingEnabled={state.metadataTaggingEnabled}
chapterMergingEnabled={state.chapterMergingEnabled}
pathsTested={state.pathsTested}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
// Step: BookDate
if (state.currentStep === currentStepNumber) {
return (
<BookDateStep
bookdateProvider={state.bookdateProvider}
bookdateApiKey={state.bookdateApiKey}
bookdateModel={state.bookdateModel}
bookdateConfigured={state.bookdateConfigured}
bookdateModels={state.bookdateModels}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onSkip={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
// Step: Review
if (state.currentStep === currentStepNumber) {
return (
<ReviewStep
config={state}
loading={loading}
error={error}
onComplete={completeSetup}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
// Step: Finalize
if (state.currentStep === currentStepNumber) {
return (
<FinalizeStep
hasAdminTokens={setupHasAdminTokens}
onComplete={() => {
// OIDC-only mode: redirect to login
if (!setupHasAdminTokens) {
window.location.href = '/login';
return;
}
// Normal mode: Force full page reload to initialize auth context with new tokens
window.location.href = '/';
}}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
return null;
};
return (
<WizardLayout
currentStep={state.currentStep}
totalSteps={totalSteps}
backendMode={state.backendMode}
authMethod={state.authMethod}
>
{renderStep()}
</WizardLayout>
);
}