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
211 changes: 185 additions & 26 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,7 @@ function getFlutterFlowEndpoint() {

function setFlutterFlowEndpoint(endpoint) {
localStorage.setItem("flutterflow_api_endpoint", endpoint);
invalidateStaleProjectSelection();
return true;
}

Expand All @@ -949,6 +950,15 @@ let commitTargetProjectId = null;
// modal's dropdown when the modal is closed/reopened before the fetch returns.
let confirmProjectToken = 0;

// Canonical account-connection state for the account connection card. It is
// derived from the outcome of a real FlutterFlow projects fetch — never from
// the presence of stored bytes alone and never from the placeholder token
// "set". 'connected' is only reachable from an actual listProjects response
// that returned at least one project.
// kind: 'not-configured' | 'validating' | 'connected' | 'no-projects'
// | 'unauthorized' | 'network-error'
let ffConnectionState = "not-configured";

async function initializeApiKeys() {
// Remove an exportable key left by an earlier version even if its encrypted
// credentials were already cleared.
Expand All @@ -959,6 +969,16 @@ async function initializeApiKeys() {
flutterflowProjectId = await getApiKey("flutterflow_project_id");
updateApiKeyStatusIndicators();
updateDeployButtonVisibility();

// If a FlutterFlow key is stored, confirm the real connection even before
// the settings modal is opened, so the account connection card is truthful
// on load. Non-blocking; runs once per initialize.
if (flutterflowApiKey) {
validateFlutterFlowConnection();
} else {
ffConnectionState = "not-configured";
renderApiKeyConnection();
}
}

// --- API KEY UI FUNCTIONS ---
Expand Down Expand Up @@ -1265,6 +1285,139 @@ function validateFlutterFlowProjectId(projectId) {
return /^[a-zA-Z0-9-]+$/.test(projectId);
}

/**
* Renders the account connection card from the canonical connection state.
* Not-config/validating/connected/no-projects/unauthorized/network-error are
* derived from ffConnectionState, which is only ever set from a real
* listProjects outcome. No raw or masked API key is ever written into a status
* label here.
*/
function renderApiKeyConnection() {
const dot = document.getElementById("acct-ff-dot");
const status = document.getElementById("acct-ff-status");
const endpoint = document.getElementById("acct-ff-endpoint");
const project = document.getElementById("acct-ff-project");

// Endpoint and default project always mirror the stored configuration (the
// same value the deploy confirmation pre-selects).
if (endpoint) {
const ep = getFlutterFlowEndpoint();
endpoint.textContent = ep && ep.includes("staging") ? "Staging" : "Production";
}
if (project) {
project.textContent = flutterflowProjectId || "—";
}
if (!status) return;

let label;
let tone;
if (!hasStoredKey("flutterflow")) {
label = "Not connected — add your FlutterFlow API key";
tone = "";
} else {
switch (ffConnectionState) {
case "connected":
label = "Connected to FlutterFlow";
tone = "ok";
break;
case "no-projects":
label = "Key accepted — no projects found";
tone = "warn";
break;
case "unauthorized":
label = "API key rejected (401/403) — re-enter your key";
tone = "bad";
break;
case "network-error":
label = "Could not reach FlutterFlow — check your network";
tone = "bad";
break;
case "validating":
default:
label = "Checking connection…";
tone = "warn";
break;
}
}
status.textContent = label;
if (dot) dot.className = `acct-dot${tone ? " " + tone : ""}`;
}

/**
* Refreshes the canonical connection state by calling the real FlutterFlow
* projects endpoint with the stored key. A returned empty list and a 401/403
* are distinct outcomes; both differ from a network failure. Used at startup
* and after saving/clearing so the account connection card is truthful.
* @returns {Promise<Array|null>} the loaded projects, or null on error.
*/
async function validateFlutterFlowConnection() {
const apiKey = await getApiKey("flutterflow");
if (!apiKey || !hasStoredKey("flutterflow")) {
ffConnectionState = "not-configured";
renderApiKeyConnection();
return null;
}

ffConnectionState = "validating";
renderApiKeyConnection();

try {
const client = new FlutterFlowApiClient(
apiKey,
"",
"main",
getFlutterFlowEndpoint(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Staging fallback loads production projects

When staging returns 404, listProjects retries its hard-coded production legacy URL. Connection, settings, and deploy selectors can accept production projects while later operations target staging.

Learn more

The configured endpoint now reaches listProjects, but the fallback URL remains fixed to production. A 404 from the staging convention path therefore changes environments rather than trying a staging-compatible path or preserving the failure. Every changed caller uses the same method, so the account check, API-key project selector, and deploy selector all inherit this behavior.

Example: With staging selected, https://api.flutterflow.io/v2-staging/listProjects returns 404. The client then requests https://api.flutterflow.io/v2/l/listProjects, accepts its production project list, and lets the user select a project unavailable on staging.

Recommended fix: Derive any legacy fallback from the configured endpoint, or disable the production legacy fallback whenever baseUrl is not the production endpoint. Add a staging-404 test that ensures no production request occurs.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

);
const projects = await client.listProjects();
ffConnectionState = projects && projects.length > 0
? "connected"
: "no-projects";
renderApiKeyConnection();
return projects;
} catch (error) {
const msg = String((error && error.message) || "");
ffConnectionState = /(401|403)|denied|unauthorized|scoped|list permission/i.test(msg)
? "unauthorized"
: "network-error";
Comment on lines +1377 to +1381

@devin-ai-integration devin-ai-integration Bot Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Failure classification depends on prose

Authorization state relies on broad error-message matching. Structured status metadata would keep future client wording changes from altering connection classification.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

renderApiKeyConnection();
return null;
}
}

/**
* Clears the currently selected FlutterFlow project so a stale selection
* (belonging to a different endpoint, or a removed key) is never reused as a
* deploy/sync default. Storage, the in-memory value and the dropdown mirror
* each other: the account connection card and the deploy confirmation read the
* same stored value, so invalidating it keeps them consistent.
*/
function clearStoredProjectSelection() {
flutterflowProjectId = "";
localStorage.removeItem(STORAGE_KEY_PREFIX + "flutterflow_project_id");
sessionStorage.removeItem(SESSION_STORAGE_KEY_PREFIX + "flutterflow_project_id");
}
Comment on lines +1394 to +1398

@devin-ai-integration devin-ai-integration Bot Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Replacement keys retain stale projects

Saving a replacement key without selecting a project retains the old default. saveApiKeys then displays and deploys with a project unrelated to the new key.

Learn more

The project ID is stored independently from the API key. The new helper clears it only when the endpoint changes, while saveApiKeys leaves it untouched when a replacement key produces no selected project. Deployment later falls back to that stored ID when the confirmation dropdown has no selection.

Example: Key A stores project alpha. The user enters key B, whose project list does not contain alpha, and saves without choosing another project. The account card still shows alpha, and a deployment can submit key B with project alpha.

Recommended fix: When a non-empty replacement API key is saved, clear the stored project unless the current dropdown supplies a valid project from that replacement key. Keep the key and project update ordered so a failed save cannot leave a partially updated pair.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.


/**
* Endpoint changes serve a different project set, so the previously selected
* project is no longer a trustworthy target. Invalidate the stale selection and
* re-fetch the project list for the new endpoint when a key is configured.
*/
function invalidateStaleProjectSelection() {
clearStoredProjectSelection();
const select = document.getElementById("flutterflow-projects-select");
if (select) {
select.innerHTML = '<option value="">Enter your API key to load projects</option>';
select.value = "";
}
if (hasStoredKey("flutterflow")) {
ffConnectionState = "validating";
fetchProjects(flutterflowApiKey);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Staging Reloads Production Projects

Changing the endpoint clears the selected project and calls fetchProjects, but that function creates FlutterFlowApiClient without passing getFlutterFlowEndpoint(), so the client defaults to Production. Selecting Staging therefore repopulates the dropdown and connection state with Production projects while the account card says “Staging,” allowing users to select a Production project for a Staging configuration.

Greptile automatically discovered a related ticket stating that endpoint changes must invalidate stale projects and reload projects for the selected endpoint, which informed this comment.

Source Used: Linear — Implement the account connection and single provider-key editor

Prompt To Fix With AI
This is a comment left during a code review.
Path: app.js
Line: 1403

Comment:
**Staging Reloads Production Projects**

Changing the endpoint clears the selected project and calls `fetchProjects`, but that function creates `FlutterFlowApiClient` without passing `getFlutterFlowEndpoint()`, so the client defaults to Production. Selecting Staging therefore repopulates the dropdown and connection state with Production projects while the account card says “Staging,” allowing users to select a Production project for a Staging configuration.

Greptile automatically discovered a related ticket stating that endpoint changes must invalidate stale projects and reload projects for the selected endpoint, which informed this comment.

**Source Used:** Linear — [Implement the account connection and single provider-key editor](https://linear.app/stuart-gardoll/issue/STU-384/implement-the-account-connection-and-single-provider-key-editor)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Devin

} else {
ffConnectionState = "not-configured";
}
renderApiKeyConnection();
}

function updateInputValidationState(inputId, isValid) {
const input = document.getElementById(inputId);
if (!input) return;
Expand Down Expand Up @@ -1334,16 +1487,33 @@ async function fetchProjects(apiKey) {
select.innerHTML = '<option value="">Loading projects...</option>';
if (errorElement) errorElement.classList.add("hidden");

// A real request is in flight; the account connection card shows validating
// until the outcome is known (never "connected" from storage alone).
if (hasStoredKey("flutterflow") || apiKey) {
ffConnectionState = "validating";
}
renderApiKeyConnection();
Comment on lines +1490 to +1495

@devin-ai-integration devin-ai-integration Bot Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Unsaved keys replace connection status

Blurring an unsaved replacement key makes fetchProjects overwrite the stored key's status. Canceling leaves the account card describing the discarded key.

Learn more

The account card is gated by the stored in-memory key, but fetchProjects validates either that key or a newly typed candidate. Its result always writes the shared ffConnectionState. Closing the modal does not revalidate the stored key, so the candidate result persists.

Example: A valid stored key shows Connected. The user types an invalid replacement, blurs the field, then closes without saving. The card reports that the API key was rejected even though the valid stored key remains active.

Recommended fix: Separate candidate-key validation state from the canonical stored-key connection state. Only update ffConnectionState when the request validates the currently stored key; use modal-local state for unsaved input.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.


try {
// Create temporary client instance (no project ID needed)
const client = new FlutterFlowApiClient(apiKey, "");
const client = new FlutterFlowApiClient(
apiKey,
"",
"main",
getFlutterFlowEndpoint(),
);
const projects = await client.listProjects();

if (!projects || projects.length === 0) {
ffConnectionState = "no-projects";
renderApiKeyConnection();
select.innerHTML = '<option value="">No projects found</option>';
return;
}

ffConnectionState = "connected";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unsaved Keys Become Canonical

Blurring a replacement key validates the unsaved input through fetchProjects, and any successful request unconditionally sets the global connection state to connected. A user can therefore close the modal without saving and still see the account card report that the persisted credential is connected. Because requests have no identity or stale-result guard, an older request can also overwrite the result for a newer credential.

Greptile automatically discovered a related ticket stating that the account card and editor must share one canonical credential value and only show connected after validating that value, which informed this comment.

Source Used: Linear — Implement the account connection and single provider-key editor

Prompt To Fix With AI
This is a comment left during a code review.
Path: app.js
Line: 1498

Comment:
**Unsaved Keys Become Canonical**

Blurring a replacement key validates the unsaved input through `fetchProjects`, and any successful request unconditionally sets the global connection state to `connected`. A user can therefore close the modal without saving and still see the account card report that the persisted credential is connected. Because requests have no identity or stale-result guard, an older request can also overwrite the result for a newer credential.

Greptile automatically discovered a related ticket stating that the account card and editor must share one canonical credential value and only show connected after validating that value, which informed this comment.

**Source Used:** Linear — [Implement the account connection and single provider-key editor](https://linear.app/stuart-gardoll/issue/STU-384/implement-the-account-connection-and-single-provider-key-editor)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex Fix in Devin

renderApiKeyConnection();
Comment on lines 1507 to +1515

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Stale project requests win races

Starting another fetchProjects request does not invalidate the first. A late response can replace the newer connection state and dropdown with stale projects.

Learn more

Opening the modal starts a request for the stored key. Blurring a replacement key or changing the endpoint can start another request before the first finishes. Unlike the deploy confirmation loader, fetchProjects has no token check before any state or dropdown write.

Example: The production request starts first. The user switches to staging, whose request finishes quickly. The slower production response then marks the card connected and replaces the dropdown with production projects.

Recommended fix: Add a monotonically increasing request token or abort controller to fetchProjects. Check it before every ffConnectionState, error, and dropdown update, including catch handling.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.


// Populate dropdown
select.innerHTML = '<option value="">Select a project...</option>';
projects.forEach((project) => {
Expand All @@ -1360,6 +1530,11 @@ async function fetchProjects(apiKey) {
}
} catch (error) {
console.error("Failed to fetch projects:", error);
const msg = String((error && error.message) || "");
ffConnectionState = /(401|403)|denied|unauthorized|scoped|list permission/i.test(msg)
? "unauthorized"
: "network-error";
renderApiKeyConnection();
select.innerHTML = '<option value="">Error loading projects</option>';
if (errorElement) {
errorElement.textContent = `Failed to load projects: ${error.message}`;
Expand Down Expand Up @@ -5330,30 +5505,8 @@ function renderAccountOverview() {
else meterTrack.setAttribute("aria-label", `${count} of ${limit} generations used`);
}

// --- FlutterFlow connection (real stored credential state) ---
const connKeyConfigured = hasStoredKey("flutterflow");
const connProjectConfigured = hasStoredKey("flutterflow_project_id");
const ffDot = document.getElementById("acct-ff-dot");
const ffStatus = document.getElementById("acct-ff-status");
const ffEndpoint = document.getElementById("acct-ff-endpoint");
const ffProject = document.getElementById("acct-ff-project");
if (ffStatus) {
if (connKeyConfigured && connProjectConfigured) {
ffStatus.textContent = "Connected to FlutterFlow";
if (ffDot) { ffDot.className = "acct-dot ok"; }
} else if (connKeyConfigured) {
ffStatus.textContent = "API key set — pick a project";
if (ffDot) { ffDot.className = "acct-dot warn"; }
} else {
ffStatus.textContent = "Not connected — add your FlutterFlow API key";
if (ffDot) { ffDot.className = "acct-dot"; }
}
}
if (ffEndpoint) {
const endpoint = getFlutterFlowEndpoint();
ffEndpoint.textContent = endpoint && endpoint.includes("staging") ? "Staging" : "Production";
}
if (ffProject) ffProject.textContent = flutterflowProjectId || "—";
// --- FlutterFlow connection (real response-driven state) ---
renderApiKeyConnection();
}

function updateUsageDisplay() {
Expand Down Expand Up @@ -5961,7 +6114,12 @@ async function populateConfirmProjectSelect() {
select.disabled = false;
select.innerHTML = '<option value="">Loading projects…</option>';
try {
const client = new FlutterFlowApiClient(apiKey, "");
const client = new FlutterFlowApiClient(
apiKey,
"",
"main",
getFlutterFlowEndpoint(),
);
const projects = await client.listProjects();
if (!isCurrent()) return;

Expand Down Expand Up @@ -6678,6 +6836,7 @@ window.openModelSelector = openModelSelector;
window.handlePromptImageSelect = handlePromptImageSelect;
window.removePromptImage = removePromptImage;
window.saveApiKeys = saveApiKeys;
window.validateFlutterFlowConnection = validateFlutterFlowConnection;
window.clearAllApiKeys = clearAllApiKeys;
window.toggleKeyVisibility = toggleKeyVisibility;
window.handleWelcomeVideoEnd = handleWelcomeVideoEnd;
Expand Down
13 changes: 13 additions & 0 deletions e2e/fixtures/apiFixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,19 @@ export const sampleProjectList = () =>
}),
});

// Deliberately different from sampleProjectList so a test asserts which host
// actually answered. Used to prove the connection check and project fetch
// honour the configured staging endpoint rather than the production default.
export const stagingProjectList = () =>
ok({
success: true,
value: JSON.stringify({
entries: [
{ id: "proj-stg-789", project: { name: "Staging Project Gamma" } },
],
}),
});

export const projectExport = () =>
ok({
success: true,
Expand Down
Loading
Loading