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
This commit is contained in:
SethBurkart123
2026-04-20 13:42:49 +10:00
parent 690792fd62
commit f9406fb469
10 changed files with 476 additions and 153 deletions
+69
View File
@@ -133,6 +133,74 @@ function handleCloudLogin(request: any, sendResponse: MessageSender): boolean {
return true; 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 { function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean {
const { refresh_token, client_id } = request; const { refresh_token, client_id } = request;
if (!refresh_token || !client_id) { if (!refresh_token || !client_id) {
@@ -269,6 +337,7 @@ const MESSAGE_HANDLERS: Record<string, MessageHandler> = {
fetchFromUrl: handleFetchFromUrl, fetchFromUrl: handleFetchFromUrl,
cloudReserveClient: handleCloudReserveClient, cloudReserveClient: handleCloudReserveClient,
cloudLogin: handleCloudLogin, cloudLogin: handleCloudLogin,
cloudStartLogin: handleCloudStartLogin,
cloudRefresh: handleCloudRefresh, cloudRefresh: handleCloudRefresh,
cloudFavorite: handleCloudFavorite, cloudFavorite: handleCloudFavorite,
cloudSettingsUpload: handleCloudSettingsUpload, cloudSettingsUpload: handleCloudSettingsUpload,
+157
View File
@@ -0,0 +1,157 @@
<script lang="ts">
import { onMount } from "svelte";
import { animate } from "motion";
import { delay } from "@/seqta/utils/delay.ts";
import { cloudAuth } from "@/seqta/utils/CloudAuth";
const { hidePanel } = $props<{
hidePanel: () => void;
}>();
let cloudState = $state(cloudAuth.state);
let background = $state<HTMLDivElement | null>(null);
let content = $state<HTMLDivElement | null>(null);
let loginError = $state<string | null>(null);
onMount(() => {
const unsub = cloudAuth.subscribe((s) => {
cloudState = s;
});
if (background && content) {
animate(
background,
{ opacity: [0, 1] },
{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }
);
animate(
content,
{ scale: [0.4, 1], opacity: [0, 1] },
{ type: "spring", stiffness: 400, damping: 30 }
);
}
const handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closePanel();
};
document.addEventListener("keydown", handleEscapeKey);
return () => {
unsub();
document.removeEventListener("keydown", handleEscapeKey);
};
});
async function closePanel() {
if (!background || !content) return;
animate(
content,
{ scale: [1, 0.4], opacity: [1, 0] },
{ type: "spring", stiffness: 400, damping: 30 }
);
animate(
background,
{ opacity: [1, 0] },
{ ease: [0.4, 0, 0.2, 1] }
);
await delay(400);
hidePanel();
}
function handleBackgroundClick(event: MouseEvent) {
if (event.target === background) closePanel();
}
async function handleSignIn() {
loginError = null;
const result = await cloudAuth.startLogin();
if (result.success) {
closePanel();
} else {
loginError = result.error ?? "Failed to open login page";
}
}
async function handleLogout() {
await cloudAuth.logout();
}
function getInitials(): string {
const u = cloudState.user;
if (!u) return "?";
if (u.displayName) return u.displayName.slice(0, 2).toUpperCase();
if (u.username) return u.username.slice(0, 2).toUpperCase();
if (u.email) return u.email.slice(0, 2).toUpperCase();
return "?";
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={background}
class="flex absolute top-0 left-0 z-50 justify-center items-center w-full h-full cursor-pointer bg-black/50"
onclick={handleBackgroundClick}
onkeydown={(e) => { if (e.key === "Enter") handleBackgroundClick; }}
>
<div
bind:this={content}
class="p-5 w-[320px] bg-white rounded-xl border shadow-lg cursor-auto dark:bg-zinc-800 border-zinc-100 dark:border-zinc-700"
>
<h3 class="text-lg font-bold text-zinc-900 dark:text-white">BetterSEQTA Cloud</h3>
<p class="mt-0.5 text-sm text-zinc-500 dark:text-zinc-400">Account & sync</p>
<div class="mt-4">
{#if cloudState.isLoggedIn}
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
{#if cloudState.user?.pfpUrl}
<img
src={cloudState.user.pfpUrl}
alt=""
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
/>
{:else}
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
{getInitials()}
</div>
{/if}
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-zinc-900 dark:text-white truncate">
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
</p>
{#if cloudState.user?.email && cloudState.user?.email !== (cloudState.user?.displayName || cloudState.user?.username)}
<p class="text-xs text-zinc-500 dark:text-zinc-400 truncate">{cloudState.user.email}</p>
{/if}
</div>
</div>
<button
type="button"
onclick={handleLogout}
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-200 dark:bg-zinc-700 text-zinc-900 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors duration-200"
>
Sign out
</button>
</div>
{:else}
<div class="flex flex-col gap-3">
<p class="text-sm text-zinc-600 dark:text-zinc-400">
Sign in to sync settings across devices, use your cloud profile picture, and more.
</p>
<button
type="button"
onclick={handleSignIn}
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
Sign in with BetterSEQTA Cloud
</button>
{#if loginError}
<p class="text-xs text-red-600 dark:text-red-400">{loginError}</p>
{/if}
<p class="text-xs text-center text-zinc-400 dark:text-zinc-500">
Opens accounts.betterseqta.org in a new tab
</p>
</div>
{/if}
</div>
</div>
</div>
@@ -2,17 +2,17 @@
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import { cloudAuth } from "@/seqta/utils/CloudAuth"; import { cloudAuth } from "@/seqta/utils/CloudAuth";
import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import { settingsState } from "@/seqta/utils/listeners/SettingsState";
import DisclaimerModal from "./DisclaimerModal.svelte";
import Button from "./Button.svelte"; import Button from "./Button.svelte";
import Switch from "./Switch.svelte"; import Switch from "./Switch.svelte";
let { showDisclaimer } = $props<{
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void;
}>();
let cloudState = $state(cloudAuth.state); let cloudState = $state(cloudAuth.state);
let busy = $state(false); let busy = $state(false);
let statusMessage = $state<string | null>(null); let statusMessage = $state<string | null>(null);
let statusError = $state<string | null>(null); let statusError = $state<string | null>(null);
let lastUploadAt = $state<string | null>(null);
let lastDownloadAt = $state<string | null>(null);
let showRestoreConfirm = $state(false);
$effect(() => { $effect(() => {
const unsub = cloudAuth.subscribe((s) => { const unsub = cloudAuth.subscribe((s) => {
@@ -21,13 +21,6 @@
return unsub; return unsub;
}); });
function formatNow(): string {
return new Date().toLocaleString(undefined, {
dateStyle: "short",
timeStyle: "short",
});
}
async function upload() { async function upload() {
const token = await cloudAuth.getStoredToken(); const token = await cloudAuth.getStoredToken();
if (!token) return; if (!token) return;
@@ -40,8 +33,7 @@
token, token,
})) as { success?: boolean; error?: string }; })) as { success?: boolean; error?: string };
if (res?.success) { if (res?.success) {
statusMessage = "Settings saved to the cloud."; statusMessage = "Settings uploaded.";
lastUploadAt = formatNow();
} else { } else {
statusError = res?.error ?? "Upload failed"; statusError = res?.error ?? "Upload failed";
} }
@@ -53,11 +45,10 @@
} }
function promptDownload() { function promptDownload() {
showRestoreConfirm = true; showDisclaimer(confirmDownload, () => {});
} }
async function confirmDownload() { async function confirmDownload() {
showRestoreConfirm = false;
const token = await cloudAuth.getStoredToken(); const token = await cloudAuth.getStoredToken();
if (!token) return; if (!token) return;
busy = true; busy = true;
@@ -69,8 +60,7 @@
token, token,
})) as { success?: boolean; error?: string; notFound?: boolean }; })) as { success?: boolean; error?: string; notFound?: boolean };
if (res?.success) { if (res?.success) {
statusMessage = "Settings restored from the cloud. SEQTA tabs were reloaded."; statusMessage = "Settings restored.";
lastDownloadAt = formatNow();
} else { } else {
statusError = res?.error ?? "Download failed"; statusError = res?.error ?? "Download failed";
} }
@@ -82,22 +72,13 @@
} }
</script> </script>
<div {#if cloudState.isLoggedIn}
class="w-full rounded-xl border border-zinc-200/60 bg-zinc-50/80 px-4 py-2.5 dark:border-zinc-700/50 dark:bg-zinc-900/40" <div class="flex flex-col gap-2.5">
> <div class="flex items-center justify-between gap-3">
<h3 class="text-xs font-bold text-zinc-800 dark:text-zinc-100">Cloud settings backup</h3> <div>
<p class="mt-0.5 text-[11px] leading-snug text-zinc-500 dark:text-zinc-400"> <p class="text-[11px] font-semibold text-zinc-800 dark:text-zinc-100">Automatic sync</p>
Upload copies this browsers BetterSEQTA+ settings to your account. Download replaces local settings with the <p class="text-[10px] text-zinc-500 dark:text-zinc-400">Syncs settings when SEQTA loads and when you make changes</p>
cloud copy (your sign-in stays on this device). </div>
</p>
<div
class="mt-2 flex flex-col gap-2 rounded-lg border border-zinc-200/50 bg-white/60 px-3 py-2.5 dark:border-zinc-600/40 dark:bg-zinc-800/40"
>
<div class="flex items-start justify-between gap-3">
<p class="min-w-0 flex-1 pt-0.5 text-[11px] font-semibold leading-tight text-zinc-800 dark:text-zinc-100">
Automatic sync
</p>
<div class="shrink-0"> <div class="shrink-0">
<Switch <Switch
state={$settingsState.autoCloudSettingsSync !== false} state={$settingsState.autoCloudSettingsSync !== false}
@@ -105,62 +86,35 @@
/> />
</div> </div>
</div> </div>
<p class="text-[10px] leading-snug text-zinc-500 dark:text-zinc-400">
When signed in, each time SEQTA loads and also hourly, if the cloud backup is newer it will replace local <div class="flex flex-wrap gap-2">
settings. Settings you change will upload shortly after you adjust them. <Button
</p> text={busy ? "Please wait\u2026" : "Upload"}
<p class="text-[10px] leading-snug text-zinc-500 dark:text-zinc-400"> onClick={upload}
Passwords, tokens, and other sensitive data are not included in the backup. disabled={busy}
/>
<Button
text={busy ? "Please wait\u2026" : "Download"}
onClick={promptDownload}
disabled={busy}
/>
</div>
{#if statusMessage}
<p class="text-[11px] text-emerald-600 dark:text-emerald-400">{statusMessage}</p>
{/if}
{#if statusError}
<p class="text-[11px] text-red-600 dark:text-red-400">{statusError}</p>
{/if}
<p class="text-[10px] text-zinc-400 dark:text-zinc-500">
Passwords and tokens are never synced.
<a <a
href="https://betterseqta.org/privacy" href="https://betterseqta.org/privacy"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="ml-0.5 inline font-medium text-emerald-600 underline decoration-emerald-600/50 underline-offset-2 transition-all duration-200 hover:text-emerald-700 hover:decoration-emerald-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-emerald-400 dark:decoration-emerald-400/50 dark:hover:text-emerald-300 dark:focus-visible:ring-offset-zinc-800 rounded-sm" 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</a>
Privacy policy
</a>
</p> </p>
</div> </div>
<div class="mt-2 flex flex-wrap gap-2">
<Button
text={busy ? "Please wait…" : "Upload to cloud"}
onClick={upload}
disabled={busy || !cloudState.isLoggedIn}
/>
<Button
text={busy ? "Please wait…" : "Download from cloud"}
onClick={promptDownload}
disabled={busy || !cloudState.isLoggedIn}
/>
</div>
{#if !cloudState.isLoggedIn}
<p class="mt-2 text-[11px] text-zinc-500 dark:text-zinc-400">
Sign in from the BetterSEQTA Cloud header above to sync settings.
</p>
{/if}
{#if statusMessage}
<p class="mt-2 text-[11px] text-emerald-600 dark:text-emerald-400">{statusMessage}</p>
{/if}
{#if statusError}
<p class="mt-2 text-[11px] text-red-600 dark:text-red-400">{statusError}</p>
{/if}
{#if lastUploadAt || lastDownloadAt}
<p class="mt-1 text-[10px] text-zinc-400 dark:text-zinc-500">
{#if lastUploadAt}<span>Last upload: {lastUploadAt}</span>{/if}
{#if lastUploadAt && lastDownloadAt}<span class="mx-1">·</span>{/if}
{#if lastDownloadAt}<span>Last download: {lastDownloadAt}</span>{/if}
</p>
{/if}
</div>
{#if showRestoreConfirm}
<DisclaimerModal
title="Restore from cloud?"
message="This will replace BetterSEQTA+ settings in this browser with your cloud backup. Your BetterSEQTA Cloud sign-in on this device will be kept. Continue?"
onConfirm={confirmDownload}
onCancel={() => (showRestoreConfirm = false)}
/>
{/if} {/if}
@@ -3,7 +3,6 @@
import { animate } from "motion"; import { animate } from "motion";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { cloudAuth } from "@/seqta/utils/CloudAuth"; import { cloudAuth } from "@/seqta/utils/CloudAuth";
import CloudLoginForm from "@/interface/components/store/CloudLoginForm.svelte";
let { onClose } = $props<{ onClose: () => void }>(); let { onClose } = $props<{ onClose: () => void }>();
let modalElement: HTMLElement; let modalElement: HTMLElement;
@@ -23,6 +22,10 @@
); );
} }
}); });
async function handleSignIn() {
await cloudAuth.startLogin();
}
</script> </script>
<div <div
@@ -52,7 +55,16 @@
Sign in to the Theme Store to save favorites across devices, or create an account to get started. Sign in to the Theme Store to save favorites across devices, or create an account to get started.
</p> </p>
<CloudLoginForm compact onSuccess={onClose} /> <button
type="button"
onclick={handleSignIn}
class="w-full px-4 py-2.5 text-sm font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
Sign in with BetterSEQTA Cloud
</button>
<p class="mt-2 text-xs text-center text-zinc-400 dark:text-zinc-500">
Opens accounts.betterseqta.org in a new tab
</p>
<div class="flex justify-end mt-4"> <div class="flex justify-end mt-4">
<button <button
@@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { cloudAuth } from "@/seqta/utils/CloudAuth"; import { cloudAuth } from "@/seqta/utils/CloudAuth";
import CloudLoginForm from "./CloudLoginForm.svelte";
let { alwaysShowUserName = false } = $props<{ let { alwaysShowUserName = false, onClick = undefined } = $props<{
/** When true (e.g. narrow extension popup), show display name below sm breakpoint */
alwaysShowUserName?: boolean; alwaysShowUserName?: boolean;
onClick?: () => void;
}>(); }>();
let cloudState = $state(cloudAuth.state); let cloudState = $state(cloudAuth.state);
@@ -42,6 +41,19 @@
open = false; open = false;
} }
async function handleSignIn() {
await cloudAuth.startLogin();
open = false;
}
function handleButtonClick() {
if (onClick) {
onClick();
} else {
open = !open;
}
}
function getInitials(): string { function getInitials(): string {
const u = cloudState.user; const u = cloudState.user;
if (!u) return "?"; if (!u) return "?";
@@ -55,35 +67,35 @@
<div class="relative flex items-center" bind:this={dropdownEl}> <div class="relative flex items-center" bind:this={dropdownEl}>
<button <button
type="button" type="button"
onclick={() => (open = !open)} onclick={handleButtonClick}
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-zinc-100/80 dark:bg-zinc-700/80 hover:bg-zinc-200/80 dark:hover:bg-zinc-600/80 transition-colors duration-200 text-base font-medium text-zinc-900 dark:text-white" class="flex items-center gap-2 px-3 py-1.5 text-[0.75rem] rounded-lg shadow-2xl border dark:bg-[#38373D]/50 bg-[#DDDDDD]/50 border-[#DDDDDD]/30 dark:border-[#38373D]/30 dark:text-white transition-colors duration-200"
> >
{#if cloudState.isLoggedIn} {#if cloudState.isLoggedIn}
{#if cloudState.user?.pfpUrl} {#if cloudState.user?.pfpUrl}
<img <img
src={cloudState.user.pfpUrl} src={cloudState.user.pfpUrl}
alt="" alt=""
class="w-8 h-8 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600" class="w-5 h-5 rounded-full object-cover ring-1 ring-zinc-200 dark:ring-zinc-600"
/> />
{:else} {:else}
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-sm"> <div class="flex items-center justify-center w-5 h-5 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-[0.6rem]">
{getInitials()} {getInitials()}
</div> </div>
{/if} {/if}
<span <span
class={alwaysShowUserName class={alwaysShowUserName
? "inline max-w-[10rem] truncate text-sm" ? "inline max-w-[10rem] truncate text-[0.75rem]"
: "hidden max-w-24 truncate sm:inline text-base"} : "hidden max-w-24 truncate sm:inline text-[0.75rem]"}
> >
{cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"} {cloudState.user?.displayName || cloudState.user?.username || cloudState.user?.email || "User"}
</span> </span>
{:else} {:else}
<span class="text-xl font-IconFamily" aria-hidden="true">{'\ued53'}</span> <span class="text-sm font-IconFamily" aria-hidden="true">{'\ued53'}</span>
<span class="text-base font-medium">Sign in</span> <span class="text-[0.75rem]">Sign in</span>
{/if} {/if}
</button> </button>
{#if open} {#if !onClick && open}
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
@@ -127,11 +139,21 @@
</button> </button>
</div> </div>
{:else} {:else}
<CloudLoginForm <div class="flex flex-col gap-3">
onSuccess={() => { <p class="text-sm text-zinc-600 dark:text-zinc-400">
open = false; Sign in to sync favorites across devices.
}} </p>
/> <button
type="button"
onclick={handleSignIn}
class="w-full px-4 py-3 text-base font-medium rounded-lg bg-zinc-800 dark:bg-zinc-200 text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors duration-200"
>
Sign in with BetterSEQTA Cloud
</button>
<p class="text-xs text-center text-zinc-400 dark:text-zinc-500">
Opens accounts.betterseqta.org in a new tab
</p>
</div>
{/if} {/if}
</div> </div>
</div> </div>
+23 -24
View File
@@ -15,14 +15,16 @@
//import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup"; //import { OpenMinecraftServerPopup } from "@/seqta/utils/Openers/OpenMinecraftServerPopup";
import ColourPicker from "../components/ColourPicker.svelte"; import ColourPicker from "../components/ColourPicker.svelte";
import CloudPanel from "../components/CloudPanel.svelte";
import DisclaimerModal from "../components/DisclaimerModal.svelte"; import DisclaimerModal from "../components/DisclaimerModal.svelte";
import CloudHeader from "@/interface/components/store/CloudHeader.svelte";
import { settingsPopup } from "../hooks/SettingsPopup"; import { settingsPopup } from "../hooks/SettingsPopup";
let devModeSequence = ""; let devModeSequence = "";
let settingsActiveTab = $state(0); let settingsActiveTab = $state(0);
let showDisclaimerModal = $state(false); let showDisclaimerModal = $state(false);
let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null); let disclaimerCallbacks = $state<{ onConfirm: () => void, onCancel: () => void } | null>(null);
let disclaimerTitle = $state("Confirm");
let disclaimerMessage = $state("");
const handleDevModeToggle = () => { const handleDevModeToggle = () => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
@@ -67,15 +69,23 @@
let { standalone } = $props<{ standalone?: boolean }>(); let { standalone } = $props<{ standalone?: boolean }>();
let showColourPicker = $state<boolean>(false); let showColourPicker = $state<boolean>(false);
let showCloudPanel = $state<boolean>(false);
const showDisclaimer = (onConfirm: () => void, onCancel: () => void) => { const openCloudPanel = () => {
showCloudPanel = true;
};
const showDisclaimer = (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => {
disclaimerCallbacks = { onConfirm, onCancel }; disclaimerCallbacks = { onConfirm, onCancel };
disclaimerTitle = title ?? "Confirm";
disclaimerMessage = message ?? "";
showDisclaimerModal = true; showDisclaimerModal = true;
}; };
onMount(() => { onMount(() => {
settingsPopup.addListener(() => { settingsPopup.addListener(() => {
showColourPicker = false; showColourPicker = false;
showCloudPanel = false;
}); });
if (standalone) { if (standalone) {
@@ -277,25 +287,13 @@
{/if} {/if}
</div> </div>
<div
class="flex shrink-0 items-center justify-between gap-2 px-4 py-2.5 border-b border-zinc-200/40 dark:border-zinc-700/40"
>
<div class="min-w-0 flex-1">
<h2 class="text-sm font-bold text-zinc-900 dark:text-white">BetterSEQTA Cloud</h2>
<p class="text-xs text-zinc-500 dark:text-zinc-400">Account & sync</p>
</div>
<div class="shrink-0">
<CloudHeader alwaysShowUserName />
</div>
</div>
<TabbedContainer <TabbedContainer
bind:activeTab={settingsActiveTab} bind:activeTab={settingsActiveTab}
tabs={[ tabs={[
{ {
title: "Settings", title: "Settings",
Content: Settings, Content: Settings,
props: { showColourPicker: openColourPicker, showDisclaimer }, props: { showColourPicker: openColourPicker, showDisclaimer, showCloudPanel: openCloudPanel },
}, },
{ title: "Shortcuts", Content: Shortcuts }, { title: "Shortcuts", Content: Shortcuts },
{ title: "Themes", Content: Theme }, { title: "Themes", Content: Theme },
@@ -310,19 +308,20 @@
}} }}
/> />
{/if} {/if}
{#if showCloudPanel}
<CloudPanel
hidePanel={() => {
showCloudPanel = false;
}}
/>
{/if}
</div> </div>
{#if showDisclaimerModal && disclaimerCallbacks} {#if showDisclaimerModal && disclaimerCallbacks}
<DisclaimerModal <DisclaimerModal
title="Assessment Averages Disclaimer" title={disclaimerTitle}
message="This feature calculates a simple average of your assessment grades. It does not take into account: message={disclaimerMessage}
• Assessment weightings
• Different grading scales
• Other factors used in official reports
The displayed average may be inaccurate compared to your actual marks found in reports.
Do you want to enable this feature?"
onConfirm={() => { onConfirm={() => {
disclaimerCallbacks?.onConfirm(); disclaimerCallbacks?.onConfirm();
showDisclaimerModal = false; showDisclaimerModal = false;
+37 -13
View File
@@ -12,6 +12,8 @@
import PickerSwatch from "@/interface/components/PickerSwatch.svelte" import PickerSwatch from "@/interface/components/PickerSwatch.svelte"
import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte" import ConnectMobileApp from "@/interface/components/ConnectMobileApp.svelte"
import CloudSettingsSync from "@/interface/components/CloudSettingsSync.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 { showPrivacyNotification } from "@/seqta/utils/Openers/OpenPrivacyNotification"
import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup" import { closeExtensionPopup } from "@/seqta/utils/Closers/closeExtensionPopup"
import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync" import { getSnapshotForUpload } from "@/seqta/utils/cloudSettingsSync"
@@ -54,6 +56,12 @@
const pluginSettings = getAllPluginSettings() as Plugin[]; const pluginSettings = getAllPluginSettings() as Plugin[];
const pluginSettingsValues = $state<Record<string, Record<string, any>>>({}); const pluginSettingsValues = $state<Record<string, Record<string, any>>>({});
let cloudState = $state(cloudAuth.state);
$effect(() => {
const unsub = cloudAuth.subscribe((s) => { cloudState = s; });
return unsub;
});
async function loadPluginSettings() { async function loadPluginSettings() {
for (const plugin of pluginSettings) { for (const plugin of pluginSettings) {
if (Object.keys(plugin.settings).length === 0) continue; if (Object.keys(plugin.settings).length === 0) continue;
@@ -95,9 +103,10 @@
loadPluginSettings(); loadPluginSettings();
}) })
const { showColourPicker, showDisclaimer } = $props<{ const { showColourPicker, showDisclaimer, showCloudPanel } = $props<{
showColourPicker: () => void; showColourPicker: () => void;
showDisclaimer: (onConfirm: () => void, onCancel: () => void) => void; showDisclaimer: (onConfirm: () => void, onCancel: () => void, title?: string, message?: string) => void;
showCloudPanel: () => void;
}>(); }>();
async function exportCloudSettingsJsonToFile() { async function exportCloudSettingsJsonToFile() {
@@ -196,12 +205,11 @@
}, },
{ {
title: "Default Page", title: "Default Page",
description: description: "Choose which page loads first when you open SEQTA",
"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.",
id: 10, id: 10,
Component: Select, Component: Select,
props: { props: {
state: $settingsState.defaultPage, state: $settingsState.defaultPage ?? "home",
onChange: (value: string) => (settingsState.defaultPage = value), onChange: (value: string) => (settingsState.defaultPage = value),
options: [ options: [
{ value: "home", label: "Home" }, { value: "home", label: "Home" },
@@ -310,8 +318,9 @@
async () => { async () => {
await updatePluginSetting(plugin.pluginId, 'enabled', true); 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; return;
} }
@@ -324,8 +333,8 @@
{#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)} {#if !((plugin as any).disableToggle) || (pluginSettingsValues[plugin.pluginId]?.enabled ?? true)}
{#each Object.entries(plugin.settings) as [key, setting]} {#each Object.entries(plugin.settings) as [key, setting]}
<!-- Skip the 'enabled' setting if it's part of the settings object --> <!-- Skip the 'enabled' setting and hide cloud-only settings when not signed in -->
{#if key !== 'enabled'} {#if key !== 'enabled' && !(key === 'useCloudPfp' && !cloudState.isLoggedIn)}
<div class="flex justify-between items-center px-4 py-3"> <div class="flex justify-between items-center px-4 py-3">
<div class="pr-4"> <div class="pr-4">
<h2 class="text-sm font-bold">{setting.title || key}</h2> <h2 class="text-sm font-bold">{setting.title || key}</h2>
@@ -388,6 +397,25 @@
</div> </div>
{/each} {/each}
<div class="border-none">
<div class="p-1 my-1 from-white to-zinc-100 bg-gradient-to-br rounded-xl border shadow-sm border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
<div class="flex justify-between items-center px-4 py-3">
<div class="pr-4">
<h2 class="text-sm font-bold">BetterSEQTA Cloud</h2>
<p class="text-xs">Account & sync</p>
</div>
<div>
<CloudHeader alwaysShowUserName onClick={showCloudPanel} />
</div>
</div>
{#if cloudState.isLoggedIn}
<div class="px-3 pb-3">
<CloudSettingsSync showDisclaimer={(onConfirm, onCancel) => showDisclaimer(onConfirm, onCancel, "Restore from cloud?", "This will replace your local settings with the cloud backup. Continue?")} />
</div>
{/if}
</div>
</div>
<div class="p-1 border-none"></div> <div class="p-1 border-none"></div>
{@render Setting({ {@render Setting({
@@ -401,10 +429,6 @@
} }
})} })}
<div class="border-none py-3">
<CloudSettingsSync />
</div>
{#if $settingsState.devMode} {#if $settingsState.devMode}
<div class="flex-col p-1 my-1 bg-gradient-to-br from-white rounded-xl border shadow-sm to-zinc-100 border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40"> <div class="flex-col p-1 my-1 bg-gradient-to-br from-white rounded-xl border shadow-sm to-zinc-100 border-zinc-200/50 dark:border-zinc-700/40 dark:to-zinc-900/50 dark:from-zinc-900/40">
<div class="flex justify-between items-center px-4 py-3"> <div class="flex justify-between items-center px-4 py-3">
+1 -2
View File
@@ -14,8 +14,7 @@ const settings = defineSettings({
useCloudPfp: booleanSetting({ useCloudPfp: booleanSetting({
default: false, default: false,
title: "Use BetterSEQTA Cloud profile picture", title: "Use BetterSEQTA Cloud profile picture",
description: description: "Use your cloud account avatar instead of the uploaded image below",
"When enabled, uses the avatar from your BetterSEQTA Cloud account (sign in from the extension store). Otherwise uses the uploaded image below.",
}), }),
picture: componentSetting({ picture: componentSetting({
title: "Profile Picture", title: "Profile Picture",
+20
View File
@@ -127,6 +127,26 @@ class CloudAuthService {
return clientId; 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( public async login(
login: string, login: string,
password: string password: string
+77 -10
View File
@@ -102,11 +102,12 @@ export function buildUploadPayload(all: Record<string, unknown>): {
schemaVersion: number; schemaVersion: number;
data: Record<string, unknown>; data: Record<string, unknown>;
} { } {
const data: Record<string, unknown> = {}; const filtered: Record<string, unknown> = {};
for (const [k, v] of Object.entries(all)) { for (const [k, v] of Object.entries(all)) {
if (shouldOmitKeyFromCloudPayload(k)) continue; if (shouldOmitKeyFromCloudPayload(k)) continue;
data[k] = v; filtered[k] = v;
} }
const data = migrateLegacyToPluginSettings(filtered);
return { schemaVersion: CLOUD_SETTINGS_SYNC_SCHEMA_VERSION, data }; 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 * Migrate legacy storage keys to plugin settings format.
* and device-only sensitive caches, which are preserved from the current device. * 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<string, unknown>): Record<string, unknown> {
const result = { ...data };
function ensurePluginSettings(pluginId: string): Record<string, unknown> {
const key = `plugin.${pluginId}.settings`;
if (!result[key] || typeof result[key] !== "object") {
result[key] = {};
}
return result[key] as Record<string, unknown>;
}
// 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<void> { export async function applyDownloadedEnvelope(envelope: unknown): Promise<void> {
let remoteFlat: Record<string, unknown>; let remoteFlat: Record<string, unknown>;
@@ -145,10 +215,7 @@ export async function applyDownloadedEnvelope(envelope: unknown): Promise<void>
throw new Error("Invalid cloud settings payload"); throw new Error("Invalid cloud settings payload");
} }
const local = await browser.storage.local.get(); const migrated = migrateLegacyToPluginSettings(remoteFlat);
const preserved = collectLocalKeysToPreserve(local); const remoteSanitized = stripExcludedKeysFromRemoteData(migrated);
const remoteSanitized = stripExcludedKeysFromRemoteData(remoteFlat); await browser.storage.local.set(remoteSanitized);
await browser.storage.local.clear();
await browser.storage.local.set({ ...remoteSanitized, ...preserved });
} }