test: stop wiping admin user during integration tests #29
@@ -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.AdminQuarantineScreen
|
||||||
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
|
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
|
||||||
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
|
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.discover.ui.DiscoverScreen
|
||||||
import com.fabledsword.minstrel.home.ui.HomeScreen
|
import com.fabledsword.minstrel.home.ui.HomeScreen
|
||||||
import com.fabledsword.minstrel.library.ui.LibraryScreen
|
import com.fabledsword.minstrel.library.ui.LibraryScreen
|
||||||
@@ -45,7 +46,7 @@ fun MinstrelNavGraph(
|
|||||||
) {
|
) {
|
||||||
inShellTopLevel(navController, expandPlayer)
|
inShellTopLevel(navController, expandPlayer)
|
||||||
inShellDetail(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>(
|
composable<NowPlaying>(
|
||||||
// Slide up from the bottom on enter; back down on dismiss.
|
// Slide up from the bottom on enter; back down on dismiss.
|
||||||
// Mirrors the Flutter CustomTransitionPage (280ms easeOutCubic).
|
// Mirrors the Flutter CustomTransitionPage (280ms easeOutCubic).
|
||||||
@@ -148,7 +149,7 @@ private fun NavGraphBuilder.outsideShell() {
|
|||||||
composable<ServerUrl> {
|
composable<ServerUrl> {
|
||||||
ComingSoon("Connect to your Minstrel", "Server-URL entry — Phase 11.")
|
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
|
@Composable
|
||||||
|
|||||||
Reference in New Issue
Block a user