diff --git a/README.md b/README.md index b5b2950..f4d0670 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,86 @@ -# sv +# svelte atproto client oauth demo -Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). +this is a scaffold for how to get client side oauth working with sveltekit and atproto +using the `@atcute/oauth-browser-client` library. -## Creating a project +perfect for static builds e.g. using github pages. -If you're seeing this, you've probably already done this step. Congrats! +## how to install -```bash -# create a new project in the current directory -npx sv create +### either clone this repo -# create a new project in my-app -npx sv create my-app +1. clone this repo +2. run `npm install` +3. run `npm run dev` +4. go to `http://127.0.0.1:5179` +5. for deployment change the `SITE_URL` variable in `src/lib/oauth/const.ts` +(e.g. for github pages: `https://your-username.github.io`) and set your base in `svelte.config.js` +(e.g. for github pages: `base: '/your-repo-name/'`) + + +### or manually install in your own project + +- copy the `src/lib/oauth` folder into your own project +- also copy the `src/routes/client-metadata.json` folder into your project +- add the following to your `src/routes/+layout.svelte` + +```svelte + + +{@render children()} ``` -## Developing +## how to use -Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: +### login flow -```bash -npm run dev +Either use the `LoginModal` component to render a login modal or use the `client` object to handle the login flow yourself. -# or start the server and open the app in a new browser tab -npm run dev -- --open +```ts +// handlin login flow yourself +import { client } from '$lib/oauth'; + +// methods: +client.login(handle); // login the user +client.isLoggedIn; // check if the user is logged in +client.logout(); // logout the user ``` -## Building +LoginModal is a component that renders a login modal, add it for a quick login flow. + +```svelte + -To create a production version of your app: + -```bash -npm run build + ``` -You can preview the production build with `npm run preview`. +### make requests + +Get the user's profile and make requests with the `client.rpc` object. -> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. +```ts +import { client } from '$lib/oauth'; + +// get the user's profile +const profile = client.profile; + +// make requests with the client.rpc object +const response = await client.rpc.request({ + type: 'get', + nsid: 'app.bsky.feed.getActorLikes', + params: { + actor: client.profile?.did, + limit: 10 + } +}); +``` diff --git a/src/lib/UI/Input.svelte b/src/lib/UI/Input.svelte deleted file mode 100644 index 3d0284d..0000000 --- a/src/lib/UI/Input.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/src/lib/UI/Button.svelte b/src/lib/oauth/UI/Button.svelte similarity index 100% rename from src/lib/UI/Button.svelte rename to src/lib/oauth/UI/Button.svelte diff --git a/src/lib/UI/LoginModal.svelte b/src/lib/oauth/UI/LoginModal.svelte similarity index 73% rename from src/lib/UI/LoginModal.svelte rename to src/lib/oauth/UI/LoginModal.svelte index 3f811c6..33e76be 100644 --- a/src/lib/UI/LoginModal.svelte +++ b/src/lib/oauth/UI/LoginModal.svelte @@ -1,7 +1,13 @@ + + -{#if showLoginModal.visible} +{#if loginModalState.visible}
(showLoginModal.visible = false)} + onclick={() => (loginModalState.visible = false)} aria-hidden="true" >
@@ -63,12 +69,12 @@ -
+
Your handle
@@ -92,6 +98,18 @@ >{loading ? 'Loading...' : 'Login'}
+ +
+ Don't have an account? +
+ + Create one on bluesky + , then sign in here. +
diff --git a/src/lib/auth.svelte.ts b/src/lib/oauth/auth.svelte.ts similarity index 67% rename from src/lib/auth.svelte.ts rename to src/lib/oauth/auth.svelte.ts index 2a0c704..ea595e7 100644 --- a/src/lib/auth.svelte.ts +++ b/src/lib/oauth/auth.svelte.ts @@ -9,11 +9,9 @@ import { } from '@atcute/oauth-browser-client'; import { dev } from '$app/environment'; import { XRPC } from '@atcute/client'; -import { base } from '$app/paths'; +import { metadata } from './const'; -export const URL = 'https://flo-bit.dev'; - -export const data = $state({ +export const client = $state({ agent: null as OAuthUserAgent | null, session: null as Session | null, rpc: null as XRPC | null, @@ -29,22 +27,23 @@ export const data = $state({ followsCount?: number; postsCount?: number; } | null, - isInitializing: true + isInitializing: true, + isLoggedIn: false }); -export async function initOAuthClient() { - data.isInitializing = true; +export async function initClient() { + client.isInitializing = true; const clientId = dev ? `http://localhost` + `?redirect_uri=${encodeURIComponent('http://127.0.0.1:5179')}` + - `&scope=${encodeURIComponent('atproto transition:generic')}` - : `${URL}${base}/client-metadata.json`; + `&scope=${encodeURIComponent(metadata.scope)}` + : metadata.client_id; configureOAuth({ metadata: { client_id: clientId, - redirect_uri: `${dev ? 'http://127.0.0.1:5179' : URL + base}` + redirect_uri: `${dev ? 'http://127.0.0.1:5179' : metadata.redirect_uris[0]}` } }); @@ -58,7 +57,42 @@ export async function initOAuthClient() { await resumeSession(did); } - data.isInitializing = false; + client.isInitializing = false; +} + +export async function login(handle: string) { + if (handle.startsWith('did:')) { + if (handle.length > 5) await authorizationFlow(handle); + else throw new Error('DID must be at least 6 characters'); + } else if (handle.includes('.') && handle.length > 3) { + const processed = handle.startsWith('@') ? handle.slice(1) : handle; + if (processed.length > 3) await authorizationFlow(processed); + else throw new Error('Handle must be at least 4 characters'); + } else if (handle.length > 3) { + const processed = (handle.startsWith('@') ? handle.slice(1) : handle) + '.bsky.social'; + await authorizationFlow(processed); + } else { + throw new Error('Please provide a valid handle, DID, or PDS URL'); + } +} + +export async function logout() { + const currentAgent = client.agent; + if (currentAgent) { + const did = currentAgent.session.info.sub; + + localStorage.removeItem('last-login'); + localStorage.removeItem(`profile-${did}`); + + await currentAgent.signOut(); + client.session = null; + client.agent = null; + client.profile = null; + + client.isLoggedIn = false; + } else { + throw new Error('Not signed in'); + } } async function finalizeLogin(params: URLSearchParams, did?: string) { @@ -66,12 +100,14 @@ async function finalizeLogin(params: URLSearchParams, did?: string) { history.replaceState(null, '', location.pathname + location.search); const session = await finalizeAuthorization(params); - data.session = session; + client.session = session; setAgentAndXRPC(session); + localStorage.setItem('last-login', session.info.sub); await loadProfile(session.info.sub); - localStorage.setItem('last-login', session.info.sub); + + client.isLoggedIn = true; } catch (error) { console.error('error finalizing login', error); if (did) { @@ -83,20 +119,22 @@ async function finalizeLogin(params: URLSearchParams, did?: string) { async function resumeSession(did: string) { try { const session = await getSession(did as `did:${string}`, { allowStale: true }); - data.session = session; + client.session = session; setAgentAndXRPC(session); await loadProfile(session.info.sub); + + client.isLoggedIn = true; } catch (error) { console.error('error resuming session', error); } } function setAgentAndXRPC(session: Session) { - data.agent = new OAuthUserAgent(session); + client.agent = new OAuthUserAgent(session); - data.rpc = new XRPC({ handler: data.agent }); + client.rpc = new XRPC({ handler: client.agent }); } async function loadProfile(actor: string) { @@ -104,46 +142,30 @@ async function loadProfile(actor: string) { const profile = localStorage.getItem(`profile-${actor}`); if (profile) { console.log('loading profile from local storage'); - data.profile = JSON.parse(profile); + client.profile = JSON.parse(profile); return; } console.log('loading profile from server'); - const response = await data.rpc?.request({ + const response = await client.rpc?.request({ type: 'get', nsid: 'app.bsky.actor.getProfile', params: { actor } }); if (response) { - data.profile = response.data; + client.profile = response.data; localStorage.setItem(`profile-${actor}`, JSON.stringify(response.data)); } } -export async function trySignIn(value: string) { - if (value.startsWith('did:')) { - if (value.length > 5) await signIn(value); - else throw new Error('DID must be at least 6 characters'); - } else if (value.includes('.') && value.length > 3) { - const handle = value.startsWith('@') ? value.slice(1) : value; - if (handle.length > 3) await signIn(handle); - else throw new Error('Handle must be at least 4 characters'); - } else if (value.length > 3) { - const handle = (value.startsWith('@') ? value.slice(1) : value) + '.bsky.social'; - await signIn(handle); - } else { - throw new Error('Please provide a valid handle, DID, or PDS URL'); - } -} - -export async function signIn(input: string) { - const { identity, metadata } = await resolveFromIdentity(input); +async function authorizationFlow(input: string) { + const { identity, metadata: meta } = await resolveFromIdentity(input); const authUrl = await createAuthorizationUrl({ - metadata: metadata, + metadata: meta, identity: identity, - scope: 'atproto transition:generic' + scope: metadata.scope }); await new Promise((resolve) => setTimeout(resolve, 200)); @@ -158,20 +180,3 @@ export async function signIn(input: string) { window.addEventListener('pageshow', listener, { once: true }); }); } - -export async function signOut() { - const currentAgent = data.agent; - if (currentAgent) { - const did = currentAgent.session.info.sub; - - localStorage.removeItem('last-login'); - localStorage.removeItem(`profile-${did}`); - - await currentAgent.signOut(); - data.session = null; - data.agent = null; - data.profile = null; - } else { - throw new Error('Not signed in'); - } -} diff --git a/src/lib/client-metadata.ts b/src/lib/oauth/const.ts similarity index 67% rename from src/lib/client-metadata.ts rename to src/lib/oauth/const.ts index 1468e1a..079743b 100644 --- a/src/lib/client-metadata.ts +++ b/src/lib/oauth/const.ts @@ -1,10 +1,11 @@ import { base } from '$app/paths'; -import { URL } from './auth.svelte'; + +export const SITE_URL = 'https://flo-bit.dev'; export const metadata = { - client_id: `${URL}${base}/client-metadata.json`, + client_id: `${SITE_URL}${base}/client-metadata.json`, - redirect_uris: [URL + base], + redirect_uris: [SITE_URL + base], scope: 'atproto transition:generic', grant_types: ['authorization_code', 'refresh_token'], diff --git a/src/lib/oauth/index.ts b/src/lib/oauth/index.ts new file mode 100644 index 0000000..15c653b --- /dev/null +++ b/src/lib/oauth/index.ts @@ -0,0 +1,6 @@ +import { client, login, logout, initClient } from './auth.svelte'; +import LoginModal, { loginModalState } from './UI/LoginModal.svelte'; +import Button from './UI/Button.svelte'; +import { metadata } from './const'; + +export { metadata, client, login, logout, initClient, LoginModal, Button, loginModalState }; diff --git a/src/lib/state.svelte.ts b/src/lib/state.svelte.ts deleted file mode 100644 index 59b0290..0000000 --- a/src/lib/state.svelte.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const showLoginModal = $state({ - visible: false -}); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 373df8f..f3db058 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,13 +1,13 @@ diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index bd55336..1e04f84 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,7 +1,5 @@ -
+

svelte atproto client oauth demo

- {#if data.isInitializing} + source code + + {#if client.isInitializing}
loading...
{/if} - {#if !data.isInitializing && !data.agent} + {#if !client.isInitializing && !client.agent}
not signed in
- + {/if} - {#if data.agent} + {#if client.isLoggedIn}
signed in as
avatar of {data.profile?.handle} - {data.profile?.handle} + {client.profile?.handle}
- + - - {#if likes.length > 0}
recent likes
    diff --git a/src/routes/client-metadata.json/+server.ts b/src/routes/client-metadata.json/+server.ts index 17ac8c7..a668e20 100644 --- a/src/routes/client-metadata.json/+server.ts +++ b/src/routes/client-metadata.json/+server.ts @@ -1,4 +1,4 @@ -import { metadata } from '$lib/client-metadata'; +import { metadata } from '$lib/oauth'; import { json } from '@sveltejs/kit'; export const prerender = true; diff --git a/svelte.config.js b/svelte.config.js index cc35615..4b8510e 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -8,9 +8,6 @@ const config = { preprocess: vitePreprocess(), kit: { - // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. - // If your environment is not supported, or you settled on a specific environment, switch out the adapter. - // See https://svelte.dev/docs/kit/adapters for more information about adapters. adapter: adapter(), paths: { base: process.env.NODE_ENV === 'development' ? '' : '/svelte-atproto-client-oauth'