diff --git a/prisma/migrations/20260304000000_normalize_local_usernames/migration.sql b/prisma/migrations/20260304000000_normalize_local_usernames/migration.sql new file mode 100644 index 0000000..5fbb0c2 --- /dev/null +++ b/prisma/migrations/20260304000000_normalize_local_usernames/migration.sql @@ -0,0 +1,3 @@ +-- Normalize existing local usernames to lowercase +UPDATE users SET plex_username = LOWER(plex_username) WHERE auth_provider = 'local' AND deleted_at IS NULL; +UPDATE users SET plex_id = 'local-' || LOWER(SUBSTRING(plex_id FROM 7)) WHERE plex_id LIKE 'local-%' AND plex_id NOT LIKE 'local-%-deleted-%'; diff --git a/src/app/api/auth/admin/login/route.ts b/src/app/api/auth/admin/login/route.ts index 291bb20..46310c5 100644 --- a/src/app/api/auth/admin/login/route.ts +++ b/src/app/api/auth/admin/login/route.ts @@ -38,9 +38,11 @@ export async function POST(request: NextRequest) { ); } + const normalizedUsername = username.trim().toLowerCase(); + // Find user by local admin identifier const user = await prisma.user.findUnique({ - where: { plexId: `local-${username}` }, + where: { plexId: `local-${normalizedUsername}` }, }); if (!user) { diff --git a/src/app/api/setup/complete/route.ts b/src/app/api/setup/complete/route.ts index 24dd593..637e0da 100644 --- a/src/app/api/setup/complete/route.ts +++ b/src/app/api/setup/complete/route.ts @@ -140,14 +140,15 @@ export async function POST(request: NextRequest) { ); } + const normalizedAdminUsername = admin.username.trim().toLowerCase(); const hashedPassword = await bcrypt.hash(admin.password, 10); const encryptionService = getEncryptionService(); const encryptedPassword = encryptionService.encrypt(hashedPassword); adminUser = await prisma.user.create({ data: { - plexId: `local-${admin.username}`, - plexUsername: admin.username, + plexId: `local-${normalizedAdminUsername}`, + plexUsername: normalizedAdminUsername, plexEmail: null, role: 'admin', isSetupAdmin: true, // Mark as setup admin - role cannot be changed diff --git a/src/lib/services/auth/LocalAuthProvider.ts b/src/lib/services/auth/LocalAuthProvider.ts index 1fbc929..c3dddb6 100644 --- a/src/lib/services/auth/LocalAuthProvider.ts +++ b/src/lib/services/auth/LocalAuthProvider.ts @@ -54,10 +54,12 @@ export class LocalAuthProvider implements IAuthProvider { return { success: false, error: 'Username and password required' }; } + const normalizedUsername = username.trim().toLowerCase(); + // Find user (exclude soft-deleted users) const user = await prisma.user.findFirst({ where: { - plexUsername: username, + plexUsername: normalizedUsername, authProvider: 'local', deletedAt: null, // Exclude soft-deleted users }, @@ -144,9 +146,10 @@ export class LocalAuthProvider implements IAuthProvider { async register(params: RegisterParams): Promise { try { const { username, password } = params; + const normalizedUsername = username?.trim().toLowerCase(); // Validate - if (!username || username.length < 3) { + if (!normalizedUsername || normalizedUsername.length < 3) { return { success: false, error: 'Username must be at least 3 characters' }; } @@ -167,7 +170,7 @@ export class LocalAuthProvider implements IAuthProvider { // Check username uniqueness (only among non-deleted users) const existing = await prisma.user.findFirst({ where: { - plexUsername: username, + plexUsername: normalizedUsername, authProvider: 'local', deletedAt: null, // Allow reuse of usernames from deleted accounts }, @@ -194,8 +197,8 @@ export class LocalAuthProvider implements IAuthProvider { // Create user const user = await prisma.user.create({ data: { - plexId: `local-${username}`, - plexUsername: username, + plexId: `local-${normalizedUsername}`, + plexUsername: normalizedUsername, authToken: encryptedHash, authProvider: 'local', role: isFirstUser ? 'admin' : 'user', diff --git a/tests/services/auth/local-auth-provider.test.ts b/tests/services/auth/local-auth-provider.test.ts index ff7a16e..f1e0a11 100644 --- a/tests/services/auth/local-auth-provider.test.ts +++ b/tests/services/auth/local-auth-provider.test.ts @@ -167,6 +167,31 @@ describe('LocalAuthProvider', () => { expect(result.error).toMatch(/invalid username or password/i); }); + it('normalizes username to lowercase on login', async () => { + prismaMock.user.findFirst.mockResolvedValue({ + id: 'user-ci', + plexId: 'local-admin', + plexUsername: 'admin', + role: 'admin', + authProvider: 'local', + authToken: 'enc:hash', + registrationStatus: 'approved', + deletedAt: null, + }); + prismaMock.user.update.mockResolvedValue({}); + bcryptCompare.mockResolvedValue(true); + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + await provider.handleCallback({ username: 'Admin', password: 'pass' }); + + expect(prismaMock.user.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ plexUsername: 'admin' }), + }) + ); + }); + it('blocks registration when disabled', async () => { configMock.get.mockResolvedValueOnce('false'); @@ -237,6 +262,51 @@ describe('LocalAuthProvider', () => { expect(result.error).toContain('Username already taken'); }); + it('stores lowercase username and plexId on registration', async () => { + configMock.get.mockResolvedValueOnce('true'); // registration enabled + configMock.get.mockResolvedValueOnce('false'); // no admin approval + prismaMock.user.findFirst.mockResolvedValue(null); + prismaMock.user.count.mockResolvedValue(1); + prismaMock.user.create.mockResolvedValue({ + id: 'user-ci2', + plexId: 'local-myuser', + plexUsername: 'myuser', + role: 'user', + }); + bcryptHash.mockResolvedValue('hash'); + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + await provider.register({ username: 'MyUser', password: 'password123' }); + + expect(prismaMock.user.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + plexId: 'local-myuser', + plexUsername: 'myuser', + }), + }) + ); + }); + + it('rejects duplicate username case-insensitively on registration', async () => { + configMock.get.mockResolvedValueOnce('true'); // registration enabled + prismaMock.user.findFirst.mockResolvedValue({ id: 'user-existing' }); + + const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider'); + const provider = new LocalAuthProvider(); + const result = await provider.register({ username: 'User', password: 'password123' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Username already taken'); + // The lookup should use the lowercased username + expect(prismaMock.user.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ plexUsername: 'user' }), + }) + ); + }); + it('creates admin user on first registration', async () => { configMock.get.mockResolvedValueOnce('true'); // registration enabled configMock.get.mockResolvedValueOnce('false'); // no admin approval