@pylonsync/create-pylon 0.3.346 → 0.3.347

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 (60) hide show
  1. package/package.json +1 -1
  2. package/templates/crm/README.md +5 -4
  3. package/templates/crm/app/companies/companies-view.tsx +6 -3
  4. package/templates/crm/app/companies/page.tsx +3 -8
  5. package/templates/crm/app/contacts/contacts-view.tsx +6 -3
  6. package/templates/crm/app/contacts/page.tsx +3 -8
  7. package/templates/crm/app/deals/[id]/deal-view.tsx +6 -3
  8. package/templates/crm/app/deals/[id]/page.tsx +3 -13
  9. package/templates/crm/app/layout.tsx +4 -4
  10. package/templates/crm/app/page.tsx +2 -19
  11. package/templates/crm/app/pipeline-view.tsx +5 -4
  12. package/templates/crm/app/workspace.tsx +12 -10
  13. package/templates/crm/{app/login → components}/auth-form.tsx +7 -4
  14. package/templates/crm/components/require-auth.tsx +66 -0
  15. package/templates/helpdesk/app/customers/customers-view.tsx +6 -3
  16. package/templates/helpdesk/app/customers/page.tsx +3 -8
  17. package/templates/helpdesk/app/inbox-view.tsx +5 -4
  18. package/templates/helpdesk/app/layout.tsx +4 -4
  19. package/templates/helpdesk/app/page.tsx +3 -19
  20. package/templates/helpdesk/app/tickets/[id]/page.tsx +3 -13
  21. package/templates/helpdesk/app/tickets/[id]/ticket-view.tsx +5 -4
  22. package/templates/helpdesk/app/workspace.tsx +12 -8
  23. package/templates/{inventory/app/login → helpdesk/components}/auth-form.tsx +7 -4
  24. package/templates/helpdesk/components/require-auth.tsx +66 -0
  25. package/templates/inventory/app/layout.tsx +4 -4
  26. package/templates/inventory/app/movements/movements-view.tsx +6 -3
  27. package/templates/inventory/app/movements/page.tsx +3 -8
  28. package/templates/inventory/app/page.tsx +3 -16
  29. package/templates/inventory/app/products/[id]/page.tsx +3 -13
  30. package/templates/inventory/app/products/[id]/product-view.tsx +5 -4
  31. package/templates/inventory/app/products-view.tsx +5 -4
  32. package/templates/inventory/app/workspace.tsx +12 -8
  33. package/templates/{helpdesk/app/login → inventory/components}/auth-form.tsx +7 -4
  34. package/templates/inventory/components/require-auth.tsx +66 -0
  35. package/templates/invoices/app/clients/clients-view.tsx +6 -3
  36. package/templates/invoices/app/clients/page.tsx +3 -8
  37. package/templates/invoices/app/invoices/[id]/invoice-view.tsx +5 -4
  38. package/templates/invoices/app/invoices/[id]/page.tsx +3 -13
  39. package/templates/invoices/app/invoices-view.tsx +5 -4
  40. package/templates/invoices/app/layout.tsx +4 -4
  41. package/templates/invoices/app/page.tsx +3 -19
  42. package/templates/invoices/app/workspace.tsx +13 -8
  43. package/templates/invoices/{app/login → components}/auth-form.tsx +7 -4
  44. package/templates/invoices/components/require-auth.tsx +66 -0
  45. package/templates/projects/app/clients/clients-view.tsx +6 -3
  46. package/templates/projects/app/clients/page.tsx +3 -8
  47. package/templates/projects/app/layout.tsx +4 -4
  48. package/templates/projects/app/page.tsx +3 -15
  49. package/templates/projects/app/projects/[id]/page.tsx +3 -13
  50. package/templates/projects/app/projects/[id]/project-view.tsx +5 -4
  51. package/templates/projects/app/projects-view.tsx +5 -4
  52. package/templates/projects/app/workspace.tsx +12 -8
  53. package/templates/projects/components/auth-form.tsx +125 -0
  54. package/templates/projects/components/require-auth.tsx +66 -0
  55. package/templates/crm/app/login/page.tsx +0 -38
  56. package/templates/helpdesk/app/login/page.tsx +0 -38
  57. package/templates/inventory/app/login/page.tsx +0 -38
  58. package/templates/invoices/app/login/page.tsx +0 -38
  59. package/templates/projects/app/login/auth-form.tsx +0 -122
  60. package/templates/projects/app/login/page.tsx +0 -38
@@ -63,21 +63,28 @@ export interface WorkspaceData {
63
63
  * path, so there's one behaviour to reason about rather than two.
64
64
  */
65
65
  export function Workspace({
66
- email,
67
66
  pathname,
68
67
  children,
69
68
  }: {
70
- email: string;
71
69
  pathname: string;
72
70
  children: (data: WorkspaceData) => React.ReactNode;
73
71
  }) {
74
72
  const router = useRouter();
75
- const { signOut } = useAuth();
73
+ // The signed-in identity comes from the CLIENT session, not from an SSR
74
+ // prop: the session cookie is SameSite=Lax and browsers withhold it inside
75
+ // the builder's cross-site preview iframe, so a server-resolved email would
76
+ // be empty exactly where this app is most often viewed.
77
+ const { signOut, userId } = useAuth();
76
78
  const { data: tickets, loading } = db.useQuery<TicketRow>("Ticket");
77
79
  const { data: customers } = db.useQuery<CustomerRow>("Customer");
78
80
  const { data: messages } = db.useQuery<MessageRow>("Message");
79
81
  const { data: users } = db.useQuery<UserRow>("User");
80
82
 
83
+ // Whoever is signed in, named from the synced User row rather than an
84
+ // SSR prop — see the note on useAuth above.
85
+ const signedInEmail =
86
+ (users ?? []).find((user) => user.id === userId)?.email ?? "";
87
+
81
88
  const [paletteOpen, setPaletteOpen] = useState(false);
82
89
  const seeded = useRef(false);
83
90
 
@@ -168,17 +175,14 @@ export function Workspace({
168
175
  <div className="flex h-screen overflow-hidden">
169
176
  <Sidebar
170
177
  workspace="Helpdesk"
171
- email={email}
178
+ email={signedInEmail}
172
179
  pathname={pathname}
173
180
  counts={{
174
181
  "/": ticketList.filter(isOpen).length,
175
182
  "/customers": customerList.length,
176
183
  }}
177
184
  onOpenCommand={() => setPaletteOpen(true)}
178
- onSignOut={async () => {
179
- await signOut();
180
- window.location.assign("/login");
181
- }}
185
+ onSignOut={() => void signOut()}
182
186
  />
183
187
  <main className="flex min-w-0 flex-1 flex-col">{children(data)}</main>
184
188
 
@@ -16,9 +16,10 @@ import { Label } from "@/components/ui/label";
16
16
  * then `persistSession` writes the token so the sync engine and `callFn`
17
17
  * authenticate as this user on the next load.
18
18
  *
19
- * The navigation is a full `assign`, not a client route change, so the SSR
20
- * runtime re-resolves auth from the HttpOnly cookie and the first paint of the
21
- * workspace is already signed in.
19
+ * No navigation afterwards: `persistSession` notifies the sync engine and the
20
+ * client-side gate swaps the app in. Reloading would hand the decision to the
21
+ * SSR cookie, which browsers withhold inside the builder's cross-site preview
22
+ * iframe — that's the sign-in loop this avoids.
22
23
  */
23
24
  export function AuthForm() {
24
25
  const [mode, setMode] = useState<"login" | "signup">("login");
@@ -36,8 +37,10 @@ export function AuthForm() {
36
37
  mode === "login"
37
38
  ? await passwordLogin({ email, password })
38
39
  : await passwordRegister({ email, password });
40
+ // No navigation: persistSession notifies the sync engine, RequireAuth
41
+ // re-renders, and the app appears in place. A full page load here would
42
+ // depend on the SSR session cookie, which a cross-site iframe withholds.
39
43
  persistSession(session);
40
- window.location.assign("/");
41
44
  } catch (err) {
42
45
  setError(messageFor(err));
43
46
  setPending(false);
@@ -0,0 +1,66 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { useAuth } from "@pylonsync/client";
5
+ import { AuthForm } from "@/components/auth-form";
6
+
7
+ /**
8
+ * Client-side auth gate.
9
+ *
10
+ * Deliberately NOT a server-side `response.redirect("/login")`. The session
11
+ * cookie is `SameSite=Lax`, which browsers withhold on cross-site iframe
12
+ * navigations — and this app is shown inside an iframe in the Pylon Cloud
13
+ * builder's live preview. An SSR gate there sees every request as anonymous no
14
+ * matter how many times you sign in, so signing in bounces straight back to the
15
+ * form. The token in local storage is visible to the client either way, so the
16
+ * gate belongs here.
17
+ *
18
+ * It also removes a full page load from the sign-in path: authenticating swaps
19
+ * this component's children in place instead of navigating.
20
+ */
21
+ export function RequireAuth({
22
+ title,
23
+ description,
24
+ children,
25
+ }: {
26
+ title: string;
27
+ description: string;
28
+ children: React.ReactNode;
29
+ }) {
30
+ const { isSignedIn, isLoaded } = useAuth();
31
+
32
+ // `isLoaded` covers the beat before the engine has resolved the stored token.
33
+ // Rendering the form during it would flash a sign-in screen at someone who is
34
+ // already signed in.
35
+ if (!isLoaded) {
36
+ return (
37
+ <main className="flex min-h-screen items-center justify-center">
38
+ <span className="text-[13px] text-muted-foreground">Loading…</span>
39
+ </main>
40
+ );
41
+ }
42
+
43
+ if (!isSignedIn) {
44
+ return (
45
+ <main className="flex min-h-screen items-center justify-center px-6">
46
+ <div className="w-full max-w-[320px]">
47
+ <div className="mb-7 flex items-center gap-2">
48
+ <span className="flex size-7 items-center justify-center rounded-md bg-primary text-[12px] font-bold text-primary-foreground">
49
+ {title.slice(0, 1).toUpperCase()}
50
+ </span>
51
+ <span className="text-[15px] font-semibold tracking-tight">{title}</span>
52
+ </div>
53
+ <h1 className="text-[19px] font-semibold tracking-tight">
54
+ Sign in to your workspace
55
+ </h1>
56
+ <p className="mt-1 text-[13px] text-muted-foreground">{description}</p>
57
+ <div className="mt-6">
58
+ <AuthForm />
59
+ </div>
60
+ </div>
61
+ </main>
62
+ );
63
+ }
64
+
65
+ return <>{children}</>;
66
+ }
@@ -9,13 +9,13 @@ interface LayoutProps {
9
9
  * lives in `app/workspace.tsx` instead, because it needs the synced data; this
10
10
  * layer only sets up the page.
11
11
  *
12
- * `class="dark"` makes dark the default theme. The light tokens in globals.css
13
- * are complete and tested, so removing the class flips the whole app; swap it
14
- * for a stored preference when you want a toggle.
12
+ * Light by default. The dark tokens in globals.css are complete, so adding
13
+ * `className="dark"` here flips the whole app or wire it to a stored
14
+ * preference, or to `prefers-color-scheme`, when you want a toggle.
15
15
  */
16
16
  export default function RootLayout({ children }: LayoutProps) {
17
17
  return (
18
- <html lang="en" className="dark">
18
+ <html lang="en">
19
19
  <head>
20
20
  <meta charSet="utf-8" />
21
21
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -7,6 +7,7 @@ import { DataTable, type ColumnDef } from "@/components/data-table";
7
7
  import { EmptyState } from "@/components/empty-state";
8
8
  import { relativeTime } from "@/lib/format";
9
9
  import { reasonById, signedQuantity } from "@/lib/stock";
10
+ import { RequireAuth } from "@/components/require-auth";
10
11
  import { Workspace, type MovementRow } from "../workspace";
11
12
 
12
13
  /**
@@ -15,9 +16,10 @@ import { Workspace, type MovementRow } from "../workspace";
15
16
  * past valuation and make the current level unexplainable. Corrections are new
16
17
  * rows in the opposite direction.
17
18
  */
18
- export function MovementsView({ email }: { email: string }) {
19
+ export function MovementsView() {
19
20
  return (
20
- <Workspace email={email} pathname="/movements">
21
+ <RequireAuth title="Inventory" description="Your team shares one stock ledger. Anyone with an account sees it.">
22
+ <Workspace pathname="/movements">
21
23
  {(data) => {
22
24
  const columns: ColumnDef<MovementRow>[] = [
23
25
  {
@@ -98,6 +100,7 @@ export function MovementsView({ email }: { email: string }) {
98
100
  </>
99
101
  );
100
102
  }}
101
- </Workspace>
103
+ </Workspace>
104
+ </RequireAuth>
102
105
  );
103
106
  }
@@ -1,4 +1,4 @@
1
- import React, { use } from "react";
1
+ import React from "react";
2
2
  import type { Metadata, PageProps } from "@pylonsync/react";
3
3
  import { MovementsView } from "./movements-view";
4
4
 
@@ -7,11 +7,6 @@ export const metadata: Metadata = {
7
7
  robots: "noindex",
8
8
  };
9
9
 
10
- export default function MovementsPage({ auth, response, serverData }: PageProps) {
11
- if (!auth.user_id || auth.user_id.startsWith("guest_")) {
12
- response.redirect("/login");
13
- return null;
14
- }
15
- const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
16
- return <MovementsView email={me?.email ?? ""} />;
10
+ export default function MovementsPage({}: PageProps) {
11
+ return <MovementsView />;
17
12
  }
@@ -1,4 +1,4 @@
1
- import React, { use } from "react";
1
+ import React from "react";
2
2
  import type { Metadata, PageProps } from "@pylonsync/react";
3
3
  import { ProductsView } from "./products-view";
4
4
 
@@ -10,24 +10,11 @@ export const metadata: Metadata = {
10
10
  /**
11
11
  * `/` — the stock list.
12
12
  *
13
- * Server-side auth gate only. Reading `auth` opts this render out of caching,
14
- * which is correct: every screen here is private.
15
13
  */
16
- export default function ProductsPage({
17
- auth,
18
- response,
19
- searchParams,
20
- serverData,
21
- }: PageProps) {
22
- if (!auth.user_id || auth.user_id.startsWith("guest_")) {
23
- response.redirect("/login");
24
- return null;
25
- }
26
- const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
14
+ export default function ProductsPage({ searchParams }: PageProps) {
27
15
  return (
28
16
  <ProductsView
29
- email={me?.email ?? ""}
30
- openNew={searchParams?.new === "product"}
17
+ openNew={searchParams?.new === "product"}
31
18
  initialFilter={searchParams?.filter}
32
19
  />
33
20
  );
@@ -1,4 +1,4 @@
1
- import React, { use } from "react";
1
+ import React from "react";
2
2
  import type { Metadata, PageProps } from "@pylonsync/react";
3
3
  import { ProductView } from "./product-view";
4
4
 
@@ -8,16 +8,6 @@ export const metadata: Metadata = {
8
8
  };
9
9
 
10
10
  /** `app/products/[id]/page.tsx` -> `/products/:id`. */
11
- export default function ProductPage({
12
- auth,
13
- response,
14
- params,
15
- serverData,
16
- }: PageProps<{ id: string }>) {
17
- if (!auth.user_id || auth.user_id.startsWith("guest_")) {
18
- response.redirect("/login");
19
- return null;
20
- }
21
- const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
22
- return <ProductView email={me?.email ?? ""} productId={params.id} />;
11
+ export default function ProductPage({ params }: PageProps<{ id: string }>) {
12
+ return <ProductView productId={params.id} />;
23
13
  }
@@ -10,19 +10,19 @@ import { StockLevel } from "@/components/stock-level";
10
10
  import { MovementDialog } from "@/components/movement-dialog";
11
11
  import { relativeTime } from "@/lib/format";
12
12
  import { money, reasonById, signedQuantity } from "@/lib/stock";
13
+ import { RequireAuth } from "@/components/require-auth";
13
14
  import { Workspace } from "../../workspace";
14
15
 
15
16
  export function ProductView({
16
- email,
17
17
  productId,
18
18
  }: {
19
- email: string;
20
19
  productId: string;
21
20
  }) {
22
21
  const [moveOpen, setMoveOpen] = useState(false);
23
22
 
24
23
  return (
25
- <Workspace email={email} pathname="/">
24
+ <RequireAuth title="Inventory" description="Your team shares one stock ledger. Anyone with an account sees it.">
25
+ <Workspace pathname="/">
26
26
  {(data) => {
27
27
  const product = data.products.find((p) => p.id === productId);
28
28
 
@@ -164,7 +164,8 @@ export function ProductView({
164
164
  </>
165
165
  );
166
166
  }}
167
- </Workspace>
167
+ </Workspace>
168
+ </RequireAuth>
168
169
  );
169
170
  }
170
171
 
@@ -13,14 +13,13 @@ import { SummaryBar } from "@/components/summary-bar";
13
13
  import { StockLevel } from "@/components/stock-level";
14
14
  import { MovementDialog } from "@/components/movement-dialog";
15
15
  import { money, parseAmount, parseCount, stockState } from "@/lib/stock";
16
+ import { RequireAuth } from "@/components/require-auth";
16
17
  import { Workspace, type ProductRow } from "./workspace";
17
18
 
18
19
  export function ProductsView({
19
- email,
20
20
  openNew,
21
21
  initialFilter,
22
22
  }: {
23
- email: string;
24
23
  openNew?: boolean;
25
24
  initialFilter?: string;
26
25
  }) {
@@ -53,7 +52,8 @@ export function ProductsView({
53
52
  }, []);
54
53
 
55
54
  return (
56
- <Workspace email={email} pathname="/">
55
+ <RequireAuth title="Inventory" description="Your team shares one stock ledger. Anyone with an account sees it.">
56
+ <Workspace pathname="/">
57
57
  {(data) => {
58
58
  const level = (id: string) => data.levels.get(id) ?? 0;
59
59
 
@@ -208,6 +208,7 @@ export function ProductsView({
208
208
  </>
209
209
  );
210
210
  }}
211
- </Workspace>
211
+ </Workspace>
212
+ </RequireAuth>
212
213
  );
213
214
  }
@@ -38,20 +38,27 @@ export interface WorkspaceData {
38
38
  * damaged.
39
39
  */
40
40
  export function Workspace({
41
- email,
42
41
  pathname,
43
42
  children,
44
43
  }: {
45
- email: string;
46
44
  pathname: string;
47
45
  children: (data: WorkspaceData) => React.ReactNode;
48
46
  }) {
49
47
  const router = useRouter();
50
- const { signOut } = useAuth();
48
+ // The signed-in identity comes from the CLIENT session, not from an SSR
49
+ // prop: the session cookie is SameSite=Lax and browsers withhold it inside
50
+ // the builder's cross-site preview iframe, so a server-resolved email would
51
+ // be empty exactly where this app is most often viewed.
52
+ const { signOut, userId } = useAuth();
51
53
  const { data: products, loading } = db.useQuery<ProductRow>("Product");
52
54
  const { data: movements } = db.useQuery<MovementRow>("Movement");
53
55
  const { data: users } = db.useQuery<UserRow>("User");
54
56
 
57
+ // Whoever is signed in, named from the synced User row rather than an
58
+ // SSR prop — see the note on useAuth above.
59
+ const signedInEmail =
60
+ (users ?? []).find((user) => user.id === userId)?.email ?? "";
61
+
55
62
  const [paletteOpen, setPaletteOpen] = useState(false);
56
63
  const seeded = useRef(false);
57
64
 
@@ -133,14 +140,11 @@ export function Workspace({
133
140
  <div className="flex h-screen overflow-hidden">
134
141
  <Sidebar
135
142
  workspace="Inventory"
136
- email={email}
143
+ email={signedInEmail}
137
144
  pathname={pathname}
138
145
  counts={{ "/": summary.skuCount }}
139
146
  onOpenCommand={() => setPaletteOpen(true)}
140
- onSignOut={async () => {
141
- await signOut();
142
- window.location.assign("/login");
143
- }}
147
+ onSignOut={() => void signOut()}
144
148
  />
145
149
  <main className="flex min-w-0 flex-1 flex-col">{children(data)}</main>
146
150
 
@@ -16,9 +16,10 @@ import { Label } from "@/components/ui/label";
16
16
  * then `persistSession` writes the token so the sync engine and `callFn`
17
17
  * authenticate as this user on the next load.
18
18
  *
19
- * The navigation is a full `assign`, not a client route change, so the SSR
20
- * runtime re-resolves auth from the HttpOnly cookie and the first paint of the
21
- * workspace is already signed in.
19
+ * No navigation afterwards: `persistSession` notifies the sync engine and the
20
+ * client-side gate swaps the app in. Reloading would hand the decision to the
21
+ * SSR cookie, which browsers withhold inside the builder's cross-site preview
22
+ * iframe — that's the sign-in loop this avoids.
22
23
  */
23
24
  export function AuthForm() {
24
25
  const [mode, setMode] = useState<"login" | "signup">("login");
@@ -36,8 +37,10 @@ export function AuthForm() {
36
37
  mode === "login"
37
38
  ? await passwordLogin({ email, password })
38
39
  : await passwordRegister({ email, password });
40
+ // No navigation: persistSession notifies the sync engine, RequireAuth
41
+ // re-renders, and the app appears in place. A full page load here would
42
+ // depend on the SSR session cookie, which a cross-site iframe withholds.
39
43
  persistSession(session);
40
- window.location.assign("/");
41
44
  } catch (err) {
42
45
  setError(messageFor(err));
43
46
  setPending(false);
@@ -0,0 +1,66 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { useAuth } from "@pylonsync/client";
5
+ import { AuthForm } from "@/components/auth-form";
6
+
7
+ /**
8
+ * Client-side auth gate.
9
+ *
10
+ * Deliberately NOT a server-side `response.redirect("/login")`. The session
11
+ * cookie is `SameSite=Lax`, which browsers withhold on cross-site iframe
12
+ * navigations — and this app is shown inside an iframe in the Pylon Cloud
13
+ * builder's live preview. An SSR gate there sees every request as anonymous no
14
+ * matter how many times you sign in, so signing in bounces straight back to the
15
+ * form. The token in local storage is visible to the client either way, so the
16
+ * gate belongs here.
17
+ *
18
+ * It also removes a full page load from the sign-in path: authenticating swaps
19
+ * this component's children in place instead of navigating.
20
+ */
21
+ export function RequireAuth({
22
+ title,
23
+ description,
24
+ children,
25
+ }: {
26
+ title: string;
27
+ description: string;
28
+ children: React.ReactNode;
29
+ }) {
30
+ const { isSignedIn, isLoaded } = useAuth();
31
+
32
+ // `isLoaded` covers the beat before the engine has resolved the stored token.
33
+ // Rendering the form during it would flash a sign-in screen at someone who is
34
+ // already signed in.
35
+ if (!isLoaded) {
36
+ return (
37
+ <main className="flex min-h-screen items-center justify-center">
38
+ <span className="text-[13px] text-muted-foreground">Loading…</span>
39
+ </main>
40
+ );
41
+ }
42
+
43
+ if (!isSignedIn) {
44
+ return (
45
+ <main className="flex min-h-screen items-center justify-center px-6">
46
+ <div className="w-full max-w-[320px]">
47
+ <div className="mb-7 flex items-center gap-2">
48
+ <span className="flex size-7 items-center justify-center rounded-md bg-primary text-[12px] font-bold text-primary-foreground">
49
+ {title.slice(0, 1).toUpperCase()}
50
+ </span>
51
+ <span className="text-[15px] font-semibold tracking-tight">{title}</span>
52
+ </div>
53
+ <h1 className="text-[19px] font-semibold tracking-tight">
54
+ Sign in to your workspace
55
+ </h1>
56
+ <p className="mt-1 text-[13px] text-muted-foreground">{description}</p>
57
+ <div className="mt-6">
58
+ <AuthForm />
59
+ </div>
60
+ </div>
61
+ </main>
62
+ );
63
+ }
64
+
65
+ return <>{children}</>;
66
+ }
@@ -10,13 +10,15 @@ import { EmptyState } from "@/components/empty-state";
10
10
  import { RecordDialog } from "@/components/record-dialog";
11
11
  import { Avatar } from "@/components/avatar";
12
12
  import { money, totals } from "@/lib/billing";
13
+ import { RequireAuth } from "@/components/require-auth";
13
14
  import { Workspace, type ClientRow } from "../workspace";
14
15
 
15
- export function ClientsView({ email }: { email: string }) {
16
+ export function ClientsView() {
16
17
  const [open, setOpen] = useState(false);
17
18
 
18
19
  return (
19
- <Workspace email={email} pathname="/clients">
20
+ <RequireAuth title="Invoices" description="Your team shares one set of books. Anyone with an account sees it.">
21
+ <Workspace pathname="/clients">
20
22
  {(data) => {
21
23
  // Outstanding per client, derived rather than stored — one less thing
22
24
  // to keep in sync when a payment lands.
@@ -125,6 +127,7 @@ export function ClientsView({ email }: { email: string }) {
125
127
  </>
126
128
  );
127
129
  }}
128
- </Workspace>
130
+ </Workspace>
131
+ </RequireAuth>
129
132
  );
130
133
  }
@@ -1,4 +1,4 @@
1
- import React, { use } from "react";
1
+ import React from "react";
2
2
  import type { Metadata, PageProps } from "@pylonsync/react";
3
3
  import { ClientsView } from "./clients-view";
4
4
 
@@ -7,11 +7,6 @@ export const metadata: Metadata = {
7
7
  robots: "noindex",
8
8
  };
9
9
 
10
- export default function ClientsPage({ auth, response, serverData }: PageProps) {
11
- if (!auth.user_id || auth.user_id.startsWith("guest_")) {
12
- response.redirect("/login");
13
- return null;
14
- }
15
- const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
16
- return <ClientsView email={me?.email ?? ""} />;
10
+ export default function ClientsPage({}: PageProps) {
11
+ return <ClientsView />;
17
12
  }
@@ -13,20 +13,20 @@ import { TotalsPanel } from "@/components/totals-panel";
13
13
  import { PaymentDialog } from "@/components/payment-dialog";
14
14
  import { relativeTime } from "@/lib/format";
15
15
  import { STATUSES, displayStatus, money, totals } from "@/lib/billing";
16
+ import { RequireAuth } from "@/components/require-auth";
16
17
  import { Workspace } from "../../workspace";
17
18
 
18
19
  export function InvoiceView({
19
- email,
20
20
  invoiceId,
21
21
  }: {
22
- email: string;
23
22
  invoiceId: string;
24
23
  }) {
25
24
  const router = useRouter();
26
25
  const [payOpen, setPayOpen] = useState(false);
27
26
 
28
27
  return (
29
- <Workspace email={email} pathname="/">
28
+ <RequireAuth title="Invoices" description="Your team shares one set of books. Anyone with an account sees it.">
29
+ <Workspace pathname="/">
30
30
  {(data) => {
31
31
  const invoice = data.invoices.find((i) => i.id === invoiceId);
32
32
 
@@ -239,7 +239,8 @@ export function InvoiceView({
239
239
  </>
240
240
  );
241
241
  }}
242
- </Workspace>
242
+ </Workspace>
243
+ </RequireAuth>
243
244
  );
244
245
  }
245
246
 
@@ -1,4 +1,4 @@
1
- import React, { use } from "react";
1
+ import React from "react";
2
2
  import type { Metadata, PageProps } from "@pylonsync/react";
3
3
  import { InvoiceView } from "./invoice-view";
4
4
 
@@ -8,16 +8,6 @@ export const metadata: Metadata = {
8
8
  };
9
9
 
10
10
  /** `app/invoices/[id]/page.tsx` -> `/invoices/:id`. */
11
- export default function InvoicePage({
12
- auth,
13
- response,
14
- params,
15
- serverData,
16
- }: PageProps<{ id: string }>) {
17
- if (!auth.user_id || auth.user_id.startsWith("guest_")) {
18
- response.redirect("/login");
19
- return null;
20
- }
21
- const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
22
- return <InvoiceView email={me?.email ?? ""} invoiceId={params.id} />;
11
+ export default function InvoicePage({ params }: PageProps<{ id: string }>) {
12
+ return <InvoiceView invoiceId={params.id} />;
23
13
  }
@@ -16,13 +16,12 @@ import {
16
16
  money,
17
17
  totals,
18
18
  } from "@/lib/billing";
19
+ import { RequireAuth } from "@/components/require-auth";
19
20
  import { Workspace, type InvoiceRow } from "./workspace";
20
21
 
21
22
  export function InvoicesView({
22
- email,
23
23
  openNew,
24
24
  }: {
25
- email: string;
26
25
  openNew?: boolean;
27
26
  }) {
28
27
  const router = useRouter();
@@ -67,7 +66,8 @@ export function InvoicesView({
67
66
  }, [creating]);
68
67
 
69
68
  return (
70
- <Workspace email={email} pathname="/">
69
+ <RequireAuth title="Invoices" description="Your team shares one set of books. Anyone with an account sees it.">
70
+ <Workspace pathname="/">
71
71
  {(data) => {
72
72
  const columns: ColumnDef<InvoiceRow>[] = [
73
73
  {
@@ -178,6 +178,7 @@ export function InvoicesView({
178
178
  </>
179
179
  );
180
180
  }}
181
- </Workspace>
181
+ </Workspace>
182
+ </RequireAuth>
182
183
  );
183
184
  }
@@ -9,13 +9,13 @@ interface LayoutProps {
9
9
  * lives in `app/workspace.tsx` instead, because it needs the synced data; this
10
10
  * layer only sets up the page.
11
11
  *
12
- * `class="dark"` makes dark the default theme. The light tokens in globals.css
13
- * are complete and tested, so removing the class flips the whole app; swap it
14
- * for a stored preference when you want a toggle.
12
+ * Light by default. The dark tokens in globals.css are complete, so adding
13
+ * `className="dark"` here flips the whole app or wire it to a stored
14
+ * preference, or to `prefers-color-scheme`, when you want a toggle.
15
15
  */
16
16
  export default function RootLayout({ children }: LayoutProps) {
17
17
  return (
18
- <html lang="en" className="dark">
18
+ <html lang="en">
19
19
  <head>
20
20
  <meta charSet="utf-8" />
21
21
  <meta name="viewport" content="width=device-width, initial-scale=1" />