diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1d334744..0add09a9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,16 +6,15 @@ on: jobs: build: runs-on: ubuntu-latest - container: - image: 'archlinux:latest' permissions: write-all steps: - name: Checkout code uses: actions/checkout@v2 - - name: Install packages - run: pacman -Sy pnpm nodejs npm chromium icu --noconfirm + - uses: pnpm/action-setup@v4 + with: + version: 9 - name: Install dependencies run: | diff --git a/src/convex/_generated/api.d.ts b/src/convex/_generated/api.d.ts index bf758f5d..19187b5f 100644 --- a/src/convex/_generated/api.d.ts +++ b/src/convex/_generated/api.d.ts @@ -14,6 +14,9 @@ import type { FunctionReference, } from "convex/server"; import type * as backups from "../backups.js"; +import type * as sync from "../sync.js"; +import type * as types from "../types.js"; +import type * as utils from "../utils.js"; /** * A utility for referencing Convex functions in your app's API. @@ -25,6 +28,9 @@ import type * as backups from "../backups.js"; */ declare const fullApi: ApiFromModules<{ backups: typeof backups; + sync: typeof sync; + types: typeof types; + utils: typeof utils; }>; export declare const api: FilterApi< typeof fullApi, diff --git a/src/convex/backups.ts b/src/convex/backups.ts index 63280174..de1c060e 100644 --- a/src/convex/backups.ts +++ b/src/convex/backups.ts @@ -1,22 +1,6 @@ import { v } from 'convex/values'; import { mutation, query } from './_generated/server'; -import * as jose from 'jose'; - -// Shared helper function to verify JWT and return payload -async function verifyJwtAndGetPayload(jwt: string) { - if (!process.env.CLERK_JWT_KEY) { - throw new Error('Missing CLERK_JWT_KEY environment variable'); - } - const publicKey = await jose.importSPKI(process.env.CLERK_JWT_KEY, 'RS256'); - if (jwt.length === 0) { - throw new Error('Missing JWT'); - } - const { payload } = await jose.jwtVerify(jwt, publicKey, {}); - if (!payload.sub) { - throw new Error('Invalid JWT'); - } - return payload; -} +import { getAndUpdateUser, getUser, verifyJwtAndGetPayload } from './utils'; export const get = query({ args: { @@ -24,11 +8,15 @@ export const get = query({ }, handler: async (ctx, args) => { const payload = await verifyJwtAndGetPayload(args.jwt); + const userInfo = await getUser(ctx, payload); + if (!userInfo) { + return []; + } const backups = await ctx.db .query('backup') .order('desc') - .filter((q) => q.eq(q.field('user'), payload.sub)) - .take(100); + .filter((q) => q.eq(q.field('user'), userInfo._id)) + .collect(); return backups.map((backup) => ({ name: backup.name, data: backup.data, @@ -49,8 +37,12 @@ export const create = mutation({ if (!payload.sub) { throw new Error('Invalid JWT: missing subject'); } + const userInfo = await getAndUpdateUser(ctx, payload); + if (!userInfo?._id) { + throw new Error('Something went wrong'); + } await ctx.db.insert('backup', { - user: payload.sub, + user: userInfo?._id, name: args.name, data: args.data }); @@ -65,9 +57,12 @@ export const remove = mutation({ handler: async (ctx, args) => { const payload = await verifyJwtAndGetPayload(args.jwt); const backup = await ctx.db.get(args.id); - if (backup?.user !== payload.sub) { + const userInfo = await getAndUpdateUser(ctx, payload); + + if (backup?.user !== userInfo?._id) { throw new Error('Unauthorized'); } + await getAndUpdateUser(ctx, payload); await ctx.db.delete(args.id); } }); diff --git a/src/convex/schema.ts b/src/convex/schema.ts index 5b2393db..b689eac1 100644 --- a/src/convex/schema.ts +++ b/src/convex/schema.ts @@ -5,11 +5,41 @@ export default defineSchema({ comments: defineTable({ body: v.string(), gmaeid: v.string(), - user: v.string() + user: v.id('users') }), backup: defineTable({ name: v.string(), data: v.string(), - user: v.string() - }) + user: v.id('users') + }), + users: defineTable({ + email: v.string(), + firstName: v.optional(v.string()), + lastName: v.optional(v.string()), + avatar: v.optional(v.string()), + username: v.string(), + verified: v.boolean(), + clerkId: v.string(), + settings: v.optional( + v.object({ + experimentalFeatures: v.boolean(), + open: v.string(), + theme: v.string(), + panic: v.object({ + enabled: v.boolean(), + key: v.string(), + url: v.string(), + disableExperimentalMode: v.boolean() + }), + cloak: v.object({ + mode: v.string(), + name: v.string(), + icon: v.string() + }), + history: v.boolean() + }) + ), + favourites: v.optional(v.array(v.string())), + history: v.optional(v.array(v.string())) + }).index('clerkid', ['clerkId']) }); diff --git a/src/convex/sync.ts b/src/convex/sync.ts new file mode 100644 index 00000000..52aa9cba --- /dev/null +++ b/src/convex/sync.ts @@ -0,0 +1,74 @@ +import { v } from 'convex/values'; +import { mutation, query } from './_generated/server'; +import { getAndUpdateUser, getUser, verifyJwtAndGetPayload } from './utils'; + +export const get = query({ + args: { + jwt: v.string() + }, + handler: async (ctx, args) => { + const payload = await verifyJwtAndGetPayload(args.jwt); + const userInfo = await getUser(ctx, payload); + if (!userInfo) { + return null; + } + return { + settings: userInfo.settings, + favourites: userInfo.favourites, + history: userInfo.history + }; + } +}); + +export const update = mutation({ + args: { + jwt: v.string(), + settings: v.optional( + v.object({ + experimentalFeatures: v.boolean(), + open: v.string(), + theme: v.string(), + panic: v.object({ + enabled: v.boolean(), + key: v.string(), + url: v.string(), + disableExperimentalMode: v.boolean() + }), + cloak: v.object({ + mode: v.string(), + name: v.string(), + icon: v.string() + }), + history: v.boolean() + }) + ), + favourites: v.optional(v.array(v.string())), + history: v.optional(v.array(v.string())) + }, + handler: async (ctx, args) => { + const payload = await verifyJwtAndGetPayload(args.jwt); + if (!payload.sub) { + throw new Error('Invalid JWT: missing subject'); + } + const userInfo = await getAndUpdateUser(ctx, payload); + if (!userInfo?._id) { + throw new Error('Something went wrong'); + } + + if (args.favourites) { + await ctx.db.patch(userInfo._id, { + favourites: args.favourites + }); + } + if (args.history) { + await ctx.db.patch(userInfo._id, { + history: args.history + }); + } + if (args.settings) { + await ctx.db.patch(userInfo._id, { + settings: args.settings + }); + } + } +}); diff --git a/src/convex/types.ts b/src/convex/types.ts new file mode 100644 index 00000000..b6a5f8e1 --- /dev/null +++ b/src/convex/types.ts @@ -0,0 +1,18 @@ +export interface JWTPayload { + email: string; + avatar: string; + lastname: string; + username: string; + verified: boolean; + firstname: string; + azp: string; + exp: number; + fva: number[]; + iat: number; + iss: string; + nbf: number; + sid: string; + sub: string; + v: string; + fea: string; +} diff --git a/src/convex/utils.ts b/src/convex/utils.ts new file mode 100644 index 00000000..77170ae2 --- /dev/null +++ b/src/convex/utils.ts @@ -0,0 +1,69 @@ +import * as jose from 'jose'; +import type { MutationCtx, QueryCtx } from './_generated/server'; +import type { JwtPayload } from 'jsonwebtoken'; + +// Shared helper function to verify JWT and return payload +export async function verifyJwtAndGetPayload(jwt: string) { + if (!process.env.CLERK_JWT_KEY) { + throw new Error('Missing CLERK_JWT_KEY environment variable'); + } + const publicKey = await jose.importSPKI(process.env.CLERK_JWT_KEY, 'RS256'); + if (jwt.length === 0) { + throw new Error('Missing JWT'); + } + const { payload } = await jose.jwtVerify(jwt, publicKey, {}); + if (!payload.sub) { + throw new Error('Invalid JWT'); + } + return payload; +} + +export async function getUser(ctx: QueryCtx, payload: JwtPayload) { + if (!payload.sub) { + throw new Error('Invalid JWT'); + } + let user = await ctx.db + .query('users') + .withIndex('clerkid', (q) => q.eq('clerkId', payload.sub || '')) + .first(); + + if (!user) { + return null; + } + + return user; +} + +export async function getAndUpdateUser(ctx: MutationCtx, payload: JwtPayload) { + if (!payload.sub) { + throw new Error('Invalid JWT'); + } + let user = await ctx.db + .query('users') + .withIndex('clerkid', (q) => q.eq('clerkId', payload.sub || '')) + .first(); + if (user) { + await ctx.db.patch(user._id, { + avatar: payload.avatar, + email: payload.email, + firstName: payload.firstname, + lastName: payload.lastname, + username: payload.username, + verified: payload.verified, + clerkId: payload.sub + }); + user = await ctx.db.get(user._id); + } else { + const userId = await ctx.db.insert('users', { + avatar: payload.avatar, + email: payload.email, + firstName: payload.firstname, + lastName: payload.firstname, + username: payload.username, + verified: payload.verified, + clerkId: payload.sub + }); + user = await ctx.db.get(userId); + } + return user; +} diff --git a/src/lib/components/app-sidebar.svelte b/src/lib/components/app-sidebar.svelte index e0d8b34e..c0fc34ed 100644 --- a/src/lib/components/app-sidebar.svelte +++ b/src/lib/components/app-sidebar.svelte @@ -253,12 +253,14 @@ {...props} > - EducationalTools/src +
EducationalTools/src
- - - {process.env.BRANCH_NAME} + + {process.env.BRANCH_NAME} + {/snippet} diff --git a/src/lib/components/providers.svelte b/src/lib/components/providers.svelte index 6fec6c5a..69858b81 100644 --- a/src/lib/components/providers.svelte +++ b/src/lib/components/providers.svelte @@ -2,6 +2,8 @@ import { ClerkProvider, GoogleOneTap } from 'svelte-clerk/client'; import { ModeWatcher } from 'mode-watcher'; import { setupConvex } from 'convex-svelte'; + import { dark } from '@clerk/themes'; + import { mode } from 'mode-watcher'; // Props let { children } = $props(); @@ -19,7 +21,10 @@ } - + {@render children()} diff --git a/src/lib/components/settings.svelte b/src/lib/components/settings.svelte index eaf2ff8e..8e16e15a 100644 --- a/src/lib/components/settings.svelte +++ b/src/lib/components/settings.svelte @@ -23,19 +23,48 @@ import Button from './ui/button/button.svelte'; import { toast } from 'svelte-sonner'; import { onMount } from 'svelte'; + import { save } from '$lib/sync'; + import { useClerkContext } from 'svelte-clerk'; + import { useConvexClient } from 'convex-svelte'; + const ctx = useClerkContext(); + const client = useConvexClient(); let distinct_id = $state('Not available') as string; + let sessionToken = $state(''); + onMount(() => { setTimeout(() => { distinct_id = posthog.get_distinct_id(); }, 1000); }); - $effect(() => { + function handleSettingsChange() { posthog.capture('settingschange', $preferencesStore); + // if (sessionToken && sessionToken !== '') { + // save(sessionToken, client, { settings: true }); + // } + } + + $effect(() => { + if (ctx.session) { + getToken().then((token) => { + sessionToken = token; + }); + } }); + async function getToken() { + const token = await ctx.session?.getToken(); + if (!token) { + if (ctx.session) { + toast.error('Something went wrong'); + } + return ''; + } + return token; + } + const themeTriggerContent = $derived( themes.find((theme) => theme.value === $preferencesStore.theme)?.label ?? 'No theme :D' ); @@ -48,7 +77,7 @@ Settings Open in - +
@@ -70,7 +99,10 @@
- + {#each themes as theme}
@@ -93,22 +125,37 @@
Panic key (requires refresh to apply)
- +
- - + +
Cloak - + Off When not focused @@ -116,12 +163,24 @@
- - + +
Privacy
- +
diff --git a/src/lib/components/sidebar-auth.svelte b/src/lib/components/sidebar-auth.svelte index 7553fb1f..3fd7f397 100644 --- a/src/lib/components/sidebar-auth.svelte +++ b/src/lib/components/sidebar-auth.svelte @@ -5,6 +5,9 @@ import * as Avatar from '$lib/components/ui/avatar/index.js'; const sidebar = useSidebar(); import * as Dialog from '$lib/components/ui/dialog/index.js'; + import * as Popover from '$lib/components/ui/popover/index.js'; + import { Checkbox } from '$lib/components/ui/checkbox/index.js'; + import * as Accordion from '$lib/components/ui/accordion/index.js'; // Lucide icons import Login from '@lucide/svelte/icons/log-in'; @@ -35,6 +38,12 @@ const ctx = useClerkContext(); import posthog from 'posthog-js'; + import { RefreshCw } from '@lucide/svelte'; + import Switch from './ui/switch/switch.svelte'; + import { syncSettingsStore } from '$lib/stores'; + import clsx from 'clsx'; + import { syncState } from '$lib/state.svelte'; + import Label from './ui/label/label.svelte'; {#if page.url.hostname == 'edutools.ingo.au' || page.url.hostname == 'localhost'} @@ -46,10 +55,100 @@ + - - - + + {#snippet child({ props })} + @@ -57,13 +156,10 @@ - {ctx.user?.username} - - - - - - + {ctx.user?.username} + {/snippet} + { diff --git a/src/lib/state.svelte.ts b/src/lib/state.svelte.ts index dbbf2e09..4bec3075 100644 --- a/src/lib/state.svelte.ts +++ b/src/lib/state.svelte.ts @@ -1 +1,2 @@ export let settingsOpen = $state({ current: false }); +export let syncState: { current: '' | 'uploading' | 'downloading' } = $state({ current: '' }); diff --git a/src/lib/stores.ts b/src/lib/stores.ts index 9112d074..67091cd5 100644 --- a/src/lib/stores.ts +++ b/src/lib/stores.ts @@ -15,9 +15,15 @@ export const preferencesStore = persisted('preferences', { name: 'Home', icon: 'https://ssl.gstatic.com/classroom/favicon.png' }, - analytics: true, history: true }); export const favoritesStore = persisted('favorites', [] as string[]); export const historyStore = persisted('history', [] as string[]); + +export const syncSettingsStore = persisted('syncSettings', { + enabled: false, + settings: true, + favorites: true, + history: true +}); diff --git a/src/lib/sync.ts b/src/lib/sync.ts new file mode 100644 index 00000000..7f9945d4 --- /dev/null +++ b/src/lib/sync.ts @@ -0,0 +1,48 @@ +import { get } from 'svelte/store'; +import { api } from '../convex/_generated/api'; +import { syncState } from './state.svelte'; +import { syncSettingsStore, preferencesStore, favoritesStore, historyStore } from './stores'; +import { useConvexClient } from 'convex-svelte'; + +export async function save( + jwt: string, + client: ReturnType, + { + settings = false, + history = false, + favourites = false + }: { settings?: boolean; history?: boolean; favourites?: boolean } +) { + syncState.current = 'uploading'; + let mutationData: { settings?: any; history?: any; favourites?: any } = {}; + + if (settings) { + let preferences = get(preferencesStore); + mutationData.settings = { + experimentalFeatures: preferences.experimentalFeatures, + open: preferences.open, + theme: preferences.theme, + panic: { + enabled: preferences.panic.enabled, + key: preferences.panic.key, + url: preferences.panic.url, + disableExperimentalMode: preferences.panic.disableExperimentalMode + }, + cloak: { + mode: preferences.cloak.mode, + name: preferences.cloak.name, + icon: preferences.cloak.icon + }, + history: preferences.history + }; + } + if (history) mutationData.history = get(historyStore); + if (favourites) mutationData.favourites = get(favoritesStore); + + try { + await client.mutation(api.sync.update, { ...mutationData, jwt }); + } catch { + syncState.current = ''; + } + syncState.current = ''; +} diff --git a/src/routes/account/[...slug]/+page.svelte b/src/routes/account/[...slug]/+page.svelte new file mode 100644 index 00000000..a0588038 --- /dev/null +++ b/src/routes/account/[...slug]/+page.svelte @@ -0,0 +1,36 @@ + + +
+ +