58 KiB
Audiobookshelf Integration - Implementation Guide
Purpose: Step-by-step implementation instructions for AI agents to build the Audiobookshelf integration feature.
Prerequisites:
- Read the full PRD:
documentation/features/audiobookshelf-integration.md - Understand current architecture via
documentation/TABLEOFCONTENTS.md
Critical Rules:
- Complete each phase fully before moving to the next
- Run tests after each phase to verify no regressions
- Existing Plex functionality must remain unchanged
- Follow existing code patterns and file structure conventions
- Update documentation as you implement
- Keep files under 400 lines - split if needed
Phase 1: Foundation (Abstraction Layer)
1.1 Create Library Service Interface
Goal: Abstract library operations so both Plex and Audiobookshelf can be used interchangeably.
Create file: src/lib/services/library/ILibraryService.ts
/**
* Library Service Interface
* Documentation: documentation/features/audiobookshelf-integration.md
*/
export interface ServerInfo {
name: string;
version: string;
platform?: string;
identifier: string; // machineIdentifier (Plex) or serverId (ABS)
}
export interface Library {
id: string;
name: string;
type: string;
itemCount?: number;
}
export interface LibraryItem {
id: string; // ratingKey (Plex) or item id (ABS)
externalId: string; // plexGuid or abs_item_id
title: string;
author: string;
narrator?: string;
description?: string;
coverUrl?: string;
duration?: number; // seconds
asin?: string;
isbn?: string;
year?: number;
addedAt: Date;
updatedAt: Date;
}
export interface LibraryConnectionResult {
success: boolean;
serverInfo?: ServerInfo;
error?: string;
}
export interface ILibraryService {
// Connection
testConnection(): Promise<LibraryConnectionResult>;
getServerInfo(): Promise<ServerInfo>;
// Libraries
getLibraries(): Promise<Library[]>;
getLibraryItems(libraryId: string): Promise<LibraryItem[]>;
getRecentlyAdded(libraryId: string, limit: number): Promise<LibraryItem[]>;
// Items
getItem(itemId: string): Promise<LibraryItem | null>;
searchItems(libraryId: string, query: string): Promise<LibraryItem[]>;
// Scanning
triggerLibraryScan(libraryId: string): Promise<void>;
}
1.2 Refactor Existing Plex Code into PlexLibraryService
Goal: Move existing Plex library logic into the new interface structure.
Create file: src/lib/services/library/PlexLibraryService.ts
Instructions:
- Read existing Plex integration code in
src/lib/services/plex/or similar - Identify all library-related functions (getLibraries, scanLibrary, getItems, etc.)
- Implement
ILibraryServiceinterface using existing Plex logic - Do NOT delete original code yet - keep for reference
- Map Plex data structures to the generic
LibraryIteminterface:ratingKey→idguid→externalIdparentTitle→authorgrandparentTitleor metadata →narrator
Key mapping:
function mapPlexItemToLibraryItem(plexItem: PlexAudiobook): LibraryItem {
return {
id: plexItem.ratingKey,
externalId: plexItem.guid,
title: plexItem.title,
author: plexItem.author, // from parentTitle
narrator: plexItem.narrator,
description: plexItem.summary,
coverUrl: plexItem.thumb,
duration: plexItem.duration ? Math.floor(plexItem.duration / 1000) : undefined,
asin: extractAsinFromGuid(plexItem.guid),
year: plexItem.year,
addedAt: new Date(plexItem.addedAt * 1000),
updatedAt: new Date(plexItem.updatedAt * 1000),
};
}
1.3 Create Library Service Factory
Create file: src/lib/services/library/index.ts
/**
* Library Service Factory
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { ILibraryService } from './ILibraryService';
import { PlexLibraryService } from './PlexLibraryService';
// import { AudiobookshelfLibraryService } from './AudiobookshelfLibraryService'; // Phase 2
export async function getLibraryService(): Promise<ILibraryService> {
// TODO: Read from config once backend mode is implemented
// const mode = await getConfig('system.backend_mode');
// if (mode === 'audiobookshelf') {
// return new AudiobookshelfLibraryService();
// }
return new PlexLibraryService();
}
export * from './ILibraryService';
1.4 Create Auth Provider Interface
Create file: src/lib/services/auth/IAuthProvider.ts
/**
* Auth Provider Interface
* Documentation: documentation/features/audiobookshelf-integration.md
*/
export interface UserInfo {
id: string; // External ID (plexId, oidc subject, or local username)
username: string;
email?: string;
avatarUrl?: string;
isAdmin?: boolean; // From claims or first-user logic
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
}
export interface LoginInitiation {
redirectUrl?: string; // For OAuth/OIDC flows
pinId?: string; // For Plex PIN flow
state?: string; // CSRF state token
}
export interface CallbackParams {
code?: string; // Authorization code
state?: string; // CSRF state
pinId?: string; // Plex PIN
error?: string;
}
export interface AuthResult {
success: boolean;
user?: UserInfo;
tokens?: AuthTokens;
error?: string;
requiresApproval?: boolean; // For pending approval flow
requiresProfileSelection?: boolean; // For Plex Home
profiles?: any[]; // Plex Home profiles
}
export interface IAuthProvider {
type: 'plex' | 'oidc' | 'local';
// Auth initiation
initiateLogin(): Promise<LoginInitiation>;
// Auth completion
handleCallback(params: CallbackParams): Promise<AuthResult>;
// Token refresh
refreshToken(refreshToken: string): Promise<AuthTokens | null>;
// Validation
validateAccess(userInfo: UserInfo): Promise<boolean>;
}
1.5 Refactor Plex OAuth into PlexAuthProvider
Create file: src/lib/services/auth/PlexAuthProvider.ts
Instructions:
- Read existing auth code in
src/lib/services/auth.tsorsrc/app/api/auth/plex/ - Extract Plex OAuth logic into
PlexAuthProviderimplementingIAuthProvider - Keep existing Plex Home profile support
- Map Plex user data to generic
UserInfointerface
1.6 Create Auth Provider Factory
Create file: src/lib/services/auth/index.ts
/**
* Auth Provider Factory
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { IAuthProvider } from './IAuthProvider';
import { PlexAuthProvider } from './PlexAuthProvider';
// import { OIDCAuthProvider } from './OIDCAuthProvider'; // Phase 3
// import { LocalAuthProvider } from './LocalAuthProvider'; // Phase 4
export type AuthMethod = 'plex' | 'oidc' | 'local';
export async function getAuthProvider(method?: AuthMethod): Promise<IAuthProvider> {
// TODO: Read from config once backend mode is implemented
// const mode = await getConfig('system.backend_mode');
// const authMethod = method || await getConfig('auth.method');
// if (authMethod === 'oidc') return new OIDCAuthProvider();
// if (authMethod === 'local') return new LocalAuthProvider();
return new PlexAuthProvider();
}
export * from './IAuthProvider';
1.7 Update Database Schema
Modify: prisma/schema.prisma
Add new fields to User model:
model User {
// ... existing fields ...
// New fields for multi-auth support
authProvider String? @map("auth_provider") // 'plex' | 'oidc' | 'local'
oidcSubject String? @map("oidc_subject") // OIDC subject ID
oidcProvider String? @map("oidc_provider") // e.g., 'authentik'
registrationStatus String? @map("registration_status") // 'pending_approval' | 'approved' | 'rejected'
}
Add new Configuration keys (will be set during setup):
// These are stored in Configuration table, not schema changes
// system.backend_mode = 'plex' | 'audiobookshelf'
Run migration:
npx prisma db push
1.8 Add Backend Mode Config Helper
Modify: src/lib/services/config.service.ts (or create if doesn't exist)
Add function to get backend mode:
export async function getBackendMode(): Promise<'plex' | 'audiobookshelf'> {
const config = await prisma.configuration.findUnique({
where: { key: 'system.backend_mode' }
});
return (config?.value as 'plex' | 'audiobookshelf') || 'plex';
}
export async function isAudiobookshelfMode(): Promise<boolean> {
return (await getBackendMode()) === 'audiobookshelf';
}
1.9 Phase 1 Verification
Tests to run:
- Existing Plex authentication still works
- Existing library scanning still works
- All existing tests pass
- New interfaces compile without errors
Checklist:
ILibraryServiceinterface createdPlexLibraryServiceimplements interface with existing logicIAuthProviderinterface createdPlexAuthProviderimplements interface with existing logic- Factory functions created for both services
- Database schema updated with new fields
- All existing functionality unchanged
Phase 2: Audiobookshelf Library Integration
2.1 Create Audiobookshelf API Client
Create file: src/lib/services/audiobookshelf/api.ts
/**
* Audiobookshelf API Client
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { getConfig } from '../config.service';
interface ABSRequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
body?: any;
}
export async function absRequest<T>(endpoint: string, options: ABSRequestOptions = {}): Promise<T> {
const serverUrl = await getConfig('abs.server_url');
const apiToken = await getConfig('abs.api_token', true); // true = decrypt
if (!serverUrl || !apiToken) {
throw new Error('Audiobookshelf not configured');
}
const url = `${serverUrl.replace(/\/$/, '')}/api${endpoint}`;
const response = await fetch(url, {
method: options.method || 'GET',
headers: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) {
throw new Error(`ABS API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
// API endpoint wrappers
export async function getABSServerInfo() {
return absRequest<{ version: string; name: string }>('/status');
}
export async function getABSLibraries() {
const result = await absRequest<{ libraries: any[] }>('/libraries');
return result.libraries;
}
export async function getABSLibraryItems(libraryId: string) {
const result = await absRequest<{ results: any[] }>(`/libraries/${libraryId}/items`);
return result.results;
}
export async function getABSRecentItems(libraryId: string, limit: number) {
const result = await absRequest<{ results: any[] }>(
`/libraries/${libraryId}/items?sort=addedAt&desc=1&limit=${limit}`
);
return result.results;
}
export async function getABSItem(itemId: string) {
return absRequest<any>(`/items/${itemId}`);
}
export async function searchABSItems(libraryId: string, query: string) {
const result = await absRequest<{ book: any[] }>(
`/libraries/${libraryId}/search?q=${encodeURIComponent(query)}`
);
return result.book || [];
}
export async function triggerABSScan(libraryId: string) {
await absRequest(`/libraries/${libraryId}/scan`, { method: 'POST' });
}
2.2 Create Audiobookshelf Type Definitions
Create file: src/lib/services/audiobookshelf/types.ts
/**
* Audiobookshelf Type Definitions
* Documentation: documentation/features/audiobookshelf-integration.md
*/
export interface ABSLibrary {
id: string;
name: string;
mediaType: 'book' | 'podcast';
folders: { id: string; fullPath: string }[];
}
export interface ABSBookMetadata {
title: string;
subtitle?: string;
authorName: string;
authorNameLF?: string;
narratorName?: string;
seriesName?: string;
genres: string[];
publishedYear?: string;
description?: string;
isbn?: string;
asin?: string;
language?: string;
explicit: boolean;
}
export interface ABSAudioFile {
index: number;
ino: string;
metadata: {
filename: string;
ext: string;
path: string;
size: number;
mtimeMs: number;
};
duration: number;
}
export interface ABSLibraryItem {
id: string;
ino: string;
libraryId: string;
folderId: string;
path: string;
relPath: string;
isFile: boolean;
mtimeMs: number;
ctimeMs: number;
birthtimeMs: number;
addedAt: number;
updatedAt: number;
isMissing: boolean;
isInvalid: boolean;
mediaType: 'book';
media: {
metadata: ABSBookMetadata;
coverPath?: string;
audioFiles: ABSAudioFile[];
duration: number;
size: number;
numTracks: number;
numAudioFiles: number;
};
numFiles: number;
size: number;
}
2.3 Create AudiobookshelfLibraryService
Create file: src/lib/services/library/AudiobookshelfLibraryService.ts
/**
* Audiobookshelf Library Service
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import {
ILibraryService,
LibraryConnectionResult,
ServerInfo,
Library,
LibraryItem,
} from './ILibraryService';
import {
absRequest,
getABSServerInfo,
getABSLibraries,
getABSLibraryItems,
getABSRecentItems,
getABSItem,
searchABSItems,
triggerABSScan,
} from '../audiobookshelf/api';
import { ABSLibraryItem } from '../audiobookshelf/types';
export class AudiobookshelfLibraryService implements ILibraryService {
async testConnection(): Promise<LibraryConnectionResult> {
try {
const serverInfo = await this.getServerInfo();
return {
success: true,
serverInfo,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
async getServerInfo(): Promise<ServerInfo> {
const info = await getABSServerInfo();
return {
name: info.name || 'Audiobookshelf',
version: info.version,
identifier: info.name, // ABS doesn't have unique identifier like Plex
};
}
async getLibraries(): Promise<Library[]> {
const libraries = await getABSLibraries();
return libraries
.filter((lib: any) => lib.mediaType === 'book') // Only audiobook libraries
.map((lib: any) => ({
id: lib.id,
name: lib.name,
type: lib.mediaType,
itemCount: lib.stats?.totalItems,
}));
}
async getLibraryItems(libraryId: string): Promise<LibraryItem[]> {
const items = await getABSLibraryItems(libraryId);
return items.map(this.mapABSItemToLibraryItem);
}
async getRecentlyAdded(libraryId: string, limit: number): Promise<LibraryItem[]> {
const items = await getABSRecentItems(libraryId, limit);
return items.map(this.mapABSItemToLibraryItem);
}
async getItem(itemId: string): Promise<LibraryItem | null> {
try {
const item = await getABSItem(itemId);
return this.mapABSItemToLibraryItem(item);
} catch {
return null;
}
}
async searchItems(libraryId: string, query: string): Promise<LibraryItem[]> {
const items = await searchABSItems(libraryId, query);
return items.map((result: any) => this.mapABSItemToLibraryItem(result.libraryItem));
}
async triggerLibraryScan(libraryId: string): Promise<void> {
await triggerABSScan(libraryId);
}
private mapABSItemToLibraryItem(item: ABSLibraryItem): LibraryItem {
const metadata = item.media.metadata;
return {
id: item.id,
externalId: item.id, // ABS item ID is the external ID
title: metadata.title,
author: metadata.authorName,
narrator: metadata.narratorName,
description: metadata.description,
coverUrl: item.media.coverPath ? `/api/items/${item.id}/cover` : undefined,
duration: item.media.duration,
asin: metadata.asin,
isbn: metadata.isbn,
year: metadata.publishedYear ? parseInt(metadata.publishedYear) : undefined,
addedAt: new Date(item.addedAt),
updatedAt: new Date(item.updatedAt),
};
}
}
2.4 Update Library Service Factory
Modify: src/lib/services/library/index.ts
import { ILibraryService } from './ILibraryService';
import { PlexLibraryService } from './PlexLibraryService';
import { AudiobookshelfLibraryService } from './AudiobookshelfLibraryService';
import { getBackendMode } from '../config.service';
export async function getLibraryService(): Promise<ILibraryService> {
const mode = await getBackendMode();
if (mode === 'audiobookshelf') {
return new AudiobookshelfLibraryService();
}
return new PlexLibraryService();
}
2.5 Create ABS Setup Test Endpoint
Create file: src/app/api/setup/test-abs/route.ts
/**
* Test Audiobookshelf Connection
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const { serverUrl, apiToken } = await request.json();
if (!serverUrl || !apiToken) {
return NextResponse.json(
{ error: 'Server URL and API token are required' },
{ status: 400 }
);
}
// Test connection
const response = await fetch(`${serverUrl.replace(/\/$/, '')}/api/status`, {
headers: {
'Authorization': `Bearer ${apiToken}`,
},
});
if (!response.ok) {
return NextResponse.json(
{ error: `Connection failed: ${response.status} ${response.statusText}` },
{ status: 400 }
);
}
const serverInfo = await response.json();
// Get libraries
const libResponse = await fetch(`${serverUrl.replace(/\/$/, '')}/api/libraries`, {
headers: {
'Authorization': `Bearer ${apiToken}`,
},
});
const libData = await libResponse.json();
const libraries = libData.libraries
.filter((lib: any) => lib.mediaType === 'book')
.map((lib: any) => ({
id: lib.id,
name: lib.name,
itemCount: lib.stats?.totalItems || 0,
}));
return NextResponse.json({
success: true,
serverInfo: {
name: serverInfo.name || 'Audiobookshelf',
version: serverInfo.version,
},
libraries,
});
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Connection failed' },
{ status: 500 }
);
}
}
2.6 Update Library Scanning Jobs
Modify existing scan jobs to use the abstraction layer:
Find files like src/lib/jobs/processors/plex-scan.ts or similar.
Replace direct Plex calls with:
import { getLibraryService } from '@/lib/services/library';
async function scanLibrary() {
const libraryService = await getLibraryService();
const items = await libraryService.getLibraryItems(libraryId);
// ... rest of scanning logic using generic LibraryItem interface
}
2.7 Update Audiobook Matcher for ABS
Modify: src/lib/utils/audiobook-matcher.ts (or create if doesn't exist)
Enhance matching to use ASIN/ISBN from ABS:
export function matchAudiobook(
request: { title: string; author: string; asin?: string; isbn?: string },
libraryItems: LibraryItem[]
): LibraryItem | null {
// 1. Exact ASIN match (highest confidence)
if (request.asin) {
const asinMatch = libraryItems.find(item =>
item.asin?.toLowerCase() === request.asin?.toLowerCase()
);
if (asinMatch) return asinMatch;
}
// 2. Exact ISBN match
if (request.isbn) {
const isbnMatch = libraryItems.find(item =>
item.isbn?.replace(/-/g, '') === request.isbn?.replace(/-/g, '')
);
if (isbnMatch) return isbnMatch;
}
// 3. Fuzzy title/author match (existing logic)
return fuzzyMatch(request, libraryItems);
}
2.8 Phase 2 Verification
Tests to run:
- ABS connection test endpoint works
- ABS library scanning retrieves items
- ABS recently added works
- Plex mode still works unchanged
- Matching works with ASIN/ISBN
Checklist:
- ABS API client created
- ABS types defined
AudiobookshelfLibraryServiceimplements interface- Test endpoint for ABS connection
- Scan jobs use abstraction layer
- Matcher enhanced for ASIN/ISBN
Phase 3: OIDC Authentication
3.1 Install OIDC Dependencies
npm install openid-client
3.2 Create OIDC Provider Service
Create file: src/lib/services/auth/OIDCAuthProvider.ts
/**
* OIDC Auth Provider
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { Issuer, Client, generators } from 'openid-client';
import { getConfig, setConfig } from '../config.service';
import {
IAuthProvider,
LoginInitiation,
CallbackParams,
AuthResult,
UserInfo,
AuthTokens,
} from './IAuthProvider';
export class OIDCAuthProvider implements IAuthProvider {
type: 'oidc' = 'oidc';
private client: Client | null = null;
private async getClient(): Promise<Client> {
if (this.client) return this.client;
const issuerUrl = await getConfig('oidc.issuer_url');
const clientId = await getConfig('oidc.client_id');
const clientSecret = await getConfig('oidc.client_secret', true);
const redirectUri = await this.getRedirectUri();
const issuer = await Issuer.discover(issuerUrl);
this.client = new issuer.Client({
client_id: clientId,
client_secret: clientSecret,
redirect_uris: [redirectUri],
response_types: ['code'],
});
return this.client;
}
private async getRedirectUri(): Promise<string> {
const baseUrl = process.env.NEXTAUTH_URL || process.env.BASE_URL || 'http://localhost:3000';
return `${baseUrl}/api/auth/oidc/callback`;
}
async initiateLogin(): Promise<LoginInitiation> {
const client = await this.getClient();
const state = generators.state();
const nonce = generators.nonce();
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
// Store state/nonce/verifier in session or encrypted cookie
// This is a simplified example - use proper session storage
await setConfig('oidc.pending_state', state);
await setConfig('oidc.pending_nonce', nonce);
await setConfig('oidc.pending_verifier', codeVerifier);
const redirectUrl = client.authorizationUrl({
scope: 'openid profile email groups',
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return { redirectUrl, state };
}
async handleCallback(params: CallbackParams): Promise<AuthResult> {
try {
const client = await this.getClient();
const redirectUri = await this.getRedirectUri();
// Retrieve stored values
const expectedState = await getConfig('oidc.pending_state');
const nonce = await getConfig('oidc.pending_nonce');
const codeVerifier = await getConfig('oidc.pending_verifier');
if (params.state !== expectedState) {
return { success: false, error: 'Invalid state parameter' };
}
const tokenSet = await client.callback(redirectUri, { code: params.code, state: params.state }, {
code_verifier: codeVerifier,
nonce,
});
const userinfo = await client.userinfo(tokenSet.access_token!);
// Check access control
const hasAccess = await this.checkAccessControl(userinfo);
if (!hasAccess) {
return {
success: false,
error: 'You do not have access to this application'
};
}
// Map to UserInfo
const user: UserInfo = {
id: userinfo.sub,
username: userinfo.preferred_username || userinfo.email || userinfo.sub,
email: userinfo.email as string | undefined,
avatarUrl: userinfo.picture as string | undefined,
isAdmin: await this.checkAdminClaim(userinfo),
};
// Check if admin approval required
const accessMethod = await getConfig('oidc.access_control_method');
if (accessMethod === 'admin_approval') {
const existingUser = await this.findExistingUser(user.id);
if (!existingUser) {
// Create pending user
await this.createPendingUser(user);
return { success: false, requiresApproval: true };
}
if (existingUser.registrationStatus === 'pending_approval') {
return { success: false, requiresApproval: true };
}
}
// Generate session tokens
const tokens = await this.generateSessionTokens(user);
return { success: true, user, tokens };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Authentication failed'
};
}
}
private async checkAccessControl(userinfo: any): Promise<boolean> {
const method = await getConfig('oidc.access_control_method');
switch (method) {
case 'open':
return true;
case 'group_claim': {
const claimName = await getConfig('oidc.access_group_claim') || 'groups';
const requiredGroup = await getConfig('oidc.access_group_value');
const userGroups = userinfo[claimName] || [];
return Array.isArray(userGroups) && userGroups.includes(requiredGroup);
}
case 'allowed_list': {
const allowedEmails = JSON.parse(await getConfig('oidc.allowed_emails') || '[]');
const allowedUsernames = JSON.parse(await getConfig('oidc.allowed_usernames') || '[]');
return (
allowedEmails.includes(userinfo.email) ||
allowedUsernames.includes(userinfo.preferred_username)
);
}
case 'admin_approval':
return true; // Handled separately
default:
return false;
}
}
private async checkAdminClaim(userinfo: any): Promise<boolean> {
const enabled = await getConfig('oidc.admin_claim_enabled');
if (enabled !== 'true') {
// First user becomes admin logic handled elsewhere
return false;
}
const claimName = await getConfig('oidc.admin_claim_name') || 'groups';
const claimValue = await getConfig('oidc.admin_claim_value');
const userClaims = userinfo[claimName] || [];
if (Array.isArray(userClaims)) {
return userClaims.includes(claimValue);
}
return userClaims === claimValue;
}
async refreshToken(refreshToken: string): Promise<AuthTokens | null> {
// Implement JWT refresh logic (reuse existing JWT refresh code)
return null;
}
async validateAccess(userInfo: UserInfo): Promise<boolean> {
return true; // Already validated in handleCallback
}
private async findExistingUser(oidcSubject: string) {
// Query database for user with this OIDC subject
return null; // Implement with Prisma
}
private async createPendingUser(user: UserInfo) {
// Create user with registrationStatus: 'pending_approval'
// Implement with Prisma
}
private async generateSessionTokens(user: UserInfo): Promise<AuthTokens> {
// Reuse existing JWT generation logic
return { accessToken: '', refreshToken: '' };
}
}
3.3 Create OIDC Login Endpoint
Create file: src/app/api/auth/oidc/login/route.ts
/**
* OIDC Login Initiation
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextResponse } from 'next/server';
import { OIDCAuthProvider } from '@/lib/services/auth/OIDCAuthProvider';
export async function GET() {
try {
const provider = new OIDCAuthProvider();
const { redirectUrl } = await provider.initiateLogin();
return NextResponse.redirect(redirectUrl!);
} catch (error) {
return NextResponse.json(
{ error: 'Failed to initiate login' },
{ status: 500 }
);
}
}
3.4 Create OIDC Callback Endpoint
Create file: src/app/api/auth/oidc/callback/route.ts
/**
* OIDC Callback Handler
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { OIDCAuthProvider } from '@/lib/services/auth/OIDCAuthProvider';
import { createOrUpdateUser, generateJWT } from '@/lib/services/auth';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const code = searchParams.get('code');
const state = searchParams.get('state');
const error = searchParams.get('error');
if (error) {
return NextResponse.redirect(`/login?error=${encodeURIComponent(error)}`);
}
try {
const provider = new OIDCAuthProvider();
const result = await provider.handleCallback({ code: code!, state: state! });
if (!result.success) {
if (result.requiresApproval) {
return NextResponse.redirect('/login?pending=approval');
}
return NextResponse.redirect(`/login?error=${encodeURIComponent(result.error || 'Authentication failed')}`);
}
// Create or update user in database
const dbUser = await createOrUpdateUser({
authProvider: 'oidc',
oidcSubject: result.user!.id,
username: result.user!.username,
email: result.user!.email,
avatarUrl: result.user!.avatarUrl,
isAdmin: result.user!.isAdmin,
});
// Generate JWT tokens
const tokens = await generateJWT(dbUser);
// Set cookies and redirect
const response = NextResponse.redirect('/');
response.cookies.set('accessToken', tokens.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60, // 1 hour
});
response.cookies.set('refreshToken', tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7, // 7 days
});
return response;
} catch (error) {
console.error('OIDC callback error:', error);
return NextResponse.redirect('/login?error=auth_failed');
}
}
3.5 Create OIDC Test Endpoint
Create file: src/app/api/setup/test-oidc/route.ts
/**
* Test OIDC Configuration
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { Issuer } from 'openid-client';
export async function POST(request: NextRequest) {
try {
const { issuerUrl, clientId, clientSecret } = await request.json();
if (!issuerUrl || !clientId || !clientSecret) {
return NextResponse.json(
{ error: 'Issuer URL, Client ID, and Client Secret are required' },
{ status: 400 }
);
}
// Discover OIDC endpoints
const issuer = await Issuer.discover(issuerUrl);
return NextResponse.json({
success: true,
issuer: {
issuer: issuer.issuer,
authorizationEndpoint: issuer.metadata.authorization_endpoint,
tokenEndpoint: issuer.metadata.token_endpoint,
userinfoEndpoint: issuer.metadata.userinfo_endpoint,
},
});
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'OIDC discovery failed' },
{ status: 500 }
);
}
}
3.6 Phase 3 Verification
Tests to run:
- OIDC discovery works with test provider
- OIDC login redirects correctly
- OIDC callback creates user
- Group claim access control works
- Admin claim mapping works
- Plex auth still works unchanged
Checklist:
openid-clientinstalledOIDCAuthProviderimplements interface- Login endpoint initiates flow
- Callback endpoint handles response
- Access control (group claim) works
- Test endpoint validates OIDC config
Phase 4: Manual Registration
4.1 Create Local Auth Provider
Create file: src/lib/services/auth/LocalAuthProvider.ts
/**
* Local Auth Provider (Username/Password)
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import bcrypt from 'bcrypt';
import { prisma } from '@/lib/prisma';
import {
IAuthProvider,
LoginInitiation,
CallbackParams,
AuthResult,
UserInfo,
AuthTokens,
} from './IAuthProvider';
import { generateJWT } from './jwt';
import { getConfig } from '../config.service';
interface LocalLoginParams extends CallbackParams {
username: string;
password: string;
}
interface RegisterParams {
username: string;
password: string;
}
export class LocalAuthProvider implements IAuthProvider {
type: 'local' = 'local';
async initiateLogin(): Promise<LoginInitiation> {
// Local auth doesn't need initiation - return empty
return {};
}
async handleCallback(params: CallbackParams): Promise<AuthResult> {
// This handles login with username/password
const { username, password } = params as LocalLoginParams;
if (!username || !password) {
return { success: false, error: 'Username and password required' };
}
// Find user
const user = await prisma.user.findFirst({
where: {
plexUsername: username,
authProvider: 'local',
},
});
if (!user) {
return { success: false, error: 'Invalid username or password' };
}
// Check registration status
if (user.registrationStatus === 'pending_approval') {
return { success: false, requiresApproval: true };
}
if (user.registrationStatus === 'rejected') {
return { success: false, error: 'Account has been rejected' };
}
// Verify password
const passwordValid = await bcrypt.compare(password, user.authToken || '');
if (!passwordValid) {
return { success: false, error: 'Invalid username or password' };
}
// Generate tokens
const tokens = await generateJWT({
id: user.id,
username: user.plexUsername,
role: user.role,
});
return {
success: true,
user: {
id: user.id,
username: user.plexUsername,
isAdmin: user.role === 'admin',
},
tokens,
};
}
async register(params: RegisterParams): Promise<AuthResult> {
const { username, password } = params;
// Validate
if (!username || username.length < 3) {
return { success: false, error: 'Username must be at least 3 characters' };
}
if (!password || password.length < 8) {
return { success: false, error: 'Password must be at least 8 characters' };
}
// Check if registration is enabled
const registrationEnabled = await getConfig('auth.registration_enabled');
if (registrationEnabled !== 'true') {
return { success: false, error: 'Registration is disabled' };
}
// Check username uniqueness
const existing = await prisma.user.findFirst({
where: {
plexUsername: username,
authProvider: 'local',
},
});
if (existing) {
return { success: false, error: 'Username already taken' };
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Determine registration status
const requireApproval = (await getConfig('auth.require_admin_approval')) === 'true';
const registrationStatus = requireApproval ? 'pending_approval' : 'approved';
// Check if first user (make admin)
const userCount = await prisma.user.count();
const isFirstUser = userCount === 0;
// Create user
const user = await prisma.user.create({
data: {
plexId: `local-${username}`,
plexUsername: username,
authToken: passwordHash,
authProvider: 'local',
role: isFirstUser ? 'admin' : 'user',
isSetupAdmin: isFirstUser,
registrationStatus: isFirstUser ? 'approved' : registrationStatus,
},
});
if (requireApproval && !isFirstUser) {
return { success: false, requiresApproval: true };
}
// Generate tokens for immediate login
const tokens = await generateJWT({
id: user.id,
username: user.plexUsername,
role: user.role,
});
return {
success: true,
user: {
id: user.id,
username: user.plexUsername,
isAdmin: user.role === 'admin',
},
tokens,
};
}
async refreshToken(refreshToken: string): Promise<AuthTokens | null> {
// Reuse existing JWT refresh logic
return null;
}
async validateAccess(userInfo: UserInfo): Promise<boolean> {
return true;
}
}
4.2 Create Registration Endpoint
Create file: src/app/api/auth/register/route.ts
/**
* User Registration Endpoint
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { LocalAuthProvider } from '@/lib/services/auth/LocalAuthProvider';
// Rate limiting map (in production, use Redis)
const registrationAttempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 5;
const WINDOW_MS = 60 * 60 * 1000; // 1 hour
function checkRateLimit(ip: string): boolean {
const now = Date.now();
const attempts = registrationAttempts.get(ip);
if (!attempts || now > attempts.resetAt) {
registrationAttempts.set(ip, { count: 1, resetAt: now + WINDOW_MS });
return true;
}
if (attempts.count >= MAX_ATTEMPTS) {
return false;
}
attempts.count++;
return true;
}
export async function POST(request: NextRequest) {
// Rate limiting
const ip = request.headers.get('x-forwarded-for') || 'unknown';
if (!checkRateLimit(ip)) {
return NextResponse.json(
{ error: 'Too many registration attempts. Please try again later.' },
{ status: 429 }
);
}
try {
const { username, password } = await request.json();
const provider = new LocalAuthProvider();
const result = await provider.register({ username, password });
if (!result.success) {
if (result.requiresApproval) {
return NextResponse.json({
success: false,
pendingApproval: true,
message: 'Account created. Waiting for admin approval.',
});
}
return NextResponse.json(
{ error: result.error },
{ status: 400 }
);
}
// Return tokens for auto-login
return NextResponse.json({
success: true,
user: result.user,
accessToken: result.tokens!.accessToken,
refreshToken: result.tokens!.refreshToken,
});
} catch (error) {
return NextResponse.json(
{ error: 'Registration failed' },
{ status: 500 }
);
}
}
4.3 Create Local Login Endpoint
Create file: src/app/api/auth/local/login/route.ts
/**
* Local Login Endpoint
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { LocalAuthProvider } from '@/lib/services/auth/LocalAuthProvider';
export async function POST(request: NextRequest) {
try {
const { username, password } = await request.json();
const provider = new LocalAuthProvider();
const result = await provider.handleCallback({ username, password });
if (!result.success) {
if (result.requiresApproval) {
return NextResponse.json({
success: false,
pendingApproval: true,
message: 'Account pending admin approval.',
});
}
return NextResponse.json(
{ error: result.error },
{ status: 401 }
);
}
return NextResponse.json({
success: true,
user: result.user,
accessToken: result.tokens!.accessToken,
refreshToken: result.tokens!.refreshToken,
});
} catch (error) {
return NextResponse.json(
{ error: 'Login failed' },
{ status: 500 }
);
}
}
4.4 Create Auth Providers Endpoint
Create file: src/app/api/auth/providers/route.ts
/**
* List Available Auth Providers
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextResponse } from 'next/server';
import { getConfig, getBackendMode } from '@/lib/services/config.service';
export async function GET() {
const mode = await getBackendMode();
if (mode === 'plex') {
return NextResponse.json({
providers: ['plex'],
registrationEnabled: false,
});
}
// Audiobookshelf mode
const oidcEnabled = (await getConfig('oidc.enabled')) === 'true';
const registrationEnabled = (await getConfig('auth.registration_enabled')) === 'true';
const oidcProviderName = await getConfig('oidc.provider_name') || 'SSO';
const providers: string[] = [];
if (oidcEnabled) providers.push('oidc');
if (registrationEnabled) providers.push('local');
return NextResponse.json({
providers,
registrationEnabled,
oidcProviderName: oidcEnabled ? oidcProviderName : null,
});
}
4.5 Create Admin User Approval Endpoints
Create file: src/app/api/admin/users/pending/route.ts
/**
* Pending User Approvals
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { requireAdmin } from '@/lib/middleware/auth';
export async function GET() {
const authResult = await requireAdmin();
if (!authResult.success) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
const pendingUsers = await prisma.user.findMany({
where: { registrationStatus: 'pending_approval' },
select: {
id: true,
plexUsername: true,
createdAt: true,
authProvider: true,
},
orderBy: { createdAt: 'desc' },
});
return NextResponse.json({ users: pendingUsers });
}
Create file: src/app/api/admin/users/[id]/approve/route.ts
/**
* Approve User Registration
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { requireAdmin } from '@/lib/middleware/auth';
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const authResult = await requireAdmin();
if (!authResult.success) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
await prisma.user.update({
where: { id: params.id },
data: { registrationStatus: 'approved' },
});
return NextResponse.json({ success: true });
}
4.6 Phase 4 Verification
Tests to run:
- Registration creates user with correct status
- Login works for approved users
- Login blocked for pending users
- Admin can see pending users
- Admin can approve users
- Rate limiting works
- Existing auth methods still work
Checklist:
LocalAuthProviderimplements interface- Registration endpoint with rate limiting
- Local login endpoint
- Auth providers listing endpoint
- Admin approval endpoints
- First user becomes admin
Phase 5: Setup Wizard Modifications
5.1 Create Backend Selection Step
Create file: src/app/setup/components/BackendSelectionStep.tsx
/**
* Backend Selection Step
* Documentation: documentation/features/audiobookshelf-integration.md
*/
'use client';
import { useState } from 'react';
interface Props {
value: 'plex' | 'audiobookshelf';
onChange: (value: 'plex' | 'audiobookshelf') => void;
onNext: () => void;
}
export function BackendSelectionStep({ value, onChange, onNext }: Props) {
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold">Choose Your Library Backend</h2>
<p className="text-gray-600 mt-2">
Select which media server you'll use to manage your audiobook library.
</p>
</div>
<div className="space-y-4">
<label className={`block p-4 border rounded-lg cursor-pointer transition ${
value === 'plex' ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'
}`}>
<input
type="radio"
name="backend"
value="plex"
checked={value === 'plex'}
onChange={() => onChange('plex')}
className="sr-only"
/>
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-orange-500 rounded-lg flex items-center justify-center">
{/* Plex icon */}
<span className="text-white text-2xl">P</span>
</div>
<div>
<h3 className="font-semibold">Plex Media Server</h3>
<p className="text-sm text-gray-600">
Use Plex for library management. Authentication via Plex OAuth.
</p>
</div>
</div>
</label>
<label className={`block p-4 border rounded-lg cursor-pointer transition ${
value === 'audiobookshelf' ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'
}`}>
<input
type="radio"
name="backend"
value="audiobookshelf"
checked={value === 'audiobookshelf'}
onChange={() => onChange('audiobookshelf')}
className="sr-only"
/>
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-green-500 rounded-lg flex items-center justify-center">
{/* ABS icon */}
<span className="text-white text-2xl">A</span>
</div>
<div>
<h3 className="font-semibold">Audiobookshelf</h3>
<p className="text-sm text-gray-600">
Use Audiobookshelf for library management. Choose OIDC or password authentication.
</p>
</div>
</div>
</label>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p className="text-sm text-yellow-800">
<strong>Note:</strong> This choice cannot be changed after setup.
To switch backends, you'll need to reset the application.
</p>
</div>
<button
onClick={onNext}
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
>
Continue
</button>
</div>
);
}
5.2 Create Audiobookshelf Setup Step
Create file: src/app/setup/components/AudiobookshelfStep.tsx
/**
* Audiobookshelf Configuration Step
* Documentation: documentation/features/audiobookshelf-integration.md
*/
'use client';
import { useState } from 'react';
interface Props {
serverUrl: string;
apiToken: string;
libraryId: string;
onServerUrlChange: (value: string) => void;
onApiTokenChange: (value: string) => void;
onLibraryIdChange: (value: string) => void;
onNext: () => void;
onBack: () => void;
}
export function AudiobookshelfStep({
serverUrl,
apiToken,
libraryId,
onServerUrlChange,
onApiTokenChange,
onLibraryIdChange,
onNext,
onBack,
}: Props) {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
libraries?: { id: string; name: string; itemCount: number }[];
error?: string;
} | null>(null);
const handleTest = async () => {
setTesting(true);
setTestResult(null);
try {
const response = await fetch('/api/setup/test-abs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverUrl, apiToken }),
});
const data = await response.json();
setTestResult(data);
} catch (error) {
setTestResult({ success: false, error: 'Connection failed' });
} finally {
setTesting(false);
}
};
const canProceed = testResult?.success && libraryId;
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold">Configure Audiobookshelf</h2>
<p className="text-gray-600 mt-2">
Enter your Audiobookshelf server details and API token.
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Server URL</label>
<input
type="url"
value={serverUrl}
onChange={(e) => onServerUrlChange(e.target.value)}
placeholder="http://audiobookshelf:13378"
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">API Token</label>
<input
type="password"
value={apiToken}
onChange={(e) => onApiTokenChange(e.target.value)}
placeholder="Your API token"
className="w-full px-3 py-2 border rounded-lg"
/>
<p className="text-xs text-gray-500 mt-1">
Find this in Audiobookshelf → Settings → Users → Your User → API Token
</p>
</div>
<button
onClick={handleTest}
disabled={testing || !serverUrl || !apiToken}
className="px-4 py-2 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50"
>
{testing ? 'Testing...' : 'Test Connection'}
</button>
{testResult && (
<div className={`p-4 rounded-lg ${testResult.success ? 'bg-green-50' : 'bg-red-50'}`}>
{testResult.success ? (
<>
<p className="text-green-800 font-medium">Connection successful!</p>
<div className="mt-2">
<label className="block text-sm font-medium mb-1">Select Library</label>
<select
value={libraryId}
onChange={(e) => onLibraryIdChange(e.target.value)}
className="w-full px-3 py-2 border rounded-lg"
>
<option value="">Select a library...</option>
{testResult.libraries?.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.name} ({lib.itemCount} items)
</option>
))}
</select>
</div>
</>
) : (
<p className="text-red-800">{testResult.error}</p>
)}
</div>
)}
</div>
<div className="flex gap-4">
<button
onClick={onBack}
className="flex-1 py-3 border rounded-lg hover:bg-gray-50 transition"
>
Back
</button>
<button
onClick={onNext}
disabled={!canProceed}
className="flex-1 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition"
>
Continue
</button>
</div>
</div>
);
}
5.3 Create Auth Method Selection Step
Create file: src/app/setup/components/AuthMethodStep.tsx
Similar pattern to BackendSelectionStep - allow choosing between:
- OIDC Provider
- Manual Registration
- Both
5.4 Create OIDC Configuration Step
Create file: src/app/setup/components/OIDCConfigStep.tsx
Include fields for:
- Provider name (display name)
- Issuer URL
- Client ID
- Client Secret
- Access control method selection
- Group claim configuration (if group_claim selected)
Include test connection button that validates OIDC discovery.
5.5 Create Registration Settings Step
Create file: src/app/setup/components/RegistrationSettingsStep.tsx
Include:
- Enable/disable toggle
- Require admin approval toggle
5.6 Update Main Setup Wizard
Modify: src/app/setup/page.tsx
Update step flow based on backend mode:
const steps = useMemo(() => {
const baseSteps = ['welcome', 'backend'];
if (state.backendMode === 'plex') {
return [...baseSteps, 'plex', 'admin', 'prowlarr', 'download', 'paths', 'bookdate', 'review', 'finalize'];
} else {
return [...baseSteps, 'audiobookshelf', 'auth-method',
...(state.authMethod === 'oidc' || state.authMethod === 'both' ? ['oidc-config'] : []),
...(state.authMethod === 'manual' || state.authMethod === 'both' ? ['registration-settings'] : []),
...(state.authMethod === 'manual' ? ['admin-account'] : []),
'prowlarr', 'download', 'paths', 'bookdate', 'review', 'finalize'
];
}
}, [state.backendMode, state.authMethod]);
5.7 Update Setup Complete Endpoint
Modify: src/app/api/setup/complete/route.ts
Handle saving all new configuration:
// Save backend mode
await setConfig('system.backend_mode', state.backendMode);
if (state.backendMode === 'audiobookshelf') {
// Save ABS config
await setConfig('abs.server_url', state.absUrl);
await setConfig('abs.api_token', state.absApiToken, true); // encrypted
await setConfig('abs.library_id', state.absLibraryId);
// Save auth config
if (state.authMethod === 'oidc' || state.authMethod === 'both') {
await setConfig('oidc.enabled', 'true');
await setConfig('oidc.provider_name', state.oidcProviderName);
await setConfig('oidc.issuer_url', state.oidcIssuerUrl);
await setConfig('oidc.client_id', state.oidcClientId);
await setConfig('oidc.client_secret', state.oidcClientSecret, true);
await setConfig('oidc.access_control_method', state.oidcAccessMethod);
// ... other OIDC config
}
if (state.authMethod === 'manual' || state.authMethod === 'both') {
await setConfig('auth.registration_enabled', 'true');
await setConfig('auth.require_admin_approval', state.requireAdminApproval ? 'true' : 'false');
}
}
5.8 Phase 5 Verification
Tests to run:
- Full setup flow with Plex mode
- Full setup flow with ABS + OIDC
- Full setup flow with ABS + Manual registration
- All config saved correctly
- Correct steps shown for each mode
Checklist:
- Backend selection step
- ABS configuration step
- Auth method selection step
- OIDC configuration step
- Registration settings step
- Dynamic step flow based on selections
- Setup complete saves all config
Phase 6: Settings & Login UI
6.1 Update Login Page for Multi-Mode
Modify: src/app/login/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { PlexLoginButton } from '@/components/auth/PlexLoginButton';
import { OIDCLoginButton } from '@/components/auth/OIDCLoginButton';
import { LocalLoginForm } from '@/components/auth/LocalLoginForm';
import { RegistrationForm } from '@/components/auth/RegistrationForm';
export default function LoginPage() {
const [providers, setProviders] = useState<{
providers: string[];
registrationEnabled: boolean;
oidcProviderName: string | null;
} | null>(null);
const [showRegister, setShowRegister] = useState(false);
useEffect(() => {
fetch('/api/auth/providers')
.then(res => res.json())
.then(setProviders);
}, []);
if (!providers) return <div>Loading...</div>;
// Plex mode
if (providers.providers.includes('plex')) {
return <PlexLoginButton />;
}
// Audiobookshelf mode
return (
<div className="max-w-md mx-auto space-y-6">
{showRegister ? (
<>
<RegistrationForm onSuccess={() => setShowRegister(false)} />
<button onClick={() => setShowRegister(false)}>
Already have an account? Login
</button>
</>
) : (
<>
{providers.providers.includes('oidc') && (
<OIDCLoginButton providerName={providers.oidcProviderName!} />
)}
{providers.providers.includes('oidc') && providers.providers.includes('local') && (
<div className="text-center text-gray-500">OR</div>
)}
{providers.providers.includes('local') && (
<LocalLoginForm />
)}
{providers.registrationEnabled && (
<button onClick={() => setShowRegister(true)}>
Don't have an account? Register
</button>
)}
</>
)}
</div>
);
}
6.2 Create Auth Components
Create: src/components/auth/OIDCLoginButton.tsx
Create: src/components/auth/LocalLoginForm.tsx
Create: src/components/auth/RegistrationForm.tsx
6.3 Add Settings Tabs for ABS Mode
Modify: src/app/admin/settings/page.tsx
Add conditional tabs:
- Audiobookshelf tab (if mode = audiobookshelf)
- OIDC tab (if OIDC enabled)
- Registration tab (if registration enabled)
6.4 Create Settings Tab Components
Create: src/app/admin/settings/components/AudiobookshelfTab.tsx
Create: src/app/admin/settings/components/OIDCTab.tsx
Create: src/app/admin/settings/components/RegistrationTab.tsx
6.5 Phase 6 Verification
Tests to run:
- Login page shows correct options per mode
- OIDC login button redirects correctly
- Local login form works
- Registration form works
- Settings tabs appear correctly
- Settings can be modified and saved
Phase 7: Integration Testing & Documentation
7.1 End-to-End Tests
Create comprehensive tests for:
-
Plex Mode (Regression)
- Full setup flow
- Login/logout
- Library scanning
- Request flow
-
ABS + OIDC Mode
- Full setup flow
- OIDC login with group claim access control
- Library scanning
- Request flow
-
ABS + Manual Registration Mode
- Full setup flow
- User registration
- Admin approval flow
- Login after approval
- Library scanning
- Request flow
7.2 Update Documentation
Update: documentation/TABLEOFCONTENTS.md
- Add entries for new integration docs
Update: documentation/backend/services/auth.md
- Add OIDC and local auth sections
Create: documentation/integrations/audiobookshelf.md
- API reference
- Configuration
- Troubleshooting
Update: documentation/setup-wizard.md
- Document new steps
- Mode-specific flows
7.3 Final Verification Checklist
- All Phase 1-6 checklists complete
- Plex mode unchanged (full regression)
- ABS library integration works
- OIDC authentication works
- Group claim access control works
- Manual registration works
- Admin approval works
- Setup wizard handles all modes
- Settings pages handle all modes
- Login page adapts to mode
- Documentation updated
- No console errors
- No TypeScript errors
- All tests pass
Important Notes for AI Agent
- Always read existing code first before making changes
- Run tests frequently - after each major change
- Keep existing functionality working - Plex mode must not break
- Follow existing patterns - match code style and structure
- Update imports when moving/creating files
- Handle errors gracefully - never crash on API errors
- Log appropriately - debug info but never tokens
- Ask if unclear - don't make assumptions about requirements