Skip to content
Merged
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
3 changes: 2 additions & 1 deletion formulus/src/components/MenuDrawer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
12 changes: 10 additions & 2 deletions formulus/src/components/MenuDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export const MenuDrawerSignedIn = ({ userInfo }: { userInfo: UserInfo }) => {
style={[styles.userAvatar, { backgroundColor: themeColors.primary }]}>
<Icon name="account" size={32} color={themeColors.onPrimary} />
</View>
<View style={styles.userInfo}>
<View style={[styles.userInfo, styles.signedInUserInfo]}>
<Text
style={[
styles.userName,
Expand All @@ -134,6 +134,7 @@ export const MenuDrawerSignedIn = ({ userInfo }: { userInfo: UserInfo }) => {
style={[
styles.roleBadge,
getRoleBadgeStyle(userInfo.role),
styles.signedInRoleBadge,
userInfo.role === 'admin' && {
backgroundColor: themeColors.primary as string,
},
Expand Down Expand Up @@ -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,
Expand All @@ -486,6 +493,7 @@ const styles = StyleSheet.create({
loginHint: {
fontSize: odeTypography.caption,
},
signedInRoleBadge: { alignSelf: 'center' },
roleBadge: {
alignSelf: 'flex-end',
paddingHorizontal: odeSpacing.xs,
Expand Down
1 change: 1 addition & 0 deletions formulus/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions formulus/src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions formulus/src/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
130 changes: 111 additions & 19 deletions formulus/src/screens/ProfileConnection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,6 +33,7 @@ import {
} from '../services/QRSettingsService';
import {
getSettingsHydrationCredentialPair,
invalidateSettingsHydrationCache,
loadSettingsHydrationFromStorage,
} from '../services/SettingsHydrationCache';
import { ToastService } from '../services/ToastService';
Expand All @@ -50,6 +55,9 @@ type Props = {
}) => Promise<void>;
};
onNewProfileFromQR?: (settings: SettingsUpdate) => void;
onCancelCreation?: () => void;
onDelete?: () => void;
canDelete?: boolean;
};

const ProfileConnection = ({
Expand All @@ -58,6 +66,9 @@ const ProfileConnection = ({
runAction,
creation,
onNewProfileFromQR,
onCancelCreation,
onDelete,
canDelete = false,
}: Props) => {
const { t } = useTranslation();
const { themeColors } = useAppTheme();
Expand All @@ -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 || '',
);
Expand Down Expand Up @@ -97,6 +109,7 @@ const ProfileConnection = ({
};
}
setHydrating(true);
setName(profile.label);
setPassword('');
const active = getActiveProfile();
setServerUrl(active.serverUrl || '');
Expand Down Expand Up @@ -124,6 +137,7 @@ const ProfileConnection = ({
};
}, [
profile.id,
profile.label,
isCreating,
initialServerUrl,
initialUsername,
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -276,7 +325,7 @@ const ProfileConnection = ({

const disabled = busy || hydrating;
return (
<View style={styles.section}>
<View style={[styles.section, creation && styles.creationSection]}>
{!creation && (
<>
<Text style={[styles.heading, { color: themeColors.onSurface }]}>
Expand All @@ -289,6 +338,17 @@ const ProfileConnection = ({
)}
</>
)}
{!creation && (
<Input
label={t('profiles.name')}
placeholder={t('profiles.name')}
value={name}
onChangeText={setName}
disabled={disabled}
autoCorrect={false}
style={styles.nameInput}
/>
)}
<TouchableOpacity
style={[styles.scanButton, { borderColor: themeColors.secondary }]}
disabled={disabled}
Expand Down Expand Up @@ -364,25 +424,50 @@ const ProfileConnection = ({
}
/>
{creation ? (
<Button
title={t('profiles.add')}
onPress={handleCreate}
loading={busy}
disabled={disabled || !creation.name.trim()}
fullWidth
size="large"
/>
<View style={styles.actions}>
<Button
title={t('common.cancel')}
variant="tertiary"
disabled={busy}
onPress={() => onCancelCreation?.()}
/>
<Button
title={t('profiles.add')}
onPress={handleCreate}
loading={busy}
disabled={disabled || !creation.name.trim()}
/>
</View>
) : (
<Button
title={t(busy ? 'profiles.working' : 'settings.login')}
onPress={handleLogin}
loading={busy}
disabled={
disabled || !serverUrl.trim() || !username.trim() || !password
}
fullWidth
size="large"
/>
<>
<View style={styles.actions}>
<Button
title={t('profiles.saveChanges')}
onPress={handleSave}
loading={busy}
disabled={disabled || !name.trim()}
/>
<Button
title={t('common.delete')}
accessibilityLabel={t('profiles.deleteLabel', {
label: profile.label,
})}
variant="danger"
disabled={disabled || !canDelete}
onPress={() => onDelete?.()}
/>
</View>
<Button
title={t(busy ? 'profiles.working' : 'settings.login')}
onPress={handleLogin}
loading={busy}
disabled={
disabled || !serverUrl.trim() || !username.trim() || !password
}
fullWidth
size="large"
/>
</>
)}
<QRScannerModal
visible={showScanner}
Expand All @@ -395,6 +480,13 @@ const ProfileConnection = ({

const styles = StyleSheet.create({
section: { marginTop: odeSpacing.md, gap: odeSpacing.xs },
creationSection: { marginTop: 0 },
nameInput: { marginBottom: 0 },
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: odeSpacing.sm,
},
heading: { fontSize: odeTypography.sectionTitle, fontWeight: '600' },
hint: { fontSize: odeTypography.bodySm, marginBottom: odeSpacing.sm },
trustNotice: {
Expand Down
Loading
Loading