Add option to require server login for user profile (#1367)

## Description
This PR adds a new method of protecting a user profile when switching to
it: server login. When enabled, switching to the user requires
authenticating with the server again via Quick Connect or username &
password. This is basically the same as the official client.

### Related issues
Closes #321
Related to #1330 - might cause merge conflicts
Related to #1287 - definitely causes merge conflicts

### Testing
Emulator with various uses cases including auto sign-in, switching
between server login & PIN protection, and removing the protection

## Screenshots
N/A, the login looks identical as adding a new user

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-05-20 12:00:02 -04:00 • committed by GitHub
parent 1463480e26
commit 45d466d76f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 813 additions and 35 deletions

View file

@ -19,11 +19,19 @@ import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice
import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser
import com.github.damontecres.wholphin.ui.components.ViewOptions
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.serializer.toUUID
import timber.log.Timber
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.UUID
@Database(
@ -40,7 +48,7 @@ import java.util.UUID
SeerrUser::class,
],
version = 31,
version = 32,
exportSchema = true,
autoMigrations = [
AutoMigration(3, 4),
@ -55,6 +63,7 @@ import java.util.UUID
AutoMigration(12, 20),
AutoMigration(20, 30),
AutoMigration(30, 31),
AutoMigration(31, 32),
],
)
@TypeConverters(Converters::class)
@ -116,6 +125,27 @@ class Converters {
Timber.e(ex, "Error parsing view options")
null
}
@TypeConverter
fun convertToString(dateTime: ZonedDateTime): String = DateTimeFormatter.ISO_ZONED_DATE_TIME.format(dateTime)
@TypeConverter
fun convertToLocalDateTime(dateTime: String): ZonedDateTime = ZonedDateTime.parse(dateTime, DateTimeFormatter.ISO_ZONED_DATE_TIME)
}
class ZonedDateTimeSerializer : KSerializer<ZonedDateTime> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor(ZonedDateTime::class.qualifiedName!!, PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): ZonedDateTime =
ZonedDateTime.parse(decoder.decodeString(), DateTimeFormatter.ISO_ZONED_DATE_TIME)
override fun serialize(
encoder: Encoder,
value: ZonedDateTime,
) {
encoder.encodeString(DateTimeFormatter.ISO_ZONED_DATE_TIME.format(value))
}
}
object Migrations {

View file

@ -27,6 +27,7 @@ import org.jellyfin.sdk.model.api.AuthenticationResult
import org.jellyfin.sdk.model.api.UserDto
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import java.time.ZonedDateTime
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@ -167,6 +168,7 @@ class ServerRepository
suspend fun changeUser(
serverUrl: String,
authenticationResult: AuthenticationResult,
existingUser: JellyfinUser?,
) = withContext(ioDispatcher) {
val accessToken = authenticationResult.accessToken
if (accessToken != null) {
@ -183,12 +185,24 @@ class ServerRepository
if (server != null) {
val user =
authedUser?.let {
JellyfinUser(
id = it.id,
name = it.name,
serverId = server.id,
accessToken = accessToken,
)
if (existingUser != null) {
Timber.d("Re-using existing user")
existingUser.copy(
// If the server authenticated via the server, always remove the PIN
pin = null,
accessToken = accessToken,
lastUsed = ZonedDateTime.now(),
)
} else {
Timber.d("Creating new user")
JellyfinUser(
id = it.id,
name = it.name,
serverId = server.id,
accessToken = accessToken,
lastUsed = ZonedDateTime.now(),
)
}
}
if (user != null) {
return@withContext changeUser(server, user)
@ -253,11 +267,12 @@ class ServerRepository
}
}
suspend fun setUserPin(
suspend fun updateUserAuth(
user: JellyfinUser,
pin: String?,
) = withContext(ioDispatcher) {
val newUser = user.copy(pin = pin)
requireLogin: Boolean,
) {
val newUser = user.copy(pin = pin, requireLogin = requireLogin)
val updatedUser = serverDao.addOrUpdateUser(newUser)
if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) {
// Updating current user, so push out the change

View file

@ -1,4 +1,4 @@
@file:UseSerializers(UUIDSerializer::class)
@file:UseSerializers(UUIDSerializer::class, ZonedDateTimeSerializer::class)
package com.github.damontecres.wholphin.data.model
@ -10,11 +10,13 @@ import androidx.room.Ignore
import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.Relation
import com.github.damontecres.wholphin.data.ZonedDateTimeSerializer
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.ServerVersion
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.time.ZonedDateTime
import java.util.UUID
/**
@ -27,6 +29,7 @@ data class JellyfinServer(
val name: String?,
val url: String,
val version: String?,
val lastUsed: ZonedDateTime? = null,
) {
@get:Ignore
val serverVersion: ServerVersion? by lazy { version?.let(ServerVersion::fromString) }
@ -58,11 +61,14 @@ data class JellyfinUser(
val serverId: UUID,
val accessToken: String?,
val pin: String? = null,
@ColumnInfo(defaultValue = "false")
val requireLogin: Boolean = false,
val lastUsed: ZonedDateTime? = null,
) {
val hasPin: Boolean get() = pin.isNotNullOrBlank()
override fun toString(): String =
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()})"
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()})"
}
/**