@pylonsync/create-pylon 0.3.269 → 0.3.270

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.
Files changed (74) hide show
  1. package/bin/create-pylon.js +11 -9
  2. package/package.json +1 -1
  3. package/templates/b2b/app/layout.tsx +1 -1
  4. package/templates/b2b/app/page.tsx +2 -2
  5. package/templates/b2b/tsconfig.json +1 -1
  6. package/templates/barebones/app/page.tsx +1 -1
  7. package/templates/barebones/tsconfig.json +1 -1
  8. package/templates/chat/app/page.tsx +1 -1
  9. package/templates/chat/tsconfig.json +1 -1
  10. package/templates/consumer/app/page.tsx +1 -1
  11. package/templates/consumer/tsconfig.json +1 -1
  12. package/templates/default/.env.example +19 -0
  13. package/templates/{ssr → default}/README.md +20 -6
  14. package/templates/default/app/auth-form.tsx +218 -0
  15. package/templates/default/app/auth-shell.tsx +76 -0
  16. package/templates/default/app/company/[slug]/page.tsx +28 -0
  17. package/templates/default/app/compare/[slug]/page.tsx +27 -0
  18. package/templates/default/app/dashboard/billing/page.tsx +49 -0
  19. package/templates/default/app/dashboard/dashboard-client.tsx +832 -0
  20. package/templates/default/app/dashboard/members/page.tsx +37 -0
  21. package/templates/default/app/dashboard/page.tsx +64 -0
  22. package/templates/default/app/dashboard/projects/page.tsx +37 -0
  23. package/templates/default/app/dashboard/settings/page.tsx +45 -0
  24. package/templates/{ssr → default}/app/globals.css +14 -0
  25. package/templates/default/app/layout.tsx +466 -0
  26. package/templates/default/app/login/page.tsx +27 -0
  27. package/templates/default/app/onboarding/onboarding-client.tsx +261 -0
  28. package/templates/default/app/onboarding/page.tsx +29 -0
  29. package/templates/default/app/page.tsx +653 -0
  30. package/templates/default/app/products/[slug]/page.tsx +134 -0
  31. package/templates/default/app/resources/[slug]/page.tsx +28 -0
  32. package/templates/default/app/signup/page.tsx +24 -0
  33. package/templates/default/app/sitemap.ts +40 -0
  34. package/templates/default/app/solutions/[slug]/page.tsx +28 -0
  35. package/templates/{ssr → default}/app.ts +17 -2
  36. package/templates/default/components/dashboard-shell.tsx +150 -0
  37. package/templates/default/components/marketing.tsx +370 -0
  38. package/templates/default/functions/_pylonStripeFindActiveSubForReference.ts +3 -0
  39. package/templates/default/functions/_pylonStripeFindByCustomerId.ts +3 -0
  40. package/templates/default/functions/_pylonStripeGetCustomerHolder.ts +3 -0
  41. package/templates/default/functions/_pylonStripeListSubsForReference.ts +3 -0
  42. package/templates/default/functions/_pylonStripeOrgMembership.ts +3 -0
  43. package/templates/default/functions/_pylonStripeSetCustomerId.ts +3 -0
  44. package/templates/default/functions/_pylonStripeUpsertSubscription.ts +3 -0
  45. package/templates/default/functions/cancelSubscription.ts +3 -0
  46. package/templates/default/functions/createBillingPortalSession.ts +3 -0
  47. package/templates/default/functions/createCheckoutSession.ts +3 -0
  48. package/templates/default/functions/restoreSubscription.ts +3 -0
  49. package/templates/default/functions/stripeWebhook.ts +3 -0
  50. package/templates/default/lib/billing.ts +46 -0
  51. package/templates/default/lib/products.ts +122 -0
  52. package/templates/default/lib/site.ts +261 -0
  53. package/templates/{ssr → default}/package.json +2 -0
  54. package/templates/{ssr → default}/tsconfig.json +2 -2
  55. package/templates/todo/app/page.tsx +1 -1
  56. package/templates/todo/tsconfig.json +1 -1
  57. package/templates/ssr/app/auth-form.tsx +0 -142
  58. package/templates/ssr/app/dashboard/dashboard-client.tsx +0 -192
  59. package/templates/ssr/app/dashboard/page.tsx +0 -26
  60. package/templates/ssr/app/layout.tsx +0 -78
  61. package/templates/ssr/app/login/page.tsx +0 -47
  62. package/templates/ssr/app/page.tsx +0 -212
  63. package/templates/ssr/app/signup/page.tsx +0 -44
  64. package/templates/ssr/app/sitemap.ts +0 -27
  65. package/templates/ssr/functions/_keep.ts +0 -13
  66. /package/templates/{ssr → default}/AGENTS.md +0 -0
  67. /package/templates/{ssr → default}/app/error.tsx +0 -0
  68. /package/templates/{ssr → default}/app/not-found.tsx +0 -0
  69. /package/templates/{ssr → default}/app/robots.ts +0 -0
  70. /package/templates/{ssr → default}/components/ui/button.tsx +0 -0
  71. /package/templates/{ssr → default}/components/ui/card.tsx +0 -0
  72. /package/templates/{ssr → default}/components.json +0 -0
  73. /package/templates/{ssr → default}/gitignore +0 -0
  74. /package/templates/{ssr → default}/lib/utils.ts +0 -0
@@ -0,0 +1,261 @@
1
+ // Content for the rest of the marketing site — solutions, resources, company,
2
+ // and comparison pages. Each collection drives a dynamic route AND the footer
3
+ // columns, so the links and the pages can never drift. Fictional demo copy —
4
+ // swap it for your own.
5
+
6
+ export type ContentSection = { title: string; body: string };
7
+
8
+ export type SitePage = {
9
+ slug: string;
10
+ navLabel: string; // label in nav/footer
11
+ eyebrow: string;
12
+ title: string; // hero headline
13
+ summary: string;
14
+ sections: ContentSection[];
15
+ };
16
+
17
+ export const SOLUTIONS: SitePage[] = [
18
+ {
19
+ slug: "startups",
20
+ navLabel: "For startups",
21
+ eyebrow: "Solutions",
22
+ title: "Move fast without losing the thread.",
23
+ summary:
24
+ "Keep a small team aligned as everything changes weekly. Acme gives you one place to plan, build, and ship before the next pivot.",
25
+ sections: [
26
+ { title: "One tool, not ten", body: "Projects, tasks, and docs in one place, so you are not paying for or stitching together five apps." },
27
+ { title: "Set up in minutes", body: "No admin overhead. Invite the team and start working the same day." },
28
+ { title: "Grows with you", body: "The same workspace works at five people and at fifty." },
29
+ ],
30
+ },
31
+ {
32
+ slug: "agencies",
33
+ navLabel: "For agencies",
34
+ eyebrow: "Solutions",
35
+ title: "Run every client like clockwork.",
36
+ summary:
37
+ "Give each client their own space, keep the work organized, and show progress without a status meeting.",
38
+ sections: [
39
+ { title: "A space per client", body: "Separate workspaces keep every engagement tidy and private." },
40
+ { title: "Shareable views", body: "Send clients a read-only view of exactly what is in flight." },
41
+ { title: "Reusable templates", body: "Start every new engagement from a proven playbook." },
42
+ ],
43
+ },
44
+ {
45
+ slug: "enterprise",
46
+ navLabel: "For enterprise",
47
+ eyebrow: "Solutions",
48
+ title: "Scale without the chaos.",
49
+ summary:
50
+ "Bring hundreds of people into one system of record, with the controls and visibility a larger org needs.",
51
+ sections: [
52
+ { title: "SSO and roles", body: "Single sign-on and granular roles keep access where it belongs." },
53
+ { title: "Audit log", body: "A complete record of who changed what, and when." },
54
+ { title: "Rollups", body: "See progress across teams and departments in one view." },
55
+ ],
56
+ },
57
+ {
58
+ slug: "teams",
59
+ navLabel: "For teams",
60
+ eyebrow: "Solutions",
61
+ title: "Built for how your team works.",
62
+ summary:
63
+ "Whether you build, design, market, or support, Acme adapts to your process instead of forcing a new one.",
64
+ sections: [
65
+ { title: "Your workflow", body: "Custom statuses and fields match the way your team already works." },
66
+ { title: "Cross-team work", body: "Hand work between teams without it falling through a crack." },
67
+ { title: "Less status-chasing", body: "Everyone sees the same live picture, so updates write themselves." },
68
+ ],
69
+ },
70
+ ];
71
+
72
+ export const RESOURCES: SitePage[] = [
73
+ {
74
+ slug: "docs",
75
+ navLabel: "Docs",
76
+ eyebrow: "Resources",
77
+ title: "Documentation.",
78
+ summary: "Everything you need to set up Acme and get your team productive.",
79
+ sections: [
80
+ { title: "Getting started", body: "Create a workspace, invite your team, and ship your first project." },
81
+ { title: "Guides", body: "Deep dives on projects, tasks, docs, automations, and analytics." },
82
+ { title: "API", body: "Build on the typed Acme API and webhooks." },
83
+ ],
84
+ },
85
+ {
86
+ slug: "guides",
87
+ navLabel: "Guides",
88
+ eyebrow: "Resources",
89
+ title: "Guides and playbooks.",
90
+ summary: "Practical walkthroughs for getting the most out of Acme.",
91
+ sections: [
92
+ { title: "Run a sprint", body: "Plan, track, and review a two-week cycle in Acme." },
93
+ { title: "Automate intake", body: "Route incoming work to the right team automatically." },
94
+ { title: "Report to leadership", body: "Build a dashboard that answers the questions you get asked." },
95
+ ],
96
+ },
97
+ {
98
+ slug: "changelog",
99
+ navLabel: "Changelog",
100
+ eyebrow: "Resources",
101
+ title: "What's new.",
102
+ summary: "Every improvement we ship, in one place.",
103
+ sections: [
104
+ { title: "This week", body: "Faster search, a redesigned task list, and new automation triggers." },
105
+ { title: "Last week", body: "Timeline view for projects and CSV export for analytics." },
106
+ { title: "Earlier", body: "Webhooks, custom fields, and version history for docs." },
107
+ ],
108
+ },
109
+ {
110
+ slug: "api",
111
+ navLabel: "API reference",
112
+ eyebrow: "Resources",
113
+ title: "API reference.",
114
+ summary: "A typed REST API and webhooks for everything in Acme.",
115
+ sections: [
116
+ { title: "Authentication", body: "API keys scoped to a workspace, revocable at any time." },
117
+ { title: "Resources", body: "Projects, tasks, docs, and automations, all over the same API." },
118
+ { title: "Webhooks", body: "Subscribe to events and react to changes in real time." },
119
+ ],
120
+ },
121
+ {
122
+ slug: "status",
123
+ navLabel: "Status",
124
+ eyebrow: "Resources",
125
+ title: "System status.",
126
+ summary: "Live status for every Acme service.",
127
+ sections: [
128
+ { title: "API", body: "Operational — 99.99% over the last 90 days." },
129
+ { title: "Web app", body: "Operational — no incidents this week." },
130
+ { title: "Webhooks", body: "Operational — delivering within seconds." },
131
+ ],
132
+ },
133
+ ];
134
+
135
+ export const COMPANY: SitePage[] = [
136
+ {
137
+ slug: "about",
138
+ navLabel: "About",
139
+ eyebrow: "Company",
140
+ title: "About Acme.",
141
+ summary: "We build the workspace we always wanted: fast, focused, and a pleasure to use.",
142
+ sections: [
143
+ { title: "Our mission", body: "Help teams do their best work without fighting their tools." },
144
+ { title: "How we work", body: "Small team, weekly releases, every decision close to the user." },
145
+ { title: "Where we are", body: "Remote-first, with people across a dozen time zones." },
146
+ ],
147
+ },
148
+ {
149
+ slug: "blog",
150
+ navLabel: "Blog",
151
+ eyebrow: "Company",
152
+ title: "The Acme blog.",
153
+ summary: "Notes on building Acme, and on building product in general.",
154
+ sections: [
155
+ { title: "Why one tool beats ten", body: "The hidden cost of stitching your stack together." },
156
+ { title: "Shipping weekly", body: "How a small team keeps a steady release cadence." },
157
+ { title: "Designing for focus", body: "The principles behind the Acme interface." },
158
+ ],
159
+ },
160
+ {
161
+ slug: "careers",
162
+ navLabel: "Careers",
163
+ eyebrow: "Company",
164
+ title: "Work at Acme.",
165
+ summary: "We are a small team that ships a lot. If that sounds good, come build with us.",
166
+ sections: [
167
+ { title: "Engineering", body: "Full-stack engineers who care about craft and speed." },
168
+ { title: "Design", body: "Product designers who sweat the details." },
169
+ { title: "Support", body: "People who love helping customers succeed." },
170
+ ],
171
+ },
172
+ {
173
+ slug: "contact",
174
+ navLabel: "Contact",
175
+ eyebrow: "Company",
176
+ title: "Get in touch.",
177
+ summary: "Questions, feedback, or just want to say hi? We would love to hear from you.",
178
+ sections: [
179
+ { title: "Sales", body: "Talk through whether Acme is a fit for your team." },
180
+ { title: "Support", body: "Get help from a human, usually within a few hours." },
181
+ { title: "Press", body: "Logos, screenshots, and company facts for the press." },
182
+ ],
183
+ },
184
+ {
185
+ slug: "privacy",
186
+ navLabel: "Privacy",
187
+ eyebrow: "Company",
188
+ title: "Privacy.",
189
+ summary: "How Acme handles your data, in plain language.",
190
+ sections: [
191
+ { title: "What we collect", body: "Only what we need to run the product and support you." },
192
+ { title: "How we use it", body: "To operate Acme — never sold, never rented." },
193
+ { title: "Your control", body: "Export or delete your data at any time." },
194
+ ],
195
+ },
196
+ ];
197
+
198
+ export type Comparison = {
199
+ slug: string;
200
+ navLabel: string;
201
+ competitor: string;
202
+ title: string;
203
+ summary: string;
204
+ rows: { dim: string; acme: string; them: string }[];
205
+ };
206
+
207
+ // Generic, made-up competitors so the template ships no real brand names.
208
+ export const COMPARISONS: Comparison[] = [
209
+ {
210
+ slug: "beacon",
211
+ navLabel: "Acme vs Beacon",
212
+ competitor: "Beacon",
213
+ title: "Acme vs Beacon",
214
+ summary:
215
+ "Beacon is a capable tool, but it splits projects, docs, and automation across separate products. Acme brings them into one fast workspace.",
216
+ rows: [
217
+ { dim: "Projects, tasks, and docs", acme: "In one workspace", them: "Separate products" },
218
+ { dim: "Real-time sync", acme: "Built in", them: "Add-on" },
219
+ { dim: "Automations", acme: "Included", them: "Higher tier" },
220
+ { dim: "Typed API", acme: "Yes", them: "Partial" },
221
+ { dim: "Setup time", acme: "Minutes", them: "Hours" },
222
+ ],
223
+ },
224
+ {
225
+ slug: "orbit",
226
+ navLabel: "Acme vs Orbit",
227
+ competitor: "Orbit",
228
+ title: "Acme vs Orbit",
229
+ summary:
230
+ "Orbit is flexible but slow to set up and heavy to run. Acme gives you the same power with a fraction of the overhead.",
231
+ rows: [
232
+ { dim: "Time to first project", acme: "Same day", them: "Onboarding required" },
233
+ { dim: "Speed", acme: "Instant, real-time", them: "Page reloads" },
234
+ { dim: "Per-seat pricing", acme: "No surprises", them: "Adds up fast" },
235
+ { dim: "Analytics", acme: "Built in", them: "Separate tool" },
236
+ { dim: "Learning curve", acme: "Gentle", them: "Steep" },
237
+ ],
238
+ },
239
+ {
240
+ slug: "tempo",
241
+ navLabel: "Acme vs Tempo",
242
+ competitor: "Tempo",
243
+ title: "Acme vs Tempo",
244
+ summary:
245
+ "Tempo is built for managers; Acme is built for the whole team. Everyone gets a fast, shared view of the work.",
246
+ rows: [
247
+ { dim: "Designed for", acme: "The whole team", them: "Managers" },
248
+ { dim: "Daily driver", acme: "Yes", them: "Reporting layer" },
249
+ { dim: "Docs included", acme: "Yes", them: "No" },
250
+ { dim: "Automations", acme: "Included", them: "Limited" },
251
+ { dim: "Self-serve", acme: "Yes", them: "Sales-led" },
252
+ ],
253
+ },
254
+ ];
255
+
256
+ export function bySlug<T extends { slug: string }>(
257
+ list: T[],
258
+ slug: string,
259
+ ): T | undefined {
260
+ return list.find((x) => x.slug === slug);
261
+ }
@@ -13,6 +13,7 @@
13
13
  "@pylonsync/sdk": "^__PYLON_VERSION__",
14
14
  "@pylonsync/functions": "^__PYLON_VERSION__",
15
15
  "@pylonsync/client": "^__PYLON_VERSION__",
16
+ "@pylonsync/stripe": "^__PYLON_VERSION__",
16
17
  "react": "^19.0.0",
17
18
  "react-dom": "^19.0.0",
18
19
  "tailwindcss": "^4.3.0",
@@ -26,6 +27,7 @@
26
27
  },
27
28
  "devDependencies": {
28
29
  "@pylonsync/cli": "^__PYLON_VERSION__",
30
+ "@types/node": "^22.0.0",
29
31
  "@types/react": "^19.0.0",
30
32
  "@types/react-dom": "^19.0.0",
31
33
  "typescript": "^5.6.0"
@@ -8,11 +8,11 @@
8
8
  "strict": true,
9
9
  "skipLibCheck": true,
10
10
  "lib": ["ES2022", "DOM"],
11
- "types": ["react", "react-dom"],
11
+ "types": ["react", "react-dom", "node"],
12
12
  "baseUrl": ".",
13
13
  "paths": {
14
14
  "@/*": ["./*"]
15
15
  }
16
16
  },
17
- "include": ["app.ts", "app/**/*", "components/**/*", "lib/**/*"]
17
+ "include": ["app.ts", "app/**/*", "components/**/*", "lib/**/*", "functions/**/*"]
18
18
  }
@@ -9,7 +9,7 @@ import { TodoApp } from "./todo-app";
9
9
  export const metadata: Metadata = {
10
10
  title: "__APP_NAME__ — a live Pylon todo",
11
11
  description:
12
- "A server-rendered todo list with live, optimistic, per-user sync — one binary, one port. Open two tabs and watch them stay in sync.",
12
+ "A server-rendered todo list with live, optimistic, per-user sync. Open two tabs and watch them stay in sync.",
13
13
  };
14
14
 
15
15
  // `app/page.tsx` → `/`. The heading and intro are server-rendered (view
@@ -14,5 +14,5 @@
14
14
  "@/*": ["./*"]
15
15
  }
16
16
  },
17
- "include": ["app.ts", "app/**/*", "components/**/*", "lib/**/*"]
17
+ "include": ["app.ts", "app/**/*", "components/**/*", "lib/**/*", "functions/**/*"]
18
18
  }
@@ -1,142 +0,0 @@
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
- }
@@ -1,192 +0,0 @@
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&apos;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
- }
@@ -1,26 +0,0 @@
1
- import React from "react";
2
- import { type Metadata, type PageProps } from "@pylonsync/react";
3
- import { Workspace } from "./dashboard-client";
4
-
5
- export const metadata: Metadata = {
6
- title: "Dashboard — Acme",
7
- robots: "noindex",
8
- };
9
-
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
- }
20
- return (
21
- <div className="space-y-6">
22
- <h1 className="text-2xl font-semibold tracking-tight">Workspace</h1>
23
- <Workspace />
24
- </div>
25
- );
26
- }