diff --git a/src/main/kotlin/org/briarproject/briar/desktop/login/LoginScreen.kt b/src/main/kotlin/org/briarproject/briar/desktop/login/LoginScreen.kt
index c3f072c1fdcef4968e7b86108a9cfd77136635b4..3beb11fbd0af019842b8f2c0c14aaf7f9984087e 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/login/LoginScreen.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/login/LoginScreen.kt
@@ -34,6 +34,8 @@ 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 org.briarproject.briar.desktop.login.LoginViewModel.LoginState.SIGNED_OUT
+import org.briarproject.briar.desktop.ui.Loader
 import org.briarproject.briar.desktop.utils.InternationalizationUtils.i18n
 import org.briarproject.briar.desktop.viewmodel.viewModel
 
@@ -48,6 +50,12 @@ fun LoginScreen(
         viewModel.signIn(onSignedIn)
     }
 
+    if (viewModel.state.value != SIGNED_OUT) {
+        // todo: handle states individually
+        Loader()
+        return
+    }
+
     val initialFocusRequester = remember { FocusRequester() }
     Surface {
         Column(
diff --git a/src/main/kotlin/org/briarproject/briar/desktop/login/LoginViewModel.kt b/src/main/kotlin/org/briarproject/briar/desktop/login/LoginViewModel.kt
index 85d939c109aba3be7e47078f0e626dbb3ea56605..39fbe88b64c83223c6181c6a7a74f62bbd8cde5d 100644
--- a/src/main/kotlin/org/briarproject/briar/desktop/login/LoginViewModel.kt
+++ b/src/main/kotlin/org/briarproject/briar/desktop/login/LoginViewModel.kt
@@ -4,35 +4,94 @@ import androidx.compose.runtime.State
 import androidx.compose.runtime.mutableStateOf
 import org.briarproject.bramble.api.account.AccountManager
 import org.briarproject.bramble.api.crypto.DecryptionException
+import org.briarproject.bramble.api.db.TransactionManager
+import org.briarproject.bramble.api.event.Event
+import org.briarproject.bramble.api.event.EventBus
+import org.briarproject.bramble.api.lifecycle.IoExecutor
 import org.briarproject.bramble.api.lifecycle.LifecycleManager
-import org.briarproject.briar.desktop.viewmodel.ViewModel
+import org.briarproject.bramble.api.lifecycle.LifecycleManager.LifecycleState
+import org.briarproject.bramble.api.lifecycle.event.LifecycleEvent
+import org.briarproject.briar.desktop.login.LoginViewModel.LoginState.COMPACTING
+import org.briarproject.briar.desktop.login.LoginViewModel.LoginState.MIGRATING
+import org.briarproject.briar.desktop.login.LoginViewModel.LoginState.SIGNED_OUT
+import org.briarproject.briar.desktop.login.LoginViewModel.LoginState.SIGNING_IN
+import org.briarproject.briar.desktop.login.LoginViewModel.LoginState.STARTED
+import org.briarproject.briar.desktop.login.LoginViewModel.LoginState.STARTING
+import org.briarproject.briar.desktop.viewmodel.BriarExecutors
+import org.briarproject.briar.desktop.viewmodel.EventListenerDbViewModel
+import org.briarproject.briar.desktop.viewmodel.UiExecutor
 import javax.inject.Inject
 
 class LoginViewModel
 @Inject
 constructor(
     private val accountManager: AccountManager,
+    private val briarExecutors: BriarExecutors,
     private val lifecycleManager: LifecycleManager,
-) : ViewModel {
+    private val eventBus: EventBus,
+    db: TransactionManager,
+) : EventListenerDbViewModel(briarExecutors, lifecycleManager, db, eventBus) {
 
+    enum class LoginState {
+        SIGNED_OUT, SIGNING_IN, SIGNED_IN, STARTING, MIGRATING, COMPACTING, STARTED
+    }
+
+    private val _state = mutableStateOf(SIGNED_OUT)
     private val _password = mutableStateOf("")
 
+    val state: State<LoginState> = _state
     val password: State<String> = _password
 
+    override fun onInit() {
+        super.onInit()
+        updateState(lifecycleManager.lifecycleState)
+    }
+
+    override fun eventOccurred(e: Event?) {
+        if (e is LifecycleEvent) {
+            updateState(e.lifecycleState)
+        }
+    }
+
+    @UiExecutor
+    private fun updateState(s: LifecycleState) {
+        _state.value =
+            if (accountManager.hasDatabaseKey()) {
+                when {
+                    s.isAfter(LifecycleState.STARTING_SERVICES) -> STARTED
+                    s == LifecycleState.MIGRATING_DATABASE -> MIGRATING
+                    s == LifecycleState.COMPACTING_DATABASE -> COMPACTING
+                    else -> STARTING
+                }
+            } else {
+                SIGNED_OUT
+            }
+    }
+
     fun setPassword(password: String) {
         _password.value = password
     }
 
-    fun signIn(success: () -> Unit) {
-        try {
-            accountManager.signIn(password.value)
-            signedIn()
-            success()
-        } catch (e: DecryptionException) {
-            // failure, try again
+    @UiExecutor
+    fun signIn(@UiExecutor success: () -> Unit) {
+        _state.value = SIGNING_IN
+        briarExecutors.onIoThread {
+            try {
+                accountManager.signIn(password.value)
+                signedIn()
+
+                briarExecutors.onUiThread(success)
+            } catch (e: DecryptionException) {
+                // failure, try again
+                briarExecutors.onUiThread {
+                    _state.value = SIGNED_OUT
+                    _password.value = ""
+                }
+            }
         }
     }
 
+    @IoExecutor
     private fun signedIn() {
         val dbKey = accountManager.databaseKey ?: throw AssertionError()
         lifecycleManager.startServices(dbKey)