@pylonsync/create-pylon 0.3.346 → 0.3.348
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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import 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
|
-
|
|
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
|
-
|
|
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={
|
|
151
|
+
email={signedInEmail}
|
|
144
152
|
pathname={pathname}
|
|
145
153
|
counts={{ "/": invoiceList.length, "/clients": clientList.length }}
|
|
146
154
|
onOpenCommand={() => setPaletteOpen(true)}
|
|
147
|
-
onSignOut={
|
|
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
|
-
*
|
|
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 { 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(
|
|
16
|
+
export function ClientsView() {
|
|
16
17
|
const [open, setOpen] = useState(false);
|
|
17
18
|
|
|
18
19
|
return (
|
|
19
|
-
<
|
|
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
|
-
|
|
126
|
+
</Workspace>
|
|
127
|
+
</RequireAuth>
|
|
125
128
|
);
|
|
126
129
|
}
|
|
@@ -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
|
}
|
|
@@ -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" />
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import 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
|
|
16
|
+
<ProjectsView openNew={searchParams?.new === "project"} />
|
|
29
17
|
);
|
|
30
18
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import 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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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={
|
|
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
|
-
}
|