feat(android): Phase 11 Commit A — AuthController + Login screen

Foundation of the auth flow. POST /api/auth/login lands the user with
their session cookie captured by the existing AuthCookieInterceptor;
LoginScreen drives it, AuthController owns the in-memory currentUser.

New:
  - models/wire/AuthWire.kt — LoginRequestBody + LoginResponseWire +
    UserWire. The response `token` is duplicated for header-auth
    clients; we use the Set-Cookie capture path instead.
  - models/UserRef.kt — caller-facing identity (no password hash, no
    api token, no Subsonic password). Matches the server's UserView
    envelope.
  - api/endpoints/AuthApi.kt — Retrofit POST /api/auth/login +
    /api/auth/logout.
  - auth/AuthController.kt — @Singleton facade. `currentUser`
    StateFlow + `isSignedIn` (read straight off AuthStore.sessionCookie).
    `signIn(username, password)` posts the login, surfaces the typed
    UserRef. `signOut()` clears the cookie locally and best-effort
    POSTs /logout (swallowed network failure — local state already
    cleared, the server session times out on its own).
    Cross-restart caveat: cookie persists in AuthStore but currentUser
    is in-memory only — UI stays signed in across restarts but can't
    render the username until a follow-up sign-in. Persisting user
    JSON on AuthSessionEntity is a follow-up if it matters.
  - auth/ui/LoginViewModel.kt — VM + LoginFormState (username +
    password fields + submitting + error + signedIn flag). `submit()`
    is a no-op while in-flight or with empty fields.
  - auth/ui/LoginScreen.kt — centered form with two OutlinedTextFields
    + a Sign-in button that spins while in-flight. Error message
    surfaces under the password field. On `signedIn = true` flips,
    navigates to Home and pops Login off the back stack.

Modified:
  - nav/MinstrelNavGraph.kt — Login route renders the real screen;
    outsideShell extension now takes navController so it can be
    threaded down (LoginScreen needs it for the post-sign-in nav).

ServerUrl screen + Settings + auth-gated redirect logic come in
Commits B / C.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 02:03:41 -04:00
parent d6301f5bdc
commit d6b8c20a17
7 changed files with 323 additions and 3 deletions
@@ -0,0 +1,22 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.LoginRequestBody
import com.fabledsword.minstrel.models.wire.LoginResponseWire
import retrofit2.http.Body
import retrofit2.http.POST
/**
* Retrofit interface for `/api/auth`. Mirrors
* `flutter_client/lib/api/endpoints/auth.dart`.
*
* The actual session-cookie capture happens in
* [com.fabledsword.minstrel.api.AuthCookieInterceptor]; we don't
* pass the response `token` around manually.
*/
interface AuthApi {
@POST("api/auth/login")
suspend fun login(@Body body: LoginRequestBody): LoginResponseWire
@POST("api/auth/logout")
suspend fun logout()
}
@@ -0,0 +1,71 @@
package com.fabledsword.minstrel.auth
import com.fabledsword.minstrel.api.endpoints.AuthApi
import com.fabledsword.minstrel.models.UserRef
import com.fabledsword.minstrel.models.wire.LoginRequestBody
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Singleton facade over the auth state machine. Mirrors Flutter's
* `AuthController` from `auth_provider.dart`.
*
* Cookie persistence is handled by [AuthCookieInterceptor] capturing
* Set-Cookie on the login response; this controller owns the
* in-memory user identity that the UI binds against.
*
* `currentUser` is null until a successful login. Across process
* restarts the cookie survives in AuthStore but `currentUser`
* doesn't — a future refinement persists the user JSON alongside
* the cookie so the UI can render an authenticated shell on cold
* launch without re-prompting.
*/
@Singleton
class AuthController @Inject constructor(
private val authStore: AuthStore,
retrofit: Retrofit,
) {
private val api: AuthApi = retrofit.create()
private val currentUserState = MutableStateFlow<UserRef?>(null)
val currentUser: StateFlow<UserRef?> = currentUserState.asStateFlow()
val sessionCookie: StateFlow<String?> get() = authStore.sessionCookie
val isSignedIn: Boolean
get() = !authStore.sessionCookie.value.isNullOrEmpty()
/**
* Submit credentials. On success the AuthCookieInterceptor will
* have captured the Set-Cookie before this method returns; the
* caller can safely navigate to the in-shell graph. Throws on
* any non-2xx response (caller surfaces in UI).
*/
suspend fun signIn(username: String, password: String): UserRef {
val response = api.login(LoginRequestBody(username = username, password = password))
val user = UserRef(
id = response.user.id,
username = response.user.username,
isAdmin = response.user.isAdmin,
)
currentUserState.value = user
return user
}
/**
* Clears the local session immediately and best-effort hits
* `/api/auth/logout` so the server can drop its session row.
* Network errors are swallowed — the user is already locally
* signed out and a stale server session times out on its own.
*/
suspend fun signOut() {
currentUserState.value = null
authStore.setSessionCookie(null)
runCatching { api.logout() }
}
}
@@ -0,0 +1,113 @@
package com.fabledsword.minstrel.auth.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.nav.Home
import com.fabledsword.minstrel.nav.Login
@Composable
fun LoginScreen(
navController: NavHostController,
viewModel: LoginViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(state.signedIn) {
if (state.signedIn) {
navController.navigate(Home) {
popUpTo(Login) { inclusive = true }
}
viewModel.consumeSignedIn()
}
}
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Sign in",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
OutlinedTextField(
value = state.username,
onValueChange = viewModel::setUsername,
modifier = Modifier.fillMaxWidth(),
label = { Text("Username") },
singleLine = true,
enabled = !state.isSubmitting,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next,
),
)
OutlinedTextField(
value = state.password,
onValueChange = viewModel::setPassword,
modifier = Modifier.fillMaxWidth(),
label = { Text("Password") },
singleLine = true,
enabled = !state.isSubmitting,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { viewModel.submit() }),
)
if (state.errorMessage != null) {
Text(
text = state.errorMessage.orEmpty(),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Button(
onClick = viewModel::submit,
enabled = !state.isSubmitting &&
state.username.isNotBlank() &&
state.password.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
if (state.isSubmitting) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp,
)
} else {
Text("Sign in")
}
}
}
}
}
@@ -0,0 +1,63 @@
package com.fabledsword.minstrel.auth.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.auth.AuthController
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class LoginFormState(
val username: String = "",
val password: String = "",
val isSubmitting: Boolean = false,
val errorMessage: String? = null,
val signedIn: Boolean = false,
)
@HiltViewModel
class LoginViewModel @Inject constructor(
private val auth: AuthController,
) : ViewModel() {
private val internal = MutableStateFlow(LoginFormState())
val state: StateFlow<LoginFormState> = internal.asStateFlow()
fun setUsername(value: String) {
internal.update { it.copy(username = value, errorMessage = null) }
}
fun setPassword(value: String) {
internal.update { it.copy(password = value, errorMessage = null) }
}
fun submit() {
val s = internal.value
if (s.username.isBlank() || s.password.isBlank() || s.isSubmitting) return
viewModelScope.launch {
internal.update { it.copy(isSubmitting = true, errorMessage = null) }
try {
auth.signIn(s.username, s.password)
internal.update { it.copy(isSubmitting = false, signedIn = true) }
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.update {
it.copy(
isSubmitting = false,
errorMessage = e.message ?: "Sign-in failed",
)
}
}
}
}
/** Called after the navigation away from /login completes. */
fun consumeSignedIn() {
internal.update { it.copy(signedIn = false) }
}
}
@@ -0,0 +1,12 @@
package com.fabledsword.minstrel.models
/**
* The caller's identity as returned by `POST /api/auth/login`. Narrow
* field set — no password hash, no api token, no Subsonic password —
* matches the server's `UserView` envelope.
*/
data class UserRef(
val id: String,
val username: String,
val isAdmin: Boolean = false,
)
@@ -0,0 +1,38 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* `POST /api/auth/login` request body.
*/
@Serializable
data class LoginRequestBody(
val username: String,
val password: String,
)
/**
* `POST /api/auth/login` response. Server emits `{token, user{id,
* username, is_admin}}`. The cookie path actually carries the
* session — the token is duplicated here for clients that prefer
* header-based auth (not us). We use the AuthCookieInterceptor's
* Set-Cookie capture instead.
*/
@Serializable
data class LoginResponseWire(
val token: String = "",
val user: UserWire,
)
/**
* Mirrors `internal/api/types.go UserView` — the narrow caller-facing
* user shape. `is_admin` is always present in the response but
* tolerated as missing (default false) so older fixtures load.
*/
@Serializable
data class UserWire(
val id: String,
val username: String,
@SerialName("is_admin") val isAdmin: Boolean = false,
)
@@ -14,6 +14,7 @@ import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
import com.fabledsword.minstrel.auth.ui.LoginScreen
import com.fabledsword.minstrel.discover.ui.DiscoverScreen
import com.fabledsword.minstrel.home.ui.HomeScreen
import com.fabledsword.minstrel.library.ui.LibraryScreen
@@ -45,7 +46,7 @@ fun MinstrelNavGraph(
) {
inShellTopLevel(navController, expandPlayer)
inShellDetail(navController, expandPlayer)
outsideShell()
outsideShell(navController)
}
}
@@ -133,7 +134,7 @@ private fun NavGraphBuilder.inShellDetail(
}
}
private fun NavGraphBuilder.outsideShell() {
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
composable<NowPlaying>(
// Slide up from the bottom on enter; back down on dismiss.
// Mirrors the Flutter CustomTransitionPage (280ms easeOutCubic).
@@ -148,7 +149,7 @@ private fun NavGraphBuilder.outsideShell() {
composable<ServerUrl> {
ComingSoon("Connect to your Minstrel", "Server-URL entry — Phase 11.")
}
composable<Login> { ComingSoon("Sign in", "Login flow — Phase 11.") }
composable<Login> { LoginScreen(navController = navController) }
}
@Composable