@pylonsync/create-pylon 0.3.267 → 0.3.269
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/bin/create-pylon.js +18 -10
- package/package.json +1 -1
- package/templates/b2b/AGENTS.md +61 -0
- package/templates/b2b/README.md +62 -0
- package/templates/b2b/app/auth-form.tsx +142 -0
- package/templates/b2b/app/dashboard/dashboard-client.tsx +192 -0
- package/templates/b2b/app/dashboard/page.tsx +63 -0
- package/templates/b2b/app/error.tsx +43 -0
- package/templates/b2b/app/globals.css +139 -0
- package/templates/b2b/app/layout.tsx +71 -0
- package/templates/b2b/app/login/page.tsx +47 -0
- package/templates/b2b/app/not-found.tsx +29 -0
- package/templates/b2b/app/page.tsx +114 -0
- package/templates/b2b/app/robots.ts +12 -0
- package/templates/b2b/app/signup/page.tsx +44 -0
- package/templates/b2b/app/sitemap.ts +27 -0
- package/templates/b2b/app.ts +179 -0
- package/templates/b2b/components/ui/button.tsx +56 -0
- package/templates/b2b/components/ui/card.tsx +90 -0
- package/templates/b2b/components.json +20 -0
- package/templates/b2b/functions/_keep.ts +13 -0
- package/templates/b2b/gitignore +10 -0
- package/templates/b2b/lib/utils.ts +10 -0
- package/templates/b2b/package.json +33 -0
- package/templates/b2b/tsconfig.json +18 -0
- package/templates/barebones/AGENTS.md +61 -0
- package/templates/barebones/README.md +45 -0
- package/templates/barebones/app/error.tsx +43 -0
- package/templates/barebones/app/globals.css +139 -0
- package/templates/barebones/app/items-client.tsx +96 -0
- package/templates/barebones/app/layout.tsx +27 -0
- package/templates/barebones/app/not-found.tsx +29 -0
- package/templates/barebones/app/page.tsx +28 -0
- package/templates/barebones/app/robots.ts +12 -0
- package/templates/barebones/app/sitemap.ts +27 -0
- package/templates/barebones/app.ts +55 -0
- package/templates/barebones/components/ui/button.tsx +56 -0
- package/templates/barebones/components/ui/card.tsx +90 -0
- package/templates/barebones/components.json +20 -0
- package/templates/barebones/functions/_keep.ts +13 -0
- package/templates/barebones/gitignore +10 -0
- package/templates/barebones/lib/utils.ts +10 -0
- package/templates/barebones/package.json +33 -0
- package/templates/barebones/tsconfig.json +18 -0
- package/templates/chat/AGENTS.md +61 -0
- package/templates/chat/README.md +51 -0
- package/templates/chat/app/chat-client.tsx +113 -0
- package/templates/chat/app/error.tsx +43 -0
- package/templates/chat/app/globals.css +139 -0
- package/templates/chat/app/layout.tsx +25 -0
- package/templates/chat/app/not-found.tsx +29 -0
- package/templates/chat/app/page.tsx +26 -0
- package/templates/chat/app/robots.ts +12 -0
- package/templates/chat/app/sitemap.ts +27 -0
- package/templates/chat/app.ts +59 -0
- package/templates/chat/components/ui/button.tsx +56 -0
- package/templates/chat/components/ui/card.tsx +90 -0
- package/templates/chat/components.json +20 -0
- package/templates/chat/functions/_keep.ts +13 -0
- package/templates/chat/gitignore +10 -0
- package/templates/chat/lib/utils.ts +10 -0
- package/templates/chat/package.json +33 -0
- package/templates/chat/tsconfig.json +18 -0
- package/templates/consumer/AGENTS.md +61 -0
- package/templates/consumer/README.md +52 -0
- package/templates/consumer/app/error.tsx +43 -0
- package/templates/consumer/app/feed-client.tsx +154 -0
- package/templates/consumer/app/globals.css +139 -0
- package/templates/consumer/app/layout.tsx +27 -0
- package/templates/consumer/app/not-found.tsx +29 -0
- package/templates/consumer/app/page.tsx +27 -0
- package/templates/consumer/app/robots.ts +12 -0
- package/templates/consumer/app/sitemap.ts +27 -0
- package/templates/consumer/app.ts +89 -0
- package/templates/consumer/components/ui/button.tsx +56 -0
- package/templates/consumer/components/ui/card.tsx +90 -0
- package/templates/consumer/components.json +20 -0
- package/templates/consumer/functions/_keep.ts +13 -0
- package/templates/consumer/gitignore +10 -0
- package/templates/consumer/lib/utils.ts +10 -0
- package/templates/consumer/package.json +33 -0
- package/templates/consumer/tsconfig.json +18 -0
- package/templates/ssr/README.md +43 -28
- package/templates/ssr/app/dashboard/dashboard-client.tsx +154 -78
- package/templates/ssr/app/dashboard/page.tsx +16 -60
- package/templates/ssr/app/layout.tsx +46 -39
- package/templates/ssr/app/login/page.tsx +1 -1
- package/templates/ssr/app/page.tsx +182 -84
- package/templates/ssr/app/signup/page.tsx +1 -1
- package/templates/ssr/app.ts +134 -46
package/bin/create-pylon.js
CHANGED
|
@@ -82,8 +82,10 @@ const TEMPLATE_REGISTRY = {
|
|
|
82
82
|
unified: true,
|
|
83
83
|
},
|
|
84
84
|
barebones: {
|
|
85
|
-
blurb:
|
|
86
|
-
|
|
85
|
+
blurb:
|
|
86
|
+
"Single entity, live list + optimistic create. The smallest SSR app, one port.",
|
|
87
|
+
platforms: [],
|
|
88
|
+
unified: true,
|
|
87
89
|
},
|
|
88
90
|
todo: {
|
|
89
91
|
blurb:
|
|
@@ -92,16 +94,22 @@ const TEMPLATE_REGISTRY = {
|
|
|
92
94
|
unified: true,
|
|
93
95
|
},
|
|
94
96
|
b2b: {
|
|
95
|
-
blurb:
|
|
96
|
-
|
|
97
|
+
blurb:
|
|
98
|
+
"Multi-tenant SaaS — orgs, members, roles, tenant-scoped data. One SSR app.",
|
|
99
|
+
platforms: [],
|
|
100
|
+
unified: true,
|
|
97
101
|
},
|
|
98
102
|
consumer: {
|
|
99
|
-
blurb:
|
|
100
|
-
|
|
103
|
+
blurb:
|
|
104
|
+
"Social feed — live posts + likes, public-read, owner-write. One SSR app.",
|
|
105
|
+
platforms: [],
|
|
106
|
+
unified: true,
|
|
101
107
|
},
|
|
102
108
|
chat: {
|
|
103
|
-
blurb:
|
|
104
|
-
|
|
109
|
+
blurb:
|
|
110
|
+
"Realtime chat — a live shared room, optimistic send. One SSR app, one port.",
|
|
111
|
+
platforms: [],
|
|
112
|
+
unified: true,
|
|
105
113
|
},
|
|
106
114
|
};
|
|
107
115
|
const TEMPLATES_AVAILABLE = Object.keys(TEMPLATE_REGISTRY);
|
|
@@ -167,8 +175,8 @@ Examples:
|
|
|
167
175
|
npm create @pylonsync/pylon my-app --template ssr # full-stack SSR, no Next.js
|
|
168
176
|
npm create @pylonsync/pylon my-app --template todo # live, optimistic todo (SSR, one port)
|
|
169
177
|
npm create @pylonsync/pylon my-app
|
|
170
|
-
npm create @pylonsync/pylon my-app --template b2b
|
|
171
|
-
npm create @pylonsync/pylon my-app --template chat
|
|
178
|
+
npm create @pylonsync/pylon my-app --template b2b # multi-tenant SaaS (orgs, members, RBAC)
|
|
179
|
+
npm create @pylonsync/pylon my-app --template chat # realtime live chat room
|
|
172
180
|
`);
|
|
173
181
|
exit(0);
|
|
174
182
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pylonsync/create-pylon",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.269",
|
|
4
4
|
"description": "Scaffold a new Pylon app — realtime backend + web/mobile/expo frontends in one command. Run via `npm create @pylonsync/pylon@latest`.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# AGENTS.md — working in a Pylon project
|
|
2
|
+
|
|
3
|
+
Operating rules for a coding agent in this Pylon app. Pylon is a Rails-like framework for realtime apps: you declare entities, policies, and server functions in TypeScript, and a single Rust binary (`pylon`) serves the API, auth, sync, WebSocket, SSE, and native React 19 SSR — one process, one port. The full API reference is at **/llms-full.txt** (served at `/llms-full.txt`; in the repo at `apps/web/public/llms-full.txt`). Read it before guessing an API name.
|
|
4
|
+
|
|
5
|
+
## Directory conventions
|
|
6
|
+
|
|
7
|
+
**Unified SSR app:**
|
|
8
|
+
- `app.ts` — data model + manifest (`entity()` + `field.*`, queries/actions/policies, `routes: await discoverAppRoutes()`). Ends with `console.log(JSON.stringify(manifest))`.
|
|
9
|
+
- `app/` — file-based SSR routes. `app/page.tsx` → `/`, `app/about/page.tsx` → `/about`, `app/blog/[slug]/page.tsx` → `/blog/:slug`. `app/layout.tsx` is the shell; `app/error.tsx` / `app/not-found.tsx` are boundaries.
|
|
10
|
+
- `app/globals.css` — Tailwind v4 entrypoint (auto-compiled and injected).
|
|
11
|
+
- `functions/` — server functions, one per file, `default`-exported.
|
|
12
|
+
- `.pylon/` — local dev state (sqlite, jobs, sessions, uploads). Created by `pylon dev`. Do not commit.
|
|
13
|
+
|
|
14
|
+
**Monorepo app:** backend is `apps/api/` (entry `apps/api/schema.ts`, handlers in `apps/api/functions/`); frontend in `apps/web/`. `pylon.manifest.json` / `pylon.client.ts` are generated — do not hand-edit.
|
|
15
|
+
|
|
16
|
+
## The core authoring loop
|
|
17
|
+
|
|
18
|
+
1. **Define an entity** — `entity("Thing", { name: field.string(), done: field.boolean().default(false) })`. Modifiers: `.optional()`, `.unique()`, `.readonly()` (settable on insert, rejected on client update — use for `authorId`/`orgId`), `.serverOnly()` (never in HTTP responses), `.encrypted()` (AEAD at rest, needs `PYLON_ENCRYPTION_KEY`), `.crdt("text")` (collaborative).
|
|
19
|
+
2. **Write a policy** — `policy({ entity: "Thing", allowRead, allowInsert, allowUpdate, allowDelete })` with CEL-like expressions over `auth.*` / `data.*` (e.g. `"auth.userId == data.authorId"`). **Omitted actions DENY by default.** Wide-open dev policies (`allow*: "true"`) are flagged by `pylon lint` — tighten before shipping.
|
|
20
|
+
3. **Author a function** in `functions/<name>.ts` — `query` (read-only), `mutation` (transactional read+write), or `action` (external I/O, no direct `ctx.db`). Import `{ query, mutation, action, v }` from `@pylonsync/functions`. `auth` defaults to `"user"` (secure-by-default); set `"public"` explicitly for unauthenticated access. Use `ctx.db.*`, `ctx.auth.userId`, `ctx.error(code, msg)`.
|
|
21
|
+
4. **Read it on the client** — `db.useQuery("Thing")` (live, re-renders on any write) or `db.useQueryOne("Thing", id)`. Call functions with `db.fn(name, args)` / `callFn`. On SSR pages, read via `use(serverData.list("Thing"))` inside `<Suspense>`.
|
|
22
|
+
|
|
23
|
+
## Key gotchas
|
|
24
|
+
|
|
25
|
+
- **Policies deny by default; server functions BYPASS them.** Direct client CRUD (`/api/entities/*`) and sync are policy-checked. Functions run with full DB access — enforce trust with `ctx.auth` checks inside the handler, not policies.
|
|
26
|
+
- **Type page props from the SDK, don't hand-roll them.** `import type { PageProps, Metadata } from "@pylonsync/react"`. Every page/layout gets `{ url, params, searchParams, auth, response, serverData }`; `PageProps<{ slug: string }>` types a `[slug]` route's params. Request headers/cookies are intentionally NOT on `PageProps` — they're server-only and stripped from hydration, so reading them in the render would mismatch.
|
|
27
|
+
- **Anonymous output caching is opt-in + earned.** `export const revalidate = 60` (seconds) on a page makes it CDN-cacheable (`public, s-maxage=60`) — but ONLY if the render is auth-INDEPENDENT: it must NOT read `props.auth` (reading it at all opts out, even for anonymous), set no cookie, and the app must not run strict per-caller policies (`PYLON_STRICT_FN_POLICIES`). `export const dynamic = "force-static"` caches until the next deploy; `"force-dynamic"` never caches. Fail-closed: without the opt-in (or if any condition fails) the page is `no-cache`. A page that reads `auth` or sets a cookie is never shared. The SAME earned render is also kept in an **origin disk cache** (`.pylon/.cache/ssr`): a cookie-less GET with no query string is served straight off disk for the TTL — skipping the render entirely — then re-rendered live when stale. The disk cache is namespaced per deploy (wiped on each new build) and OFF in `pylon dev` (so an edit is never masked by a stale entry); invalidation is by the `revalidate` TTL or the next deploy.
|
|
28
|
+
- **No-JS forms use `route.ts` + `<Form>`.** Drop `app/.../route.ts` exporting `export const POST: RouteHandler = async ({ form, db, response, auth }) => { await db.insert("X", {...}); response.redirect("/x?ok=1"); }` (303 POST-redirect-GET by default). Render `<Form action="/x">` (from @pylonsync/react) with plain `<input name=...>` — works with JS off (native POST→handler→redirect) and is enhanced to no-reload when JS is on. The handler's `db` is read+write (mutation trust model — gate on `auth`); CSRF is automatic (Origin gate + SameSite=Lax). Multipart/file uploads aren't supported yet — use urlencoded forms + `/api/files`.
|
|
29
|
+
- **`loading.tsx` streams a skeleton while the page's data resolves.** Drop `app/.../loading.tsx` (default export, page props) and the nearest one becomes a route-level Suspense fallback: Pylon flushes the shell + skeleton immediately, then reveals the real page when its top-level `use(serverData…)` resolves (no blank page). It only shows when the PAGE suspends — a page that wraps its own `<Suspense>` around a child (like `/dashboard` in this template) handles that itself. The skeleton is SERVER-ONLY: don't read `serverData` in it. A page with no `loading.tsx` is buffered (unchanged).
|
|
30
|
+
- **`export const streaming = true` streams a page's OWN inner `<Suspense>` boundaries.** Without it (and without a `loading.tsx`), a page is BUFFERED — the whole document, including suspended children, resolves before the first byte. Opt in and the shell + each inner `<Suspense>` fallback flush immediately, then each boundary's real content streams in as its data resolves (multi-boundary progressive streaming). It's opt-in because it changes the response timing contract: a streaming render commits its HTTP head BEFORE suspended subtrees finish, so (a) it's never CDN/disk cacheable — don't combine with `export const revalidate`; (b) `response.setStatus/setCookie/redirect/notFound` only take effect from the SYNCHRONOUS shell render — a call from inside a suspended subtree is dropped (the runtime logs a loud warning naming what was lost); (c) a `throw` from a deep `<Suspense>` child resolves via its nearest `error.tsx` at HTTP 200, not a 5xx. Hydration is clean for any number of boundaries (the data blob ships before hydration runs). Type the config with `import type { RouteSegmentConfig } from "@pylonsync/react"`.
|
|
31
|
+
- **`error.tsx` / `not-found.tsx` boundaries are HYDRATED (interactive).** `app/.../error.tsx` catches a throw below it (HTTP 500) and receives `{ error: { message, digest }, reset }` (`import type { ErrorBoundaryProps }`) — `reset()` re-attempts the route; the stack NEVER reaches the client (dev overlay + logs only). `app/.../not-found.tsx` renders at 404 (also for `response.notFound()`) and gets the page props (`NotFoundProps`), no `reset`. Both run useState/onClick/hooks.
|
|
32
|
+
- **Client navigation hooks live in @pylonsync/react.** `useRouter()` → `{ push, replace, back, forward, refresh, prefetch }`; `useSearchParams()` → reactive `URLSearchParams`; `usePathname()` → reactive pathname. The hooks are CLIENT-reactive — during SSR they return defaults (empty params / "/"); for server-side URL values read the `url` / `searchParams` page props.
|
|
33
|
+
- **Dynamic + catch-all routes follow Next conventions.** `app/blog/[slug]/page.tsx` → `params.slug`. `app/docs/[...path]/page.tsx` is a catch-all (matches `/docs/a/b/c`; `params.path === "a/b/c"` — `.split("/")` for segments). `app/shop/[[...filters]]/page.tsx` is an optional catch-all (also matches the bare `/shop`, with `params.filters === ""`). A catch-all must be the last segment; static beats dynamic beats catch-all on overlap.
|
|
34
|
+
- **`serverData` (SSR) is READ-ONLY.** No write methods; the runtime rejects write frames (`SSR_WRITE_FORBIDDEN`). Mutations belong in actions/functions, never in a page render.
|
|
35
|
+
- **`response.*` / `response.redirect()` / `response.notFound()` must fire in the synchronous shell render**, before any `await` / `<Suspense>`. The HTTP head commits when the shell is ready — status/headers/cookies set from a suspended subtree are lost, and `redirect`/`notFound` thrown below a Suspense boundary are swallowed.
|
|
36
|
+
- **`ctx.llm` and `ctx.connections` are on mutation + action only, NOT query** (reactive purity). `action` has no direct `ctx.db` — use `ctx.runQuery` / `ctx.runMutation`.
|
|
37
|
+
- **It's `db.useQueryOne`, not `useOne`.** Validators and field types have aliases: `v.bool`/`v.boolean`, `v.float`/`v.number`.
|
|
38
|
+
- **There is no `ctx.files` or `defineWorkflow`/`defineJob`.** Files go through `<FileUpload>` + `/api/files/*`; deferred execution is `ctx.scheduler.runAfter/runAt/cancel`.
|
|
39
|
+
|
|
40
|
+
## Use the CLI — don't guess
|
|
41
|
+
|
|
42
|
+
| Need | Command |
|
|
43
|
+
|---|---|
|
|
44
|
+
| Run the app (SSR + API, hot reload, one port `:4321`) | `pylon dev` (or `npm run dev`) |
|
|
45
|
+
| Regenerate manifest + typed client | `pylon codegen` (Swift client: `pylon codegen client --target swift`) |
|
|
46
|
+
| Validate / diff / push schema | `pylon schema check` \| `diff` \| `push` |
|
|
47
|
+
| Migrations | `pylon migrate create <name>` \| `plan` \| `apply` |
|
|
48
|
+
| Lint policies (PYL001–PYL004) | `pylon lint --strict` |
|
|
49
|
+
| Tests | `pylon test` |
|
|
50
|
+
| Adversarial security probe | `pylon test:security` |
|
|
51
|
+
| Inspect cloud request logs (agent-safe) | `pylon logs --json --limit 50` |
|
|
52
|
+
| Inspect data / entities | `pylon data entities` \| `pylon data list <Entity>` |
|
|
53
|
+
| Call a function | `pylon fn <name> key=value` |
|
|
54
|
+
| Health snapshot | `pylon status` |
|
|
55
|
+
| Build for prod | `pylon build` |
|
|
56
|
+
| Deploy (Pylon Cloud by default) | `pylon deploy` |
|
|
57
|
+
| Look up an error code | `pylon explain <CODE>` |
|
|
58
|
+
|
|
59
|
+
`--json` works on every command for machine-readable output. Prefer one-shot/agent-safe flags (`pylon logs --limit N`, not a blocking `--follow`).
|
|
60
|
+
|
|
61
|
+
For full signatures, env vars, the complete CLI, and SSR/client/server-primitive details: **/llms-full.txt**.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# __APP_NAME__
|
|
2
|
+
|
|
3
|
+
A multi-tenant SaaS starter on [Pylon](https://pylonsync.com) — email/password
|
|
4
|
+
accounts, organizations with members + roles, and tenant-scoped data, all
|
|
5
|
+
server-rendered from one binary on one port. No Next.js, no separate API server.
|
|
6
|
+
|
|
7
|
+
## Develop
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
__RUN_DEV__
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Open http://localhost:4321. Sign up, create an organization, and you land in a
|
|
14
|
+
workspace with **tenant-scoped projects** and a **members** panel. Create a
|
|
15
|
+
second org and switch between them — each org's projects are private to it.
|
|
16
|
+
Edit any file under `app/` and save — the page reloads instantly.
|
|
17
|
+
|
|
18
|
+
## Layout
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
app.ts User + Org/OrgMember/OrgInvite + tenant-scoped Project
|
|
22
|
+
app/page.tsx "/" — server-rendered, auth-aware homepage
|
|
23
|
+
app/login,signup/ email/password (POST /api/auth/password/*)
|
|
24
|
+
app/dashboard/ "/dashboard" — authed; org switcher + projects + members
|
|
25
|
+
app/dashboard/dashboard-client.tsx the workspace client island
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## How multi-tenancy works
|
|
29
|
+
|
|
30
|
+
Organizations are a **framework primitive**. Declaring `Org` / `OrgMember` /
|
|
31
|
+
`OrgInvite` with the framework's field names lights up `/api/auth/orgs/*`
|
|
32
|
+
(create/list orgs, members, invites) and `/api/auth/select-org` (switch your
|
|
33
|
+
active tenant) — driven by `<OrganizationSwitcher>` from `@pylonsync/client`.
|
|
34
|
+
|
|
35
|
+
`select-org` checks your `OrgMember` row before committing, then sets the
|
|
36
|
+
session's `tenantId`. Your data lives in tenant-scoped entities:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
allowRead: "auth.tenantId == data.orgId"
|
|
40
|
+
allowInsert: "auth.tenantId == data.orgId"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
So `db.useQuery("Project")` returns only your **active org's** projects, and a
|
|
44
|
+
client literally cannot read or write another tenant's rows — switch orgs and
|
|
45
|
+
the list changes. **RBAC** is built in too: the framework gates invites/member
|
|
46
|
+
management to org admins, so a `member` calling `createInvite` gets a 403.
|
|
47
|
+
|
|
48
|
+
## Grow it
|
|
49
|
+
|
|
50
|
+
- **Add tenant data:** new `entity()` with an `orgId` + the same two policy
|
|
51
|
+
lines. That's a new tenant-scoped table.
|
|
52
|
+
- **Custom roles:** read `OrgMember.role` in a server function and gate writes
|
|
53
|
+
with `ctx.elevate({ admin })` / `ctx.db.unsafe.*`.
|
|
54
|
+
- **SSO / SAML:** per-org SSO is built in at `/api/auth/orgs/:id/sso/*`.
|
|
55
|
+
|
|
56
|
+
## Deploy
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pylon deploy
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Docs: https://docs.pylonsync.com
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import { passwordLogin, passwordRegister, ApiError } from "@pylonsync/client";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
|
|
7
|
+
// The email/password form, shared by /login and /signup. It calls the built-in
|
|
8
|
+
// auth API directly — `passwordLogin` / `passwordRegister` (from
|
|
9
|
+
// @pylonsync/client) POST to `/api/auth/password/*`.
|
|
10
|
+
//
|
|
11
|
+
// On success the server sets an HttpOnly session cookie on the response. We do
|
|
12
|
+
// a full navigation to /dashboard rather than a client transition: the fresh
|
|
13
|
+
// page load hands that cookie to the SSR runtime (which resolves auth and
|
|
14
|
+
// renders the dashboard server-side) and to the sync engine (which
|
|
15
|
+
// authenticates with the same cookie via `credentials: include`). Because the
|
|
16
|
+
// cookie is HttpOnly it can never be read by JavaScript, so there is no
|
|
17
|
+
// session token sitting in `localStorage` for an XSS to lift. (Cross-origin or
|
|
18
|
+
// native clients, which can't rely on the cookie, use the token-based path via
|
|
19
|
+
// `persistSession` instead — not needed here, same origin.)
|
|
20
|
+
export function AuthForm({ mode }: { mode: "login" | "signup" }) {
|
|
21
|
+
const [email, setEmail] = useState("");
|
|
22
|
+
const [password, setPassword] = useState("");
|
|
23
|
+
const [displayName, setDisplayName] = useState("");
|
|
24
|
+
const [error, setError] = useState<string | null>(null);
|
|
25
|
+
const [pending, setPending] = useState(false);
|
|
26
|
+
|
|
27
|
+
async function onSubmit(e: React.FormEvent) {
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
setError(null);
|
|
30
|
+
setPending(true);
|
|
31
|
+
try {
|
|
32
|
+
if (mode === "login") {
|
|
33
|
+
await passwordLogin({ email, password });
|
|
34
|
+
} else {
|
|
35
|
+
await passwordRegister({
|
|
36
|
+
email,
|
|
37
|
+
password,
|
|
38
|
+
displayName: displayName.trim() || undefined,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
// Full navigation: the SSR dashboard re-renders with the new cookie.
|
|
42
|
+
window.location.assign("/dashboard");
|
|
43
|
+
} catch (err) {
|
|
44
|
+
setError(messageFor(err));
|
|
45
|
+
setPending(false); // keep the form up to retry (success navigates away)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<form onSubmit={onSubmit} className="space-y-4">
|
|
51
|
+
{mode === "signup" ? (
|
|
52
|
+
<Field
|
|
53
|
+
label="Name"
|
|
54
|
+
value={displayName}
|
|
55
|
+
onChange={setDisplayName}
|
|
56
|
+
autoComplete="name"
|
|
57
|
+
placeholder="optional"
|
|
58
|
+
/>
|
|
59
|
+
) : null}
|
|
60
|
+
<Field
|
|
61
|
+
label="Email"
|
|
62
|
+
type="email"
|
|
63
|
+
value={email}
|
|
64
|
+
onChange={setEmail}
|
|
65
|
+
required
|
|
66
|
+
autoComplete="email"
|
|
67
|
+
placeholder="you@example.com"
|
|
68
|
+
/>
|
|
69
|
+
<Field
|
|
70
|
+
label="Password"
|
|
71
|
+
type="password"
|
|
72
|
+
value={password}
|
|
73
|
+
onChange={setPassword}
|
|
74
|
+
required
|
|
75
|
+
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
|
76
|
+
placeholder={mode === "signup" ? "at least 8 characters" : undefined}
|
|
77
|
+
/>
|
|
78
|
+
{error ? (
|
|
79
|
+
<p className="rounded-md border border-red-600/30 bg-red-600/10 px-3 py-2 text-sm text-red-700">
|
|
80
|
+
{error}
|
|
81
|
+
</p>
|
|
82
|
+
) : null}
|
|
83
|
+
<Button type="submit" disabled={pending} className="w-full">
|
|
84
|
+
{pending ? "…" : mode === "login" ? "Sign in" : "Create account"}
|
|
85
|
+
</Button>
|
|
86
|
+
</form>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function Field({
|
|
91
|
+
label,
|
|
92
|
+
value,
|
|
93
|
+
onChange,
|
|
94
|
+
type = "text",
|
|
95
|
+
required,
|
|
96
|
+
autoComplete,
|
|
97
|
+
placeholder,
|
|
98
|
+
}: {
|
|
99
|
+
label: string;
|
|
100
|
+
value: string;
|
|
101
|
+
onChange: (v: string) => void;
|
|
102
|
+
type?: string;
|
|
103
|
+
required?: boolean;
|
|
104
|
+
autoComplete?: string;
|
|
105
|
+
placeholder?: string;
|
|
106
|
+
}) {
|
|
107
|
+
return (
|
|
108
|
+
<label className="block space-y-1.5">
|
|
109
|
+
<span className="text-sm font-medium">{label}</span>
|
|
110
|
+
<input
|
|
111
|
+
type={type}
|
|
112
|
+
value={value}
|
|
113
|
+
onChange={(e) => onChange(e.target.value)}
|
|
114
|
+
required={required}
|
|
115
|
+
autoComplete={autoComplete}
|
|
116
|
+
placeholder={placeholder}
|
|
117
|
+
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
118
|
+
/>
|
|
119
|
+
</label>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Map the framework's auth error codes to friendly copy. `ApiError` carries a
|
|
124
|
+
// stable `.code` (and `.status`) so you branch on the code, not the message.
|
|
125
|
+
function messageFor(err: unknown): string {
|
|
126
|
+
if (err instanceof ApiError) {
|
|
127
|
+
switch (err.code) {
|
|
128
|
+
case "INVALID_CREDENTIALS":
|
|
129
|
+
return "Wrong email or password.";
|
|
130
|
+
case "USER_EXISTS":
|
|
131
|
+
return "That email is already in use — sign in instead.";
|
|
132
|
+
case "WEAK_PASSWORD":
|
|
133
|
+
return "Pick a stronger password (at least 8 characters).";
|
|
134
|
+
case "RATE_LIMITED":
|
|
135
|
+
return "Too many attempts — try again in a minute.";
|
|
136
|
+
default:
|
|
137
|
+
return err.message;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (err instanceof Error) return err.message;
|
|
141
|
+
return "Something went wrong. Try again.";
|
|
142
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useCallback, useEffect, useState } from "react";
|
|
4
|
+
import { db } from "@pylonsync/react";
|
|
5
|
+
import {
|
|
6
|
+
useAuth,
|
|
7
|
+
OrganizationSwitcher,
|
|
8
|
+
listOrgMembers,
|
|
9
|
+
createInvite,
|
|
10
|
+
type OrgMember,
|
|
11
|
+
} from "@pylonsync/client";
|
|
12
|
+
import { Button } from "@/components/ui/button";
|
|
13
|
+
|
|
14
|
+
export interface Project {
|
|
15
|
+
id: string;
|
|
16
|
+
orgId: string;
|
|
17
|
+
name: string;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The workspace. `<OrganizationSwitcher>` (from @pylonsync/client) lists your
|
|
22
|
+
// orgs, creates new ones, and switches your active tenant via
|
|
23
|
+
// /api/auth/select-org — all against the framework's built-in org system. The
|
|
24
|
+
// rest of the page keys off `tenantId` (your active org).
|
|
25
|
+
export function Workspace() {
|
|
26
|
+
const { tenantId, signOut } = useAuth();
|
|
27
|
+
|
|
28
|
+
async function onSignOut() {
|
|
29
|
+
await signOut();
|
|
30
|
+
window.location.assign("/");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="space-y-6">
|
|
35
|
+
<div className="flex items-center justify-between gap-3">
|
|
36
|
+
<OrganizationSwitcher />
|
|
37
|
+
<Button variant="ghost" size="sm" onClick={onSignOut}>
|
|
38
|
+
Sign out
|
|
39
|
+
</Button>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
{tenantId ? (
|
|
43
|
+
<div className="grid gap-6 sm:grid-cols-2">
|
|
44
|
+
<Projects orgId={tenantId} />
|
|
45
|
+
<Members orgId={tenantId} />
|
|
46
|
+
</div>
|
|
47
|
+
) : (
|
|
48
|
+
<div className="rounded-lg border border-dashed px-6 py-10 text-center text-sm text-muted-foreground">
|
|
49
|
+
Create or select an organization above to get started. Each org is an
|
|
50
|
+
isolated tenant — its projects and members are private to it.
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Tenant-scoped data. `db.useQuery("Project")` returns only your active org's
|
|
58
|
+
// projects (the policy gates on `auth.tenantId == data.orgId`), and switching
|
|
59
|
+
// orgs re-syncs the list. `db.insert` is optimistic; we pass `orgId` = the
|
|
60
|
+
// active tenant so the row lands in this org — the policy rejects any other.
|
|
61
|
+
function Projects({ orgId }: { orgId: string }) {
|
|
62
|
+
const [name, setName] = useState("");
|
|
63
|
+
const { data: all } = db.useQuery<Project>("Project");
|
|
64
|
+
// Defensive filter while a tenant switch re-syncs the local replica.
|
|
65
|
+
const projects = all.filter((p) => p.orgId === orgId);
|
|
66
|
+
|
|
67
|
+
async function add(e: React.FormEvent) {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
const value = name.trim();
|
|
70
|
+
if (!value) return;
|
|
71
|
+
setName("");
|
|
72
|
+
await db.insert("Project", { orgId, name: value });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<section className="space-y-3">
|
|
77
|
+
<h2 className="text-sm font-semibold">Projects</h2>
|
|
78
|
+
<form onSubmit={add} className="flex items-center gap-2">
|
|
79
|
+
<input
|
|
80
|
+
value={name}
|
|
81
|
+
onChange={(e) => setName(e.target.value)}
|
|
82
|
+
placeholder="New project…"
|
|
83
|
+
aria-label="Project name"
|
|
84
|
+
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
85
|
+
/>
|
|
86
|
+
<Button type="submit" size="sm">
|
|
87
|
+
Add
|
|
88
|
+
</Button>
|
|
89
|
+
</form>
|
|
90
|
+
{projects.length === 0 ? (
|
|
91
|
+
<p className="text-sm text-muted-foreground">No projects yet.</p>
|
|
92
|
+
) : (
|
|
93
|
+
<ul className="space-y-1.5">
|
|
94
|
+
{projects.map((p) => (
|
|
95
|
+
<li
|
|
96
|
+
key={p.id}
|
|
97
|
+
className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
|
|
98
|
+
>
|
|
99
|
+
<span className="truncate">{p.name}</span>
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
aria-label="Delete project"
|
|
103
|
+
onClick={() => db.delete("Project", p.id)}
|
|
104
|
+
className="text-muted-foreground/40 transition-colors hover:text-red-600"
|
|
105
|
+
>
|
|
106
|
+
✕
|
|
107
|
+
</button>
|
|
108
|
+
</li>
|
|
109
|
+
))}
|
|
110
|
+
</ul>
|
|
111
|
+
)}
|
|
112
|
+
<p className="text-xs text-muted-foreground">
|
|
113
|
+
Tenant-scoped: only this org's projects, enforced by policy — switch
|
|
114
|
+
orgs and the list changes.
|
|
115
|
+
</p>
|
|
116
|
+
</section>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Membership + invites go through the framework's /api/auth/orgs/:id endpoints
|
|
121
|
+
// (the @pylonsync/client helpers). The framework gates invites to org admins,
|
|
122
|
+
// so a member calling createInvite gets a 403 — real RBAC, no extra code.
|
|
123
|
+
function Members({ orgId }: { orgId: string }) {
|
|
124
|
+
const [members, setMembers] = useState<OrgMember[] | null>(null);
|
|
125
|
+
const [email, setEmail] = useState("");
|
|
126
|
+
const [note, setNote] = useState("");
|
|
127
|
+
|
|
128
|
+
const refresh = useCallback(() => {
|
|
129
|
+
void listOrgMembers(orgId).then(setMembers);
|
|
130
|
+
}, [orgId]);
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
setMembers(null);
|
|
134
|
+
refresh();
|
|
135
|
+
}, [refresh]);
|
|
136
|
+
|
|
137
|
+
async function invite(e: React.FormEvent) {
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
const value = email.trim();
|
|
140
|
+
if (!value) return;
|
|
141
|
+
setEmail("");
|
|
142
|
+
setNote("");
|
|
143
|
+
try {
|
|
144
|
+
await createInvite(orgId, value, "member");
|
|
145
|
+
setNote(`Invited ${value}.`);
|
|
146
|
+
refresh();
|
|
147
|
+
} catch {
|
|
148
|
+
setNote("Only org admins can invite members.");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<section className="space-y-3">
|
|
154
|
+
<h2 className="text-sm font-semibold">Members</h2>
|
|
155
|
+
{members === null ? (
|
|
156
|
+
<p className="text-sm text-muted-foreground">Loading…</p>
|
|
157
|
+
) : (
|
|
158
|
+
<ul className="space-y-1.5">
|
|
159
|
+
{members.map((m) => (
|
|
160
|
+
<li
|
|
161
|
+
key={m.user_id}
|
|
162
|
+
className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
|
|
163
|
+
>
|
|
164
|
+
<span className="truncate font-mono text-xs">
|
|
165
|
+
{shortId(m.user_id)}
|
|
166
|
+
</span>
|
|
167
|
+
<span className="text-xs text-muted-foreground">{m.role}</span>
|
|
168
|
+
</li>
|
|
169
|
+
))}
|
|
170
|
+
</ul>
|
|
171
|
+
)}
|
|
172
|
+
<form onSubmit={invite} className="flex items-center gap-2">
|
|
173
|
+
<input
|
|
174
|
+
type="email"
|
|
175
|
+
value={email}
|
|
176
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
177
|
+
placeholder="invite by email…"
|
|
178
|
+
aria-label="Invite email"
|
|
179
|
+
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
180
|
+
/>
|
|
181
|
+
<Button type="submit" size="sm" variant="outline">
|
|
182
|
+
Invite
|
|
183
|
+
</Button>
|
|
184
|
+
</form>
|
|
185
|
+
{note && <p className="text-xs text-muted-foreground">{note}</p>}
|
|
186
|
+
</section>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function shortId(id: string) {
|
|
191
|
+
return id.replace(/^user_/, "").slice(0, 10);
|
|
192
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import React, { Suspense, use } from "react";
|
|
2
|
+
import {
|
|
3
|
+
type Metadata,
|
|
4
|
+
type PageProps,
|
|
5
|
+
type ServerData,
|
|
6
|
+
} from "@pylonsync/react";
|
|
7
|
+
import { Workspace } from "./dashboard-client";
|
|
8
|
+
|
|
9
|
+
export const metadata: Metadata = {
|
|
10
|
+
title: "Dashboard — __APP_NAME__",
|
|
11
|
+
robots: "noindex",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
interface User {
|
|
15
|
+
id: string;
|
|
16
|
+
email: string;
|
|
17
|
+
displayName?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Reads the signed-in user DURING the server render (owner-scoped: only your
|
|
21
|
+
// own row). The org list + the active org's projects load client-side, since
|
|
22
|
+
// they depend on the session's selected tenant.
|
|
23
|
+
function Greeting({
|
|
24
|
+
serverData,
|
|
25
|
+
userId,
|
|
26
|
+
}: {
|
|
27
|
+
serverData: ServerData;
|
|
28
|
+
userId: string;
|
|
29
|
+
}) {
|
|
30
|
+
const user = use(serverData.get<User>("User", userId));
|
|
31
|
+
return (
|
|
32
|
+
<p className="text-sm text-muted-foreground">
|
|
33
|
+
Signed in as{" "}
|
|
34
|
+
<span className="font-medium text-foreground">
|
|
35
|
+
{user?.displayName || user?.email || "you"}
|
|
36
|
+
</span>
|
|
37
|
+
.
|
|
38
|
+
</p>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// `app/dashboard/page.tsx` → `/dashboard`.
|
|
43
|
+
export default function DashboardPage({
|
|
44
|
+
auth,
|
|
45
|
+
response,
|
|
46
|
+
serverData,
|
|
47
|
+
}: PageProps) {
|
|
48
|
+
// Server-side auth gate: anonymous requests get a 307 to /login before any
|
|
49
|
+
// HTML. Must fire in the synchronous shell render, not inside <Suspense> —
|
|
50
|
+
// and return immediately so nothing renders below the already-sent redirect.
|
|
51
|
+
if (!auth.user_id) {
|
|
52
|
+
response.redirect("/login");
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return (
|
|
56
|
+
<div className="space-y-6">
|
|
57
|
+
<Suspense fallback={null}>
|
|
58
|
+
<Greeting serverData={serverData} userId={auth.user_id!} />
|
|
59
|
+
</Suspense>
|
|
60
|
+
<Workspace />
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type ErrorBoundaryProps } from "@pylonsync/react";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
|
|
5
|
+
// `app/error.tsx` → the error boundary for this segment. It catches a throw
|
|
6
|
+
// in any page/layout below it and renders at HTTP 500. It's HYDRATED, so
|
|
7
|
+
// this is a real interactive client component: `reset()` re-attempts the
|
|
8
|
+
// route, and useState/onClick work. The thrown error reaches the client as
|
|
9
|
+
// `{ message, digest }` only — the stack stays in the dev overlay
|
|
10
|
+
// (PYLON_DEV_MODE) and the server logs, never in the page.
|
|
11
|
+
export default function Error({ error, reset }: ErrorBoundaryProps) {
|
|
12
|
+
const [tries, setTries] = React.useState(0);
|
|
13
|
+
return (
|
|
14
|
+
<div className="space-y-6">
|
|
15
|
+
<section>
|
|
16
|
+
<h1 className="text-2xl font-semibold tracking-tight">
|
|
17
|
+
Something went wrong
|
|
18
|
+
</h1>
|
|
19
|
+
<p className="mt-2 text-muted-foreground">{error.message}</p>
|
|
20
|
+
{error.digest ? (
|
|
21
|
+
<p className="mt-1 text-xs text-muted-foreground/70">
|
|
22
|
+
Reference: <code>{error.digest}</code>
|
|
23
|
+
</p>
|
|
24
|
+
) : null}
|
|
25
|
+
</section>
|
|
26
|
+
<div className="flex items-center gap-3">
|
|
27
|
+
<Button
|
|
28
|
+
onClick={() => {
|
|
29
|
+
setTries((n) => n + 1);
|
|
30
|
+
reset();
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
Try again
|
|
34
|
+
</Button>
|
|
35
|
+
{tries > 0 ? (
|
|
36
|
+
<span className="text-sm text-muted-foreground">
|
|
37
|
+
Retried {tries} {tries === 1 ? "time" : "times"}
|
|
38
|
+
</span>
|
|
39
|
+
) : null}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|