diff --git a/src/app/oauth-client-metadata.json/route.ts b/src/app/oauth-client-metadata.json/route.ts index 7aeee93..42f0d0a 100644 --- a/src/app/oauth-client-metadata.json/route.ts +++ b/src/app/oauth-client-metadata.json/route.ts @@ -12,11 +12,11 @@ */ import { NextResponse } from 'next/server'; +import { METADATA_SCOPE } from '@/lib/oauth/scopes'; export const runtime = 'edge'; export const dynamic = 'force-dynamic'; -const OAUTH_SCOPE = 'atproto transition:generic'; const REDIRECT_PATH = '/oauth/callback'; function isAllowedHost(hostname: string): boolean { @@ -46,7 +46,7 @@ export async function GET(request: Request): Promise { tos_uri: `${origin}/terms`, policy_uri: `${origin}/terms`, redirect_uris: [`${origin}${REDIRECT_PATH}`], - scope: OAUTH_SCOPE, + scope: METADATA_SCOPE, grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', diff --git a/src/components/AtprotoSessionProvider.tsx b/src/components/AtprotoSessionProvider.tsx index 8856e63..6389c4e 100644 --- a/src/components/AtprotoSessionProvider.tsx +++ b/src/components/AtprotoSessionProvider.tsx @@ -11,7 +11,8 @@ import { } from 'react'; import type { Agent } from '@atproto/api'; import type { OAuthSession } from '@atproto/oauth-client-browser'; -import { getOauthClient, getOauthEvents, OAUTH_SCOPE } from '@/lib/oauth/client'; +import { getOauthClient, getOauthEvents } from '@/lib/oauth/client'; +import { METADATA_SCOPE } from '@/lib/oauth/scopes'; type SessionContextValue = { session: OAuthSession | null; @@ -19,7 +20,12 @@ type SessionContextValue = { did: string | null; loading: boolean; error: Error | null; - signIn: (input: string) => Promise; + /** + * Kick off the OAuth flow. `scope` is the runtime-requested scope string + * (must be a subset of METADATA_SCOPE); defaults to the full superset + * if the caller doesn't pass one (e.g. legacy entry points). + */ + signIn: (input: string, scope?: string) => Promise; signOut: () => Promise; }; @@ -91,9 +97,9 @@ export function AtprotoSessionProvider({ children }: { children: ReactNode }) { }; }, [session]); - const signIn = useCallback(async (input: string) => { + const signIn = useCallback(async (input: string, scope?: string) => { const client = await getOauthClient(); - await client.signIn(input, { scope: OAUTH_SCOPE }); + await client.signIn(input, { scope: scope ?? METADATA_SCOPE }); }, []); const signOut = useCallback(async () => { diff --git a/src/components/SessionMenu.tsx b/src/components/SessionMenu.tsx index 28142e3..4f979e1 100644 --- a/src/components/SessionMenu.tsx +++ b/src/components/SessionMenu.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react'; import Link from 'next/link'; import { ChevronDown, LogIn, LogOut, Settings, Telescope, User } from 'lucide-react'; import { useAtprotoSession } from './AtprotoSessionProvider'; +import ScopeSelector from './oauth/ScopeSelector'; import { getProfile, type AppViewProfile } from '@/utils/atproto/appview'; import { encodeRepo } from '@/utils/atproto/urls'; @@ -22,17 +23,27 @@ export default function SessionMenu({ variant = 'inline' }: { variant?: Variant const { session, did, signIn, signOut, loading } = useAtprotoSession(); const [open, setOpen] = useState(false); const [signInInput, setSignInInput] = useState(''); + const [step, setStep] = useState<'handle' | 'scopes'>('handle'); + const [pendingAccount, setPendingAccount] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [profile, setProfile] = useState(null); const rootRef = useRef(null); + function closePopover() { + setOpen(false); + setStep('handle'); + setPendingAccount(''); + setBusy(false); + setError(null); + } + // Close on outside click. useEffect(() => { if (!open) return; function onClick(e: MouseEvent) { if (rootRef.current && !rootRef.current.contains(e.target as Node)) { - setOpen(false); + closePopover(); } } document.addEventListener('mousedown', onClick); @@ -62,7 +73,7 @@ export default function SessionMenu({ variant = 'inline' }: { variant?: Variant
- {open && ( + {open && step === 'handle' && ( { + onSubmit={() => { const v = signInInput.trim(); if (!v) return; - setBusy(true); setError(null); - try { - await signIn(v); - } catch (err) { - setBusy(false); - setError(err instanceof Error ? err.message : String(err)); - } + setPendingAccount(v); + setStep('scopes'); }} /> )} + {open && step === 'scopes' && ( +
+
+ { + setStep('handle'); + setError(null); + }} + onContinue={async (scopeString) => { + setBusy(true); + setError(null); + try { + await signIn(pendingAccount, scopeString); + } catch (err) { + setBusy(false); + setError(err instanceof Error ? err.message : String(err)); + } + }} + /> +
+
+ )}
); } @@ -215,7 +246,7 @@ function SignInPopover({ Sign in with your atproto handle
- We’ll redirect you to your PDS to authorize Aturi. + We’ll ask which permissions to grant before redirecting.
- {busy ? 'Redirecting…' : 'Continue →'} + {busy ? 'Redirecting…' : 'Next →'} {error && (

diff --git a/src/components/account/AccountPage.tsx b/src/components/account/AccountPage.tsx index 5cc3edd..f94664e 100644 --- a/src/components/account/AccountPage.tsx +++ b/src/components/account/AccountPage.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import Link from 'next/link'; import { CheckCircle2, CircleAlert, Loader2, LogOut, Telescope, User } from 'lucide-react'; import { useAtprotoSession } from '@/components/AtprotoSessionProvider'; +import ScopeSelector from '@/components/oauth/ScopeSelector'; import { usePreferences } from '@/components/PreferencesProvider'; import { getProfile, type AppViewProfile } from '@/utils/atproto/appview'; import { encodeRepo } from '@/utils/atproto/urls'; @@ -14,6 +15,8 @@ export default function AccountPage() { const { session, did, signIn, signOut, loading } = useAtprotoSession(); const { pdsSync } = usePreferences(); const [input, setInput] = useState(''); + const [step, setStep] = useState<'handle' | 'scopes'>('handle'); + const [pendingAccount, setPendingAccount] = useState(''); const [busy, setBusy] = useState(false); const [profile, setProfile] = useState(null); const [error, setError] = useState(null); @@ -52,62 +55,81 @@ export default function AccountPage() { Reorder waypoints, hide ones you don’t use, or add your own. Your preferences sync to your PDS so they follow you across devices.

- { - e.preventDefault(); - const v = input.trim(); - if (!v) return; - setBusy(true); - setError(null); - try { - await signIn(v); - } catch (err) { - setBusy(false); - setError(err instanceof Error ? err.message : String(err)); - } - }} - style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }} - > - setInput(e.target.value)} - disabled={busy} + {step === 'handle' ? ( + { + e.preventDefault(); + const v = input.trim(); + if (!v) return; + setError(null); + setPendingAccount(v); + setStep('scopes'); + }} + style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }} + > + setInput(e.target.value)} + style={{ + padding: '0.75rem 0.875rem', + background: 'var(--bg-secondary)', + border: '1px solid var(--border-medium)', + color: 'var(--text-primary)', + fontFamily: 'var(--font-mono)', + fontSize: '0.9rem', + outline: 'none', + }} + /> + +
+ ) : ( +
- - {error && ( -

- {error} -

- )} - + { + setStep('handle'); + setError(null); + }} + onContinue={async (scopeString) => { + setBusy(true); + setError(null); + try { + await signIn(pendingAccount, scopeString); + } catch (err) { + setBusy(false); + setError(err instanceof Error ? err.message : String(err)); + } + }} + /> +
+ )}

- You’ll be redirected to your PDS to authorize Aturi. We only request the - permissions needed to read and write your preferences record. + You’ll be redirected to your PDS to authorize Aturi. Uncheck any + permission above you don’t want to grant — reads always work + since your repo is public.

); diff --git a/src/components/explore/SignInPanel.tsx b/src/components/explore/SignInPanel.tsx index 05efc94..4894fc7 100644 --- a/src/components/explore/SignInPanel.tsx +++ b/src/components/explore/SignInPanel.tsx @@ -3,35 +3,64 @@ import { useState } from 'react'; import { LogIn } from 'lucide-react'; import { useAtprotoSession } from '@/components/AtprotoSessionProvider'; +import ScopeSelector from '@/components/oauth/ScopeSelector'; /** * Compact sign-in form used inside the record view's action row. Accepts a - * handle / DID; redirects out to the user's OAuth provider. + * handle / DID; redirects out to the user's OAuth provider via a two-step + * flow that lets the user pick which permissions to grant. */ export default function SignInPanel({ defaultInput }: { defaultInput?: string }) { const { signIn } = useAtprotoSession(); const [value, setValue] = useState(defaultInput || ''); + const [step, setStep] = useState<'handle' | 'scopes'>('handle'); + const [pendingAccount, setPendingAccount] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - const v = value.trim(); - if (!v) return; - setBusy(true); - setError(null); - try { - await signIn(v); - // signIn redirects; this is unreachable in normal flow. - } catch (err) { - setBusy(false); - setError(err instanceof Error ? err.message : String(err)); - } + if (step === 'scopes') { + return ( +
+ { + setStep('handle'); + setError(null); + }} + onContinue={async (scopeString) => { + setBusy(true); + setError(null); + try { + await signIn(pendingAccount, scopeString); + } catch (err) { + setBusy(false); + setError(err instanceof Error ? err.message : String(err)); + } + }} + /> +
+ ); } return (
{ + e.preventDefault(); + const v = value.trim(); + if (!v) return; + setError(null); + setPendingAccount(v); + setStep('scopes'); + }} style={{ display: 'flex', flexWrap: 'wrap', @@ -46,7 +75,6 @@ export default function SignInPanel({ defaultInput }: { defaultInput?: string }) placeholder="handle or DID" value={value} onChange={(e) => setValue(e.target.value)} - disabled={busy} style={{ flex: '1 1 220px', minWidth: 0, @@ -61,7 +89,7 @@ export default function SignInPanel({ defaultInput }: { defaultInput?: string }) /> {error && (

void; + onContinue: (scopeString: string) => void | Promise; +} + +/** + * Step 2 of the sign-in flow: granular permission picker. + * + * All four write-side actions default to checked; users can opt out of + * individual ones to grant a narrower scope than `repo:*`. The atproto + * OAuth consent screen at the user's PDS will then only show / authorize + * the subset they actually requested. + */ +export default function ScopeSelector({ + account, + busy, + error, + onBack, + onContinue, +}: Props) { + const [selected, setSelected] = useState>( + new Set(ALL_SCOPE_IDS), + ); + + function toggle(id: ScopeId) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + return ( + { + e.preventDefault(); + void onContinue(buildScopeString(selected)); + }} + style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }} + > +

+ +
+
+ Select permissions +
+
+ for {account} +
+
+
+ +
    + {GRANULAR_SCOPES.map((scope) => { + const checked = selected.has(scope.id); + return ( +
  • + +
  • + ); + })} +
+ + + +

+ Reading records is always allowed — your repo is public. +

+ + {error && ( +

+ {error} +

+ )} +
+ ); +} + +function backButtonStyle(): React.CSSProperties { + return { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 24, + height: 24, + background: 'var(--bg-tertiary)', + border: '1px solid var(--border-subtle)', + color: 'var(--text-secondary)', + cursor: 'pointer', + flexShrink: 0, + }; +} + +function rowStyle(checked: boolean, busy?: boolean): React.CSSProperties { + return { + display: 'flex', + alignItems: 'flex-start', + gap: '0.625rem', + padding: '0.5rem 0.625rem', + background: checked ? 'var(--bg-tertiary)' : 'transparent', + border: '1px solid', + borderColor: checked ? 'var(--border-medium)' : 'var(--border-subtle)', + cursor: busy ? 'wait' : 'pointer', + }; +} diff --git a/src/lib/oauth/client.ts b/src/lib/oauth/client.ts index 11df0f8..f50b9f1 100644 --- a/src/lib/oauth/client.ts +++ b/src/lib/oauth/client.ts @@ -17,6 +17,7 @@ */ import type { BrowserOAuthClient } from '@atproto/oauth-client-browser'; +import { METADATA_SCOPE } from './scopes'; let client: BrowserOAuthClient | null = null; let pending: Promise | null = null; @@ -27,8 +28,6 @@ let pending: Promise | null = null; // EventTarget is a global in Node 18+ too, so this is safe SSR-side. const events = new EventTarget(); -export const OAUTH_SCOPE = 'atproto transition:generic'; - export function getOauthEvents(): EventTarget { return events; } @@ -90,7 +89,7 @@ export async function getOauthClient(): Promise { tos_uri: `${origin}/terms`, policy_uri: `${origin}/terms`, redirect_uris: [`${origin}${redirectPath}`], - scope: OAUTH_SCOPE, + scope: METADATA_SCOPE, grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', diff --git a/src/lib/oauth/scopes.ts b/src/lib/oauth/scopes.ts new file mode 100644 index 0000000..20e8d25 --- /dev/null +++ b/src/lib/oauth/scopes.ts @@ -0,0 +1,68 @@ +/** + * OAuth scope definitions and helpers. + * + * Aturi advertises a superset of granular per-action scopes in its OAuth + * client metadata and then lets users opt out of individual permissions + * at sign-in time. Per the atproto OAuth PAR spec, the runtime-requested + * scope must be a subset of what the metadata advertises — so the + * metadata string is the union of every granular scope below. + * + * Reads aren't gated by a scope (records in the user's own repo are + * public), so the picker only exposes write-side actions. + */ + +export type ScopeId = 'create' | 'update' | 'delete' | 'blob'; + +export type GranularScope = { + id: ScopeId; + scope: string; + label: string; + hint: string; +}; + +export const GRANULAR_SCOPES: GranularScope[] = [ + { + id: 'create', + scope: 'repo:*?action=create', + label: 'Create records', + hint: 'Add new records to any collection in your repo.', + }, + { + id: 'update', + scope: 'repo:*?action=update', + label: 'Update records', + hint: 'Edit existing records (Record Editor, preferences).', + }, + { + id: 'delete', + scope: 'repo:*?action=delete', + label: 'Delete records', + hint: 'Remove records from your repo.', + }, + { + id: 'blob', + scope: 'blob:*/*', + label: 'Upload blobs', + hint: 'Upload images and other media attachments.', + }, +]; + +export const BASE_SCOPE = 'atproto'; + +/** Superset string baked into oauth-client-metadata.json. */ +export const METADATA_SCOPE = [ + BASE_SCOPE, + ...GRANULAR_SCOPES.map((s) => s.scope), +].join(' '); + +export const ALL_SCOPE_IDS: ReadonlySet = new Set( + GRANULAR_SCOPES.map((s) => s.id), +); + +/** Build the runtime scope string from a set of selected granular IDs. */ +export function buildScopeString(selected: Set): string { + const granular = GRANULAR_SCOPES.filter((s) => selected.has(s.id)).map( + (s) => s.scope, + ); + return [BASE_SCOPE, ...granular].join(' '); +}