@pylonsync/create-pylon 0.3.268 → 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.
- package/bin/create-pylon.js +11 -9
- package/package.json +1 -1
- package/templates/b2b/app/layout.tsx +1 -1
- package/templates/b2b/app/page.tsx +2 -2
- package/templates/b2b/tsconfig.json +1 -1
- package/templates/barebones/app/page.tsx +1 -1
- package/templates/barebones/tsconfig.json +1 -1
- package/templates/chat/app/page.tsx +1 -1
- package/templates/chat/tsconfig.json +1 -1
- package/templates/consumer/app/page.tsx +1 -1
- package/templates/consumer/tsconfig.json +1 -1
- package/templates/default/.env.example +19 -0
- package/templates/default/README.md +85 -0
- package/templates/default/app/auth-form.tsx +218 -0
- package/templates/default/app/auth-shell.tsx +76 -0
- package/templates/default/app/company/[slug]/page.tsx +28 -0
- package/templates/default/app/compare/[slug]/page.tsx +27 -0
- package/templates/default/app/dashboard/billing/page.tsx +49 -0
- package/templates/default/app/dashboard/dashboard-client.tsx +832 -0
- package/templates/default/app/dashboard/members/page.tsx +37 -0
- package/templates/default/app/dashboard/page.tsx +64 -0
- package/templates/default/app/dashboard/projects/page.tsx +37 -0
- package/templates/default/app/dashboard/settings/page.tsx +45 -0
- package/templates/{ssr → default}/app/globals.css +14 -0
- package/templates/default/app/layout.tsx +466 -0
- package/templates/default/app/login/page.tsx +27 -0
- package/templates/default/app/onboarding/onboarding-client.tsx +261 -0
- package/templates/default/app/onboarding/page.tsx +29 -0
- package/templates/default/app/page.tsx +653 -0
- package/templates/default/app/products/[slug]/page.tsx +134 -0
- package/templates/default/app/resources/[slug]/page.tsx +28 -0
- package/templates/default/app/signup/page.tsx +24 -0
- package/templates/default/app/sitemap.ts +40 -0
- package/templates/default/app/solutions/[slug]/page.tsx +28 -0
- package/templates/default/app.ts +194 -0
- package/templates/default/components/dashboard-shell.tsx +150 -0
- package/templates/default/components/marketing.tsx +370 -0
- package/templates/default/functions/_pylonStripeFindActiveSubForReference.ts +3 -0
- package/templates/default/functions/_pylonStripeFindByCustomerId.ts +3 -0
- package/templates/default/functions/_pylonStripeGetCustomerHolder.ts +3 -0
- package/templates/default/functions/_pylonStripeListSubsForReference.ts +3 -0
- package/templates/default/functions/_pylonStripeOrgMembership.ts +3 -0
- package/templates/default/functions/_pylonStripeSetCustomerId.ts +3 -0
- package/templates/default/functions/_pylonStripeUpsertSubscription.ts +3 -0
- package/templates/default/functions/cancelSubscription.ts +3 -0
- package/templates/default/functions/createBillingPortalSession.ts +3 -0
- package/templates/default/functions/createCheckoutSession.ts +3 -0
- package/templates/default/functions/restoreSubscription.ts +3 -0
- package/templates/default/functions/stripeWebhook.ts +3 -0
- package/templates/default/lib/billing.ts +46 -0
- package/templates/default/lib/products.ts +122 -0
- package/templates/default/lib/site.ts +261 -0
- package/templates/{ssr → default}/package.json +2 -0
- package/templates/{ssr → default}/tsconfig.json +2 -2
- package/templates/todo/app/page.tsx +1 -1
- package/templates/todo/tsconfig.json +1 -1
- package/templates/ssr/README.md +0 -56
- package/templates/ssr/app/auth-form.tsx +0 -142
- package/templates/ssr/app/dashboard/dashboard-client.tsx +0 -116
- package/templates/ssr/app/dashboard/page.tsx +0 -70
- package/templates/ssr/app/layout.tsx +0 -71
- package/templates/ssr/app/login/page.tsx +0 -47
- package/templates/ssr/app/page.tsx +0 -114
- package/templates/ssr/app/signup/page.tsx +0 -44
- package/templates/ssr/app/sitemap.ts +0 -27
- package/templates/ssr/app.ts +0 -94
- package/templates/ssr/functions/_keep.ts +0 -13
- /package/templates/{ssr → default}/AGENTS.md +0 -0
- /package/templates/{ssr → default}/app/error.tsx +0 -0
- /package/templates/{ssr → default}/app/not-found.tsx +0 -0
- /package/templates/{ssr → default}/app/robots.ts +0 -0
- /package/templates/{ssr → default}/components/ui/button.tsx +0 -0
- /package/templates/{ssr → default}/components/ui/card.tsx +0 -0
- /package/templates/{ssr → default}/components.json +0 -0
- /package/templates/{ssr → default}/gitignore +0 -0
- /package/templates/{ssr → default}/lib/utils.ts +0 -0
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from "react";
|
|
4
|
+
import { db, callFn } from "@pylonsync/react";
|
|
5
|
+
import {
|
|
6
|
+
createInvite,
|
|
7
|
+
deleteOrg,
|
|
8
|
+
listInvites,
|
|
9
|
+
listOrgMembers,
|
|
10
|
+
renameOrg,
|
|
11
|
+
revokeInvite,
|
|
12
|
+
useAuth,
|
|
13
|
+
type OrgMember,
|
|
14
|
+
type PendingInvite,
|
|
15
|
+
} from "@pylonsync/client";
|
|
16
|
+
import { Button } from "@/components/ui/button";
|
|
17
|
+
|
|
18
|
+
export interface Project {
|
|
19
|
+
id: string;
|
|
20
|
+
orgId: string;
|
|
21
|
+
name: string;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// OrgMember rows as returned by `serverData.list("OrgMember")` (the entity
|
|
26
|
+
// shape — camelCase fields). The read policy returns the caller's memberships
|
|
27
|
+
// across ALL their orgs plus everyone in the active org, so consumers must
|
|
28
|
+
// filter by `orgId === tenantId` to count just the active workspace.
|
|
29
|
+
export interface OrgMemberRow {
|
|
30
|
+
id: string;
|
|
31
|
+
orgId: string;
|
|
32
|
+
userId: string;
|
|
33
|
+
role: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Every view receives its data from the SERVER (resolved via `serverData` +
|
|
37
|
+
// React 19 `use()` in the page) and the active org as `tenantId` (from
|
|
38
|
+
// `auth.tenant_id`). So the first client paint already has the right state —
|
|
39
|
+
// no `useAuth()`/fetch round-trip, no empty-state flash.
|
|
40
|
+
|
|
41
|
+
function NoOrg() {
|
|
42
|
+
return (
|
|
43
|
+
<div className="rounded-xl border border-dashed border-zinc-300 px-6 py-12 text-center">
|
|
44
|
+
<p className="text-sm text-zinc-500">
|
|
45
|
+
You're not in a workspace yet. Each one is an isolated tenant — its
|
|
46
|
+
projects and members are private to it.
|
|
47
|
+
</p>
|
|
48
|
+
<a
|
|
49
|
+
href="/onboarding"
|
|
50
|
+
className="mt-4 inline-flex h-9 items-center rounded-lg bg-zinc-900 px-4 text-[13px] font-medium text-white transition-colors hover:bg-zinc-700"
|
|
51
|
+
>
|
|
52
|
+
Set up your workspace
|
|
53
|
+
</a>
|
|
54
|
+
<p className="mt-3 text-xs text-zinc-400">
|
|
55
|
+
…or pick one from the switcher in the sidebar.
|
|
56
|
+
</p>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function Card({
|
|
62
|
+
title,
|
|
63
|
+
action,
|
|
64
|
+
children,
|
|
65
|
+
}: {
|
|
66
|
+
title: string;
|
|
67
|
+
action?: React.ReactNode;
|
|
68
|
+
children: React.ReactNode;
|
|
69
|
+
}) {
|
|
70
|
+
return (
|
|
71
|
+
<div className="rounded-xl border border-zinc-200 bg-white p-5">
|
|
72
|
+
<div className="flex items-center justify-between">
|
|
73
|
+
<h2 className="text-sm font-semibold text-zinc-900">{title}</h2>
|
|
74
|
+
{action}
|
|
75
|
+
</div>
|
|
76
|
+
<div className="mt-3">{children}</div>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
|
|
82
|
+
return (
|
|
83
|
+
<div className="rounded-xl border border-zinc-200 bg-white p-4">
|
|
84
|
+
<div className="text-[11px] font-medium uppercase tracking-wide text-zinc-400">
|
|
85
|
+
{label}
|
|
86
|
+
</div>
|
|
87
|
+
<div className="mt-1 text-2xl font-semibold text-zinc-900">{value}</div>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const inputCls =
|
|
93
|
+
"h-9 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-zinc-900 focus:ring-2 focus:ring-zinc-900/10";
|
|
94
|
+
|
|
95
|
+
/* ============================ Overview ============================ */
|
|
96
|
+
|
|
97
|
+
export function Overview({
|
|
98
|
+
tenantId,
|
|
99
|
+
projects,
|
|
100
|
+
memberCount,
|
|
101
|
+
plan,
|
|
102
|
+
}: {
|
|
103
|
+
tenantId: string | null;
|
|
104
|
+
projects: Project[];
|
|
105
|
+
memberCount: number;
|
|
106
|
+
plan: string;
|
|
107
|
+
}) {
|
|
108
|
+
if (!tenantId) return <NoOrg />;
|
|
109
|
+
return (
|
|
110
|
+
<div className="space-y-6">
|
|
111
|
+
<div className="grid gap-4 sm:grid-cols-3">
|
|
112
|
+
<Stat label="Projects" value={projects.length} />
|
|
113
|
+
<Stat label="Members" value={memberCount} />
|
|
114
|
+
<Stat
|
|
115
|
+
label="Plan"
|
|
116
|
+
value={<span className="capitalize">{plan}</span>}
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
<Card
|
|
120
|
+
title="Recent projects"
|
|
121
|
+
action={
|
|
122
|
+
<a
|
|
123
|
+
href="/dashboard/projects"
|
|
124
|
+
className="text-[13px] font-medium text-brand hover:underline"
|
|
125
|
+
>
|
|
126
|
+
View all →
|
|
127
|
+
</a>
|
|
128
|
+
}
|
|
129
|
+
>
|
|
130
|
+
{projects.length === 0 ? (
|
|
131
|
+
<p className="text-sm text-zinc-500">
|
|
132
|
+
No projects yet — create one on the Projects tab.
|
|
133
|
+
</p>
|
|
134
|
+
) : (
|
|
135
|
+
<ul className="divide-y divide-zinc-100">
|
|
136
|
+
{projects.slice(0, 5).map((p) => (
|
|
137
|
+
<li key={p.id} className="py-2.5 text-sm text-zinc-700">
|
|
138
|
+
{p.name}
|
|
139
|
+
</li>
|
|
140
|
+
))}
|
|
141
|
+
</ul>
|
|
142
|
+
)}
|
|
143
|
+
</Card>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* ============================ Projects ============================ */
|
|
149
|
+
|
|
150
|
+
export function Projects({
|
|
151
|
+
tenantId,
|
|
152
|
+
initial,
|
|
153
|
+
}: {
|
|
154
|
+
tenantId: string | null;
|
|
155
|
+
initial: Project[];
|
|
156
|
+
}) {
|
|
157
|
+
if (!tenantId) return <NoOrg />;
|
|
158
|
+
return <ProjectsList orgId={tenantId} initial={initial} />;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Live + optimistic, seeded from the server. `db.useQuery` is reactive
|
|
162
|
+
// (db.insert/db.delete update it instantly across tabs), but until the first
|
|
163
|
+
// server-confirmed sync settles we render the server-passed `initial` rows — so
|
|
164
|
+
// there's no flash of an empty list on load. The policy gates reads on
|
|
165
|
+
// `auth.tenantId == data.orgId`, so this is only ever this org's projects.
|
|
166
|
+
function ProjectsList({
|
|
167
|
+
orgId,
|
|
168
|
+
initial,
|
|
169
|
+
}: {
|
|
170
|
+
orgId: string;
|
|
171
|
+
initial: Project[];
|
|
172
|
+
}) {
|
|
173
|
+
const [name, setName] = useState("");
|
|
174
|
+
const { data, loading } = db.useQuery<Project>("Project");
|
|
175
|
+
const projects = loading ? initial : data.filter((p) => p.orgId === orgId);
|
|
176
|
+
|
|
177
|
+
async function add(e: React.FormEvent) {
|
|
178
|
+
e.preventDefault();
|
|
179
|
+
const value = name.trim();
|
|
180
|
+
if (!value) return;
|
|
181
|
+
setName("");
|
|
182
|
+
await db.insert("Project", { orgId, name: value });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<Card title="Projects">
|
|
187
|
+
<form onSubmit={add} className="flex items-center gap-2">
|
|
188
|
+
<input
|
|
189
|
+
value={name}
|
|
190
|
+
onChange={(e) => setName(e.target.value)}
|
|
191
|
+
placeholder="New project…"
|
|
192
|
+
aria-label="Project name"
|
|
193
|
+
className={inputCls}
|
|
194
|
+
/>
|
|
195
|
+
<Button type="submit" size="sm">
|
|
196
|
+
Add
|
|
197
|
+
</Button>
|
|
198
|
+
</form>
|
|
199
|
+
{projects.length === 0 ? (
|
|
200
|
+
<p className="mt-3 text-sm text-zinc-500">No projects yet.</p>
|
|
201
|
+
) : (
|
|
202
|
+
<ul className="mt-3 space-y-1.5">
|
|
203
|
+
{projects.map((p) => (
|
|
204
|
+
<li
|
|
205
|
+
key={p.id}
|
|
206
|
+
className="flex items-center justify-between rounded-md border border-zinc-200 px-3 py-2 text-sm"
|
|
207
|
+
>
|
|
208
|
+
<span className="truncate">{p.name}</span>
|
|
209
|
+
<button
|
|
210
|
+
type="button"
|
|
211
|
+
aria-label="Delete project"
|
|
212
|
+
onClick={() => db.delete("Project", p.id)}
|
|
213
|
+
className="text-zinc-300 transition-colors hover:text-red-600"
|
|
214
|
+
>
|
|
215
|
+
✕
|
|
216
|
+
</button>
|
|
217
|
+
</li>
|
|
218
|
+
))}
|
|
219
|
+
</ul>
|
|
220
|
+
)}
|
|
221
|
+
<p className="mt-3 text-xs text-zinc-400">
|
|
222
|
+
Tenant-scoped + live: only this org's projects (enforced by policy),
|
|
223
|
+
seeded from the server so there's no load flash.
|
|
224
|
+
</p>
|
|
225
|
+
</Card>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* ============================= Members ============================ */
|
|
230
|
+
|
|
231
|
+
// Active-org info passed from the server (resolved via serverData) so the views
|
|
232
|
+
// render real names instead of raw ids — and instantly, with no fetch flash.
|
|
233
|
+
export interface OrgInfo {
|
|
234
|
+
id: string;
|
|
235
|
+
name: string;
|
|
236
|
+
createdAt: string;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const isManager = (role: string) => role === "owner" || role === "admin";
|
|
240
|
+
|
|
241
|
+
function RoleBadge({ role }: { role: string }) {
|
|
242
|
+
const tone =
|
|
243
|
+
role === "owner"
|
|
244
|
+
? "bg-brand-soft text-brand"
|
|
245
|
+
: role === "admin"
|
|
246
|
+
? "bg-amber-50 text-amber-700"
|
|
247
|
+
: "bg-zinc-100 text-zinc-600";
|
|
248
|
+
return (
|
|
249
|
+
<span
|
|
250
|
+
className={
|
|
251
|
+
"rounded-full px-2 py-0.5 text-[11px] font-medium capitalize " + tone
|
|
252
|
+
}
|
|
253
|
+
>
|
|
254
|
+
{role}
|
|
255
|
+
</span>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function Members({
|
|
260
|
+
tenantId,
|
|
261
|
+
currentUserId,
|
|
262
|
+
role,
|
|
263
|
+
}: {
|
|
264
|
+
tenantId: string | null;
|
|
265
|
+
currentUserId: string | null;
|
|
266
|
+
role: string;
|
|
267
|
+
}) {
|
|
268
|
+
if (!tenantId) return <NoOrg />;
|
|
269
|
+
return (
|
|
270
|
+
<MembersList orgId={tenantId} currentUserId={currentUserId} role={role} />
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// The roster comes from the framework's members endpoint, which joins each
|
|
275
|
+
// member's email + name server-side (the User read policy blocks reading other
|
|
276
|
+
// users via sync, so this trusted endpoint is the only place to get identities).
|
|
277
|
+
// Invites + the roster are gated to owners/admins here AND on the server.
|
|
278
|
+
function MembersList({
|
|
279
|
+
orgId,
|
|
280
|
+
currentUserId,
|
|
281
|
+
role,
|
|
282
|
+
}: {
|
|
283
|
+
orgId: string;
|
|
284
|
+
currentUserId: string | null;
|
|
285
|
+
role: string;
|
|
286
|
+
}) {
|
|
287
|
+
const canManage = isManager(role);
|
|
288
|
+
const [members, setMembers] = useState<OrgMember[] | null>(null);
|
|
289
|
+
const [invites, setInvites] = useState<PendingInvite[] | null>(null);
|
|
290
|
+
const [email, setEmail] = useState("");
|
|
291
|
+
const [note, setNote] = useState<string | null>(null);
|
|
292
|
+
const [inviting, setInviting] = useState(false);
|
|
293
|
+
|
|
294
|
+
async function load() {
|
|
295
|
+
setMembers(await listOrgMembers(orgId));
|
|
296
|
+
// Pending invites are admin-gated server-side; only fetch when we'd be
|
|
297
|
+
// allowed to see them (avoids a guaranteed 403 for plain members).
|
|
298
|
+
if (canManage) setInvites(await listInvites(orgId));
|
|
299
|
+
}
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
void load();
|
|
302
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
303
|
+
}, [orgId]);
|
|
304
|
+
|
|
305
|
+
async function invite(e: React.FormEvent) {
|
|
306
|
+
e.preventDefault();
|
|
307
|
+
const value = email.trim();
|
|
308
|
+
if (!value) return;
|
|
309
|
+
setInviting(true);
|
|
310
|
+
setNote(null);
|
|
311
|
+
try {
|
|
312
|
+
await createInvite(orgId, value, "member");
|
|
313
|
+
setEmail("");
|
|
314
|
+
setNote(`Invite sent to ${value}.`);
|
|
315
|
+
void load();
|
|
316
|
+
} catch {
|
|
317
|
+
setNote("Couldn't send that invite — check the address and your role.");
|
|
318
|
+
} finally {
|
|
319
|
+
setInviting(false);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function revoke(inviteId: string) {
|
|
324
|
+
setInvites((prev) => prev?.filter((i) => i.id !== inviteId) ?? null);
|
|
325
|
+
try {
|
|
326
|
+
await revokeInvite(orgId, inviteId);
|
|
327
|
+
} finally {
|
|
328
|
+
void load();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
<div className="space-y-6">
|
|
334
|
+
<Card title="Members" action={members ? <Count n={members.length} /> : null}>
|
|
335
|
+
{canManage && (
|
|
336
|
+
<form onSubmit={invite} className="flex items-center gap-2">
|
|
337
|
+
<input
|
|
338
|
+
type="email"
|
|
339
|
+
value={email}
|
|
340
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
341
|
+
placeholder="teammate@company.com"
|
|
342
|
+
aria-label="Invite email"
|
|
343
|
+
className={inputCls}
|
|
344
|
+
/>
|
|
345
|
+
<Button type="submit" size="sm" disabled={inviting || !email.trim()}>
|
|
346
|
+
{inviting ? "…" : "Invite"}
|
|
347
|
+
</Button>
|
|
348
|
+
</form>
|
|
349
|
+
)}
|
|
350
|
+
{note && <p className="mt-2 text-xs text-zinc-500">{note}</p>}
|
|
351
|
+
|
|
352
|
+
<ul className="mt-3 divide-y divide-zinc-100">
|
|
353
|
+
{members === null
|
|
354
|
+
? // Skeleton rows while the roster loads — sized to the real row so
|
|
355
|
+
// there's no layout shift when it lands.
|
|
356
|
+
Array.from({ length: 3 }).map((_, i) => (
|
|
357
|
+
<li key={i} className="flex items-center gap-3 py-2.5">
|
|
358
|
+
<div className="size-8 animate-pulse rounded-full bg-zinc-100" />
|
|
359
|
+
<div className="h-3 w-40 animate-pulse rounded bg-zinc-100" />
|
|
360
|
+
</li>
|
|
361
|
+
))
|
|
362
|
+
: members.map((m) => {
|
|
363
|
+
const label = m.name || m.email || "Unknown member";
|
|
364
|
+
const initial = (label.trim()[0] || "?").toUpperCase();
|
|
365
|
+
const isMe = m.user_id === currentUserId;
|
|
366
|
+
return (
|
|
367
|
+
<li
|
|
368
|
+
key={m.user_id}
|
|
369
|
+
className="flex items-center gap-3 py-2.5"
|
|
370
|
+
>
|
|
371
|
+
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-zinc-100 text-[12px] font-semibold text-zinc-600">
|
|
372
|
+
{initial}
|
|
373
|
+
</span>
|
|
374
|
+
<div className="min-w-0 flex-1">
|
|
375
|
+
<div className="flex items-center gap-2">
|
|
376
|
+
<span className="truncate text-sm font-medium text-zinc-900">
|
|
377
|
+
{m.name || m.email || "Unknown member"}
|
|
378
|
+
</span>
|
|
379
|
+
{isMe && (
|
|
380
|
+
<span className="rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-medium text-zinc-500">
|
|
381
|
+
You
|
|
382
|
+
</span>
|
|
383
|
+
)}
|
|
384
|
+
</div>
|
|
385
|
+
{m.name && m.email && (
|
|
386
|
+
<div className="truncate text-xs text-zinc-500">
|
|
387
|
+
{m.email}
|
|
388
|
+
</div>
|
|
389
|
+
)}
|
|
390
|
+
</div>
|
|
391
|
+
<RoleBadge role={m.role} />
|
|
392
|
+
</li>
|
|
393
|
+
);
|
|
394
|
+
})}
|
|
395
|
+
</ul>
|
|
396
|
+
{!canManage && (
|
|
397
|
+
<p className="mt-3 text-xs text-zinc-400">
|
|
398
|
+
Only owners and admins can invite new members.
|
|
399
|
+
</p>
|
|
400
|
+
)}
|
|
401
|
+
</Card>
|
|
402
|
+
|
|
403
|
+
{canManage && invites && invites.length > 0 && (
|
|
404
|
+
<Card
|
|
405
|
+
title="Pending invitations"
|
|
406
|
+
action={
|
|
407
|
+
<span className="text-[13px] text-zinc-400">
|
|
408
|
+
{invites.length} pending
|
|
409
|
+
</span>
|
|
410
|
+
}
|
|
411
|
+
>
|
|
412
|
+
<ul className="divide-y divide-zinc-100">
|
|
413
|
+
{invites.map((inv) => (
|
|
414
|
+
<li key={inv.id} className="flex items-center gap-3 py-2.5">
|
|
415
|
+
<span className="flex size-8 shrink-0 items-center justify-center rounded-full border border-dashed border-zinc-300 text-zinc-400">
|
|
416
|
+
<MailIcon />
|
|
417
|
+
</span>
|
|
418
|
+
<div className="min-w-0 flex-1">
|
|
419
|
+
<div className="truncate text-sm font-medium text-zinc-900">
|
|
420
|
+
{inv.email}
|
|
421
|
+
</div>
|
|
422
|
+
<div className="text-xs text-zinc-500">
|
|
423
|
+
Invited · expires {formatDate(unixToIso(inv.expires_at))}
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
<RoleBadge role={inv.role} />
|
|
427
|
+
<button
|
|
428
|
+
type="button"
|
|
429
|
+
onClick={() => revoke(inv.id)}
|
|
430
|
+
className="text-[13px] font-medium text-zinc-400 transition-colors hover:text-red-600"
|
|
431
|
+
>
|
|
432
|
+
Revoke
|
|
433
|
+
</button>
|
|
434
|
+
</li>
|
|
435
|
+
))}
|
|
436
|
+
</ul>
|
|
437
|
+
</Card>
|
|
438
|
+
)}
|
|
439
|
+
</div>
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function MailIcon() {
|
|
444
|
+
return (
|
|
445
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
446
|
+
<rect x="3" y="5" width="18" height="14" rx="2" />
|
|
447
|
+
<path d="m3 7 9 6 9-6" />
|
|
448
|
+
</svg>
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// PendingInvite.expires_at is unix SECONDS; formatDate() wants an ISO string.
|
|
453
|
+
function unixToIso(sec: number) {
|
|
454
|
+
return new Date(sec * 1000).toISOString();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function Count({ n }: { n: number }) {
|
|
458
|
+
return (
|
|
459
|
+
<span className="text-[13px] text-zinc-400">
|
|
460
|
+
{n} {n === 1 ? "person" : "people"}
|
|
461
|
+
</span>
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/* ============================ Settings ============================ */
|
|
466
|
+
|
|
467
|
+
export function Settings({
|
|
468
|
+
org,
|
|
469
|
+
role,
|
|
470
|
+
memberCount,
|
|
471
|
+
}: {
|
|
472
|
+
org: OrgInfo | null;
|
|
473
|
+
role: string;
|
|
474
|
+
memberCount: number;
|
|
475
|
+
}) {
|
|
476
|
+
if (!org) return <NoOrg />;
|
|
477
|
+
return <SettingsView org={org} role={role} memberCount={memberCount} />;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function SettingsView({
|
|
481
|
+
org,
|
|
482
|
+
role,
|
|
483
|
+
memberCount,
|
|
484
|
+
}: {
|
|
485
|
+
org: OrgInfo;
|
|
486
|
+
role: string;
|
|
487
|
+
memberCount: number;
|
|
488
|
+
}) {
|
|
489
|
+
const { clearOrg } = useAuth();
|
|
490
|
+
const [name, setName] = useState(org.name);
|
|
491
|
+
const [saving, setSaving] = useState(false);
|
|
492
|
+
const [saved, setSaved] = useState(false);
|
|
493
|
+
const [error, setError] = useState<string | null>(null);
|
|
494
|
+
const canManage = isManager(role);
|
|
495
|
+
const canDelete = role === "owner";
|
|
496
|
+
const dirty = name.trim() !== org.name && name.trim().length > 0;
|
|
497
|
+
|
|
498
|
+
async function rename(e: React.FormEvent) {
|
|
499
|
+
e.preventDefault();
|
|
500
|
+
if (!dirty) return;
|
|
501
|
+
setSaving(true);
|
|
502
|
+
setError(null);
|
|
503
|
+
setSaved(false);
|
|
504
|
+
try {
|
|
505
|
+
await renameOrg(org.id, name.trim());
|
|
506
|
+
setSaved(true);
|
|
507
|
+
// Reflect the new name in the sidebar switcher + everywhere else.
|
|
508
|
+
window.location.reload();
|
|
509
|
+
} catch {
|
|
510
|
+
setError("Couldn't rename — only owners and admins can.");
|
|
511
|
+
setSaving(false);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return (
|
|
516
|
+
<div className="max-w-2xl space-y-6">
|
|
517
|
+
<Card title="Workspace">
|
|
518
|
+
<form onSubmit={rename} className="space-y-3">
|
|
519
|
+
<label className="block">
|
|
520
|
+
<span className="mb-1.5 block text-[13px] font-medium text-zinc-700">
|
|
521
|
+
Name
|
|
522
|
+
</span>
|
|
523
|
+
<div className="flex items-center gap-2">
|
|
524
|
+
<input
|
|
525
|
+
value={name}
|
|
526
|
+
onChange={(e) => {
|
|
527
|
+
setName(e.target.value);
|
|
528
|
+
setSaved(false);
|
|
529
|
+
}}
|
|
530
|
+
disabled={!canManage}
|
|
531
|
+
aria-label="Workspace name"
|
|
532
|
+
className={inputCls + " disabled:bg-zinc-50 disabled:text-zinc-500"}
|
|
533
|
+
/>
|
|
534
|
+
{canManage && (
|
|
535
|
+
<Button type="submit" size="sm" disabled={!dirty || saving}>
|
|
536
|
+
{saving ? "…" : "Save"}
|
|
537
|
+
</Button>
|
|
538
|
+
)}
|
|
539
|
+
</div>
|
|
540
|
+
</label>
|
|
541
|
+
{saved && (
|
|
542
|
+
<p className="text-xs text-green-600">Workspace name updated.</p>
|
|
543
|
+
)}
|
|
544
|
+
{error && <p className="text-xs text-red-600">{error}</p>}
|
|
545
|
+
</form>
|
|
546
|
+
|
|
547
|
+
<dl className="mt-5 grid grid-cols-2 gap-y-3 border-t border-zinc-100 pt-4 text-sm">
|
|
548
|
+
<dt className="text-zinc-500">Your role</dt>
|
|
549
|
+
<dd className="text-right">
|
|
550
|
+
<RoleBadge role={role} />
|
|
551
|
+
</dd>
|
|
552
|
+
<dt className="text-zinc-500">Members</dt>
|
|
553
|
+
<dd className="text-right text-zinc-900">{memberCount}</dd>
|
|
554
|
+
<dt className="text-zinc-500">Created</dt>
|
|
555
|
+
<dd className="text-right text-zinc-900">{formatDate(org.createdAt)}</dd>
|
|
556
|
+
</dl>
|
|
557
|
+
</Card>
|
|
558
|
+
|
|
559
|
+
<Card title="Danger zone">
|
|
560
|
+
{canDelete ? (
|
|
561
|
+
<DeleteOrg org={org} onDeleted={clearOrg} />
|
|
562
|
+
) : (
|
|
563
|
+
<p className="text-sm text-zinc-500">
|
|
564
|
+
Only the workspace owner can delete this workspace.
|
|
565
|
+
</p>
|
|
566
|
+
)}
|
|
567
|
+
</Card>
|
|
568
|
+
</div>
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Real, irreversible delete: type the workspace name to confirm, then call the
|
|
573
|
+
// framework's owner-gated DELETE endpoint, drop the active org, and bounce back
|
|
574
|
+
// to onboarding.
|
|
575
|
+
function DeleteOrg({
|
|
576
|
+
org,
|
|
577
|
+
onDeleted,
|
|
578
|
+
}: {
|
|
579
|
+
org: OrgInfo;
|
|
580
|
+
onDeleted: () => Promise<void>;
|
|
581
|
+
}) {
|
|
582
|
+
const [confirm, setConfirm] = useState("");
|
|
583
|
+
const [deleting, setDeleting] = useState(false);
|
|
584
|
+
const [error, setError] = useState<string | null>(null);
|
|
585
|
+
const armed = confirm.trim() === org.name;
|
|
586
|
+
|
|
587
|
+
async function remove() {
|
|
588
|
+
if (!armed) return;
|
|
589
|
+
setDeleting(true);
|
|
590
|
+
setError(null);
|
|
591
|
+
try {
|
|
592
|
+
await deleteOrg(org.id);
|
|
593
|
+
await onDeleted();
|
|
594
|
+
window.location.assign("/onboarding");
|
|
595
|
+
} catch {
|
|
596
|
+
setError("Delete failed. Try again.");
|
|
597
|
+
setDeleting(false);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return (
|
|
602
|
+
<div className="space-y-3">
|
|
603
|
+
<p className="text-sm text-zinc-600">
|
|
604
|
+
Deleting <span className="font-medium">{org.name}</span> removes its
|
|
605
|
+
projects and members for everyone. This can't be undone.
|
|
606
|
+
</p>
|
|
607
|
+
<label className="block">
|
|
608
|
+
<span className="mb-1.5 block text-[13px] text-zinc-500">
|
|
609
|
+
Type <span className="font-medium text-zinc-700">{org.name}</span> to
|
|
610
|
+
confirm
|
|
611
|
+
</span>
|
|
612
|
+
<input
|
|
613
|
+
value={confirm}
|
|
614
|
+
onChange={(e) => setConfirm(e.target.value)}
|
|
615
|
+
aria-label="Confirm workspace name"
|
|
616
|
+
className={inputCls}
|
|
617
|
+
/>
|
|
618
|
+
</label>
|
|
619
|
+
{error && <p className="text-xs text-red-600">{error}</p>}
|
|
620
|
+
<button
|
|
621
|
+
type="button"
|
|
622
|
+
onClick={remove}
|
|
623
|
+
disabled={!armed || deleting}
|
|
624
|
+
className="inline-flex h-9 items-center rounded-lg bg-red-600 px-4 text-[13px] font-medium text-white transition-colors hover:bg-red-700 disabled:opacity-40"
|
|
625
|
+
>
|
|
626
|
+
{deleting ? "Deleting…" : "Delete workspace"}
|
|
627
|
+
</button>
|
|
628
|
+
</div>
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function formatDate(iso: string) {
|
|
633
|
+
const t = Date.parse(iso);
|
|
634
|
+
if (Number.isNaN(t)) return "—";
|
|
635
|
+
return new Date(t).toLocaleDateString(undefined, {
|
|
636
|
+
year: "numeric",
|
|
637
|
+
month: "short",
|
|
638
|
+
day: "numeric",
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/* ============================ Billing ============================ */
|
|
643
|
+
|
|
644
|
+
// One row of the @pylonsync/stripe StripeSubscription entity (client-readable
|
|
645
|
+
// via the plugin's policy, scoped to the active org by referenceId).
|
|
646
|
+
export interface Subscription {
|
|
647
|
+
id: string;
|
|
648
|
+
referenceId: string;
|
|
649
|
+
plan: string;
|
|
650
|
+
status: string;
|
|
651
|
+
cancelAtPeriodEnd?: boolean;
|
|
652
|
+
currentPeriodEnd?: string;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const ACTIVE = ["active", "trialing", "past_due"];
|
|
656
|
+
|
|
657
|
+
export function Billing({
|
|
658
|
+
tenantId,
|
|
659
|
+
role,
|
|
660
|
+
subscription,
|
|
661
|
+
}: {
|
|
662
|
+
tenantId: string | null;
|
|
663
|
+
role: string;
|
|
664
|
+
subscription: Subscription | null;
|
|
665
|
+
}) {
|
|
666
|
+
if (!tenantId) return <NoOrg />;
|
|
667
|
+
return (
|
|
668
|
+
<BillingView tenantId={tenantId} role={role} subscription={subscription} />
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Real Stripe billing via the plugin's actions (callFn → /api/fn/*). Upgrade
|
|
673
|
+
// opens Stripe Checkout; Manage opens the Customer Portal; the webhook keeps the
|
|
674
|
+
// StripeSubscription row in sync, so a full page load after returning reflects
|
|
675
|
+
// the new plan. Until STRIPE_SECRET_KEY + STRIPE_PRICE_PRO are set the actions
|
|
676
|
+
// return STRIPE_NOT_CONFIGURED, which we surface as a "connect Stripe" state.
|
|
677
|
+
function BillingView({
|
|
678
|
+
tenantId,
|
|
679
|
+
role,
|
|
680
|
+
subscription,
|
|
681
|
+
}: {
|
|
682
|
+
tenantId: string;
|
|
683
|
+
role: string;
|
|
684
|
+
subscription: Subscription | null;
|
|
685
|
+
}) {
|
|
686
|
+
const canManage = isManager(role);
|
|
687
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
688
|
+
const [error, setError] = useState<string | null>(null);
|
|
689
|
+
const active = subscription && ACTIVE.includes(subscription.status);
|
|
690
|
+
const planLabel = active ? subscription!.plan : "free";
|
|
691
|
+
|
|
692
|
+
function origin() {
|
|
693
|
+
return typeof window !== "undefined" ? window.location.origin : "";
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function run(action: string, fn: () => Promise<void>) {
|
|
697
|
+
setBusy(action);
|
|
698
|
+
setError(null);
|
|
699
|
+
try {
|
|
700
|
+
await fn();
|
|
701
|
+
} catch (e) {
|
|
702
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
703
|
+
setError(
|
|
704
|
+
/not.?configured|STRIPE_NOT_CONFIGURED/i.test(msg)
|
|
705
|
+
? "Stripe isn't configured yet — set STRIPE_SECRET_KEY + STRIPE_PRICE_PRO to enable billing."
|
|
706
|
+
: msg,
|
|
707
|
+
);
|
|
708
|
+
setBusy(null);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async function upgrade() {
|
|
713
|
+
await run("upgrade", async () => {
|
|
714
|
+
const res = await callFn<{ url: string }>("createCheckoutSession", {
|
|
715
|
+
plan: "pro",
|
|
716
|
+
referenceId: tenantId,
|
|
717
|
+
successUrl: `${origin()}/dashboard/billing`,
|
|
718
|
+
cancelUrl: `${origin()}/dashboard/billing`,
|
|
719
|
+
});
|
|
720
|
+
window.location.assign(res.url);
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function manage() {
|
|
725
|
+
await run("manage", async () => {
|
|
726
|
+
const res = await callFn<{ url: string }>("createBillingPortalSession", {
|
|
727
|
+
referenceId: tenantId,
|
|
728
|
+
returnUrl: `${origin()}/dashboard/billing`,
|
|
729
|
+
});
|
|
730
|
+
window.location.assign(res.url);
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function cancel() {
|
|
735
|
+
await run("cancel", async () => {
|
|
736
|
+
await callFn("cancelSubscription", {
|
|
737
|
+
referenceId: tenantId,
|
|
738
|
+
scheduleAtPeriodEnd: true,
|
|
739
|
+
});
|
|
740
|
+
window.location.reload();
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async function restore() {
|
|
745
|
+
await run("restore", async () => {
|
|
746
|
+
await callFn("restoreSubscription", { referenceId: tenantId });
|
|
747
|
+
window.location.reload();
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
return (
|
|
752
|
+
<div className="max-w-2xl space-y-6">
|
|
753
|
+
<Card title="Plan">
|
|
754
|
+
<div className="flex items-center justify-between">
|
|
755
|
+
<div>
|
|
756
|
+
<div className="flex items-center gap-2">
|
|
757
|
+
<span className="text-2xl font-semibold capitalize text-zinc-900">
|
|
758
|
+
{planLabel}
|
|
759
|
+
</span>
|
|
760
|
+
{active && <RoleBadge role={subscription!.status} />}
|
|
761
|
+
</div>
|
|
762
|
+
{active && subscription!.currentPeriodEnd && (
|
|
763
|
+
<p className="mt-1 text-[13px] text-zinc-500">
|
|
764
|
+
{subscription!.cancelAtPeriodEnd ? "Ends" : "Renews"}{" "}
|
|
765
|
+
{formatDate(subscription!.currentPeriodEnd)}
|
|
766
|
+
</p>
|
|
767
|
+
)}
|
|
768
|
+
{!active && (
|
|
769
|
+
<p className="mt-1 text-[13px] text-zinc-500">
|
|
770
|
+
The free plan. Upgrade to Pro for higher limits.
|
|
771
|
+
</p>
|
|
772
|
+
)}
|
|
773
|
+
</div>
|
|
774
|
+
|
|
775
|
+
{canManage ? (
|
|
776
|
+
active ? (
|
|
777
|
+
<Button
|
|
778
|
+
type="button"
|
|
779
|
+
variant="outline"
|
|
780
|
+
size="sm"
|
|
781
|
+
onClick={manage}
|
|
782
|
+
disabled={!!busy}
|
|
783
|
+
>
|
|
784
|
+
{busy === "manage" ? "…" : "Manage billing"}
|
|
785
|
+
</Button>
|
|
786
|
+
) : (
|
|
787
|
+
<Button type="button" onClick={upgrade} disabled={!!busy}>
|
|
788
|
+
{busy === "upgrade" ? "…" : "Upgrade to Pro"}
|
|
789
|
+
</Button>
|
|
790
|
+
)
|
|
791
|
+
) : (
|
|
792
|
+
<span className="text-xs text-zinc-400">
|
|
793
|
+
Owners and admins manage billing.
|
|
794
|
+
</span>
|
|
795
|
+
)}
|
|
796
|
+
</div>
|
|
797
|
+
|
|
798
|
+
{active && canManage && (
|
|
799
|
+
<div className="mt-4 border-t border-zinc-100 pt-3 text-[13px]">
|
|
800
|
+
{subscription!.cancelAtPeriodEnd ? (
|
|
801
|
+
<button
|
|
802
|
+
type="button"
|
|
803
|
+
onClick={restore}
|
|
804
|
+
disabled={!!busy}
|
|
805
|
+
className="font-medium text-brand hover:underline disabled:opacity-50"
|
|
806
|
+
>
|
|
807
|
+
{busy === "restore" ? "…" : "Resume subscription"}
|
|
808
|
+
</button>
|
|
809
|
+
) : (
|
|
810
|
+
<button
|
|
811
|
+
type="button"
|
|
812
|
+
onClick={cancel}
|
|
813
|
+
disabled={!!busy}
|
|
814
|
+
className="text-zinc-500 hover:text-red-600 disabled:opacity-50"
|
|
815
|
+
>
|
|
816
|
+
{busy === "cancel" ? "…" : "Cancel at period end"}
|
|
817
|
+
</button>
|
|
818
|
+
)}
|
|
819
|
+
</div>
|
|
820
|
+
)}
|
|
821
|
+
|
|
822
|
+
{error && <p className="mt-3 text-xs text-red-600">{error}</p>}
|
|
823
|
+
</Card>
|
|
824
|
+
|
|
825
|
+
<p className="text-xs text-zinc-400">
|
|
826
|
+
Powered by Stripe Checkout + Customer Portal via the @pylonsync/stripe
|
|
827
|
+
plugin. Set STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and STRIPE_PRICE_PRO
|
|
828
|
+
to go live; point the webhook at <code>/api/fn/stripeWebhook</code>.
|
|
829
|
+
</p>
|
|
830
|
+
</div>
|
|
831
|
+
);
|
|
832
|
+
}
|