feat(www): Finish up on the onboarding (#210)

Merging this prematurely to make sure the team is on the same boat... like dang! We need to find a better way to do this. 

Plus it has become too big
This commit is contained in:
Wanjohi
2025-03-26 02:21:53 +03:00
committed by GitHub
parent 957eca7794
commit f62fc1fb4b
106 changed files with 6329 additions and 866 deletions

View File

@@ -1,4 +1,4 @@
import { createStore } from "solid-js/store";
import { createStore, reconcile } from "solid-js/store";
import { makePersisted } from "@solid-primitives/storage";
import { ParentProps, createContext, useContext } from "solid-js";
@@ -10,7 +10,6 @@ function init() {
createStore({
account: "",
team: "",
dummy: "",
})
);
@@ -31,4 +30,74 @@ export function useStorage() {
throw new Error("No storage context");
}
return ctx;
}
}
import { createEffect } from "solid-js";
import { useOpenAuth } from "@openauthjs/solid"
import { Team } from "@nestri/core/team/index";
import { createInitializedContext } from "../common/context";
type Storage = {
accounts: Record<string, {
id: string
email: string
avatarUrl?: string
discriminator: number
name: string;
polarCustomerID: string;
teams: Team.Info[];
}>
}
export const { use: useAccount, provider: AccountProvider } = createInitializedContext("AccountContext", () => {
const auth = useOpenAuth()
const [store, setStore] = makePersisted(
createStore<Storage>({
accounts: {},
}),
{
name: "nestri.account",
},
);
async function refresh(id: string) {
const access = await auth.access(id).catch(() => { })
if (!access) {
auth.authorize()
return
}
return await fetch(import.meta.env.VITE_API_URL + "/account", {
headers: {
authorization: `Bearer ${access}`,
},
})
.then(val => val.json())
.then(val => setStore("accounts", id, reconcile(val.data)))
}
createEffect((previous: string[]) => {
if (!Object.values(auth.all).length) {
auth.authorize()
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[])
return {
get all() {
return store.accounts
},
get current() {
return store.accounts[auth.subject!.id]
},
refresh,
get ready() {
if (!auth.subject) return false
return store.accounts[auth.subject.id] !== undefined
}
}
})

View File

@@ -0,0 +1,36 @@
import { hc } from "hono/client";
import { useTeam } from "./context";
import { useOpenAuth } from "@openauthjs/solid";
import { type app } from "@nestri/functions/api/index";
import { createInitializedContext } from "@nestri/www/common/context";
export const { use: useApi, provider: ApiProvider } = createInitializedContext(
"Api",
() => {
const team = useTeam();
const auth = useOpenAuth();
const client = hc<typeof app>(import.meta.env.VITE_API_URL, {
async fetch(...args: Parameters<typeof fetch>): Promise<Response> {
const [input, init] = args;
const request =
input instanceof Request ? input : new Request(input, init);
const headers = new Headers(request.headers);
headers.set("authorization", `Bearer ${await auth.access()}`);
headers.set("x-nestri-team", team().id);
return fetch(
new Request(request, {
...init,
headers,
}),
);
},
});
return {
client,
ready: true,
};
},
);

View File

@@ -1,226 +0,0 @@
import { type Team } from "@nestri/core/team/index";
import { makePersisted } from "@solid-primitives/storage";
import { useLocation, useNavigate } from "@solidjs/router";
import { createClient } from "@openauthjs/openauth/client";
import { createInitializedContext } from "../common/context";
import { createEffect, createMemo, onMount } from "solid-js";
import { createStore, produce, reconcile } from "solid-js/store";
interface AccountInfo {
id: string;
email: string;
name: string;
access: string;
refresh: string;
avatarUrl: string;
teams: Team.Info[];
discriminator: number;
polarCustomerID: string | null;
}
interface Storage {
accounts: Record<string, AccountInfo>;
current?: string;
}
//TODO: Fix bug where authenticator deletes auth state for no reason
export const client = createClient({
issuer: import.meta.env.VITE_AUTH_URL,
clientID: "web",
});
export const { use: useAuth, provider: AuthProvider } =
createInitializedContext("AuthContext", () => {
const [store, setStore] = makePersisted(
createStore<Storage>({
accounts: {},
}),
{
name: "radiant.auth",
},
);
const location = useLocation();
const params = createMemo(
() => new URLSearchParams(location.hash.substring(1)),
);
const accessToken = createMemo(() => params().get("access_token"));
const refreshToken = createMemo(() => params().get("refresh_token"));
createEffect(async () => {
// if (!result.current && Object.keys(store.accounts).length) {
// result.switch(Object.keys(store.accounts)[0])
// navigate("/")
// }
})
createEffect(async () => {
if (accessToken()) return;
if (Object.keys(store.accounts).length) return;
const redirect = await client.authorize(window.location.origin, "token");
window.location.href = redirect.url
});
createEffect(async () => {
const current = store.current;
const accounts = store.accounts;
if (!current) return;
const match = accounts[current];
if (match) return;
const keys = Object.keys(accounts);
if (keys.length) {
setStore("current", keys[0]);
navigate("/");
return
}
const redirect = await client.authorize(window.location.origin, "token");
window.location.href = redirect.url
});
async function refresh() {
for (const account of [...Object.values(store.accounts)]) {
if (!account.refresh) continue;
const result = await client.refresh(account.refresh, {
access: account.access,
})
if (result.err) {
console.log("error", result.err)
if ("id" in account)
setStore(produce((state) => {
delete state.accounts[account.id];
}))
continue
};
const tokens = result.tokens || {
access: account.access,
refresh: account.refresh,
}
fetch(import.meta.env.VITE_API_URL + "/account", {
headers: {
authorization: `Bearer ${tokens.access}`,
},
}).then(async (response) => {
await new Promise((resolve) => setTimeout(resolve, 10000));
if (response.ok) {
const result = await response.json();
const info = await result.data;
setStore(
"accounts",
info.id,
reconcile({
...info,
...tokens,
}),
);
}
if (!response.ok)
console.log("error from account", response.json())
setStore(
produce((state) => {
delete state.accounts[account.id];
}),
);
})
}
}
onMount(async () => {
if (refreshToken() && accessToken()) {
const result = await fetch(import.meta.env.VITE_API_URL + "/account", {
headers: {
authorization: `Bearer ${accessToken()}`,
},
}).catch(() => { })
if (result?.ok) {
const response = await result.json();
const info = await response.data;
setStore(
"accounts",
info.id,
reconcile({
...info,
access: accessToken(),
refresh: refreshToken(),
}),
);
setStore("current", info.id);
}
window.location.hash = "";
}
await refresh();
})
const navigate = useNavigate();
// const bar = useCommandBar()
// bar.register("auth", async () => {
// return [
// {
// category: "Account",
// title: "Logout",
// icon: IconLogout,
// run: async (bar) => {
// result.logout();
// setStore("current", undefined);
// navigate("/");
// bar.hide()
// },
// },
// {
// category: "Add Account",
// title: "Add Account",
// icon: IconUserAdd,
// run: async () => {
// const redir = await client.authorize(window.location.origin, "token");
// window.location.href = redir.url
// bar.hide()
// },
// },
// ...result.all()
// .filter((item) => item.id !== result.current.id)
// .map((item) => ({
// category: "Account",
// title: "Switch to " + item.email,
// icon: IconUser,
// run: async () => {
// result.switch(item.id);
// navigate("/");
// bar.hide()
// },
// })),
// ]
// })
const result = {
get current() {
return store.accounts[store.current!]!;
},
switch(accountID: string) {
setStore("current", accountID);
},
all() {
return Object.values(store.accounts);
},
refresh,
logout() {
setStore(
produce((state) => {
if (!state.current) return;
delete state.accounts[state.current];
state.current = Object.keys(state.accounts)[0];
}),
);
},
get ready() {
return Boolean(!accessToken() && store.current);
},
};
return result;
});

View File

@@ -0,0 +1,10 @@
import { Team } from "@nestri/core/team/index";
import { Accessor, createContext, useContext } from "solid-js";
export const TeamContext = createContext<Accessor<Team.Info>>();
export function useTeam() {
const context = useContext(TeamContext);
if (!context) throw new Error("No team context");
return context;
}

View File

@@ -0,0 +1,223 @@
import { useTeam } from "./context";
import { EventSource } from 'eventsource'
import { useOpenAuth } from "@openauthjs/solid";
import { createSignal, onCleanup } from "solid-js";
import { createInitializedContext } from "../common/context";
// Type definitions for the events
interface SteamEventTypes {
'url': string;
'login-attempt': { username: string };
'login-success': { username: string; steamId: string };
'login-unsuccessful': { error: string };
'logged-off': { reason: string };
}
// Type for the connection
type SteamConnection = {
addEventListener: <T extends keyof SteamEventTypes>(
event: T,
callback: (data: SteamEventTypes[T]) => void
) => () => void;
removeEventListener: <T extends keyof SteamEventTypes>(
event: T,
callback: (data: SteamEventTypes[T]) => void
) => void;
disconnect: () => void;
isConnected: () => boolean;
}
interface SteamContext {
ready: boolean;
client: {
// Regular API endpoints
whoami: () => Promise<any>;
games: () => Promise<any>;
// SSE connection for login
login: {
connect: () => SteamConnection;
};
};
}
// Create the initialized context
export const { use: useSteam, provider: SteamProvider } = createInitializedContext(
"Steam",
() => {
const team = useTeam();
const auth = useOpenAuth();
// Create the HTTP client for regular endpoints
const client = {
// Regular HTTP endpoints
whoami: async () => {
const token = await auth.access();
const response = await fetch(`${import.meta.env.VITE_STEAM_URL}/whoami`, {
headers: {
'Authorization': `Bearer ${token}`,
'x-nestri-team': team().id
}
});
return response.json();
},
games: async () => {
const token = await auth.access();
const response = await fetch(`${import.meta.env.VITE_STEAM_URL}/games`, {
headers: {
'Authorization': `Bearer ${token}`,
'x-nestri-team': team().id
}
});
return response.json();
},
// SSE connection factory for login
login: {
connect: async (): Promise<SteamConnection> => {
let eventSource: EventSource | null = null;
const [isConnected, setIsConnected] = createSignal(false);
// Store event listeners
const listeners: Record<string, Array<(data: any) => void>> = {
'url': [],
'login-attempt': [],
'login-success': [],
'login-unsuccessful': [],
'logged-off': []
};
// Method to add event listeners
const addEventListener = <T extends keyof SteamEventTypes>(
event: T,
callback: (data: SteamEventTypes[T]) => void
) => {
if (!listeners[event]) {
listeners[event] = [];
}
listeners[event].push(callback as any);
// Return a function to remove this specific listener
return () => {
removeEventListener(event, callback);
};
};
// Method to remove event listeners
const removeEventListener = <T extends keyof SteamEventTypes>(
event: T,
callback: (data: SteamEventTypes[T]) => void
) => {
if (listeners[event]) {
const index = listeners[event].indexOf(callback as any);
if (index !== -1) {
listeners[event].splice(index, 1);
}
}
};
// Initialize connection
const initConnection = async () => {
if (eventSource) {
eventSource.close();
}
try {
const token = await auth.access();
eventSource = new EventSource(`${import.meta.env.VITE_STEAM_URL}/login`, {
fetch: (input, init) =>
fetch(input, {
...init,
headers: {
...init?.headers,
'Authorization': `Bearer ${token}`,
'x-nestri-team': team().id
},
}),
});
eventSource.onopen = () => {
console.log('Connected to Steam login stream');
setIsConnected(true);
};
// Set up event handlers for all specific events
['url', 'login-attempt', 'login-success', 'login-unsuccessful', 'logged-off'].forEach((eventType) => {
eventSource!.addEventListener(eventType, (event) => {
try {
const data = JSON.parse(event.data);
console.log(`Received ${eventType} event:`, data);
// Notify all registered listeners for this event type
if (listeners[eventType]) {
listeners[eventType].forEach(callback => {
callback(data);
});
}
} catch (error) {
console.error(`Error parsing ${eventType} event data:`, error);
}
});
});
// Handle generic messages (fallback)
eventSource.onmessage = (event) => {
console.log('Received generic message:', event.data);
};
eventSource.onerror = (error) => {
console.error('Steam login stream error:', error);
setIsConnected(false);
// Attempt to reconnect after a delay
setTimeout(initConnection, 5000);
};
} catch (error) {
console.error('Failed to connect to Steam login stream:', error);
setIsConnected(false);
}
};
// Disconnection function
const disconnect = () => {
if (eventSource) {
eventSource.close();
eventSource = null;
setIsConnected(false);
console.log('Disconnected from Steam login stream');
// Clear all listeners
Object.keys(listeners).forEach(key => {
listeners[key] = [];
});
}
};
// Start the connection immediately
await initConnection();
// Create the connection interface
const connection: SteamConnection = {
addEventListener,
removeEventListener,
disconnect,
isConnected: () => isConnected()
};
// Clean up on context destruction
onCleanup(() => {
disconnect();
});
return connection;
}
}
};
return {
client,
ready: true
};
}
);

View File

@@ -0,0 +1,39 @@
import { useTeam } from "./context"
import { createEffect } from "solid-js"
import { schema } from "@nestri/zero/schema"
import { useQuery } from "@rocicorp/zero/solid"
import { useOpenAuth } from "@openauthjs/solid"
import { Query, Schema, Zero } from "@rocicorp/zero"
import { useAccount } from "@nestri/www/providers/account"
import { createInitializedContext } from "@nestri/www/common/context"
export const { use: useZero, provider: ZeroProvider } =
createInitializedContext("ZeroContext", () => {
const auth = useOpenAuth()
const account = useAccount()
const team = useTeam()
const zero = new Zero({
schema: schema,
auth: () => auth.access(),
userID: account.current.email,
storageKey: team().id,
server: import.meta.env.VITE_ZERO_URL,
})
return {
mutate: zero.mutate,
query: zero.query,
client: zero,
ready: true,
};
});
export function usePersistentQuery<TSchema extends Schema, TTable extends keyof TSchema['tables'] & string, TReturn>(querySignal: () => Query<TSchema, TTable, TReturn>) {
const team = useTeam()
//@ts-ignore
const q = () => querySignal().where("team_id", "=", team().id).where("time_deleted", "IS", null)
createEffect(() => {
q().preload()
})
return useQuery<TSchema, TTable, TReturn>(q)
}