@pylonsync/create-pylon 0.3.345 → 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
@@ -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 { InvoicesView } from "./invoices-view";
4
4
 
@@ -10,27 +10,11 @@ export const metadata: Metadata = {
10
10
  /**
11
11
  * `/` — the invoice 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
- *
16
- * The signed-in email comes from the User row — `PageAuth` is
17
- * `{ user_id, is_admin, tenant_id, roles }` and carries no email.
18
13
  */
19
- export default function InvoicesPage({
20
- auth,
21
- response,
22
- searchParams,
23
- serverData,
24
- }: PageProps) {
25
- if (!auth.user_id || auth.user_id.startsWith("guest_")) {
26
- response.redirect("/login");
27
- return null;
28
- }
29
- const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
14
+ export default function InvoicesPage({ searchParams }: PageProps) {
30
15
  return (
31
16
  <InvoicesView
32
- email={me?.email ?? ""}
33
- openNew={searchParams?.new === "invoice"}
17
+ openNew={searchParams?.new === "invoice"}
34
18
  />
35
19
  );
36
20
  }
@@ -46,20 +46,28 @@ export interface WorkspaceData {
46
46
  * the moment one of them marks it paid.
47
47
  */
48
48
  export function Workspace({
49
- email,
50
49
  pathname,
51
50
  children,
52
51
  }: {
53
- email: string;
54
52
  pathname: string;
55
53
  children: (data: WorkspaceData) => React.ReactNode;
56
54
  }) {
57
55
  const router = useRouter();
58
- const { signOut } = useAuth();
56
+ // The signed-in identity comes from the CLIENT session, not from an SSR
57
+ // prop: the session cookie is SameSite=Lax and browsers withhold it inside
58
+ // the builder's cross-site preview iframe, so a server-resolved email would
59
+ // be empty exactly where this app is most often viewed.
60
+ const { signOut, userId } = useAuth();
59
61
  const { data: invoices, loading } = db.useQuery<InvoiceRow>("Invoice");
60
62
  const { data: clients } = db.useQuery<ClientRow>("Client");
61
63
  const { data: items } = db.useQuery<LineItemRow>("LineItem");
62
64
  const { data: payments } = db.useQuery<PaymentRow>("Payment");
65
+ const { data: users } = db.useQuery<UserRow>("User");
66
+
67
+ // Whoever is signed in, named from the synced User row rather than an
68
+ // SSR prop — see the note on useAuth above.
69
+ const signedInEmail =
70
+ (users ?? []).find((user) => user.id === userId)?.email ?? "";
63
71
 
64
72
  const [paletteOpen, setPaletteOpen] = useState(false);
65
73
  const seeded = useRef(false);
@@ -140,14 +148,11 @@ export function Workspace({
140
148
  <div className="flex h-screen overflow-hidden">
141
149
  <Sidebar
142
150
  workspace="Invoices"
143
- email={email}
151
+ email={signedInEmail}
144
152
  pathname={pathname}
145
153
  counts={{ "/": invoiceList.length, "/clients": clientList.length }}
146
154
  onOpenCommand={() => setPaletteOpen(true)}
147
- onSignOut={async () => {
148
- await signOut();
149
- window.location.assign("/login");
150
- }}
155
+ onSignOut={() => void signOut()}
151
156
  />
152
157
  <main className="flex min-w-0 flex-1 flex-col">{children(data)}</main>
153
158
 
@@ -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 { billableCents, duration, minutesForProject, money } from "@/lib/work";
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="Projects" description="Your team shares one set of projects. Anyone with an account sees it.">
21
+ <Workspace pathname="/clients">
20
22
  {(data) => {
21
23
  const forClient = (clientId: string) =>
22
24
  data.projects.filter((p) => p.clientId === clientId);
@@ -121,6 +123,7 @@ export function ClientsView({ email }: { email: string }) {
121
123
  </>
122
124
  );
123
125
  }}
124
- </Workspace>
126
+ </Workspace>
127
+ </RequireAuth>
125
128
  );
126
129
  }
@@ -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
  }
@@ -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" />
@@ -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 { ProjectsView } from "./projects-view";
4
4
 
@@ -10,21 +10,9 @@ export const metadata: Metadata = {
10
10
  /**
11
11
  * `/` — the project 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 ProjectsPage({
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 ProjectsPage({ searchParams }: PageProps) {
27
15
  return (
28
- <ProjectsView email={me?.email ?? ""} openNew={searchParams?.new === "project"} />
16
+ <ProjectsView openNew={searchParams?.new === "project"} />
29
17
  );
30
18
  }
@@ -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 { ProjectView } from "./project-view";
4
4
 
@@ -8,16 +8,6 @@ export const metadata: Metadata = {
8
8
  };
9
9
 
10
10
  /** `app/projects/[id]/page.tsx` -> `/projects/:id`. */
11
- export default function ProjectPage({
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 <ProjectView email={me?.email ?? ""} projectId={params.id} />;
11
+ export default function ProjectPage({ params }: PageProps<{ id: string }>) {
12
+ return <ProjectView projectId={params.id} />;
23
13
  }
@@ -13,13 +13,12 @@ import { BoardSkeleton } from "@/components/board-skeleton";
13
13
  import { BudgetBar } from "@/components/budget-bar";
14
14
  import { TimeDialog } from "@/components/time-dialog";
15
15
  import { PROJECT_STATUSES, duration, minutesForProject, progress } from "@/lib/work";
16
+ import { RequireAuth } from "@/components/require-auth";
16
17
  import { Workspace } from "../../workspace";
17
18
 
18
19
  export function ProjectView({
19
- email,
20
20
  projectId,
21
21
  }: {
22
- email: string;
23
22
  projectId: string;
24
23
  }) {
25
24
  const router = useRouter();
@@ -27,7 +26,8 @@ export function ProjectView({
27
26
  const [timeFor, setTimeFor] = useState<{ id: string; title: string } | null>(null);
28
27
 
29
28
  return (
30
- <Workspace email={email} pathname="/">
29
+ <RequireAuth title="Projects" description="Your team shares one set of projects. Anyone with an account sees it.">
30
+ <Workspace pathname="/">
31
31
  {(data) => {
32
32
  const project = data.projects.find((p) => p.id === projectId);
33
33
 
@@ -174,6 +174,7 @@ export function ProjectView({
174
174
  </>
175
175
  );
176
176
  }}
177
- </Workspace>
177
+ </Workspace>
178
+ </RequireAuth>
178
179
  );
179
180
  }
@@ -16,20 +16,20 @@ import {
16
16
  parseDuration,
17
17
  progress,
18
18
  } from "@/lib/work";
19
+ import { RequireAuth } from "@/components/require-auth";
19
20
  import { Workspace, type ProjectRow } from "./workspace";
20
21
 
21
22
  export function ProjectsView({
22
- email,
23
23
  openNew,
24
24
  }: {
25
- email: string;
26
25
  openNew?: boolean;
27
26
  }) {
28
27
  const router = useRouter();
29
28
  const [open, setOpen] = useState(Boolean(openNew));
30
29
 
31
30
  return (
32
- <Workspace email={email} pathname="/">
31
+ <RequireAuth title="Projects" description="Your team shares one set of projects. Anyone with an account sees it.">
32
+ <Workspace pathname="/">
33
33
  {(data) => {
34
34
  const columns: ColumnDef<ProjectRow>[] = [
35
35
  {
@@ -176,6 +176,7 @@ export function ProjectsView({
176
176
  </>
177
177
  );
178
178
  }}
179
- </Workspace>
179
+ </Workspace>
180
+ </RequireAuth>
180
181
  );
181
182
  }
@@ -47,22 +47,29 @@ export interface WorkspaceData {
47
47
  * a standup doesn\'t start with reconciling two views of the work.
48
48
  */
49
49
  export function Workspace({
50
- email,
51
50
  pathname,
52
51
  children,
53
52
  }: {
54
- email: string;
55
53
  pathname: string;
56
54
  children: (data: WorkspaceData) => React.ReactNode;
57
55
  }) {
58
56
  const router = useRouter();
59
- const { signOut } = useAuth();
57
+ // The signed-in identity comes from the CLIENT session, not from an SSR
58
+ // prop: the session cookie is SameSite=Lax and browsers withhold it inside
59
+ // the builder's cross-site preview iframe, so a server-resolved email would
60
+ // be empty exactly where this app is most often viewed.
61
+ const { signOut, userId } = useAuth();
60
62
  const { data: projects, loading } = db.useQuery<ProjectRow>("Project");
61
63
  const { data: clients } = db.useQuery<ClientRow>("Client");
62
64
  const { data: tasks } = db.useQuery<TaskRow>("Task");
63
65
  const { data: entries } = db.useQuery<TimeEntryRow>("TimeEntry");
64
66
  const { data: users } = db.useQuery<UserRow>("User");
65
67
 
68
+ // Whoever is signed in, named from the synced User row rather than an
69
+ // SSR prop — see the note on useAuth above.
70
+ const signedInEmail =
71
+ (users ?? []).find((user) => user.id === userId)?.email ?? "";
72
+
66
73
  const [paletteOpen, setPaletteOpen] = useState(false);
67
74
  const seeded = useRef(false);
68
75
 
@@ -153,17 +160,14 @@ export function Workspace({
153
160
  <div className="flex h-screen overflow-hidden">
154
161
  <Sidebar
155
162
  workspace="Projects"
156
- email={email}
163
+ email={signedInEmail}
157
164
  pathname={pathname}
158
165
  counts={{
159
166
  "/": projectList.filter((p) => p.status === "active").length,
160
167
  "/clients": clientList.length,
161
168
  }}
162
169
  onOpenCommand={() => setPaletteOpen(true)}
163
- onSignOut={async () => {
164
- await signOut();
165
- window.location.assign("/login");
166
- }}
170
+ onSignOut={() => void signOut()}
167
171
  />
168
172
  <main className="flex min-w-0 flex-1 flex-col">{children(data)}</main>
169
173
 
@@ -0,0 +1,125 @@
1
+ "use client";
2
+
3
+ import React, { useState } from "react";
4
+ import {
5
+ ApiError,
6
+ passwordLogin,
7
+ passwordRegister,
8
+ persistSession,
9
+ } from "@pylonsync/client";
10
+ import { Button } from "@/components/ui/button";
11
+ import { Input } from "@/components/ui/input";
12
+ import { Label } from "@/components/ui/label";
13
+
14
+ /**
15
+ * One form, two modes. Calls the built-in auth API (`/api/auth/password/*`),
16
+ * then `persistSession` writes the token so the sync engine and `callFn`
17
+ * authenticate as this user on the next load.
18
+ *
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.
23
+ */
24
+ export function AuthForm() {
25
+ const [mode, setMode] = useState<"login" | "signup">("login");
26
+ const [email, setEmail] = useState("");
27
+ const [password, setPassword] = useState("");
28
+ const [error, setError] = useState<string | null>(null);
29
+ const [pending, setPending] = useState(false);
30
+
31
+ async function onSubmit(event: React.FormEvent) {
32
+ event.preventDefault();
33
+ setError(null);
34
+ setPending(true);
35
+ try {
36
+ const session =
37
+ mode === "login"
38
+ ? await passwordLogin({ email, password })
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.
43
+ persistSession(session);
44
+ } catch (err) {
45
+ setError(messageFor(err));
46
+ setPending(false);
47
+ }
48
+ }
49
+
50
+ return (
51
+ <div className="space-y-5">
52
+ <form onSubmit={onSubmit} className="space-y-3.5">
53
+ <div className="space-y-1.5">
54
+ <Label htmlFor="email">Email</Label>
55
+ <Input
56
+ id="email"
57
+ type="email"
58
+ value={email}
59
+ onChange={(event) => setEmail(event.target.value)}
60
+ required
61
+ autoComplete="email"
62
+ placeholder="you@company.com"
63
+ />
64
+ </div>
65
+ <div className="space-y-1.5">
66
+ <Label htmlFor="password">Password</Label>
67
+ <Input
68
+ id="password"
69
+ type="password"
70
+ value={password}
71
+ onChange={(event) => setPassword(event.target.value)}
72
+ required
73
+ autoComplete={mode === "login" ? "current-password" : "new-password"}
74
+ placeholder={mode === "login" ? "Your password" : "At least 10 characters"}
75
+ />
76
+ </div>
77
+ {error ? (
78
+ <p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-[12px] leading-snug text-destructive">
79
+ {error}
80
+ </p>
81
+ ) : null}
82
+ <Button type="submit" disabled={pending} className="w-full">
83
+ {pending ? "…" : mode === "login" ? "Sign in" : "Create account"}
84
+ </Button>
85
+ </form>
86
+
87
+ <p className="text-center text-[12px] text-muted-foreground">
88
+ {mode === "login" ? "First time here?" : "Already have an account?"}{" "}
89
+ <button
90
+ type="button"
91
+ onClick={() => {
92
+ setMode(mode === "login" ? "signup" : "login");
93
+ setError(null);
94
+ }}
95
+ className="font-medium text-foreground underline underline-offset-2"
96
+ >
97
+ {mode === "login" ? "Create an account" : "Sign in"}
98
+ </button>
99
+ </p>
100
+ </div>
101
+ );
102
+ }
103
+
104
+ // `ApiError` carries a stable `.code`, so branch on the code rather than the
105
+ // message — the copy here is ours, the message is the framework's.
106
+ function messageFor(err: unknown): string {
107
+ if (err instanceof ApiError) {
108
+ switch (err.code) {
109
+ case "INVALID_CREDENTIALS":
110
+ return "Wrong email or password.";
111
+ case "USER_EXISTS":
112
+ return "That email is already registered — sign in instead.";
113
+ case "WEAK_PASSWORD":
114
+ return "Pick a longer password — at least 10 characters.";
115
+ case "PWNED_PASSWORD":
116
+ return "That password has appeared in a known breach. Choose another.";
117
+ case "RATE_LIMITED":
118
+ return "Too many attempts — try again in a minute.";
119
+ default:
120
+ return err.message;
121
+ }
122
+ }
123
+ if (err instanceof Error) return err.message;
124
+ return "Something went wrong. Try again.";
125
+ }
@@ -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
+ }
@@ -1,38 +0,0 @@
1
- import React from "react";
2
- import type { Metadata, PageProps } from "@pylonsync/react";
3
- import { AuthForm } from "./auth-form";
4
-
5
- export const metadata: Metadata = {
6
- title: "Sign in",
7
- robots: "noindex",
8
- };
9
-
10
- export default function LoginPage({ auth, response }: PageProps) {
11
- // Already signed in? Nothing to do here.
12
- if (auth.user_id && !auth.user_id.startsWith("guest_")) {
13
- response.redirect("/");
14
- return null;
15
- }
16
-
17
- return (
18
- <main className="flex min-h-screen items-center justify-center px-6">
19
- <div className="w-full max-w-[320px]">
20
- <div className="mb-7 flex items-center gap-2">
21
- <span className="flex size-7 items-center justify-center rounded-md bg-primary text-[12px] font-bold text-primary-foreground">
22
- C
23
- </span>
24
- <span className="text-[15px] font-semibold tracking-tight">CRM</span>
25
- </div>
26
- <h1 className="text-[19px] font-semibold tracking-tight">
27
- Sign in to your workspace
28
- </h1>
29
- <p className="mt-1 text-[13px] text-muted-foreground">
30
- Your team shares one pipeline. Anyone with an account sees it.
31
- </p>
32
- <div className="mt-6">
33
- <AuthForm />
34
- </div>
35
- </div>
36
- </main>
37
- );
38
- }