@biffo/cli 0.41.15 → 0.41.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_skeletons/sibling-template/.github/workflows/deploy.yml +34 -1
- package/_skeletons/sibling-template/README.md +117 -3
- package/_skeletons/sibling-template/apps/frontend/src/app/example/members/page.tsx +31 -0
- package/_skeletons/sibling-template/apps/frontend/src/app/example/page.tsx +24 -0
- package/_skeletons/sibling-template/apps/frontend/src/lib/auth-gate.test.tsx +83 -0
- package/_skeletons/sibling-template/apps/frontend/src/lib/auth-gate.tsx +110 -0
- package/core.version +1 -1
- package/package.json +1 -1
|
@@ -190,14 +190,47 @@ jobs:
|
|
|
190
190
|
# would give "s3://bucket//", whose keys all begin with a literal
|
|
191
191
|
# slash and match no request CloudFront ever forwards.
|
|
192
192
|
DEST="s3://${SITE_BUCKET_NAME}/${PATH_PREFIX:+${PATH_PREFIX}/}"
|
|
193
|
+
# Three passes, split by mutability and content type — kept in lockstep
|
|
194
|
+
# with the core portal's deploy-app.yml (issue #275, ported in #351).
|
|
195
|
+
#
|
|
196
|
+
# Next's App Router emits its RSC flight payloads as *.txt. Left in the
|
|
197
|
+
# first pass they get two wrong properties that break every client-side
|
|
198
|
+
# navigation — and, across the portal↔sibling SSO boundary, strand a
|
|
199
|
+
# returning user on /index.txt showing raw serialised React (#351):
|
|
200
|
+
#
|
|
201
|
+
# - `aws s3 sync` guesses content type from the extension, so they
|
|
202
|
+
# serve as text/plain rather than text/x-component. The router does
|
|
203
|
+
# not recognise that as an RSC response and falls back to a hard MPA
|
|
204
|
+
# navigation TO THE URL IT WAS FETCHING — landing the browser on the
|
|
205
|
+
# .txt payload. Typing the URL works, because a full page load never
|
|
206
|
+
# fetches the payload.
|
|
207
|
+
#
|
|
208
|
+
# - they get cached immutable for a year, but they change on every
|
|
209
|
+
# deploy exactly like the HTML does.
|
|
210
|
+
#
|
|
211
|
+
# Hashed assets under _next/static are genuinely immutable and keep the
|
|
212
|
+
# long cache; HTML and RSC payloads must not.
|
|
193
213
|
aws s3 sync apps/frontend/out/ "$DEST" \
|
|
194
214
|
--delete \
|
|
195
215
|
--cache-control "public, max-age=31536000, immutable" \
|
|
196
|
-
--exclude "*.html"
|
|
216
|
+
--exclude "*.html" \
|
|
217
|
+
--exclude "*.txt"
|
|
218
|
+
# `--include` only re-includes from an excluded set; on its own it is a
|
|
219
|
+
# no-op, since everything is included by default. Without the explicit
|
|
220
|
+
# `--exclude "*"` this pass would upload the .txt payloads too — as
|
|
221
|
+
# text/plain — and the pass below, being a sync, would then skip them
|
|
222
|
+
# as unchanged and the wrong content type would survive.
|
|
197
223
|
aws s3 sync apps/frontend/out/ "$DEST" \
|
|
198
224
|
--delete \
|
|
199
225
|
--cache-control "no-cache" \
|
|
226
|
+
--exclude "*" \
|
|
200
227
|
--include "*.html"
|
|
228
|
+
aws s3 sync apps/frontend/out/ "$DEST" \
|
|
229
|
+
--delete \
|
|
230
|
+
--cache-control "no-cache" \
|
|
231
|
+
--content-type "text/x-component" \
|
|
232
|
+
--exclude "*" \
|
|
233
|
+
--include "*.txt"
|
|
201
234
|
- name: Invalidate the core project's CloudFront path
|
|
202
235
|
if: vars.PARENT_CLOUDFRONT_DISTRIBUTION_ID != ''
|
|
203
236
|
env:
|
|
@@ -33,9 +33,10 @@ anywhere in `services/api/`, on purpose.
|
|
|
33
33
|
## What's here
|
|
34
34
|
|
|
35
35
|
```
|
|
36
|
-
apps/frontend/ # Next.js 15 static export
|
|
37
|
-
# proving the shared-session SSO works.
|
|
38
|
-
# src/
|
|
36
|
+
apps/frontend/ # Next.js 15 static export. `/` is the SSO demo ("<name> - Hello <username>",
|
|
37
|
+
# proving the shared-session SSO works); src/lib/auth-gate.tsx + the
|
|
38
|
+
# src/app/example/ routes show the public-default / opt-in-auth pattern
|
|
39
|
+
# ("Your app goes here" below). src/lib/auth.ts reads the shared session.
|
|
39
40
|
services/api/ # FastAPI + Mangum backend. Verifies the core project's Cognito JWT itself
|
|
40
41
|
# (defense in depth — API Gateway's own JWT authorizer is the first layer).
|
|
41
42
|
# core_client.py is the ONLY sanctioned way to reach core-owned data.
|
|
@@ -57,6 +58,119 @@ biffo.sibling.json # This sibling's name, its paired core project's nam
|
|
|
57
58
|
# without also updating the core project's siblings.auto.tfvars.json.
|
|
58
59
|
```
|
|
59
60
|
|
|
61
|
+
## Your app goes here — from the SSO demo to a public go-live
|
|
62
|
+
|
|
63
|
+
This skeleton is a **blank canvas**. Everything below is additive: the app
|
|
64
|
+
ships with one demo page and a thin auth helper, and your job is to build on
|
|
65
|
+
top, not to unpick anything.
|
|
66
|
+
|
|
67
|
+
### What the `/` demo is (and that it's replaceable)
|
|
68
|
+
|
|
69
|
+
`apps/frontend/src/app/page.tsx` is an **SSO demonstration**, not the app you
|
|
70
|
+
ship. It proves the shared-Cognito round-trip (ADR-0007) works end to end: a
|
|
71
|
+
signed-out visitor is bounced to the core portal's login and returned here; a
|
|
72
|
+
signed-in visitor's session ID token is sent to **this sibling's own backend**
|
|
73
|
+
(`/api/v1/whoami`), which re-verifies the JWT and echoes the username back.
|
|
74
|
+
|
|
75
|
+
Keep it as a working reference, or replace its contents with your own home
|
|
76
|
+
page once you've seen it work — it's your route to do with as you like. Nothing
|
|
77
|
+
else depends on it.
|
|
78
|
+
|
|
79
|
+
### Where your content goes, and how routing works
|
|
80
|
+
|
|
81
|
+
This is a Next.js **App Router** app with `output: 'export'` (a static site).
|
|
82
|
+
A route is just a folder with a `page.tsx` under `apps/frontend/src/app/`:
|
|
83
|
+
|
|
84
|
+
| File | URL served |
|
|
85
|
+
| ------------------------------- | ----------------------------------- |
|
|
86
|
+
| `src/app/page.tsx` | `/` (the demo — replace or keep) |
|
|
87
|
+
| `src/app/pricing/page.tsx` | `/pricing/` |
|
|
88
|
+
| `src/app/example/page.tsx` | `/example/` (public example, below) |
|
|
89
|
+
| `src/app/example/members/page.tsx` | `/example/members/` (gated example) |
|
|
90
|
+
|
|
91
|
+
**The `basePath` / `PATH_PREFIX` wiring is automatic — don't hand-write it.**
|
|
92
|
+
The core project's CloudFront routes `baseurl.com/<name>/*` to this sibling and
|
|
93
|
+
forwards the full URI with no prefix stripping, so the static export's own asset
|
|
94
|
+
and link URLs must already carry `/<name>`. `next.config.ts` reads
|
|
95
|
+
`NEXT_PUBLIC_BASE_PATH` (set by `deploy.yml` from your `path_prefix`) and Next
|
|
96
|
+
prepends it to every asset and every `next/link` href for you. Two rules follow:
|
|
97
|
+
|
|
98
|
+
- **Always link with `next/link`** (`<Link href="/pricing/">`), never a bare
|
|
99
|
+
`<a href>` — `Link` adds the base path; a raw anchor doesn't and 404s in the
|
|
100
|
+
deployed sibling.
|
|
101
|
+
- The **root application sibling** created by `biffo init` serves `/` itself, so
|
|
102
|
+
its `path_prefix` is empty and `NEXT_PUBLIC_BASE_PATH` is `""` — the same code
|
|
103
|
+
works unchanged, prefix or no prefix.
|
|
104
|
+
|
|
105
|
+
### Reaching your backend (never the core API directly)
|
|
106
|
+
|
|
107
|
+
The frontend talks **only to this sibling's own backend** (`services/api/`),
|
|
108
|
+
never to the core project's API — ADR-0002/ADR-0007. Use `createApiClient` from
|
|
109
|
+
`src/lib/api-client.ts`, pass it the session's ID token, and call your own
|
|
110
|
+
routes (`NEXT_PUBLIC_API_URL` points at this sibling's API Gateway). If you need
|
|
111
|
+
core-owned data, your backend calls the core API server-side
|
|
112
|
+
(`services/api/src/api/core_client.py`) and re-verifies the JWT itself — the
|
|
113
|
+
browser never holds a core credential.
|
|
114
|
+
|
|
115
|
+
### Public is the default; auth is opt-in per page
|
|
116
|
+
|
|
117
|
+
The go-live state for most products is a **public** app. That is the easy path
|
|
118
|
+
here: any `page.tsx` you add is served **unauthenticated** the moment it
|
|
119
|
+
deploys — no auth code, no bounce. `src/app/example/page.tsx` is a one-screen
|
|
120
|
+
demonstration of exactly that; copy it or delete it.
|
|
121
|
+
|
|
122
|
+
When a page _does_ need a signed-in user, opt in with the `<AuthGate>` helper
|
|
123
|
+
(`src/lib/auth-gate.tsx`) — one wrapper, and only that page becomes private:
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
'use client'
|
|
127
|
+
import { AuthGate } from '@/lib/auth-gate'
|
|
128
|
+
|
|
129
|
+
export default function Dashboard() {
|
|
130
|
+
return (
|
|
131
|
+
<AuthGate>
|
|
132
|
+
{(session) => {
|
|
133
|
+
const token = session.getIdToken().getJwtToken()
|
|
134
|
+
// pass `token` to createApiClient to call this sibling's backend
|
|
135
|
+
return <h1>Members only</h1>
|
|
136
|
+
}}
|
|
137
|
+
</AuthGate>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
A signed-out visitor is redirected to the core portal's login and returned to
|
|
143
|
+
that exact route afterwards; a signed-in visitor sees the content. `AuthGate`
|
|
144
|
+
builds on `getCurrentSession`/`auth.ts` and never signs anyone in itself
|
|
145
|
+
(ADR-0007). `src/app/example/members/page.tsx` is the runnable version of the
|
|
146
|
+
snippet above. Wrap only what must be private — never gate the whole app.
|
|
147
|
+
|
|
148
|
+
### The path a founder actually walks
|
|
149
|
+
|
|
150
|
+
1. Run locally (`pnpm dev`, below) and open `/` — watch the SSO demo work.
|
|
151
|
+
2. Replace `src/app/page.tsx` with your own public home page (or add
|
|
152
|
+
`src/app/<something>/page.tsx`). It's public by default — that's your
|
|
153
|
+
go-live state. Delete the `example/` routes once you've read them.
|
|
154
|
+
3. For any area that needs a login, wrap its `page.tsx` in `<AuthGate>`.
|
|
155
|
+
4. Push to `main`; `deploy.yml` builds the static export with the right
|
|
156
|
+
`NEXT_PUBLIC_BASE_PATH` and syncs it to S3 behind the core CloudFront —
|
|
157
|
+
your public page is live at `baseurl.com/<name>/`.
|
|
158
|
+
|
|
159
|
+
### Running the frontend locally
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
cd apps/frontend
|
|
163
|
+
cp .env.example .env.local # fill in your core project's Cognito/API values
|
|
164
|
+
pnpm install
|
|
165
|
+
pnpm dev # http://localhost:3000
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Public pages render with no configuration. The SSO demo and any `<AuthGate>`
|
|
169
|
+
page need the real `NEXT_PUBLIC_CORE_*` values in `.env.local` to complete the
|
|
170
|
+
portal round-trip; without them they resolve as "signed out" rather than
|
|
171
|
+
crashing (the Cognito pool is constructed lazily — see "The build must not need
|
|
172
|
+
Cognito credentials" below).
|
|
173
|
+
|
|
60
174
|
## Standalone repo, not a monorepo package
|
|
61
175
|
|
|
62
176
|
Both `apps/frontend/package.json` and `services/api/pyproject.toml` are
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { AuthGate } from '@/lib/auth-gate'
|
|
4
|
+
|
|
5
|
+
// The SAME page, made private by a single wrapper. Opting into auth is one
|
|
6
|
+
// deliberate line — <AuthGate> — not the default. A signed-out visitor is
|
|
7
|
+
// bounced to the core portal's login (ADR-0007) and returned here afterwards;
|
|
8
|
+
// a signed-in visitor sees the content below.
|
|
9
|
+
//
|
|
10
|
+
// The render-prop form hands you the session, whose ID token you pass to THIS
|
|
11
|
+
// sibling's own backend via createApiClient (never the core API directly,
|
|
12
|
+
// ADR-0002). This example doesn't call the backend, but shows where the token
|
|
13
|
+
// comes from.
|
|
14
|
+
export default function ExampleMembersPage() {
|
|
15
|
+
return (
|
|
16
|
+
<AuthGate>
|
|
17
|
+
{(session) => (
|
|
18
|
+
<main className="center-screen">
|
|
19
|
+
<div>
|
|
20
|
+
<h1>Members only</h1>
|
|
21
|
+
<p>You're signed in — this rendered because a valid session exists.</p>
|
|
22
|
+
<p>
|
|
23
|
+
Your ID token (for calls to this sibling's backend) is{' '}
|
|
24
|
+
{session.getIdToken().getJwtToken().slice(0, 8)}…
|
|
25
|
+
</p>
|
|
26
|
+
</div>
|
|
27
|
+
</main>
|
|
28
|
+
)}
|
|
29
|
+
</AuthGate>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import Link from 'next/link'
|
|
2
|
+
|
|
3
|
+
// A minimal PUBLIC page — the go-live default.
|
|
4
|
+
//
|
|
5
|
+
// There is no auth code here, and that is the whole point: anything you drop
|
|
6
|
+
// under src/app/ is served unauthenticated the moment it deploys. Delete this
|
|
7
|
+
// route once you've seen how it works, or copy it as the starting point for
|
|
8
|
+
// your own public content. To make a page private instead, wrap it in
|
|
9
|
+
// <AuthGate> — see ./members/page.tsx.
|
|
10
|
+
export default function ExamplePublicPage() {
|
|
11
|
+
return (
|
|
12
|
+
<main className="center-screen">
|
|
13
|
+
<div>
|
|
14
|
+
<h1>This page is public</h1>
|
|
15
|
+
<p>
|
|
16
|
+
Anyone can see it — no login, no redirect. This is how most of your app ships at go-live.
|
|
17
|
+
</p>
|
|
18
|
+
<p>
|
|
19
|
+
<Link href="/example/members/">See the same pattern, but auth-gated →</Link>
|
|
20
|
+
</p>
|
|
21
|
+
</div>
|
|
22
|
+
</main>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
vi.mock('@/lib/auth', () => ({
|
|
5
|
+
getCurrentSession: vi.fn(),
|
|
6
|
+
}))
|
|
7
|
+
|
|
8
|
+
// Read at module-evaluation time; stub before the dynamic import below so the
|
|
9
|
+
// component sees the value (same pattern as page.test.tsx).
|
|
10
|
+
process.env['NEXT_PUBLIC_CORE_PORTAL_URL'] = 'https://core.example.com'
|
|
11
|
+
process.env['NEXT_PUBLIC_SIBLING_PATH_PREFIX'] = '/my-sibling'
|
|
12
|
+
|
|
13
|
+
const { getCurrentSession } = await import('@/lib/auth')
|
|
14
|
+
const { AuthGate } = await import('./auth-gate')
|
|
15
|
+
|
|
16
|
+
function fakeSession(idToken: string) {
|
|
17
|
+
return {
|
|
18
|
+
getIdToken: () => ({ getJwtToken: () => idToken }),
|
|
19
|
+
} as unknown as Awaited<ReturnType<typeof getCurrentSession>>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.clearAllMocks()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('AuthGate', () => {
|
|
27
|
+
it('renders children once a valid session resolves', async () => {
|
|
28
|
+
vi.mocked(getCurrentSession).mockResolvedValueOnce(fakeSession('a-jwt'))
|
|
29
|
+
|
|
30
|
+
render(
|
|
31
|
+
<AuthGate>
|
|
32
|
+
<p>secret content</p>
|
|
33
|
+
</AuthGate>,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
await waitFor(() => {
|
|
37
|
+
expect(screen.getByText('secret content')).toBeInTheDocument()
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('passes the session to a render-prop child', async () => {
|
|
42
|
+
vi.mocked(getCurrentSession).mockResolvedValueOnce(fakeSession('token-1234'))
|
|
43
|
+
|
|
44
|
+
render(<AuthGate>{(session) => <p>token: {session.getIdToken().getJwtToken()}</p>}</AuthGate>)
|
|
45
|
+
|
|
46
|
+
await waitFor(() => {
|
|
47
|
+
expect(screen.getByText('token: token-1234')).toBeInTheDocument()
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('redirects to the core portal login, returning to the current path, when there is no session', async () => {
|
|
52
|
+
vi.mocked(getCurrentSession).mockResolvedValueOnce(null)
|
|
53
|
+
|
|
54
|
+
// jsdom's real window.location throws on navigation — swap in a plain
|
|
55
|
+
// object so we can inspect the attempted href (same trick as page.test.tsx).
|
|
56
|
+
const originalLocation = window.location
|
|
57
|
+
Object.defineProperty(window, 'location', {
|
|
58
|
+
value: { href: '', pathname: '/my-sibling/example/members/', search: '' },
|
|
59
|
+
writable: true,
|
|
60
|
+
configurable: true,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
render(
|
|
64
|
+
<AuthGate>
|
|
65
|
+
<p>secret content</p>
|
|
66
|
+
</AuthGate>,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
await waitFor(() => {
|
|
70
|
+
expect(window.location.href).toBe(
|
|
71
|
+
'https://core.example.com/login?return_to=' +
|
|
72
|
+
encodeURIComponent('/my-sibling/example/members/'),
|
|
73
|
+
)
|
|
74
|
+
})
|
|
75
|
+
expect(screen.queryByText('secret content')).not.toBeInTheDocument()
|
|
76
|
+
|
|
77
|
+
Object.defineProperty(window, 'location', {
|
|
78
|
+
value: originalLocation,
|
|
79
|
+
writable: true,
|
|
80
|
+
configurable: true,
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, type ReactNode } from 'react'
|
|
4
|
+
import type { CognitoUserSession } from 'amazon-cognito-identity-js'
|
|
5
|
+
import { getCurrentSession } from '@/lib/auth'
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// PUBLIC IS THE DEFAULT — read this before reaching for auth.
|
|
9
|
+
//
|
|
10
|
+
// A sibling app serves UNAUTHENTICATED content by default. That is the
|
|
11
|
+
// go-live state for most products: a page you drop under src/app/ is public
|
|
12
|
+
// the moment it deploys, with no auth code at all. Do NOT gate the whole app.
|
|
13
|
+
//
|
|
14
|
+
// This module is the *opt-in*: wrap only the routes/pages that genuinely need
|
|
15
|
+
// a signed-in user in <AuthGate>. Everything you don't wrap stays public.
|
|
16
|
+
//
|
|
17
|
+
// AuthGate does NOT sign anyone in — a sibling never owns authentication
|
|
18
|
+
// (ADR-0007). It reads the session the core portal already established (via
|
|
19
|
+
// getCurrentSession in ./auth.ts); if there isn't one, it bounces the visitor
|
|
20
|
+
// to the portal's login with `return_to` pointing back at the exact page they
|
|
21
|
+
// were trying to reach, so they land right back here once signed in.
|
|
22
|
+
//
|
|
23
|
+
// The session it yields carries the ID token you pass to THIS sibling's own
|
|
24
|
+
// backend (createApiClient in ./api-client.ts) — never to the core API
|
|
25
|
+
// directly (ADR-0002). The backend re-verifies that JWT itself.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const CORE_PORTAL_URL = process.env['NEXT_PUBLIC_CORE_PORTAL_URL'] ?? ''
|
|
29
|
+
const SIBLING_PATH_PREFIX = process.env['NEXT_PUBLIC_SIBLING_PATH_PREFIX'] ?? ''
|
|
30
|
+
|
|
31
|
+
type GateState =
|
|
32
|
+
{ kind: 'checking' } | { kind: 'redirecting' } | { kind: 'authed'; session: CognitoUserSession }
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The URL to send an unauthenticated visitor back to after they log in.
|
|
36
|
+
*
|
|
37
|
+
* Prefer the live location (so a deep protected route returns to itself); fall
|
|
38
|
+
* back to this sibling's root prefix when there is no DOM (SSR/prerender).
|
|
39
|
+
*/
|
|
40
|
+
function currentReturnTo(): string {
|
|
41
|
+
if (typeof window !== 'undefined') {
|
|
42
|
+
return window.location.pathname + window.location.search
|
|
43
|
+
}
|
|
44
|
+
return `${SIBLING_PATH_PREFIX}/`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type AuthGateProps = {
|
|
48
|
+
/**
|
|
49
|
+
* What to render once a valid session exists. Either plain nodes, or a
|
|
50
|
+
* render function that receives the session — use the function form when the
|
|
51
|
+
* protected UI needs the ID token to call this sibling's backend:
|
|
52
|
+
*
|
|
53
|
+
* <AuthGate>{(session) => <Dashboard token={session.getIdToken().getJwtToken()} />}</AuthGate>
|
|
54
|
+
*/
|
|
55
|
+
children: ReactNode | ((session: CognitoUserSession) => ReactNode)
|
|
56
|
+
/**
|
|
57
|
+
* Shown while the session is being read and during the redirect bounce.
|
|
58
|
+
* Defaults to a centered spinner (see globals.css).
|
|
59
|
+
*/
|
|
60
|
+
fallback?: ReactNode
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Gate a route/page behind the shared portal session.
|
|
65
|
+
*
|
|
66
|
+
* Public is the default for a sibling; this is how you make ONE page private
|
|
67
|
+
* without touching the rest of the app:
|
|
68
|
+
*
|
|
69
|
+
* 'use client'
|
|
70
|
+
* import { AuthGate } from '@/lib/auth-gate'
|
|
71
|
+
* export default function Page() {
|
|
72
|
+
* return <AuthGate><h1>Members only</h1></AuthGate>
|
|
73
|
+
* }
|
|
74
|
+
*
|
|
75
|
+
* No session → the visitor is redirected to the core portal's login and
|
|
76
|
+
* returned here afterwards. Valid session → `children` render.
|
|
77
|
+
*/
|
|
78
|
+
export function AuthGate({ children, fallback }: AuthGateProps) {
|
|
79
|
+
const [state, setState] = useState<GateState>({ kind: 'checking' })
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
let cancelled = false
|
|
83
|
+
void getCurrentSession().then((session) => {
|
|
84
|
+
if (cancelled) return
|
|
85
|
+
setState(session === null ? { kind: 'redirecting' } : { kind: 'authed', session })
|
|
86
|
+
})
|
|
87
|
+
return () => {
|
|
88
|
+
cancelled = true
|
|
89
|
+
}
|
|
90
|
+
}, [])
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (state.kind === 'redirecting' && CORE_PORTAL_URL) {
|
|
94
|
+
const returnTo = encodeURIComponent(currentReturnTo())
|
|
95
|
+
window.location.href = `${CORE_PORTAL_URL}/login?return_to=${returnTo}`
|
|
96
|
+
}
|
|
97
|
+
}, [state])
|
|
98
|
+
|
|
99
|
+
if (state.kind !== 'authed') {
|
|
100
|
+
return (
|
|
101
|
+
fallback ?? (
|
|
102
|
+
<main className="center-screen">
|
|
103
|
+
<div className="spinner" />
|
|
104
|
+
</main>
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return <>{typeof children === 'function' ? children(state.session) : children}</>
|
|
110
|
+
}
|
package/core.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.41.
|
|
1
|
+
0.41.17
|