summaryrefslogtreecommitdiffhomepage
path: root/cloud/web/src/components
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-08-08 13:22:54 -0400
committerFrank <[email protected]>2025-08-08 13:24:32 -0400
commit183e0911b76025a1f2a82e979d9834fec2131d0e (patch)
tree9987c1753bd64d1ce1d174ab397f1a8c681f642c /cloud/web/src/components
parentc7bb19ad0712469063eab35589aa5d3602b0c5b1 (diff)
downloadopencode-183e0911b76025a1f2a82e979d9834fec2131d0e.tar.gz
opencode-183e0911b76025a1f2a82e979d9834fec2131d0e.zip
wip: gateway
Diffstat (limited to 'cloud/web/src/components')
-rw-r--r--cloud/web/src/components/context-account.tsx99
-rw-r--r--cloud/web/src/components/context-openauth.tsx180
-rw-r--r--cloud/web/src/components/context-theme.tsx39
3 files changed, 318 insertions, 0 deletions
diff --git a/cloud/web/src/components/context-account.tsx b/cloud/web/src/components/context-account.tsx
new file mode 100644
index 000000000..e6aabafd3
--- /dev/null
+++ b/cloud/web/src/components/context-account.tsx
@@ -0,0 +1,99 @@
+import { createContext, createEffect, ParentProps, Suspense, useContext } from "solid-js"
+import { makePersisted } from "@solid-primitives/storage"
+import { createStore } from "solid-js/store"
+import { useOpenAuth } from "./context-openauth"
+import { createAsync } from "@solidjs/router"
+import { isServer } from "solid-js/web"
+
+type Storage = {
+ accounts: Record<
+ string,
+ {
+ id: string
+ email: string
+ workspaces: {
+ id: string
+ name: string
+ slug: string
+ }[]
+ }
+ >
+}
+
+const context = createContext<ReturnType<typeof init>>()
+
+function init() {
+ const auth = useOpenAuth()
+ const [store, setStore] = makePersisted(
+ createStore<Storage>({
+ accounts: {},
+ }),
+ {
+ name: "opencontrol.account",
+ },
+ )
+
+ async function refresh(id: string) {
+ return fetch(import.meta.env.VITE_API_URL + "/rest/account", {
+ headers: {
+ authorization: `Bearer ${await auth.access(id)}`,
+ },
+ })
+ .then((val) => val.json())
+ .then((val) => setStore("accounts", id, val as any))
+ }
+
+ createEffect((previous: string[]) => {
+ if (Object.keys(auth.all).length === 0) {
+ return []
+ }
+ for (const item of Object.values(auth.all)) {
+ if (previous.includes(item.id)) continue
+ refresh(item.id)
+ }
+ return Object.keys(auth.all)
+ }, [] as string[])
+
+ const result = {
+ get all() {
+ return Object.keys(auth.all)
+ .map((id) => store.accounts[id])
+ .filter(Boolean)
+ },
+ get current() {
+ if (!auth.subject) return undefined
+ return store.accounts[auth.subject.id]
+ },
+ refresh,
+ get ready() {
+ return Object.keys(auth.all).length === result.all.length
+ },
+ }
+
+ return result
+}
+
+export function AccountProvider(props: ParentProps) {
+ const ctx = init()
+ const resource = createAsync(async () => {
+ await new Promise<void>((resolve) => {
+ if (isServer) return resolve()
+ createEffect(() => {
+ if (ctx.ready) resolve()
+ })
+ })
+ return null
+ })
+ return (
+ <Suspense>
+ {resource()}
+ <context.Provider value={ctx}>{props.children}</context.Provider>
+ </Suspense>
+ )
+}
+
+export function useAccount() {
+ const result = useContext(context)
+ if (!result) throw new Error("no account context")
+ return result
+}
diff --git a/cloud/web/src/components/context-openauth.tsx b/cloud/web/src/components/context-openauth.tsx
new file mode 100644
index 000000000..bd6a45dd1
--- /dev/null
+++ b/cloud/web/src/components/context-openauth.tsx
@@ -0,0 +1,180 @@
+import { createClient } from "@openauthjs/openauth/client"
+import { makePersisted } from "@solid-primitives/storage"
+import { createAsync } from "@solidjs/router"
+import {
+ batch,
+ createContext,
+ createEffect,
+ createResource,
+ createSignal,
+ onMount,
+ ParentProps,
+ Show,
+ Suspense,
+ useContext,
+} from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import { isServer } from "solid-js/web"
+
+interface Storage {
+ subjects: Record<string, SubjectInfo>
+ current?: string
+}
+
+interface Context {
+ all: Record<string, SubjectInfo>
+ subject?: SubjectInfo
+ switch(id: string): void
+ logout(id: string): void
+ access(id?: string): Promise<string | undefined>
+ authorize(opts?: AuthorizeOptions): void
+}
+
+export interface AuthorizeOptions {
+ redirectPath?: string
+ provider?: string
+}
+
+interface SubjectInfo {
+ id: string
+ refresh: string
+}
+
+interface AuthContextOpts {
+ issuer: string
+ clientID: string
+}
+
+const context = createContext<Context>()
+
+export function OpenAuthProvider(props: ParentProps<AuthContextOpts>) {
+ const client = createClient({
+ issuer: props.issuer,
+ clientID: props.clientID,
+ })
+ const [storage, setStorage] = makePersisted(
+ createStore<Storage>({
+ subjects: {},
+ }),
+ {
+ name: `${props.issuer}.auth`,
+ },
+ )
+
+ const resource = createAsync(async () => {
+ if (isServer) return true
+ const hash = new URLSearchParams(window.location.search.substring(1))
+ const code = hash.get("code")
+ const state = hash.get("state")
+ if (code && state) {
+ const oldState = sessionStorage.getItem("openauth.state")
+ const verifier = sessionStorage.getItem("openauth.verifier")
+ const redirect = sessionStorage.getItem("openauth.redirect")
+ if (redirect && verifier && oldState === state) {
+ const result = await client.exchange(code, redirect, verifier)
+ if (!result.err) {
+ const id = result.tokens.refresh.split(":").slice(0, -1).join(":")
+ batch(() => {
+ setStorage("subjects", id, {
+ id: id,
+ refresh: result.tokens.refresh,
+ })
+ setStorage("current", id)
+ })
+ }
+ }
+ }
+ return true
+ })
+
+ async function authorize(opts?: AuthorizeOptions) {
+ const redirect = new URL(window.location.origin + (opts?.redirectPath ?? "/")).toString()
+ const authorize = await client.authorize(redirect, "code", {
+ pkce: true,
+ provider: opts?.provider,
+ })
+ sessionStorage.setItem("openauth.state", authorize.challenge.state)
+ sessionStorage.setItem("openauth.redirect", redirect)
+ if (authorize.challenge.verifier) sessionStorage.setItem("openauth.verifier", authorize.challenge.verifier)
+ window.location.href = authorize.url
+ }
+
+ const accessCache = new Map<string, string>()
+ const pendingRequests = new Map<string, Promise<any>>()
+ async function access(id: string) {
+ const pending = pendingRequests.get(id)
+ if (pending) return pending
+ const promise = (async () => {
+ const existing = accessCache.get(id)
+ const subject = storage.subjects[id]
+ const access = await client.refresh(subject.refresh, {
+ access: existing,
+ })
+ if (access.err) {
+ pendingRequests.delete(id)
+ ctx.logout(id)
+ return
+ }
+ if (access.tokens) {
+ setStorage("subjects", id, "refresh", access.tokens.refresh)
+ accessCache.set(id, access.tokens.access)
+ }
+ pendingRequests.delete(id)
+ return access.tokens?.access || existing!
+ })()
+ pendingRequests.set(id, promise)
+ return promise
+ }
+
+ const ctx: Context = {
+ get all() {
+ return storage.subjects
+ },
+ get subject() {
+ if (!storage.current) return
+ return storage.subjects[storage.current!]
+ },
+ switch(id: string) {
+ if (!storage.subjects[id]) return
+ setStorage("current", id)
+ },
+ authorize,
+ logout(id: string) {
+ if (!storage.subjects[id]) return
+ setStorage(
+ produce((s) => {
+ delete s.subjects[id]
+ if (s.current === id) s.current = Object.keys(s.subjects)[0]
+ }),
+ )
+ },
+ async access(id?: string) {
+ id = id || storage.current
+ if (!id) return
+ return access(id || storage.current!)
+ },
+ }
+
+ createEffect(() => {
+ if (!resource()) return
+ if (storage.current) return
+ const [first] = Object.keys(storage.subjects)
+ if (first) {
+ setStorage("current", first)
+ return
+ }
+ })
+
+ return (
+ <>
+ {resource()}
+ <context.Provider value={ctx}>{props.children}</context.Provider>
+ </>
+ )
+}
+
+export function useOpenAuth() {
+ const result = useContext(context)
+ if (!result) throw new Error("no auth context")
+ return result
+}
diff --git a/cloud/web/src/components/context-theme.tsx b/cloud/web/src/components/context-theme.tsx
new file mode 100644
index 000000000..7800aeca0
--- /dev/null
+++ b/cloud/web/src/components/context-theme.tsx
@@ -0,0 +1,39 @@
+import { createStore } from "solid-js/store"
+import { makePersisted } from "@solid-primitives/storage"
+import { createEffect } from "solid-js"
+import { createInitializedContext } from "../util/context"
+import { isServer } from "solid-js/web"
+
+interface Storage {
+ mode: "light" | "dark"
+}
+
+export const { provider: ThemeProvider, use: useTheme } =
+ createInitializedContext("ThemeContext", () => {
+ const [store, setStore] = makePersisted(
+ createStore<Storage>({
+ mode:
+ !isServer &&
+ window.matchMedia &&
+ window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light",
+ }),
+ {
+ name: "theme",
+ },
+ )
+ createEffect(() => {
+ document.documentElement.setAttribute("data-color-mode", store.mode)
+ })
+
+ return {
+ setMode(mode: Storage["mode"]) {
+ setStore("mode", mode)
+ },
+ get mode() {
+ return store.mode
+ },
+ ready: true,
+ }
+ })