Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,7 @@ val androidModule = module {
viewModel { SignupViewModel(get()) }
viewModel { InviteClaimViewModel(get(), get()) }
viewModel { OnboardingTourViewModel(get(), get(), get(), get(), get()) }
viewModel { ProfileSelectionViewModel(get()) }
viewModel { ProfileSelectionViewModel(profileRepository = get(), authRepository = get()) }
viewModel { CreateProfileViewModel(get()) }
viewModel { EditProfileViewModel(get()) }
viewModel { ServerListViewModel(get(), get()) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,21 +194,31 @@ fun ProfileSelectionScreen(
ProfileFlow(
profiles = state.profiles,
isManageMode = state.isManageMode,
// Server-gated: creating needs an admin account or the
// acting profile to be the household primary (phone keeps
// it across Switch Profile) — except the very first
// profile, which the server lets any account bootstrap.
showAddProfile = state.canAddProfile,
onProfileTap = { viewModel.onProfileTapped(it) },
onProfileEdit = { onNavigateToEditProfile(it.id) },
onProfileDelete = { viewModel.requestDeleteProfile(it) },
onAddProfile = onNavigateToCreateProfile,
)

Spacer(modifier = Modifier.height(24.dp))
// Edit/delete would 403 unless the account is admin or the
// acting profile is the primary, so only offer the mode to
// callers the server will authorize.
if (state.canManageProfiles) {
Spacer(modifier = Modifier.height(24.dp))

TextButton(onClick = viewModel::toggleManageMode) {
Text(
text = if (state.isManageMode) "Done" else "Manage Profiles",
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
color = AuthColors.OnBackground,
)
TextButton(onClick = viewModel::toggleManageMode) {
Text(
text = if (state.isManageMode) "Done" else "Manage Profiles",
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
color = AuthColors.OnBackground,
)
}
}
}
}
Expand All @@ -226,6 +236,7 @@ fun ProfileSelectionScreen(
private fun ProfileFlow(
profiles: List<Profile>,
isManageMode: Boolean,
showAddProfile: Boolean,
onProfileTap: (Profile) -> Unit,
onProfileEdit: (Profile) -> Unit,
onProfileDelete: (Profile) -> Unit,
Expand All @@ -246,7 +257,9 @@ private fun ProfileFlow(
onDelete = { onProfileDelete(profile) },
)
}
AddProfileCard(onClick = onAddProfile)
if (showAddProfile) {
AddProfileCard(onClick = onAddProfile)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ package org.siloserver.silo.android.ui.screens.profiles

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import org.siloserver.silo.model.auth.User
import org.siloserver.silo.model.auth.canManageProfilesFromPicker
import org.siloserver.silo.model.profile.Profile
import org.siloserver.silo.model.profile.authorizedProfileToken
import org.siloserver.silo.network.ApiResult
import org.siloserver.silo.network.AuthScopeSnapshot
import org.siloserver.silo.repository.AuthRepository
import org.siloserver.silo.repository.ProfileCommitResult
import org.siloserver.silo.repository.ProfileRepository
import kotlinx.coroutines.flow.MutableStateFlow
Expand All @@ -30,12 +33,42 @@ data class ProfileSelectionUiState(
/** The profile this session is signed in as — deleting it needs a
* stronger warning and clears the local selection first. */
val activeProfileId: String? = null,
)
/**
* Whether the picker shows management affordances (Manage Profiles, Add
* Profile). Mirrors the server's gate — admin account OR acting as the
* primary profile (phone keeps the acting profile across Switch Profile) —
* see [canManageProfilesFromPicker]. Fails closed while neither resolves.
*/
val canManageProfiles: Boolean = false,
) {
/**
* The add tile stays visible on an empty grid regardless of role: the
* server exempts creation of the very first profile from the
* primary-or-admin gate, and hiding it would strand a fresh account.
*/
val canAddProfile: Boolean get() = canManageProfiles || profiles.isEmpty()
}

class ProfileSelectionViewModel(
private val profileRepository: ProfileRepository,
/**
* Resolves the signed-in account, null when it cannot be resolved (fails
* closed) — the seam unit tests drive, mirroring AdminEntryViewModel;
* production uses the repository-backed secondary constructor.
*/
private val currentUserProvider: suspend () -> User? = { null },
) : ViewModel() {

constructor(
profileRepository: ProfileRepository,
authRepository: AuthRepository,
) : this(
profileRepository = profileRepository,
currentUserProvider = {
(authRepository.getCurrentUser() as? ApiResult.Success)?.data
},
)

private val _uiState = MutableStateFlow(ProfileSelectionUiState())
val uiState: StateFlow<ProfileSelectionUiState> = _uiState.asStateFlow()

Expand Down Expand Up @@ -72,6 +105,7 @@ class ProfileSelectionViewModel(

val scope = profileRepository.captureIdentityScope()
val activeId = profileRepository.getActiveProfileId()
val user = currentUserProvider()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
val result = profileRepository.listProfiles()
// Two separate reasons to drop this response: a newer load
// superseded it, or the identity it was fetched under is gone.
Expand All @@ -83,7 +117,18 @@ class ProfileSelectionViewModel(
// Leaving a scope behind for an empty grid is stale metadata
// that a later selection could be qualified against.
gridScope = null
_uiState.update { it.copy(isLoading = false, profiles = emptyList()) }
// The management grant belongs to the identity the grid was
// fetched under — fail closed with it, and take the manage
// affordances (mode + pending delete) down with the grant.
_uiState.update {
it.copy(
isLoading = false,
profiles = emptyList(),
canManageProfiles = false,
isManageMode = false,
deleteDialogProfile = null,
)
}
return@launch
}

Expand All @@ -96,8 +141,28 @@ class ProfileSelectionViewModel(
// accepted as belonging to the new one. That is worse than
// the unguarded commit this was meant to fix.
gridScope = scope
// Resolve the acting profile against THIS grid so the
// grant stays paired with the identity scope it was
// fetched under. Phone keeps the acting profile across
// Switch Profile, so a non-admin owner acting as the
// primary is authorized here; at first login (or if the
// active id is gone from the list) this is null and only
// the admin arm can hold.
val activeProfile = result.data.firstOrNull { it.id == activeId }
val canManage = canManageProfilesFromPicker(user, activeProfile)
_uiState.update {
it.copy(isLoading = false, profiles = result.data, activeProfileId = activeId)
it.copy(
isLoading = false,
profiles = result.data,
activeProfileId = activeId,
canManageProfiles = canManage,
// A revoked grant must also leave manage mode (or
// the "Done" toggle disappears while its mode
// stays) and close a pending delete confirmation,
// which would otherwise stay open and actionable.
isManageMode = it.isManageMode && canManage,
deleteDialogProfile = if (canManage) it.deleteDialogProfile else null,
)
}
}

Expand Down Expand Up @@ -288,6 +353,8 @@ class ProfileSelectionViewModel(
pinIsVerifying = false,
pinError = null,
deleteDialogProfile = null,
isManageMode = false,
canManageProfiles = false,
)
}
loadProfiles()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package org.siloserver.silo.android.ui.screens.profiles

import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.siloserver.silo.model.auth.User
import org.siloserver.silo.model.profile.Profile
import org.siloserver.silo.network.ApiResult
import org.siloserver.silo.network.TokenManagerImpl
import org.siloserver.silo.network.api.ProfileApi
import org.siloserver.silo.repository.ProfileRepository
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue

/**
* Picker-time management gating. The server authorizes profile management
* for admin accounts OR when the acting profile is the household primary —
* and the phone keeps the acting profile across "Switch Profile", so both
* arms matter here. The picker must not offer create/edit/delete to anyone
* else — except the add tile on an empty grid, which the server exempts
* (first-profile bootstrap).
*/
@OptIn(ExperimentalCoroutinesApi::class)
class ProfileSelectionManagementGatingTest {
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
}

private fun user(role: String) = User(
id = 1,
username = "someone",
email = "someone@example.com",
role = role,
)

@Test
fun `admin account gets management affordances`() = runTest {
Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler))
val viewModel = ProfileSelectionViewModel(
profileRepository = FixedProfileRepository(listOf(Profile(id = "p1", name = "One"))),
currentUserProvider = { user("admin") },
)
advanceUntilIdle()

assertTrue(viewModel.uiState.value.canManageProfiles)
assertTrue(viewModel.uiState.value.canAddProfile)
}

@Test
fun `non-admin account with no acting profile gets no management affordances`() = runTest {
Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler))
val viewModel = ProfileSelectionViewModel(
profileRepository = FixedProfileRepository(listOf(Profile(id = "p1", name = "One"))),
currentUserProvider = { user("user") },
)
advanceUntilIdle()

assertFalse(viewModel.uiState.value.canManageProfiles)
assertFalse(viewModel.uiState.value.canAddProfile)
}

/**
* The regression the first cut of this gating introduced: phone keeps the
* acting profile across "Switch Profile", and the picker is the ONLY
* route to create/edit — a non-admin household owner acting as the
* primary is authorized by the server and must keep the affordances.
*/
@Test
fun `non-admin acting as the primary profile keeps management affordances`() = runTest {
Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler))
val viewModel = ProfileSelectionViewModel(
profileRepository = FixedProfileRepository(
profiles = listOf(
Profile(id = "owner", name = "Owner", isPrimary = true),
Profile(id = "kid", name = "Kid"),
),
activeProfileId = "owner",
),
currentUserProvider = { user("user") },
)
advanceUntilIdle()

assertTrue(viewModel.uiState.value.canManageProfiles)
assertTrue(viewModel.uiState.value.canAddProfile)
}

@Test
fun `non-admin acting as a non-primary profile gets no management affordances`() = runTest {
Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler))
val viewModel = ProfileSelectionViewModel(
profileRepository = FixedProfileRepository(
profiles = listOf(
Profile(id = "owner", name = "Owner", isPrimary = true),
Profile(id = "kid", name = "Kid"),
),
activeProfileId = "kid",
),
currentUserProvider = { user("user") },
)
advanceUntilIdle()

assertFalse(viewModel.uiState.value.canManageProfiles)
assertFalse(viewModel.uiState.value.canAddProfile)
}

@Test
fun `unresolved user fails closed`() = runTest {
Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler))
val viewModel = ProfileSelectionViewModel(
profileRepository = FixedProfileRepository(listOf(Profile(id = "p1", name = "One"))),
currentUserProvider = { null },
)
advanceUntilIdle()

assertFalse(viewModel.uiState.value.canManageProfiles)
assertFalse(viewModel.uiState.value.canAddProfile)
}

@Test
fun `empty grid keeps the add tile for first-profile bootstrap`() = runTest {
Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler))
val viewModel = ProfileSelectionViewModel(
profileRepository = FixedProfileRepository(emptyList()),
currentUserProvider = { user("user") },
)
advanceUntilIdle()

assertFalse(viewModel.uiState.value.canManageProfiles)
assertTrue(viewModel.uiState.value.canAddProfile)
}

/** Bootstrap must survive a dead `/me`: fresh account, user unresolved. */
@Test
fun `empty grid with unresolved user still shows the add tile`() = runTest {
Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler))
val viewModel = ProfileSelectionViewModel(
profileRepository = FixedProfileRepository(emptyList()),
currentUserProvider = { null },
)
advanceUntilIdle()

assertFalse(viewModel.uiState.value.canManageProfiles)
assertTrue(viewModel.uiState.value.canAddProfile)
}

@Test
fun `revoking the grant on reload leaves manage mode and closes the delete dialog`() = runTest {
Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler))
var currentUser: User? = user("admin")
val profile = Profile(id = "p1", name = "One")
val viewModel = ProfileSelectionViewModel(
profileRepository = FixedProfileRepository(listOf(profile)),
currentUserProvider = { currentUser },
)
advanceUntilIdle()
viewModel.toggleManageMode()
viewModel.requestDeleteProfile(profile)
assertTrue(viewModel.uiState.value.isManageMode)
assertNotNull(viewModel.uiState.value.deleteDialogProfile)

currentUser = null
viewModel.loadProfiles()
advanceUntilIdle()

// Otherwise the "Done" toggle disappears while its mode stays on,
// and the pending delete confirmation stays open and actionable.
assertFalse(viewModel.uiState.value.canManageProfiles)
assertFalse(viewModel.uiState.value.isManageMode)
assertNull(viewModel.uiState.value.deleteDialogProfile)
}
}

private class FixedProfileRepository(
private val profiles: List<Profile>,
private val activeProfileId: String? = null,
) : ProfileRepository(
profileApi = ProfileApi(HttpClient(MockEngine { respond("{}") })),
tokenManager = TokenManagerImpl(),
) {
override suspend fun listProfiles(): ApiResult<List<Profile>> = ApiResult.Success(profiles)

override suspend fun getActiveProfileId(): String? = activeProfileId
}
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ val androidTvModule = module {
viewModel { org.siloserver.silo.tv.ui.screens.auth.TvSetupViewModel(get()) }
viewModel { org.siloserver.silo.tv.ui.screens.auth.TvSignupViewModel(get()) }
viewModel { TvLoginViewModel(get(), get(), get()) }
viewModel { TvProfileSelectionViewModel(get()) }
viewModel { TvProfileSelectionViewModel(profileRepository = get(), authRepository = get()) }
viewModel { org.siloserver.silo.tv.ui.screens.profiles.TvCreateProfileViewModel(get()) }
viewModel { params ->
org.siloserver.silo.tv.ui.screens.profiles.TvEditProfileViewModel(
Expand Down
Loading