diff --git a/src/background.ts b/src/background.ts index 0aa331b8..04da8de2 100644 --- a/src/background.ts +++ b/src/background.ts @@ -14,52 +14,203 @@ function reloadSeqtaPages() { result.then(open, console.error); } -// @ts-ignore -browser.runtime.onMessage.addListener( - (request: any, _: any, sendResponse: (response?: any) => void) => { - switch (request.type) { - case "reloadTabs": - reloadSeqtaPages(); - break; +/** Callback for sending a response back to the message sender */ +type MessageSender = { (response?: unknown): void }; - case "extensionPages": - browser.tabs.query({}).then(function (tabs) { - for (let tab of tabs) { - if (tab.url?.includes("chrome-extension://")) { - browser.tabs.sendMessage(tab.id!, request); - } - } +function handleFetchThemes(request: any, sendResponse: MessageSender): boolean { + const { token } = request; + const apiUrl = `https://betterseqta.org/api/themes?type=betterseqta&limit=100&nocache=${Date.now()}`; + const githubUrl = `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${Date.now()}`; + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + fetch(apiUrl, { cache: "no-store", headers }) + .then((r) => r.json()) + .then(sendResponse) + .catch((err) => { + console.warn("[Background] fetchThemes API failed, trying GitHub fallback:", err?.message); + fetch(githubUrl, { cache: "no-store" }) + .then((r) => r.json()) + .then((data) => sendResponse({ success: true, data: { themes: data.themes ?? [] } })) + .catch((fallbackErr) => { + console.error("[Background] fetchThemes GitHub fallback error:", fallbackErr); + sendResponse({ success: false, error: fallbackErr?.message }); }); - break; + }); + return true; +} - case "currentTab": - browser.tabs - .query({ active: true, currentWindow: true }) - .then(function (tabs) { - browser.tabs - .sendMessage(tabs[0].id!, request) - .then(function (response) { - sendResponse(response); - }); - }); - return true; +function handleFetchThemeDetails(request: any, sendResponse: MessageSender): boolean { + const { themeId, token } = request; + if (!themeId || typeof themeId !== "string") { + sendResponse({ success: false, error: "Missing themeId" }); + return false; + } + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + fetch(`https://betterseqta.org/api/themes/${themeId}`, { cache: "no-store", headers }) + .then((r) => r.json()) + .then(sendResponse) + .catch((err) => { + console.error("[Background] fetchThemeDetails error:", err); + sendResponse({ success: false, error: err?.message }); + }); + return true; +} - case "githubTab": - browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" }); - break; +function handleFetchFromUrl(request: any, sendResponse: MessageSender): boolean { + const { url } = request; + if (!url || typeof url !== "string") { + sendResponse({ error: "Missing url" }); + return false; + } + fetch(url, { cache: "no-store" }) + .then((r) => r.json()) + .then((data) => sendResponse({ data })) + .catch((err) => { + console.error("[Background] fetchFromUrl error:", err); + sendResponse({ error: err?.message }); + }); + return true; +} - case "setDefaultStorage": - SetStorageValue(getDefaultValues()); - break; +async function parseJsonResponse(r: Response): Promise { + const text = await r.text(); + try { + return text ? JSON.parse(text) : {}; + } catch { + return {}; + } +} - case "sendNews": - fetchNews(request.source ?? "australia", sendResponse); - return true; +function handleCloudReserveClient(request: any, sendResponse: MessageSender): boolean { + const redirect_uri = request.redirect_uri ?? "https://accounts.betterseqta.org/auth/bsplus/callback"; + fetch("https://accounts.betterseqta.org/api/bsplus/client/reserve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ redirect_uri }), + }) + .then(async (r) => { + const data = await parseJsonResponse(r); + if (!r.ok) sendResponse({ error: data?.error ?? `Reserve failed (${r.status})` }); + else sendResponse(data); + }) + .catch((err) => { + console.error("[Background] cloudReserveClient error:", err); + sendResponse({ error: err?.message ?? "Network error" }); + }); + return true; +} - default: - console.log("Unknown request type"); +function handleCloudLogin(request: any, sendResponse: MessageSender): boolean { + const { client_id, redirect_uri, login, password } = request; + if (!client_id || !redirect_uri || !login || !password) { + sendResponse({ error: "Missing client_id, redirect_uri, login, or password" }); + return false; + } + fetch("https://accounts.betterseqta.org/api/bsplus/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ client_id, redirect_uri, login, password }), + }) + .then(async (r) => { + const data = await parseJsonResponse(r); + if (!r.ok) sendResponse({ error: data?.error ?? "Login failed" }); + else sendResponse(data); + }) + .catch((err) => { + console.error("[Background] cloudLogin error:", err); + sendResponse({ error: err?.message ?? "Network error" }); + }); + return true; +} + +function handleCloudRefresh(request: any, sendResponse: MessageSender): boolean { + const { refresh_token, client_id } = request; + if (!refresh_token || !client_id) { + sendResponse({ error: "Missing refresh_token or client_id" }); + return false; + } + fetch("https://accounts.betterseqta.org/api/bsplus/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token, client_id }), + }) + .then(async (r) => { + const data = await parseJsonResponse(r); + if (!r.ok) sendResponse({ error: data?.error ?? "Refresh failed" }); + else sendResponse(data); + }) + .catch((err) => { + console.error("[Background] cloudRefresh error:", err); + sendResponse({ error: err?.message ?? "Network error" }); + }); + return true; +} + +function handleCloudFavorite(request: any, sendResponse: MessageSender): boolean { + const { themeId, token, action } = request; + if (!themeId || !token) { + sendResponse({ success: false, error: "Theme ID and token required" }); + return false; + } + const isFavorite = action === "favorite"; + fetch(`https://betterseqta.org/api/themes/${themeId}/favorite`, { + method: isFavorite ? "POST" : "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }) + .then((r) => r.json()) + .then(sendResponse) + .catch((err) => { + console.error("[Background] cloudFavorite error:", err); + sendResponse({ success: false, error: err?.message }); + }); + return true; +} + +/** Handler for a message type; receives request and sendResponse callback */ +type MessageHandler = { (request: any, sendResponse: MessageSender): boolean | void }; + +const MESSAGE_HANDLERS: Record = { + reloadTabs: () => reloadSeqtaPages(), + extensionPages: (req) => { + browser.tabs.query({}).then((tabs) => { + for (const tab of tabs) { + if (tab.url?.includes("chrome-extension://")) browser.tabs.sendMessage(tab.id!, req); + } + }); + }, + currentTab: (req, sendResponse) => { + browser.tabs.query({ active: true, currentWindow: true }).then((tabs) => { + browser.tabs.sendMessage(tabs[0].id!, req).then(sendResponse); + }); + return true; + }, + githubTab: () => { + void browser.tabs.create({ url: "github.com/BetterSEQTA/BetterSEQTA-Plus" }); + }, + setDefaultStorage: () => SetStorageValue(getDefaultValues()), + sendNews: (req, sendResponse) => { + fetchNews(req.source ?? "australia", sendResponse); + return true; + }, + fetchThemes: handleFetchThemes, + fetchThemeDetails: handleFetchThemeDetails, + fetchFromUrl: handleFetchFromUrl, + cloudReserveClient: handleCloudReserveClient, + cloudLogin: handleCloudLogin, + cloudRefresh: handleCloudRefresh, + cloudFavorite: handleCloudFavorite, +}; + +browser.runtime.onMessage.addListener( + // @ts-ignore - OnMessageListener expects literal true for async, we return boolean + (request: any, _: any, sendResponse: MessageSender) => { + const handler = MESSAGE_HANDLERS[request.type]; + if (handler) { + const result = handler(request, sendResponse); + return result === true; } - + console.log("Unknown request type"); return false; }, ); diff --git a/src/interface/components/SignInToFavoriteModal.svelte b/src/interface/components/SignInToFavoriteModal.svelte new file mode 100644 index 00000000..24f21e1b --- /dev/null +++ b/src/interface/components/SignInToFavoriteModal.svelte @@ -0,0 +1,77 @@ + + +
{ + if (e.target === e.currentTarget) onClose(); + }} + onkeydown={(e) => { + if (e.key === "Escape") onClose(); + }} + role="button" + tabindex="-1" + transition:fade={{ duration: 150 }} +> + +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + > +

+ Sign in to favorite themes +

+ +

+ Sign in in the Theme Store to save favorites across devices, or create an account to get started. +

+ +
+ + + Create account + + +
+
+
diff --git a/src/interface/components/TabbedContainer.svelte b/src/interface/components/TabbedContainer.svelte index 43a62349..d053f890 100644 --- a/src/interface/components/TabbedContainer.svelte +++ b/src/interface/components/TabbedContainer.svelte @@ -3,8 +3,7 @@ import './TabbedContainer.css'; import { onMount } from 'svelte'; - let { tabs } = $props<{ tabs: { title: string, Content: any, props?: any }[] }>(); - let activeTab = $state(0); + let { tabs, activeTab = $bindable(0) } = $props<{ tabs: { title: string, Content: any, props?: any }[]; activeTab?: number }>(); let containerRef: HTMLElement | null = null; let tabWidth = $state(0); diff --git a/src/interface/components/store/CloudHeader.svelte b/src/interface/components/store/CloudHeader.svelte new file mode 100644 index 00000000..7804ec2a --- /dev/null +++ b/src/interface/components/store/CloudHeader.svelte @@ -0,0 +1,203 @@ + + +
+ + + {#if open} + + +
e.stopPropagation()} + > +
+

BetterSEQTA Cloud

+

Sync favorites across devices

+
+
+ {#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 favorite themes. Your favorites sync across devices when logged in. +

+
{ + e.preventDefault(); + handleLogin(); + }} + > + e.currentTarget.removeAttribute('readonly')} + class="w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200" + /> + e.currentTarget.removeAttribute('readonly')} + class="w-full px-4 py-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800 dark:text-white border border-zinc-200 dark:border-zinc-600 focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-transparent transition-colors duration-200" + /> + {#if error} +

{error}

+ {/if} + + + Create account + +
+ {/if} +
+
+ {/if} +
diff --git a/src/interface/components/store/CoverSwiper.svelte b/src/interface/components/store/CoverSwiper.svelte index 981eb4af..d5c35c1a 100644 --- a/src/interface/components/store/CoverSwiper.svelte +++ b/src/interface/components/store/CoverSwiper.svelte @@ -29,7 +29,7 @@ {#if coverThemes.length > 0}
@@ -42,7 +42,7 @@ onkeydown={(e) => { if (e.key === 'Enter') setDisplayTheme(theme) }} onclick={() => setDisplayTheme(theme)} > - Theme Preview + Theme Preview

{theme.name}

{theme.description}

diff --git a/src/interface/components/store/Header.svelte b/src/interface/components/store/Header.svelte index 720827af..4ee50781 100644 --- a/src/interface/components/store/Header.svelte +++ b/src/interface/components/store/Header.svelte @@ -3,6 +3,7 @@ import logoDark from '@/resources/icons/betterseqta-light-full.png'; import { closeStore } from '@/seqta/ui/renderStore' import browser from 'webextension-polyfill'; + import CloudHeader from './CloudHeader.svelte'; // Props let { searchTerm, setSearchTerm, darkMode, activeTab, setActiveTab } = $props<{ @@ -39,6 +40,8 @@ > Backgrounds + +
diff --git a/src/interface/components/store/ThemeCard.svelte b/src/interface/components/store/ThemeCard.svelte index 178371eb..7b28a504 100644 --- a/src/interface/components/store/ThemeCard.svelte +++ b/src/interface/components/store/ThemeCard.svelte @@ -1,19 +1,110 @@ -
-
-
- {theme.name} +
+
+ +
+ + {#if menuOpen} + + {/if} +
+
+ {theme.name} +
+ + + + + {(theme.download_count ?? 0).toLocaleString()} + + + + + + {(theme.favorite_count ?? 0).toLocaleString()} + +
- Theme Preview + Theme Preview
+ +{#if showSignInModal} + (showSignInModal = false)} /> +{/if} diff --git a/src/interface/components/store/ThemeGrid.svelte b/src/interface/components/store/ThemeGrid.svelte index 6f56120c..b3523f67 100644 --- a/src/interface/components/store/ThemeGrid.svelte +++ b/src/interface/components/store/ThemeGrid.svelte @@ -2,7 +2,13 @@ import type { Theme } from '@/interface/types/Theme' import ThemeCard from './ThemeCard.svelte'; - let { themes, searchTerm, setDisplayTheme } = $props<{ themes: Theme[]; searchTerm: string, setDisplayTheme: (theme: Theme) => void }>(); + let { themes, searchTerm, setDisplayTheme, toggleFavorite, isLoggedIn } = $props<{ + themes: Theme[]; + searchTerm: string; + setDisplayTheme: (theme: Theme) => void; + toggleFavorite: (theme: Theme) => void; + isLoggedIn: boolean; + }>(); let filteredThemes = $derived(themes.filter((theme: Theme) => theme.name.toLowerCase().includes(searchTerm.toLowerCase()) || theme.description.toLowerCase().includes(searchTerm.toLowerCase()) @@ -12,7 +18,12 @@
{#each filteredThemes as theme (theme.id)} - setDisplayTheme(theme)} /> + setDisplayTheme(theme)} + {toggleFavorite} + {isLoggedIn} + /> {/each} {#if filteredThemes.length !== 0} diff --git a/src/interface/components/store/ThemeModal.svelte b/src/interface/components/store/ThemeModal.svelte index e7752e70..5deff576 100644 --- a/src/interface/components/store/ThemeModal.svelte +++ b/src/interface/components/store/ThemeModal.svelte @@ -2,8 +2,9 @@ import type { Theme } from '@/interface/types/Theme' import { fade } from 'svelte/transition'; import { animate } from 'motion'; + import SignInToFavoriteModal from '@/interface/components/SignInToFavoriteModal.svelte'; - let { theme, currentThemes, setDisplayTheme, onInstall, onRemove, allThemes, displayTheme } = $props<{ + let { theme, currentThemes, setDisplayTheme, onInstall, onRemove, allThemes, displayTheme, toggleFavorite, isLoggedIn } = $props<{ theme: Theme | null; currentThemes: string[]; setDisplayTheme: (theme: Theme | null) => void; @@ -11,10 +12,21 @@ onRemove: (themeId: string) => void; allThemes: Theme[]; displayTheme: Theme | null; + toggleFavorite?: (theme: Theme) => void; + isLoggedIn?: boolean; }>(); let installing = $state(false); + let showSignInModal = $state(false); let modalElement: HTMLElement; + function handleFavoriteClick() { + if (isLoggedIn && toggleFavorite && theme) { + toggleFavorite(theme); + } else { + showSignInModal = true; + } + } + // Function to get related themes function getRelatedThemes() { return allThemes @@ -73,35 +85,67 @@ onkeydown={(e) => e.stopPropagation()} >
- -

+
+ +
+

{theme.name}

- Theme Cover +
+ + + + + {(theme.download_count ?? 0).toLocaleString()} downloads + + + + + + {(theme.favorite_count ?? 0).toLocaleString()} favorites + +
+ Theme Cover

{theme.description}

- {#if currentThemes.includes(theme.id)} - - {:else} - - {/if} + {theme.is_favorited ? 'Favorited' : 'Favorite'} + + {/if} + {#if currentThemes.includes(theme.id)} + + {:else} + + {/if} +
@@ -116,7 +160,7 @@ {relatedTheme.name}
- Theme Preview + Theme Preview
{/each} @@ -124,3 +168,7 @@
+ +{#if showSignInModal} + (showSignInModal = false)} /> +{/if} diff --git a/src/interface/components/themes/ThemeSelector.svelte b/src/interface/components/themes/ThemeSelector.svelte index e8606c6f..80be9e8d 100644 --- a/src/interface/components/themes/ThemeSelector.svelte +++ b/src/interface/components/themes/ThemeSelector.svelte @@ -1,11 +1,14 @@ @@ -275,6 +277,7 @@
{ cloudLoggedIn = s.isLoggedIn; }); // State variables let searchTerm = $state(''); @@ -48,20 +52,57 @@ activeTab = tab; }; - // Fetch themes and initialize app + const toggleFavorite = async (theme: Theme) => { + const token = await cloudAuth.getStoredToken(); + if (!token) return; + const isFavorite = !theme.is_favorited; + const result = (await browser.runtime.sendMessage({ + type: 'cloudFavorite', + themeId: theme.id, + token, + action: isFavorite ? 'favorite' : 'unfavorite', + })) as { success?: boolean }; + if (result?.success) { + const delta = isFavorite ? 1 : -1; + themes = themes.map((t) => + t.id === theme.id + ? { ...t, is_favorited: isFavorite, favorite_count: Math.max(0, (t.favorite_count ?? 0) + delta) } + : t + ); + if (displayTheme?.id === theme.id) { + displayTheme = { + ...displayTheme, + is_favorited: isFavorite, + favorite_count: Math.max(0, (displayTheme.favorite_count ?? 0) + delta), + }; + } + } + }; + + // Fetch themes via background script (avoids CORS when store runs inside SEQTA page) const fetchThemes = async () => { try { - const response = await fetch(`https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes.json?nocache=${(new Date()).getTime()}`, { cache: 'no-store' }); - const data = await response.json(); - themes = data.themes; + const token = await cloudAuth.getStoredToken(); + const data = (await browser.runtime.sendMessage({ + type: 'fetchThemes', + token: token ?? undefined, + })) as { + success?: boolean; + data?: { themes: Theme[] }; + error?: string; + }; + if (!data?.success || !data?.data?.themes) { + throw new Error(data?.error || 'Failed to fetch themes'); + } + themes = data.data.themes; // Shuffle for cover themes const shuffled = [...themes].sort(() => 0.5 - Math.random()); coverThemes = shuffled.slice(0, 3); loading = false; - } catch (error) { - console.error('Failed to fetch themes', error); + } catch (err) { + console.error('Failed to fetch themes', err); setTimeout(fetchThemes, 5000); // Retry after 5 seconds if failure occurs } }; @@ -91,6 +132,17 @@ console.error(error); } }); + + // Refetch themes when user logs in (from another tab) to get is_favorited + let lastLoggedIn = $state(false); + $effect(() => { + if (cloudLoggedIn && !lastLoggedIn) { + lastLoggedIn = true; + fetchThemes(); + } else if (!cloudLoggedIn) { + lastLoggedIn = false; + } + });
@@ -111,7 +163,13 @@ {/if} - + {#if displayTheme} { if (displayTheme) { await themeManager.downloadTheme(displayTheme); diff --git a/src/interface/types/Theme.ts b/src/interface/types/Theme.ts index b68a5ba1..28fb7918 100644 --- a/src/interface/types/Theme.ts +++ b/src/interface/types/Theme.ts @@ -1,7 +1,11 @@ export type Theme = { + id: string; name: string; description: string; coverImage: string; - marqueeImage: string; - id: string; + marqueeImage?: string; + theme_json_url?: string; + is_favorited?: boolean; + favorite_count?: number; + download_count?: number; }; diff --git a/src/manifests/manifest.json b/src/manifests/manifest.json index 92736dce..40091d86 100644 --- a/src/manifests/manifest.json +++ b/src/manifests/manifest.json @@ -16,12 +16,12 @@ } }, "permissions": ["tabs", "notifications", "storage"], - "host_permissions": ["https://newsapi.org/", "*://*/*"], + "host_permissions": ["https://newsapi.org/", "https://betterseqta.org/", "https://accounts.betterseqta.org/", "*://*/*"], "background": { "service_worker": "background.ts" }, "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self'" + "extension_pages": "script-src 'self'; object-src 'self'; connect-src 'self' https://betterseqta.org https://accounts.betterseqta.org https://raw.githubusercontent.com" }, "content_scripts": [ { diff --git a/src/plugins/built-in/themes/theme-manager.ts b/src/plugins/built-in/themes/theme-manager.ts index 69e6ff23..488e376b 100644 --- a/src/plugins/built-in/themes/theme-manager.ts +++ b/src/plugins/built-in/themes/theme-manager.ts @@ -1,4 +1,5 @@ import localforage from "localforage"; +import browser from "webextension-polyfill"; import type { CustomTheme, LoadedCustomTheme } from "@/types/CustomThemes"; import { settingsState } from "@/seqta/utils/listeners/SettingsState"; import debounce from "@/seqta/utils/debounce"; @@ -470,23 +471,53 @@ export class ThemeManager { } } + private readonly THEME_API_BASE = 'https://betterseqta.org/api'; + private readonly GITHUB_THEMES_BASE = 'https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes'; + /** - * Download and install a theme from the store + * Fetch JSON from a URL via background script (avoids CORS when running inside SEQTA page) + */ + private async fetchFromUrl(url: string): Promise { + const result = (await browser.runtime.sendMessage({ + type: 'fetchFromUrl', + url, + })) as { data?: unknown; error?: string }; + if (result?.error) throw new Error(result.error); + return result?.data; + } + + /** + * Download and install a theme from the store. + * Uses API first (increments download_count), falls back to GitHub if unreachable. */ public async downloadTheme(themeContent: { id: string; name: string; - description: string; - coverImage: string; + description?: string; + coverImage?: string; + theme_json_url?: string; }): Promise { console.debug("[ThemeManager] Downloading theme:", themeContent.name); try { if (!themeContent.id) return; - const response = await fetch( - `https://raw.githubusercontent.com/BetterSEQTA/BetterSEQTA-Themes/main/store/themes/${themeContent.id}/theme.json`, - ); - const themeData = (await response.json()) as ThemeContent; + let themeData: ThemeContent; + + try { + // Try API first (increments download_count) + const downloadData = (await this.fetchFromUrl( + `${this.THEME_API_BASE}/themes/${themeContent.id}/download` + )) as { success?: boolean; data?: { theme_json_url: string } }; + if (!downloadData?.success || !downloadData?.data?.theme_json_url) { + throw new Error("Failed to get theme download URL"); + } + themeData = (await this.fetchFromUrl(downloadData.data.theme_json_url)) as ThemeContent; + } catch (apiError) { + // Fallback to GitHub if API is unreachable + console.warn("[ThemeManager] API failed, trying GitHub fallback:", apiError); + const githubUrl = `${this.GITHUB_THEMES_BASE}/${themeContent.id}/theme.json`; + themeData = (await this.fetchFromUrl(githubUrl)) as ThemeContent; + } await this.installTheme(themeData); } catch (error) { diff --git a/src/seqta/utils/CloudAuth.ts b/src/seqta/utils/CloudAuth.ts new file mode 100644 index 00000000..ff4f15f8 --- /dev/null +++ b/src/seqta/utils/CloudAuth.ts @@ -0,0 +1,199 @@ +import browser from "webextension-polyfill"; +import { settingsState } from "@/seqta/utils/listeners/SettingsState"; + +const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback"; + +const STORAGE_KEYS = { + clientId: "bsplus_client_id", + accessToken: "bsplus_token", + refreshToken: "bsplus_refresh_token", + user: "bsplus_user", +} as const; + +export type CloudUser = { + id: string; + email?: string; + username?: string; + displayName?: string; + pfpUrl?: string; + admin_level?: number; +}; + +export type CloudAuthState = { + isLoggedIn: boolean; + user: CloudUser | null; +}; + +/** Callback invoked when auth state changes */ +type Listener = { (state: CloudAuthState): void }; + +class CloudAuthService { + private static instance: CloudAuthService; + private listeners = new Set(); + private _state: CloudAuthState = { isLoggedIn: false, user: null }; + + private constructor() { + void this.loadFromStorage(); + browser.storage.onChanged.addListener((changes, areaName) => { + if ( + areaName === "local" && + (changes[STORAGE_KEYS.accessToken] || + changes[STORAGE_KEYS.user] || + changes[STORAGE_KEYS.clientId]) + ) { + void this.loadFromStorage(); + } + }); + } + + public static getInstance(): CloudAuthService { + if (!CloudAuthService.instance) { + CloudAuthService.instance = new CloudAuthService(); + } + return CloudAuthService.instance; + } + + public get state(): CloudAuthState { + return this._state; + } + + public subscribe(listener: Listener): () => void { + this.listeners.add(listener); + listener(this._state); + return () => this.listeners.delete(listener); + } + + private async loadFromStorage(): Promise { + const result = await browser.storage.local.get([ + STORAGE_KEYS.accessToken, + STORAGE_KEYS.user, + ]); + const token = result[STORAGE_KEYS.accessToken] as string | undefined; + const user = result[STORAGE_KEYS.user] as CloudUser | undefined; + this._state = { + isLoggedIn: !!token, + user: user ?? null, + }; + this.notify(); + } + + private notify(): void { + for (const listener of this.listeners) { + listener(this._state); + } + } + + public async getStoredToken(): Promise { + const result = await browser.storage.local.get(STORAGE_KEYS.accessToken); + return (result[STORAGE_KEYS.accessToken] as string) ?? null; + } + + private async getClientId(): Promise { + let clientId = (settingsState as any)[STORAGE_KEYS.clientId] as string | undefined; + if (!clientId) { + const stored = await browser.storage.local.get(STORAGE_KEYS.clientId); + clientId = stored[STORAGE_KEYS.clientId] as string | undefined; + } + if (!clientId) { + const reserveResult = (await browser.runtime.sendMessage({ + type: "cloudReserveClient", + redirect_uri: REDIRECT_URI, + })) as { client_id?: string; error?: string }; + if (!reserveResult?.client_id) { + throw new Error(reserveResult?.error ?? "Failed to reserve client"); + } + clientId = reserveResult.client_id; + (settingsState as any).setKey(STORAGE_KEYS.clientId, clientId); + } + return clientId; + } + + public async login( + login: string, + password: string + ): Promise<{ success: boolean; error?: string }> { + try { + const clientId = await this.getClientId(); + const result = (await browser.runtime.sendMessage({ + type: "cloudLogin", + client_id: clientId, + redirect_uri: REDIRECT_URI, + login: login.trim(), + password, + })) as { + access_token?: string; + refresh_token?: string; + user?: CloudUser; + error?: string; + }; + if (result?.access_token && result?.refresh_token) { + (settingsState as any).setKey(STORAGE_KEYS.accessToken, result.access_token); + (settingsState as any).setKey(STORAGE_KEYS.refreshToken, result.refresh_token); + (settingsState as any).setKey(STORAGE_KEYS.user, result.user ?? null); + this._state = { + isLoggedIn: true, + user: result.user ?? null, + }; + this.notify(); + return { success: true }; + } + return { + success: false, + error: result?.error ?? "Login failed", + }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Login failed", + }; + } + } + + public async logout(): Promise { + await browser.storage.local.remove([ + STORAGE_KEYS.accessToken, + STORAGE_KEYS.refreshToken, + STORAGE_KEYS.user, + "cloudAccessToken", + "cloudUsername", + ]); + this._state = { isLoggedIn: false, user: null }; + this.notify(); + } + + public async refreshToken(): Promise { + const result = await browser.storage.local.get([ + STORAGE_KEYS.refreshToken, + STORAGE_KEYS.clientId, + ]); + const refreshToken = result[STORAGE_KEYS.refreshToken] as string | undefined; + const clientId = result[STORAGE_KEYS.clientId] as string | undefined; + if (!refreshToken || !clientId) return false; + + const refreshResult = (await browser.runtime.sendMessage({ + type: "cloudRefresh", + refresh_token: refreshToken, + client_id: clientId, + })) as { + access_token?: string; + refresh_token?: string; + user?: CloudUser; + error?: string; + }; + + if (refreshResult?.access_token && refreshResult?.refresh_token) { + (settingsState as any).setKey(STORAGE_KEYS.accessToken, refreshResult.access_token); + (settingsState as any).setKey(STORAGE_KEYS.refreshToken, refreshResult.refresh_token); + (settingsState as any).setKey(STORAGE_KEYS.user, refreshResult.user ?? null); + this._state = { + isLoggedIn: true, + user: refreshResult.user ?? null, + }; + this.notify(); + return true; + } + return false; + } +} + +export const cloudAuth = CloudAuthService.getInstance(); diff --git a/src/types/storage.ts b/src/types/storage.ts index d25cc192..92ee9e5f 100644 --- a/src/types/storage.ts +++ b/src/types/storage.ts @@ -47,6 +47,12 @@ export interface SettingsState { lettergrade: boolean; assessmentsAverage?: boolean; notificationCollector?: boolean; + + // BetterSEQTA Cloud (accounts.betterseqta.org) + bsplus_client_id?: string; + bsplus_token?: string; + bsplus_refresh_token?: string; + bsplus_user?: { id: string; email?: string; username?: string; displayName?: string; pfpUrl?: string; admin_level?: number }; } interface ToggleItem {