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
1 change: 1 addition & 0 deletions app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export default {
},
plugins: [
"expo-router",
"expo-secure-store",
[
"expo-splash-screen",
{
Expand Down
30 changes: 19 additions & 11 deletions app/(tabs)/settings/servers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ export default function ServersScreen() {
};

const handleEdit = (server: SavedServer) => {
setEditingServer({ ...server });
// Password is stored securely; don't prefill it into UI.
setEditingServer({ ...server, password: "" });
setDialogMode("edit");
setDialogVisible(true);
};
Expand Down Expand Up @@ -94,12 +95,16 @@ export default function ServersScreen() {
const handleSave = async () => {
if (!editingServer) return;

if (
!editingServer.apiUrl ||
!editingServer.username ||
!editingServer.password
) {
Alert.alert("Error", "Server URL, username, and password are required");
if (!editingServer.apiUrl || !editingServer.username) {
Alert.alert("Error", "Server URL and username are required");
return;
}

const needsPassword =
dialogMode === "add" ||
(!editingServer.hasPassword && !editingServer.password);
if (needsPassword && !editingServer.password) {
Alert.alert("Error", "Password is required");
return;
}

Expand All @@ -110,7 +115,7 @@ export default function ServersScreen() {
const success = await login(
editingServer.apiUrl,
editingServer.username,
editingServer.password,
editingServer.password || "",
true, // save to server list
);
if (success) {
Expand All @@ -124,12 +129,15 @@ export default function ServersScreen() {
}
} else if (editingServer.id) {
// Editing existing server
await updateServer(editingServer.id, {
const updates: Partial<SavedServerInput> = {
nickname: editingServer.nickname,
apiUrl: editingServer.apiUrl,
username: editingServer.username,
password: editingServer.password,
});
...(editingServer.password
? { password: editingServer.password }
: {}),
};
await updateServer(editingServer.id, updates);
setDialogVisible(false);
setEditingServer(null);
}
Expand Down
38 changes: 11 additions & 27 deletions context/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { SavedServer, SavedServerInput } from "@/types/server";
import {
clearStoredCredentials,
getStoredCredentials,
storeCredentials,
} from "@/utils/authStorage";
import { base64Encode } from "@/utils/base64";
import {
deleteServer as deleteServerFromStorage,
Expand All @@ -14,7 +19,6 @@ import {
clearWidgetCredentials,
saveCredentialsToWidget,
} from "@/widgets/storage";
import AsyncStorage from "@react-native-async-storage/async-storage";
import React, {
createContext,
ReactNode,
Expand Down Expand Up @@ -84,12 +88,6 @@ interface AuthContextType extends AuthState {

const AuthContext = createContext<AuthContextType | undefined>(undefined);

const STORAGE_KEYS = {
API_URL: "mova_api_url",
USERNAME: "mova_username",
PASSWORD: "mova_password",
};

export function AuthProvider({ children }: { children: ReactNode }) {
const { triggerRefresh } = useMutation();
const [state, setState] = useState<AuthState>({
Expand Down Expand Up @@ -135,11 +133,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return;
}

const [apiUrl, username, password] = await Promise.all([
AsyncStorage.getItem(STORAGE_KEYS.API_URL),
AsyncStorage.getItem(STORAGE_KEYS.USERNAME),
AsyncStorage.getItem(STORAGE_KEYS.PASSWORD),
]);
const { apiUrl, username, password } = await getStoredCredentials();

if (apiUrl && username && password) {
setState({
Expand Down Expand Up @@ -174,11 +168,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
});

if (response.ok) {
await Promise.all([
AsyncStorage.setItem(STORAGE_KEYS.API_URL, normalizedUrl),
AsyncStorage.setItem(STORAGE_KEYS.USERNAME, username),
AsyncStorage.setItem(STORAGE_KEYS.PASSWORD, password),
]);
await storeCredentials({ apiUrl: normalizedUrl, username, password });

// Also save to SharedPreferences for widget access
await saveCredentialsToWidget(normalizedUrl, username, password);
Expand All @@ -200,11 +190,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setActiveServerIdState(newServer.id);
await refreshSavedServers();
} else {
// Update password if changed
if (existing.password !== password) {
await updateServerInStorage(existing.id, { password });
await refreshSavedServers();
}
// Always keep the stored password up to date (secure store).
await updateServerInStorage(existing.id, { password });
await refreshSavedServers();
await setActiveServerId(existing.id);
setActiveServerIdState(existing.id);
}
Expand All @@ -229,11 +217,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}

async function logout() {
await Promise.all([
AsyncStorage.removeItem(STORAGE_KEYS.API_URL),
AsyncStorage.removeItem(STORAGE_KEYS.USERNAME),
AsyncStorage.removeItem(STORAGE_KEYS.PASSWORD),
]);
await clearStoredCredentials();

// Also clear widget credentials
await clearWidgetCredentials();
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"expo-linking": "~8.0.11",
"expo-notifications": "~0.32.16",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-system-ui": "~6.0.9",
Expand Down
20 changes: 2 additions & 18 deletions services/backgroundSync.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { getStoredCredentials } from "@/utils/authStorage";
import { AppState, Platform } from "react-native";

const BACKGROUND_SYNC_TASK = "MOVA_BACKGROUND_SYNC";
Expand All @@ -9,23 +9,7 @@ const isHeadlessContext = () => {
return AppState.currentState === null || AppState.currentState === undefined;
};

// Storage keys for credentials (must match AuthContext STORAGE_KEYS)
const API_URL_KEY = "mova_api_url";
const USERNAME_KEY = "mova_username";
const PASSWORD_KEY = "mova_password";

async function getStoredCredentials(): Promise<{
apiUrl: string | null;
username: string | null;
password: string | null;
}> {
const [apiUrl, username, password] = await Promise.all([
AsyncStorage.getItem(API_URL_KEY),
AsyncStorage.getItem(USERNAME_KEY),
AsyncStorage.getItem(PASSWORD_KEY),
]);
return { apiUrl, username, password };
}
// Credentials are stored via utils/authStorage (secure store + legacy migration).

// Lazy-load expo modules to avoid issues in headless widget context
let BackgroundFetch: typeof import("expo-background-fetch") | null = null;
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/AuthContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe("AuthContext multi-server", () => {
id: "1",
apiUrl: "https://test.com",
username: "user",
password: "pass",
hasPassword: true,
},
]);

Expand Down
46 changes: 43 additions & 3 deletions tests/unit/serverStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ jest.mock("@react-native-async-storage/async-storage", () => ({
removeItem: jest.fn(),
}));

jest.mock("../../utils/secretStore", () => ({
getSecret: jest.fn(),
setSecret: jest.fn(),
deleteSecret: jest.fn(),
}));

describe("serverStorage", () => {
beforeEach(() => {
jest.clearAllMocks();
Expand All @@ -38,20 +44,34 @@ describe("serverStorage", () => {
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(
JSON.stringify(mockServers),
);
const { getSecret, setSecret } = jest.requireMock(
"../../utils/secretStore",
);
(getSecret as jest.Mock).mockResolvedValue("pass1");
const servers = await getSavedServers();
expect(servers).toEqual(mockServers);
expect(setSecret).toHaveBeenCalled();
expect(servers).toEqual([
{
id: "1",
apiUrl: "https://server1.com",
username: "user1",
hasPassword: true,
},
]);
});
});

describe("saveServer", () => {
it("should add new server with generated id", async () => {
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);
const { setSecret } = jest.requireMock("../../utils/secretStore");
const server = await saveServer({
apiUrl: "https://new.com",
username: "user",
password: "pass",
});
expect(server.id).toBeDefined();
expect(setSecret).toHaveBeenCalled();
expect(AsyncStorage.setItem).toHaveBeenCalled();
});
});
Expand All @@ -69,10 +89,19 @@ describe("serverStorage", () => {
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(
JSON.stringify(mockServers),
);
const { getSecret } = jest.requireMock("../../utils/secretStore");
(getSecret as jest.Mock).mockResolvedValue("pass1");
await updateServer("1", { nickname: "My Server" });
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
"mova_saved_servers",
JSON.stringify([{ ...mockServers[0], nickname: "My Server" }]),
JSON.stringify([
{
id: "1",
apiUrl: "https://server1.com",
username: "user1",
nickname: "My Server",
},
]),
);
});
});
Expand All @@ -96,10 +125,21 @@ describe("serverStorage", () => {
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(
JSON.stringify(mockServers),
);
const { getSecret, deleteSecret } = jest.requireMock(
"../../utils/secretStore",
);
(getSecret as jest.Mock).mockResolvedValue("pass");
await deleteServer("1");
expect(deleteSecret).toHaveBeenCalled();
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
"mova_saved_servers",
JSON.stringify([mockServers[1]]),
JSON.stringify([
{
id: "2",
apiUrl: "https://server2.com",
username: "user2",
},
]),
);
});
});
Expand Down
8 changes: 6 additions & 2 deletions types/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ export interface SavedServer {
nickname?: string;
apiUrl: string;
username: string;
password: string;
defaultCaptureTemplate?: string;
hasPassword?: boolean;
}

export interface SavedServerWithPassword extends SavedServer {
password: string;
}

export type SavedServerInput = Omit<SavedServer, "id">;
export type SavedServerInput = Omit<SavedServerWithPassword, "id">;
77 changes: 77 additions & 0 deletions utils/authStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { deleteSecret, getSecret, setSecret } from "@/utils/secretStore";
import AsyncStorage from "@react-native-async-storage/async-storage";

export const AUTH_STORAGE_KEYS = {
API_URL: "mova_api_url",
USERNAME: "mova_username",
PASSWORD: "mova_password",
} as const;

export async function getStoredCredentials(): Promise<{
apiUrl: string | null;
username: string | null;
password: string | null;
}> {
// Prefer secure store, but migrate from AsyncStorage if needed.
const [apiUrl, username, password] = await Promise.all([
getSecret(AUTH_STORAGE_KEYS.API_URL),
getSecret(AUTH_STORAGE_KEYS.USERNAME),
getSecret(AUTH_STORAGE_KEYS.PASSWORD),
]);

if (apiUrl && username && password) {
return { apiUrl, username, password };
}

const [legacyApiUrl, legacyUsername, legacyPassword] = await Promise.all([
AsyncStorage.getItem(AUTH_STORAGE_KEYS.API_URL),
AsyncStorage.getItem(AUTH_STORAGE_KEYS.USERNAME),
AsyncStorage.getItem(AUTH_STORAGE_KEYS.PASSWORD),
]);

if (legacyApiUrl && legacyUsername && legacyPassword) {
await Promise.all([
setSecret(AUTH_STORAGE_KEYS.API_URL, legacyApiUrl),
setSecret(AUTH_STORAGE_KEYS.USERNAME, legacyUsername),
setSecret(AUTH_STORAGE_KEYS.PASSWORD, legacyPassword),
AsyncStorage.removeItem(AUTH_STORAGE_KEYS.API_URL),
AsyncStorage.removeItem(AUTH_STORAGE_KEYS.USERNAME),
AsyncStorage.removeItem(AUTH_STORAGE_KEYS.PASSWORD),
]);
return {
apiUrl: legacyApiUrl,
username: legacyUsername,
password: legacyPassword,
};
}

return { apiUrl: null, username: null, password: null };
}

export async function storeCredentials(input: {
apiUrl: string;
username: string;
password: string;
}) {
await Promise.all([
setSecret(AUTH_STORAGE_KEYS.API_URL, input.apiUrl),
setSecret(AUTH_STORAGE_KEYS.USERNAME, input.username),
setSecret(AUTH_STORAGE_KEYS.PASSWORD, input.password),
// Best-effort cleanup of legacy keys.
AsyncStorage.removeItem(AUTH_STORAGE_KEYS.API_URL),
AsyncStorage.removeItem(AUTH_STORAGE_KEYS.USERNAME),
AsyncStorage.removeItem(AUTH_STORAGE_KEYS.PASSWORD),
]);
}

export async function clearStoredCredentials() {
await Promise.all([
deleteSecret(AUTH_STORAGE_KEYS.API_URL),
deleteSecret(AUTH_STORAGE_KEYS.USERNAME),
deleteSecret(AUTH_STORAGE_KEYS.PASSWORD),
// Best-effort cleanup of legacy keys.
AsyncStorage.removeItem(AUTH_STORAGE_KEYS.API_URL),
AsyncStorage.removeItem(AUTH_STORAGE_KEYS.USERNAME),
AsyncStorage.removeItem(AUTH_STORAGE_KEYS.PASSWORD),
]);
}
Loading
Loading