OBLS-688 Add server profiles feature with multi-env support#381
OBLS-688 Add server profiles feature with multi-env support#381olewandowski1 wants to merge 1 commit intodevelopfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a server profiles system to support multiple backend environments (dev/staging/prod), including migration from the legacy single API_URL setting and UI entry points to manage/switch profiles.
Changes:
- Introduces persisted “profiles” storage (versioned schema) with migration from legacy
API_URL. - Adds a Profiles management screen + dialog for create/edit/delete/switch, including logout/reset on switch.
- Updates Settings and Login screens to surface profile management and show the active profile.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/types/profile.ts | Adds Profile + ProfileStorageData types for the new storage schema. |
| src/components/ProfileStorage.ts | Implements AsyncStorage persistence, validation helpers, and legacy API_URL migration. |
| src/hooks/useProfiles.ts | Adds a navigation-aware hook to load and mutate profile storage from screens. |
| src/screens/Profiles/index.tsx | Implements Profiles list UI + activate/edit/delete flows and auth reset on switch. |
| src/screens/Profiles/ProfileFormDialog.tsx | Adds modal form UI for creating/editing profiles with validation feedback. |
| src/screens/Profiles/styles.ts | Styling for Profiles screen and dialog. |
| src/screens/Settings/index.tsx | Replaces “Server Connection” URL editing with navigation to Profiles management. |
| src/screens/Login/index.tsx | Displays the active profile card on Login with tap-to-change navigation. |
| src/screens/Login/styles.ts | Adds styles for the active-profile card on Login. |
| src/Main.tsx | Runs profile migration on navigator ready and sets ApiClient base URL or routes to Profiles. |
| src/redux/reducers/mainReducer.ts | Resets main app state on LOGOUT_REQUEST_SUCCESS (used for profile switching). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| await addProfile(label, serverUrl); | ||
| } | ||
|
|
||
| setDialogVisible(false); | ||
| }, | ||
| [editingProfile, activeProfileId, updateProfile, addProfile] |
There was a problem hiding this comment.
When creating the very first profile (fresh install / no legacy API_URL), the new profile becomes active in storage but ApiClient never gets initialized with its base URL. Since ApiClient.client is only created via setBaseUrl, subsequent login requests will crash/fail until the user edits/switches a profile. Consider detecting the “first profile” case and calling ApiClient.setBaseUrl (and/or setting it active explicitly) right after creation so the app can log in immediately.
| await addProfile(label, serverUrl); | |
| } | |
| setDialogVisible(false); | |
| }, | |
| [editingProfile, activeProfileId, updateProfile, addProfile] | |
| const isFirstProfile = profiles.length === 0; | |
| await addProfile(label, serverUrl); | |
| if (isFirstProfile) { | |
| ApiClient.setBaseUrl(serverUrl); | |
| } | |
| } | |
| setDialogVisible(false); | |
| }, | |
| [editingProfile, activeProfileId, updateProfile, addProfile, profiles.length] |
| showPopup({ | ||
| title: `Delete "${profile.label}"?`, | ||
| message: 'This profile will be permanently removed. This action cannot be undone.', | ||
| positiveButton: { | ||
| text: 'Delete', | ||
| callback: async () => { | ||
| const wasActive = profile.id === activeProfileId; | ||
| const success = await removeProfile(profile.id); | ||
| if (success && wasActive) { | ||
| const newActive = await ProfileStorage.getActiveProfile(); | ||
| if (newActive) { | ||
| ApiClient.setBaseUrl(newActive.serverUrl); | ||
| } | ||
| } | ||
| } | ||
| }, |
There was a problem hiding this comment.
The confirm dialog’s positiveButton.callback is invoked synchronously by showPopup (Alert), and it does not await Promises. Using an async callback here means any thrown error becomes an unhandled rejected Promise and can be missed. Wrap the async work in a non-async callback that calls the async function and handles errors (e.g., with .catch(...)), or update the popup helper to support awaiting callbacks.
| showPopup({ | ||
| title: `Switch to "${profile.label}"?`, | ||
| message: 'You will be connected to a different server and will need to log in again.', | ||
| positiveButton: { | ||
| text: 'Switch', | ||
| callback: async () => { | ||
| await setActive(profile.id); | ||
| ApiClient.setBaseUrl(profile.serverUrl); | ||
| dispatch({ type: LOGOUT_REQUEST_SUCCESS }); | ||
| NavigationService.reset('Login'); | ||
| } | ||
| }, | ||
| negativeButtonText: 'Cancel' | ||
| }); |
There was a problem hiding this comment.
Same issue as above: this async positiveButton callback returns a Promise that showPopup doesn’t await/handle. If setActive/ApiClient.setBaseUrl throws, it will surface as an unhandled rejection while the UI continues. Use a sync callback that explicitly handles the Promise (or enhance showPopup to handle async callbacks).
| const stored = await ProfileStorage.getProfiles(); | ||
| setData(stored); | ||
| setLoading(false); |
There was a problem hiding this comment.
refresh() doesn’t handle errors from AsyncStorage/JSON parsing (via ProfileStorage.getProfiles). If it throws, loading will stay true and screens using this hook will render nothing indefinitely. Add a try/catch to set an empty/default state and ensure setLoading(false) always runs (e.g., in a finally block).
| const stored = await ProfileStorage.getProfiles(); | |
| setData(stored); | |
| setLoading(false); | |
| try { | |
| const stored = await ProfileStorage.getProfiles(); | |
| setData(stored); | |
| } catch (error) { | |
| // Fallback to a safe default state if reading/parsing profiles fails | |
| setData({ | |
| version: 1, | |
| activeProfileId: null, | |
| profiles: [] | |
| }); | |
| } finally { | |
| setLoading(false); | |
| } |
| const active = data.profiles.find((p) => p.id === data.activeProfileId); | ||
| return active?.serverUrl ?? data.profiles[0]?.serverUrl ?? null; |
There was a problem hiding this comment.
If PROFILES storage contains profiles but activeProfileId is missing/invalid (e.g., partial/corrupted data), migrate() returns a URL but never normalizes/persists a valid activeProfileId. This leaves the app in a state where no profile is marked active in the UI. Consider defaulting activeProfileId to the first profile (and writing it back) when profiles exist but the active id is null/unknown.
| const active = data.profiles.find((p) => p.id === data.activeProfileId); | |
| return active?.serverUrl ?? data.profiles[0]?.serverUrl ?? null; | |
| let active = data.profiles.find((p) => p.id === data.activeProfileId); | |
| // If no valid active profile is set but profiles exist, default to the first | |
| // profile and persist this choice so the UI has a consistent activeProfileId. | |
| if (!active && data.profiles[0]) { | |
| active = data.profiles[0]; | |
| const updatedData: ProfileStorageData = { | |
| ...data, | |
| activeProfileId: active.id | |
| }; | |
| await writeStorage(updatedData); | |
| } | |
| return active?.serverUrl ?? null; |
https://openboxes.atlassian.net/browse/OBLS-688
Changes:
Kazam_screencast_00057.mp4