From f9406fb4693d237849f63c782818ec1d748ea01f Mon Sep 17 00:00:00 2001 From: SethBurkart123 Date: Mon, 20 Apr 2026 13:42:49 +1000 Subject: [PATCH] feat: redesign Cloud settings UI and switch to OAuth redirect login - Move Cloud section inline with other settings, remove dedicated header bar - Replace in-extension login form with browser redirect to accounts.betterseqta.org - Background script intercepts OAuth callback URL to capture tokens - Add animated CloudPanel overlay (same pattern as ColourPicker) - Hide cloud sync details and profile picture setting when not signed in - Simplify CloudSettingsSync UI, reduce text verbosity - Fix settings download to merge keys instead of clear+set - Add legacy-to-plugin settings migration for cloud sync - Shorten profile picture and default page descriptions - Make DisclaimerModal title/message dynamic - Update CloudHeader button styling to match other buttons --- src/background.ts | 69 ++++++++ src/interface/components/CloudPanel.svelte | 157 ++++++++++++++++++ .../components/CloudSettingsSync.svelte | 124 +++++--------- .../components/SignInToFavoriteModal.svelte | 16 +- .../components/store/CloudHeader.svelte | 56 +++++-- src/interface/pages/settings.svelte | 47 +++--- src/interface/pages/settings/general.svelte | 50 ++++-- src/plugins/built-in/profilePicture/index.ts | 3 +- src/seqta/utils/CloudAuth.ts | 20 +++ src/seqta/utils/cloudSettingsSync.ts | 87 ++++++++-- 10 files changed, 476 insertions(+), 153 deletions(-) create mode 100644 src/interface/components/CloudPanel.svelte diff --git a/src/background.ts b/src/background.ts index acdeddae..d0926ae7 100644 --- a/src/background.ts +++ b/src/background.ts @@ -133,6 +133,74 @@ function handleCloudLogin(request: any, sendResponse: MessageSender): boolean { return true; } +function handleCloudStartLogin(request: any, sendResponse: MessageSender): boolean { + const { client_id, redirect_uri } = request; + if (!client_id || !redirect_uri) { + sendResponse({ error: "Missing client_id or redirect_uri" }); + return true; + } + const authorizeUrl = `https://accounts.betterseqta.org/login?redirect=${encodeURIComponent(`/oauth/authorize?client_id=${client_id}&redirect_uri=${encodeURIComponent(redirect_uri)}`)}`; + browser.tabs.create({ url: authorizeUrl }).then(() => { + sendResponse({ success: true }); + }).catch((err) => { + console.error("[Background] cloudStartLogin error:", err); + sendResponse({ error: err?.message ?? "Failed to open login page" }); + }); + return true; +} + +const CALLBACK_URL_PREFIX = "https://accounts.betterseqta.org/auth/bsplus/callback"; + +function initCloudLoginCallbackListener() { + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.url && changeInfo.url.startsWith(CALLBACK_URL_PREFIX)) { + try { + const url = new URL(changeInfo.url); + const token = url.searchParams.get("token"); + const refreshToken = url.searchParams.get("refresh_token"); + const userId = url.searchParams.get("user_id"); + + if (token && refreshToken) { + // Store tokens + void (async () => { + try { + await browser.storage.local.set({ + bsplus_token: token, + bsplus_refresh_token: refreshToken, + }); + + // Fetch full user info + const userRes = await fetch("https://accounts.betterseqta.org/api/auth/me", { + headers: { Authorization: `Bearer ${token}` }, + }); + if (userRes.ok) { + const user = await userRes.json(); + await browser.storage.local.set({ bsplus_user: user }); + } else if (userId) { + await browser.storage.local.set({ bsplus_user: { id: userId } }); + } + + // Trigger cloud settings download + void performCloudSettingsDownloadWithRetry(token).catch((err) => { + console.warn("[Background] Cloud settings download after login:", err); + }); + } catch (err) { + console.error("[Background] Failed to process login callback:", err); + } + })(); + + // Close the callback tab + void browser.tabs.remove(tabId); + } + } catch (err) { + console.error("[Background] Error parsing callback URL:", err); + } + } + }); +} + +initCloudLoginCallbackListener(); + function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean { const { refresh_token, client_id } = request; if (!refresh_token || !client_id) { @@ -269,6 +337,7 @@ const MESSAGE_HANDLERS: Record = { fetchFromUrl: handleFetchFromUrl, cloudReserveClient: handleCloudReserveClient, cloudLogin: handleCloudLogin, + cloudStartLogin: handleCloudStartLogin, cloudRefresh: handleCloudRefresh, cloudFavorite: handleCloudFavorite, cloudSettingsUpload: handleCloudSettingsUpload, diff --git a/src/interface/components/CloudPanel.svelte b/src/interface/components/CloudPanel.svelte new file mode 100644 index 00000000..ee8faea2 --- /dev/null +++ b/src/interface/components/CloudPanel.svelte @@ -0,0 +1,157 @@ + + + +
{ if (e.key === "Enter") handleBackgroundClick; }} +> +
+

BetterSEQTA Cloud

+

Account & sync

+ +
+ {#if cloudState.isLoggedIn} +
+
+ {#if cloudState.user?.pfpUrl} + + {:else} +
+ {getInitials()} +
+ {/if} +
+

+ {cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"} +

+ {#if cloudState.user?.email && cloudState.user?.email !== (cloudState.user?.displayName || cloudState.user?.username)} +

{cloudState.user.email}

+ {/if} +
+
+ +
+ {:else} +
+

+ Sign in to sync settings across devices, use your cloud profile picture, and more. +

+ + {#if loginError} +

{loginError}

+ {/if} +

+ Opens accounts.betterseqta.org in a new tab +

+
+ {/if} +
+
+
diff --git a/src/interface/components/CloudSettingsSync.svelte b/src/interface/components/CloudSettingsSync.svelte index b50fe6b4..9acb24bf 100644 --- a/src/interface/components/CloudSettingsSync.svelte +++ b/src/interface/components/CloudSettingsSync.svelte @@ -2,17 +2,17 @@ import browser from "webextension-polyfill"; import { cloudAuth } from "@/seqta/utils/CloudAuth"; import { settingsState } from "@/seqta/utils/listeners/SettingsState"; - import DisclaimerModal from "./DisclaimerModal.svelte"; import Button from "./Button.svelte"; import Switch from "./Switch.svelte"; + let { showDisclaimer } = $props<{ + showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void; + }>(); + let cloudState = $state(cloudAuth.state); let busy = $state(false); let statusMessage = $state(null); let statusError = $state(null); - let lastUploadAt = $state(null); - let lastDownloadAt = $state(null); - let showRestoreConfirm = $state(false); $effect(() => { const unsub = cloudAuth.subscribe((s) => { @@ -21,13 +21,6 @@ return unsub; }); - function formatNow(): string { - return new Date().toLocaleString(undefined, { - dateStyle: "short", - timeStyle: "short", - }); - } - async function upload() { const token = await cloudAuth.getStoredToken(); if (!token) return; @@ -40,8 +33,7 @@ token, })) as { success?: boolean; error?: string }; if (res?.success) { - statusMessage = "Settings saved to the cloud."; - lastUploadAt = formatNow(); + statusMessage = "Settings uploaded."; } else { statusError = res?.error ?? "Upload failed"; } @@ -53,11 +45,10 @@ } function promptDownload() { - showRestoreConfirm = true; + showDisclaimer(confirmDownload, () => {}); } async function confirmDownload() { - showRestoreConfirm = false; const token = await cloudAuth.getStoredToken(); if (!token) return; busy = true; @@ -69,8 +60,7 @@ token, })) as { success?: boolean; error?: string; notFound?: boolean }; if (res?.success) { - statusMessage = "Settings restored from the cloud. SEQTA tabs were reloaded."; - lastDownloadAt = formatNow(); + statusMessage = "Settings restored."; } else { statusError = res?.error ?? "Download failed"; } @@ -82,22 +72,13 @@ } -
-

Cloud settings backup

-

- Upload copies this browser’s BetterSEQTA+ settings to your account. Download replaces local settings with the - cloud copy (your sign-in stays on this device). -

- -
-
-

- Automatic sync -

+{#if cloudState.isLoggedIn} +
+
+
+

Automatic sync

+

Syncs settings when SEQTA loads and when you make changes

+
-

- When signed in, each time SEQTA loads and also hourly, if the cloud backup is newer it will replace local - settings. Settings you change will upload shortly after you adjust them. -

-

- Passwords, tokens, and other sensitive data are not included in the backup. + +

+
+ + {#if statusMessage} +

{statusMessage}

+ {/if} + {#if statusError} +

{statusError}

+ {/if} + +

+ Passwords and tokens are never synced. - Privacy policy - + class="font-medium text-emerald-600 underline decoration-emerald-600/50 underline-offset-2 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300 rounded-sm" + >Privacy policy

- -
-
- - {#if !cloudState.isLoggedIn} -

- Sign in from the BetterSEQTA Cloud header above to sync settings. -

- {/if} - - {#if statusMessage} -

{statusMessage}

- {/if} - {#if statusError} -

{statusError}

- {/if} - {#if lastUploadAt || lastDownloadAt} -

- {#if lastUploadAt}Last upload: {lastUploadAt}{/if} - {#if lastUploadAt && lastDownloadAt}·{/if} - {#if lastDownloadAt}Last download: {lastDownloadAt}{/if} -

- {/if} -
- -{#if showRestoreConfirm} - (showRestoreConfirm = false)} - /> {/if} diff --git a/src/interface/components/SignInToFavoriteModal.svelte b/src/interface/components/SignInToFavoriteModal.svelte index 2f5f178f..133b3f98 100644 --- a/src/interface/components/SignInToFavoriteModal.svelte +++ b/src/interface/components/SignInToFavoriteModal.svelte @@ -3,7 +3,6 @@ import { animate } from "motion"; import { onMount } from "svelte"; import { cloudAuth } from "@/seqta/utils/CloudAuth"; - import CloudLoginForm from "@/interface/components/store/CloudLoginForm.svelte"; let { onClose } = $props<{ onClose: () => void }>(); let modalElement: HTMLElement; @@ -23,6 +22,10 @@ ); } }); + + async function handleSignIn() { + await cloudAuth.startLogin(); + }
- + +

+ Opens accounts.betterseqta.org in a new tab +

- {#if open} + {#if !onClick && open}
{:else} - { - open = false; - }} - /> +
+

+ Sign in to sync favorites across devices. +

+ +

+ Opens accounts.betterseqta.org in a new tab +

+
{/if}
diff --git a/src/interface/pages/settings.svelte b/src/interface/pages/settings.svelte index 777cedbc..bcff0e80 100644 --- a/src/interface/pages/settings.svelte +++ b/src/interface/pages/settings.svelte @@ -15,14 +15,16 @@ //import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup"; import ColourPicker from "../components/ColourPicker.svelte"; + import CloudPanel from "../components/CloudPanel.svelte"; import DisclaimerModal from "../components/DisclaimerModal.svelte"; - import CloudHeader from "@/interface/components/store/CloudHeader.svelte"; import { settingsPopup } from "../hooks/SettingsPopup"; let devModeSequence = ""; let settingsActiveTab = $state(0); let showDisclaimerModal = $state(false); let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null); + let disclaimerTitle = $state("Confirm"); + let disclaimerMessage = $state(""); const handleDevModeToggle = () => { const handleKeyDown = (event: KeyboardEvent) => { @@ -67,15 +69,23 @@ let { standalone } = $props<{ standalone?: boolean }>(); let showColourPicker = $state(false); + let showCloudPanel = $state(false); - const showDisclaimer = (onConfirm: () => void, onCancel: () => void) => { + const openCloudPanel = () => { + showCloudPanel = true; + }; + + const showDisclaimer = (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => { disclaimerCallbacks = { onConfirm, onCancel }; + disclaimerTitle = title ?? "Confirm"; + disclaimerMessage = message ?? ""; showDisclaimerModal = true; }; onMount(() => { settingsPopup.addListener(() => { showColourPicker = false; + showCloudPanel = false; }); if (standalone) { @@ -277,25 +287,13 @@ {/if}
-
-
-

BetterSEQTA Cloud

-

Account & sync

-
-
- -
-
- {/if} + + {#if showCloudPanel} + { + showCloudPanel = false; + }} + /> + {/if}
{#if showDisclaimerModal && disclaimerCallbacks} { disclaimerCallbacks?.onConfirm(); showDisclaimerModal = false; diff --git a/src/interface/pages/settings/general.svelte b/src/interface/pages/settings/general.svelte index 5e7236fd..2928fffa 100644 --- a/src/interface/pages/settings/general.svelte +++ b/src/interface/pages/settings/general.svelte @@ -12,6 +12,8 @@ import PickerSwatch from "@/interface/components/PickerSwatch.svelte" import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte" import CloudSettingsSync from "@/interface/components/CloudSettingsSync.svelte" + import CloudHeader from "@/interface/components/store/CloudHeader.svelte" + import { cloudAuth } from "@/seqta/utils/CloudAuth" import { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification" import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup" import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync" @@ -53,6 +55,12 @@ const pluginSettings = getAllPluginSettings() as Plugin[]; const pluginSettingsValues = $state>>({}); + + let cloudState = $state(cloudAuth.state); + $effect(() => { + const unsub = cloudAuth.subscribe((s) => { cloudState = s; }); + return unsub; + }); async function loadPluginSettings() { for (const plugin of pluginSettings) { @@ -95,9 +103,10 @@ loadPluginSettings(); }) - const { showColourPicker, showDisclaimer } = $props<{ + const { showColourPicker, showDisclaimer, showCloudPanel } = $props<{ showColourPicker: () => void; - showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void; + showDisclaimer: (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => void; + showCloudPanel: () => void; }>(); async function exportCloudSettingsJsonToFile() { @@ -196,12 +205,11 @@ }, { title: "Default Page", - description: - "The page to load when SEQTA Learn or SEQTA Engage opens (uses the same #?page=/… URL as SEQTA). BetterSEQTA home on Engage only applies when Home is selected.", + description: "Choose which page loads first when you open SEQTA", id: 10, Component: Select, props: { - state: $settingsState.defaultPage, + state: $settingsState.defaultPage ?? "home", onChange: (value: string) => (settingsState.defaultPage = value), options: [ { value: "home", label: "Home" }, @@ -310,8 +318,9 @@ async () => { await updatePluginSetting(plugin.pluginId, 'enabled', true); }, - () => { - } + () => {}, + "Assessment Averages Disclaimer", + "This feature calculates a simple average of your assessment grades. It does not take into account:\n• Assessment weightings\n• Different grading scales\n• Other factors used in official reports\n\nThe displayed average may be inaccurate compared to your actual marks found in reports.\n\nDo you want to enable this feature?" ); return; } @@ -324,8 +333,8 @@ {#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)} {#each Object.entries(plugin.settings) as [key, setting]} - - {#if key !== 'enabled'} + + {#if key !== 'enabled' && !(key === 'useCloudPfp' && !cloudState.isLoggedIn)}

{setting.title || key}

@@ -388,6 +397,25 @@
{/each} +
+
+
+
+

BetterSEQTA Cloud

+

Account & sync

+
+
+ +
+
+ {#if cloudState.isLoggedIn} +
+ showDisclaimer(onConfirm, onCancel, "Restore from cloud?", "This will replace your local settings with the cloud backup. Continue?")} /> +
+ {/if} +
+
+
{@render Setting({ @@ -401,10 +429,6 @@ } })} -
- -
- {#if $settingsState.devMode}
diff --git a/src/plugins/built-in/profilePicture/index.ts b/src/plugins/built-in/profilePicture/index.ts index eee17cb9..9cf3e7d5 100644 --- a/src/plugins/built-in/profilePicture/index.ts +++ b/src/plugins/built-in/profilePicture/index.ts @@ -14,8 +14,7 @@ const settings = defineSettings({ useCloudPfp: booleanSetting({ default: false, title: "Use BetterSEQTA Cloud profile picture", - description: - "When enabled, uses the avatar from your BetterSEQTA Cloud account (sign in from the extension store). Otherwise uses the uploaded image below.", + description: "Use your cloud account avatar instead of the uploaded image below", }), picture: componentSetting({ title: "Profile Picture", diff --git a/src/seqta/utils/CloudAuth.ts b/src/seqta/utils/CloudAuth.ts index 28591290..a2490959 100644 --- a/src/seqta/utils/CloudAuth.ts +++ b/src/seqta/utils/CloudAuth.ts @@ -127,6 +127,26 @@ class CloudAuthService { return clientId; } + public async startLogin(): Promise<{ success: boolean; error?: string }> { + try { + const clientId = await this.getClientId(); + const result = (await browser.runtime.sendMessage({ + type: "cloudStartLogin", + client_id: clientId, + redirect_uri: REDIRECT_URI, + })) as { success?: boolean; error?: string }; + if (result?.success) { + return { success: true }; + } + return { success: false, error: result?.error ?? "Failed to open login page" }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Failed to open login page", + }; + } + } + public async login( login: string, password: string diff --git a/src/seqta/utils/cloudSettingsSync.ts b/src/seqta/utils/cloudSettingsSync.ts index 40392e03..9472a5ac 100644 --- a/src/seqta/utils/cloudSettingsSync.ts +++ b/src/seqta/utils/cloudSettingsSync.ts @@ -102,11 +102,12 @@ export function buildUploadPayload(all: Record): { schemaVersion: number; data: Record; } { - const data: Record = {}; + const filtered: Record = {}; for (const [k, v] of Object.entries(all)) { if (shouldOmitKeyFromCloudPayload(k)) continue; - data[k] = v; + filtered[k] = v; } + const data = migrateLegacyToPluginSettings(filtered); return { schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, data }; } @@ -124,8 +125,77 @@ export async function setKnownRemoteUpdatedAt(iso: string | undefined): Promise< } /** - * Replace local extension storage with the downloaded snapshot, except auth keys - * and device-only sensitive caches, which are preserved from the current device. + * Migrate legacy storage keys to plugin settings format. + * Only applies migrations for keys present in the data; does not overwrite + * existing plugin settings if the legacy key is absent. + */ +function migrateLegacyToPluginSettings(data: Record): Record { + const result = { ...data }; + + function ensurePluginSettings(pluginId: string): Record { + const key = `plugin.${pluginId}.settings`; + if (!result[key] || typeof result[key] !== "object") { + result[key] = {}; + } + return result[key] as Record; + } + + // animatedbk -> plugin.animated-background.settings.enabled + if ("animatedbk" in result) { + const settings = ensurePluginSettings("animated-background"); + if (settings.enabled === undefined) { + settings.enabled = !!result.animatedbk; + } + delete result.animatedbk; + } + + // bksliderinput -> plugin.animated-background.settings.speed + // Legacy: string "0"-"100", New: float 0.1-2.0 + if ("bksliderinput" in result) { + const settings = ensurePluginSettings("animated-background"); + if (settings.speed === undefined) { + const legacy = parseFloat(String(result.bksliderinput)); + if (!isNaN(legacy)) { + settings.speed = Math.round((0.1 + (legacy / 100) * 1.9) * 100) / 100; + } + } + delete result.bksliderinput; + } + + // assessmentsAverage -> plugin.assessments-average.settings.enabled + if ("assessmentsAverage" in result) { + const settings = ensurePluginSettings("assessments-average"); + if (settings.enabled === undefined) { + settings.enabled = !!result.assessmentsAverage; + } + delete result.assessmentsAverage; + } + + // lettergrade -> plugin.assessments-average.settings.lettergrade + if ("lettergrade" in result) { + const settings = ensurePluginSettings("assessments-average"); + if (settings.lettergrade === undefined) { + settings.lettergrade = !!result.lettergrade; + } + delete result.lettergrade; + } + + // notificationCollector -> plugin.notificationCollector.settings.enabled + if ("notificationCollector" in result && typeof result.notificationCollector === "boolean") { + const settings = ensurePluginSettings("notificationCollector"); + if (settings.enabled === undefined) { + settings.enabled = result.notificationCollector; + } + delete result.notificationCollector; + } + + return result; +} + +/** + * Apply the downloaded cloud snapshot by setting each key individually, + * preserving auth keys and device-only sensitive caches. + * Legacy keys are automatically migrated to plugin settings format. */ export async function applyDownloadedEnvelope(envelope: unknown): Promise { let remoteFlat: Record; @@ -145,10 +215,7 @@ export async function applyDownloadedEnvelope(envelope: unknown): Promise throw new Error("Invalid cloud settings payload"); } - const local = await browser.storage.local.get(); - const preserved = collectLocalKeysToPreserve(local); - const remoteSanitized = stripExcludedKeysFromRemoteData(remoteFlat); - - await browser.storage.local.clear(); - await browser.storage.local.set({ ...remoteSanitized, ...preserved }); + const migrated = migrateLegacyToPluginSettings(remoteFlat); + const remoteSanitized = stripExcludedKeysFromRemoteData(migrated); + await browser.storage.local.set(remoteSanitized); }