@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.
- package/package.json +1 -1
- package/templates/crm/README.md +5 -4
- package/templates/crm/app/companies/companies-view.tsx +6 -3
- package/templates/crm/app/companies/page.tsx +3 -8
- package/templates/crm/app/contacts/contacts-view.tsx +6 -3
- package/templates/crm/app/contacts/page.tsx +3 -8
- package/templates/crm/app/deals/[id]/deal-view.tsx +6 -3
- package/templates/crm/app/deals/[id]/page.tsx +3 -13
- package/templates/crm/app/layout.tsx +4 -4
- package/templates/crm/app/page.tsx +2 -19
- package/templates/crm/app/pipeline-view.tsx +5 -4
- package/templates/crm/app/workspace.tsx +12 -10
- package/templates/crm/{app/login → components}/auth-form.tsx +7 -4
- package/templates/crm/components/require-auth.tsx +66 -0
- package/templates/helpdesk/app/customers/customers-view.tsx +6 -3
- package/templates/helpdesk/app/customers/page.tsx +3 -8
- package/templates/helpdesk/app/inbox-view.tsx +5 -4
- package/templates/helpdesk/app/layout.tsx +4 -4
- package/templates/helpdesk/app/page.tsx +3 -19
- package/templates/helpdesk/app/tickets/[id]/page.tsx +3 -13
- package/templates/helpdesk/app/tickets/[id]/ticket-view.tsx +5 -4
- package/templates/helpdesk/app/workspace.tsx +12 -8
- package/templates/{inventory/app/login → helpdesk/components}/auth-form.tsx +7 -4
- package/templates/helpdesk/components/require-auth.tsx +66 -0
- package/templates/inventory/app/layout.tsx +4 -4
- package/templates/inventory/app/movements/movements-view.tsx +6 -3
- package/templates/inventory/app/movements/page.tsx +3 -8
- package/templates/inventory/app/page.tsx +3 -16
- package/templates/inventory/app/products/[id]/page.tsx +3 -13
- package/templates/inventory/app/products/[id]/product-view.tsx +5 -4
- package/templates/inventory/app/products-view.tsx +5 -4
- package/templates/inventory/app/workspace.tsx +12 -8
- package/templates/{helpdesk/app/login → inventory/components}/auth-form.tsx +7 -4
- package/templates/inventory/components/require-auth.tsx +66 -0
- package/templates/invoices/app/clients/clients-view.tsx +6 -3
- package/templates/invoices/app/clients/page.tsx +3 -8
- package/templates/invoices/app/invoices/[id]/invoice-view.tsx +5 -4
- package/templates/invoices/app/invoices/[id]/page.tsx +3 -13
- package/templates/invoices/app/invoices-view.tsx +5 -4
- package/templates/invoices/app/layout.tsx +4 -4
- package/templates/invoices/app/page.tsx +3 -19
- package/templates/invoices/app/workspace.tsx +13 -8
- package/templates/invoices/{app/login → components}/auth-form.tsx +7 -4
- package/templates/invoices/components/require-auth.tsx +66 -0
- package/templates/projects/app/clients/clients-view.tsx +6 -3
- package/templates/projects/app/clients/page.tsx +3 -8
- package/templates/projects/app/layout.tsx +4 -4
- package/templates/projects/app/page.tsx +3 -15
- package/templates/projects/app/projects/[id]/page.tsx +3 -13
- package/templates/projects/app/projects/[id]/project-view.tsx +5 -4
- package/templates/projects/app/projects-view.tsx +5 -4
- package/templates/projects/app/workspace.tsx +12 -8
- package/templates/projects/components/auth-form.tsx +125 -0
- package/templates/projects/components/require-auth.tsx +66 -0
- package/templates/crm/app/login/page.tsx +0 -38
- package/templates/helpdesk/app/login/page.tsx +0 -38
- package/templates/inventory/app/login/page.tsx +0 -38
- package/templates/invoices/app/login/page.tsx +0 -38
- package/templates/projects/app/login/auth-form.tsx +0 -122
- 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
|
-
|
|
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={
|
|
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={
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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"
|
|
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(
|
|
19
|
+
export function MovementsView() {
|
|
19
20
|
return (
|
|
20
|
-
<
|
|
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
|
-
|
|
103
|
+
</Workspace>
|
|
104
|
+
</RequireAuth>
|
|
102
105
|
);
|
|
103
106
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import 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({
|
|
11
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
143
|
+
email={signedInEmail}
|
|
137
144
|
pathname={pathname}
|
|
138
145
|
counts={{ "/": summary.skuCount }}
|
|
139
146
|
onOpenCommand={() => setPaletteOpen(true)}
|
|
140
|
-
onSignOut={
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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(
|
|
16
|
+
export function ClientsView() {
|
|
16
17
|
const [open, setOpen] = useState(false);
|
|
17
18
|
|
|
18
19
|
return (
|
|
19
|
-
<
|
|
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
|
-
|
|
130
|
+
</Workspace>
|
|
131
|
+
</RequireAuth>
|
|
129
132
|
);
|
|
130
133
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import 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({
|
|
11
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
242
|
+
</Workspace>
|
|
243
|
+
</RequireAuth>
|
|
243
244
|
);
|
|
244
245
|
}
|
|
245
246
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import 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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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"
|
|
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" />
|