Skip to content

Commit ff5fd02

Browse files
committed
Add optional /vue wrapper and isolated framework demos
1 parent b4e64be commit ff5fd02

27 files changed

Lines changed: 1209 additions & 16 deletions

‎README.md‎

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
> Named share links, Google sign-in, email codes, and an access log for gated dashboards.
44
5-
Reusable auth layers for apps with the "public site, gate a slice" shape: a backend kernel (HMAC sessions, DB-backed grant tokens, Google/OIDC sign-in, emailed codes) and unstyled React primitives (`useWhoami` / `AuthGate` / `SignInPanel` / `WhoamiChip`).
5+
Reusable auth layers for apps with the "public site, gate a slice" shape: a backend kernel (HMAC sessions, DB-backed grant tokens, Google/OIDC sign-in, emailed codes), a framework-free client, unstyled React primitives, and Vue `useWhoami` / `AuthGate` wrappers.
66

77
Mint a link, name it after the person you're sending it to, set how many times and how long it works, and see what they looked at. Google or an emailed code for people the policy admits; request-access for everyone else.
88

@@ -33,6 +33,7 @@ src/adapters/ d1 (stores, audit sink & queries), oidc (Google + any issuer), r
3333
r2 (avatar bytes), google-directory (group → allowlist sync)
3434
src/client/ typed fetch client, URL/exchange/identity/image helpers — no framework imports
3535
src/react/ useWhoami / AuthGate / SignInPanel / WhoamiChip / Avatar / AvatarField / disclosure — unstyled
36+
src/vue/ useWhoami / AuthGate — Vue-only composable and slotted gate, unstyled
3637
src/testing/ in-memory stores, so adopters can test a gated route without a DB
3738
migrations/ the schema (one baseline file pre-1.0; see below)
3839
scripts/ provisioning (Google client, Resend domain, group-sync SA) and d1-rebaseline
@@ -59,7 +60,44 @@ await auth.grants.revoke(grant.id)
5960

6061
`displayName`, `hasScope`, `hasKeyParam`, `exchangeKeyParam` and `downscaleImage` are available from `/client`, along with the shared wire types. Existing `/react` helper imports still work. Importing `/client` is SSR-safe; browser-only helpers need browser APIs when called, and `shareUrl` needs an explicit absolute target outside the browser. It preserves the target's other query parameters and hash, replacing an existing `key`. The emitted module graph is checked during `pnpm build` for external dependencies, including React/Vue.
6162

62-
The backend root import and platform adapters remain separate from `/client`; there is no Vue dependency or Vue-specific UI package. React hooks/components remain under `/react`.
63+
The backend root import and platform adapters remain separate from `/client`. React hooks/components live under `/react`; Vue wrappers live under `/vue`. Vanilla apps need neither framework.
64+
65+
## Vue
66+
67+
Import `useWhoami`, `AuthGate`, and shared identity helpers from `@open-athena/auth/vue`. Vue is an optional peer dependency; React and TanStack Query are not required for this subpath.
68+
69+
```vue
70+
<script setup lang="ts">
71+
import { AuthGate, displayName } from '@open-athena/auth/vue'
72+
73+
const options = {
74+
basePath: '/api/view',
75+
exchange: { endpoint: '/api/view/exchange' },
76+
}
77+
</script>
78+
79+
<template>
80+
<AuthGate :options="options">
81+
<template #loading>Checking access...</template>
82+
<template #error="{ error }"><p role="alert">{{ error.message }}</p></template>
83+
<template #unauthenticated><h2>Private workspace</h2></template>
84+
<template #default="{ whoami, signOut }">
85+
<p>{{ displayName(whoami) ?? 'Anonymous link' }}</p>
86+
<button @click="signOut">Sign out</button>
87+
</template>
88+
</AuthGate>
89+
</template>
90+
```
91+
92+
`useWhoami(options)` runs during component setup and returns readonly `whoami`, `loading`, and `error` refs plus `refetch()` and `signOut()`. A 401 means signed out; other failures populate `error`. Matching clients share state within a Vue app, not across apps or SSR requests. Options are setup-time configuration: remount to change them. The composable opts into key exchange through `exchange`; `AuthGate` enables it by default, before probing identity. The exchange endpoint defaults to `<basePath>/exchange`; a supplied custom client needs an explicit exchange endpoint if its base differs. Slots receive `whoami`, `loading`, `error`, `refetch`, and `signOut`. `signOut()` records errors in state and rejects, so event handlers should handle that rejection. The server must still enforce authorization.
93+
94+
## Minimal Demo Routes
95+
96+
The demo's [root][demo-root] and [Vanilla][demo-vanilla] routes use the framework-free client; [Vue][demo-vue] and [React][demo-react] show their respective wrappers. Framework links navigate to separate documents so vanilla loads neither framework, and Vue and React do not load each other.
97+
98+
Each example opens a named or anonymous share link, exchanges its `key` for a session, removes the key from the URL, and shows the returned identity. Sign out returns to the private-workspace wall. The examples use raw identity JSON from `GET /api/view/whoami` (401 when signed out), `POST /api/view/exchange` with `{ token }`, and `POST /api/view/logout`; `/api/demo-link?named=1|0&next=<route>` mints a link and redirects back to the selected example.
99+
100+
Run `pnpm test:browser` for isolated Chromium regressions (mocked auth boundaries). With `pnpm --filter @open-athena/auth-demo dev` running at `http://localhost:4187`, `pnpm test:browser:live` exercises actual local Pages Functions, D1, and shared session cookies across frameworks. It does not send email or authenticate with Google.
63101

64102
## Installing
65103

@@ -78,7 +116,7 @@ pnpm add "@open-athena/auth@github:Open-Athena/auth#$SHA"
78116

79117
The `dist` branch only advances on a commit whose tests passed, and CI then installs the published branch and exercises it (`scripts/verify-dist.mjs`) — so any SHA you can pin is green *as an artifact*, not just as source. It carries built JS + `.d.ts`, the migrations, and the peer-dep declarations; versions read `0.1.0-dist.<sha>`.
80118

81-
Peer deps are all optional and only needed for what you use: `@cloudflare/workers-types` (types only), and `react` + `@tanstack/react-query` for the `/react` subpath.
119+
Peer deps are all optional and only needed for what you use: `@cloudflare/workers-types` (types only), `react` + `@tanstack/react-query` for `/react`, and `vue` for `/vue`.
82120

83121
## Migrations
84122

@@ -321,3 +359,7 @@ cd demo && pnpm dev # the whole thing running, on :4187
321359
`pnpm build` compiles `src/core` and `src/adapters` against `@cloudflare/workers-types` alone (no Node types), which is what keeps them honest about being runtime-agnostic. `src/react` is a separate compilation because DOM lib and workers-types declare conflicting globals.
322360

323361
[`npm-dist`]: https://github.com/runsascoded/npm-dist
362+
[demo-root]: https://auth.oa.dev/
363+
[demo-vanilla]: https://auth.oa.dev/vanilla
364+
[demo-vue]: https://auth.oa.dev/vue
365+
[demo-react]: https://auth.oa.dev/react

‎demo/functions/api/demo-link.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ const TTL_S = 60 * 60
2222

2323
export const onRequestGet = async ({ request, env }: Ctx): Promise<Response> => {
2424
const { viewGate } = gates(env, request)
25-
const named = new URL(request.url).searchParams.get('named') === '1'
25+
const query = new URL(request.url).searchParams
26+
const named = query.get('named') === '1'
27+
const next = query.get('next') ?? '/dashboard'
28+
const target = ['/', '/vanilla', '/vue', '/react', '/dashboard'].includes(next) ? next : '/dashboard'
2629

2730
// A real face, copied here once rather than fetched in the recipient's
2831
// browser on every render. GitHub rather than Gravatar for the demo persona
@@ -46,6 +49,6 @@ export const onRequestGet = async ({ request, env }: Ctx): Promise<Response> =>
4649

4750
return new Response(null, {
4851
status: 302,
49-
headers: { location: `/dashboard?key=${token}`, 'cache-control': 'no-store' },
52+
headers: { location: `${target}?key=${token}`, 'cache-control': 'no-store' },
5053
})
5154
}

‎demo/index.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@
1818
</head>
1919
<body>
2020
<div id="root"></div>
21-
<script type="module" src="/src/main.tsx"></script>
21+
<script type="module" src="/src/boot.ts"></script>
2222
</body>
2323
</html>

‎demo/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"@open-athena/auth": "workspace:*",
1818
"@tanstack/react-query": "^5.101.4",
1919
"react": "^19.2.8",
20-
"react-dom": "^19.2.8"
20+
"react-dom": "^19.2.8",
21+
"vue": "^3.5.44"
2122
},
2223
"devDependencies": {
2324
"@cloudflare/workers-types": "^4.20260601.0",

‎demo/src/App.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ export function App() {
2727
<code>@open-athena/auth</code>
2828
</Link>
2929
<div className="nav-links">
30+
<a href="/vanilla">Vanilla</a>
31+
<a href="/vue">Vue</a>
32+
<a href="/react">React</a>
3033
<Link to="/dashboard" className={path === '/dashboard' ? 'on' : ''}>
3134
Dashboard
3235
</Link>

‎demo/src/boot.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import './styles.scss'
2+
3+
const path = location.pathname.replace(/\/$/, '') || '/'
4+
if (path === '/' || path === '/vanilla') await import('./examples/vanilla-entry.js')
5+
else if (path === '/vue') await import('./examples/vue-entry.js')
6+
else if (path === '/react') await import('./examples/react-entry.js')
7+
else await import('./main.js')

‎demo/src/examples/react-entry.tsx‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { createAuthClient, displayName } from '@open-athena/auth/client'
2+
import { Avatar, useWhoami, useKeyExchange, useForgetWhoami, type Whoami } from '@open-athena/auth/react'
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4+
import { useState } from 'react'
5+
import { createRoot } from 'react-dom/client'
6+
import { endpoint, exchange, linkFor, mountShell, source, trackView } from './shell.js'
7+
8+
const host = mountShell('React · @open-athena/auth/react')
9+
const client = createAuthClient({ basePath: endpoint })
10+
11+
function Identity({ whoami }: { whoami: Whoami }) {
12+
const forget = useForgetWhoami()
13+
const [busy, setBusy] = useState(false)
14+
const [error, setError] = useState(false)
15+
async function signOut() {
16+
setBusy(true)
17+
setError(false)
18+
try { await client.logout(); forget() } catch { setError(true) } finally { setBusy(false) }
19+
}
20+
return <>
21+
<h2>You're in.</h2>
22+
<p className="example-identity"><Avatar whoami={whoami} size={40} /><span>{displayName(whoami) ?? 'Anonymous link'}</span></p>
23+
<code>{whoami.kind === 'grant' ? `g:${whoami.id}` : `e:${whoami.email}`}</code>
24+
<div className="placeholder">Your private workspace</div>
25+
<button className="btn" disabled={busy} onClick={() => void signOut()}>Sign out</button>
26+
{error && <p role="alert">Could not sign out. Please try again.</p>}
27+
</>
28+
}
29+
30+
function Example() {
31+
const ready = useKeyExchange(exchange)
32+
const { whoami, error, refresh } = useWhoami(source, { enabled: ready })
33+
if (!ready || whoami === undefined) return <p>Checking access…</p>
34+
if (error) return <><p role="alert">Could not check access. Please try again.</p><button className="btn" onClick={refresh}>Try again</button></>
35+
if (whoami) return <Identity whoami={whoami} />
36+
return <>
37+
<h2>Private workspace</h2>
38+
<p className="muted">Open a demo link to enter. No account needed.</p>
39+
<div className="wall-actions">
40+
<a className="btn primary" href={linkFor(true)}>Open named link</a>
41+
<a className="btn" href={linkFor(false)}>Open anonymous link</a>
42+
</div>
43+
</>
44+
}
45+
46+
createRoot(host).render(<QueryClientProvider client={new QueryClient()}><Example /></QueryClientProvider>)
47+
trackView()

‎demo/src/examples/shell.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { Whoami } from '@open-athena/auth/client'
2+
import { displayName } from '@open-athena/auth/client'
3+
4+
export const endpoint = '/api/view'
5+
export const source = { endpoint: `${endpoint}/whoami` }
6+
export const exchange = { endpoint: `${endpoint}/exchange` }
7+
8+
export function identityLabel(whoami: Whoami): string {
9+
return displayName(whoami) ?? 'Anonymous link'
10+
}
11+
12+
export function linkFor(named: boolean): string {
13+
const next = location.pathname.replace(/\/$/, '') || '/'
14+
return `/api/demo-link?${new URLSearchParams({ named: named ? '1' : '0', next })}`
15+
}
16+
17+
/** Static shared chrome; framework runtimes mount only inside the example. */
18+
export function mountShell(framework: string): HTMLElement {
19+
const root = document.getElementById('root')!
20+
root.innerHTML = `
21+
<nav class="nav" aria-label="Main navigation">
22+
<a class="brand" href="/"><code>@open-athena/auth</code></a>
23+
<div class="nav-links">
24+
<a href="/vanilla">Vanilla</a><a href="/vue">Vue</a><a href="/react">React</a>
25+
<a href="/dashboard">Dashboard</a><a href="/admin">Admin</a>
26+
<a href="https://github.com/Open-Athena/auth">GitHub</a>
27+
</div>
28+
</nav>
29+
<main class="examples">
30+
<header class="example-intro">
31+
<h1>@open-athena/auth</h1>
32+
<p class="lede">An auth wall. A link that lets you through.</p>
33+
<p class="muted">Named share links, Google sign-in, email codes, and revocable access.</p>
34+
</header>
35+
<nav class="framework-tabs" aria-label="Framework examples">
36+
<a href="/vanilla">Vanilla JS</a><a href="/vue">Vue</a><a href="/react">React</a>
37+
</nav>
38+
<section class="example-stage" aria-label="Live auth example">
39+
<p class="example-caption"></p>
40+
<div id="example"></div>
41+
</section>
42+
<section class="example-more">
43+
<h2>One gate, more ways in</h2>
44+
<p><a href="/dashboard">Google or an emailed code</a> · <a href="/admin">Mint and revoke links</a></p>
45+
<p class="muted small">The library also handles approval queues, email notifications, allowlists, and access logs.</p>
46+
<a href="https://github.com/Open-Athena/auth#readme">Documentation and source</a>
47+
</section>
48+
</main>
49+
<footer><p class="muted small">Demo content only. Access to gated pages is logged. <a href="/privacy">Privacy</a></p></footer>`
50+
root.querySelector('.example-caption')!.textContent = framework
51+
for (const anchor of root.querySelectorAll<HTMLAnchorElement>('.framework-tabs a')) {
52+
if (anchor.pathname === location.pathname || (location.pathname === '/' && anchor.pathname === '/vanilla')) {
53+
anchor.setAttribute('aria-current', 'page')
54+
}
55+
}
56+
return document.getElementById('example')!
57+
}
58+
59+
export function trackView(): void {
60+
void fetch(`${endpoint}/track`, {
61+
method: 'POST', headers: { 'content-type': 'application/json' },
62+
body: JSON.stringify({ path: location.pathname }), keepalive: true,
63+
}).catch(() => {})
64+
}

‎demo/src/examples/vanilla-entry.ts‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { AuthClientError, createAuthClient, exchangeKeyParam, type Whoami } from '@open-athena/auth/client'
2+
import { endpoint, exchange, identityLabel, linkFor, mountShell, trackView } from './shell.js'
3+
4+
const host = mountShell('Vanilla JS · @open-athena/auth/client')
5+
const client = createAuthClient({ basePath: endpoint })
6+
let version = 0
7+
let signingOut = false
8+
9+
function button(label: string, action: () => void): HTMLButtonElement {
10+
const node = document.createElement('button')
11+
node.className = 'btn'
12+
node.textContent = label
13+
node.onclick = action
14+
return node
15+
}
16+
17+
function render(whoami: Whoami | null): void {
18+
host.replaceChildren()
19+
const heading = document.createElement('h2')
20+
heading.textContent = whoami ? "You're in." : 'Private workspace'
21+
host.appendChild(heading)
22+
if (whoami) {
23+
const name = document.createElement('p')
24+
name.className = 'example-identity'
25+
name.textContent = identityLabel(whoami)
26+
if (whoami.subject?.avatar) {
27+
const avatar = document.createElement('img')
28+
avatar.src = whoami.subject.avatar
29+
avatar.alt = ''
30+
avatar.width = avatar.height = 40
31+
avatar.referrerPolicy = 'no-referrer'
32+
avatar.onerror = () => avatar.remove()
33+
name.prepend(avatar)
34+
}
35+
const subject = document.createElement('code')
36+
subject.textContent = whoami.kind === 'grant' ? `g:${whoami.id}` : `e:${whoami.email}`
37+
const content = document.createElement('div')
38+
content.className = 'placeholder'
39+
content.textContent = 'Your private workspace'
40+
for (const node of [name, subject, content, button('Sign out', () => void signOut())]) host.appendChild(node)
41+
} else {
42+
const note = document.createElement('p')
43+
note.className = 'muted'
44+
note.textContent = 'Open a demo link to enter. No account needed.'
45+
const links = document.createElement('div')
46+
links.className = 'wall-actions'
47+
for (const [named, label] of [[true, 'Open named link'], [false, 'Open anonymous link']] as const) {
48+
const anchor = document.createElement('a')
49+
anchor.className = named ? 'btn primary' : 'btn'
50+
anchor.href = linkFor(named)
51+
anchor.textContent = label
52+
links.appendChild(anchor)
53+
}
54+
host.appendChild(note)
55+
host.appendChild(links)
56+
}
57+
}
58+
59+
function showError(): void {
60+
host.replaceChildren()
61+
const error = document.createElement('p')
62+
error.setAttribute('role', 'alert')
63+
error.textContent = 'Could not check access. Please try again.'
64+
host.appendChild(error)
65+
host.appendChild(button('Try again', () => void refresh()))
66+
}
67+
68+
async function refresh(): Promise<void> {
69+
if (signingOut) return
70+
const request = ++version
71+
host.textContent = 'Checking access…'
72+
try {
73+
const identity = await client.whoami()
74+
if (request === version) render(identity)
75+
} catch (error) {
76+
if (request !== version) return
77+
if (error instanceof AuthClientError && [401, 403].includes(error.status)) render(null)
78+
else showError()
79+
}
80+
}
81+
82+
async function signOut(): Promise<void> {
83+
if (signingOut) return
84+
signingOut = true
85+
++version
86+
const control = host.querySelector('button')!
87+
control.disabled = true
88+
try {
89+
await client.logout()
90+
render(null)
91+
} catch {
92+
control.disabled = false
93+
const error = document.createElement('p')
94+
error.setAttribute('role', 'alert')
95+
error.textContent = 'Could not sign out. Please try again.'
96+
host.appendChild(error)
97+
} finally {
98+
signingOut = false
99+
}
100+
}
101+
102+
host.textContent = 'Checking access…'
103+
void (async () => {
104+
await exchangeKeyParam(exchange)
105+
await refresh()
106+
trackView()
107+
window.addEventListener('focus', () => void refresh())
108+
})()

0 commit comments

Comments
 (0)