Add account connecting to your site
Put a panel on your own page where your users connect, reconnect and disconnect their social accounts, without your API key ever reaching the browser.
TL;DR
| Question | Answer |
|---|---|
| What are the parts? | A route on your server that makes a connect token, and <flung-connect> on the page, which uses it. |
| What does a person see? | The platforms you offer and their connected accounts, with Connect, Reconnect and Disconnect. Connecting opens the platform's own sign-in in a small window. |
| Which frameworks? | Any. Next.js, React with Vite and a plain page are shown below, each from a working example. |
| Can each of my users have their own accounts? | Yes: make each token with that user's profileId. |
Install
npm install @flung.dev/sdk @flung.dev/connectNext.js
The server keeps the key, in .env.local:
.env.local
# A full-access Flung API key (flung_sk_...). Server only: never prefix it with NEXT_PUBLIC_.FLUNG_API_KEY=
# Optional. The API: https://api.flung.dev (the default) or https://api-preview.flung.dev.FLUNG_BASE_URL=
# The origin this app is served from, which connect tokens are made for. http://localhost:3000 when left out.APP_ORIGIN=A route makes a connect token for the page. Put your own sign-in check at its top:
app/api/connect-token/route.ts
import { Flung, FlungError } from '@flung.dev/sdk'
/** * `POST /api/connect-token`: a short-lived connect token for this app's own page. The API key stays here on the server; * the token it makes can only list, connect, reconnect and disconnect accounts, from `APP_ORIGIN`, for fifteen minutes. * * In a real app, check who is signed in first, and pass their profile as `profileId` so each person sees only theirs. */export async function POST(): Promise<Response> { const apiKey = process.env.FLUNG_API_KEY if (apiKey === undefined || apiKey === '') return Response.json({ error: 'Set FLUNG_API_KEY in .env.local.' }, { status: 500 }) const flung = new Flung({ apiKey, ...(process.env.FLUNG_BASE_URL ? { baseUrl: process.env.FLUNG_BASE_URL } : {}) }) try { const { token, expiresAt } = await flung.connectTokens.create({ origins: [process.env.APP_ORIGIN || 'http://localhost:3000'], expiresIn: 900 }) return Response.json({ token, expiresAt }, { headers: { 'cache-control': 'no-store' } }) } catch (error) { const status = error instanceof FlungError ? (error.status ?? 502) : 502 return Response.json({ error: error instanceof Error ? error.message : 'Could not make a connect token.' }, { status }) }}The page shows the panel, which asks the route for a token, and again when one expires:
app/accounts.tsx
'use client'
import { useCallback, useState } from 'react'
import { FlungConnect } from '@flung.dev/connect/react'
/** Asks this app's server for a connect token; the panel calls it again when a token expires. */async function connectToken(): Promise<string> { const response = await fetch('/api/connect-token', { method: 'POST' }) const body = (await response.json()) as { token?: string; error?: string } if (!response.ok || body.token === undefined) throw new Error(body.error ?? 'Could not make a connect token.') return body.token}
export function Accounts({ baseUrl }: { baseUrl: string | undefined }) { const [events, setEvents] = useState<string[]>([]) const log = useCallback((line: string) => setEvents((previous) => [line, ...previous].slice(0, 5)), []) return ( <> <FlungConnect token={connectToken} baseUrl={baseUrl} onConnect={({ accounts }) => log(`Connected ${accounts.map((account) => account.username ?? account.id).join(', ')}`)} onDisconnect={({ account }) => log(`Disconnected ${account.username ?? account.id}`)} onError={({ code, message }) => log(`${code}: ${message}`)} /> {events.length > 0 && ( <ul className="events" aria-label="What happened"> {events.map((line, index) => ( <li key={`${index}-${line}`}>{line}</li> ))} </ul> )} </> )}React with Vite
For trying the panel out, Vite's development server can make the token itself:
vite.config.ts
import { Flung } from '@flung.dev/sdk'import { defineConfig, loadEnv } from 'vite'import type { Connect, Plugin } from 'vite'
/** * `POST /connect-token` on the dev and preview servers: makes a connect token for the page with the key from * `.env.local`. The key has no `VITE_` prefix, so Vite never puts it in the bundle. This is for trying the panel out; a * deployed app makes tokens on its own server, behind its own sign-in. */function connectTokens(apiKey: string, baseUrl: string | undefined): Plugin { const handler: Connect.NextHandleFunction = (request, response, next) => { if (request.method !== 'POST' || request.url !== '/connect-token') return next() const send = (status: number, body: unknown) => { response.statusCode = status response.setHeader('content-type', 'application/json') response.setHeader('cache-control', 'no-store') response.end(JSON.stringify(body)) } if (apiKey === '') return send(500, { error: 'Set FLUNG_API_KEY in .env.local.' }) const origin = request.headers.origin ?? `http://${request.headers.host ?? 'localhost:5173'}` const flung = new Flung({ apiKey, ...(baseUrl ? { baseUrl } : {}) }) flung.connectTokens.create({ origins: [origin], expiresIn: 900 }).then( ({ token }) => send(200, { token }), (error: unknown) => send(502, { error: error instanceof Error ? error.message : 'Could not make a connect token.' }), ) } return { name: 'flung-connect-tokens', configureServer: (server) => void server.middlewares.use(handler), configurePreviewServer: (server) => void server.middlewares.use(handler) }}
export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') return { plugins: [connectTokens(env.FLUNG_API_KEY ?? '', env.FLUNG_BASE_URL || undefined)], define: { 'import.meta.env.FLUNG_BASE_URL': JSON.stringify(env.FLUNG_BASE_URL || '') }, esbuild: { jsx: 'automatic' }, }})src/App.tsx
import { useCallback, useState } from 'react'
import { FlungConnect } from '@flung.dev/connect/react'
/** Asks the dev server for a connect token; the panel calls it again when a token expires. */async function connectToken(): Promise<string> { const response = await fetch('/connect-token', { method: 'POST' }) const body = (await response.json()) as { token?: string; error?: string } if (!response.ok || body.token === undefined) throw new Error(body.error ?? 'Could not make a connect token.') return body.token}
const baseUrl = import.meta.env.FLUNG_BASE_URL || undefined
export function App() { const [events, setEvents] = useState<string[]>([]) const log = useCallback((line: string) => setEvents((previous) => [line, ...previous].slice(0, 5)), []) return ( <main> <h1>Connect your accounts</h1> <p>We post to the accounts you connect here. You can disconnect them at any time.</p> <FlungConnect token={connectToken} baseUrl={baseUrl} onConnect={({ accounts }) => log(`Connected ${accounts.map((account) => account.username ?? account.id).join(', ')}`)} onDisconnect={({ account }) => log(`Disconnected ${account.username ?? account.id}`)} onError={({ code, message }) => log(`${code}: ${message}`)} /> {events.length > 0 && ( <ul className="events" aria-label="What happened"> {events.map((line, index) => ( <li key={`${index}-${line}`}>{line}</li> ))} </ul> )} </main> )}A deployed app makes tokens on its own server, behind its own sign-in, as the Next.js route does.
A plain page
No framework and nothing to install: a server of a few lines makes the token, and the page loads the panel.
server.mjs
#!/usr/bin/env node/** * The smallest server a connect panel needs, with nothing to install: it keeps the API key, makes a connect token for * the page when the page asks, and serves the page and the built package. * * pnpm build * FLUNG_API_KEY=flung_sk_... node examples/vanilla/server.mjs * * Then open http://localhost:5173. FLUNG_BASE_URL=https://api-preview.flung.dev tries it against preview. */import { readFile } from 'node:fs/promises'import { createServer } from 'node:http'import { extname, join, normalize } from 'node:path'
const PORT = Number(process.env.PORT ?? 5173)const ORIGIN = `http://localhost:${PORT}`const BASE_URL = process.env.FLUNG_BASE_URL ?? 'https://api.flung.dev'const API_KEY = process.env.FLUNG_API_KEYif (API_KEY === undefined) throw new Error('Set FLUNG_API_KEY to a full-access Flung API key.')
const here = import.meta.dirnameconst dist = join(here, '..', '..', 'packages', 'connect', 'dist')const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8' }
/** In your app, check who is signed in here first, and pass their profile as `profileId`. */async function connectToken() { const response = await fetch(`${BASE_URL}/v1/connect-tokens`, { method: 'POST', headers: { authorization: `Bearer ${API_KEY}`, 'content-type': 'application/json' }, body: JSON.stringify({ origins: [ORIGIN], expiresIn: 900 }), }) return { status: response.status, body: await response.text() }}
createServer(async (request, response) => { const path = new URL(request.url ?? '/', ORIGIN).pathname if (request.method === 'POST' && path === '/connect-token') { const made = await connectToken() response.writeHead(made.status, { 'content-type': 'application/json' }).end(made.body) return } const file = path === '/' ? join(here, 'index.html') : path.startsWith('/connect/') ? normalize(join(dist, path.slice('/connect/'.length))) : null if (file === null || (file !== join(here, 'index.html') && !file.startsWith(dist))) { response.writeHead(404).end() return } try { const page = file.endsWith('index.html') ? (await readFile(file, 'utf8')).replace('__FLUNG_BASE_URL__', BASE_URL) : await readFile(file) response.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream' }).end(page) } catch { response.writeHead(404).end() }}).listen(PORT, () => process.stdout.write(`Open ${ORIGIN}\n`))index.html
<!doctype html><html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Connect your accounts</title> <style> body { font: 16px/1.5 system-ui, sans-serif; max-width: 36rem; margin: 8vh auto; padding: 0 16px; } @media (prefers-color-scheme: dark) { body { background: #09090b; color: #f4f4f5; } } pre { font-size: 13px; overflow: auto; } </style> </head> <body> <h1>Connect your accounts</h1> <p>Flung posts to the accounts you connect here. You can disconnect them at any time.</p> <flung-connect id="panel" base-url="__FLUNG_BASE_URL__"></flung-connect> <pre id="log"></pre> <script type="module"> import '/connect/register.js'
const panel = document.getElementById('panel') const log = (type, detail) => (document.getElementById('log').textContent += `${type} ${JSON.stringify(detail)}\n`) // A function, so the panel asks for a fresh token when one expires. panel.token = async () => { const response = await fetch('/connect-token', { method: 'POST' }) if (!response.ok) throw new Error(`Could not make a connect token: ${await response.text()}`) return (await response.json()).token } for (const type of ['flung-connect', 'flung-disconnect', 'flung-error']) panel.addEventListener(type, (event) => log(type, event.detail)) </script> </body></html>How it behaves
- The panel offers only the platforms this Flung can connect, narrowed by its
platformsattribute and the token's own list. - Connecting opens the platform's sign-in in a small window. When a browser blocks the window, the panel shows a link instead.
- Mastodon asks for the person's server first; Bluesky takes a handle when they have one.
- Disconnecting asks to confirm. The account then leaves the panel.
- With
mode="redirect"the whole page goes to the platform and back, for pages that cannot open windows.
Every attribute, event, style hook and word is in the connect panel reference.