mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-16 10:45:37 +02:00
⭐ 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:
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
36
packages/www/src/providers/api.tsx
Normal file
36
packages/www/src/providers/api.tsx
Normal 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,
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
});
|
||||
10
packages/www/src/providers/context.tsx
Normal file
10
packages/www/src/providers/context.tsx
Normal 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;
|
||||
}
|
||||
223
packages/www/src/providers/steam.tsx
Normal file
223
packages/www/src/providers/steam.tsx
Normal 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
|
||||
};
|
||||
}
|
||||
);
|
||||
39
packages/www/src/providers/zero.tsx
Normal file
39
packages/www/src/providers/zero.tsx
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user