diff --git a/.env.example b/.env.example index 74b966b..f191f65 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,16 @@ PLATFORM_FEE_PERCENT=20 # Set to true to require a verified email before posting, liking, commenting or following. REQUIRE_VERIFIED_EMAIL=false +# Optional: "Sign in with Google". Create a Web OAuth client in Google Cloud Console and paste +# its client ID here AND in VITE_GOOGLE_CLIENT_ID below (the same value; it is public). +GOOGLE_CLIENT_ID= + # ---- Web ---- # Only needed for local development; production builds use "/api" automatically. VITE_API_URL=http://localhost:4000/api + +# Optional: Google sign-in button and AdSense ads (both are hidden when blank). +VITE_GOOGLE_CLIENT_ID= +# AdSense publisher id (ca-pub-...) and the ad unit id for the Explore feed. Production only. +VITE_ADSENSE_CLIENT= +VITE_ADSENSE_FEED_SLOT= diff --git a/README.md b/README.md index a726c6a..af98dc2 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Log out and back in, and an **Admin** link appears in the header. - [x] Moderation tools (reports, admin queue, post removal, account suspension) - [x] Email verification and password reset (Resend) - [x] Paid posts (pay to unlock, Lemon Squeezy checkout, creator earnings ledger) +- [x] Sign in with Google; public Explore page with optional AdSense ads ## License diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 6f38eb3..61fa22d 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -35,6 +35,8 @@ const schema = z.object({ .string() .optional() .transform((v) => v?.trim() || "Commune "), + // "Sign in with Google": the OAuth Web client ID (looks like 123-abc.apps.googleusercontent.com). + GOOGLE_CLIENT_ID: z.string().optional(), // Paid posts, through Lemon Squeezy (https://lemonsqueezy.com). All four are needed for real // checkouts. Outside production, with none set, purchases are simulated so you can test. LEMONSQUEEZY_API_KEY: z.string().optional(), diff --git a/apps/api/src/controllers/authController.ts b/apps/api/src/controllers/authController.ts index f64efc1..e662e31 100644 --- a/apps/api/src/controllers/authController.ts +++ b/apps/api/src/controllers/authController.ts @@ -20,3 +20,7 @@ export async function logout(req: Request, res: Response) { await authService.logout(presented, res); res.status(204).end(); } + +export async function google(req: Request, res: Response) { + res.json(await authService.loginWithGoogle(req.body.credential, res)); +} diff --git a/apps/api/src/controllers/publicController.ts b/apps/api/src/controllers/publicController.ts new file mode 100644 index 0000000..cfef59b --- /dev/null +++ b/apps/api/src/controllers/publicController.ts @@ -0,0 +1,31 @@ +import mongoose from "mongoose"; +import type { Request, Response } from "express"; +import type { Page, PostDTO } from "@commune/shared"; +import { Post } from "../models/Post.js"; +import { badRequest } from "../utils/errors.js"; +import { toPostDTO, type PopulatedPost } from "../utils/serializers.js"; + +/** + * Latest posts for logged-out visitors. Paid posts are excluded entirely (not even teasers), + * and nothing viewer-specific (likes) is included, so the response is the same for everyone. + */ +export async function listPosts(_req: Request, res: Response) { + const { cursor, limit } = (res.locals as { query: { cursor?: string; limit: number } }).query; + if (cursor && !mongoose.isValidObjectId(cursor)) throw badRequest("Invalid cursor"); + + const filter = cursor ? { priceCents: null, _id: { $lt: cursor } } : { priceCents: null }; + const rows = (await Post.find(filter) + .sort({ _id: -1 }) + .limit(limit + 1) + .populate("author", "username avatarUrl") + .lean()) as unknown as PopulatedPost[]; + + const hasMore = rows.length > limit; + const posts = hasMore ? rows.slice(0, limit) : rows; + const body: Page = { + items: posts.map((p) => toPostDTO(p, false, true)), + nextCursor: hasMore ? (posts[posts.length - 1]?._id.toString() ?? null) : null, + }; + res.set("Cache-Control", "public, max-age=30"); + res.json(body); +} diff --git a/apps/api/src/middleware/rateLimit.ts b/apps/api/src/middleware/rateLimit.ts index c3a920f..3f9a0a6 100644 --- a/apps/api/src/middleware/rateLimit.ts +++ b/apps/api/src/middleware/rateLimit.ts @@ -66,3 +66,13 @@ export const searchLimiter = rateLimit({ skip, message: { error: "Searching too fast, try again in a moment." }, }); + +/** Unauthenticated read-only endpoints (the public Explore page). */ +export const publicLimiter = rateLimit({ + windowMs: 60 * 1000, + limit: 60, + standardHeaders: "draft-7", + legacyHeaders: false, + skip, + message: { error: "Too many requests, slow down." }, +}); diff --git a/apps/api/src/models/User.ts b/apps/api/src/models/User.ts index 7301ca9..55e21ff 100644 --- a/apps/api/src/models/User.ts +++ b/apps/api/src/models/User.ts @@ -10,6 +10,8 @@ const userSchema = new Schema( avatarUrl: { type: String, default: null, index: true, sparse: true }, role: { type: String, enum: ["user", "admin"], default: "user" }, banned: { type: Boolean, default: false }, + // Google account id ("sub"). Unset for password-only accounts, so the unique index is sparse. + googleId: { type: String, unique: true, sparse: true }, emailVerified: { type: Boolean, default: false }, }, { timestamps: true }, diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index a423eba..ffa1641 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -5,6 +5,7 @@ import { createPostSchema, createReportSchema, forgotPasswordSchema, + googleLoginSchema, loginSchema, paginationSchema, registerSchema, @@ -22,11 +23,12 @@ import * as payments from "../controllers/paymentController.js"; import * as notifications from "../controllers/notificationController.js"; import * as posts from "../controllers/postController.js"; import * as reports from "../controllers/reportController.js"; +import * as publicApi from "../controllers/publicController.js"; import * as search from "../controllers/searchController.js"; import * as uploads from "../controllers/uploadController.js"; import * as users from "../controllers/userController.js"; import { requireActive, requireAdmin, requireAuth } from "../middleware/auth.js"; -import { authLimiter, checkoutLimiter, emailLimiter, reportLimiter, searchLimiter, uploadLimiter } from "../middleware/rateLimit.js"; +import { authLimiter, checkoutLimiter, emailLimiter, reportLimiter, publicLimiter, searchLimiter, uploadLimiter } from "../middleware/rateLimit.js"; import { parseImageUpload } from "../middleware/upload.js"; import { validate } from "../middleware/validate.js"; import { asyncHandler } from "../utils/errors.js"; @@ -47,6 +49,7 @@ router.get("/health", (_req, res) => { // Auth router.post("/auth/register", authLimiter, validate(registerSchema), asyncHandler(auth.register)); router.post("/auth/login", authLimiter, validate(loginSchema), asyncHandler(auth.login)); +router.post("/auth/google", authLimiter, validate(googleLoginSchema), asyncHandler(auth.google)); router.post("/auth/refresh", authLimiter, asyncHandler(auth.refresh)); router.post("/auth/logout", asyncHandler(auth.logout)); @@ -78,6 +81,15 @@ router.post( asyncHandler(account.resendVerification), ); +// Public, read-only (no login): powers the Explore page that search engines and ad +// reviewers can see. Only free posts are ever returned. +router.get( + "/public/posts", + publicLimiter, + validate(paginationSchema, "query"), + asyncHandler(publicApi.listPosts), +); + // Uploads (auth and rate limit run before the file is parsed) router.post( "/uploads", diff --git a/apps/api/src/services/authService.ts b/apps/api/src/services/authService.ts index 5b3ed38..9de1fb4 100644 --- a/apps/api/src/services/authService.ts +++ b/apps/api/src/services/authService.ts @@ -1,11 +1,13 @@ +import crypto from "node:crypto"; import bcrypt from "bcryptjs"; import type { Response } from "express"; import type { AuthResponse, LoginInput, RegisterInput } from "@commune/shared"; import { env, isProd } from "../config/env.js"; import { RefreshToken } from "../models/RefreshToken.js"; import { User } from "../models/User.js"; -import { conflict, forbidden, unauthorized } from "../utils/errors.js"; +import { AppError, conflict, forbidden, unauthorized } from "../utils/errors.js"; import { toPublicUser } from "../utils/serializers.js"; +import { verifyGoogleIdToken, type GoogleProfile } from "./googleAuth.js"; import { sendVerificationEmailInBackground } from "./accountService.js"; import { generateRefreshToken, hashToken, signAccessToken } from "../utils/tokens.js"; @@ -89,3 +91,56 @@ export async function logout(presented: string | undefined, res: Response) { if (presented) await RefreshToken.deleteOne({ tokenHash: hashToken(presented) }); clearRefreshCookie(res); } + +/** Accounts created with Google get a random password nobody knows, so password login is off. */ +const randomPasswordHash = () => bcrypt.hash(crypto.randomBytes(32).toString("hex"), 12); + +async function uniqueUsername(profile: GoogleProfile): Promise { + const raw = profile.name ?? profile.email.split("@")[0] ?? ""; + let base = raw.normalize("NFKD").replace(/[^a-zA-Z0-9_]/g, "").slice(0, 16); + if (base.length < 3) base = "user"; + for (let attempt = 0; attempt < 6; attempt++) { + const candidate = + attempt === 0 ? base : `${base}${crypto.randomInt(0, 10000).toString().padStart(4, "0")}`; + const taken = await User.exists({ username: candidate }).collation({ + locale: "en", + strength: 2, + }); + if (!taken) return candidate; + } + return `user${crypto.randomBytes(5).toString("hex")}`; +} + +/** Sign in, or sign up, with a Google ID token. */ +export async function loginWithGoogle(credential: string, res: Response) { + if (!env.GOOGLE_CLIENT_ID) throw new AppError(503, "Google sign-in is not set up"); + const google = await verifyGoogleIdToken(credential); + + let user = await User.findOne({ googleId: google.sub }); + if (!user) { + const existing = await User.findOne({ email: google.email }); + if (existing) { + existing.googleId = google.sub; + if (!existing.emailVerified) { + // Someone may have registered this address with a password before its real owner + // arrived. Google just proved ownership, so cancel any password and sessions that + // were set up without that proof. + existing.passwordHash = await randomPasswordHash(); + existing.emailVerified = true; + await RefreshToken.deleteMany({ user: existing._id }); + } + await existing.save(); + user = existing; + } else { + user = await User.create({ + username: await uniqueUsername(google), + email: google.email, + passwordHash: await randomPasswordHash(), + googleId: google.sub, + emailVerified: true, + }); + } + } + if (user.banned) throw forbidden("This account has been suspended"); + return issueSession(res, user); +} diff --git a/apps/api/src/services/googleAuth.ts b/apps/api/src/services/googleAuth.ts new file mode 100644 index 0000000..d770ffb --- /dev/null +++ b/apps/api/src/services/googleAuth.ts @@ -0,0 +1,103 @@ +import crypto from "node:crypto"; +import { env } from "../config/env.js"; +import { AppError, unauthorized } from "../utils/errors.js"; + +/** + * Verifies a Google ID token (the "credential" from Sign in with Google) without any extra + * dependency: the RS256 signature is checked against Google's published public keys, then the + * claims are checked. Nothing in the token is trusted before the signature is valid. + */ +const CERTS_URL = "https://www.googleapis.com/oauth2/v3/certs"; +const ISSUERS = ["https://accounts.google.com", "accounts.google.com"]; +const CLOCK_SKEW_MS = 60_000; + +interface KeyCache { + keys: Map; + fetchedAt: number; + expiresAt: number; +} +let cache: KeyCache | null = null; + +async function loadKeys(): Promise { + let res: Response; + try { + res = await fetch(CERTS_URL); + } catch { + throw new AppError(502, "Google sign-in is unavailable right now"); + } + if (!res.ok) throw new AppError(502, "Google sign-in is unavailable right now"); + + const body = (await res.json()) as { keys?: (crypto.JsonWebKey & { kid?: string })[] }; + const keys = new Map(); + for (const jwk of body.keys ?? []) { + if (jwk.kid && jwk.kty === "RSA") { + keys.set(jwk.kid, crypto.createPublicKey({ key: jwk, format: "jwk" })); + } + } + const maxAge = /max-age=(\d+)/.exec(res.headers.get("cache-control") ?? "")?.[1]; + const now = Date.now(); + cache = { keys, fetchedAt: now, expiresAt: now + (maxAge ? Number(maxAge) : 3600) * 1000 }; + return cache; +} + +async function findKey(kid: string): Promise { + const now = Date.now(); + if (cache && now < cache.expiresAt && cache.keys.has(kid)) return cache.keys.get(kid); + // Unknown key id: Google may have rotated keys. Refetch, but at most once a minute. + if (!cache || now >= cache.expiresAt || now - cache.fetchedAt > 60_000) await loadKeys(); + return cache?.keys.get(kid); +} + +const decode = (part: string) => Buffer.from(part, "base64url"); + +export interface GoogleProfile { + sub: string; + email: string; + name: string | null; +} + +export async function verifyGoogleIdToken(token: string): Promise { + const fail = () => unauthorized("Google sign-in failed"); + if (!env.GOOGLE_CLIENT_ID) throw new AppError(503, "Google sign-in is not set up"); + + const parts = token.split("."); + if (parts.length !== 3) throw fail(); + const [headerPart, payloadPart, signaturePart] = parts as [string, string, string]; + + let header: { alg?: string; kid?: string }; + let claims: { + iss?: string; + aud?: string; + exp?: number; + sub?: string; + email?: string; + email_verified?: boolean | string; + name?: string; + }; + try { + header = JSON.parse(decode(headerPart).toString("utf8")); + claims = JSON.parse(decode(payloadPart).toString("utf8")); + } catch { + throw fail(); + } + + if (header.alg !== "RS256" || !header.kid) throw fail(); + const key = await findKey(header.kid); + if (!key) throw fail(); + const valid = crypto.verify( + "RSA-SHA256", + Buffer.from(`${headerPart}.${payloadPart}`), + key, + decode(signaturePart), + ); + if (!valid) throw fail(); + + if (!claims.iss || !ISSUERS.includes(claims.iss)) throw fail(); + if (claims.aud !== env.GOOGLE_CLIENT_ID) throw fail(); + if (typeof claims.exp !== "number" || claims.exp * 1000 + CLOCK_SKEW_MS < Date.now()) throw fail(); + if (!claims.sub || !claims.email) throw fail(); + // Only trust an email address Google itself has verified. + if (claims.email_verified !== true && claims.email_verified !== "true") throw fail(); + + return { sub: claims.sub, email: claims.email.toLowerCase(), name: claims.name ?? null }; +} diff --git a/apps/api/src/tests/google.test.ts b/apps/api/src/tests/google.test.ts new file mode 100644 index 0000000..3405017 --- /dev/null +++ b/apps/api/src/tests/google.test.ts @@ -0,0 +1,157 @@ +import crypto from "node:crypto"; +import mongoose from "mongoose"; +import request from "supertest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// The client id must exist before the config module is first imported. +vi.hoisted(() => { + process.env.GOOGLE_CLIENT_ID = "test-client.apps.googleusercontent.com"; +}); + +import { createApp } from "../app.js"; +import { RefreshToken } from "../models/RefreshToken.js"; +import { User } from "../models/User.js"; + +const app = createApp(); +const CLIENT_ID = "test-client.apps.googleusercontent.com"; + +// A throwaway RSA key pair stands in for Google's signing keys. +const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }); +const jwk = { ...publicKey.export({ format: "jwk" }), kid: "test-key-1", alg: "RS256", use: "sig" }; + +const b64 = (value: object | Buffer) => + Buffer.from(value instanceof Buffer ? value : JSON.stringify(value)).toString("base64url"); + +function idToken( + claims: Record = {}, + opts: { kid?: string; key?: crypto.KeyObject; alg?: string } = {}, +) { + const header = b64({ alg: opts.alg ?? "RS256", kid: opts.kid ?? "test-key-1", typ: "JWT" }); + const payload = b64({ + iss: "https://accounts.google.com", + aud: CLIENT_ID, + sub: "google-sub-1", + email: "Sam@Example.com", + email_verified: true, + name: "Sam Rivers", + exp: Math.floor(Date.now() / 1000) + 3600, + ...claims, + }); + const sig = crypto.sign("RSA-SHA256", Buffer.from(`${header}.${payload}`), opts.key ?? privateKey); + return `${header}.${payload}.${b64(sig)}`; +} + +const post = (credential: string) => request(app).post("/api/auth/google").send({ credential }); + +beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "cache-control": "public, max-age=3600" }), + json: async () => ({ keys: [jwk] }), + }), + ); + vi.spyOn(RefreshToken, "create").mockResolvedValue({} as never); + vi.spyOn(RefreshToken, "deleteMany").mockResolvedValue({} as never); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const newUser = (overrides: Record = {}) => ({ + _id: new mongoose.Types.ObjectId(), + username: "SamRivers", + email: "sam@example.com", + bio: "", + avatarUrl: null, + role: "user", + banned: false, + emailVerified: true, + createdAt: new Date(), + ...overrides, +}); + +describe("POST /api/auth/google: token checks", () => { + it("rejects a malformed credential before doing anything", async () => { + expect((await request(app).post("/api/auth/google").send({})).status).toBe(400); + expect((await post("x".repeat(120))).status).toBe(401); + }); + + it("rejects a token signed with a different key", async () => { + const other = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }); + const res = await post(idToken({}, { key: other.privateKey })); + expect(res.status).toBe(401); + }); + + it("rejects a wrong audience, issuer, expired token and unverified email", async () => { + for (const claims of [ + { aud: "someone-elses-app" }, + { iss: "https://evil.example" }, + { exp: Math.floor(Date.now() / 1000) - 3600 }, + { email_verified: false }, + ]) { + expect((await post(idToken(claims))).status).toBe(401); + } + }); + + it("rejects algorithm tricks and unknown key ids", async () => { + expect((await post(idToken({}, { alg: "none" }))).status).toBe(401); + expect((await post(idToken({}, { kid: "unknown" }))).status).toBe(401); + }); +}); + +describe("POST /api/auth/google: accounts", () => { + it("signs in a returning Google user", async () => { + vi.spyOn(User, "findOne").mockResolvedValue(newUser() as never); + const create = vi.spyOn(User, "create"); + const res = await post(idToken()); + expect(res.status).toBe(200); + expect(res.body.accessToken).toBeTypeOf("string"); + expect(res.body.user.username).toBe("SamRivers"); + expect(create).not.toHaveBeenCalled(); + expect(res.headers["set-cookie"]?.[0]).toMatch(/commune_rt=/); + }); + + it("creates a verified account with a safe unique username for a new user", async () => { + vi.spyOn(User, "findOne").mockResolvedValue(null as never); + vi.spyOn(User, "exists").mockReturnValue({ collation: () => Promise.resolve(null) } as never); + let doc: Record = {}; + vi.spyOn(User, "create").mockImplementation((async (input: Record) => { + doc = input; + return newUser(input); + }) as never); + + const res = await post(idToken({ name: "Sam Rivers!" })); + expect(res.status).toBe(200); + expect(doc.username).toBe("SamRivers"); + expect(doc.email).toBe("sam@example.com"); + expect(doc.googleId).toBe("google-sub-1"); + expect(doc.emailVerified).toBe(true); + // A random, unknown password: not the email, not the Google id. + expect(String(doc.passwordHash)).toMatch(/^\$2[aby]\$/); + }); + + it("links an existing password account and cancels a password set before verification", async () => { + const save = vi.fn().mockResolvedValue(undefined); + const existing = newUser({ emailVerified: false, passwordHash: "old-hash", googleId: undefined, save }); + vi.spyOn(User, "findOne").mockImplementation(((filter: Record) => + Promise.resolve("googleId" in filter ? null : existing)) as never); + + const res = await post(idToken()); + expect(res.status).toBe(200); + expect(existing.googleId).toBe("google-sub-1"); + expect(existing.emailVerified).toBe(true); + expect(existing.passwordHash).not.toBe("old-hash"); + expect(save).toHaveBeenCalled(); + expect(RefreshToken.deleteMany).toHaveBeenCalledWith({ user: existing._id }); + }); + + it("refuses suspended accounts", async () => { + vi.spyOn(User, "findOne").mockResolvedValue(newUser({ banned: true }) as never); + const res = await post(idToken()); + expect(res.status).toBe(403); + }); +}); diff --git a/apps/api/src/tests/public.test.ts b/apps/api/src/tests/public.test.ts new file mode 100644 index 0000000..acffd41 --- /dev/null +++ b/apps/api/src/tests/public.test.ts @@ -0,0 +1,55 @@ +import mongoose from "mongoose"; +import request from "supertest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createApp } from "../app.js"; +import { Post } from "../models/Post.js"; + +const app = createApp(); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function queryChain(result: unknown) { + const chain: Record = {}; + for (const step of ["sort", "limit", "populate"]) chain[step] = vi.fn(() => chain); + chain.lean = () => Promise.resolve(result); + return chain; +} + +describe("GET /api/public/posts", () => { + const author = { _id: new mongoose.Types.ObjectId(), username: "sam", avatarUrl: null }; + const row = (content: string) => ({ + _id: new mongoose.Types.ObjectId(), + content, + imageUrl: null, + author, + likeCount: 2, + commentCount: 1, + createdAt: new Date(), + }); + + it("works without logging in and asks only for free posts", async () => { + const find = vi.spyOn(Post, "find").mockReturnValue(queryChain([row("Hello")]) as never); + const res = await request(app).get("/api/public/posts"); + expect(res.status).toBe(200); + expect(find).toHaveBeenCalledWith({ priceCents: null }); + expect(res.body.items).toHaveLength(1); + expect(res.body.items[0]).toMatchObject({ content: "Hello", likedByMe: false, locked: false }); + expect(res.body.nextCursor).toBeNull(); + expect(res.headers["cache-control"]).toMatch(/public/); + }); + + it("paginates with a cursor", async () => { + const rows = Array.from({ length: 3 }, (_, i) => row(`p${i}`)); + vi.spyOn(Post, "find").mockReturnValue(queryChain(rows) as never); + const res = await request(app).get("/api/public/posts?limit=2"); + expect(res.body.items).toHaveLength(2); + expect(res.body.nextCursor).toBe(rows[1]!._id.toString()); + }); + + it("rejects a bad cursor", async () => { + const res = await request(app).get("/api/public/posts?cursor=nope"); + expect(res.status).toBe(400); + }); +}); diff --git a/apps/api/src/tests/setup.ts b/apps/api/src/tests/setup.ts index 1822ac8..93de7ff 100644 --- a/apps/api/src/tests/setup.ts +++ b/apps/api/src/tests/setup.ts @@ -12,6 +12,7 @@ process.env.CLOUDINARY_CLOUD_NAME = ""; process.env.CLOUDINARY_API_KEY = ""; process.env.CLOUDINARY_API_SECRET = ""; process.env.RESEND_API_KEY = ""; +process.env.GOOGLE_CLIENT_ID = ""; process.env.LEMONSQUEEZY_API_KEY = ""; process.env.LEMONSQUEEZY_STORE_ID = ""; process.env.LEMONSQUEEZY_VARIANT_ID = ""; diff --git a/apps/web/index.html b/apps/web/index.html index 68bd070..e2f67ad 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -8,6 +8,10 @@ + + + diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 885fd2b..d57b67c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -17,6 +17,7 @@ import VerifyBanner from "./components/VerifyBanner"; import SearchPage from "./pages/SearchPage"; import SearchBox from "./components/SearchBox"; import PwaBanner from "./components/PwaBanner"; +import ExplorePage from "./pages/ExplorePage"; function Protected({ children }: { children: ReactNode }) { const { user, loading } = useAuth(); @@ -24,6 +25,13 @@ function Protected({ children }: { children: ReactNode }) { return user ? <>{children} : ; } +/** "/" shows the feed to members and the public Explore page to everyone else. */ +function Home() { + const { user, loading } = useAuth(); + if (loading) return

Loading…

; + return user ? : ; +} + function Header() { const { user, logout } = useAuth(); const unread = useUnreadCount(Boolean(user)); @@ -35,6 +43,16 @@ function Header() { Commune {user && }{" "} + {!user && ( + + )} {user && (