this repo has no description
10
fork

Configure Feed

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

feat: intent-based sign-in replaces account-type chooser

New sign-in flow:
- Default sign-in (header button, review-page CTA) → user account, no
chooser shown; user lands on /account/reviews dashboard.
- "Submit your project" CTA → carries intent=project through OAuth;
freshly-signed-in DID is auto-classified as a project and lands on
/explore/manage.

User → project upgrade (for accounts already signed in as users):
- A small "Submit your project" button appears at the top of the
/account/reviews dashboard.
- Clicking it opens a modal: "Is this account a project?"
- "Yes" → POSTs to /api/account/type, converts to project, redirects
to /explore/manage.
- A link lets users sign in with a different (project) account via
/oauth/add-account?intent=project, so the next sign-in is also
auto-classified as a project.

Removed:
- /account/type chooser page (replaced with a smart redirect for legacy
bookmarks/in-flight sessions; untyped DIDs default to user).
- requiresAccountTypeChoice helper (no longer needed).

Made-with: Cursor

+404 -140
+14
assets/styles.css
··· 6090 6090 .account-reviews-header { 6091 6091 margin-bottom: 1.5rem; 6092 6092 } 6093 + .account-reviews-header-row { 6094 + display: flex; 6095 + gap: 1rem; 6096 + align-items: flex-start; 6097 + justify-content: space-between; 6098 + flex-wrap: wrap; 6099 + } 6100 + .user-profile-upgrade-button { 6101 + flex: 0 0 auto; 6102 + align-self: flex-start; 6103 + margin-top: 0.35rem; 6104 + font-size: 0.85rem; 6105 + padding: 0.45rem 0.9rem; 6106 + } 6093 6107 .account-reviews-empty { 6094 6108 display: flex; 6095 6109 gap: 1rem;
+4 -1
components/explore/EmptyState.tsx
··· 6 6 <div class="explore-empty glass"> 7 7 <p class="text-subsection">{t.nothingHere}</p> 8 8 <p class="text-body-sm mt-2">{t.nothingHereSubtle}</p> 9 - <a href="/explore/create" class="explore-cta-primary mt-4"> 9 + <a 10 + href="/explore/create?intent=project" 11 + class="explore-cta-primary mt-4" 12 + > 10 13 {t.submitYourProject} 11 14 </a> 12 15 </div>
+3 -1
components/explore/StoreHero.tsx
··· 17 17 <div class="explore-hero-actions"> 18 18 <ExploreSearch initialQuery={initialQuery} /> 19 19 <a 20 - href={signedIn ? "/explore/manage" : "/explore/create"} 20 + href={signedIn 21 + ? "/explore/manage" 22 + : "/explore/create?intent=project"} 21 23 class="explore-cta-primary" 22 24 > 23 25 {signedIn ? t.manageYourProfile : t.submitYourProject}
+12 -1
i18n/messages/en.tsx
··· 41 41 signInHint: "Sign in with your Atmosphere account to publish a profile.", 42 42 manageProfile: "Manage profile", 43 43 manageReviews: "Manage reviews", 44 - chooseAccountType: "Choose account type", 45 44 viewProfile: "View profile", 46 45 signOut: "Sign out", 47 46 avatarAlt: "Account", ··· 985 984 deleting: "Deleting…", 986 985 deleted: "Review deleted.", 987 986 error: "Couldn't update the review", 987 + upgrade: { 988 + button: "Submit your project", 989 + modalTitle: "Is this account a project?", 990 + modalBody: 991 + "Choosing yes converts this account into a project profile and unlocks the project dashboard. If this isn't your project's account,", 992 + signInWithProjectLink: "sign in with your project's account here", 993 + signInWithProjectSuffix: ".", 994 + yes: "Yes, convert this account", 995 + cancel: "Cancel", 996 + submitting: "Converting…", 997 + error: "Couldn't convert this account.", 998 + }, 988 999 }, 989 1000 990 1001 userProfile: {
+3 -9
islands/AccountMenu.tsx
··· 165 165 </a> 166 166 )} 167 167 <a 168 - href={accountType === "user" 169 - ? "/account/reviews" 170 - : accountType === "project" 168 + href={accountType === "project" 171 169 ? "/explore/manage" 172 - : "/account/type"} 170 + : "/account/reviews"} 173 171 class="account-menu-item" 174 172 role="menuitem" 175 173 onClick={() => { 176 174 open.value = false; 177 175 }} 178 176 > 179 - {accountType === "user" 180 - ? t.manageProfile 181 - : accountType === "project" 182 - ? t.manageProfile 183 - : t.chooseAccountType} 177 + {t.manageProfile} 184 178 </a> 185 179 <form 186 180 method="POST"
+10 -2
islands/SignInForm.tsx
··· 3 3 import { useT } from "../i18n/mod.ts"; 4 4 5 5 interface Props { 6 - /** Optional path to redirect to after successful login (defaults to /explore/manage) */ 6 + /** Optional path to redirect to after successful login (defaults to 7 + * `/account/reviews` for users and `/explore/manage` for projects). */ 7 8 returnTo?: string; 9 + /** 10 + * Account-type hint carried through OAuth. When `"project"` is set 11 + * (typically via a "Submit your project" CTA), a freshly-signed-in 12 + * DID is auto-classified as a project account. Defaults to user. 13 + */ 14 + intent?: "user" | "project"; 8 15 } 9 16 10 17 interface PreviewMatch { ··· 26 33 27 34 type PreviewResponse = PreviewSuccess | PreviewMiss; 28 35 29 - export default function SignInForm({ returnTo }: Props) { 36 + export default function SignInForm({ returnTo, intent }: Props) { 30 37 const t = useT(); 31 38 const handle = useSignal(""); 32 39 const submitting = useSignal(false); ··· 124 131 class="signin-form" 125 132 > 126 133 {returnTo && <input type="hidden" name="next" value={returnTo} />} 134 + {intent && <input type="hidden" name="intent" value={intent} />} 127 135 <div class="signin-form-preview-wrap" ref={wrapRef}> 128 136 <label class="signin-form-label" for="signin-handle"> 129 137 {t.explore.create.signInLabel}
+145
islands/UpgradeToProjectModal.tsx
··· 1 + import { useEffect } from "preact/hooks"; 2 + import { useSignal } from "@preact/signals"; 3 + import { createPortal } from "preact/compat"; 4 + 5 + interface Props { 6 + /** 7 + * When true, the modal opens itself on mount. Used by the dashboard 8 + * to auto-show the upgrade prompt when the user lands here from a 9 + * "Submit your project" CTA while already signed in as a user. 10 + */ 11 + initiallyOpen?: boolean; 12 + copy: { 13 + button: string; 14 + modalTitle: string; 15 + modalBody: string; 16 + signInWithProjectLink: string; 17 + signInWithProjectSuffix: string; 18 + yes: string; 19 + cancel: string; 20 + submitting: string; 21 + error: string; 22 + }; 23 + } 24 + 25 + export default function UpgradeToProjectModal( 26 + { initiallyOpen = false, copy }: Props, 27 + ) { 28 + /** 29 + * `open` always starts false so SSR never tries to evaluate 30 + * `document.body` (which doesn't exist server-side). The `useEffect` 31 + * below flips it true after hydration when `initiallyOpen` is set, 32 + * and clears the `?upgrade=1` query param so refreshes don't replay 33 + * the modal forever. 34 + */ 35 + const open = useSignal(false); 36 + const submitting = useSignal(false); 37 + const error = useSignal<string | null>(null); 38 + 39 + useEffect(() => { 40 + if (!initiallyOpen) return; 41 + open.value = true; 42 + const url = new URL(globalThis.location.href); 43 + if (url.searchParams.has("upgrade")) { 44 + url.searchParams.delete("upgrade"); 45 + const next = url.pathname + (url.search ? url.search : "") + url.hash; 46 + globalThis.history.replaceState(null, "", next); 47 + } 48 + }, []); 49 + 50 + const onConfirm = async () => { 51 + submitting.value = true; 52 + error.value = null; 53 + try { 54 + const body = new URLSearchParams({ accountType: "project" }); 55 + const res = await fetch("/api/account/type", { 56 + method: "POST", 57 + headers: { "content-type": "application/x-www-form-urlencoded" }, 58 + body, 59 + redirect: "manual", 60 + }); 61 + /** 62 + * The API responds 303 → /explore/manage on success. `redirect: 63 + * "manual"` surfaces that as `res.type === "opaqueredirect"` 64 + * (status 0); treat any 2xx/3xx outcome as success and navigate 65 + * the browser to the project dashboard ourselves. 66 + */ 67 + if ( 68 + res.type === "opaqueredirect" || 69 + (res.status >= 200 && res.status < 400) 70 + ) { 71 + globalThis.location.href = "/explore/manage"; 72 + return; 73 + } 74 + const text = await res.text().catch(() => ""); 75 + throw new Error(text || copy.error); 76 + } catch (err) { 77 + error.value = err instanceof Error ? err.message : copy.error; 78 + submitting.value = false; 79 + } 80 + }; 81 + 82 + return ( 83 + <> 84 + <button 85 + type="button" 86 + class="profile-form-button-secondary user-profile-upgrade-button" 87 + onClick={() => { 88 + open.value = true; 89 + }} 90 + > 91 + {copy.button} 92 + </button> 93 + 94 + {open.value && createPortal( 95 + <div 96 + class="modal-backdrop" 97 + onClick={(e) => { 98 + if (e.target === e.currentTarget && !submitting.value) { 99 + open.value = false; 100 + } 101 + }} 102 + > 103 + <div class="modal-card"> 104 + <div class="modal-header"> 105 + <p class="modal-title">{copy.modalTitle}</p> 106 + <p class="modal-body-text"> 107 + {copy.modalBody}{" "} 108 + <a href="/oauth/add-account?intent=project"> 109 + {copy.signInWithProjectLink} 110 + </a> 111 + {copy.signInWithProjectSuffix} 112 + </p> 113 + </div> 114 + {error.value && ( 115 + <p class="report-modal-status report-modal-status--error"> 116 + {error.value} 117 + </p> 118 + )} 119 + <div class="profile-review-composer-actions"> 120 + <button 121 + type="button" 122 + class="profile-form-button-link" 123 + onClick={() => { 124 + open.value = false; 125 + }} 126 + disabled={submitting.value} 127 + > 128 + {copy.cancel} 129 + </button> 130 + <button 131 + type="button" 132 + class="profile-form-button-primary" 133 + onClick={onConfirm} 134 + disabled={submitting.value} 135 + > 136 + {submitting.value ? copy.submitting : copy.yes} 137 + </button> 138 + </div> 139 + </div> 140 + </div>, 141 + document.body, 142 + )} 143 + </> 144 + ); 145 + }
-4
lib/account-types.ts
··· 243 243 }).catch(() => null); 244 244 return profile?.profileType ?? null; 245 245 } 246 - 247 - export async function requiresAccountTypeChoice(did: string): Promise<boolean> { 248 - return (await getEffectiveAccountType(did)) == null; 249 - }
+16
lib/oauth.ts
··· 75 75 if (!isOAuthConfigured()) throw new OAuthNotConfiguredError(); 76 76 } 77 77 78 + /** 79 + * Intent carried through the OAuth dance — tells the callback whether 80 + * the user clicked a generic "Sign in" CTA (default = user account) or 81 + * a "Submit your project" CTA (= project account). The callback uses 82 + * this to auto-classify a freshly-signed-in DID instead of bouncing 83 + * the user through a separate chooser screen. 84 + * 85 + * If the DID already has a type assigned, the intent is ignored. 86 + */ 87 + export type SignInIntent = "user" | "project"; 88 + 78 89 interface FlowState { 79 90 state: string; 80 91 pkceVerifier: string; ··· 85 96 handle: string; 86 97 pdsUrl: string; 87 98 returnTo?: string; 99 + intent?: SignInIntent; 88 100 asNonce?: string; 89 101 } 90 102 ··· 225 237 export async function startLogin( 226 238 handleOrDid: string, 227 239 returnTo?: string | null, 240 + intent?: SignInIntent | null, 228 241 ): Promise<{ redirectUrl: string }> { 229 242 ensureConfigured(); 230 243 const id = await resolveIdentity(handleOrDid); ··· 244 257 handle: id.handle, 245 258 pdsUrl: id.pdsUrl, 246 259 returnTo: returnTo ?? undefined, 260 + intent: intent ?? undefined, 247 261 }; 248 262 await saveFlowState(flow); 249 263 ··· 323 337 handle: string; 324 338 pdsUrl: string; 325 339 returnTo?: string; 340 + intent?: SignInIntent; 326 341 } 327 342 328 343 export async function completeCallback(params: { ··· 370 385 handle: session.handle, 371 386 pdsUrl: session.pdsUrl, 372 387 returnTo: flow.returnTo, 388 + intent: flow.intent, 373 389 }; 374 390 } 375 391
+24 -3
routes/account/reviews.tsx
··· 3 3 import Footer from "../../components/Footer.tsx"; 4 4 import UserBskyClientPicker from "../../islands/UserBskyClientPicker.tsx"; 5 5 import UserReviewRow from "../../islands/UserReviewRow.tsx"; 6 + import UpgradeToProjectModal from "../../islands/UpgradeToProjectModal.tsx"; 6 7 import { getMessages } from "../../i18n/mod.ts"; 7 8 import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 8 9 import { ··· 54 55 }), 55 56 ); 56 57 58 + /** 59 + * `?upgrade=1` is set by entry points that want to nudge a user-typed 60 + * account towards converting to a project (e.g. clicking "Submit 61 + * your project" while already signed in as a user). The dashboard 62 + * island opens the upgrade modal automatically when this flag is 63 + * present and strips the param from the URL after mount. 64 + */ 65 + const autoOpenUpgrade = ctx.url.searchParams.get("upgrade") === "1"; 66 + 57 67 return ctx.render( 58 68 <AccountReviewsPage 59 69 account={buildAccountMenuProps(ctx.state)} 60 70 handle={user.handle} 61 71 profile={appUser} 62 72 reviews={enriched} 73 + autoOpenUpgrade={autoOpenUpgrade} 63 74 t={getMessages(ctx.state.locale)} 64 75 />, 65 76 ); ··· 71 82 handle: string; 72 83 profile: Awaited<ReturnType<typeof getAppUser>>; 73 84 reviews: ReviewWithTarget[]; 85 + autoOpenUpgrade: boolean; 74 86 // deno-lint-ignore no-explicit-any 75 87 t: any; 76 88 } 77 89 78 90 function AccountReviewsPage( 79 - { account, handle, profile, reviews, t }: AccountReviewsPageProps, 91 + { account, handle, profile, reviews, autoOpenUpgrade, t }: 92 + AccountReviewsPageProps, 80 93 ) { 81 94 const copy = t.accountReviews; 82 95 const avatarUrl = profile?.avatarCid && profile.avatarMime ··· 90 103 <section class="account-reviews-section"> 91 104 <div class="container" style={{ maxWidth: "820px" }}> 92 105 <header class="account-reviews-header"> 93 - <p class="text-eyebrow">{copy.eyebrow}</p> 94 - <h1 class="text-section">{copy.headline}</h1> 106 + <div class="account-reviews-header-row"> 107 + <div> 108 + <p class="text-eyebrow">{copy.eyebrow}</p> 109 + <h1 class="text-section">{copy.headline}</h1> 110 + </div> 111 + <UpgradeToProjectModal 112 + initiallyOpen={autoOpenUpgrade} 113 + copy={copy.upgrade} 114 + /> 115 + </div> 95 116 <p class="text-body mt-2">{copy.subhead(handle)}</p> 96 117 </header> 97 118
+37 -80
routes/account/type.tsx
··· 1 + /** 2 + * Legacy account-type chooser. The chooser modal has been retired — 3 + * sign-in intent now classifies new accounts automatically (default 4 + * sign-in = user; "Submit your project" = project) and existing users 5 + * who want to convert to a project use the upgrade modal on 6 + * /account/reviews. 7 + * 8 + * This route still exists so old bookmarks, hashed redirects from the 9 + * OAuth callback (in case any are still in flight from older deploys), 10 + * and any cached AccountMenu links resolve cleanly. It just routes 11 + * the request to the appropriate dashboard. 12 + */ 1 13 import { define } from "../../utils.ts"; 2 - import Nav from "../../components/Nav.tsx"; 3 - import Footer from "../../components/Footer.tsx"; 4 - import { getMessages } from "../../i18n/mod.ts"; 5 - import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 6 - import { getEffectiveAccountType } from "../../lib/account-types.ts"; 14 + import { 15 + getEffectiveAccountType, 16 + setAppUserType, 17 + } from "../../lib/account-types.ts"; 7 18 8 19 export const handler = define.handlers({ 9 20 async GET(ctx) { ··· 11 22 if (!user) { 12 23 return new Response(null, { 13 24 status: 303, 14 - headers: { 15 - location: `/explore/create?next=${ 16 - encodeURIComponent("/account/type") 17 - }`, 18 - }, 25 + headers: { location: "/explore/create" }, 19 26 }); 20 27 } 21 28 ··· 24 31 ? rawNext 25 32 : null; 26 33 27 - const existingType = await getEffectiveAccountType(user.did).catch(() => 28 - null 29 - ); 30 - if (existingType === "project") { 31 - return new Response(null, { 32 - status: 303, 33 - headers: { location: next ?? "/explore/manage" }, 34 - }); 35 - } 36 - if (existingType === "user") { 37 - return new Response(null, { 38 - status: 303, 39 - headers: { location: next ?? "/account/reviews" }, 40 - }); 34 + let accountType = await getEffectiveAccountType(user.did).catch(() => null); 35 + /** 36 + * Legacy DIDs that signed in before the auto-classification rollout 37 + * may still be untyped. Default them to user, matching the new 38 + * sign-in flow's default, and route them to their dashboard. 39 + */ 40 + if (accountType == null) { 41 + await setAppUserType({ 42 + did: user.did, 43 + handle: user.handle, 44 + accountType: "user", 45 + }).catch(() => {}); 46 + accountType = "user"; 41 47 } 42 48 43 - return ctx.render( 44 - <AccountTypePage 45 - account={buildAccountMenuProps(ctx.state)} 46 - handle={user.handle} 47 - next={next} 48 - t={getMessages(ctx.state.locale)} 49 - />, 50 - ); 49 + return new Response(null, { 50 + status: 303, 51 + headers: { 52 + location: next ?? 53 + (accountType === "project" ? "/explore/manage" : "/account/reviews"), 54 + }, 55 + }); 51 56 }, 52 57 }); 53 - 54 - interface AccountTypePageProps { 55 - account: ReturnType<typeof buildAccountMenuProps>; 56 - handle: string; 57 - next: string | null; 58 - // deno-lint-ignore no-explicit-any 59 - t: any; 60 - } 61 - 62 - function AccountTypePage({ account, handle, next, t }: AccountTypePageProps) { 63 - const copy = t.accountType; 64 - return ( 65 - <div id="page-top"> 66 - <div class="content-layer"> 67 - <Nav account={account} /> 68 - <section class="account-type-section"> 69 - <div class="modal-backdrop account-type-backdrop"> 70 - <div class="modal-card account-type-card"> 71 - <div class="modal-header"> 72 - <p class="modal-title">{copy.title}</p> 73 - <p class="modal-body-text">{copy.body(handle)}</p> 74 - </div> 75 - <div class="account-type-options"> 76 - <form method="POST" action="/api/account/type"> 77 - <input type="hidden" name="accountType" value="user" /> 78 - {next && <input type="hidden" name="next" value={next} />} 79 - <button type="submit" class="account-type-option"> 80 - <strong>{copy.userTitle}</strong> 81 - <span>{copy.userBody}</span> 82 - </button> 83 - </form> 84 - <form method="POST" action="/api/account/type"> 85 - <input type="hidden" name="accountType" value="project" /> 86 - {next && <input type="hidden" name="next" value={next} />} 87 - <button type="submit" class="account-type-option"> 88 - <strong>{copy.projectTitle}</strong> 89 - <span>{copy.projectBody}</span> 90 - </button> 91 - </form> 92 - </div> 93 - </div> 94 - </div> 95 - </section> 96 - <Footer variant="compact" /> 97 - </div> 98 - </div> 99 - ); 100 - }
+28 -8
routes/explore/create.tsx
··· 14 14 const next = rawNext && rawNext.startsWith("/") && !rawNext.startsWith("//") 15 15 ? rawNext 16 16 : null; 17 + const rawIntent = ctx.url.searchParams.get("intent"); 18 + const intent: "user" | "project" | undefined = 19 + rawIntent === "project" || rawIntent === "user" ? rawIntent : undefined; 17 20 18 21 if (user) { 19 22 const accountType = await getEffectiveAccountType(user.did).catch(() => 20 23 null 21 24 ); 25 + /** 26 + * Already signed in: 27 + * - Project account → straight to manage. 28 + * - User account that hit "Submit your project" → bounce to the 29 + * dashboard with the upgrade modal pre-opened so they can either 30 + * convert this account or sign in with a different one. 31 + * - User account otherwise → their reviews dashboard. 32 + * - Unknown legacy state → user dashboard (callback now always 33 + * sets a type, so this branch should be unreachable). 34 + */ 35 + let location: string; 36 + if (accountType === "project") { 37 + location = next ?? "/explore/manage"; 38 + } else if (accountType === "user" && intent === "project") { 39 + location = "/account/reviews?upgrade=1"; 40 + } else { 41 + location = next ?? "/account/reviews"; 42 + } 22 43 return new Response(null, { 23 44 status: 303, 24 - headers: { 25 - location: accountType === "project" 26 - ? next ?? "/explore/manage" 27 - : accountType === "user" 28 - ? next ?? "/account/reviews" 29 - : `/account/type${next ? `?next=${encodeURIComponent(next)}` : ""}`, 30 - }, 45 + headers: { location }, 31 46 }) as unknown as preact.JSX.Element; 32 47 } 33 48 ··· 58 73 }} 59 74 > 60 75 {isOAuthConfigured() 61 - ? <SignInForm returnTo={next ?? undefined} /> 76 + ? ( 77 + <SignInForm 78 + returnTo={next ?? undefined} 79 + intent={intent} 80 + /> 81 + ) 62 82 : <p class="text-body">{t.create.configError}</p>} 63 83 </div> 64 84 </div>
+9 -6
routes/explore/manage.tsx
··· 18 18 if (!user) { 19 19 return new Response(null, { 20 20 status: 303, 21 - headers: { location: "/explore/create" }, 21 + headers: { location: "/explore/create?intent=project" }, 22 22 }); 23 23 } 24 24 const accountType = await getEffectiveAccountType(user.did).catch(() => 25 25 null 26 26 ); 27 27 if (accountType !== "project") { 28 + /** 29 + * Signed in with a non-project type. Send users to their dashboard 30 + * with the upgrade modal pre-opened so they can either convert 31 + * this account or sign in with a different one. Legacy untyped 32 + * accounts (which the OAuth callback now always assigns) fall 33 + * through to the user dashboard as well. 34 + */ 28 35 return new Response(null, { 29 36 status: 303, 30 - headers: { 31 - location: accountType === "user" 32 - ? "/account/reviews" 33 - : "/account/type", 34 - }, 37 + headers: { location: "/account/reviews?upgrade=1" }, 35 38 }); 36 39 } 37 40
+27 -3
routes/oauth/add-account.ts
··· 1 1 /** 2 - * "Add another account" entry point for the AccountMenu switcher. 2 + * "Add another account" entry point for the AccountMenu switcher and 3 + * for the user→project "sign in with your project's account" link in 4 + * the upgrade modal. 3 5 * 4 6 * Clears the active app session (so /explore/create renders its 5 7 * sign-in form instead of bouncing back to /explore/manage) but ··· 7 9 * cookie intact. After the new sign-in completes the callback will 8 10 * append the new identity to the list and the user can switch back 9 11 * and forth from the menu. 12 + * 13 + * Optionally accepts an `intent` query/form param (`user` | `project`) 14 + * which is forwarded to /explore/create so the next sign-in is 15 + * auto-classified. 10 16 */ 11 17 import { define } from "../../utils.ts"; 12 18 import { clearSessionCookie, destroySession } from "../../lib/session.ts"; 13 19 14 - async function handle(ctx: { req: Request }): Promise<Response> { 20 + function readIntent( 21 + value: string | null | undefined, 22 + ): "user" | "project" | null { 23 + return value === "user" || value === "project" ? value : null; 24 + } 25 + 26 + async function handle(ctx: { req: Request; url: URL }): Promise<Response> { 27 + let intent = readIntent(ctx.url.searchParams.get("intent")); 28 + if (!intent && ctx.req.method === "POST") { 29 + const ct = (ctx.req.headers.get("content-type") ?? "").toLowerCase(); 30 + if ( 31 + ct.includes("application/x-www-form-urlencoded") || 32 + ct.includes("multipart/form-data") 33 + ) { 34 + const form = await ctx.req.formData().catch(() => null); 35 + const raw = form?.get("intent"); 36 + intent = readIntent(typeof raw === "string" ? raw : null); 37 + } 38 + } 15 39 await destroySession(ctx.req).catch(() => {}); 16 40 return new Response(null, { 17 41 status: 303, 18 42 headers: { 19 - location: "/explore/create", 43 + location: intent ? `/explore/create?intent=${intent}` : "/explore/create", 20 44 "set-cookie": clearSessionCookie(), 21 45 }, 22 46 });
+36 -10
routes/oauth/callback.ts
··· 16 16 readRememberedAccountsFromHeader, 17 17 } from "../../lib/remembered-accounts.ts"; 18 18 import { 19 + type AccountType, 19 20 getEffectiveAccountType, 20 - requiresAccountTypeChoice, 21 + setAppUserType, 21 22 updateAppUserProfile, 22 23 } from "../../lib/account-types.ts"; 23 24 import { type ProfileRecord, validateProfile } from "../../lib/lexicons.ts"; ··· 55 56 handle: result.handle, 56 57 }); 57 58 58 - const needsChoice = await requiresAccountTypeChoice(result.did); 59 59 const bskyProfile = await getBskyProfile(result.pdsUrl, result.did).catch( 60 60 () => null, 61 61 ); ··· 67 67 avatarCid: bskyProfile?.avatar?.ref.$link ?? null, 68 68 avatarMime: bskyProfile?.avatar?.mimeType ?? null, 69 69 }).catch(() => {}); 70 - const accountType = await getEffectiveAccountType(result.did).catch(() => 71 - null 72 - ); 70 + 71 + /** 72 + * Auto-classify newly signed-in DIDs based on the sign-in intent 73 + * carried through the OAuth flow: 74 + * - `intent === "project"` (clicked "Submit your project") 75 + * → mark as project, take them to the project dashboard. 76 + * - `intent === "user"` or unset (header sign-in, review CTAs) 77 + * → mark as user and publish a baseline user profile record. 78 + * 79 + * If the DID already has a type (re-sign-in or upgrade flows), 80 + * the intent is ignored and the existing classification stands. 81 + */ 82 + let accountType: AccountType | null = await getEffectiveAccountType( 83 + result.did, 84 + ).catch(() => null); 85 + if (accountType == null) { 86 + const desired: AccountType = result.intent === "project" 87 + ? "project" 88 + : "user"; 89 + await setAppUserType({ 90 + did: result.did, 91 + handle: result.handle, 92 + displayName: bskyProfile?.displayName ?? null, 93 + bio: bskyProfile?.description ?? null, 94 + avatarCid: bskyProfile?.avatar?.ref.$link ?? null, 95 + avatarMime: bskyProfile?.avatar?.mimeType ?? null, 96 + accountType: desired, 97 + }).catch(() => {}); 98 + accountType = desired; 99 + } 73 100 if (accountType === "user") { 74 101 const now = new Date().toISOString(); 75 102 const draft: ProfileRecord = { ··· 111 138 !result.returnTo.startsWith("//") 112 139 ? result.returnTo 113 140 : null; 141 + const defaultLanding = accountType === "project" 142 + ? "/explore/manage" 143 + : "/account/reviews"; 114 144 const headers = new Headers({ 115 - location: needsChoice 116 - ? `/account/type${ 117 - returnTo ? `?next=${encodeURIComponent(returnTo)}` : "" 118 - }` 119 - : returnTo ?? "/explore/manage", 145 + location: returnTo ?? defaultLanding, 120 146 }); 121 147 headers.append("set-cookie", sessionCookie); 122 148 headers.append("set-cookie", rememberedCookie);
+36 -12
routes/oauth/login.ts
··· 7 7 * and 302s the browser to the consent screen. 8 8 */ 9 9 import { define } from "../../utils.ts"; 10 - import { isOAuthConfigured, startLogin } from "../../lib/oauth.ts"; 10 + import { 11 + isOAuthConfigured, 12 + type SignInIntent, 13 + startLogin, 14 + } from "../../lib/oauth.ts"; 11 15 12 16 function safeNext(raw: string | null): string | null { 13 17 if (!raw || !raw.startsWith("/") || raw.startsWith("//")) return null; 14 18 return raw; 15 19 } 16 20 17 - async function getLoginInput( 18 - req: Request, 19 - url: URL, 20 - ): Promise<{ handle: string | null; next: string | null }> { 21 + function safeIntent(raw: string | null | undefined): SignInIntent | null { 22 + return raw === "user" || raw === "project" ? raw : null; 23 + } 24 + 25 + interface LoginInput { 26 + handle: string | null; 27 + next: string | null; 28 + intent: SignInIntent | null; 29 + } 30 + 31 + async function getLoginInput(req: Request, url: URL): Promise<LoginInput> { 21 32 const fromQs = url.searchParams.get("handle"); 22 33 const nextFromQs = safeNext(url.searchParams.get("next")); 23 - if (fromQs) return { handle: fromQs.trim(), next: nextFromQs }; 34 + const intentFromQs = safeIntent(url.searchParams.get("intent")); 35 + if (fromQs) { 36 + return { 37 + handle: fromQs.trim(), 38 + next: nextFromQs, 39 + intent: intentFromQs, 40 + }; 41 + } 24 42 const ct = (req.headers.get("content-type") ?? "").toLowerCase(); 25 43 if (ct.includes("application/json")) { 26 44 const body = await req.json().catch(() => null) as 27 - | { handle?: string; next?: string } 45 + | { handle?: string; next?: string; intent?: string } 28 46 | null; 29 47 return { 30 48 handle: body?.handle?.trim() ?? null, 31 - next: safeNext(body?.next ?? null), 49 + next: safeNext(body?.next ?? null) ?? nextFromQs, 50 + intent: safeIntent(body?.intent) ?? intentFromQs, 32 51 }; 33 52 } 34 53 if ( ··· 36 55 ct.includes("multipart/form-data") 37 56 ) { 38 57 const form = await req.formData().catch(() => null); 39 - if (!form) return { handle: null, next: nextFromQs }; 58 + if (!form) { 59 + return { handle: null, next: nextFromQs, intent: intentFromQs }; 60 + } 40 61 const v = form.get("handle"); 41 62 const next = form.get("next"); 63 + const intent = form.get("intent"); 42 64 return { 43 65 handle: typeof v === "string" ? v.trim() : null, 44 66 next: safeNext(typeof next === "string" ? next : null) ?? nextFromQs, 67 + intent: safeIntent(typeof intent === "string" ? intent : null) ?? 68 + intentFromQs, 45 69 }; 46 70 } 47 - return { handle: null, next: nextFromQs }; 71 + return { handle: null, next: nextFromQs, intent: intentFromQs }; 48 72 } 49 73 50 74 async function handle(ctx: { req: Request; url: URL }): Promise<Response> { ··· 54 78 { status: 503 }, 55 79 ); 56 80 } 57 - const { handle: handleStr, next: returnTo } = await getLoginInput( 81 + const { handle: handleStr, next: returnTo, intent } = await getLoginInput( 58 82 ctx.req, 59 83 ctx.url, 60 84 ); ··· 62 86 return new Response("missing handle", { status: 400 }); 63 87 } 64 88 try { 65 - const { redirectUrl } = await startLogin(handleStr, returnTo); 89 + const { redirectUrl } = await startLogin(handleStr, returnTo, intent); 66 90 return new Response(null, { 67 91 status: 303, 68 92 headers: { location: redirectUrl },