diff --git a/app.js b/app.js index 76168af..0fb85ae 100644 --- a/app.js +++ b/app.js @@ -925,6 +925,7 @@ function getFlutterFlowEndpoint() { function setFlutterFlowEndpoint(endpoint) { localStorage.setItem("flutterflow_api_endpoint", endpoint); + invalidateStaleProjectSelection(); return true; } @@ -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. @@ -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 --- @@ -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} 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(), + ); + 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"; + 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"); +} + +/** + * 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 = ''; + select.value = ""; + } + if (hasStoredKey("flutterflow")) { + ffConnectionState = "validating"; + fetchProjects(flutterflowApiKey); + } else { + ffConnectionState = "not-configured"; + } + renderApiKeyConnection(); +} + function updateInputValidationState(inputId, isValid) { const input = document.getElementById(inputId); if (!input) return; @@ -1334,16 +1487,33 @@ async function fetchProjects(apiKey) { select.innerHTML = ''; 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(); + 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 = ''; return; } + ffConnectionState = "connected"; + renderApiKeyConnection(); + // Populate dropdown select.innerHTML = ''; projects.forEach((project) => { @@ -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 = ''; if (errorElement) { errorElement.textContent = `Failed to load projects: ${error.message}`; @@ -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() { @@ -5961,7 +6114,12 @@ async function populateConfirmProjectSelect() { select.disabled = false; select.innerHTML = ''; try { - const client = new FlutterFlowApiClient(apiKey, ""); + const client = new FlutterFlowApiClient( + apiKey, + "", + "main", + getFlutterFlowEndpoint(), + ); const projects = await client.listProjects(); if (!isCurrent()) return; @@ -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; diff --git a/e2e/fixtures/apiFixtures.js b/e2e/fixtures/apiFixtures.js index 3bf6425..93d1f8b 100644 --- a/e2e/fixtures/apiFixtures.js +++ b/e2e/fixtures/apiFixtures.js @@ -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, diff --git a/e2e/stu384-connection.spec.js b/e2e/stu384-connection.spec.js new file mode 100644 index 0000000..6705b2e --- /dev/null +++ b/e2e/stu384-connection.spec.js @@ -0,0 +1,342 @@ +import { test, expect } from "@playwright/test"; +import { + applyDefaultRoutes, + guestIdentity, + signedInSession, + freeSubscription, + ok, + err, + sampleProjectList, + stagingProjectList, + emptyProjectList, + ENDPOINTS, +} from "./fixtures/apiFixtures.js"; + +/** + * STU-384 — account connection + single provider-key editor. + * + * The account connection card is the canonical, single editor surface for the + * FlutterFlow key, endpoint and target project. Every entry point + * (page/settings/account) opens the same api-keys modal and writes through the + * same encrypted storage, so there is exactly one value across save, reload and + * removal. The "connected" claim is only ever derived from a real projects + * response — never from stored bytes and never the placeholder "set" — and no + * key material (raw or masked) is rendered into a status label. + */ +const KEY = "ff-secret-token-abc123"; +const PROJ = "proj-abc-123"; +const EMAIL = "metered@example.com"; +const STAGING_LIST = "https://api.flutterflow.io/v2-staging/listProjects"; + +const sessionFor = (email = EMAIL) => ({ + status: 200, + body: JSON.stringify({ email, sessionToken: "session-token" }), + contentType: "application/json", +}); + +function signedInContext(email = EMAIL) { + return { + [ENDPOINTS.identity]: { + status: 200, + body: JSON.stringify({ + status: "recognized", + user_id: "acct-0001", + identity_token: "identity-token", + usage_count: 2, + usage_month: new Date().toISOString().slice(0, 7), + }), + contentType: "application/json", + }, + [ENDPOINTS.authRefreshSession]: sessionFor(email), + [ENDPOINTS.getSubscription]: freeSubscription(), + }; +} + +async function seedSession(page, email = EMAIL) { + await page.addInitScript(({ email }) => { + localStorage.setItem( + "ccc_auth_session", + JSON.stringify({ email, sessionToken: "session-token" }), + ); + }, { email }); +} + +async function openAccount(page) { + await dismissOpenModals(page); + await page.locator('a.nav-link[data-view="account"]').click(); + await expect(page.locator("#account-view")).toBeVisible(); +} + +// Saving through the api-keys modal closes it and (existing behaviour) reopens +// the walkthrough over the page. Close it through the app's own handler so the +// sharedControls background-inert state is restored; a raw classList removal +// would leave the background inert and block every later click. +async function dismissOpenModals(page) { + await page.evaluate(() => { + const open = document.querySelector(".modal-overlay.open"); + if (!open) return; + if (open.id === "walkthrough-modal" && window.closeWalkthroughModal) { + window.closeWalkthroughModal(); + } + }); +} + +async function saveKeyThroughModal(page, { key, project } = {}) { + // Open the canonical editor from the account connection card. + await openAccount(page); + await page + .locator(".acct-connection button", { hasText: "Configure" }) + .first() + .click(); + await expect(page.locator("#api-keys-modal")).toBeVisible(); + + const input = page.locator("#flutterflow-api-key-input"); + await input.fill(key); + + // Blur triggers a real listProjects fetch that populates the dropdown. + await input.blur(); + if (project) { + await expect( + page.locator(`#flutterflow-projects-select option[value="${project}"]`), + ).toHaveCount(1); + await page.locator("#flutterflow-projects-select").selectOption(project); + } + + await page.locator("#api-keys-modal .bg-blue-500").click(); + await expect(page.locator("#api-keys-modal")).toBeHidden(); + // saveApiKeys closes the modal and (existing behaviour) reopens the + // walkthrough inside a 1s timeout; wait it out, then clear the overlay once. + await page.waitForTimeout(1300); + await dismissOpenModals(page); +} + +test.describe("STU-384 account connection", () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem("hasSeenWalkthrough", "true"); + }); + }); + + test("not-configured state is shown when no key is stored", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, signedInContext()); + await page.goto("/"); + await openAccount(page); + + await expect(page.locator("#acct-ff-status")).toHaveText( + "Not connected — add your FlutterFlow API key", + ); + }); + + test("connected only from a real response; saves and reloads share one stored value", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, signedInContext()); + await page.goto("/"); + + await saveKeyThroughModal(page, { key: KEY, project: PROJ }); + + // Connected is claimed only after the projects endpoint responds. + await expect(page.locator("#acct-ff-status")).toHaveText( + "Connected to FlutterFlow", + ); + await expect(page.locator("#acct-ff-project")).toHaveText(PROJ); + await expect(page.locator("#acct-ff-dot")).toHaveClass(/ok/); + + // The status label and card never expose the raw or a masked key. + const card = page.locator(".acct-card.acct-connection"); + await expect(card).not.toContainText(KEY); + await expect(page.locator("#acct-ff-status")).not.toContainText("••••"); + + // Reload: the account card revalidates the same stored key -> connected. + await page.reload({ waitUntil: "domcontentloaded" }); + await openAccount(page); + await expect(page.locator("#acct-ff-status")).toHaveText( + "Connected to FlutterFlow", + ); + await expect(page.locator("#acct-ff-project")).toHaveText(PROJ); + }); + + test("empty project list is distinct from an auth failure", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, { + ...signedInContext(), + [ENDPOINTS.flutterFlowListProjects]: emptyProjectList(), + }); + await page.goto("/"); + await saveKeyThroughModal(page, { key: KEY, project: undefined }); + + // A successful but empty response is "no-projects", not an auth error. + await expect(page.locator("#acct-ff-status")).toHaveText( + "Key accepted — no projects found", + ); + }); + + test("401/403 renders an unauthorized state, distinct from empty/network", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, { + ...signedInContext(), + [ENDPOINTS.flutterFlowListProjects]: err(403, '{"message":"Forbidden"}'), + }); + await page.goto("/"); + await saveKeyThroughModal(page, { key: KEY, project: undefined }); + + await expect(page.locator("#acct-ff-status")).toHaveText( + "API key rejected (401/403) — re-enter your key", + ); + await expect(page.locator("#acct-ff-dot")).toHaveClass(/bad/); + }); + + test("network error state, then a retry recovers to connected", async ({ page }) => { + await seedSession(page); + // First all FlutterFlow project calls fail (network error). + await applyDefaultRoutes(page, { + ...signedInContext(), + [ENDPOINTS.flutterFlowListProjects]: err(503, "boom"), + }); + await page.goto("/"); + await saveKeyThroughModal(page, { key: KEY, project: undefined }); + + await expect(page.locator("#acct-ff-status")).toHaveText( + "Could not reach FlutterFlow — check your network", + ); + await expect(page.locator("#acct-ff-dot")).toHaveClass(/bad/); + + // Failure retry: subsequent success flips the state to connected. A later + // registered route takes precedence in Playwright, so the sample fixture + // now wins over the earlier 503 route. + await applyDefaultRoutes(page, { + ...signedInContext(), + [ENDPOINTS.flutterFlowListProjects]: sampleProjectList(), + }); + await page.evaluate(() => window.validateFlutterFlowConnection()); + await expect(page.locator("#acct-ff-status")).toHaveText( + "Connected to FlutterFlow", + ); + }); + + test("endpoint change invalidates stale project selection and re-fetches", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, { + ...signedInContext(), + [ENDPOINTS.flutterFlowListProjects]: sampleProjectList(), + // A deliberately DIFFERENT list on staging: the test only passes if the + // re-fetch actually hits the staging host. Returning the same list on + // both hosts (as before) could not tell production from staging. + [STAGING_LIST]: stagingProjectList(), + }); + await page.goto("/"); + await saveKeyThroughModal(page, { key: KEY, project: PROJ }); + + await expect(page.locator("#acct-ff-project")).toHaveText(PROJ); + const storedBefore = await page.evaluate(() => + localStorage.getItem("ccc_api_key_flutterflow_project_id"), + ); + expect(storedBefore).not.toBeNull(); + + // Switch to staging — the stored project no longer belongs to this + // endpoint, so the selection must be invalidated and the list re-fetched. + // We are already on the account view (the modal opened from it). + await dismissOpenModals(page); + await page + .locator(".acct-connection button", { hasText: "Configure" }) + .first() + .click(); + await expect(page.locator("#api-keys-modal")).toBeVisible(); + // The endpoint picker lives inside the collapsed "Advanced" disclosure. + await page + .locator("#api-keys-modal summary", { hasText: "Advanced" }) + .click(); + await page.locator("#flutterflow-endpoint-select").selectOption( + "https://api.flutterflow.io/v2-staging/", + ); + + const storedAfter = await page.evaluate(() => + localStorage.getItem("ccc_api_key_flutterflow_project_id"), + ); + expect(storedAfter).toBeNull(); + await expect(page.locator("#acct-ff-project")).toHaveText("—"); + await expect(page.locator("#acct-ff-status")).toHaveText( + "Connected to FlutterFlow", + ); + + // The re-fetch must have hit the STAGING host: only the staging project is + // offered and no production-only project remains. If the client fell back + // to the production endpoint, proj-stg-789 would never appear. + await expect( + page.locator( + `#flutterflow-projects-select option[value="proj-stg-789"]`, + ), + ).toHaveCount(1); + await expect( + page.locator(`#flutterflow-projects-select option[value="proj-def-456"]`), + ).toHaveCount(0); + }); + + test("removing the key flips the connection card back to not-configured", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, signedInContext()); + await page.goto("/"); + + await saveKeyThroughModal(page, { key: KEY, project: PROJ }); + await expect(page.locator("#acct-ff-status")).toHaveText( + "Connected to FlutterFlow", + ); + await expect(page.locator("#acct-ff-project")).toHaveText(PROJ); + + // Re-open the canonical editor for the account card and clear every key. + await page + .locator(".acct-connection button", { hasText: "Configure" }) + .first() + .click(); + await expect(page.locator("#api-keys-modal")).toBeVisible(); + + // Accept the destructive confirm, then close the modal through the app's + // own handler so the sharedControls background-inert state is restored. + page.once("dialog", (dialog) => dialog.accept()); + await page + .locator("#api-keys-modal button", { hasText: "Clear All Keys" }) + .click(); + await page.evaluate(() => window.closeApiKeysModal()); + + // The card must flip back to not-configured: no stale "connected" state + // may survive removal, and no target project may linger. + await expect(page.locator("#acct-ff-status")).toHaveText( + "Not connected — add your FlutterFlow API key", + ); + await expect(page.locator("#acct-ff-project")).toHaveText("—"); + await expect(page.locator("#acct-ff-dot")).not.toHaveClass(/ok/); + + // No masked or raw key material may remain in any account card label. + const card = page.locator(".acct-card.acct-connection"); + await expect(card).not.toContainText(KEY); + await expect(page.locator("#acct-ff-status")).not.toContainText("••••"); + }); + + test("signed-out entry can still save the key without a connection card", async ({ page }) => { + // No session seeded: the account view shows the signed-out prompt. + await applyDefaultRoutes(page, { + [ENDPOINTS.identity]: guestIdentity(), + }); + await page.goto("/"); + + await page.locator('a.nav-link[data-view="account"]').click(); + await expect(page.locator("#auth-signedout")).toBeVisible(); + await expect(page.locator("#account-view #auth-signedin")).toBeHidden(); + + // The canonical editor stays reachable from the home settings entry point. + await page.locator('a.nav-link[data-view="home"]').click(); + await page.locator('button.settings-link', { hasText: "Configure API Keys" }).click(); + await expect(page.locator("#api-keys-modal")).toBeVisible(); + await page.locator("#flutterflow-api-key-input").fill(KEY); + await page.locator("#api-keys-modal .bg-blue-500").click(); + await expect(page.locator("#api-keys-modal")).toBeHidden(); + + // Reload reads the saved key from the same storage (one value). + await page.reload({ waitUntil: "domcontentloaded" }); + await page.locator('button.settings-link', { hasText: "Configure API Keys" }).click(); + await expect(page.locator("#flutterflow-api-key-input")).toHaveAttribute( + "placeholder", + /Key saved/, + ); + }); +}); diff --git a/index.html b/index.html index 76f4bbf..5cb66b4 100644 --- a/index.html +++ b/index.html @@ -3370,6 +3370,7 @@ } .acct-dot.ok { background: var(--ok); } .acct-dot.warn { background: var(--warn); } + .acct-dot.bad { background: var(--bad); } .acct-conn-spacer { flex: 1; } .acct-kv { margin: 12px 0 0; } .acct-row { diff --git a/screenshots/stu-383/account-free-zero-usage-desktop.png b/screenshots/stu-383/account-free-zero-usage-desktop.png index f798209..4640210 100644 Binary files a/screenshots/stu-383/account-free-zero-usage-desktop.png and b/screenshots/stu-383/account-free-zero-usage-desktop.png differ diff --git a/screenshots/stu-383/account-free-zero-usage-mobile.png b/screenshots/stu-383/account-free-zero-usage-mobile.png index 3bb5138..4e05e68 100644 Binary files a/screenshots/stu-383/account-free-zero-usage-mobile.png and b/screenshots/stu-383/account-free-zero-usage-mobile.png differ diff --git a/screenshots/stu-383/account-power-max-usage-desktop.png b/screenshots/stu-383/account-power-max-usage-desktop.png index fc9b61b..1d8d843 100644 Binary files a/screenshots/stu-383/account-power-max-usage-desktop.png and b/screenshots/stu-383/account-power-max-usage-desktop.png differ diff --git a/screenshots/stu-383/account-power-max-usage-mobile.png b/screenshots/stu-383/account-power-max-usage-mobile.png index d903809..cf3a0c5 100644 Binary files a/screenshots/stu-383/account-power-max-usage-mobile.png and b/screenshots/stu-383/account-power-max-usage-mobile.png differ diff --git a/screenshots/stu-383/account-professional-31-50-long-email-desktop.png b/screenshots/stu-383/account-professional-31-50-long-email-desktop.png index 98f47dc..2208d46 100644 Binary files a/screenshots/stu-383/account-professional-31-50-long-email-desktop.png and b/screenshots/stu-383/account-professional-31-50-long-email-desktop.png differ diff --git a/screenshots/stu-383/account-professional-31-50-long-email-mobile.png b/screenshots/stu-383/account-professional-31-50-long-email-mobile.png index e5e0357..d498fe7 100644 Binary files a/screenshots/stu-383/account-professional-31-50-long-email-mobile.png and b/screenshots/stu-383/account-professional-31-50-long-email-mobile.png differ diff --git a/screenshots/stu-383/account-unresolved-desktop.png b/screenshots/stu-383/account-unresolved-desktop.png index 2212a48..29ae54e 100644 Binary files a/screenshots/stu-383/account-unresolved-desktop.png and b/screenshots/stu-383/account-unresolved-desktop.png differ diff --git a/screenshots/stu-383/account-unresolved-mobile.png b/screenshots/stu-383/account-unresolved-mobile.png index 2aaa213..f9302a4 100644 Binary files a/screenshots/stu-383/account-unresolved-mobile.png and b/screenshots/stu-383/account-unresolved-mobile.png differ diff --git a/screenshots/stu384-connected-1440x900.png b/screenshots/stu384-connected-1440x900.png new file mode 100644 index 0000000..9e6fefd Binary files /dev/null and b/screenshots/stu384-connected-1440x900.png differ diff --git a/screenshots/stu384-connected-360x800.png b/screenshots/stu384-connected-360x800.png new file mode 100644 index 0000000..cb10d30 Binary files /dev/null and b/screenshots/stu384-connected-360x800.png differ