this repo has no description
10
fork

Configure Feed

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

feat(account): split user and project sign-in flows

Separate user accounts from project registry profiles so reviewers can manage their reviews without creating public project profiles.

Made-with: Cursor

+893 -15
+133
assets/styles.css
··· 5060 5060 .profile-review-author { 5061 5061 font-weight: 800; 5062 5062 } 5063 + .profile-review-handle { 5064 + margin: 0.1rem 0 0; 5065 + font-size: 0.78rem; 5066 + color: rgba(18, 26, 47, 0.55); 5067 + } 5063 5068 .profile-review-date { 5064 5069 margin-top: 0.15rem; 5065 5070 font-size: 0.78rem; ··· 5109 5114 } 5110 5115 .dark-phase .profile-reviews-threshold, 5111 5116 .dark-phase .profile-rating-row, 5117 + .dark-phase .profile-review-handle, 5112 5118 .dark-phase .profile-review-date, 5113 5119 .dark-phase .profile-review-char-count, 5114 5120 .dark-phase .profile-review-action-hint, ··· 5140 5146 grid-template-columns: 1fr; 5141 5147 } 5142 5148 .profile-review-header { 5149 + flex-direction: column; 5150 + } 5151 + } 5152 + 5153 + /* Account type onboarding + user review management */ 5154 + .account-type-section, 5155 + .account-reviews-section { 5156 + min-height: 70vh; 5157 + padding: 8rem 0 4rem; 5158 + } 5159 + .account-type-backdrop { 5160 + position: fixed; 5161 + } 5162 + .account-type-card { 5163 + width: min(560px, 100%); 5164 + } 5165 + .account-type-options { 5166 + display: grid; 5167 + grid-template-columns: 1fr 1fr; 5168 + gap: 0.85rem; 5169 + } 5170 + .account-type-options form { 5171 + margin: 0; 5172 + } 5173 + .account-type-option { 5174 + display: flex; 5175 + min-height: 10rem; 5176 + width: 100%; 5177 + flex-direction: column; 5178 + gap: 0.45rem; 5179 + align-items: flex-start; 5180 + justify-content: flex-start; 5181 + border: 1px solid rgba(18, 26, 47, 0.14); 5182 + border-radius: 1rem; 5183 + padding: 1rem; 5184 + background: rgba(255, 255, 255, 0.72); 5185 + color: inherit; 5186 + font: inherit; 5187 + text-align: left; 5188 + cursor: pointer; 5189 + transition: 5190 + transform 0.15s ease, 5191 + border-color 0.15s ease, 5192 + background 0.15s ease; 5193 + } 5194 + .account-type-option:hover { 5195 + transform: translateY(-1px); 5196 + border-color: rgba(81, 114, 255, 0.45); 5197 + background: rgba(255, 255, 255, 0.9); 5198 + } 5199 + .account-type-option strong { 5200 + font-size: 1rem; 5201 + } 5202 + .account-type-option span { 5203 + font-size: 0.9rem; 5204 + color: rgba(18, 26, 47, 0.68); 5205 + } 5206 + .account-reviews-header { 5207 + margin-bottom: 1.5rem; 5208 + } 5209 + .account-reviews-empty { 5210 + display: flex; 5211 + gap: 1rem; 5212 + align-items: center; 5213 + justify-content: space-between; 5214 + padding: 1.2rem; 5215 + } 5216 + .user-review-list { 5217 + display: flex; 5218 + flex-direction: column; 5219 + gap: 0.85rem; 5220 + } 5221 + .user-review-row { 5222 + padding: 1rem 1.1rem; 5223 + border-radius: 1.2rem; 5224 + } 5225 + .user-review-row--deleted { 5226 + opacity: 0.65; 5227 + } 5228 + .user-review-row-header, 5229 + .user-review-row-actions { 5230 + display: flex; 5231 + flex-wrap: wrap; 5232 + gap: 0.75rem; 5233 + align-items: flex-start; 5234 + justify-content: space-between; 5235 + } 5236 + .user-review-row-header h2, 5237 + .user-review-row-header p, 5238 + .user-review-row-body { 5239 + margin: 0; 5240 + } 5241 + .user-review-row-header h2 { 5242 + font-size: 1rem; 5243 + } 5244 + .user-review-row-header a { 5245 + color: inherit; 5246 + opacity: 0.68; 5247 + } 5248 + .user-review-row-body { 5249 + margin-top: 0.8rem; 5250 + white-space: pre-wrap; 5251 + } 5252 + .user-review-row-actions { 5253 + align-items: center; 5254 + margin-top: 1rem; 5255 + color: rgba(18, 26, 47, 0.58); 5256 + font-size: 0.82rem; 5257 + } 5258 + .dark-phase .account-type-option { 5259 + background: rgba(255, 255, 255, 0.06); 5260 + border-color: rgba(255, 255, 255, 0.12); 5261 + } 5262 + .dark-phase .account-type-option:hover { 5263 + background: rgba(255, 255, 255, 0.1); 5264 + border-color: rgba(160, 180, 255, 0.45); 5265 + } 5266 + .dark-phase .account-type-option span, 5267 + .dark-phase .user-review-row-actions { 5268 + color: rgba(255, 255, 255, 0.6); 5269 + } 5270 + @media (max-width: 640px) { 5271 + .account-type-options { 5272 + grid-template-columns: 1fr; 5273 + } 5274 + .account-reviews-empty { 5275 + align-items: flex-start; 5143 5276 flex-direction: column; 5144 5277 } 5145 5278 }
+2
components/Nav.tsx
··· 13 13 */ 14 14 account?: { 15 15 user: { did: string; handle: string } | null; 16 + accountType?: "user" | "project" | null; 16 17 avatarUrl?: string | null; 17 18 publicProfileHandle?: string | null; 18 19 /** Other accounts that have signed in on this device, used to ··· 47 48 <div class="account-menu-rail" id="account-menu-rail"> 48 49 <AccountMenu 49 50 user={account.user} 51 + accountType={account.accountType ?? null} 50 52 avatarUrl={account.avatarUrl ?? null} 51 53 publicProfileHandle={account.publicProfileHandle ?? null} 52 54 rememberedAccounts={account.rememberedAccounts ?? []}
+8 -3
components/explore/ProfileReviewList.tsx
··· 4 4 import ReviewResponseComposer from "../../islands/ReviewResponseComposer.tsx"; 5 5 6 6 export interface DisplayReview extends ReviewRow { 7 + reviewerName: string | null; 7 8 reviewerHandle: string | null; 8 9 } 9 10 ··· 65 66 <header class="profile-review-header"> 66 67 <div> 67 68 <p class="profile-review-author"> 68 - {review.reviewerHandle 69 - ? `@${review.reviewerHandle}` 70 - : copy.reviewerFallback} 69 + {review.reviewerName ?? review.reviewerHandle ?? 70 + copy.reviewerFallback} 71 71 </p> 72 + {review.reviewerHandle && ( 73 + <p class="profile-review-handle"> 74 + @{review.reviewerHandle} 75 + </p> 76 + )} 72 77 <p class="profile-review-date"> 73 78 {new Date(review.createdAt).toISOString().slice(0, 10)} 74 79 {review.updatedAt > review.createdAt && (
+28
i18n/messages/en.tsx
··· 40 40 signIn: "Sign in", 41 41 signInHint: "Sign in with your Atmosphere account to publish a profile.", 42 42 manageProfile: "Manage profile", 43 + manageReviews: "Manage reviews", 44 + chooseAccountType: "Choose account type", 43 45 viewProfile: "View profile", 44 46 signOut: "Sign out", 45 47 avatarAlt: "Account", ··· 881 883 spam: "Spam", 882 884 other: "Other", 883 885 }, 886 + }, 887 + 888 + accountType: { 889 + title: "How will you use Atmosphere?", 890 + body: (handle: string): string => 891 + `You're signed in as @${handle}. Choose whether this account represents you as a person or a project you want listed in Explore.`, 892 + userTitle: "I'm a user", 893 + userBody: 894 + "Use your Bluesky profile for identity, write reviews, and manage your reviews. No registry profile is created.", 895 + projectTitle: "I'm a project", 896 + projectBody: 897 + "Create and manage a public project profile in Explore with app links, screenshots, and developer details.", 898 + }, 899 + 900 + accountReviews: { 901 + eyebrow: "User account", 902 + headline: "Your reviews", 903 + subhead: (handle: string): string => 904 + `Signed in as @${handle}. Your user profile comes from Bluesky; reviews are managed here.`, 905 + empty: "You haven't reviewed any projects yet.", 906 + explore: "Explore projects", 907 + viewProject: "View project", 908 + delete: "Delete review", 909 + deleting: "Deleting…", 910 + deleted: "Review deleted.", 911 + error: "Couldn't update the review", 884 912 }, 885 913 886 914 reviews: {
+15 -3
islands/AccountMenu.tsx
··· 11 11 /** null when signed out — drives whether the menu shows sign-in or 12 12 * sign-out + manage actions. */ 13 13 user: { did: string; handle: string } | null; 14 + accountType?: "user" | "project" | null; 14 15 /** 15 16 * Server-resolved avatar URL (typically /api/me/avatar). Falls back 16 17 * to a handle-initial pill if the image 404s or fails to load. ··· 38 39 export default function AccountMenu( 39 40 { 40 41 user, 42 + accountType, 41 43 avatarUrl, 42 44 publicProfileHandle, 43 45 rememberedAccounts, ··· 63 65 return ( 64 66 <SignedInMenu 65 67 user={user} 68 + accountType={accountType ?? null} 66 69 avatarUrl={avatarUrl ?? null} 67 70 publicProfileHandle={publicProfileHandle ?? null} 68 71 rememberedAccounts={rememberedAccounts ?? []} ··· 72 75 73 76 interface SignedInMenuProps { 74 77 user: { did: string; handle: string }; 78 + accountType: "user" | "project" | null; 75 79 avatarUrl: string | null; 76 80 publicProfileHandle: string | null; 77 81 rememberedAccounts: RememberedAccount[]; 78 82 } 79 83 80 84 function SignedInMenu( 81 - { user, avatarUrl, publicProfileHandle, rememberedAccounts }: 85 + { user, accountType, avatarUrl, publicProfileHandle, rememberedAccounts }: 82 86 SignedInMenuProps, 83 87 ) { 84 88 const t = useT().nav.account; ··· 161 165 </a> 162 166 )} 163 167 <a 164 - href="/explore/manage" 168 + href={accountType === "user" 169 + ? "/account/reviews" 170 + : accountType === "project" 171 + ? "/explore/manage" 172 + : "/account/type"} 165 173 class="account-menu-item" 166 174 role="menuitem" 167 175 onClick={() => { 168 176 open.value = false; 169 177 }} 170 178 > 171 - {t.manageProfile} 179 + {accountType === "user" 180 + ? t.manageReviews 181 + : accountType === "project" 182 + ? t.manageProfile 183 + : t.chooseAccountType} 172 184 </a> 173 185 <form 174 186 method="POST"
+88
islands/UserReviewRow.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + 3 + interface Props { 4 + reviewId: number; 5 + targetHandle: string; 6 + targetName: string; 7 + rating: number; 8 + body: string; 9 + updatedAt: number; 10 + copy: { 11 + viewProject: string; 12 + delete: string; 13 + deleting: string; 14 + deleted: string; 15 + error: string; 16 + }; 17 + } 18 + 19 + export default function UserReviewRow(p: Props) { 20 + const status = useSignal<"idle" | "deleting" | "deleted">("idle"); 21 + const error = useSignal<string | null>(null); 22 + 23 + const remove = async () => { 24 + status.value = "deleting"; 25 + error.value = null; 26 + try { 27 + const r = await fetch( 28 + `/api/registry/reviews/${encodeURIComponent(String(p.reviewId))}`, 29 + { method: "DELETE" }, 30 + ); 31 + if (!r.ok) throw new Error(await r.text()); 32 + status.value = "deleted"; 33 + } catch (err) { 34 + error.value = err instanceof Error ? err.message : p.copy.error; 35 + status.value = "idle"; 36 + } 37 + }; 38 + 39 + if (status.value === "deleted") { 40 + return ( 41 + <article class="user-review-row glass user-review-row--deleted"> 42 + {p.copy.deleted} 43 + </article> 44 + ); 45 + } 46 + 47 + return ( 48 + <article class="user-review-row glass"> 49 + <div class="user-review-row-header"> 50 + <div> 51 + <h2>{p.targetName}</h2> 52 + <p> 53 + <a href={`/explore/${encodeURIComponent(p.targetHandle)}`}> 54 + @{p.targetHandle} 55 + </a> 56 + </p> 57 + </div> 58 + <p class="profile-review-stars" aria-label={`${p.rating} stars`}> 59 + {"★".repeat(p.rating)} 60 + <span aria-hidden="true">{"☆".repeat(5 - p.rating)}</span> 61 + </p> 62 + </div> 63 + {p.body && <p class="user-review-row-body">{p.body}</p>} 64 + <div class="user-review-row-actions"> 65 + <span>{new Date(p.updatedAt).toISOString().slice(0, 10)}</span> 66 + <a 67 + class="profile-form-button-secondary" 68 + href={`/explore/${encodeURIComponent(p.targetHandle)}`} 69 + > 70 + {p.copy.viewProject} 71 + </a> 72 + <button 73 + type="button" 74 + class="profile-form-button-danger" 75 + onClick={remove} 76 + disabled={status.value === "deleting"} 77 + > 78 + {status.value === "deleting" ? p.copy.deleting : p.copy.delete} 79 + </button> 80 + </div> 81 + {error.value && ( 82 + <p class="report-modal-status report-modal-status--error"> 83 + {p.copy.error}: {error.value} 84 + </p> 85 + )} 86 + </article> 87 + ); 88 + }
+4 -1
lib/account-menu-props.ts
··· 12 12 * previous user. 13 13 */ 14 14 import type { State } from "../utils.ts"; 15 + import type { AccountType } from "./account-types.ts"; 15 16 16 17 interface AccountMenuProps { 17 18 user: { did: string; handle: string } | null; 19 + accountType: AccountType | null; 18 20 avatarUrl: string | null; 19 21 publicProfileHandle: string | null; 20 22 rememberedAccounts: { did: string; handle: string }[]; 21 23 } 22 24 23 25 export function buildAccountMenuProps( 24 - state: Pick<State, "user" | "rememberedAccounts">, 26 + state: Pick<State, "user" | "accountType" | "rememberedAccounts">, 25 27 publicProfileHandle: string | null = null, 26 28 ): AccountMenuProps { 27 29 const user = state.user; 28 30 return { 29 31 user: user ? { did: user.did, handle: user.handle } : null, 32 + accountType: state.accountType ?? null, 30 33 avatarUrl: user ? `/api/me/avatar?v=${encodeURIComponent(user.did)}` : null, 31 34 publicProfileHandle, 32 35 rememberedAccounts: state.rememberedAccounts ?? [],
+136
lib/account-types.ts
··· 1 + /** 2 + * App-level account classification. A signed-in DID can be a normal 3 + * user (reviewing projects) or a project account (publishing a registry 4 + * profile). The choice is local to this AppView. 5 + */ 6 + import { withDb } from "./db.ts"; 7 + import { getProfileByDid } from "./registry.ts"; 8 + 9 + export type AccountType = "user" | "project"; 10 + 11 + export interface AppUserRow { 12 + did: string; 13 + handle: string; 14 + displayName: string | null; 15 + accountType: AccountType; 16 + createdAt: number; 17 + updatedAt: number; 18 + } 19 + 20 + interface RawAppUserRow { 21 + did: string; 22 + handle: string; 23 + display_name: string | null; 24 + account_type: string; 25 + created_at: number; 26 + updated_at: number; 27 + } 28 + 29 + function normalizeAccountType(value: string): AccountType { 30 + return value === "project" ? "project" : "user"; 31 + } 32 + 33 + function rowToAppUser(row: RawAppUserRow): AppUserRow { 34 + return { 35 + did: row.did, 36 + handle: row.handle, 37 + displayName: row.display_name, 38 + accountType: normalizeAccountType(row.account_type), 39 + createdAt: Number(row.created_at), 40 + updatedAt: Number(row.updated_at), 41 + }; 42 + } 43 + 44 + export async function getAppUser(did: string): Promise<AppUserRow | null> { 45 + return await withDb(async (c) => { 46 + const r = await c.execute({ 47 + sql: ` 48 + SELECT did, handle, display_name, account_type, created_at, updated_at 49 + FROM app_user 50 + WHERE did = ? 51 + LIMIT 1 52 + `, 53 + args: [did], 54 + }); 55 + const row = r.rows[0] as unknown as RawAppUserRow | undefined; 56 + return row ? rowToAppUser(row) : null; 57 + }); 58 + } 59 + 60 + export async function setAppUserType(input: { 61 + did: string; 62 + handle: string; 63 + displayName?: string | null; 64 + accountType: AccountType; 65 + }): Promise<AppUserRow> { 66 + return await withDb(async (c) => { 67 + const now = Date.now(); 68 + await c.execute({ 69 + sql: ` 70 + INSERT INTO app_user ( 71 + did, handle, display_name, account_type, created_at, updated_at 72 + ) VALUES (?, ?, ?, ?, ?, ?) 73 + ON CONFLICT(did) DO UPDATE SET 74 + handle = excluded.handle, 75 + display_name = COALESCE(excluded.display_name, app_user.display_name), 76 + account_type = excluded.account_type, 77 + updated_at = excluded.updated_at 78 + `, 79 + args: [ 80 + input.did, 81 + input.handle, 82 + input.displayName ?? null, 83 + input.accountType, 84 + now, 85 + now, 86 + ], 87 + }); 88 + const row = await getAppUser(input.did); 89 + if (!row) throw new Error("app_user_write_failed"); 90 + return row; 91 + }); 92 + } 93 + 94 + export async function updateAppUserProfile(input: { 95 + did: string; 96 + handle: string; 97 + displayName?: string | null; 98 + }): Promise<void> { 99 + await withDb(async (c) => { 100 + await c.execute({ 101 + sql: ` 102 + UPDATE app_user SET 103 + handle = ?, 104 + display_name = ?, 105 + updated_at = ? 106 + WHERE did = ? 107 + `, 108 + args: [ 109 + input.handle, 110 + input.displayName?.trim() || null, 111 + Date.now(), 112 + input.did, 113 + ], 114 + }); 115 + }); 116 + } 117 + 118 + /** 119 + * Existing published registry profiles predate account types. Treat those 120 + * DIDs as projects so old project accounts do not get forced through the 121 + * new chooser on their next sign-in. 122 + */ 123 + export async function getEffectiveAccountType( 124 + did: string, 125 + ): Promise<AccountType | null> { 126 + const user = await getAppUser(did); 127 + if (user) return user.accountType; 128 + const profile = await getProfileByDid(did, { includeTakenDown: true }).catch( 129 + () => null, 130 + ); 131 + return profile ? "project" : null; 132 + } 133 + 134 + export async function requiresAccountTypeChoice(did: string): Promise<boolean> { 135 + return (await getEffectiveAccountType(did)) == null; 136 + }
+20
lib/db.ts
··· 175 175 expires_at INTEGER NOT NULL 176 176 )`, 177 177 /** 178 + * App-level account type. OAuth identities can use the registry as 179 + * plain users (reviews only) or as projects (can publish registry 180 + * profiles). This is separate from the public `profile` table so 181 + * regular users never need to create a registry profile. 182 + */ 183 + `CREATE TABLE IF NOT EXISTS app_user ( 184 + did TEXT PRIMARY KEY, 185 + handle TEXT NOT NULL, 186 + display_name TEXT, 187 + account_type TEXT NOT NULL, 188 + created_at INTEGER NOT NULL, 189 + updated_at INTEGER NOT NULL 190 + )`, 191 + `CREATE INDEX IF NOT EXISTS app_user_account_type ON app_user(account_type)`, 192 + /** 178 193 * User reports against profiles. Anonymous reports carry a hashed IP 179 194 * for dedup + rate-limit; signed-in reports also record the 180 195 * reporter's DID. Admin actions write `status`, `admin_notes`, ··· 396 411 table: "profile", 397 412 column: "takedown_at", 398 413 ddl: "ALTER TABLE profile ADD COLUMN takedown_at INTEGER", 414 + }, 415 + { 416 + table: "app_user", 417 + column: "display_name", 418 + ddl: "ALTER TABLE app_user ADD COLUMN display_name TEXT", 399 419 }, 400 420 ]; 401 421 for (const m of additiveColumns) {
+43
lib/reviews.ts
··· 297 297 }); 298 298 } 299 299 300 + export async function deleteOwnReviewById( 301 + reviewId: number, 302 + reviewerDid: string, 303 + ): Promise<boolean> { 304 + return await withDb(async (c) => { 305 + const r = await c.execute({ 306 + sql: ` 307 + UPDATE review SET 308 + status = 'removed', 309 + updated_at = ?, 310 + removed_at = ?, 311 + removed_by = ? 312 + WHERE id = ? AND reviewer_did = ? AND status != 'removed' 313 + `, 314 + args: [Date.now(), Date.now(), reviewerDid, reviewId, reviewerDid], 315 + }); 316 + return Number(r.rowsAffected ?? 0) > 0; 317 + }); 318 + } 319 + 300 320 export async function getReviewSummary( 301 321 targetDid: string, 302 322 ): Promise<ReviewSummary> { ··· 355 375 LIMIT ? 356 376 `, 357 377 args: hasCursor ? [targetDid, opts.cursor!, limit] : [targetDid, limit], 378 + }); 379 + return r.rows.map((row) => rowToReview(row as unknown as RawReviewRow)); 380 + }); 381 + } 382 + 383 + export async function listReviewsByReviewer( 384 + reviewerDid: string, 385 + opts: { includeRemoved?: boolean } = {}, 386 + ): Promise<ReviewRow[]> { 387 + return await withDb(async (c) => { 388 + const r = await c.execute({ 389 + sql: ` 390 + SELECT r.*, rr.body AS response_body, 391 + rr.responder_did AS response_responder_did, 392 + rr.created_at AS response_created_at, 393 + rr.updated_at AS response_updated_at 394 + FROM review r 395 + LEFT JOIN review_response rr ON rr.review_id = r.id 396 + WHERE r.reviewer_did = ? 397 + ${opts.includeRemoved ? "" : "AND r.status != 'removed'"} 398 + ORDER BY r.updated_at DESC 399 + `, 400 + args: [reviewerDid], 358 401 }); 359 402 return r.rows.map((row) => rowToReview(row as unknown as RawReviewRow)); 360 403 });
+5
lib/session.ts
··· 10 10 import { hmacSign, hmacVerify, randomB64u } from "./jose.ts"; 11 11 import { IS_DEV, SESSION_SECRET } from "./env.ts"; 12 12 import { readRememberedAccounts } from "./remembered-accounts.ts"; 13 + import { getEffectiveAccountType } from "./account-types.ts"; 13 14 14 15 export interface SessionUser { 15 16 did: string; ··· 120 121 export const sessionMiddleware = define.middleware(async (ctx) => { 121 122 try { 122 123 ctx.state.user = await readSessionCookie(ctx.req); 124 + ctx.state.accountType = ctx.state.user 125 + ? await getEffectiveAccountType(ctx.state.user.did).catch(() => null) 126 + : null; 123 127 } catch (err) { 124 128 if (IS_DEV) console.warn("session read failed:", err); 125 129 ctx.state.user = null; 130 + ctx.state.accountType = null; 126 131 } 127 132 try { 128 133 ctx.state.rememberedAccounts = await readRememberedAccounts(ctx.req);
+123
routes/account/reviews.tsx
··· 1 + import { define } from "../../utils.ts"; 2 + import Nav from "../../components/Nav.tsx"; 3 + import GlassClouds from "../../components/GlassClouds.tsx"; 4 + import Footer from "../../components/Footer.tsx"; 5 + import UserReviewRow from "../../islands/UserReviewRow.tsx"; 6 + import { getMessages } from "../../i18n/mod.ts"; 7 + import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 8 + import { getEffectiveAccountType } from "../../lib/account-types.ts"; 9 + import { getProfileByDid } from "../../lib/registry.ts"; 10 + import { listReviewsByReviewer, type ReviewRow } from "../../lib/reviews.ts"; 11 + 12 + interface ReviewWithTarget extends ReviewRow { 13 + targetHandle: string; 14 + targetName: string; 15 + } 16 + 17 + export const handler = define.handlers({ 18 + async GET(ctx) { 19 + const user = ctx.state.user; 20 + if (!user) { 21 + return new Response(null, { 22 + status: 303, 23 + headers: { location: "/explore/create" }, 24 + }); 25 + } 26 + const accountType = await getEffectiveAccountType(user.did).catch(() => 27 + null 28 + ); 29 + if (!accountType) { 30 + return new Response(null, { 31 + status: 303, 32 + headers: { location: "/account/type" }, 33 + }); 34 + } 35 + 36 + const reviews = await listReviewsByReviewer(user.did).catch(() => []); 37 + const enriched: ReviewWithTarget[] = await Promise.all( 38 + reviews.map(async (review) => { 39 + const target = await getProfileByDid(review.targetDid, { 40 + includeTakenDown: true, 41 + }).catch(() => null); 42 + return { 43 + ...review, 44 + targetHandle: target?.handle ?? review.targetDid, 45 + targetName: target?.name ?? review.targetDid, 46 + }; 47 + }), 48 + ); 49 + 50 + return ctx.render( 51 + <AccountReviewsPage 52 + account={buildAccountMenuProps(ctx.state)} 53 + handle={user.handle} 54 + reviews={enriched} 55 + t={getMessages(ctx.state.locale)} 56 + />, 57 + ); 58 + }, 59 + }); 60 + 61 + interface AccountReviewsPageProps { 62 + account: ReturnType<typeof buildAccountMenuProps>; 63 + handle: string; 64 + reviews: ReviewWithTarget[]; 65 + // deno-lint-ignore no-explicit-any 66 + t: any; 67 + } 68 + 69 + function AccountReviewsPage( 70 + { account, handle, reviews, t }: AccountReviewsPageProps, 71 + ) { 72 + const copy = t.accountReviews; 73 + return ( 74 + <div id="page-top"> 75 + <GlassClouds /> 76 + <div class="content-layer"> 77 + <Nav account={account} /> 78 + <section class="account-reviews-section"> 79 + <div class="container" style={{ maxWidth: "820px" }}> 80 + <header class="account-reviews-header"> 81 + <p class="text-eyebrow">{copy.eyebrow}</p> 82 + <h1 class="text-section">{copy.headline}</h1> 83 + <p class="text-body mt-2">{copy.subhead(handle)}</p> 84 + </header> 85 + 86 + {reviews.length === 0 87 + ? ( 88 + <div class="glass account-reviews-empty"> 89 + <p class="text-body">{copy.empty}</p> 90 + <a href="/explore" class="explore-cta-primary"> 91 + {copy.explore} 92 + </a> 93 + </div> 94 + ) 95 + : ( 96 + <div class="user-review-list"> 97 + {reviews.map((review) => ( 98 + <UserReviewRow 99 + key={review.id} 100 + reviewId={review.id} 101 + targetHandle={review.targetHandle} 102 + targetName={review.targetName} 103 + rating={review.rating} 104 + body={review.body} 105 + updatedAt={review.updatedAt} 106 + copy={{ 107 + viewProject: copy.viewProject, 108 + delete: copy.delete, 109 + deleting: copy.deleting, 110 + deleted: copy.deleted, 111 + error: copy.error, 112 + }} 113 + /> 114 + ))} 115 + </div> 116 + )} 117 + </div> 118 + </section> 119 + <Footer variant="compact" /> 120 + </div> 121 + </div> 122 + ); 123 + }
+93
routes/account/type.tsx
··· 1 + import { define } from "../../utils.ts"; 2 + import Nav from "../../components/Nav.tsx"; 3 + import GlassClouds from "../../components/GlassClouds.tsx"; 4 + import Footer from "../../components/Footer.tsx"; 5 + import { getMessages } from "../../i18n/mod.ts"; 6 + import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 7 + import { getEffectiveAccountType } from "../../lib/account-types.ts"; 8 + 9 + export const handler = define.handlers({ 10 + async GET(ctx) { 11 + const user = ctx.state.user; 12 + if (!user) { 13 + return new Response(null, { 14 + status: 303, 15 + headers: { 16 + location: `/explore/create?next=${ 17 + encodeURIComponent("/account/type") 18 + }`, 19 + }, 20 + }); 21 + } 22 + 23 + const existingType = await getEffectiveAccountType(user.did).catch(() => 24 + null 25 + ); 26 + if (existingType === "project") { 27 + return new Response(null, { 28 + status: 303, 29 + headers: { location: "/explore/manage" }, 30 + }); 31 + } 32 + if (existingType === "user") { 33 + return new Response(null, { 34 + status: 303, 35 + headers: { location: "/account/reviews" }, 36 + }); 37 + } 38 + 39 + return ctx.render( 40 + <AccountTypePage 41 + account={buildAccountMenuProps(ctx.state)} 42 + handle={user.handle} 43 + t={getMessages(ctx.state.locale)} 44 + />, 45 + ); 46 + }, 47 + }); 48 + 49 + interface AccountTypePageProps { 50 + account: ReturnType<typeof buildAccountMenuProps>; 51 + handle: string; 52 + // deno-lint-ignore no-explicit-any 53 + t: any; 54 + } 55 + 56 + function AccountTypePage({ account, handle, t }: AccountTypePageProps) { 57 + const copy = t.accountType; 58 + return ( 59 + <div id="page-top"> 60 + <GlassClouds /> 61 + <div class="content-layer"> 62 + <Nav account={account} /> 63 + <section class="account-type-section"> 64 + <div class="modal-backdrop account-type-backdrop"> 65 + <div class="modal-card account-type-card"> 66 + <div class="modal-header"> 67 + <p class="modal-title">{copy.title}</p> 68 + <p class="modal-body-text">{copy.body(handle)}</p> 69 + </div> 70 + <div class="account-type-options"> 71 + <form method="POST" action="/api/account/type"> 72 + <input type="hidden" name="accountType" value="user" /> 73 + <button type="submit" class="account-type-option"> 74 + <strong>{copy.userTitle}</strong> 75 + <span>{copy.userBody}</span> 76 + </button> 77 + </form> 78 + <form method="POST" action="/api/account/type"> 79 + <input type="hidden" name="accountType" value="project" /> 80 + <button type="submit" class="account-type-option"> 81 + <strong>{copy.projectTitle}</strong> 82 + <span>{copy.projectBody}</span> 83 + </button> 84 + </form> 85 + </div> 86 + </div> 87 + </div> 88 + </section> 89 + <Footer variant="compact" /> 90 + </div> 91 + </div> 92 + ); 93 + }
+60
routes/api/account/type.ts
··· 1 + /** 2 + * Persist the signed-in account's local role: normal user or project. 3 + */ 4 + import { define } from "../../../utils.ts"; 5 + import { 6 + type AccountType, 7 + setAppUserType, 8 + } from "../../../lib/account-types.ts"; 9 + import { loadSession } from "../../../lib/oauth.ts"; 10 + import { getBskyProfile } from "../../../lib/pds.ts"; 11 + import { getProfileByDid } from "../../../lib/registry.ts"; 12 + 13 + export const handler = define.handlers({ 14 + async POST(ctx) { 15 + const user = ctx.state.user; 16 + if (!user) { 17 + return new Response("not authenticated", { status: 401 }); 18 + } 19 + const form = await ctx.req.formData().catch(() => null); 20 + const raw = form?.get("accountType"); 21 + const accountType = raw === "project" || raw === "user" 22 + ? raw as AccountType 23 + : null; 24 + if (!accountType) { 25 + return new Response("invalid account type", { status: 400 }); 26 + } 27 + if (accountType === "user") { 28 + const existingProject = await getProfileByDid(user.did, { 29 + includeTakenDown: true, 30 + }).catch(() => null); 31 + if (existingProject) { 32 + return new Response( 33 + "This account already has a project profile. Delete the project profile before switching it to a user account.", 34 + { status: 409 }, 35 + ); 36 + } 37 + } 38 + 39 + const session = await loadSession(user.did).catch(() => null); 40 + const bskyProfile = session 41 + ? await getBskyProfile(session.pdsUrl, user.did).catch(() => null) 42 + : null; 43 + 44 + await setAppUserType({ 45 + did: user.did, 46 + handle: user.handle, 47 + displayName: bskyProfile?.displayName ?? null, 48 + accountType, 49 + }); 50 + 51 + return new Response(null, { 52 + status: 303, 53 + headers: { 54 + location: accountType === "project" 55 + ? "/explore/manage" 56 + : "/account/reviews", 57 + }, 58 + }); 59 + }, 60 + });
+13
routes/api/registry/profile.ts
··· 29 29 upsertProfile, 30 30 } from "../../../lib/registry.ts"; 31 31 import { sanitizeSvgBytes } from "../../../lib/svg-sanitize.ts"; 32 + import { getEffectiveAccountType } from "../../../lib/account-types.ts"; 32 33 33 34 const ICON_MAX_BYTES = 200_000; 34 35 const SCREENSHOT_MAX_BYTES = 5_000_000; ··· 160 161 async PUT(ctx) { 161 162 const user = ctx.state.user; 162 163 if (!user) return new Response("not authenticated", { status: 401 }); 164 + const accountType = await getEffectiveAccountType(user.did).catch(() => 165 + null 166 + ); 167 + if (accountType !== "project") { 168 + return new Response("project account required", { status: 403 }); 169 + } 163 170 164 171 const session = await loadSession(user.did); 165 172 if (!session) { ··· 431 438 async DELETE(ctx) { 432 439 const user = ctx.state.user; 433 440 if (!user) return new Response("not authenticated", { status: 401 }); 441 + const accountType = await getEffectiveAccountType(user.did).catch(() => 442 + null 443 + ); 444 + if (accountType !== "project") { 445 + return new Response("project account required", { status: 403 }); 446 + } 434 447 435 448 const session = await loadSession(user.did); 436 449 if (!session) return new Response("OAuth session expired", { status: 401 });
+34
routes/api/registry/reviews/[reviewId].ts
··· 1 + /** 2 + * Signed-in caller actions for one of their own reviews. 3 + * 4 + * DELETE /api/registry/reviews/:reviewId 5 + */ 6 + import { define } from "../../../../utils.ts"; 7 + import { withRateLimit } from "../../../../lib/rate-limit.ts"; 8 + import { deleteOwnReviewById } from "../../../../lib/reviews.ts"; 9 + 10 + export const handler = define.handlers({ 11 + DELETE: withRateLimit(async (ctx) => { 12 + const user = ctx.state.user; 13 + if (!user) return jsonError(401, "not_authenticated"); 14 + 15 + const reviewId = Number(ctx.params.reviewId); 16 + if (!Number.isFinite(reviewId) || reviewId <= 0) { 17 + return jsonError(400, "invalid_review_id"); 18 + } 19 + 20 + const removed = await deleteOwnReviewById(reviewId, user.did); 21 + return jsonResponse(200, { ok: true, removed }); 22 + }), 23 + }); 24 + 25 + function jsonResponse(status: number, body: unknown): Response { 26 + return new Response(JSON.stringify(body), { 27 + status, 28 + headers: { "content-type": "application/json; charset=utf-8" }, 29 + }); 30 + } 31 + 32 + function jsonError(status: number, code: string): Response { 33 + return jsonResponse(status, { error: code }); 34 + }
+33 -4
routes/explore/[handle].tsx
··· 27 27 } from "../../lib/reviews.ts"; 28 28 import { accountProviderName } from "../../lib/account-providers.ts"; 29 29 import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 30 + import { getAppUser, updateAppUserProfile } from "../../lib/account-types.ts"; 31 + import { resolveIdentity } from "../../lib/identity.ts"; 32 + import { getBskyProfile } from "../../lib/pds.ts"; 30 33 31 34 export const handler = define.handlers({ 32 35 async GET(ctx) { ··· 242 245 async function enrichReviews(reviews: ReviewRow[]): Promise<DisplayReview[]> { 243 246 return await Promise.all( 244 247 reviews.map(async (review) => { 245 - const profile = await getProfileByDid(review.reviewerDid).catch(() => 246 - null 247 - ); 248 - return { ...review, reviewerHandle: profile?.handle ?? null }; 248 + const [appUser, profile] = await Promise.all([ 249 + getAppUser(review.reviewerDid).catch(() => null), 250 + getProfileByDid(review.reviewerDid).catch(() => null), 251 + ]); 252 + let reviewerName = appUser?.displayName ?? profile?.name ?? null; 253 + let reviewerHandle = appUser?.handle ?? profile?.handle ?? null; 254 + if (!reviewerName) { 255 + const identity = await resolveIdentity(review.reviewerDid).catch(() => 256 + null 257 + ); 258 + const bsky = identity 259 + ? await getBskyProfile(identity.pdsUrl, review.reviewerDid).catch( 260 + () => null, 261 + ) 262 + : null; 263 + reviewerName = bsky?.displayName ?? null; 264 + reviewerHandle = reviewerHandle ?? identity?.handle ?? null; 265 + if (appUser && identity) { 266 + await updateAppUserProfile({ 267 + did: review.reviewerDid, 268 + handle: identity.handle, 269 + displayName: bsky?.displayName ?? null, 270 + }).catch(() => {}); 271 + } 272 + } 273 + return { 274 + ...review, 275 + reviewerName, 276 + reviewerHandle, 277 + }; 249 278 }), 250 279 ); 251 280 }
+12 -2
routes/explore/create.tsx
··· 6 6 import { getMessages } from "../../i18n/mod.ts"; 7 7 import { isOAuthConfigured } from "../../lib/oauth.ts"; 8 8 import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 9 + import { getEffectiveAccountType } from "../../lib/account-types.ts"; 9 10 10 - export default define.page(function ExploreCreate(ctx) { 11 + export default define.page(async function ExploreCreate(ctx) { 11 12 const t = getMessages(ctx.state.locale).explore; 12 13 const user = ctx.state.user; 13 14 14 15 if (user) { 16 + const accountType = await getEffectiveAccountType(user.did).catch(() => 17 + null 18 + ); 15 19 return new Response(null, { 16 20 status: 303, 17 - headers: { location: "/explore/manage" }, 21 + headers: { 22 + location: accountType === "project" 23 + ? "/explore/manage" 24 + : accountType === "user" 25 + ? "/account/reviews" 26 + : "/account/type", 27 + }, 18 28 }) as unknown as preact.JSX.Element; 19 29 } 20 30
+14
routes/explore/manage.tsx
··· 8 8 import { loadSession } from "../../lib/oauth.ts"; 9 9 import { getBskyProfile } from "../../lib/pds.ts"; 10 10 import { buildAccountMenuProps } from "../../lib/account-menu-props.ts"; 11 + import { getEffectiveAccountType } from "../../lib/account-types.ts"; 11 12 12 13 /** 13 14 * Build the deterministic public Bluesky CDN URL for a user's avatar ··· 33 34 return new Response(null, { 34 35 status: 303, 35 36 headers: { location: "/explore/create" }, 37 + }); 38 + } 39 + const accountType = await getEffectiveAccountType(user.did).catch(() => 40 + null 41 + ); 42 + if (accountType !== "project") { 43 + return new Response(null, { 44 + status: 303, 45 + headers: { 46 + location: accountType === "user" 47 + ? "/account/reviews" 48 + : "/account/type", 49 + }, 36 50 }); 37 51 } 38 52
+17 -1
routes/oauth/callback.ts
··· 10 10 import { define } from "../../utils.ts"; 11 11 import { completeCallback, isOAuthConfigured } from "../../lib/oauth.ts"; 12 12 import { buildSessionCookie, createSession } from "../../lib/session.ts"; 13 + import { getBskyProfile } from "../../lib/pds.ts"; 13 14 import { 14 15 addRememberedAccountCookie, 15 16 readRememberedAccountsFromHeader, 16 17 } from "../../lib/remembered-accounts.ts"; 18 + import { 19 + requiresAccountTypeChoice, 20 + updateAppUserProfile, 21 + } from "../../lib/account-types.ts"; 17 22 18 23 export const handler = define.handlers({ 19 24 async GET(ctx) { ··· 47 52 handle: result.handle, 48 53 }); 49 54 50 - const headers = new Headers({ location: "/explore/manage" }); 55 + const needsChoice = await requiresAccountTypeChoice(result.did); 56 + const bskyProfile = await getBskyProfile(result.pdsUrl, result.did).catch( 57 + () => null, 58 + ); 59 + await updateAppUserProfile({ 60 + did: result.did, 61 + handle: result.handle, 62 + displayName: bskyProfile?.displayName ?? null, 63 + }).catch(() => {}); 64 + const headers = new Headers({ 65 + location: needsChoice ? "/account/type" : "/explore/manage", 66 + }); 51 67 headers.append("set-cookie", sessionCookie); 52 68 headers.append("set-cookie", rememberedCookie); 53 69 return new Response(null, { status: 303, headers });
+9 -1
routes/oauth/switch.ts
··· 23 23 destroySession, 24 24 } from "../../lib/session.ts"; 25 25 import { readRememberedAccountsFromHeader } from "../../lib/remembered-accounts.ts"; 26 + import { getEffectiveAccountType } from "../../lib/account-types.ts"; 26 27 27 28 async function readDid(req: Request): Promise<string | null> { 28 29 const ct = (req.headers.get("content-type") ?? "").toLowerCase(); ··· 74 75 did: oauthSession.did, 75 76 handle: oauthSession.handle, 76 77 }); 78 + const accountType = await getEffectiveAccountType(oauthSession.did).catch( 79 + () => null, 80 + ); 77 81 78 82 return new Response(null, { 79 83 status: 303, 80 84 headers: { 81 - location: "/explore/manage", 85 + location: accountType === "project" 86 + ? "/explore/manage" 87 + : accountType === "user" 88 + ? "/account/reviews" 89 + : "/account/type", 82 90 "set-cookie": buildSessionCookie(cookieValue), 83 91 }, 84 92 });
+3
utils.ts
··· 1 1 import { createDefine } from "fresh"; 2 2 import type { Locale } from "./i18n/locales.ts"; 3 3 import type { RememberedAccount } from "./lib/remembered-accounts.ts"; 4 + import type { AccountType } from "./lib/account-types.ts"; 4 5 5 6 export interface SessionUser { 6 7 did: string; ··· 12 13 locale: Locale; 13 14 /** Logged-in registry account, or null when signed out. Set by sessionMiddleware. */ 14 15 user: SessionUser | null; 16 + /** Local account role: users manage reviews, projects manage registry profiles. */ 17 + accountType: AccountType | null; 15 18 /** Accounts that have completed OAuth on this device, in 16 19 * most-recently-used order. Populated by sessionMiddleware so 17 20 * routes can hand the list to AccountMenu for the switcher. */