diff --git a/src/backend/accounts/auth.ts b/src/backend/accounts/auth.ts new file mode 100644 index 00000000..4084f483 --- /dev/null +++ b/src/backend/accounts/auth.ts @@ -0,0 +1,71 @@ +import { ofetch } from "ofetch"; + +export interface SessionResponse { + id: string; + userId: string; + createdAt: string; + accessedAt: string; + device: string; + userAgent: string; +} + +export interface UserResponse { + id: string; + namespace: string; + name: string; + roles: string[]; + createdAt: string; + profile: { + colorA: string; + colorB: string; + icon: string; + }; +} + +export interface LoginResponse { + session: SessionResponse; + token: string; +} + +function getAuthHeaders(token: string): Record { + return { + authorization: `Bearer ${token}`, + }; +} + +export async function accountLogin( + url: string, + id: string, + deviceName: string +): Promise { + return ofetch("/auth/login", { + method: "POST", + body: { + id, + device: deviceName, + }, + baseURL: url, + }); +} + +export async function getUser( + url: string, + token: string +): Promise { + return ofetch("/user/@me", { + headers: getAuthHeaders(token), + baseURL: url, + }); +} + +export async function removeSession( + url: string, + token: string, + sessionId: string +): Promise { + return ofetch(`/sessions/${sessionId}`, { + method: "DELETE", + headers: getAuthHeaders(token), + baseURL: url, + }); +} diff --git a/src/components/layout/Navigation.tsx b/src/components/layout/Navigation.tsx index 3076a274..f1821ed8 100644 --- a/src/components/layout/Navigation.tsx +++ b/src/components/layout/Navigation.tsx @@ -1,10 +1,10 @@ import classNames from "classnames"; -import { ReactNode } from "react"; import { Link } from "react-router-dom"; import { IconPatch } from "@/components/buttons/IconPatch"; import { Icons } from "@/components/Icon"; import { Lightbar } from "@/components/utils/Lightbar"; +import { useAuth } from "@/hooks/useAuth"; import { BlurEllipsis } from "@/pages/layouts/SubPageLayout"; import { conf } from "@/setup/config"; import { useBannerSize } from "@/stores/banner"; @@ -12,7 +12,6 @@ import { useBannerSize } from "@/stores/banner"; import { BrandPill } from "./BrandPill"; export interface NavigationProps { - children?: ReactNode; bg?: boolean; noLightbar?: boolean; doBackground?: boolean; @@ -20,6 +19,7 @@ export interface NavigationProps { export function Navigation(props: NavigationProps) { const bannerHeight = useBannerSize(); + const { loggedIn } = useAuth(); return ( <> @@ -60,26 +60,30 @@ export function Navigation(props: NavigationProps) {
- - - - - - - - - - {props.children} +
+ + + + + + + + + +
+
+

User: {JSON.stringify(loggedIn)}

+
diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 00000000..1c87f7ed --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,54 @@ +import { useCallback } from "react"; + +import { accountLogin, getUser, removeSession } from "@/backend/accounts/auth"; +import { conf } from "@/setup/config"; +import { useAuthStore } from "@/stores/auth"; + +export function useBackendUrl() { + const backendUrl = useAuthStore((s) => s.backendUrl); + return backendUrl ?? conf().BACKEND_URL; +} + +export function useAuth() { + const currentAccount = useAuthStore((s) => s.account); + const profile = useAuthStore((s) => s.account?.profile); + const loggedIn = !!useAuthStore((s) => s.account); + const setAccount = useAuthStore((s) => s.setAccount); + const removeAccount = useAuthStore((s) => s.removeAccount); + const backendUrl = useBackendUrl(); + + const login = useCallback( + async (id: string, device: string) => { + const account = await accountLogin(backendUrl, id, device); + const user = await getUser(backendUrl, account.token); + setAccount({ + token: account.token, + userId: user.id, + sessionId: account.session.id, + profile: user.profile, + }); + }, + [setAccount, backendUrl] + ); + + const logout = useCallback(async () => { + if (!currentAccount) return; + try { + await removeSession( + backendUrl, + currentAccount.token, + currentAccount.sessionId + ); + } catch { + // we dont care about failing to delete session + } + removeAccount(); // TODO clear local data + }, [removeAccount, backendUrl, currentAccount]); + + return { + loggedIn, + profile, + login, + logout, + }; +} diff --git a/src/setup/config.ts b/src/setup/config.ts index 36c4b66f..36b63c2c 100644 --- a/src/setup/config.ts +++ b/src/setup/config.ts @@ -7,6 +7,7 @@ interface Config { TMDB_READ_API_KEY: string; CORS_PROXY_URL: string; NORMAL_ROUTER: boolean; + BACKEND_URL: string; } export interface RuntimeConfig { @@ -16,6 +17,7 @@ export interface RuntimeConfig { TMDB_READ_API_KEY: string; NORMAL_ROUTER: boolean; PROXY_URLS: string[]; + BACKEND_URL: string; } const env: Record = { @@ -25,6 +27,7 @@ const env: Record = { DISCORD_LINK: undefined, CORS_PROXY_URL: import.meta.env.VITE_CORS_PROXY_URL, NORMAL_ROUTER: import.meta.env.VITE_NORMAL_ROUTER, + BACKEND_URL: import.meta.env.VITE_BACKEND_URL, }; // loads from different locations, in order: environment (VITE_{KEY}), window (public/config.js) @@ -44,6 +47,7 @@ export function conf(): RuntimeConfig { APP_VERSION, GITHUB_LINK, DISCORD_LINK, + BACKEND_URL: getKey("BACKEND_URL"), TMDB_READ_API_KEY: getKey("TMDB_READ_API_KEY"), PROXY_URLS: getKey("CORS_PROXY_URL") .split(",") diff --git a/src/stores/auth/index.ts b/src/stores/auth/index.ts new file mode 100644 index 00000000..d11583b5 --- /dev/null +++ b/src/stores/auth/index.ts @@ -0,0 +1,58 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { immer } from "zustand/middleware/immer"; + +interface Account { + profile: { + colorA: string; + colorB: string; + icon: string; + }; +} + +type AccountWithToken = Account & { + sessionId: string; + userId: string; + token: string; +}; + +interface AuthStore { + account: null | AccountWithToken; + backendUrl: null | string; + proxySet: null | string[]; // TODO actually use these settings + removeAccount(): void; + setAccount(acc: AccountWithToken): void; + updateAccount(acc: Account): void; +} + +export const useAuthStore = create( + persist( + immer((set) => ({ + account: null, + backendUrl: null, + proxySet: null, + setAccount(acc) { + set((s) => { + s.account = acc; + }); + }, + removeAccount() { + set((s) => { + s.account = null; + }); + }, + updateAccount(acc) { + set((s) => { + if (!s.account) return; + s.account = { + ...s.account, + ...acc, + }; + }); + }, + })), + { + name: "__MW::auth", + } + ) +);