Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ deno task -f web build # Production build (vite build → _fresh/)
deno task -f web favicon # Regenerate favicon assets
```

To exercise the real sign-in flows (Google One Tap, OAuth) instead of the
seeded demo user, disable the fallback (Google credentials come from the
`.env` file):

```
DISABLE_DEMO_USER=1 deno task -f web dev
```

The Google Cloud OAuth client needs `http://localhost:5173` in its
Authorized JavaScript origins for One Tap to appear.

Production builds and serving are handled by Deno Deploy.

## Favicon
Expand Down
33 changes: 25 additions & 8 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 11 additions & 2 deletions packages/web/deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@
"deno.ns",
"deno.unstable"
],
"types": ["vite/client", "@types/gapi", "@types/google.picker"]
"types": [
"vite/client",
"@types/gapi",
"@types/google.picker",
"@types/google.accounts"
]
},
"exclude": ["**/_fresh/*"],
"exports": "./main.ts",
Expand Down Expand Up @@ -38,17 +43,21 @@
"@preact/signals": "npm:@preact/signals@^2.11.2",
"@resvg/resvg-wasm": "npm:@resvg/resvg-wasm@2.6.2",
"@std/assert": "jsr:@std/assert@^1.0.19",
"@std/encoding": "jsr:@std/encoding@^1.0.0",
"@std/http": "jsr:@std/http@^0.221.0",
"@std/text": "jsr:@std/text@^1.0.19",
"@tailwindcss/typography": "npm:@tailwindcss/typography@^0.5.20",
"@tailwindcss/vite": "npm:@tailwindcss/vite@^4.3.3",
"@types/babel__core": "npm:@types/babel__core@^7.20.5",
"@types/gapi": "npm:@types/gapi@^0.0.47",
"@types/google.accounts": "npm:@types/google.accounts@^0.0.18",
"@types/google.picker": "npm:@types/google.picker@^0.0.52",
"@types/node": "npm:@types/node@^26.5.1",
"culori": "npm:culori@^4.0.2",
"date-fns": "npm:date-fns@^4.4.0",
"dompurify": "npm:dompurify@^3.4.15",
"fresh": "jsr:@fresh/core@^2.3.3",
"jose": "npm:jose@^6.0.0",
"lexical": "npm:lexical@^0.50.0",
"lucide-preact": "npm:lucide-preact@^1.44.0",
"marked": "npm:marked@^18.0.12",
Expand All @@ -69,7 +78,7 @@
"tasks": {
"build": "vite build",
"check": "deno run -A npm:@biomejs/biome check && deno lint . && deno check",
"dev": "DENO_ENV=development vite | pino-pretty --colorize --translateTime SYS:standard --ignore pid,hostname",
"dev": "DENO_ENV=development DENO_KV_PATH=./local-kv.sqlite3 vite | pino-pretty --colorize --translateTime SYS:standard --ignore pid,hostname",
"favicon": "deno run -A favicon/generate.ts",
"favicon:check": "deno run -A favicon/generate.ts --check",
"kvctl": "deno run -A --env-file=.env kvctl.ts",
Expand Down
80 changes: 80 additions & 0 deletions packages/web/islands/GoogleOneTap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { ArrowUpRight } from "lucide-preact";
import { useEffect, useRef, useState } from "preact/hooks";
import { loadGsi } from "@/utils/googleGsi.ts";

interface GoogleOneTapProps {
clientId: string;
next: string;
}

/**
* Google sign-in for the login page: the native "Sign in with Google"
* button plus the One Tap prompt (FedCM) for eligible visitors. Both share
* one callback that posts the ID token credential to /oauth/onetap, which
* sets the session cookie and redirects to `next`. If GIS fails to load,
* falls back to the full OAuth redirect link.
*/
export default function GoogleOneTap({ clientId, next }: GoogleOneTapProps) {
const formRef = useRef<HTMLFormElement>(null);
const credentialRef = useRef<HTMLInputElement>(null);
const buttonRef = useRef<HTMLDivElement>(null);
const [gsiFailed, setGsiFailed] = useState(false);

useEffect(() => {
let cancelled = false;
loadGsi()
.then(() => {
if (cancelled || !buttonRef.current) return;
google.accounts.id.initialize({
client_id: clientId,
auto_select: true,
use_fedcm_for_prompt: true,
callback: (response) => {
if (!response.credential || !credentialRef.current) return;
credentialRef.current.value = response.credential;
formRef.current?.submit();
},
});
// Arriving from /oauth/signout: clear GSI's remembered auto selection
// so the user is not silently signed back in.
if (new URLSearchParams(globalThis.location.search).has("signedout")) {
google.accounts.id.disableAutoSelect();
}
google.accounts.id.renderButton(buttonRef.current, {
type: "standard",
theme: "filled_blue",
size: "large",
text: "signin_with",
shape: "rectangular",
logo_alignment: "left",
});
google.accounts.id.prompt();
})
.catch(() => {
if (!cancelled) setGsiFailed(true);
});
return () => {
cancelled = true;
};
}, [clientId]);

return (
<div class="flex flex-col items-start">
{gsiFailed ? (
<a
href={`/oauth/signin?success_url=${encodeURIComponent(next)}`}
class="btn cell--accent"
>
<ArrowUpRight size={16} />
Sign in with Google
</a>
) : (
<div ref={buttonRef} />
)}
<form ref={formRef} method="POST" action="/oauth/onetap" class="hidden">
<input ref={credentialRef} type="hidden" name="credential" />
<input type="hidden" name="next" value={next} />
</form>
</div>
);
}
4 changes: 2 additions & 2 deletions packages/web/islands/Navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ interface NavigationProps {
export default function Navigation({ user, children }: NavigationProps) {
return (
<div class="relative z-panel shrink-0 bg-surface shadow-md">
<EssayistLogo class="absolute left-0 top-[-1px] h-[calc(2.5rem+1px)] w-5 items-start @[64rem]:w-10" />
<div class="content-layout content-layout--side">
<div class="content-layout content-layout--side relative">
<EssayistLogo class="absolute left-0 top-[-1px] h-[calc(2.5rem+1px)] w-5 items-start @[64rem]:w-10" />
<div class="content-main flex flex-col items-start">{children}</div>
<div class="content-side flex items-center justify-end">
<div class="flex w-fit stack stack--row">
Expand Down
9 changes: 5 additions & 4 deletions packages/web/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,17 @@ import { getOAuthHelpers } from "@/utils/oauth.ts";
import { getUserIdForSession } from "@/utils/sessions.ts";

const isDev = Deno.env.get("DENO_ENV") === "development";
const demoUserDisabled = Deno.env.get("DISABLE_DEMO_USER") === "1";

/**
* Resolves `ctx.state.user` for each request.
*
* Resolution order:
* 1. `X-User-Id` header, dev only -- lets local scripts/tests act as a
* seeded user; disabled in production.
* 2. Valid Google OAuth session cookie (see routes/oauth/*).
* 3. The seeded demo user, in dev only.
* 4. Otherwise unauthenticated: API routes get 401 JSON, browser routes
* 2. Valid Google OAuth session cookie (see routes/oauth/*).
* 3. The seeded demo user, in dev only (skip with DISABLE_DEMO_USER=1).
* 4. Otherwise unauthenticated: API routes get 401 JSON, browser routes
* redirect to /login.
*
* The /oauth/* routes and /login page are skipped so sign-in / sign-out /
Expand Down Expand Up @@ -60,7 +61,7 @@ const authMiddleware: Middleware<State> = define.middleware(async (ctx) => {
// Fall through to the dev demo-user fallback below.
}

if (isDev && demoUser) {
if (isDev && demoUser && !demoUserDisabled) {
ctx.state.user = demoUser;
return ctx.next();
}
Expand Down
51 changes: 24 additions & 27 deletions packages/web/routes/login.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,35 @@
import type { PageProps } from "fresh";
import { ArrowUpRight } from "lucide-preact";
import GoogleOneTap from "@/islands/GoogleOneTap.tsx";
import Navigation from "@/islands/Navigation.tsx";
import { safeNext } from "@/utils/nextUrl.ts";

/**
* Returns a safe same-origin path to redirect to after sign-in, or `/` if the
* given value is missing or unsafe. Rejects protocol-relative URLs (`//...`),
* the login page itself, and OAuth routes (avoids post-login redirect loops).
*/
function safeNext(next: string | null): string {
if (!next?.startsWith("/") || next.startsWith("//")) return "/";
if (next === "/login" || next.startsWith("/oauth/")) return "/";
return next;
}
const clientId = Deno.env.get("GOOGLE_CLIENT_ID");

/**
* Sign-in landing page. Shown to unauthenticated browser users (the auth
* middleware redirects them here with a `next` query param). The button starts
* the Google OAuth flow and passes `next` as `success_url` so
* `@deno/kv-oauth` sends the user back to the page they originally requested
* instead of falling back to the `/login` referer.
* middleware redirects them here with a `next` query param). The Google
* island handles both the native button and the One Tap prompt; its
* credential callback posts to /oauth/onetap with `next` so the user lands
* back on the page they originally requested.
*/
export default function LoginPage({ url }: PageProps) {
const next = safeNext(url.searchParams.get("next"));
const href = `/oauth/signin?success_url=${encodeURIComponent(next)}`;
return (
<main class="flex items-start bg-surface h-full">
<div class="flex flex-col stack w-1/2 max-w-128">
<p class="text-ink bg-surface h-20 p-4 w-full">
Sign in with your Google account to continue.
</p>
<a href={href} class="btn cell--accent self-end">
<ArrowUpRight size={16} />
Sign in with Google
</a>
</div>
</main>
<div class="flex flex-1 min-h-0">
<main class="flex flex-1 flex-col stack stack--col min-h-0 @container">
<Navigation>
<div class="flex stack stack--row">
<div class="cell">Sign in to Essayist</div>
</div>
</Navigation>
<div class="flex-1 min-h-0 overflow-y-auto bg-paper">
<div class="content-layout">
<div class="content-main flex flex-col gap-5 py-10">
{clientId && <GoogleOneTap clientId={clientId} next={next} />}
</div>
</div>
</div>
</main>
</div>
);
}
Loading