Merge pull request #1549 from aliasvault/1548-add-support-to-block-specific-unsupported-client-versions-in-server-api

Add support to block specific unsupported client versions in server API
This commit is contained in:
Leendert de Borst
2026-01-31 16:18:42 +00:00
committed by GitHub
13 changed files with 303 additions and 43 deletions
@@ -109,6 +109,12 @@ export const useVaultSync = (): { syncVault: (options?: VaultSyncOptions) => Pro
const statusError = webApi.validateStatusResponse(statusResponse);
if (statusError) {
// Version compatibility errors require logout
if (statusError === 'clientVersionNotSupported' || statusError === 'serverVersionNotSupported') {
await app.logout(t('common.errors.' + statusError));
return false;
}
// Other errors just show the error
onError?.(t('common.errors.' + statusError));
return false;
}
@@ -26,6 +26,9 @@ import type { MobileLoginResult } from '@/utils/types/messaging/MobileLoginResul
import { storage } from '#imports';
/** Track if username prefill has been attempted (only do it once on mount) */
let usernamePrefillAttempted = false;
/**
* Login page
*/
@@ -168,6 +171,9 @@ const Login: React.FC = () => {
return;
}
// Reset prefill flag so next logout will prefill again
usernamePrefillAttempted = false;
// Navigate to reinitialize page which will take care of the proper redirect.
navigate('/reinitialize', { replace: true });
@@ -188,10 +194,16 @@ const Login: React.FC = () => {
}
setClientUrl(clientUrl);
// Check for saved username (from forced logout) and prefill
const savedUsername = await storage.getItem('local:username') as string | null;
if (savedUsername) {
setCredentials(prev => ({ ...prev, username: savedUsername }));
/*
* Check for saved username (from forced logout) and prefill once on mount
* If user clears it, don't repopulate
*/
if (!usernamePrefillAttempted) {
usernamePrefillAttempted = true;
const savedUsername = await storage.getItem('local:username') as string | null;
if (savedUsername) {
setCredentials(prev => ({ ...prev, username: savedUsername }));
}
}
setIsInitialLoading(false);
@@ -623,6 +623,8 @@ class VaultSync(
is VaultSyncError.SessionExpired,
is VaultSyncError.AuthenticationFailed,
is VaultSyncError.PasswordChanged,
is VaultSyncError.ClientVersionNotSupported,
is VaultSyncError.ServerVersionNotSupported,
-> {
VaultSyncResult(
success = false,
+28 -19
View File
@@ -4,7 +4,7 @@ import { MaterialIcons } from '@expo/vector-icons';
import { useFocusEffect } from '@react-navigation/native';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { StyleSheet, View, Text, SafeAreaView, TextInput, ActivityIndicator, Animated, ScrollView, KeyboardAvoidingView, Platform, Dimensions } from 'react-native';
import { useApiUrl } from '@/utils/ApiUrlUtility';
@@ -41,6 +41,9 @@ export default function LoginScreen() : React.ReactNode {
const [fadeAnim] = useState(new Animated.Value(0));
const { loadApiUrl, getDisplayUrl } = useApiUrl();
// Track if username prefill has been attempted (only do it once on mount)
const usernamePrefillAttemptedRef = useRef(false);
useEffect(() => {
Animated.timing(fadeAnim, {
toValue: 1,
@@ -52,19 +55,18 @@ export default function LoginScreen() : React.ReactNode {
/**
* Check for saved username (from forced logout) and prefill the username field.
* This enables users to easily re-login after a forced logout.
* Only prefill if the username field is empty (user hasn't started typing).
* Only prefill once on mount - if user clears it, don't repopulate.
*/
const loadSavedUsername = async () : Promise<void> => {
if (usernamePrefillAttemptedRef.current) {
return;
}
usernamePrefillAttemptedRef.current = true;
try {
const savedUsername = await NativeVaultManager.getUsername();
if (savedUsername) {
setCredentials(prev => {
// Only prefill if username is empty - don't overwrite user input
if (prev.username === '') {
return { ...prev, username: savedUsername };
}
return prev;
});
setCredentials(prev => ({ ...prev, username: savedUsername }));
}
} catch {
// Ignore errors - username prefill is optional
@@ -222,14 +224,17 @@ export default function LoginScreen() : React.ReactNode {
}
}
let checkSuccess = true;
let upgradeRequired = false;
/*
* Sync vault from server (downloads, stores, and validates compatibility)
* This will handle the forced logout recovery check in case our local vault is dirty
* or is ahead of server in case of RPO event.
*
* Critical errors (auth, version) are handled internally via app.logout(message)
* which shows a native alert. We check the return value to know if sync succeeded.
*/
await syncVault({
const syncSuccess = await syncVault({
/**
* Update login status during sync.
*/
@@ -237,21 +242,18 @@ export default function LoginScreen() : React.ReactNode {
setLoginStatus(status);
},
/**
* Handle the status update.
* Handle non-critical errors (shown via custom dialog).
*/
onError: (message) => {
checkSuccess = false;
// Show modal with error message
// Show modal with error message for non-critical errors
showAlert(t('common.error'), message);
// Error will trigger logout through the sync process
setIsLoading(false);
},
/**
* On upgrade required.
*/
onUpgradeRequired: async () : Promise<void> => {
checkSuccess = false;
upgradeRequired = true;
// Still login to ensure the user is logged in.
await authContext.login();
@@ -262,8 +264,12 @@ export default function LoginScreen() : React.ReactNode {
},
});
if (!checkSuccess) {
// If the syncvault checks have failed, we can't continue with the login process.
if (!syncSuccess || upgradeRequired) {
/*
* Sync failed or upgrade required - don't continue with login
* Critical errors already showed alert via app.logout()
*/
setIsLoading(false);
return;
}
@@ -275,6 +281,9 @@ export default function LoginScreen() : React.ReactNode {
await authContext.login();
// Reset prefill flag so next logout will prefill again
usernamePrefillAttemptedRef.current = false;
authContext.setOfflineMode(false);
setTwoFactorRequired(false);
setTwoFactorCode('');
+5 -6
View File
@@ -4,10 +4,11 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import { NavigationContainerRef, ParamListBase } from '@react-navigation/native';
import * as LocalAuthentication from 'expo-local-authentication';
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
import { Alert, Platform } from 'react-native';
import { Platform } from 'react-native';
import EncryptionUtility from '@/utils/EncryptionUtility';
import { useDb } from '@/context/DbContext';
import { dialogEventEmitter } from '@/events/DialogEventEmitter';
import NativeVaultManager from '@/specs/NativeVaultManager';
import i18n from '@/i18n';
@@ -221,11 +222,9 @@ export const AuthProvider: React.FC<{
await AsyncStorage.multiRemove(['accessToken', 'refreshToken', 'authMethods']);
if (errorMessage) {
Alert.alert(
i18n.t('common.error'),
errorMessage,
[{ text: i18n.t('common.ok'), style: 'default' }]
);
// Use event emitter to show dialog via DialogContext
// This allows Android to use the custom styled dialog
dialogEventEmitter.emitAlert(i18n.t('common.error'), errorMessage);
}
setIsLoggedIn(false);
+53 -4
View File
@@ -1,7 +1,8 @@
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
import React, { createContext, useContext, useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { Alert, Platform } from 'react-native';
import { ConfirmDialog, type IConfirmDialogButton } from '@/components/common/ConfirmDialog';
import { dialogEventEmitter } from '@/events/DialogEventEmitter';
import i18n from '@/i18n';
interface DialogConfig {
@@ -82,10 +83,52 @@ interface DialogProviderProps {
export function DialogProvider({ children }: DialogProviderProps): React.ReactNode {
const [dialogConfig, setDialogConfig] = useState<DialogConfig | null>(null);
// Use a ref to store pending dialog config that survives re-renders/navigation
const pendingDialogRef = useRef<DialogConfig | null>(null);
// Check for pending dialogs on every render
useEffect(() => {
if (pendingDialogRef.current && !dialogConfig) {
setDialogConfig(pendingDialogRef.current);
pendingDialogRef.current = null;
}
});
// Subscribe to dialog events from outside React (e.g., AuthContext logout)
useEffect(() => {
const unsubscribe = dialogEventEmitter.subscribe((title, message) => {
// On iOS, use native Alert
if (Platform.OS === 'ios') {
Alert.alert(title, message, [{ text: 'OK', style: 'default' }]);
return;
}
// On Android, use custom dialog with ref persistence
const config: DialogConfig = {
title,
message,
buttons: [{
text: 'OK',
style: 'default',
onPress: (): void => {
pendingDialogRef.current = null;
setDialogConfig(null);
},
}],
};
pendingDialogRef.current = config;
setDialogConfig(config);
});
return unsubscribe;
}, []);
/**
* Hide the dialog.
*/
const hideDialog = useCallback((): void => {
pendingDialogRef.current = null;
setDialogConfig(null);
}, []);
@@ -93,7 +136,7 @@ export function DialogProvider({ children }: DialogProviderProps): React.ReactNo
* Show a simple alert with an OK button.
*/
const showAlert = useCallback((title: string, message: string, onOk?: () => void): void => {
// On iOS, use native Alert for simple alerts too
// On iOS, use native Alert
if (Platform.OS === 'ios') {
Alert.alert(title, message, [{
text: i18n.t('common.ok'),
@@ -103,7 +146,8 @@ export function DialogProvider({ children }: DialogProviderProps): React.ReactNo
return;
}
setDialogConfig({
// On Android, use custom dialog with ref persistence
const config: DialogConfig = {
title,
message,
buttons: [{
@@ -111,10 +155,15 @@ export function DialogProvider({ children }: DialogProviderProps): React.ReactNo
style: 'default',
onPress: (): void => {
onOk?.();
pendingDialogRef.current = null;
setDialogConfig(null);
},
}],
});
};
// Store in ref so it persists through navigation/re-renders
pendingDialogRef.current = config;
setDialogConfig(config);
}, []);
/**
@@ -0,0 +1,30 @@
/**
* Simple event emitter for showing dialogs from outside React components.
* This allows AuthContext to trigger dialogs in DialogContext without direct coupling.
*/
type AlertListener = (title: string, message: string) => void;
class DialogEventEmitter {
private listeners: AlertListener[] = [];
/**
* Subscribe to alert events.
* @returns Unsubscribe function
*/
subscribe(listener: AlertListener): () => void {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
/**
* Emit an alert event to all listeners.
*/
emitAlert(title: string, message: string): void {
this.listeners.forEach(listener => listener(title, message));
}
}
export const dialogEventEmitter = new DialogEventEmitter();
+13 -4
View File
@@ -104,6 +104,7 @@ export const useVaultSync = (): {
if (result.wasOffline) {
await dbContext.setIsOffline(true);
console.log('[useVaultSync] Set offline mode');
onOffline?.();
// Return true to continue with local vault
return true;
@@ -155,7 +156,8 @@ export const useVaultSync = (): {
console.warn('Vault sync: Failed to register credential identities:', error);
}
return hasNewVault;
// Return true for successful sync (regardless of whether vault changed)
return true;
} catch (err) {
if (err instanceof VaultVersionIncompatibleError) {
await app.logout(t(err.message));
@@ -224,6 +226,10 @@ function getVaultSyncErrorCodeFromString(error: string): VaultSyncErrorCode | nu
/**
* Handle sync errors by mapping error codes to appropriate actions.
*
* For critical errors requiring logout (auth, version), we ALWAYS use app.logout(message)
* which shows a native Alert.alert that persists through navigation on both platforms.
* The onError callback is only used for non-critical errors that don't require logout.
*/
async function handleSyncError(
err: unknown,
@@ -235,23 +241,26 @@ async function handleSyncError(
onOffline?: () => void
): Promise<boolean> {
switch (errorCode) {
// Authentication errors - logout with message (shows native alert)
case VaultSyncErrorCode.SESSION_EXPIRED:
case VaultSyncErrorCode.AUTHENTICATION_FAILED:
await app.logout('Your session has expired. Please login again.');
await app.logout(t('auth.errors.sessionExpired'));
return false;
case VaultSyncErrorCode.PASSWORD_CHANGED:
await app.logout(t('vault.errors.passwordChanged'));
return false;
// Version compatibility errors - logout with message (shows native alert)
case VaultSyncErrorCode.CLIENT_VERSION_NOT_SUPPORTED:
onError?.(t('vault.errors.versionNotSupported'));
await app.logout(t('vault.errors.versionNotSupported'));
return false;
case VaultSyncErrorCode.SERVER_VERSION_NOT_SUPPORTED:
await app.logout(t('vault.errors.serverVersionNotSupported'));
return false;
// Network errors - set offline mode, don't logout
case VaultSyncErrorCode.SERVER_UNAVAILABLE:
case VaultSyncErrorCode.NETWORK_ERROR:
case VaultSyncErrorCode.TIMEOUT:
@@ -260,8 +269,8 @@ async function handleSyncError(
// Return true to continue with local vault
return true;
// Unknown errors - use onError callback if provided
default:
// Unknown error
const errorMessage = err instanceof Error ? err.message : t('common.errors.unknownError');
onError?.(errorMessage);
return false;
@@ -513,7 +513,8 @@ extension VaultStore {
wasOffline: true,
error: error.message
)
case .sessionExpired, .authenticationFailed, .passwordChanged:
case .sessionExpired, .authenticationFailed, .passwordChanged,
.clientVersionNotSupported, .serverVersionNotSupported:
return VaultSyncResult(
success: false,
action: .error,
@@ -110,10 +110,11 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
if (AppInfo.MinimumClientVersions.TryGetValue(platform, out var minimumVersion))
{
if (VersionHelper.IsVersionEqualOrNewer(clientVersion, minimumVersion))
{
clientSupported = true;
}
// Check if version meets minimum requirement AND is not in blocked list
var meetsMinimum = VersionHelper.IsVersionEqualOrNewer(clientVersion, minimumVersion);
var isBlocked = VersionHelper.IsVersionBlocked(platform, clientVersion, AppInfo.UnsupportedClientVersions);
clientSupported = meetsMinimum && !isBlocked;
}
else
{
@@ -59,4 +59,36 @@ public static class VersionHelper
// Compare the versions
return v1 >= v2;
}
/// <summary>
/// Checks if a version is blocked for a specific platform.
/// Checks both platform-specific blocks and global blocks (using "*" key).
/// </summary>
/// <param name="platform">The platform to check (e.g., "chrome", "ios").</param>
/// <param name="version">The version to check.</param>
/// <param name="blockedVersions">Dictionary of platform to blocked versions. Use "*" for global blocks.</param>
/// <returns>True if the version is blocked for this platform, false otherwise.</returns>
public static bool IsVersionBlocked(string platform, string version, IReadOnlyDictionary<string, HashSet<string>> blockedVersions)
{
if (string.IsNullOrEmpty(version) || blockedVersions == null || blockedVersions.Count == 0)
{
return false;
}
// Check global blocks (applies to all platforms)
if (blockedVersions.TryGetValue("*", out var globalBlocked) && globalBlocked.Contains(version))
{
return true;
}
// Check platform-specific blocks
if (!string.IsNullOrEmpty(platform) &&
blockedVersions.TryGetValue(platform, out var platformBlocked) &&
platformBlocked.Contains(version))
{
return true;
}
return false;
}
}
@@ -42,7 +42,7 @@ public static class AppInfo
/// for all clients as we are using a monorepo to build all clients from the same source code. But it's
/// possible to override the minimum client version for a specific client if needed.
/// </summary>
public const string MinimumClientVersion = "0.26.1";
public const string MinimumClientVersion = "0.12.0";
/// <summary>
/// Gets a dictionary of minimum supported client versions that the WebApi supports.
@@ -68,6 +68,24 @@ public static class AppInfo
{ "android", MinimumClientVersion },
}.AsReadOnly();
/// <summary>
/// Gets a dictionary of specific client versions that are explicitly unsupported (blocked) per platform.
/// This is useful for blocking specific versions with (critical) bugs while still allowing
/// older versions that predate the bug to continue working.
/// For example: if 0.26.0 has a critical bug fixed in 0.26.1, we can block only 0.26.0
/// without affecting users on 0.25.x who may still have compatible vaults.
/// Use "*" as the platform key to block a version across all platforms.
/// </summary>
public static IReadOnlyDictionary<string, HashSet<string>> UnsupportedClientVersions { get; } = new Dictionary<string, HashSet<string>>
{
// Block version across all platforms: "*" applies to all clients.
{ "*", ["0.26.0"] }, // Version with vault migration bug, fixed in 0.26.1
// Platform-specific blocks (examples):
// { "chrome", ["0.25.0"] },
// { "ios", ["0.24.0", "0.24.1"] },
};
/// <summary>
/// Gets the build number, typically used in CI/CD pipelines.
/// Can be overridden at build time.
@@ -68,4 +68,96 @@ public class VersionTests
var version2 = "1.1.0";
Assert.That(VersionHelper.IsVersionEqualOrNewer(version1, version2), Is.True);
}
/// <summary>
/// Test that a version in the global blocked list is correctly identified as blocked.
/// </summary>
[Test]
public void VersionBlockedReturnsTrueForGloballyBlockedVersion()
{
var blockedVersions = new Dictionary<string, HashSet<string>>
{
{ "*", ["0.26.0", "0.27.0"] },
};
Assert.That(VersionHelper.IsVersionBlocked("chrome", "0.26.0", blockedVersions), Is.True);
Assert.That(VersionHelper.IsVersionBlocked("ios", "0.27.0", blockedVersions), Is.True);
Assert.That(VersionHelper.IsVersionBlocked("android", "0.26.0", blockedVersions), Is.True);
}
/// <summary>
/// Test that a version in the platform-specific blocked list is correctly identified as blocked.
/// </summary>
[Test]
public void VersionBlockedReturnsTrueForPlatformSpecificBlockedVersion()
{
var blockedVersions = new Dictionary<string, HashSet<string>>
{
{ "chrome", ["0.25.0"] },
{ "ios", ["0.24.0"] },
};
// Platform-specific blocks should work
Assert.That(VersionHelper.IsVersionBlocked("chrome", "0.25.0", blockedVersions), Is.True);
Assert.That(VersionHelper.IsVersionBlocked("ios", "0.24.0", blockedVersions), Is.True);
// Other platforms should not be blocked
Assert.That(VersionHelper.IsVersionBlocked("firefox", "0.25.0", blockedVersions), Is.False);
Assert.That(VersionHelper.IsVersionBlocked("android", "0.24.0", blockedVersions), Is.False);
}
/// <summary>
/// Test that global and platform-specific blocks work together.
/// </summary>
[Test]
public void VersionBlockedCombinesGlobalAndPlatformSpecific()
{
var blockedVersions = new Dictionary<string, HashSet<string>>
{
{ "*", ["0.26.0"] },
{ "chrome", ["0.25.0"] },
};
// Global block applies to all platforms for exact version
Assert.That(VersionHelper.IsVersionBlocked("chrome", "0.26.0", blockedVersions), Is.True);
Assert.That(VersionHelper.IsVersionBlocked("ios", "0.26.0", blockedVersions), Is.True);
Assert.That(VersionHelper.IsVersionBlocked("ios", "0.26.1", blockedVersions), Is.False);
// Platform-specific block only applies to that platform
Assert.That(VersionHelper.IsVersionBlocked("chrome", "0.25.0", blockedVersions), Is.True);
Assert.That(VersionHelper.IsVersionBlocked("firefox", "0.25.0", blockedVersions), Is.False);
}
/// <summary>
/// Test that a version not in the blocked list is correctly identified as not blocked.
/// </summary>
[Test]
public void VersionBlockedReturnsFalseForNonBlockedVersion()
{
var blockedVersions = new Dictionary<string, HashSet<string>>
{
{ "*", ["0.26.0"] },
};
Assert.That(VersionHelper.IsVersionBlocked("chrome", "0.25.3", blockedVersions), Is.False);
Assert.That(VersionHelper.IsVersionBlocked("chrome", "0.26.1", blockedVersions), Is.False);
Assert.That(VersionHelper.IsVersionBlocked("chrome", "0.27.0", blockedVersions), Is.False);
}
/// <summary>
/// Test that empty or null inputs are handled correctly.
/// </summary>
[Test]
public void VersionBlockedHandlesEmptyInputs()
{
var blockedVersions = new Dictionary<string, HashSet<string>>
{
{ "*", ["0.26.0"] },
};
var emptyBlockedVersions = new Dictionary<string, HashSet<string>>();
Assert.That(VersionHelper.IsVersionBlocked("chrome", string.Empty, blockedVersions), Is.False);
Assert.That(VersionHelper.IsVersionBlocked("chrome", "0.26.0", emptyBlockedVersions), Is.False);
}
}