From 1c79b490c0d4dd13f7dcc3bf5e08dfab1ee54c73 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Wed, 19 Jun 2024 14:23:21 +0200 Subject: [PATCH] feat(oauth): support dual login method --- app/configure/page-content.tsx | 6 +- app/layout.tsx | 6 +- .../[id]/[...record]/page-content.tsx | 4 +- components/common/Tabs.tsx | 32 ++ .../common/labels/useLabelerDefinition.ts | 4 +- components/shell/AuthContext.tsx | 115 ++++--- components/shell/AuthForm.tsx | 127 +++----- components/shell/ConfigurationContext.tsx | 4 +- components/shell/ConfigurationFlow.tsx | 13 +- components/shell/MobileMenu.tsx | 2 +- components/shell/auth/atp/AtpSignInForm.tsx | 228 +++++++++++++ components/shell/auth/atp/useAtpAuth.ts | 105 ++++++ .../shell/auth/oauth/OAuthSignInForm.tsx | 98 ++++++ components/shell/auth/oauth/useOAuth.ts | 302 ++++++++++++++++++ lib/useOAuth.ts | 225 ------------- 15 files changed, 901 insertions(+), 370 deletions(-) create mode 100644 components/shell/auth/atp/AtpSignInForm.tsx create mode 100644 components/shell/auth/atp/useAtpAuth.ts create mode 100644 components/shell/auth/oauth/OAuthSignInForm.tsx create mode 100644 components/shell/auth/oauth/useOAuth.ts delete mode 100644 lib/useOAuth.ts diff --git a/app/configure/page-content.tsx b/app/configure/page-content.tsx index ac9a2b29..aee46382 100644 --- a/app/configure/page-content.tsx +++ b/app/configure/page-content.tsx @@ -11,7 +11,7 @@ import { ButtonGroup, ButtonPrimary, ButtonSecondary } from '@/common/buttons' import { Checkbox, Textarea } from '@/common/forms' import { isDarkModeEnabled } from '@/common/useColorScheme' import { useSyncedState } from '@/lib/useSyncedState' -import { useAuthDid, usePdsAgent } from '@/shell/AuthContext' +import { useAuthContext, useAuthDid } from '@/shell/AuthContext' import { useConfigurationContext } from '@/shell/ConfigurationContext' const BrowserReactJsonView = dynamic(() => import('react-json-view'), { @@ -90,7 +90,7 @@ function ConfigureDetails() { function RecordInitStep({ repo }: { repo: string }) { const [checked, setChecked] = useState(false) - const pdsAgent = usePdsAgent() + const { pdsAgent } = useAuthContext() const { reconfigure } = useConfigurationContext() const createInitialRecord = useMutation({ @@ -153,7 +153,7 @@ function RecordEditStep({ record: AppBskyLabelerService.Record repo: string }) { - const pdsAgent = usePdsAgent() + const { pdsAgent } = useAuthContext() const { reconfigure } = useConfigurationContext() const [editorMode, setEditorMode] = useState<'json' | 'plain'>('json') diff --git a/app/layout.tsx b/app/layout.tsx index 82af4591..5c8fb689 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -7,6 +7,7 @@ import { QueryClientProvider } from '@tanstack/react-query' import { ToastContainer } from 'react-toastify' import { isDarkModeEnabled } from '@/common/useColorScheme' +import { PLC_DIRECTORY_URL, SOCIAL_APP_URL } from '@/lib/constants' import { AuthProvider } from '@/shell/AuthContext' import { CommandPaletteRoot } from '@/shell/CommandPalette/Root' import { ConfigurationProvider } from '@/shell/ConfigurationContext' @@ -47,7 +48,10 @@ export default function RootLayout({ closeOnClick /> - + {children} diff --git a/app/repositories/[id]/[...record]/page-content.tsx b/app/repositories/[id]/[...record]/page-content.tsx index e4ef5182..6dca2c55 100644 --- a/app/repositories/[id]/[...record]/page-content.tsx +++ b/app/repositories/[id]/[...record]/page-content.tsx @@ -17,7 +17,7 @@ import { ReportPanel } from '@/reports/ReportPanel' import { CollectionId } from '@/reports/helpers/subject' import { RecordView } from '@/repositories/RecordView' import { useCreateReport } from '@/repositories/createReport' -import { usePdsAgent } from '@/shell/AuthContext' +import { useAuthContext } from '@/shell/AuthContext' import { useLabelerAgent } from '@/shell/ConfigurationContext' import { ModActionPanelQuick } from 'app/actions/ModActionPanel/QuickAction' @@ -56,7 +56,7 @@ export default function RecordViewPageContent({ params: { id: string; record: string[] } }) { const labelerAgent = useLabelerAgent() - const pdsAgent = usePdsAgent() + const { pdsAgent } = useAuthContext() const emitEvent = useEmitEvent() const createReport = useCreateReport() diff --git a/components/common/Tabs.tsx b/components/common/Tabs.tsx index 9d28c941..ed059cef 100644 --- a/components/common/Tabs.tsx +++ b/components/common/Tabs.tsx @@ -1,4 +1,5 @@ import { classNames } from '@/lib/util' +import { HTMLAttributes, ReactNode, useEffect, useState } from 'react' export type TabView = { view: ViewName @@ -61,3 +62,34 @@ function Tab({ ) } + +export function TabsPanel({ + views, + fallback, + ...props +}: { + views: (TabView & { content: ReactNode })[] + fallback?: ReactNode +} & HTMLAttributes) { + const available = views.filter((v) => v.content) + const defaultView = available[0]?.view + + const [currentView, setCurrentView] = useState(defaultView) + + const current = available.find((v) => v.view === currentView) + + useEffect(() => { + if (!current?.view) setCurrentView(defaultView) + }, [current?.view, defaultView]) + + return ( +
+ + {current?.content ?? fallback} +
+ ) +} diff --git a/components/common/labels/useLabelerDefinition.ts b/components/common/labels/useLabelerDefinition.ts index 3c6ac54b..5a3f4076 100644 --- a/components/common/labels/useLabelerDefinition.ts +++ b/components/common/labels/useLabelerDefinition.ts @@ -1,11 +1,11 @@ import { ComAtprotoLabelDefs } from '@atproto/api' import { useQuery } from '@tanstack/react-query' -import { usePdsAgent } from '@/shell/AuthContext' +import { useAuthContext } from '@/shell/AuthContext' import { ExtendedLabelerServiceDef } from './util' export const useLabelerServiceDef = (did: string) => { - const pdsAgent = usePdsAgent() + const { pdsAgent } = useAuthContext() const { data: labelerDef } = useQuery({ queryKey: ['labelerDef', { did, for: pdsAgent.getDid() }], diff --git a/components/shell/AuthContext.tsx b/components/shell/AuthContext.tsx index f8f3a955..ab770f44 100644 --- a/components/shell/AuthContext.tsx +++ b/components/shell/AuthContext.tsx @@ -1,65 +1,103 @@ 'use client' import { AppBskyActorDefs, BskyAgent } from '@atproto/api' -import { - BrowserOAuthClientLoadOptions, - isLoopbackHost, -} from '@atproto/oauth-client-browser' -import { useQuery } from '@tanstack/react-query' +import { isLoopbackHost } from '@atproto/oauth-client-browser' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { usePathname, useRouter } from 'next/navigation' -import { createContext, useContext, useMemo, useState } from 'react' +import { createContext, ReactNode, useContext, useMemo } from 'react' import { Loading } from '@/common/Loader' import { SetupModal } from '@/common/SetupModal' -import { PLC_DIRECTORY_URL, SOCIAL_APP_URL } from '@/lib/constants' -import { useOAuth } from '@/lib/useOAuth' -import { queryClient } from 'components/QueryClient' +import { useAtpAuth } from './auth/atp/useAtpAuth' +import { useOAuth, UseOAuthOptions } from './auth/oauth/useOAuth' import { AuthForm } from './AuthForm' export type Profile = AppBskyActorDefs.ProfileViewDetailed -export type AuthContextData = { +export type AuthContext = { pdsAgent: BskyAgent signOut: () => Promise } -const AuthContext = createContext(null) +const AuthContext = createContext(null) -export const AuthProvider = ({ children }: { children: React.ReactNode }) => { +export const AuthProvider = ({ + children, + ...options +}: { + children: ReactNode +} & UseOAuthOptions) => { const pathname = usePathname() const router = useRouter() + const queryClient = useQueryClient() + + const { + isLoginPopup, + isInitializing, + client: oauthClient, + agent: oauthAgent, + signIn: oauthSignIn, + } = useOAuth({ + ...options, - const [oauthOptions] = useState(() => ({ - clientId: - typeof window === 'undefined' || isLoopbackHost(window.location.hostname) - ? 'http://localhost' - : new URL(`/oauth-client.json`, window.location.origin).href, - plcDirectoryUrl: PLC_DIRECTORY_URL, - handleResolver: SOCIAL_APP_URL, - })) - - const { isInitialized, pdsAgent, signIn, signOut } = useOAuth(oauthOptions, { getState: async () => { + // Save the current path before signing in return pathname }, onSignedIn: async (agent, state) => { + // Restore the previous path after signing in if (state) router.push(state) }, onSignedOut: async () => { // Clear all cached queries when signing out queryClient.removeQueries() }, + + // use "https://ozone.example.com/oauth-client.json" in prod and a loopback URL in dev + clientId: + options['clientId'] ?? + (options['clientMetadata'] == null + ? typeof window === 'undefined' || + isLoopbackHost(window.location.hostname) + ? undefined + : new URL(`/oauth-client.json`, window.location.origin).href + : undefined), }) - const value = useMemo( - () => (pdsAgent ? { pdsAgent, signOut } : null), - [pdsAgent, signOut], + const { + session: atpSession, + signIn: atpSignIn, + signOut: atpSignOut, + } = useAtpAuth() + + const value = useMemo( + () => + oauthAgent + ? { + pdsAgent: new BskyAgent(oauthAgent), + signOut: () => oauthAgent.signOut(), + } + : atpSession + ? { + pdsAgent: new BskyAgent(atpSession), + signOut: atpSignOut, + } + : null, + [atpSession, oauthAgent, atpSignOut], ) - if (!isInitialized) { + if (isLoginPopup) { return ( - +

This window can be closed

+
+ ) + } + + if (isInitializing) { + return ( + + ) } @@ -67,7 +105,10 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { if (!value) { return ( - + ) } @@ -75,29 +116,25 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { return {children} } -export const useAuthContext = () => { +export function useAuthContext(): AuthContext { const context = useContext(AuthContext) if (context) return context - throw new Error(`useAuthContext() must be used within a `) -} - -export function usePdsAgent() { - const { pdsAgent } = useAuthContext() - return pdsAgent + throw new Error(`useAuthContext() must be used within an `) } export const useAuthDid = () => { - return usePdsAgent().getDid() + const { pdsAgent } = useAuthContext() + return pdsAgent.getDid() } export const useAuthProfileQuery = () => { - const pds = usePdsAgent() - const did = pds.getDid() + const { pdsAgent } = useAuthContext() + const did = pdsAgent.getDid() return useQuery({ queryKey: ['profile', did], - queryFn: async () => pds.getProfile({ actor: did }), + queryFn: async () => pdsAgent.getProfile({ actor: did }), }) } diff --git a/components/shell/AuthForm.tsx b/components/shell/AuthForm.tsx index 087cdefa..921afcd3 100644 --- a/components/shell/AuthForm.tsx +++ b/components/shell/AuthForm.tsx @@ -1,90 +1,45 @@ -'use client' - -import { LockClosedIcon } from '@heroicons/react/20/solid' -import { FormEvent, useCallback, useState } from 'react' - -import { ErrorInfo } from '@/common/ErrorInfo' - -type LoginFormProps = { - signIn: (handle: string) => Promise - disabled?: boolean -} - -export function AuthForm({ signIn, disabled }: LoginFormProps) { - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - const [handle, setHandle] = useState('') - - const submitButtonClassNames = `group relative flex w-full justify-center rounded-md border border-transparent py-2 px-4 text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-rose-500 dark:focus:ring-slate-500 focus:ring-offset-2 ${ - loading - ? 'bg-gray-500 hover:bg-gray-600' - : 'bg-rose-600 dark:bg-teal-600 hover:bg-rose-700 dark:hover:bg-teal-700' - }` - const submitButtonIconClassNames = `h-5 w-5 ${ - loading - ? 'text-gray-800 group-hover:text-gray-700' - : 'text-rose-500 dark:text-gray-50 group-hover:text-rose-400 dark:group-hover:text-gray-100' - }` - - const onSubmit = useCallback( - async (e: FormEvent) => { - e.preventDefault() - e.stopPropagation() - setError(null) - setLoading(true) - try { - await signIn(handle) - } catch (err: unknown) { - setError(String(err) || 'An unknown error occurred') - } finally { - setLoading(false) - } - }, - [handle, signIn], - ) - +import { HTMLAttributes } from 'react' + +import { TabsPanel } from '@/common/Tabs' +import { AtpSignIn, AtpSignInForm } from './auth/atp/AtpSignInForm' +import { OAuthSignIn, OAuthSignInForm } from './auth/oauth/OAuthSignInForm' + +export function AuthForm({ + atpSignIn, + oauthSignIn, + ...props +}: { + atpSignIn?: AtpSignIn + oauthSignIn?: OAuthSignIn +} & HTMLAttributes) { return ( -
- -
-
- - { - setError(null) - setHandle(e.target.value) - }} - /> -
-
- - {error != null ? {error} : undefined} - -
-
} + views={[ + { + view: 'atp', + label: 'Credentials', + content: atpSignIn ? ( + + ) : undefined, + }, + { + view: 'oauth', + label: 'OAuth', + content: oauthSignIn ? ( + - - {loading ? 'Authenticating...' : 'Sign in'} - - - + ) : undefined, + }, + ]} + /> ) } diff --git a/components/shell/ConfigurationContext.tsx b/components/shell/ConfigurationContext.tsx index 35887dcc..0b891f2f 100644 --- a/components/shell/ConfigurationContext.tsx +++ b/components/shell/ConfigurationContext.tsx @@ -14,7 +14,7 @@ import { SetupModal } from '@/common/SetupModal' import { getConfig, OzoneConfig } from '@/lib/client-config' import { useSignaledEffect } from '@/lib/useSignaledEffect' import { useQuery } from '@tanstack/react-query' -import { usePdsAgent } from './AuthContext' +import { useAuthContext } from './AuthContext' import { ConfigurationFlow } from './ConfigurationFlow' export enum ConfigurationState { @@ -44,7 +44,7 @@ export const ConfigurationProvider = ({ }: { children: React.ReactNode }) => { - const pdsAgent = usePdsAgent() + const { pdsAgent } = useAuthContext() const { data: config, error, diff --git a/components/shell/ConfigurationFlow.tsx b/components/shell/ConfigurationFlow.tsx index 0808662b..9d3763a0 100644 --- a/components/shell/ConfigurationFlow.tsx +++ b/components/shell/ConfigurationFlow.tsx @@ -10,8 +10,8 @@ import Link from 'next/link' import { ComponentProps, ReactElement, cloneElement, useState } from 'react' import { ErrorInfo } from '@/common/ErrorInfo' -import { Loading } from '@/common/Loader' import { Checkbox, Input } from '@/common/forms' +import { Loading } from '@/common/Loader' import { OzoneConfig, OzoneConfigFull, @@ -19,12 +19,7 @@ import { withDocAndMeta, } from '@/lib/client-config' import { BskyAgent } from '@atproto/api' -import { - useAuthContext, - useAuthDid, - useAuthIdentifier, - usePdsAgent, -} from './AuthContext' +import { useAuthContext, useAuthDid, useAuthIdentifier } from './AuthContext' import { ConfigurationState, ReconfigureOptions } from './ConfigurationContext' type ReconfigureFn = (options?: ReconfigureOptions) => void | Promise @@ -227,7 +222,7 @@ function IdentityConfigurationFlow({ }) { const [token, setToken] = useState('') const { signOut } = useAuthContext() - const pdsAgent = usePdsAgent() + const { pdsAgent } = useAuthContext() const requestPlcOperationSignature = useMutation({ mutationKey: [pdsAgent.did, config.did], @@ -340,7 +335,7 @@ function RecordConfigurationFlow({ const [checked, setChecked] = useState(false) const authDid = useAuthDid() - const pdsAgent = usePdsAgent() + const { pdsAgent } = useAuthContext() const identifier = useAuthIdentifier() const putServiceRecord = useMutation({ diff --git a/components/shell/MobileMenu.tsx b/components/shell/MobileMenu.tsx index f0ca663d..ed7f838f 100644 --- a/components/shell/MobileMenu.tsx +++ b/components/shell/MobileMenu.tsx @@ -10,8 +10,8 @@ import { Fragment, createContext, useContext, useState } from 'react' import { classNames } from '@/lib/util' import { useAuthDid } from './AuthContext' -import { useConfigurationContext } from './ConfigurationContext' import { ICONS, NAV_ITEMS, isCurrent } from './common' +import { useConfigurationContext } from './ConfigurationContext' interface MobileMenuOpen { open: boolean diff --git a/components/shell/auth/atp/AtpSignInForm.tsx b/components/shell/auth/atp/AtpSignInForm.tsx new file mode 100644 index 00000000..1d509944 --- /dev/null +++ b/components/shell/auth/atp/AtpSignInForm.tsx @@ -0,0 +1,228 @@ +import { ComAtprotoServerCreateSession } from '@atproto/api' +import { LockClosedIcon } from '@heroicons/react/20/solid' +import { createRef, FormEvent, useCallback, useState } from 'react' + +import { Alert } from '@/common/Alert' +import { ErrorInfo } from '@/common/ErrorInfo' + +export type AtpSignIn = (input: { + identifier: string + password: string + authFactorToken?: string + service: string +}) => unknown + +/** + * @returns Nice tailwind css form asking to enter either a handle or the host + * to use to login. + */ +export function AtpSignInForm({ + signIn, + ...props +}: { + signIn: AtpSignIn +} & Omit, 'onSubmit'>) { + const [error, setError] = useState(null) + const [isValidatingAuth, setIsValidatingAuth] = useState(false) + + const [handle, setHandle] = useState('') + const [password, setPassword] = useState('') + const [service, setService] = useState('http://localhost:2583') + const [authFactor, setAuthFactor] = useState<{ + token: string + isInvalid: boolean + isNeeded: boolean + }>({ + token: '', + isNeeded: false, + isInvalid: false, + }) + + const handleRef = createRef() + + const onSubmit = useCallback( + async (e: FormEvent) => { + e.preventDefault() + e.stopPropagation() + + if (isValidatingAuth) return + + setIsValidatingAuth(true) + + try { + await signIn({ + identifier: handle, + password, + service, + authFactorToken: authFactor.isNeeded ? authFactor.token : undefined, + }) + } catch (err) { + const errMsg = e.toString() + if ( + e instanceof + ComAtprotoServerCreateSession.AuthFactorTokenRequiredError + ) { + setAuthFactor({ ...authFactor, isNeeded: true }) + } else if (errMsg.includes('Token is invalid')) { + setAuthFactor({ ...authFactor, isInvalid: true }) + } else { + setError(errMsg) + } + } finally { + setIsValidatingAuth(false) + } + }, + [authFactor, isValidatingAuth, handle, password, service, signIn], + ) + + const submitButtonClassNames = `group relative flex w-full justify-center rounded-md border border-transparent py-2 px-4 text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-rose-500 dark:focus:ring-slate-500 focus:ring-offset-2 ${ + isValidatingAuth + ? 'bg-gray-500 hover:bg-gray-600' + : 'bg-rose-600 dark:bg-teal-600 hover:bg-rose-700 dark:hover:bg-teal-700' + }` + const submitButtonIconClassNames = `h-5 w-5 ${ + isValidatingAuth + ? 'text-gray-800 group-hover:text-gray-700' + : 'text-rose-500 dark:text-gray-50 group-hover:text-rose-400 dark:group-hover:text-gray-100' + }` + + return ( +
+ +
+
+ + setService(e.target.value)} + /> + + +
+
+ + setHandle(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+ + {/* When user fills in the token and hits submit again, the AuthState value changes to Validating so the input field goes away which is a bit odd */} + {authFactor.isNeeded && ( +
+ + + setAuthFactor({ ...authFactor, token: e.target.value }) + } + /> +
+ )} +
+ + {authFactor.isNeeded && ( + + Check your email for a confirmation code and enter it here or{' '} + + + } + /> + )} + + {error ? {error} : undefined} + +
+ +
+ + ) +} diff --git a/components/shell/auth/atp/useAtpAuth.ts b/components/shell/auth/atp/useAtpAuth.ts new file mode 100644 index 00000000..d851ce66 --- /dev/null +++ b/components/shell/auth/atp/useAtpAuth.ts @@ -0,0 +1,105 @@ +import { AtpSessionData, AtpSessionManager } from '@atproto/api' +import { useCallback, useMemo, useState } from 'react' + +type Session = AtpSessionData & { service: string } + +export function useAtpAuth() { + const persistSession = useCallback((session?: Session) => { + if (session) { + saveSession(session) + } else { + deleteSession() + setSession(null) + } + }, []) + + const [session, setSession] = useState(() => { + const prev = loadSession() + if (!prev) return null + + const { service } = prev + + const session = new AtpSessionManager({ + service, + persistSession: (type, session) => { + persistSession(session && { ...session, service }) + }, + }) + + void session.resumeSession(prev).catch((err) => { + console.warn('Failed to resume session', err) + setSession((s) => (s === session ? null : s)) + }) + + return session + }) + + const signIn = useCallback( + async ({ + identifier, + password, + authFactorToken, + service, + }: { + identifier: string + password: string + authFactorToken?: string + service: string + }) => { + const session = new AtpSessionManager({ + service, + persistSession: (type, session) => { + persistSession(session && { ...session, service }) + }, + }) + await session.login({ identifier, password, authFactorToken }) + setSession(session) + }, + [], + ) + + const signOut = useCallback(async () => { + if (session) { + // Is there no way to clear credentials? + // await session.logout() + + deleteSession() + setSession(null) + } + }, [session]) + + return useMemo( + () => ({ signIn, signOut, session }), + [signIn, signOut, session], + ) +} + +const SESSION_KEY = 'ozone_session' + +function loadSession(): Session | undefined { + try { + const str = localStorage.getItem(SESSION_KEY) + const obj: unknown = str ? JSON.parse(str) : undefined + if ( + obj && + obj['service'] && + obj['refreshJwt'] && + obj['accessJwt'] && + obj['handle'] && + obj['did'] + ) { + return obj as Session + } + return undefined + } catch (e) { + return undefined + } +} + +function saveSession(session: Session) { + localStorage.setItem(SESSION_KEY, JSON.stringify(session)) +} + +function deleteSession() { + localStorage.removeItem(SESSION_KEY) +} diff --git a/components/shell/auth/oauth/OAuthSignInForm.tsx b/components/shell/auth/oauth/OAuthSignInForm.tsx new file mode 100644 index 00000000..300b90f1 --- /dev/null +++ b/components/shell/auth/oauth/OAuthSignInForm.tsx @@ -0,0 +1,98 @@ +import { AuthorizeOptions } from '@atproto/oauth-client-browser' +import { LockClosedIcon } from '@heroicons/react/20/solid' +import { FormEvent, useCallback, useState } from 'react' + +import { ErrorInfo } from '@/common/ErrorInfo' + +export type OAuthSignIn = (input: string, options?: AuthorizeOptions) => unknown + +/** + * @returns Nice tailwind css form asking to enter either a handle or the host + * to use to login. + */ +export function OAuthSignInForm({ + signIn, + ...props +}: { + signIn: OAuthSignIn +} & Omit, 'onSubmit'>) { + const [value, setValue] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const onSubmit = useCallback( + async (e: FormEvent) => { + e.preventDefault() + e.stopPropagation() + + if (loading) return + + setError(null) + setLoading(true) + + try { + await signIn(value) + } catch (err) { + setError(err?.['message'] || String(err)) + } finally { + setLoading(false) + } + }, + [loading, value, signIn], + ) + + const submitButtonClassNames = `group relative flex w-full justify-center rounded-md border border-transparent py-2 px-4 text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-rose-500 dark:focus:ring-slate-500 focus:ring-offset-2 ${ + loading + ? 'bg-gray-500 hover:bg-gray-600' + : 'bg-rose-600 dark:bg-teal-600 hover:bg-rose-700 dark:hover:bg-teal-700' + }` + const submitButtonIconClassNames = `h-5 w-5 ${ + loading + ? 'text-gray-800 group-hover:text-gray-700' + : 'text-rose-500 dark:text-gray-50 group-hover:text-rose-400 dark:group-hover:text-gray-100' + }` + + return ( +
+
+
+ + { + setError(null) + setValue(e.target.value) + }} + /> +
+
+ + {error != null ? {error} : undefined} + +
+ +
+
+ ) +} diff --git a/components/shell/auth/oauth/useOAuth.ts b/components/shell/auth/oauth/useOAuth.ts new file mode 100644 index 00000000..2bfbdf1a --- /dev/null +++ b/components/shell/auth/oauth/useOAuth.ts @@ -0,0 +1,302 @@ +'use client' + +import { + AuthorizeOptions, + BrowserOAuthClient, + BrowserOAuthClientLoadOptions, + BrowserOAuthClientOptions, + LoginContinuedInParentWindowError, + OAuthAgent, + OAuthClientMetadataInput, +} from '@atproto/oauth-client-browser' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +export type OnRestored = (agent: OAuthAgent | null) => void +export type OnSignedIn = (agent: OAuthAgent, state: null | string) => void +export type OnSignedOut = () => void +export type GetState = () => + | undefined + | string + | PromiseLike + +function useCallbackRef any>( + fn: T, +): (this: ThisParameterType, ...args: Parameters) => ReturnType + +function useCallbackRef any>( + fn?: T, +): (this: ThisParameterType, ...args: Parameters) => void | ReturnType + +function useCallbackRef any>(fn?: T) { + const fnRef = useRef(fn) + useEffect(() => { + fnRef.current = fn + }, [fn]) + return useCallback(function ( + this: ThisParameterType, + ...args: Parameters + ): void | ReturnType { + const { current } = fnRef + if (current) return current.call(this, ...args) + }, []) +} + +type ClientOptions = + | { client: BrowserOAuthClient } + | Pick< + BrowserOAuthClientLoadOptions, + | 'clientId' + | 'handleResolver' + | 'responseMode' + | 'plcDirectoryUrl' + | 'fetch' + > + | Pick< + BrowserOAuthClientOptions, + | 'clientMetadata' + | 'handleResolver' + | 'responseMode' + | 'plcDirectoryUrl' + | 'fetch' + > + +function useOAuthClient(options: ClientOptions): null | BrowserOAuthClient +function useOAuthClient( + options: Partial< + { client: BrowserOAuthClient } & BrowserOAuthClientLoadOptions & + BrowserOAuthClientOptions + >, +) { + const { + client: optionClient, + clientId, + clientMetadata, + handleResolver, + responseMode, + plcDirectoryUrl, + } = options + + const optionsClientMetadata: null | 'auto' | OAuthClientMetadataInput = + !optionClient && (!clientId || clientMetadata != null) + ? clientMetadata || 'auto' + : null + + const fetch = useCallbackRef(options.fetch || globalThis.fetch) + + const oauthClientOptions = useMemo( + () => + optionsClientMetadata + ? { + clientMetadata: + optionsClientMetadata === 'auto' + ? undefined + : optionsClientMetadata, + handleResolver, + responseMode, + plcDirectoryUrl, + fetch, + } + : null, + [ + optionsClientMetadata, + handleResolver, + responseMode, + plcDirectoryUrl, + fetch, + ], + ) + + const optionsClientId = + (!optionClient && !optionsClientMetadata && clientId) || null + + const optionsLoad = useMemo( + () => + optionsClientId + ? { + clientId: optionsClientId, + handleResolver, + responseMode, + plcDirectoryUrl, + fetch, + } + : null, + [optionsClientId, handleResolver, responseMode, plcDirectoryUrl, fetch], + ) + + const [client, setClient] = useState(null) + + useEffect(() => { + if (optionClient) { + setClient(optionClient) + } else if (oauthClientOptions) { + const client = new BrowserOAuthClient(oauthClientOptions) + setClient(client) + return () => client.dispose() + } else if (optionsLoad) { + const ac = new AbortController() + const { signal } = ac + + setClient(null) + + void BrowserOAuthClient.load({ ...optionsLoad, signal }).then( + (client) => { + if (!signal.aborted) { + signal.addEventListener('abort', () => client.dispose(), { + once: true, + }) + setClient(client) + } else { + client.dispose() + } + }, + (err) => { + if (!signal.aborted) throw err + }, + ) + + return () => ac.abort() + } else { + // Should never happen... + setClient(null) + } + }, [optionClient || oauthClientOptions || optionsLoad]) + + return client +} + +export type UseOAuthOptions = ClientOptions & { + onRestored?: OnRestored + onSignedIn?: OnSignedIn + onSignedOut?: OnSignedOut + getState?: GetState +} + +export function useOAuth(options: UseOAuthOptions) { + const onRestored = useCallbackRef(options.onRestored) + const onSignedIn = useCallbackRef(options.onSignedIn) + const onSignedOut = useCallbackRef(options.onSignedOut) + const getState = useCallbackRef(options.getState) + + const [agent, setAgent] = useState(null) + const [client, setClient] = useState(null) + const [isInitializing, setIsInitializing] = useState(client != null) + const [isLoginPopup, setIsLoginPopup] = useState(false) + + const clientForInit = useOAuthClient(options) + const clientForInitRef = useRef() + useEffect(() => { + // In strict mode, we don't want to re-init() the client if it's the same + if (clientForInitRef.current === clientForInit) return + clientForInitRef.current = clientForInit + + setAgent(null) + setClient(null) + setIsLoginPopup(false) + setIsInitializing(clientForInit != null) + + clientForInit + ?.init() + .then( + async (r) => { + if (clientForInitRef.current !== clientForInit) return + + setClient(clientForInit) + if (r) { + setAgent(r.agent) + + if ('state' in r) { + await onSignedIn(r.agent, r.state) + } else { + await onRestored(r.agent) + } + } else { + await onRestored(null) + } + }, + async (err) => { + if (clientForInitRef.current !== clientForInit) return + if (err instanceof LoginContinuedInParentWindowError) { + setIsLoginPopup(true) + return + } + + setClient(clientForInit) + await onRestored(null) + + console.error('Failed to init:', err) + }, + ) + .finally(() => { + if (clientForInitRef.current !== clientForInit) return + + setIsInitializing(false) + }) + }, [clientForInit]) + + useEffect(() => { + if (!client) return + + const controller = new AbortController() + const { signal } = controller + + client.addEventListener( + 'updated', + ({ detail: { sub } }) => { + if (!agent || agent.sub !== sub) { + setAgent(null) + client.restore(sub, false).then((agent) => { + if (!signal.aborted) setAgent(agent) + }) + } + }, + { signal }, + ) + + if (agent) { + client.addEventListener( + 'deleted', + ({ detail: { sub } }) => { + if (agent.sub === sub) { + setAgent(null) + void onSignedOut() + } + }, + { signal }, + ) + } + + void agent?.refreshIfNeeded() + + return () => { + controller.abort() + } + }, [client, agent]) + + const signIn = useCallback( + async (input: string, options?: AuthorizeOptions) => { + if (!client) throw new Error('Client not initialized') + + const state = options?.state ?? (await getState()) ?? undefined + const agent = await client.signIn(input, { ...options, state }) + setAgent(agent) + await onSignedIn(agent, state ?? null) + return agent + }, + [client], + ) + + // Memoize the return value to avoid re-renders in consumers + return useMemo( + () => ({ + isInitializing, + isInitialized: client != null, + isLoginPopup, + + signIn, + + client, + agent, + }), + [isInitializing, isLoginPopup, agent, client, signIn], + ) +} diff --git a/lib/useOAuth.ts b/lib/useOAuth.ts deleted file mode 100644 index 5bff0e98..00000000 --- a/lib/useOAuth.ts +++ /dev/null @@ -1,225 +0,0 @@ -'use client' - -import { BskyAgent } from '@atproto/api' -import { - AuthorizeOptions, - BrowserOAuthClient, - BrowserOAuthClientLoadOptions, - BrowserOAuthClientOptions, - LoginContinuedInParentWindowError, - OAuthAgent, -} from '@atproto/oauth-client-browser' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' - -import { useSignaledEffect } from './useSignaledEffect' - -const CURRENT_AUTHENTICATED_SUB = 'CURRENT_AUTHENTICATED_SUB' - -type Options = { - onRestored?: (agent: OAuthAgent | null) => void - onSignedIn?: (agent: OAuthAgent, state: null | string) => void - onSignedOut?: () => void - getState?: () => string | Promise -} - -export type OAuth = { - isInitialized: boolean - isAuthenticating: boolean - - signIn: (input: string) => Promise - signOut: () => Promise - - oauthClient: BrowserOAuthClient | null - oauthAgent: OAuthAgent | null - /** An agent to use in order to communicate with the user's PDS. */ - pdsAgent: BskyAgent | undefined -} - -export function useOAuth( - config: - | BrowserOAuthClientLoadOptions - | BrowserOAuthClientOptions - | BrowserOAuthClient, - options?: Options, -): OAuth { - const [authenticating, setAuthenticating] = useState(false) - const [initialized, setInitialized] = useState(false) - - const [oauthClient, setOAuthClient] = useState( - () => (config instanceof BrowserOAuthClient ? config : null), - ) - const [oauthAgent, setOAuthAgent] = useState(null) - - const optionsRef = useRef(options) - optionsRef.current = options - - useSignaledEffect( - (signal) => { - if (config instanceof BrowserOAuthClient) { - setOAuthClient(config) - } else if ('clientMetadata' in config) { - setOAuthClient(new BrowserOAuthClient(config)) - } else if ('clientId' in config) { - setOAuthClient(null) - void BrowserOAuthClient.load({ ...config, signal }).then( - (client) => { - if (!signal.aborted) setOAuthClient(client) - }, - (err) => { - if (!signal.aborted) throw err - }, - ) - } else { - setOAuthClient(null) - console.error('Invalid config:', config) - } - }, - [config], - ) - - useEffect(() => { - if (!initialized) return // Process after init is over - - if (oauthAgent) { - localStorage.setItem(CURRENT_AUTHENTICATED_SUB, oauthAgent.sub) - } else { - localStorage.removeItem(CURRENT_AUTHENTICATED_SUB) - } - }, [initialized, oauthAgent]) - - const clientRef = useRef() - useEffect(() => { - // In strict mode, we don't want to reinitialize the client if it's the same - if (clientRef.current === oauthClient) return - clientRef.current = oauthClient - - setInitialized(false) - setOAuthAgent(null) - - oauthClient - ?.init(localStorage.getItem(CURRENT_AUTHENTICATED_SUB) || undefined) - .then( - async (r) => { - if (clientRef.current !== oauthClient) return - - if (r) { - setOAuthAgent(r.agent) - - if ('state' in r) { - await optionsRef.current?.onSignedIn?.(r.agent, r.state) - } else { - await optionsRef.current?.onRestored?.(r.agent) - } - } else { - await optionsRef.current?.onRestored?.(null) - } - }, - async (err) => { - if (clientRef.current !== oauthClient) return - if (err instanceof LoginContinuedInParentWindowError) return - - await optionsRef.current?.onRestored?.(null) - - console.error('Failed to init:', err) - - localStorage.removeItem(CURRENT_AUTHENTICATED_SUB) - }, - ) - .finally(() => { - if (clientRef.current !== oauthClient) return - - setInitialized(true) - }) - }, [oauthClient]) - - useSignaledEffect( - (signal) => { - if (!oauthClient) return - if (!oauthAgent) return - - oauthClient.addEventListener( - 'deleted', - ({ detail }) => { - if (oauthAgent.sub === detail.sub) { - setOAuthAgent(null) - void optionsRef.current?.onSignedOut?.() - } - }, - { signal }, - ) - - void oauthAgent.refreshIfNeeded() - }, - [oauthClient, oauthAgent], - ) - - const signIn = useCallback( - async (input: string, options?: Omit) => { - if (authenticating) throw new Error('Already loading') - if (!oauthClient || !initialized) - throw new Error('Client not initialized') - - setAuthenticating(true) - - try { - const state = await optionsRef.current?.getState?.() - const agent = await oauthClient.signIn(input, { ...options, state }) - setOAuthAgent(agent) - await optionsRef.current?.onSignedIn?.(agent, state ?? null) - } catch (err) { - console.error('Failed to sign in:', err) - throw err - } finally { - setAuthenticating(false) - } - }, - [authenticating, oauthClient, initialized, optionsRef], - ) - - const signOut = useCallback(async () => { - if (authenticating) throw new Error('Already loading') - if (!oauthAgent) throw new Error('Not signed in') - - setAuthenticating(true) - - try { - await oauthAgent.signOut() - } catch (err) { - console.error('Failed to clear credentials', err) - - setOAuthAgent(null) - await optionsRef.current?.onSignedOut?.() - } finally { - setAuthenticating(false) - } - }, [authenticating, oauthAgent, optionsRef]) - - const pdsAgent = useMemo( - () => (oauthAgent ? new BskyAgent(oauthAgent) : undefined), - [oauthAgent], - ) - - // Memoize the return value to avoid re-renders in consumers - return useMemo( - () => ({ - isInitialized: initialized, - isAuthenticating: authenticating, - - signIn, - signOut, - - oauthClient, - oauthAgent, - pdsAgent, - }), - [ - initialized, - authenticating, - oauthClient, - oauthAgent, - pdsAgent, - signIn, - signOut, - ], - ) -}