diff --git a/formulus/src/components/MenuDrawer.test.tsx b/formulus/src/components/MenuDrawer.test.tsx
index edfb5b5f6..04a21e8c3 100644
--- a/formulus/src/components/MenuDrawer.test.tsx
+++ b/formulus/src/components/MenuDrawer.test.tsx
@@ -106,7 +106,8 @@ test.each([
expect(name).toHaveProp('ellipsizeMode', 'tail');
expect(StyleSheet.flatten(name.props.style)).toMatchObject({
color,
- alignSelf: 'stretch',
+ flex: 1,
+ marginBottom: 0,
});
expect(screen.getByText('roles.readWrite')).toBeTruthy();
});
diff --git a/formulus/src/components/MenuDrawer.tsx b/formulus/src/components/MenuDrawer.tsx
index 0262a4089..1882fdda2 100644
--- a/formulus/src/components/MenuDrawer.tsx
+++ b/formulus/src/components/MenuDrawer.tsx
@@ -118,7 +118,7 @@ export const MenuDrawerSignedIn = ({ userInfo }: { userInfo: UserInfo }) => {
style={[styles.userAvatar, { backgroundColor: themeColors.primary }]}>
-
+
{
style={[
styles.roleBadge,
getRoleBadgeStyle(userInfo.role),
+ styles.signedInRoleBadge,
userInfo.role === 'admin' && {
backgroundColor: themeColors.primary as string,
},
@@ -470,8 +471,14 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'flex-end',
},
+ signedInUserInfo: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: odeSpacing.sm,
+ },
loggedInUserName: {
- alignSelf: 'stretch',
+ flex: 1,
+ marginBottom: 0,
},
userName: {
fontSize: odeTypography.body,
@@ -486,6 +493,7 @@ const styles = StyleSheet.create({
loginHint: {
fontSize: odeTypography.caption,
},
+ signedInRoleBadge: { alignSelf: 'center' },
roleBadge: {
alignSelf: 'flex-end',
paddingHorizontal: odeSpacing.xs,
diff --git a/formulus/src/locales/en.json b/formulus/src/locales/en.json
index bc2e56818..5bdb05be9 100644
--- a/formulus/src/locales/en.json
+++ b/formulus/src/locales/en.json
@@ -27,6 +27,7 @@
"profiles.deleteLabel": "Delete profile {{label}}",
"profiles.name": "Profile name",
"profiles.saveName": "Save name",
+ "profiles.saveChanges": "Save changes",
"profiles.lastProfileHint": "Keep at least one profile. The last profile cannot be deleted.",
"profiles.deleteTitle": "Delete profile?",
"profiles.deleteMessage": "Delete “{{label}}”? Unsynced data will be lost. Profile data is scheduled for deletion the next time you fully close and reopen Formulus. For more complete removal of local data, uninstall Formulus (this removes all profiles; OS backups and saved credentials may persist). Server data is not deleted. If this profile is active, another will be selected in the app.",
diff --git a/formulus/src/locales/fr.json b/formulus/src/locales/fr.json
index 45dd97283..918ade128 100644
--- a/formulus/src/locales/fr.json
+++ b/formulus/src/locales/fr.json
@@ -27,6 +27,7 @@
"profiles.deleteLabel": "Supprimer le profil {{label}}",
"profiles.name": "Nom du profil",
"profiles.saveName": "Enregistrer le nom",
+ "profiles.saveChanges": "Enregistrer les modifications",
"profiles.lastProfileHint": "Conservez au moins un profil. Le dernier profil ne peut pas être supprimé.",
"profiles.deleteTitle": "Supprimer le profil ?",
"profiles.deleteMessage": "Supprimer « {{label}} » ? Les données non synchronisées seront perdues. La suppression des données du profil est prévue après avoir complètement fermé puis rouvert Formulus. Pour un retrait plus complet des données locales, désinstallez Formulus (cela supprime tous les profils ; des sauvegardes ou identifiants du système peuvent subsister). Les données du serveur ne seront pas supprimées. Si ce profil est actif, un autre sera sélectionné dans l’application.",
diff --git a/formulus/src/locales/pt.json b/formulus/src/locales/pt.json
index 2c59c5b10..6bb1130fb 100644
--- a/formulus/src/locales/pt.json
+++ b/formulus/src/locales/pt.json
@@ -27,6 +27,7 @@
"profiles.deleteLabel": "Eliminar o perfil {{label}}",
"profiles.name": "Nome do perfil",
"profiles.saveName": "Guardar nome",
+ "profiles.saveChanges": "Guardar alterações",
"profiles.lastProfileHint": "Mantenha pelo menos um perfil. O último perfil não pode ser eliminado.",
"profiles.deleteTitle": "Eliminar perfil?",
"profiles.deleteMessage": "Eliminar «{{label}}»? Os dados não sincronizados serão perdidos. A eliminação dos dados do perfil está agendada para depois de fechar completamente e reabrir o Formulus. Para remover os dados locais de forma mais completa, desinstale o Formulus (isto remove todos os perfis; cópias de segurança ou credenciais do sistema podem persistir). Os dados do servidor não serão eliminados. Se este perfil estiver ativo, outro será selecionado na aplicação.",
diff --git a/formulus/src/screens/ProfileConnection.tsx b/formulus/src/screens/ProfileConnection.tsx
index 138c65333..6ab41d304 100644
--- a/formulus/src/screens/ProfileConnection.tsx
+++ b/formulus/src/screens/ProfileConnection.tsx
@@ -13,6 +13,10 @@ import { withAlpha } from '../theme/colors';
import { useConfirmModal } from '../contexts/ConfirmModalContext';
import type { MainTabParamList } from '../navigation/ProfileNavigationTypes';
import { profileRegistry } from '../profiles/ProfileRegistry';
+import {
+ resetProfileCredentials,
+ setCredentialsForProfile,
+} from '../profiles/ProfileKeychain';
import { getActiveProfile } from '../profiles/ProfileRuntime';
import {
login,
@@ -29,6 +33,7 @@ import {
} from '../services/QRSettingsService';
import {
getSettingsHydrationCredentialPair,
+ invalidateSettingsHydrationCache,
loadSettingsHydrationFromStorage,
} from '../services/SettingsHydrationCache';
import { ToastService } from '../services/ToastService';
@@ -50,6 +55,9 @@ type Props = {
}) => Promise;
};
onNewProfileFromQR?: (settings: SettingsUpdate) => void;
+ onCancelCreation?: () => void;
+ onDelete?: () => void;
+ canDelete?: boolean;
};
const ProfileConnection = ({
@@ -58,6 +66,9 @@ const ProfileConnection = ({
runAction,
creation,
onNewProfileFromQR,
+ onCancelCreation,
+ onDelete,
+ canDelete = false,
}: Props) => {
const { t } = useTranslation();
const { themeColors } = useAppTheme();
@@ -68,6 +79,7 @@ const ProfileConnection = ({
const initialServerUrl = creation?.initialSettings?.serverUrl;
const initialUsername = creation?.initialSettings?.username;
const initialPassword = creation?.initialSettings?.password;
+ const [name, setName] = useState(profile.label);
const [serverUrl, setServerUrl] = useState(
isCreating ? initialServerUrl || '' : profile.serverUrl || '',
);
@@ -97,6 +109,7 @@ const ProfileConnection = ({
};
}
setHydrating(true);
+ setName(profile.label);
setPassword('');
const active = getActiveProfile();
setServerUrl(active.serverUrl || '');
@@ -124,6 +137,7 @@ const ProfileConnection = ({
};
}, [
profile.id,
+ profile.label,
isCreating,
initialServerUrl,
initialUsername,
@@ -195,6 +209,41 @@ const ProfileConnection = ({
}, 'profiles.connectionFailed');
};
+ const handleSave = () => {
+ if (hydrating || !name.trim()) return;
+ void runAction(async () => {
+ assertCurrent();
+ if (password && (!serverUrl.trim() || !username.trim()))
+ throw new ProfileUIError('profiles.credentialsIncomplete');
+ const normalized = serverUrl.trim() ? normalizedUrl(serverUrl) : null;
+ if (normalized) warnHttp(normalized.isHttp);
+ const current = getActiveProfile();
+ if (
+ !password &&
+ (current.username !== username.trim() ||
+ current.serverUrl !== (normalized?.href || ''))
+ ) {
+ await resetProfileCredentials();
+ }
+ await saveConnection(normalized?.href || '', username.trim());
+ if (password) {
+ const saved = await setCredentialsForProfile(
+ profile.id,
+ username.trim(),
+ password,
+ );
+ if (saved === false)
+ throw new ProfileUIError('profiles.qrCredentialsFailed');
+ }
+ invalidateSettingsHydrationCache();
+ assertCurrent();
+ if (name.trim() !== profile.label) {
+ await profileRegistry.rename(profile.id, name.trim());
+ }
+ ToastService.showShort(t('profiles.connectionSaved'));
+ }, 'profiles.connectionFailed');
+ };
+
const handleCreate = () => {
if (!creation?.name.trim() || busy) return;
void runAction(async () => {
@@ -276,7 +325,7 @@ const ProfileConnection = ({
const disabled = busy || hydrating;
return (
-
+
{!creation && (
<>
@@ -289,6 +338,17 @@ const ProfileConnection = ({
)}
>
)}
+ {!creation && (
+
+ )}
{creation ? (
-
+
+
) : (
-
+ <>
+
+
+ onDelete?.()}
+ />
+
+
+ >
)}
Promise;
type LabelEditor = {
- id?: string;
label: string;
initialSettings?: SettingsUpdate;
};
@@ -106,21 +105,12 @@ const ProfilesScreen = () => {
});
};
- const saveLabel = () => {
- if (!editor?.id || !editor.label.trim()) return;
- const { id, label } = editor;
- void runAction(async () => {
- await profileRegistry.rename(id, label.trim());
- if (mountedRef.current) setEditor(null);
- }, 'profiles.renameFailed');
- };
-
const createProfile = async (connection: {
serverUrl: string;
username: string;
password: string;
}) => {
- if (!editor || editor.id || !editor.label.trim()) return;
+ if (!editor || !editor.label.trim()) return;
const created = await profileRegistry.add(editor.label.trim(), {
serverUrl: connection.serverUrl,
username: connection.username,
@@ -184,68 +174,52 @@ const ProfilesScreen = () => {
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag">
-
- setDropdownOpen(open => !open)}>
-
+
+
+ setDropdownOpen(open => !open)}>
{activeProfile.label}
-
+
+ {dropdownOpen && (
+
- {activeProfile.serverUrl || t('profiles.notConfigured')}
-
-
-
-
- {
- setDropdownOpen(false);
- setEditor({ label: '' });
- }}>
-
-
-
- {dropdownOpen && (
-
- {profiles.map(profile => {
- const active = profile.id === activeProfile.id;
- return (
-
+ {profiles.map(profile => (
selectProfile(profile.id)}>
{
]}>
{profile.label}
-
- {profile.serverUrl || t('profiles.notConfigured')}
-
- {active && (
-
- )}
- {!active && (
-
- {
- setDropdownOpen(false);
- setEditor({ id: profile.id, label: profile.label });
- }}
- />
- {
- setDropdownOpen(false);
- confirmDelete(profile.id, profile.label);
- }}
- />
-
- )}
-
- );
- })}
+ ))}
+
+ )}
- )}
-
-
- setEditor({ id: activeProfile.id, label: activeProfile.label })
- }
- />
-
- confirmDelete(activeProfile.id, activeProfile.label)
- }
- />
+ onPress={() => {
+ setDropdownOpen(false);
+ setEditor({ label: '' });
+ }}>
+
+
+
{profiles.length === 1 && (
@@ -343,53 +262,41 @@ const ProfilesScreen = () => {
- {t(editor.id ? 'profiles.rename' : 'profiles.add')}
+ {t('profiles.add')}
setEditor({ ...editor, label })}
disabled={busy}
autoCorrect={false}
/>
- {!editor.id && (
-
- )}
-
- setEditor(null)}
- />
- {!!editor.id && (
-
- )}
-
+ setEditor(null)}
+ />
)}
- {(!editor || !!editor.id) && (
+ {!editor && (
+ confirmDelete(activeProfile.id, activeProfile.label)
+ }
+ canDelete={profiles.length > 1}
onNewProfileFromQR={settings =>
setEditor({ label: '', initialSettings: settings })
}
@@ -418,11 +325,12 @@ const styles = StyleSheet.create({
description: { fontSize: odeTypography.bodySm },
selectorRow: {
flexDirection: 'row',
- alignItems: 'center',
+ alignItems: 'flex-start',
gap: odeSpacing.sm,
},
+ selectorOpen: { zIndex: 2 },
+ selectorControl: { flex: 1 },
dropdown: {
- flex: 1,
minHeight: 56,
flexDirection: 'row',
alignItems: 'center',
@@ -432,13 +340,21 @@ const styles = StyleSheet.create({
},
dropdownLabel: { flex: 1 },
options: {
+ position: 'absolute',
+ top: 56,
+ left: 0,
+ right: 0,
+ zIndex: 2,
+ elevation: 4,
borderWidth: odeBorderWidth.hairline,
borderRadius: odeSpacing.sm,
overflow: 'hidden',
},
- optionGroup: { padding: odeSpacing.xs },
- option: { minHeight: 52, padding: odeSpacing.sm },
- optionActions: { flexDirection: 'row', gap: odeSpacing.sm },
+ option: {
+ minHeight: 48,
+ paddingHorizontal: odeSpacing.md,
+ justifyContent: 'center',
+ },
addButton: {
width: 48,
height: 48,
@@ -447,7 +363,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
profileLabel: { fontSize: odeTypography.body, fontWeight: '600' },
- actions: { flexDirection: 'row', flexWrap: 'wrap', gap: odeSpacing.sm },
+
editor: {
borderWidth: odeBorderWidth.hairline,
borderRadius: odeSpacing.sm,
diff --git a/formulus/src/screens/__tests__/ProfilesScreen.test.tsx b/formulus/src/screens/__tests__/ProfilesScreen.test.tsx
index 1d55c3a3f..52fe39363 100644
--- a/formulus/src/screens/__tests__/ProfilesScreen.test.tsx
+++ b/formulus/src/screens/__tests__/ProfilesScreen.test.tsx
@@ -9,7 +9,10 @@ import {
switchProfile,
deleteProfile,
} from '../../profiles/ProfileTransitions';
-import { setCredentialsForProfile } from '../../profiles/ProfileKeychain';
+import {
+ resetProfileCredentials,
+ setCredentialsForProfile,
+} from '../../profiles/ProfileKeychain';
import { login } from '../../api/synkronus/Auth';
import { serverConfigService } from '../../services/ServerConfigService';
import { QRSettingsService } from '../../services/QRSettingsService';
@@ -96,7 +99,10 @@ jest.mock(
);
jest.mock(
'../../profiles/ProfileKeychain',
- () => ({ setCredentialsForProfile: jest.fn() }),
+ () => ({
+ setCredentialsForProfile: jest.fn(),
+ resetProfileCredentials: jest.fn(),
+ }),
{ virtual: true },
);
jest.mock('../../navigation/ProfileNavigationIntent', () => ({
@@ -221,6 +227,7 @@ jest.mock('../../services/QRSettingsService', () => ({
}));
jest.mock('../../services/SettingsHydrationCache', () => ({
loadSettingsHydrationFromStorage: jest.fn(),
+ invalidateSettingsHydrationCache: jest.fn(),
getSettingsHydrationCredentialPair: (snapshot: any) =>
snapshot.credentials || null,
}));
@@ -296,6 +303,7 @@ beforeEach(() => {
.mocked(login)
.mockResolvedValue({ username: 'new-user', role: 'read-write' });
jest.mocked(setCredentialsForProfile).mockResolvedValue(undefined);
+ jest.mocked(resetProfileCredentials).mockResolvedValue(true);
jest
.mocked(profileRegistry.updateConnection)
.mockImplementation(async connection => {
@@ -551,7 +559,10 @@ test('locked-server QR cancellation keeps the active connection and creates noth
).toEqual(['Cancel', 'Add profile']);
expect(profileRegistry.add).not.toHaveBeenCalled();
cancel();
- expect(screen.queryByPlaceholderText('Profile name')).toBeNull();
+ expect(screen.getByPlaceholderText('Profile name')).toHaveProp(
+ 'value',
+ 'First',
+ );
expect(profileRegistry.add).not.toHaveBeenCalled();
expect(mockActive.id).toBe('one');
});
@@ -772,7 +783,10 @@ test('new-profile panel requires a name and Cancel abandons manual entry', async
screen.getAllByRole('button', { name: 'Add profile' })[1],
).toBeDisabled();
fireEvent.press(screen.getByRole('button', { name: 'Cancel' }));
- expect(screen.queryByPlaceholderText('Profile name')).toBeNull();
+ expect(screen.getByPlaceholderText('Profile name')).toHaveProp(
+ 'value',
+ 'First',
+ );
expect(profileRegistry.add).not.toHaveBeenCalled();
});
@@ -894,24 +908,24 @@ test('does not attach a password to a profile without a server URL and username'
expect(setCredentialsForProfile).not.toHaveBeenCalled();
});
-test('can rename and delete an inactive profile from the dropdown', async () => {
+test('dropdown displays only profile names and no editing actions', async () => {
const screen = await setup();
openProfiles(screen);
- fireEvent.press(
- screen.getByRole('button', { name: 'Rename profile Second' }),
- );
- fireEvent.changeText(screen.getByPlaceholderText('Profile name'), 'Away');
- fireEvent.press(screen.getByRole('button', { name: 'Save name' }));
- await waitFor(() =>
- expect(profileRegistry.rename).toHaveBeenCalledWith('two', 'Away'),
- );
- expect(mockActive.id).toBe('one');
- openProfiles(screen);
- fireEvent.press(screen.getByRole('button', { name: 'Delete profile Away' }));
- expect(deleteProfile).not.toHaveBeenCalled();
- await confirm();
- await waitFor(() => expect(deleteProfile).toHaveBeenCalledWith('two'));
- expect(mockActive.id).toBe('one');
+ expect(
+ screen.getByRole('radio', { name: 'Use profile Second' }),
+ ).toBeTruthy();
+ expect(screen.queryByText('https://one.example')).toBeNull();
+ expect(
+ screen.queryByRole('button', { name: 'Rename profile Second' }),
+ ).toBeNull();
+ expect(
+ screen.queryByRole('button', { name: 'Delete profile Second' }),
+ ).toBeNull();
+ expect(
+ screen
+ .UNSAFE_getAllByType('Icon')
+ .some(icon => icon.props.name === 'check'),
+ ).toBe(false);
});
test('adds a named profile via manual connection and renames the active profile', async () => {
@@ -964,15 +978,52 @@ test('adds a named profile via manual connection and renames the active profile'
expect(screen.getByRole('button', { name: 'Scan QR' })).not.toBeDisabled(),
);
expect(mockActive.label).toBe('Fieldwork');
- fireEvent.press(
- screen.getByRole('button', { name: 'Rename profile Fieldwork' }),
- );
fireEvent.changeText(screen.getByPlaceholderText('Profile name'), 'Renamed');
- fireEvent.press(screen.getByRole('button', { name: 'Save name' }));
+ fireEvent.press(screen.getByRole('button', { name: 'Save changes' }));
await waitFor(() => expect(mockActive.label).toBe('Renamed'));
expect(profileRegistry.rename).toHaveBeenCalledWith('three', 'Renamed');
});
+test('save changes persists the active name, URL, username and password without logging in', async () => {
+ mockActive = mockProfiles[1];
+ const screen = await setup();
+ fireEvent.changeText(screen.getByPlaceholderText('Profile name'), ' Field ');
+ fireEvent.changeText(
+ screen.getByPlaceholderText('Server URL'),
+ 'Two.Example/',
+ );
+ fireEvent.changeText(screen.getByPlaceholderText('Username'), ' new-user ');
+ fireEvent.changeText(screen.getByPlaceholderText('Password'), 'secret');
+ fireEvent.press(screen.getByRole('button', { name: 'Save changes' }));
+ await waitFor(() =>
+ expect(profileRegistry.rename).toHaveBeenCalledWith('two', 'Field'),
+ );
+ expect(profileRegistry.updateConnection).toHaveBeenCalledWith({
+ serverUrl: 'https://two.example',
+ username: 'new-user',
+ });
+ expect(setCredentialsForProfile).toHaveBeenCalledWith(
+ 'two',
+ 'new-user',
+ 'secret',
+ );
+ expect(login).not.toHaveBeenCalled();
+});
+
+test('saving a changed username without a password clears outdated stored credentials', async () => {
+ const screen = await setup();
+ fireEvent.changeText(screen.getByPlaceholderText('Username'), 'another-user');
+ fireEvent.press(screen.getByRole('button', { name: 'Save changes' }));
+ await waitFor(() =>
+ expect(profileRegistry.updateConnection).toHaveBeenCalledWith({
+ serverUrl: 'https://one.example',
+ username: 'another-user',
+ }),
+ );
+ expect(resetProfileCredentials).toHaveBeenCalledTimes(1);
+ expect(setCredentialsForProfile).not.toHaveBeenCalled();
+});
+
test('deleting the active profile asks for confirmation and uses the central fallback', async () => {
const screen = await setup();
fireEvent.press(screen.getByRole('button', { name: 'Delete profile First' }));