social components inlay.at
atproto components sdui
86
fork

Configure Feed

Select the types of activity you want to include in your feed.

request oauth scopes for lexicon publishing when needed

+117 -21
+69 -12
app/edit/actions.tsx
··· 42 42 }; 43 43 } 44 44 45 + const LEXICON_SCOPE = "repo:com.atproto.lexicon.schema"; 46 + 47 + async function hasLexiconScope(): Promise<boolean> { 48 + const session = await getSessionAgent(); 49 + if (!session) return false; 50 + const info = await session.getTokenInfo(false); 51 + const scopes = info.scope.split(" "); 52 + return scopes.some((s) => s.startsWith(LEXICON_SCOPE)); 53 + } 54 + 55 + async function parsePdsError( 56 + res: Response 57 + ): Promise<{ success: false; error: string; needsReauth?: boolean }> { 58 + const body = await res.text().catch(() => ""); 59 + let parsed: { error?: string; message?: string } = {}; 60 + try { 61 + parsed = JSON.parse(body); 62 + } catch {} 63 + if (res.status === 403 && parsed.error === "ScopeMissingError") { 64 + return { 65 + success: false, 66 + error: "Inlay needs permission to manage lexicons. Please re-authorize.", 67 + needsReauth: true, 68 + }; 69 + } 70 + return { success: false, error: parsed.message ?? `PDS error ${res.status}` }; 71 + } 72 + 73 + export type LexiconSaveResult = 74 + | { success: true } 75 + | { success: false; error: string; needsReauth?: boolean }; 76 + 45 77 export async function saveLexiconAction( 46 78 nsid: string, 47 79 lexiconJson: string 48 - ): Promise<{ success: true } | { success: false; error: string }> { 80 + ): Promise<LexiconSaveResult> { 49 81 let raw: unknown; 50 82 try { 51 83 raw = JSON.parse(lexiconJson); ··· 67 99 const did = await getCurrentDid(); 68 100 if (!did) return { success: false, error: "Not authenticated" }; 69 101 102 + if (!(await hasLexiconScope())) { 103 + return { 104 + success: false, 105 + error: "Inlay needs permission to manage lexicons. Please re-authorize.", 106 + needsReauth: true, 107 + }; 108 + } 109 + 70 110 // Verify ownership 71 111 const ownerDid = await resolveNamespaceOwner(nsid); 72 112 if (ownerDid !== did) { ··· 77 117 if (!session) return { success: false, error: "No session" }; 78 118 79 119 try { 80 - await session.fetchHandler("/xrpc/com.atproto.repo.putRecord", { 120 + const res = await session.fetchHandler("/xrpc/com.atproto.repo.putRecord", { 81 121 method: "POST", 82 122 headers: { "Content-Type": "application/json" }, 83 123 body: JSON.stringify({ ··· 87 127 record: doc, 88 128 }), 89 129 }); 130 + if (res.status !== 200) { 131 + return parsePdsError(res); 132 + } 90 133 updateTag(`lexicon:${nsid}`); 91 134 updateTag(`lexicon-authority:${nsid}`); 92 135 return { success: true }; ··· 97 140 98 141 export async function deleteLexiconAction( 99 142 nsid: string 100 - ): Promise<{ success: true } | { success: false; error: string }> { 143 + ): Promise<LexiconSaveResult> { 101 144 const did = await getCurrentDid(); 102 145 if (!did) return { success: false, error: "Not authenticated" }; 103 146 147 + if (!(await hasLexiconScope())) { 148 + return { 149 + success: false, 150 + error: "Inlay needs permission to manage lexicons. Please re-authorize.", 151 + needsReauth: true, 152 + }; 153 + } 154 + 104 155 const ownerDid = await resolveNamespaceOwner(nsid); 105 156 if (ownerDid !== did) { 106 157 return { success: false, error: "You don't own this namespace" }; ··· 110 161 if (!session) return { success: false, error: "No session" }; 111 162 112 163 try { 113 - await session.fetchHandler("/xrpc/com.atproto.repo.deleteRecord", { 114 - method: "POST", 115 - headers: { "Content-Type": "application/json" }, 116 - body: JSON.stringify({ 117 - repo: did, 118 - collection: "com.atproto.lexicon.schema", 119 - rkey: nsid, 120 - }), 121 - }); 164 + const res = await session.fetchHandler( 165 + "/xrpc/com.atproto.repo.deleteRecord", 166 + { 167 + method: "POST", 168 + headers: { "Content-Type": "application/json" }, 169 + body: JSON.stringify({ 170 + repo: did, 171 + collection: "com.atproto.lexicon.schema", 172 + rkey: nsid, 173 + }), 174 + } 175 + ); 176 + if (res.status !== 200) { 177 + return parsePdsError(res); 178 + } 122 179 updateTag(`lexicon:${nsid}`); 123 180 updateTag(`lexicon-authority:${nsid}`); 124 181 return { success: true };
+14 -5
app/edit/lexicon-modal.tsx
··· 9 9 saveLexiconAction, 10 10 deleteLexiconAction, 11 11 recheckNamespaceAction, 12 + type LexiconSaveResult, 12 13 } from "./actions"; 14 + import { getReauthorizeUrl } from "@/auth/actions"; 13 15 import { GhostButton } from "@/app/ghost-button"; 14 16 import { PrimaryButton } from "@/app/primary-button"; 15 17 import { ··· 125 127 }, []); 126 128 127 129 // Save 128 - type SaveResult = 129 - | { success: true } 130 - | { success: false; error: string } 131 - | null; 132 130 const [saveResult, dispatchSave, isSaving] = useActionState( 133 - async (_prev: SaveResult, json: string) => { 131 + async (_prev: LexiconSaveResult | null, json: string) => { 134 132 const result = await saveLexiconAction(nsid, json); 135 133 if (result.success) { 136 134 onRefresh(); 137 135 onClose(); 136 + } 137 + if (!result.success && result.needsReauth) { 138 + const url = await getReauthorizeUrl( 139 + window.location.pathname + window.location.search, 140 + [ 141 + "repo:com.atproto.lexicon.schema?action=create", 142 + "repo:com.atproto.lexicon.schema?action=update", 143 + "repo:com.atproto.lexicon.schema?action=delete", 144 + ] 145 + ); 146 + window.location.href = url; 138 147 } 139 148 return result; 140 149 },
+22
auth/actions.ts
··· 63 63 } 64 64 } 65 65 66 + export async function getReauthorizeUrl( 67 + returnUrl: string, 68 + extraScopes?: string[] 69 + ): Promise<string> { 70 + const session = await getSession(); 71 + const did = session.did; 72 + if (!did) throw new Error("Not authenticated"); 73 + 74 + const scope = [ 75 + "atproto", 76 + "include:at.inlay.authFullPermissions", 77 + ...(extraScopes ?? []), 78 + ].join(" "); 79 + 80 + const client = await getOAuthClient(); 81 + const url = await client.authorize(did, { 82 + scope, 83 + state: JSON.stringify({ returnUrl }), 84 + }); 85 + return url.toString(); 86 + } 87 + 66 88 export async function logout() { 67 89 const session = await getSession(); 68 90 session.did = undefined;
+11 -3
auth/client.ts
··· 71 71 72 72 const hasSigningKey = keyset && keyset.size > 0; 73 73 74 + const BASE_SCOPE = "atproto include:at.inlay.authFullPermissions"; 75 + const LEXICON_SCOPES = [ 76 + "repo:com.atproto.lexicon.schema?action=create", 77 + "repo:com.atproto.lexicon.schema?action=update", 78 + "repo:com.atproto.lexicon.schema?action=delete", 79 + ].join(" "); 80 + const FULL_SCOPE = `${BASE_SCOPE} ${LEXICON_SCOPES}`; 81 + 74 82 const clientMetadata: OAuthClientMetadataInput = PUBLIC_URL 75 83 ? { 76 84 client_name: "Inlay", 77 85 client_id: `${PUBLIC_URL}/oauth-client-metadata.json`, 78 86 client_uri: PUBLIC_URL, 79 87 redirect_uris: [`${PUBLIC_URL}/oauth/callback`], 80 - scope: "atproto include:at.inlay.authFullPermissions", 88 + scope: FULL_SCOPE, 81 89 grant_types: ["authorization_code", "refresh_token"], 82 90 response_types: ["code"], 83 91 application_type: "web", ··· 90 98 client_name: "Inlay (Development)", 91 99 client_id: `http://localhost?redirect_uri=${encodeURIComponent( 92 100 "http://127.0.0.1:3000/oauth/callback" 93 - )}&scope=${encodeURIComponent("atproto include:at.inlay.authFullPermissions")}`, 101 + )}&scope=${encodeURIComponent(FULL_SCOPE)}`, 94 102 redirect_uris: ["http://127.0.0.1:3000/oauth/callback"], 95 - scope: "atproto include:at.inlay.authFullPermissions", 103 + scope: FULL_SCOPE, 96 104 grant_types: ["authorization_code", "refresh_token"], 97 105 response_types: ["code"], 98 106 application_type: "web",
+1 -1
auth/index.ts
··· 13 13 getServiceJwt, 14 14 } from "./session"; 15 15 export type { SessionData } from "./session"; 16 - export { getLoginUrl, logout } from "./actions"; 16 + export { getLoginUrl, getReauthorizeUrl, logout } from "./actions";