diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AuthApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AuthApi.kt new file mode 100644 index 00000000..cb8cbebb --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AuthApi.kt @@ -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() +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthController.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthController.kt new file mode 100644 index 00000000..05150cf1 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthController.kt @@ -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(null) + val currentUser: StateFlow = currentUserState.asStateFlow() + + val sessionCookie: StateFlow 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() } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/LoginScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/LoginScreen.kt new file mode 100644 index 00000000..24fbb12c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/LoginScreen.kt @@ -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") + } + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/LoginViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/LoginViewModel.kt new file mode 100644 index 00000000..43b15e1a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/LoginViewModel.kt @@ -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 = 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) } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/UserRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/UserRef.kt new file mode 100644 index 00000000..404489d3 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/UserRef.kt @@ -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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AuthWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AuthWire.kt new file mode 100644 index 00000000..f678f7e1 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AuthWire.kt @@ -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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt index fedc385a..13aa6476 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt @@ -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( // 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 { ComingSoon("Connect to your Minstrel", "Server-URL entry — Phase 11.") } - composable { ComingSoon("Sign in", "Login flow — Phase 11.") } + composable { LoginScreen(navController = navController) } } @Composable