@pylonsync/create-pylon 0.3.268 → 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/package.json +1 -1
- 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 +131 -46
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"
|
package/templates/ssr/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# __APP_NAME__
|
|
2
2
|
|
|
3
|
-
A full-stack [Pylon](https://pylonsync.com)
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
A full-stack, multi-tenant SaaS starter on [Pylon](https://pylonsync.com),
|
|
4
|
+
branded as a fictional product called **Acme**: a server-rendered marketing
|
|
5
|
+
landing page, email/password auth, organizations with members + roles, and
|
|
6
|
+
tenant-scoped projects — all from one binary on one port. No Next.js, no
|
|
7
|
+
separate API server, no realtime sidecar.
|
|
6
8
|
|
|
7
9
|
## Develop
|
|
8
10
|
|
|
@@ -10,42 +12,55 @@ served from one binary on one port. No Next.js, no separate API server.
|
|
|
10
12
|
__RUN_DEV__
|
|
11
13
|
```
|
|
12
14
|
|
|
13
|
-
Open http://localhost:4321.
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
Open http://localhost:4321. You get the **Acme landing page**. Sign up, create
|
|
16
|
+
an organization, and you land in a **workspace** with tenant-scoped projects and
|
|
17
|
+
a members panel. Create a second org and switch between them — each org's data
|
|
18
|
+
is private to it. Edit any file under `app/` and save — the page reloads.
|
|
16
19
|
|
|
17
20
|
## Layout
|
|
18
21
|
|
|
19
22
|
```
|
|
20
|
-
app.ts
|
|
21
|
-
app/page.tsx
|
|
22
|
-
app/
|
|
23
|
-
app/
|
|
24
|
-
app/
|
|
25
|
-
app/
|
|
26
|
-
app/globals.css
|
|
27
|
-
|
|
23
|
+
app.ts User + Org/OrgMember/OrgInvite + tenant-scoped Project
|
|
24
|
+
app/page.tsx "/" — the server-rendered Acme landing page (auth-aware)
|
|
25
|
+
app/layout.tsx marketing nav + footer (rebrand "Acme")
|
|
26
|
+
app/login,signup/ email/password (POST /api/auth/password/*)
|
|
27
|
+
app/dashboard/ "/dashboard" — authed; org switcher + projects + members
|
|
28
|
+
app/dashboard/dashboard-client.tsx the workspace client island
|
|
29
|
+
app/globals.css Tailwind v4 + shadcn tokens (compiled by Pylon)
|
|
30
|
+
components/ui/ shadcn primitives (Button, Card)
|
|
28
31
|
```
|
|
29
32
|
|
|
30
|
-
## How
|
|
33
|
+
## How it works
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
before any HTML, so there's no flash and it works with JS off. The sync engine
|
|
37
|
-
authenticates with the same cookie.
|
|
35
|
+
**The landing page** (`app/page.tsx`) is server-rendered React — view source and
|
|
36
|
+
the copy + SEO `<head>` are in the HTML, so it's fully indexable. It reads the
|
|
37
|
+
session during the render, so the call-to-action is "Get started" for visitors
|
|
38
|
+
and "Open dashboard" once you're signed in — no flash, no client fetch.
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
**Auth** is built in: `/login` + `/signup` POST to `/api/auth/password/*`, the
|
|
41
|
+
server sets an HttpOnly session cookie, and `/dashboard` redirects anonymous
|
|
42
|
+
visitors with a real 3xx before any HTML (works with JS off).
|
|
40
43
|
|
|
41
|
-
|
|
42
|
-
`
|
|
43
|
-
|
|
44
|
+
**Multi-tenancy** is a framework primitive. Declaring `Org` / `OrgMember` /
|
|
45
|
+
`OrgInvite` lights up `/api/auth/orgs/*` + `/api/auth/select-org`, driven by
|
|
46
|
+
`<OrganizationSwitcher>` from `@pylonsync/client`. Your data lives in
|
|
47
|
+
tenant-scoped entities (`Project`), gated by policy:
|
|
44
48
|
|
|
45
|
-
|
|
49
|
+
```ts
|
|
50
|
+
allowRead: "auth.tenantId == data.orgId"
|
|
51
|
+
allowInsert: "auth.tenantId == data.orgId"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
So `db.useQuery("Project")` returns only your **active org's** projects — switch
|
|
55
|
+
orgs and the list changes, and a client literally cannot read or write another
|
|
56
|
+
tenant's rows. `db.useQuery` is live; `db.insert` is optimistic.
|
|
57
|
+
|
|
58
|
+
## Make it yours
|
|
46
59
|
|
|
47
|
-
|
|
48
|
-
|
|
60
|
+
- **Rebrand:** replace "Acme" in `app/page.tsx` + `app/layout.tsx`.
|
|
61
|
+
- **Add tenant data:** new `entity()` with an `orgId` + the same two policy
|
|
62
|
+
lines — a new tenant-scoped table, typed client and REST/realtime API included.
|
|
63
|
+
- **Add a route:** drop `app/about/page.tsx` and visit `/about`.
|
|
49
64
|
|
|
50
65
|
## Deploy
|
|
51
66
|
|
|
@@ -1,109 +1,107 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import React, { useState } from "react";
|
|
3
|
+
import React, { useCallback, useEffect, useState } from "react";
|
|
4
4
|
import { db } from "@pylonsync/react";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
useAuth,
|
|
7
|
+
OrganizationSwitcher,
|
|
8
|
+
listOrgMembers,
|
|
9
|
+
createInvite,
|
|
10
|
+
type OrgMember,
|
|
11
|
+
} from "@pylonsync/client";
|
|
6
12
|
import { Button } from "@/components/ui/button";
|
|
7
13
|
|
|
8
|
-
export interface
|
|
14
|
+
export interface Project {
|
|
9
15
|
id: string;
|
|
10
|
-
|
|
11
|
-
|
|
16
|
+
orgId: string;
|
|
17
|
+
name: string;
|
|
18
|
+
createdAt: string;
|
|
12
19
|
}
|
|
13
20
|
|
|
14
|
-
// The
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
// `initial` are the rows the server rendered into the HTML (see page.tsx).
|
|
21
|
-
// We show them on the first paint — before the local store has hydrated — so
|
|
22
|
-
// there's no empty flash, then hand off to the live data. Server-rendered for
|
|
23
|
-
// the first byte, local-first realtime after.
|
|
24
|
-
export function Dashboard({ initial }: { initial: Note[] }) {
|
|
25
|
-
const { signOut } = useAuth();
|
|
26
|
-
const [body, setBody] = useState("");
|
|
27
|
-
const { data: live, loading } = db.useQuery<Note>("Note");
|
|
28
|
-
const notes = !loading || live.length > 0 ? live : initial;
|
|
29
|
-
|
|
30
|
-
async function addNote(e: React.FormEvent) {
|
|
31
|
-
e.preventDefault();
|
|
32
|
-
const text = body.trim();
|
|
33
|
-
if (!text) return;
|
|
34
|
-
setBody("");
|
|
35
|
-
// We don't send ownerId — `field.owner()` stamps it from the session
|
|
36
|
-
// server-side and rejects any forged value, so this optimistic insert is
|
|
37
|
-
// safe.
|
|
38
|
-
await db.insert("Note", { body: text, done: false });
|
|
39
|
-
}
|
|
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();
|
|
40
27
|
|
|
41
28
|
async function onSignOut() {
|
|
42
|
-
// Clears the server session (DELETE /api/auth/session → the cookie is
|
|
43
|
-
// cleared), then we land back on the public homepage.
|
|
44
29
|
await signOut();
|
|
45
30
|
window.location.assign("/");
|
|
46
31
|
}
|
|
47
32
|
|
|
48
33
|
return (
|
|
49
|
-
<div className="space-y-
|
|
50
|
-
<div className="flex items-center justify-
|
|
34
|
+
<div className="space-y-6">
|
|
35
|
+
<div className="flex items-center justify-between gap-3">
|
|
36
|
+
<OrganizationSwitcher />
|
|
51
37
|
<Button variant="ghost" size="sm" onClick={onSignOut}>
|
|
52
38
|
Sign out
|
|
53
39
|
</Button>
|
|
54
40
|
</div>
|
|
55
41
|
|
|
56
|
-
|
|
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">
|
|
57
79
|
<input
|
|
58
|
-
value={
|
|
59
|
-
onChange={(e) =>
|
|
60
|
-
placeholder="
|
|
61
|
-
aria-label="
|
|
80
|
+
value={name}
|
|
81
|
+
onChange={(e) => setName(e.target.value)}
|
|
82
|
+
placeholder="New project…"
|
|
83
|
+
aria-label="Project name"
|
|
62
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"
|
|
63
85
|
/>
|
|
64
|
-
<Button type="submit">
|
|
86
|
+
<Button type="submit" size="sm">
|
|
87
|
+
Add
|
|
88
|
+
</Button>
|
|
65
89
|
</form>
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
<p className="text-sm text-muted-foreground">
|
|
69
|
-
No notes yet — add one above. It appears instantly (optimistic) and
|
|
70
|
-
syncs; open this page in a second tab to watch it arrive live.
|
|
71
|
-
</p>
|
|
90
|
+
{projects.length === 0 ? (
|
|
91
|
+
<p className="text-sm text-muted-foreground">No projects yet.</p>
|
|
72
92
|
) : (
|
|
73
|
-
<ul className="space-y-
|
|
74
|
-
{
|
|
93
|
+
<ul className="space-y-1.5">
|
|
94
|
+
{projects.map((p) => (
|
|
75
95
|
<li
|
|
76
|
-
key={
|
|
77
|
-
className="flex items-center
|
|
96
|
+
key={p.id}
|
|
97
|
+
className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
|
|
78
98
|
>
|
|
99
|
+
<span className="truncate">{p.name}</span>
|
|
79
100
|
<button
|
|
80
101
|
type="button"
|
|
81
|
-
aria-label=
|
|
82
|
-
onClick={() =>
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
className={
|
|
86
|
-
note.done
|
|
87
|
-
? "text-emerald-600"
|
|
88
|
-
: "text-muted-foreground/50 hover:text-muted-foreground"
|
|
89
|
-
}
|
|
90
|
-
>
|
|
91
|
-
{note.done ? "✓" : "○"}
|
|
92
|
-
</button>
|
|
93
|
-
<span
|
|
94
|
-
className={
|
|
95
|
-
note.done
|
|
96
|
-
? "flex-1 line-through text-muted-foreground"
|
|
97
|
-
: "flex-1"
|
|
98
|
-
}
|
|
99
|
-
>
|
|
100
|
-
{note.body}
|
|
101
|
-
</span>
|
|
102
|
-
<button
|
|
103
|
-
type="button"
|
|
104
|
-
aria-label="Delete note"
|
|
105
|
-
onClick={() => db.delete("Note", note.id)}
|
|
106
|
-
className="text-muted-foreground/40 hover:text-red-600"
|
|
102
|
+
aria-label="Delete project"
|
|
103
|
+
onClick={() => db.delete("Project", p.id)}
|
|
104
|
+
className="text-muted-foreground/40 transition-colors hover:text-red-600"
|
|
107
105
|
>
|
|
108
106
|
✕
|
|
109
107
|
</button>
|
|
@@ -111,6 +109,84 @@ export function Dashboard({ initial }: { initial: Note[] }) {
|
|
|
111
109
|
))}
|
|
112
110
|
</ul>
|
|
113
111
|
)}
|
|
114
|
-
|
|
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>
|
|
115
187
|
);
|
|
116
188
|
}
|
|
189
|
+
|
|
190
|
+
function shortId(id: string) {
|
|
191
|
+
return id.replace(/^user_/, "").slice(0, 10);
|
|
192
|
+
}
|
|
@@ -1,70 +1,26 @@
|
|
|
1
|
-
import React
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
type PageProps,
|
|
5
|
-
type ServerData,
|
|
6
|
-
} from "@pylonsync/react";
|
|
7
|
-
import { Dashboard, type Note } from "./dashboard-client";
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type Metadata, type PageProps } from "@pylonsync/react";
|
|
3
|
+
import { Workspace } from "./dashboard-client";
|
|
8
4
|
|
|
9
5
|
export const metadata: Metadata = {
|
|
10
|
-
title: "Dashboard —
|
|
6
|
+
title: "Dashboard — Acme",
|
|
11
7
|
robots: "noindex",
|
|
12
8
|
};
|
|
13
9
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
// arrives with your notes already in it (no empty flash); then the <Dashboard>
|
|
25
|
-
// island hydrates and takes over live.
|
|
26
|
-
function DashboardBody({
|
|
27
|
-
serverData,
|
|
28
|
-
userId,
|
|
29
|
-
}: {
|
|
30
|
-
serverData: ServerData;
|
|
31
|
-
userId: string;
|
|
32
|
-
}) {
|
|
33
|
-
const user = use(serverData.get<User>("User", userId));
|
|
34
|
-
const notes = use(serverData.list<Note>("Note"));
|
|
35
|
-
return (
|
|
36
|
-
<>
|
|
37
|
-
<p className="text-sm text-muted-foreground">
|
|
38
|
-
Signed in as{" "}
|
|
39
|
-
<span className="font-medium text-foreground">
|
|
40
|
-
{user?.displayName || user?.email || "you"}
|
|
41
|
-
</span>
|
|
42
|
-
.
|
|
43
|
-
</p>
|
|
44
|
-
<Dashboard initial={notes} />
|
|
45
|
-
</>
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// `app/dashboard/page.tsx` → `/dashboard`.
|
|
50
|
-
export default function DashboardPage({
|
|
51
|
-
auth,
|
|
52
|
-
response,
|
|
53
|
-
serverData,
|
|
54
|
-
}: PageProps) {
|
|
55
|
-
// Server-side auth gate: anonymous requests get a 307 to /login before any
|
|
56
|
-
// HTML. The redirect MUST fire here in the synchronous shell render — not
|
|
57
|
-
// inside the <Suspense> below — or React swallows it. No flash of the
|
|
58
|
-
// dashboard, works with JS disabled.
|
|
59
|
-
if (!auth.user_id) response.redirect("/login");
|
|
10
|
+
// `app/dashboard/page.tsx` → `/dashboard`. Server-side auth gate: anonymous
|
|
11
|
+
// requests get a 307 to /login before any HTML is sent (works with JS off, no
|
|
12
|
+
// flash of the dashboard). The redirect fires — and we return — in the
|
|
13
|
+
// synchronous shell render. The workspace itself is a client island that reads
|
|
14
|
+
// your active org from the session.
|
|
15
|
+
export default function DashboardPage({ auth, response }: PageProps) {
|
|
16
|
+
if (!auth.user_id) {
|
|
17
|
+
response.redirect("/login");
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
60
20
|
return (
|
|
61
21
|
<div className="space-y-6">
|
|
62
|
-
<h1 className="text-2xl font-semibold tracking-tight">
|
|
63
|
-
<
|
|
64
|
-
fallback={<p className="text-sm text-muted-foreground">Loading…</p>}
|
|
65
|
-
>
|
|
66
|
-
<DashboardBody serverData={serverData} userId={auth.user_id!} />
|
|
67
|
-
</Suspense>
|
|
22
|
+
<h1 className="text-2xl font-semibold tracking-tight">Workspace</h1>
|
|
23
|
+
<Workspace />
|
|
68
24
|
</div>
|
|
69
25
|
);
|
|
70
26
|
}
|
|
@@ -1,69 +1,76 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Link, type PageAuth } from "@pylonsync/react";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
3
4
|
|
|
4
|
-
// A layout receives the page props plus `children`. `auth.user_id` is null
|
|
5
|
-
//
|
|
6
|
-
// server-side from the session cookie
|
|
7
|
-
// renders the right links on the first byte (no flash, no client fetch).
|
|
8
|
-
// `PageAuth` type is exported from @pylonsync/react so you never hand-roll it.
|
|
5
|
+
// A layout receives the page props plus `children`. `auth.user_id` is null for
|
|
6
|
+
// anonymous visitors and the signed-in user's id otherwise — resolved
|
|
7
|
+
// server-side from the session cookie before any HTML is sent, so the nav
|
|
8
|
+
// renders the right links on the first byte (no flash, no client fetch).
|
|
9
9
|
interface LayoutProps {
|
|
10
10
|
children: React.ReactNode;
|
|
11
11
|
url: string;
|
|
12
12
|
auth: PageAuth;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
// The root layout wraps every page.
|
|
15
|
+
// The root layout wraps every page: a marketing nav up top, a footer below.
|
|
16
|
+
// Rebrand "Acme" to your product.
|
|
16
17
|
export default function RootLayout({ children, auth }: LayoutProps) {
|
|
17
18
|
const signedIn = Boolean(auth?.user_id);
|
|
18
|
-
// Add `className="dark"` to this <html> to flip every shadcn token to its
|
|
19
|
-
// dark value. The classes below use semantic tokens (bg-background,
|
|
20
|
-
// text-foreground, …) so the whole UI re-themes from app/globals.css.
|
|
21
19
|
return (
|
|
22
20
|
<html lang="en">
|
|
23
21
|
<head>
|
|
24
22
|
<meta charSet="utf-8" />
|
|
25
23
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
26
|
-
<title>
|
|
24
|
+
<title>Acme</title>
|
|
27
25
|
{/* Tailwind is compiled by Pylon from app/globals.css and the
|
|
28
|
-
stylesheet link is injected here automatically
|
|
29
|
-
wire up. */}
|
|
26
|
+
stylesheet link is injected here automatically. */}
|
|
30
27
|
</head>
|
|
31
|
-
<body className="min-h-screen bg-background text-foreground antialiased">
|
|
32
|
-
<header className="sticky top-0 z-
|
|
33
|
-
<div className="mx-auto flex max-w-
|
|
34
|
-
<Link
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
28
|
+
<body className="flex min-h-screen flex-col bg-background text-foreground antialiased">
|
|
29
|
+
<header className="sticky top-0 z-20 border-b bg-background/70 backdrop-blur">
|
|
30
|
+
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
|
|
31
|
+
<Link href="/" className="flex items-center gap-2">
|
|
32
|
+
<span className="flex size-7 items-center justify-center rounded-md bg-primary text-sm font-bold text-primary-foreground">
|
|
33
|
+
A
|
|
34
|
+
</span>
|
|
35
|
+
<span className="text-sm font-semibold tracking-tight">Acme</span>
|
|
39
36
|
</Link>
|
|
40
|
-
<nav className="flex items-center gap-
|
|
41
|
-
<Link href="/" className="hover:text-foreground">
|
|
42
|
-
Home
|
|
43
|
-
</Link>
|
|
37
|
+
<nav className="flex items-center gap-2 text-sm">
|
|
44
38
|
{signedIn ? (
|
|
45
|
-
<
|
|
46
|
-
Dashboard
|
|
47
|
-
</
|
|
39
|
+
<Button asChild size="sm">
|
|
40
|
+
<Link href="/dashboard">Dashboard</Link>
|
|
41
|
+
</Button>
|
|
48
42
|
) : (
|
|
49
43
|
<>
|
|
50
|
-
<
|
|
51
|
-
Sign in
|
|
52
|
-
</
|
|
53
|
-
<
|
|
54
|
-
href="/signup"
|
|
55
|
-
|
|
56
|
-
>
|
|
57
|
-
Sign up
|
|
58
|
-
</Link>
|
|
44
|
+
<Button asChild size="sm" variant="ghost">
|
|
45
|
+
<Link href="/login">Sign in</Link>
|
|
46
|
+
</Button>
|
|
47
|
+
<Button asChild size="sm">
|
|
48
|
+
<Link href="/signup">Get started</Link>
|
|
49
|
+
</Button>
|
|
59
50
|
</>
|
|
60
51
|
)}
|
|
61
52
|
</nav>
|
|
62
53
|
</div>
|
|
63
54
|
</header>
|
|
64
|
-
|
|
65
|
-
<
|
|
66
|
-
|
|
55
|
+
|
|
56
|
+
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-10">
|
|
57
|
+
{children}
|
|
58
|
+
</main>
|
|
59
|
+
|
|
60
|
+
<footer className="border-t">
|
|
61
|
+
<div className="mx-auto flex max-w-5xl flex-col items-center justify-between gap-2 px-4 py-6 text-xs text-muted-foreground sm:flex-row">
|
|
62
|
+
<span>© Acme, Inc.</span>
|
|
63
|
+
<span>
|
|
64
|
+
Built with{" "}
|
|
65
|
+
<a
|
|
66
|
+
href="https://pylonsync.com"
|
|
67
|
+
className="font-medium text-foreground hover:underline"
|
|
68
|
+
>
|
|
69
|
+
Pylon
|
|
70
|
+
</a>{" "}
|
|
71
|
+
· one binary, one port
|
|
72
|
+
</span>
|
|
73
|
+
</div>
|
|
67
74
|
</footer>
|
|
68
75
|
</body>
|
|
69
76
|
</html>
|
|
@@ -1,114 +1,212 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Link, type Metadata, type PageProps } from "@pylonsync/react";
|
|
3
3
|
import { Button } from "@/components/ui/button";
|
|
4
|
-
import {
|
|
5
|
-
Card,
|
|
6
|
-
CardContent,
|
|
7
|
-
CardDescription,
|
|
8
|
-
CardHeader,
|
|
9
|
-
CardTitle,
|
|
10
|
-
} from "@/components/ui/card";
|
|
11
4
|
|
|
12
|
-
// SEO metadata.
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// @pylonsync/react.
|
|
5
|
+
// SEO metadata. Exported `metadata` is rendered into <head> on the server, so
|
|
6
|
+
// this marketing page is fully indexable — view source and the copy is in the
|
|
7
|
+
// HTML. Swap "Acme" for your product throughout.
|
|
16
8
|
export const metadata: Metadata = {
|
|
17
|
-
title: "
|
|
9
|
+
title: "Acme — the workspace your team actually wants",
|
|
18
10
|
description:
|
|
19
|
-
"
|
|
11
|
+
"Acme is a collaborative workspace for fast-moving teams. Organize projects by team, collaborate in real time, and keep every tenant's data private. Built on Pylon.",
|
|
20
12
|
};
|
|
21
13
|
|
|
22
|
-
// `app/page.tsx` → `/`.
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
// hand-rolled interface.
|
|
28
|
-
export default function IndexPage({ auth }: PageProps) {
|
|
14
|
+
// `app/page.tsx` → `/`. A server-rendered marketing landing page. It reads
|
|
15
|
+
// `auth` (resolved from the session cookie during the render) so the call to
|
|
16
|
+
// action is right on the first byte — "Get started" for visitors, "Open
|
|
17
|
+
// dashboard" once you're signed in. No client fetch, no flash.
|
|
18
|
+
export default function LandingPage({ auth }: PageProps) {
|
|
29
19
|
const signedIn = Boolean(auth.user_id);
|
|
20
|
+
|
|
30
21
|
return (
|
|
31
|
-
<div className="space-y-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
22
|
+
<div className="space-y-24 pb-24">
|
|
23
|
+
{/* Hero */}
|
|
24
|
+
<section className="relative overflow-hidden">
|
|
25
|
+
{/* Soft gradient wash behind the hero. */}
|
|
26
|
+
<div
|
|
27
|
+
aria-hidden
|
|
28
|
+
className="pointer-events-none absolute -top-40 left-1/2 h-[32rem] w-[60rem] -translate-x-1/2 rounded-full bg-gradient-to-tr from-primary/15 via-primary/5 to-transparent blur-3xl"
|
|
29
|
+
/>
|
|
30
|
+
<div className="relative mx-auto max-w-3xl px-2 pt-16 text-center sm:pt-24">
|
|
31
|
+
<span className="inline-flex items-center gap-2 rounded-full border bg-background/60 px-3 py-1 text-xs font-medium text-muted-foreground backdrop-blur">
|
|
32
|
+
<span className="size-1.5 rounded-full bg-emerald-500" />
|
|
33
|
+
New · Real-time projects for every team
|
|
34
|
+
</span>
|
|
35
|
+
<h1 className="mt-6 text-balance text-5xl font-semibold tracking-tight sm:text-6xl">
|
|
36
|
+
The workspace your team{" "}
|
|
37
|
+
<span className="bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
|
|
38
|
+
actually wants
|
|
39
|
+
</span>
|
|
40
|
+
.
|
|
41
|
+
</h1>
|
|
42
|
+
<p className="mx-auto mt-5 max-w-xl text-balance text-lg text-muted-foreground">
|
|
43
|
+
Acme keeps your team's projects organized, in sync, and private
|
|
44
|
+
to your organization. Invite your teammates, switch between orgs, and
|
|
45
|
+
watch every change land live.
|
|
46
|
+
</p>
|
|
47
|
+
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
|
48
|
+
{signedIn ? (
|
|
49
|
+
<Button asChild size="lg">
|
|
50
|
+
<Link href="/dashboard">Open dashboard →</Link>
|
|
54
51
|
</Button>
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
52
|
+
) : (
|
|
53
|
+
<>
|
|
54
|
+
<Button asChild size="lg">
|
|
55
|
+
<Link href="/signup">Get started — it's free</Link>
|
|
56
|
+
</Button>
|
|
57
|
+
<Button asChild size="lg" variant="outline">
|
|
58
|
+
<Link href="/login">Sign in</Link>
|
|
59
|
+
</Button>
|
|
60
|
+
</>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
<p className="mt-4 text-xs text-muted-foreground">
|
|
64
|
+
No credit card required · Set up your first org in seconds.
|
|
65
|
+
</p>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{/* Product peek — a stylized dashboard mock so the hero has a subject. */}
|
|
69
|
+
<div className="relative mx-auto mt-14 max-w-4xl px-4">
|
|
70
|
+
<div className="rounded-xl border bg-card shadow-2xl shadow-primary/5">
|
|
71
|
+
<div className="flex items-center gap-1.5 border-b px-4 py-3">
|
|
72
|
+
<span className="size-3 rounded-full bg-red-400/70" />
|
|
73
|
+
<span className="size-3 rounded-full bg-yellow-400/70" />
|
|
74
|
+
<span className="size-3 rounded-full bg-green-400/70" />
|
|
75
|
+
<span className="ml-3 text-xs text-muted-foreground">
|
|
76
|
+
acme.app/dashboard
|
|
77
|
+
</span>
|
|
78
|
+
</div>
|
|
79
|
+
<div className="grid gap-4 p-5 sm:grid-cols-[1fr_1.4fr]">
|
|
80
|
+
<div className="space-y-2">
|
|
81
|
+
<div className="text-xs font-medium text-muted-foreground">
|
|
82
|
+
Organization
|
|
83
|
+
</div>
|
|
84
|
+
<div className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
|
|
85
|
+
<span className="flex size-6 items-center justify-center rounded bg-primary/10 text-xs font-semibold text-primary">
|
|
86
|
+
A
|
|
87
|
+
</span>
|
|
88
|
+
Acme Inc
|
|
89
|
+
</div>
|
|
90
|
+
<div className="pt-2 text-xs font-medium text-muted-foreground">
|
|
91
|
+
Members
|
|
92
|
+
</div>
|
|
93
|
+
{["you · owner", "jordan · admin", "sam · member"].map((m) => (
|
|
94
|
+
<div
|
|
95
|
+
key={m}
|
|
96
|
+
className="rounded-md border px-3 py-1.5 font-mono text-xs text-muted-foreground"
|
|
97
|
+
>
|
|
98
|
+
{m}
|
|
99
|
+
</div>
|
|
100
|
+
))}
|
|
101
|
+
</div>
|
|
102
|
+
<div className="space-y-2">
|
|
103
|
+
<div className="text-xs font-medium text-muted-foreground">
|
|
104
|
+
Projects
|
|
105
|
+
</div>
|
|
106
|
+
{["Website redesign", "Mobile app", "Q3 launch"].map((p, i) => (
|
|
107
|
+
<div
|
|
108
|
+
key={p}
|
|
109
|
+
className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
|
|
110
|
+
>
|
|
111
|
+
{p}
|
|
112
|
+
{i === 0 && (
|
|
113
|
+
<span className="rounded bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600">
|
|
114
|
+
live
|
|
115
|
+
</span>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
))}
|
|
119
|
+
<div className="rounded-md border border-dashed px-3 py-2 text-sm text-muted-foreground">
|
|
120
|
+
+ New project
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
60
125
|
</div>
|
|
61
126
|
</section>
|
|
62
127
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
</Feature>
|
|
76
|
-
<Feature title="Synced database">
|
|
77
|
-
Every <Code>entity()</Code> in <Code>app.ts</Code> gets a REST +
|
|
78
|
-
realtime API and a typed client. <Code>db.useQuery</Code> is live;{" "}
|
|
79
|
-
<Code>db.insert</Code> is optimistic.
|
|
80
|
-
</Feature>
|
|
128
|
+
{/* Logos / social proof */}
|
|
129
|
+
<section className="mx-auto max-w-3xl px-4 text-center">
|
|
130
|
+
<p className="text-xs font-medium uppercase tracking-widest text-muted-foreground">
|
|
131
|
+
Trusted by teams at
|
|
132
|
+
</p>
|
|
133
|
+
<div className="mt-4 flex flex-wrap items-center justify-center gap-x-10 gap-y-3 text-lg font-semibold text-muted-foreground/60">
|
|
134
|
+
<span>Globex</span>
|
|
135
|
+
<span>Initech</span>
|
|
136
|
+
<span>Hooli</span>
|
|
137
|
+
<span>Soylent</span>
|
|
138
|
+
<span>Stark</span>
|
|
139
|
+
</div>
|
|
81
140
|
</section>
|
|
82
141
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
142
|
+
{/* Features */}
|
|
143
|
+
<section className="mx-auto max-w-4xl px-4">
|
|
144
|
+
<div className="mx-auto max-w-2xl text-center">
|
|
145
|
+
<h2 className="text-3xl font-semibold tracking-tight">
|
|
146
|
+
Everything a growing team needs
|
|
147
|
+
</h2>
|
|
148
|
+
<p className="mt-3 text-muted-foreground">
|
|
149
|
+
Acme is multi-tenant from day one — every org is an isolated,
|
|
150
|
+
real-time workspace.
|
|
151
|
+
</p>
|
|
152
|
+
</div>
|
|
153
|
+
<div className="mt-12 grid gap-6 sm:grid-cols-3">
|
|
154
|
+
<Feature title="Organize by org" icon="▦">
|
|
155
|
+
Spin up an organization, invite your team, and switch between them in
|
|
156
|
+
a click. Each org's projects and members are completely private.
|
|
157
|
+
</Feature>
|
|
158
|
+
<Feature title="Real-time by default" icon="✦">
|
|
159
|
+
Changes sync instantly across everyone's screens — no refresh,
|
|
160
|
+
no stale data. Open two tabs and watch it happen.
|
|
161
|
+
</Feature>
|
|
162
|
+
<Feature title="Secure tenant isolation" icon="◆">
|
|
163
|
+
Access is enforced at the data layer. A member of one org can't
|
|
164
|
+
read or write another's rows — not by convention, by policy.
|
|
165
|
+
</Feature>
|
|
166
|
+
</div>
|
|
167
|
+
</section>
|
|
168
|
+
|
|
169
|
+
{/* CTA */}
|
|
170
|
+
<section className="mx-auto max-w-3xl px-4">
|
|
171
|
+
<div className="rounded-2xl border bg-gradient-to-br from-primary/10 to-transparent px-8 py-12 text-center">
|
|
172
|
+
<h2 className="text-3xl font-semibold tracking-tight">
|
|
173
|
+
Ready to get your team in sync?
|
|
174
|
+
</h2>
|
|
175
|
+
<p className="mx-auto mt-3 max-w-md text-muted-foreground">
|
|
176
|
+
Create your organization and invite your first teammate in under a
|
|
177
|
+
minute.
|
|
178
|
+
</p>
|
|
179
|
+
<div className="mt-6">
|
|
180
|
+
<Button asChild size="lg">
|
|
181
|
+
<Link href={signedIn ? "/dashboard" : "/signup"}>
|
|
182
|
+
{signedIn ? "Open dashboard →" : "Start for free →"}
|
|
183
|
+
</Link>
|
|
184
|
+
</Button>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
</section>
|
|
87
188
|
</div>
|
|
88
189
|
);
|
|
89
190
|
}
|
|
90
191
|
|
|
91
192
|
function Feature({
|
|
92
193
|
title,
|
|
194
|
+
icon,
|
|
93
195
|
children,
|
|
94
196
|
}: {
|
|
95
197
|
title: string;
|
|
198
|
+
icon: string;
|
|
96
199
|
children: React.ReactNode;
|
|
97
200
|
}) {
|
|
98
201
|
return (
|
|
99
|
-
<
|
|
100
|
-
<
|
|
101
|
-
|
|
102
|
-
</
|
|
103
|
-
<
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
</Card>
|
|
202
|
+
<div className="rounded-xl border bg-card p-5">
|
|
203
|
+
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
204
|
+
{icon}
|
|
205
|
+
</div>
|
|
206
|
+
<h3 className="mt-4 text-base font-semibold tracking-tight">{title}</h3>
|
|
207
|
+
<p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">
|
|
208
|
+
{children}
|
|
209
|
+
</p>
|
|
210
|
+
</div>
|
|
109
211
|
);
|
|
110
212
|
}
|
|
111
|
-
|
|
112
|
-
function Code({ children }: { children: React.ReactNode }) {
|
|
113
|
-
return <code className="rounded bg-muted px-1 text-xs">{children}</code>;
|
|
114
|
-
}
|
package/templates/ssr/app.ts
CHANGED
|
@@ -7,11 +7,9 @@ import {
|
|
|
7
7
|
discoverAppRoutes,
|
|
8
8
|
} from "@pylonsync/sdk";
|
|
9
9
|
|
|
10
|
-
// Accounts
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// "User" as the account table — `passwordHash` is server-only and never
|
|
14
|
-
// serialized to a client.
|
|
10
|
+
// Accounts — email/password is built in (the entity named "User" is the
|
|
11
|
+
// account table; passwordHash is server-only). Each user can belong to many
|
|
12
|
+
// organizations.
|
|
15
13
|
const User = entity(
|
|
16
14
|
"User",
|
|
17
15
|
{
|
|
@@ -26,40 +24,87 @@ const User = entity(
|
|
|
26
24
|
{ indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
|
|
27
25
|
);
|
|
28
26
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Organizations — multi-tenancy is a framework primitive. Declaring these
|
|
29
|
+
// three entities with the names + fields below lights up the built-in
|
|
30
|
+
// `/api/auth/orgs/*` routes (create/list orgs, members, invites) and
|
|
31
|
+
// `/api/auth/select-org` (switch your active tenant). The framework writes
|
|
32
|
+
// only the fields it manages; add your own (logo, plan, billingEmail…) freely.
|
|
33
|
+
// The `@pylonsync/client` `<OrganizationSwitcher>` drives all of this for you.
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
const Org = entity(
|
|
36
|
+
"Org",
|
|
36
37
|
{
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
createdAt: field.datetime().defaultNow(),
|
|
38
|
+
name: field.string(),
|
|
39
|
+
createdBy: field.id("User"),
|
|
40
|
+
createdAt: field.datetime(),
|
|
41
41
|
},
|
|
42
|
-
{ indexes: [{ name: "
|
|
42
|
+
{ indexes: [{ name: "by_created_by", fields: ["createdBy"], unique: false }] },
|
|
43
43
|
);
|
|
44
44
|
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
45
|
+
// User ↔ Org edge with a role. `select-org` checks this table before letting
|
|
46
|
+
// you switch tenants, so a client can't impersonate an org it doesn't belong
|
|
47
|
+
// to. role ∈ "owner" | "admin" | "member".
|
|
48
|
+
const OrgMember = entity(
|
|
49
|
+
"OrgMember",
|
|
50
|
+
{
|
|
51
|
+
orgId: field.id("Org"),
|
|
52
|
+
userId: field.id("User"),
|
|
53
|
+
role: field.string(),
|
|
54
|
+
joinedAt: field.datetime(),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
indexes: [
|
|
58
|
+
{ name: "by_org_user", fields: ["orgId", "userId"], unique: true },
|
|
59
|
+
{ name: "by_user", fields: ["userId"], unique: false },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// Pending invite. The framework's /api/auth/orgs/:id/invites endpoints write
|
|
65
|
+
// these (tokenHash is server-only — the raw token only ever goes to the
|
|
66
|
+
// invitee). accepted* are filled in when the invite is redeemed.
|
|
67
|
+
const OrgInvite = entity(
|
|
68
|
+
"OrgInvite",
|
|
69
|
+
{
|
|
70
|
+
orgId: field.id("Org"),
|
|
71
|
+
email: field.string(),
|
|
72
|
+
role: field.string(),
|
|
73
|
+
invitedBy: field.id("User"),
|
|
74
|
+
tokenHash: field.string().serverOnly(),
|
|
75
|
+
tokenPrefix: field.string(),
|
|
76
|
+
createdAt: field.datetime(),
|
|
77
|
+
expiresAt: field.datetime(),
|
|
78
|
+
acceptedAt: field.datetime().optional(),
|
|
79
|
+
acceptedByUserId: field.id("User").optional(),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
indexes: [
|
|
83
|
+
{ name: "by_org", fields: ["orgId"], unique: false },
|
|
84
|
+
{ name: "by_email_org", fields: ["email", "orgId"], unique: false },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Your app's data — one tenant-scoped resource. `orgId` carries the tenant,
|
|
91
|
+
// and the policy scopes every read AND write to your ACTIVE org
|
|
92
|
+
// (`auth.tenantId`, set by select-org). Switch orgs in the UI and the project
|
|
93
|
+
// list changes — clients literally cannot read or write another tenant's rows.
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
const Project = entity(
|
|
96
|
+
"Project",
|
|
97
|
+
{
|
|
98
|
+
orgId: field.id("Org"),
|
|
99
|
+
name: field.string(),
|
|
100
|
+
createdAt: field.datetime().defaultNow(),
|
|
101
|
+
},
|
|
102
|
+
{ indexes: [{ name: "by_org", fields: ["orgId"], unique: false }] },
|
|
103
|
+
);
|
|
57
104
|
|
|
58
|
-
// User rows
|
|
59
|
-
// read your display name). The auth subsystem owns writes — registration and
|
|
60
|
-
// login go through /api/auth/password/*, never the entity API.
|
|
105
|
+
// User rows: read your own; the auth subsystem owns writes.
|
|
61
106
|
const userPolicy = policy({
|
|
62
|
-
name: "
|
|
107
|
+
name: "user_self",
|
|
63
108
|
entity: "User",
|
|
64
109
|
allowRead: "auth.userId == data.id",
|
|
65
110
|
allowInsert: "false",
|
|
@@ -67,28 +112,68 @@ const userPolicy = policy({
|
|
|
67
112
|
allowDelete: "false",
|
|
68
113
|
});
|
|
69
114
|
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
115
|
+
// Org / OrgMember / OrgInvite are managed by the framework's /api/auth/orgs
|
|
116
|
+
// routes (which bypass these policies via the OrgStore). Clients reach them
|
|
117
|
+
// through the `@pylonsync/client` org helpers, not the entity API — so deny
|
|
118
|
+
// direct writes, and scope reads to your own membership / active org.
|
|
119
|
+
const orgPolicy = policy({
|
|
120
|
+
name: "org_access",
|
|
121
|
+
entity: "Org",
|
|
122
|
+
allowRead: "auth.tenantId == data.id",
|
|
123
|
+
allowInsert: "false",
|
|
124
|
+
allowUpdate: "false",
|
|
125
|
+
allowDelete: "false",
|
|
126
|
+
});
|
|
127
|
+
const orgMemberPolicy = policy({
|
|
128
|
+
name: "org_member_access",
|
|
129
|
+
entity: "OrgMember",
|
|
130
|
+
allowRead: "auth.userId == data.userId || auth.tenantId == data.orgId",
|
|
131
|
+
allowInsert: "false",
|
|
132
|
+
allowUpdate: "false",
|
|
133
|
+
allowDelete: "false",
|
|
134
|
+
});
|
|
135
|
+
const orgInvitePolicy = policy({
|
|
136
|
+
name: "org_invite_access",
|
|
137
|
+
entity: "OrgInvite",
|
|
138
|
+
allowRead: "auth.tenantId == data.orgId",
|
|
139
|
+
allowInsert: "false",
|
|
140
|
+
allowUpdate: "false",
|
|
141
|
+
allowDelete: "false",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Projects are scoped to your ACTIVE tenant. `auth.tenantId == data.orgId`
|
|
145
|
+
// gates read AND write — and because orgId is client-supplied at insert time
|
|
146
|
+
// (not stamped later), checking it here means you can only create a project in
|
|
147
|
+
// the org you've selected. Switch orgs → a different project list.
|
|
148
|
+
const projectPolicy = policy({
|
|
149
|
+
name: "project_tenant",
|
|
150
|
+
entity: "Project",
|
|
151
|
+
allowRead: "auth.tenantId == data.orgId",
|
|
152
|
+
allowInsert: "auth.tenantId == data.orgId",
|
|
153
|
+
allowUpdate: "auth.tenantId == data.orgId",
|
|
154
|
+
allowDelete: "auth.tenantId == data.orgId",
|
|
155
|
+
});
|
|
156
|
+
|
|
74
157
|
const manifest = buildManifest({
|
|
75
158
|
name: "__APP_NAME__",
|
|
76
159
|
version: "0.1.0",
|
|
77
|
-
entities: [User,
|
|
160
|
+
entities: [User, Org, OrgMember, OrgInvite, Project],
|
|
78
161
|
queries: [],
|
|
79
162
|
actions: [],
|
|
80
|
-
policies: [
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
163
|
+
policies: [
|
|
164
|
+
userPolicy,
|
|
165
|
+
orgPolicy,
|
|
166
|
+
orgMemberPolicy,
|
|
167
|
+
orgInvitePolicy,
|
|
168
|
+
projectPolicy,
|
|
169
|
+
],
|
|
170
|
+
// Email/password is on by default against the User entity. The org entities
|
|
171
|
+
// above are named with the framework defaults (Org / OrgMember / OrgInvite),
|
|
172
|
+
// so `/api/auth/orgs/*` + `/api/auth/select-org` work with no extra config.
|
|
85
173
|
auth: auth(),
|
|
86
|
-
// File-based routing: `discoverAppRoutes()` walks `app/**/page.tsx` and
|
|
87
|
-
// emits one route per page. Drop `app/about/page.tsx` to add `/about`.
|
|
88
174
|
routes: await discoverAppRoutes(),
|
|
89
175
|
});
|
|
90
176
|
|
|
91
|
-
// Emit canonical manifest JSON to stdout for `pylon codegen`.
|
|
92
177
|
console.log(JSON.stringify(manifest, null, 2));
|
|
93
178
|
|
|
94
179
|
export default manifest;
|