diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index f35fef27..0aff3481 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -2,7 +2,10 @@ import * as Y from "yjs"; import * as base64 from "base64-js"; -import { createOauthClient } from "src/atproto-oauth"; +import { + restoreOAuthSession, + OAuthSessionError, +} from "src/atproto-oauth"; import { getIdentityData } from "actions/getIdentityData"; import { AtpBaseClient, @@ -50,6 +53,10 @@ import { parseColor } from "@react-stately/color"; import { Notification, pingIdentityToUpdateNotification } from "src/notifications"; import { v7 } from "uuid"; +type PublishResult = + | { success: true; rkey: string; record: PubLeafletDocument.Record } + | { success: false; error: OAuthSessionError }; + export async function publishToPublication({ root_entity, publication_uri, @@ -68,12 +75,24 @@ export async function publishToPublication({ tags?: string[]; cover_image?: string | null; entitiesToDelete?: string[]; -}) { - const oauthClient = await createOauthClient(); +}): Promise { let identity = await getIdentityData(); - if (!identity || !identity.atp_did) throw new Error("No Identity"); + if (!identity || !identity.atp_did) { + return { + success: false, + error: { + type: "oauth_session_expired", + message: "Not authenticated", + did: "", + }, + }; + } - let credentialSession = await oauthClient.restore(identity.atp_did); + const sessionResult = await restoreOAuthSession(identity.atp_did); + if (!sessionResult.ok) { + return { success: false, error: sessionResult.error }; + } + let credentialSession = sessionResult.value; let agent = new AtpBaseClient( credentialSession.fetchHandler.bind(credentialSession), ); @@ -237,7 +256,7 @@ export async function publishToPublication({ await createMentionNotifications(result.uri, record, credentialSession.did!); } - return { rkey, record: JSON.parse(JSON.stringify(record)) }; + return { success: true, rkey, record: JSON.parse(JSON.stringify(record)) }; } async function processBlocksToPages( diff --git a/app/[leaflet_id]/actions/PublishButton.tsx b/app/[leaflet_id]/actions/PublishButton.tsx index e5308f84..0991bffa 100644 --- a/app/[leaflet_id]/actions/PublishButton.tsx +++ b/app/[leaflet_id]/actions/PublishButton.tsx @@ -39,6 +39,7 @@ import { YJSFragmentToString } from "src/utils/yjsFragmentToString"; import { BlueskyLogin } from "app/login/LoginForm"; import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication"; import { AddTiny } from "components/Icons/AddTiny"; +import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; export const PublishButton = (props: { entityID: string }) => { let { data: pub } = useLeafletPublicationData(); @@ -102,7 +103,7 @@ const UpdateButton = () => { onClick={async () => { if (!pub) return; setIsLoading(true); - let doc = await publishToPublication({ + let result = await publishToPublication({ root_entity: rootEntity, publication_uri: pub.publications?.uri, leaflet_id: permission_token.id, @@ -114,10 +115,22 @@ const UpdateButton = () => { setIsLoading(false); mutate(); + if (!result.success) { + toaster({ + content: isOAuthSessionError(result.error) ? ( + + ) : ( + "Failed to publish" + ), + type: "error", + }); + return; + } + // Generate URL based on whether it's in a publication or standalone let docUrl = pub.publications - ? `${getPublicationURL(pub.publications)}/${doc?.rkey}` - : `https://leaflet.pub/p/${identity?.atp_did}/${doc?.rkey}`; + ? `${getPublicationURL(pub.publications)}/${result.rkey}` + : `https://leaflet.pub/p/${identity?.atp_did}/${result.rkey}`; toaster({ content: ( diff --git a/app/[leaflet_id]/publish/PublishPost.tsx b/app/[leaflet_id]/publish/PublishPost.tsx index 59578f0a..6b0bdf15 100644 --- a/app/[leaflet_id]/publish/PublishPost.tsx +++ b/app/[leaflet_id]/publish/PublishPost.tsx @@ -22,6 +22,7 @@ import { EditorState } from "prosemirror-state"; import { TagSelector } from "../../../components/Tags"; import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; import { PubIcon } from "components/ActionBar/Publications"; +import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; type Props = { title: string; @@ -65,6 +66,9 @@ const PublishPostForm = ( let [charCount, setCharCount] = useState(0); let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); let [isLoading, setIsLoading] = useState(false); + let [oauthError, setOauthError] = useState< + import("src/atproto-oauth").OAuthSessionError | null + >(null); let params = useParams(); let { rep } = useReplicache(); @@ -101,8 +105,9 @@ const PublishPostForm = ( async function submit() { if (isLoading) return; setIsLoading(true); + setOauthError(null); await rep?.push(); - let doc = await publishToPublication({ + let result = await publishToPublication({ root_entity: props.root_entity, publication_uri: props.publication_uri, leaflet_id: props.leaflet_id, @@ -112,26 +117,39 @@ const PublishPostForm = ( cover_image: replicacheCoverImage, entitiesToDelete: props.entitiesToDelete, }); - if (!doc) return; + + if (!result.success) { + setIsLoading(false); + if (isOAuthSessionError(result.error)) { + setOauthError(result.error); + } + return; + } // Generate post URL based on whether it's in a publication or standalone let post_url = props.record?.base_path - ? `https://${props.record.base_path}/${doc.rkey}` - : `https://leaflet.pub/p/${props.profile.did}/${doc.rkey}`; + ? `https://${props.record.base_path}/${result.rkey}` + : `https://leaflet.pub/p/${props.profile.did}/${result.rkey}`; let [text, facets] = editorStateRef.current ? editorStateToFacetedText(editorStateRef.current) : []; - if (shareOption === "bluesky") - await publishPostToBsky({ + if (shareOption === "bluesky") { + let bskyResult = await publishPostToBsky({ facets: facets || [], text: text || "", title: props.title, url: post_url, description: props.description, - document_record: doc.record, - rkey: doc.rkey, + document_record: result.record, + rkey: result.rkey, }); + if (!bskyResult.success && isOAuthSessionError(bskyResult.error)) { + setIsLoading(false); + setOauthError(bskyResult.error); + return; + } + } setIsLoading(false); props.setPublishState({ state: "success", post_url }); } @@ -168,20 +186,28 @@ const PublishPostForm = (
-
- - Back - - 300} - > - {isLoading ? : "Publish this Post!"} - +
+
+ + Back + + 300} + > + {isLoading ? : "Publish this Post!"} + +
+ {oauthError && ( + + )}
diff --git a/app/[leaflet_id]/publish/publishBskyPost.ts b/app/[leaflet_id]/publish/publishBskyPost.ts index 04cc976b..be612396 100644 --- a/app/[leaflet_id]/publish/publishBskyPost.ts +++ b/app/[leaflet_id]/publish/publishBskyPost.ts @@ -9,7 +9,10 @@ import sharp from "sharp"; import { TID } from "@atproto/common"; import { getIdentityData } from "actions/getIdentityData"; import { AtpBaseClient, PubLeafletDocument } from "lexicons/api"; -import { createOauthClient } from "src/atproto-oauth"; +import { + restoreOAuthSession, + OAuthSessionError, +} from "src/atproto-oauth"; import { supabaseServerClient } from "supabase/serverClient"; import { Json } from "supabase/database.types"; import { @@ -18,6 +21,10 @@ import { } from "src/utils/getMicroLinkOgImage"; import { fetchAtprotoBlob } from "app/api/atproto_images/route"; +type PublishBskyResult = + | { success: true } + | { success: false; error: OAuthSessionError }; + export async function publishPostToBsky(args: { text: string; url: string; @@ -26,12 +33,24 @@ export async function publishPostToBsky(args: { document_record: PubLeafletDocument.Record; rkey: string; facets: AppBskyRichtextFacet.Main[]; -}) { - const oauthClient = await createOauthClient(); +}): Promise { let identity = await getIdentityData(); - if (!identity || !identity.atp_did) return null; + if (!identity || !identity.atp_did) { + return { + success: false, + error: { + type: "oauth_session_expired", + message: "Not authenticated", + did: "", + }, + }; + } - let credentialSession = await oauthClient.restore(identity.atp_did); + const sessionResult = await restoreOAuthSession(identity.atp_did); + if (!sessionResult.ok) { + return { success: false, error: sessionResult.error }; + } + let credentialSession = sessionResult.value; let agent = new AtpBaseClient( credentialSession.fetchHandler.bind(credentialSession), ); @@ -111,5 +130,5 @@ export async function publishPostToBsky(args: { data: record as Json, }) .eq("uri", result.uri); - return true; + return { success: true }; } diff --git a/app/api/oauth/[route]/route.ts b/app/api/oauth/[route]/route.ts index 55b69460..b904069d 100644 --- a/app/api/oauth/[route]/route.ts +++ b/app/api/oauth/[route]/route.ts @@ -121,7 +121,7 @@ const handleAction = async ( else url = new URL(decodeURIComponent(redirectPath), "https://example.com"); if (action?.action === "subscribe") { let result = await subscribeToPublication(action.publication); - if (result.hasFeed === false) + if (result.success && result.hasFeed === false) url.searchParams.set("showSubscribeSuccess", "true"); } diff --git a/app/lish/Subscribe.tsx b/app/lish/Subscribe.tsx index 883e967d..19538fd7 100644 --- a/app/lish/Subscribe.tsx +++ b/app/lish/Subscribe.tsx @@ -23,6 +23,7 @@ import { addFeed } from "./addFeed"; import { useSearchParams } from "next/navigation"; import LoginForm from "app/login/LoginForm"; import { RSSSmall } from "components/Icons/RSSSmall"; +import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; export const SubscribeWithBluesky = (props: { pubName: string; @@ -133,11 +134,21 @@ let BlueskySubscribeButton = (props: { }) => { let { identity } = useIdentityData(); let toaster = useToaster(); + let [oauthError, setOauthError] = useState< + import("src/atproto-oauth").OAuthSessionError | null + >(null); let [, subscribe, subscribePending] = useActionState(async () => { + setOauthError(null); let result = await subscribeToPublication( props.pub_uri, window.location.href + "?refreshAuth", ); + if (!result.success) { + if (isOAuthSessionError(result.error)) { + setOauthError(result.error); + } + return; + } if (result.hasFeed === false) { props.setSuccessModalOpen(true); } @@ -172,7 +183,7 @@ let BlueskySubscribeButton = (props: { } return ( - <> +
- + {oauthError && ( + + )} +
); }; diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx index f99e1a6f..f0e3bef5 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx @@ -38,6 +38,8 @@ import { create } from "zustand"; import { CloseTiny } from "components/Icons/CloseTiny"; import { CloseFillTiny } from "components/Icons/CloseFillTiny"; import { betterIsUrl } from "src/utils/isURL"; +import { useToaster } from "components/Toast"; +import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; import { Mention, MentionAutocomplete } from "components/Mention"; import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; @@ -95,6 +97,7 @@ export function CommentBox(props: { } = useInteractionState(props.doc_uri); let [loading, setLoading] = useState(false); let view = useRef(null); + let toaster = useToaster(); // Mention autocomplete state const [mentionOpen, setMentionOpen] = useState(false); @@ -161,7 +164,7 @@ export function CommentBox(props: { setLoading(true); let currentState = view.current.state; let [plaintext, facets] = docToFacetedText(currentState.doc); - let comment = await publishComment({ + let result = await publishComment({ pageId: props.pageId, document: props.doc_uri, comment: { @@ -178,6 +181,19 @@ export function CommentBox(props: { }, }); + if (!result.success) { + setLoading(false); + toaster({ + content: isOAuthSessionError(result.error) ? ( + + ) : ( + "Failed to post comment" + ), + type: "error", + }); + return; + } + let tr = currentState.tr; tr = tr.replaceWith( 0, @@ -194,11 +210,11 @@ export function CommentBox(props: { localComments: [ ...s.localComments, { - record: comment.record, - uri: comment.uri, + record: result.record, + uri: result.uri, bsky_profiles: { - record: comment.profile as Json, - did: new AtUri(comment.uri).host, + record: result.profile as Json, + did: new AtUri(result.uri).host, }, }, ], diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts index 04fde096..d0509b6c 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts @@ -3,7 +3,10 @@ import { AtpBaseClient, PubLeafletComment } from "lexicons/api"; import { getIdentityData } from "actions/getIdentityData"; import { PubLeafletRichtextFacet } from "lexicons/api"; -import { createOauthClient } from "src/atproto-oauth"; +import { + restoreOAuthSession, + OAuthSessionError, +} from "src/atproto-oauth"; import { TID } from "@atproto/common"; import { AtUri, lexToJson, Un$Typed } from "@atproto/api"; import { supabaseServerClient } from "supabase/serverClient"; @@ -15,6 +18,10 @@ import { } from "src/notifications"; import { v7 } from "uuid"; +type PublishCommentResult = + | { success: true; record: Json; profile: any; uri: string } + | { success: false; error: OAuthSessionError }; + export async function publishComment(args: { document: string; pageId?: string; @@ -24,12 +31,24 @@ export async function publishComment(args: { replyTo?: string; attachment: PubLeafletComment.Record["attachment"]; }; -}) { - const oauthClient = await createOauthClient(); +}): Promise { let identity = await getIdentityData(); - if (!identity || !identity.atp_did) throw new Error("No Identity"); + if (!identity || !identity.atp_did) { + return { + success: false, + error: { + type: "oauth_session_expired", + message: "Not authenticated", + did: "", + }, + }; + } - let credentialSession = await oauthClient.restore(identity.atp_did); + const sessionResult = await restoreOAuthSession(identity.atp_did); + if (!sessionResult.ok) { + return { success: false, error: sessionResult.error }; + } + let credentialSession = sessionResult.value; let agent = new AtpBaseClient( credentialSession.fetchHandler.bind(credentialSession), ); @@ -108,6 +127,7 @@ export async function publishComment(args: { } return { + success: true, record: data?.[0].record as Json, profile: lexToJson(profile.value), uri: uri.toString(), diff --git a/app/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts b/app/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts index e04d6161..e7abd3f2 100644 --- a/app/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts +++ b/app/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts @@ -1,6 +1,9 @@ "use server"; -import { createOauthClient } from "src/atproto-oauth"; +import { + restoreOAuthSession, + OAuthSessionError, +} from "src/atproto-oauth"; import { getIdentityData } from "actions/getIdentityData"; import { AtpBaseClient, AtUri } from "@atproto/api"; import { PubLeafletPollVote } from "lexicons/api"; @@ -12,7 +15,9 @@ export async function voteOnPublishedPoll( pollUri: string, pollCid: string, selectedOption: string, -): Promise<{ success: boolean; error?: string }> { +): Promise< + { success: true } | { success: false; error: string | OAuthSessionError } +> { try { const identity = await getIdentityData(); @@ -20,8 +25,11 @@ export async function voteOnPublishedPoll( return { success: false, error: "Not authenticated" }; } - const oauthClient = await createOauthClient(); - const session = await oauthClient.restore(identity.atp_did); + const sessionResult = await restoreOAuthSession(identity.atp_did); + if (!sessionResult.ok) { + return { success: false, error: sessionResult.error }; + } + const session = sessionResult.value; let agent = new AtpBaseClient(session.fetchHandler.bind(session)); const voteRecord: PubLeafletPollVote.Record = { diff --git a/app/lish/[did]/[publication]/dashboard/deletePost.ts b/app/lish/[did]/[publication]/dashboard/deletePost.ts index 1723ed0f..32b10eba 100644 --- a/app/lish/[did]/[publication]/dashboard/deletePost.ts +++ b/app/lish/[did]/[publication]/dashboard/deletePost.ts @@ -2,22 +2,41 @@ import { AtpBaseClient } from "lexicons/api"; import { getIdentityData } from "actions/getIdentityData"; -import { createOauthClient } from "src/atproto-oauth"; +import { + restoreOAuthSession, + OAuthSessionError, +} from "src/atproto-oauth"; import { AtUri } from "@atproto/syntax"; import { supabaseServerClient } from "supabase/serverClient"; import { revalidatePath } from "next/cache"; -export async function deletePost(document_uri: string) { +export async function deletePost( + document_uri: string +): Promise<{ success: true } | { success: false; error: OAuthSessionError }> { let identity = await getIdentityData(); - if (!identity || !identity.atp_did) throw new Error("No Identity"); + if (!identity || !identity.atp_did) { + return { + success: false, + error: { + type: "oauth_session_expired", + message: "Not authenticated", + did: "", + }, + }; + } - const oauthClient = await createOauthClient(); - let credentialSession = await oauthClient.restore(identity.atp_did); + const sessionResult = await restoreOAuthSession(identity.atp_did); + if (!sessionResult.ok) { + return { success: false, error: sessionResult.error }; + } + let credentialSession = sessionResult.value; let agent = new AtpBaseClient( credentialSession.fetchHandler.bind(credentialSession), ); let uri = new AtUri(document_uri); - if (uri.host !== identity.atp_did) return; + if (uri.host !== identity.atp_did) { + return { success: true }; + } await Promise.all([ agent.pub.leaflet.document.delete({ @@ -31,20 +50,37 @@ export async function deletePost(document_uri: string) { .eq("doc", document_uri), ]); - return revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); + revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); + return { success: true }; } -export async function unpublishPost(document_uri: string) { +export async function unpublishPost( + document_uri: string +): Promise<{ success: true } | { success: false; error: OAuthSessionError }> { let identity = await getIdentityData(); - if (!identity || !identity.atp_did) throw new Error("No Identity"); + if (!identity || !identity.atp_did) { + return { + success: false, + error: { + type: "oauth_session_expired", + message: "Not authenticated", + did: "", + }, + }; + } - const oauthClient = await createOauthClient(); - let credentialSession = await oauthClient.restore(identity.atp_did); + const sessionResult = await restoreOAuthSession(identity.atp_did); + if (!sessionResult.ok) { + return { success: false, error: sessionResult.error }; + } + let credentialSession = sessionResult.value; let agent = new AtpBaseClient( credentialSession.fetchHandler.bind(credentialSession), ); let uri = new AtUri(document_uri); - if (uri.host !== identity.atp_did) return; + if (uri.host !== identity.atp_did) { + return { success: true }; + } await Promise.all([ agent.pub.leaflet.document.delete({ @@ -53,5 +89,6 @@ export async function unpublishPost(document_uri: string) { }), supabaseServerClient.from("documents").delete().eq("uri", document_uri), ]); - return revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); + revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); + return { success: true }; } diff --git a/app/lish/addFeed.tsx b/app/lish/addFeed.tsx index 10eff878..09b80189 100644 --- a/app/lish/addFeed.tsx +++ b/app/lish/addFeed.tsx @@ -2,18 +2,33 @@ import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api"; import { getIdentityData } from "actions/getIdentityData"; -import { createOauthClient } from "src/atproto-oauth"; +import { + restoreOAuthSession, + OAuthSessionError, +} from "src/atproto-oauth"; const leafletFeedURI = "at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications"; -export async function addFeed() { - const oauthClient = await createOauthClient(); +export async function addFeed(): Promise< + { success: true } | { success: false; error: OAuthSessionError } +> { let identity = await getIdentityData(); if (!identity || !identity.atp_did) { - throw new Error("Invalid identity data"); + return { + success: false, + error: { + type: "oauth_session_expired", + message: "Not authenticated", + did: "", + }, + }; } - let credentialSession = await oauthClient.restore(identity.atp_did); + const sessionResult = await restoreOAuthSession(identity.atp_did); + if (!sessionResult.ok) { + return { success: false, error: sessionResult.error }; + } + let credentialSession = sessionResult.value; let bsky = new BskyAgent(credentialSession); let prefs = await bsky.app.bsky.actor.getPreferences(); let savedFeeds = prefs.data.preferences.find( @@ -23,7 +38,7 @@ export async function addFeed() { let hasFeed = !!savedFeeds.items.find( (feed) => feed.value === leafletFeedURI, ); - if (hasFeed) return; + if (hasFeed) return { success: true }; await bsky.addSavedFeeds([ { @@ -32,4 +47,5 @@ export async function addFeed() { type: "feed", }, ]); + return { success: true }; } diff --git a/app/lish/createPub/CreatePubForm.tsx b/app/lish/createPub/CreatePubForm.tsx index 2ebcdf4c..2d3f6655 100644 --- a/app/lish/createPub/CreatePubForm.tsx +++ b/app/lish/createPub/CreatePubForm.tsx @@ -13,6 +13,7 @@ import { getBasePublicationURL, getPublicationURL } from "./getPublicationURL"; import { string } from "zod"; import { DotLoader } from "components/utils/DotLoader"; import { Checkbox } from "components/Checkbox"; +import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; type DomainState = | { status: "empty" } @@ -32,6 +33,9 @@ export const CreatePubForm = () => { let [domainState, setDomainState] = useState({ status: "empty", }); + let [oauthError, setOauthError] = useState< + import("src/atproto-oauth").OAuthSessionError | null + >(null); let fileInputRef = useRef(null); let router = useRouter(); @@ -43,18 +47,28 @@ export const CreatePubForm = () => { e.preventDefault(); if (!subdomainValidator.safeParse(domainValue).success) return; setFormState("loading"); - let data = await createPublication({ + setOauthError(null); + let result = await createPublication({ name: nameValue, description: descriptionValue, iconFile: logoFile, subdomain: domainValue, preferences: { showInDiscover, showComments: true }, }); + + if (!result.success) { + setFormState("normal"); + if (result.error && isOAuthSessionError(result.error)) { + setOauthError(result.error); + } + return; + } + // Show a spinner while this is happening! Maybe a progress bar? setTimeout(() => { setFormState("normal"); - if (data?.publication) - router.push(`${getBasePublicationURL(data.publication)}/dashboard`); + if (result.publication) + router.push(`${getBasePublicationURL(result.publication)}/dashboard`); }, 500); }} > @@ -139,15 +153,23 @@ export const CreatePubForm = () => {
-
- - {formState === "loading" ? : "Create Publication!"} - +
+
+ + {formState === "loading" ? : "Create Publication!"} + +
+ {oauthError && ( + + )}
); diff --git a/app/lish/createPub/createPublication.ts b/app/lish/createPub/createPublication.ts index 9ce067cb..d41dd7f0 100644 --- a/app/lish/createPub/createPublication.ts +++ b/app/lish/createPub/createPublication.ts @@ -1,7 +1,10 @@ "use server"; import { TID } from "@atproto/common"; import { AtpBaseClient, PubLeafletPublication } from "lexicons/api"; -import { createOauthClient } from "src/atproto-oauth"; +import { + restoreOAuthSession, + OAuthSessionError, +} from "src/atproto-oauth"; import { getIdentityData } from "actions/getIdentityData"; import { supabaseServerClient } from "supabase/serverClient"; import { Un$Typed } from "@atproto/api"; @@ -18,6 +21,10 @@ let subdomainValidator = string() .min(3) .max(63) .regex(/^[a-z0-9-]+$/); +type CreatePublicationResult = + | { success: true; publication: any } + | { success: false; error?: OAuthSessionError }; + export async function createPublication({ name, description, @@ -30,18 +37,30 @@ export async function createPublication({ iconFile: File | null; subdomain: string; preferences: Omit; -}) { +}): Promise { let isSubdomainValid = subdomainValidator.safeParse(subdomain); if (!isSubdomainValid.success) { return { success: false }; } - const oauthClient = await createOauthClient(); let identity = await getIdentityData(); - if (!identity || !identity.atp_did) return; + if (!identity || !identity.atp_did) { + return { + success: false, + error: { + type: "oauth_session_expired", + message: "Not authenticated", + did: "", + }, + }; + } let domain = `${subdomain}.leaflet.pub`; - let credentialSession = await oauthClient.restore(identity.atp_did); + const sessionResult = await restoreOAuthSession(identity.atp_did); + if (!sessionResult.ok) { + return { success: false, error: sessionResult.error }; + } + let credentialSession = sessionResult.value; let agent = new AtpBaseClient( credentialSession.fetchHandler.bind(credentialSession), ); diff --git a/app/lish/createPub/updatePublication.ts b/app/lish/createPub/updatePublication.ts index 020d50cf..6e4a9588 100644 --- a/app/lish/createPub/updatePublication.ts +++ b/app/lish/createPub/updatePublication.ts @@ -5,13 +5,20 @@ import { PubLeafletPublication, PubLeafletThemeColor, } from "lexicons/api"; -import { createOauthClient } from "src/atproto-oauth"; +import { + restoreOAuthSession, + OAuthSessionError, +} from "src/atproto-oauth"; import { getIdentityData } from "actions/getIdentityData"; import { supabaseServerClient } from "supabase/serverClient"; import { Json } from "supabase/database.types"; import { AtUri } from "@atproto/syntax"; import { $Typed } from "@atproto/api"; +type UpdatePublicationResult = + | { success: true; publication: any } + | { success: false; error?: OAuthSessionError }; + export async function updatePublication({ uri, name, @@ -24,12 +31,24 @@ export async function updatePublication({ description: string; iconFile: File | null; preferences?: Omit; -}) { - const oauthClient = await createOauthClient(); +}): Promise { let identity = await getIdentityData(); - if (!identity || !identity.atp_did) return; + if (!identity || !identity.atp_did) { + return { + success: false, + error: { + type: "oauth_session_expired", + message: "Not authenticated", + did: "", + }, + }; + } - let credentialSession = await oauthClient.restore(identity.atp_did); + const sessionResult = await restoreOAuthSession(identity.atp_did); + if (!sessionResult.ok) { + return { success: false, error: sessionResult.error }; + } + let credentialSession = sessionResult.value; let agent = new AtpBaseClient( credentialSession.fetchHandler.bind(credentialSession), ); @@ -38,7 +57,9 @@ export async function updatePublication({ .select("*") .eq("uri", uri) .single(); - if (!existingPub || existingPub.identity_did !== identity.atp_did) return; + if (!existingPub || existingPub.identity_did !== identity.atp_did) { + return { success: false }; + } let aturi = new AtUri(existingPub.uri); let record: PubLeafletPublication.Record = { @@ -94,12 +115,24 @@ export async function updatePublicationBasePath({ }: { uri: string; base_path: string; -}) { - const oauthClient = await createOauthClient(); +}): Promise { let identity = await getIdentityData(); - if (!identity || !identity.atp_did) return; + if (!identity || !identity.atp_did) { + return { + success: false, + error: { + type: "oauth_session_expired", + message: "Not authenticated", + did: "", + }, + }; + } - let credentialSession = await oauthClient.restore(identity.atp_did); + const sessionResult = await restoreOAuthSession(identity.atp_did); + if (!sessionResult.ok) { + return { success: false, error: sessionResult.error }; + } + let credentialSession = sessionResult.value; let agent = new AtpBaseClient( credentialSession.fetchHandler.bind(credentialSession), ); @@ -108,7 +141,9 @@ export async function updatePublicationBasePath({ .select("*") .eq("uri", uri) .single(); - if (!existingPub || existingPub.identity_did !== identity.atp_did) return; + if (!existingPub || existingPub.identity_did !== identity.atp_did) { + return { success: false }; + } let aturi = new AtUri(existingPub.uri); let record: PubLeafletPublication.Record = { @@ -155,12 +190,24 @@ export async function updatePublicationTheme({ accentBackground: Color; accentText: Color; }; -}) { - const oauthClient = await createOauthClient(); +}): Promise { let identity = await getIdentityData(); - if (!identity || !identity.atp_did) return; + if (!identity || !identity.atp_did) { + return { + success: false, + error: { + type: "oauth_session_expired", + message: "Not authenticated", + did: "", + }, + }; + } - let credentialSession = await oauthClient.restore(identity.atp_did); + const sessionResult = await restoreOAuthSession(identity.atp_did); + if (!sessionResult.ok) { + return { success: false, error: sessionResult.error }; + } + let credentialSession = sessionResult.value; let agent = new AtpBaseClient( credentialSession.fetchHandler.bind(credentialSession), ); @@ -169,7 +216,9 @@ export async function updatePublicationTheme({ .select("*") .eq("uri", uri) .single(); - if (!existingPub || existingPub.identity_did !== identity.atp_did) return; + if (!existingPub || existingPub.identity_did !== identity.atp_did) { + return { success: false }; + } let aturi = new AtUri(existingPub.uri); let oldRecord = existingPub.record as PubLeafletPublication.Record; diff --git a/app/lish/subscribeToPublication.ts b/app/lish/subscribeToPublication.ts index c221800a..e18d30fe 100644 --- a/app/lish/subscribeToPublication.ts +++ b/app/lish/subscribeToPublication.ts @@ -3,7 +3,10 @@ import { AtpBaseClient } from "lexicons/api"; import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api"; import { getIdentityData } from "actions/getIdentityData"; -import { createOauthClient } from "src/atproto-oauth"; +import { + restoreOAuthSession, + OAuthSessionError, +} from "src/atproto-oauth"; import { TID } from "@atproto/common"; import { supabaseServerClient } from "supabase/serverClient"; import { revalidatePath } from "next/cache"; @@ -21,11 +24,15 @@ import { v7 } from "uuid"; let leafletFeedURI = "at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications"; let idResolver = new IdResolver(); + +type SubscribeResult = + | { success: true; hasFeed: boolean } + | { success: false; error: OAuthSessionError }; + export async function subscribeToPublication( publication: string, redirectRoute?: string, -) { - const oauthClient = await createOauthClient(); +): Promise { let identity = await getIdentityData(); if (!identity || !identity.atp_did) { return redirect( @@ -33,7 +40,11 @@ export async function subscribeToPublication( ); } - let credentialSession = await oauthClient.restore(identity.atp_did); + const sessionResult = await restoreOAuthSession(identity.atp_did); + if (!sessionResult.ok) { + return { success: false, error: sessionResult.error }; + } + let credentialSession = sessionResult.value; let agent = new AtpBaseClient( credentialSession.fetchHandler.bind(credentialSession), ); @@ -90,16 +101,35 @@ export async function subscribeToPublication( ) as AppBskyActorDefs.SavedFeedsPrefV2; revalidatePath("/lish/[did]/[publication]", "layout"); return { + success: true, hasFeed: !!savedFeeds.items.find((feed) => feed.value === leafletFeedURI), }; } -export async function unsubscribeToPublication(publication: string) { - const oauthClient = await createOauthClient(); +type UnsubscribeResult = + | { success: true } + | { success: false; error: OAuthSessionError }; + +export async function unsubscribeToPublication( + publication: string +): Promise { let identity = await getIdentityData(); - if (!identity || !identity.atp_did) return; + if (!identity || !identity.atp_did) { + return { + success: false, + error: { + type: "oauth_session_expired", + message: "Not authenticated", + did: "", + }, + }; + } - let credentialSession = await oauthClient.restore(identity.atp_did); + const sessionResult = await restoreOAuthSession(identity.atp_did); + if (!sessionResult.ok) { + return { success: false, error: sessionResult.error }; + } + let credentialSession = sessionResult.value; let agent = new AtpBaseClient( credentialSession.fetchHandler.bind(credentialSession), ); @@ -109,7 +139,7 @@ export async function unsubscribeToPublication(publication: string) { .eq("identity", identity.atp_did) .eq("publication", publication) .single(); - if (!existingSubscription) return; + if (!existingSubscription) return { success: true }; await agent.pub.leaflet.graph.subscription.delete({ repo: credentialSession.did!, rkey: new AtUri(existingSubscription.uri).rkey, @@ -120,4 +150,5 @@ export async function unsubscribeToPublication(publication: string) { .eq("identity", identity.atp_did) .eq("publication", publication); revalidatePath("/lish/[did]/[publication]", "layout"); + return { success: true }; } diff --git a/components/OAuthError.tsx b/components/OAuthError.tsx new file mode 100644 index 00000000..e23a438a --- /dev/null +++ b/components/OAuthError.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { OAuthSessionError } from "src/atproto-oauth"; +import { usePathname } from "next/navigation"; + +export function OAuthErrorMessage({ + error, + className, +}: { + error: OAuthSessionError; + className?: string; +}) { + const pathname = usePathname(); + const signInUrl = `/api/oauth/login?redirect_url=${encodeURIComponent(pathname)}${error.did ? `&handle=${encodeURIComponent(error.did)}` : ""}`; + + return ( +
+ Your session has expired or is invalid. + + Sign in again + +
+ ); +} + +export function isOAuthSessionError( + error: unknown, +): error is OAuthSessionError { + return ( + typeof error === "object" && + error !== null && + "type" in error && + (error as OAuthSessionError).type === "oauth_session_expired" + ); +} diff --git a/components/ThemeManager/PubThemeSetter.tsx b/components/ThemeManager/PubThemeSetter.tsx index 9339345b..297cb1a8 100644 --- a/components/ThemeManager/PubThemeSetter.tsx +++ b/components/ThemeManager/PubThemeSetter.tsx @@ -17,6 +17,8 @@ import { PubAccentPickers } from "./PubPickers/PubAcccentPickers"; import { Separator } from "components/Layout"; import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/PublicationSettings"; import { ColorToRGB, ColorToRGBA } from "./colorToLexicons"; +import { useToaster } from "components/Toast"; +import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; export type ImageState = { src: string; @@ -57,6 +59,7 @@ export const PubThemeSetter = (props: { let pubBGImage = image?.src || null; let leafletBGRepeat = image?.repeat || null; + let toaster = useToaster(); return ( @@ -80,8 +83,25 @@ export const PubThemeSetter = (props: { accentText: ColorToRGB(localPubTheme.accent2), }, }); + + if (!result.success) { + props.setLoading(false); + if (result.error && isOAuthSessionError(result.error)) { + toaster({ + content: , + type: "error", + }); + } else { + toaster({ + content: "Failed to update theme", + type: "error", + }); + } + return; + } + mutate((pub) => { - if (result?.publication && pub?.publication) + if (result.publication && pub?.publication) return { ...pub, publication: { ...pub.publication, ...result.publication }, diff --git a/src/atproto-oauth.ts b/src/atproto-oauth.ts index c188bf30..1a7d7f77 100644 --- a/src/atproto-oauth.ts +++ b/src/atproto-oauth.ts @@ -3,6 +3,7 @@ import { NodeSavedSession, NodeSavedState, RuntimeLock, + OAuthSession, } from "@atproto/oauth-client-node"; import { JoseKey } from "@atproto/jwk-jose"; import { oauth_metadata } from "app/api/oauth/[route]/oauth-metadata"; @@ -10,6 +11,7 @@ import { supabaseServerClient } from "supabase/serverClient"; import Client from "ioredis"; import Redlock from "redlock"; +import { Result, Ok, Err } from "./result"; export async function createOauthClient() { let keyset = process.env.NODE_ENV === "production" @@ -90,3 +92,28 @@ let sessionStore = { .eq("key", key); }, }; + +export type OAuthSessionError = { + type: "oauth_session_expired"; + message: string; + did: string; +}; + +export async function restoreOAuthSession( + did: string +): Promise> { + try { + const oauthClient = await createOauthClient(); + const session = await oauthClient.restore(did); + return Ok(session); + } catch (error) { + return Err({ + type: "oauth_session_expired", + message: + error instanceof Error + ? error.message + : "OAuth session expired or invalid", + did, + }); + } +} diff --git a/src/result.ts b/src/result.ts new file mode 100644 index 00000000..ba14c9fe --- /dev/null +++ b/src/result.ts @@ -0,0 +1,8 @@ +// Result type - a discriminated union for handling success/error cases +export type Result = + | { ok: true; value: T } + | { ok: false; error: E }; + +// Constructors +export const Ok = (value: T): Result => ({ ok: true, value }); +export const Err = (error: E): Result => ({ ok: false, error });