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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user