@pylonsync/create-pylon 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-pylon.js +10 -2
- package/package.json +1 -1
- package/templates/backend/mobile/apps/api/.env.example +22 -0
- package/templates/backend/mobile/apps/api/README.md +31 -0
- package/templates/backend/mobile/apps/api/app.ts +89 -0
- package/templates/backend/mobile/apps/api/functions/_pylonRcUpsertEntitlement.ts +1 -0
- package/templates/backend/mobile/apps/api/functions/createNote.ts +44 -0
- package/templates/backend/mobile/apps/api/functions/revenuecatWebhook.ts +1 -0
- package/templates/backend/mobile/apps/api/functions/syncEntitlements.ts +1 -0
- package/templates/backend/mobile/apps/api/lib/purchases.ts +22 -0
- package/templates/backend/mobile/apps/api/package.json +23 -0
- package/templates/backend/mobile/apps/api/tsconfig.json +13 -0
- package/templates/expo/mobile/apps/expo/.env.example +19 -0
- package/templates/expo/mobile/apps/expo/README.md +34 -0
- package/templates/expo/mobile/apps/expo/STORE.md +82 -0
- package/templates/expo/mobile/apps/expo/app/(auth)/_layout.tsx +6 -0
- package/templates/expo/mobile/apps/expo/app/(auth)/sign-in.tsx +194 -0
- package/templates/expo/mobile/apps/expo/app/(auth)/verify.tsx +78 -0
- package/templates/expo/mobile/apps/expo/app/(onboarding)/_layout.tsx +6 -0
- package/templates/expo/mobile/apps/expo/app/(onboarding)/welcome.tsx +125 -0
- package/templates/expo/mobile/apps/expo/app/(tabs)/_layout.tsx +27 -0
- package/templates/expo/mobile/apps/expo/app/(tabs)/index.tsx +128 -0
- package/templates/expo/mobile/apps/expo/app/(tabs)/settings.tsx +122 -0
- package/templates/expo/mobile/apps/expo/app/_layout.tsx +61 -0
- package/templates/expo/mobile/apps/expo/app/index.tsx +15 -0
- package/templates/expo/mobile/apps/expo/app/paywall.tsx +177 -0
- package/templates/expo/mobile/apps/expo/app.config.ts +70 -0
- package/templates/expo/mobile/apps/expo/assets/adaptive-icon.png +0 -0
- package/templates/expo/mobile/apps/expo/assets/icon.png +0 -0
- package/templates/expo/mobile/apps/expo/assets/splash-icon.png +0 -0
- package/templates/expo/mobile/apps/expo/babel.config.js +8 -0
- package/templates/expo/mobile/apps/expo/eas.json +45 -0
- package/templates/expo/mobile/apps/expo/package.json +52 -0
- package/templates/expo/mobile/apps/expo/src/analytics.ts +25 -0
- package/templates/expo/mobile/apps/expo/src/entitlements.ts +21 -0
- package/templates/expo/mobile/apps/expo/src/flags.ts +31 -0
- package/templates/expo/mobile/apps/expo/src/purchases.ts +164 -0
- package/templates/expo/mobile/apps/expo/src/pylon.ts +23 -0
- package/templates/expo/mobile/apps/expo/src/session.tsx +101 -0
- package/templates/expo/mobile/apps/expo/src/theme.ts +36 -0
- package/templates/expo/mobile/apps/expo/src/ui.tsx +187 -0
- package/templates/expo/mobile/apps/expo/tsconfig.json +10 -0
- package/templates/saas/.env.example +2 -0
- package/templates/saas/README.md +12 -8
- package/templates/saas/app/(auth)/auth-form.tsx +136 -95
- package/templates/saas/app/(auth)/forgot-password/page.tsx +20 -0
- package/templates/saas/app/(auth)/recovery-forms.tsx +135 -0
- package/templates/saas/app/(auth)/reset-password/page.tsx +24 -0
- package/templates/saas/app/(marketing)/layout.tsx +1 -1
- package/templates/saas/app/(marketing)/page.tsx +3 -52
- package/templates/saas/app/(marketing)/pricing/page.tsx +43 -0
- package/templates/saas/app/dashboard/dashboard-client.tsx +368 -18
- package/templates/saas/app/dashboard/page.tsx +30 -2
- package/templates/saas/app/dashboard/provision-workspace.tsx +10 -8
- package/templates/saas/app/dashboard/settings/page.tsx +3 -0
- package/templates/saas/app/onboarding/onboarding-client.tsx +250 -0
- package/templates/saas/app/onboarding/page.tsx +34 -0
- package/templates/saas/app/sitemap.ts +1 -0
- package/templates/saas/app.ts +18 -10
- package/templates/saas/components/pricing-table.tsx +97 -0
- package/templates/saas/components/setup-checklist.tsx +83 -0
- package/templates/saas/functions/completeOnboarding.ts +15 -0
- package/templates/saas/functions/createProject.ts +56 -0
- package/templates/saas/functions/dismissSetup.ts +14 -0
- package/templates/saas/functions/updateProfile.ts +18 -0
- package/templates/saas/lib/billing.ts +10 -3
- package/templates/saas/lib/plans.ts +94 -0
- package/templates/saas/lib/site.config.ts +13 -45
- package/templates/saas/tests/plans.test.ts +35 -0
- package/templates/saas/app/not-found.tsx +0 -44
|
@@ -1,38 +1,33 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import React, { useEffect, useState } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { Link } from "@pylonsync/react";
|
|
5
5
|
import {
|
|
6
6
|
passwordLogin,
|
|
7
7
|
passwordRegister,
|
|
8
8
|
persistSession,
|
|
9
|
-
createOrg,
|
|
10
9
|
ApiError,
|
|
11
10
|
} from "@pylonsync/client";
|
|
12
11
|
|
|
13
|
-
// The email/password form
|
|
14
|
-
//
|
|
15
|
-
//
|
|
12
|
+
// The email/password form shared by /login and /signup, plus an email-code
|
|
13
|
+
// path ("Email me a code") for people who never set a password. Every path
|
|
14
|
+
// calls the built-in auth API (`/api/auth/password/*`, `/api/auth/magic/*`).
|
|
16
15
|
//
|
|
17
|
-
// On success the server sets
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// Skipping this was a real footgun: a stale anonymous guest token left in
|
|
24
|
-
// localStorage would be sent as a `Bearer` on the engine's auth calls, where
|
|
25
|
-
// the server prefers it over the cookie — so `selectOrg` would 401 with
|
|
26
|
-
// "anonymous session" even though the cookie was a valid user.
|
|
16
|
+
// On success the server sets the session cookie AND returns the token; we
|
|
17
|
+
// call `persistSession` so the sync engine adopts the identity at once (a
|
|
18
|
+
// stale guest token in localStorage would otherwise shadow the cookie on the
|
|
19
|
+
// engine's calls). Sign-up then lands on /onboarding, which creates the
|
|
20
|
+
// workspace; sign-in lands on /dashboard.
|
|
27
21
|
export function AuthForm({ mode }: { mode: "login" | "signup" }) {
|
|
28
22
|
const [email, setEmail] = useState("");
|
|
29
23
|
const [password, setPassword] = useState("");
|
|
24
|
+
const [code, setCode] = useState("");
|
|
25
|
+
const [codeSent, setCodeSent] = useState(false);
|
|
26
|
+
const [method, setMethod] = useState<"password" | "code">("password");
|
|
30
27
|
const [error, setError] = useState<string | null>(null);
|
|
31
28
|
const [pending, setPending] = useState(false);
|
|
32
|
-
// OAuth buttons render only for providers the server
|
|
33
|
-
//
|
|
34
|
-
// login URL is a JSON error dead-end, not a login page. `null` while
|
|
35
|
-
// loading so nothing flashes in.
|
|
29
|
+
// OAuth buttons render only for providers the server has configured
|
|
30
|
+
// (GET /api/auth/providers). `null` while loading so nothing flashes in.
|
|
36
31
|
const [providers, setProviders] = useState<string[] | null>(null);
|
|
37
32
|
|
|
38
33
|
useEffect(() => {
|
|
@@ -50,37 +45,39 @@ export function AuthForm({ mode }: { mode: "login" | "signup" }) {
|
|
|
50
45
|
};
|
|
51
46
|
}, []);
|
|
52
47
|
|
|
48
|
+
const destination = mode === "login" ? "/dashboard" : "/onboarding";
|
|
49
|
+
|
|
53
50
|
async function onSubmit(e: React.FormEvent) {
|
|
54
51
|
e.preventDefault();
|
|
55
52
|
setError(null);
|
|
56
53
|
setPending(true);
|
|
57
54
|
try {
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
window.location.assign("/dashboard");
|
|
65
|
-
} else {
|
|
66
|
-
const session = await passwordRegister({ email, password });
|
|
67
|
-
persistSession(session);
|
|
68
|
-
// Auto-provision a first workspace so new accounts land in a ready
|
|
69
|
-
// dashboard instead of an empty first-run screen. Named "My Workspace"
|
|
70
|
-
// (renamable in Settings) and made the active tenant. Either way we land
|
|
71
|
-
// on /dashboard — if provisioning failed here, the dashboard's org-less
|
|
72
|
-
// safety net retries it.
|
|
73
|
-
try {
|
|
74
|
-
const org = await createOrg("My Workspace");
|
|
75
|
-
await db.sync.selectOrg(org.id);
|
|
76
|
-
} catch {
|
|
77
|
-
// Swallowed — the dashboard provisions on load if there's still no org.
|
|
55
|
+
if (method === "code") {
|
|
56
|
+
if (!codeSent) {
|
|
57
|
+
await postJson("/api/auth/magic/send", { email });
|
|
58
|
+
setCodeSent(true);
|
|
59
|
+
setPending(false);
|
|
60
|
+
return;
|
|
78
61
|
}
|
|
79
|
-
|
|
62
|
+
const session = await postJson<{ token: string; user_id?: string | null }>(
|
|
63
|
+
"/api/auth/magic/verify",
|
|
64
|
+
{ email, code },
|
|
65
|
+
);
|
|
66
|
+
persistSession({ token: session.token, user_id: session.user_id ?? "" });
|
|
67
|
+
// A code sign-in may have just created the account; the wizard
|
|
68
|
+
// sends an already-onboarded workspace straight to the dashboard.
|
|
69
|
+
window.location.assign("/onboarding");
|
|
70
|
+
return;
|
|
80
71
|
}
|
|
72
|
+
const session =
|
|
73
|
+
mode === "login"
|
|
74
|
+
? await passwordLogin({ email, password })
|
|
75
|
+
: await passwordRegister({ email, password });
|
|
76
|
+
persistSession(session);
|
|
77
|
+
window.location.assign(destination);
|
|
81
78
|
} catch (err) {
|
|
82
79
|
setError(messageFor(err));
|
|
83
|
-
setPending(false);
|
|
80
|
+
setPending(false);
|
|
84
81
|
}
|
|
85
82
|
}
|
|
86
83
|
|
|
@@ -96,18 +93,43 @@ export function AuthForm({ mode }: { mode: "login" | "signup" }) {
|
|
|
96
93
|
required
|
|
97
94
|
autoComplete="email"
|
|
98
95
|
placeholder="Enter your email"
|
|
96
|
+
disabled={codeSent}
|
|
99
97
|
/>
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
98
|
+
{method === "password" ? (
|
|
99
|
+
<IconField
|
|
100
|
+
label="Password"
|
|
101
|
+
type="password"
|
|
102
|
+
icon={<LockIcon />}
|
|
103
|
+
value={password}
|
|
104
|
+
onChange={setPassword}
|
|
105
|
+
required
|
|
106
|
+
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
|
107
|
+
placeholder={mode === "login" ? "Enter your password" : "Choose a password (10+ characters)"}
|
|
108
|
+
trailing={
|
|
109
|
+
mode === "login" ? (
|
|
110
|
+
<Link
|
|
111
|
+
href="/forgot-password"
|
|
112
|
+
className="text-[12px] text-zinc-500 underline underline-offset-2 hover:text-zinc-900"
|
|
113
|
+
>
|
|
114
|
+
Forgot password?
|
|
115
|
+
</Link>
|
|
116
|
+
) : null
|
|
117
|
+
}
|
|
118
|
+
/>
|
|
119
|
+
) : codeSent ? (
|
|
120
|
+
<IconField
|
|
121
|
+
label="6-digit code"
|
|
122
|
+
type="text"
|
|
123
|
+
icon={<LockIcon />}
|
|
124
|
+
value={code}
|
|
125
|
+
onChange={(v) => setCode(v.replace(/\D/g, "").slice(0, 6))}
|
|
126
|
+
required
|
|
127
|
+
autoComplete="one-time-code"
|
|
128
|
+
placeholder="123456"
|
|
129
|
+
hint={`We emailed a code to ${email}.`}
|
|
130
|
+
/>
|
|
131
|
+
) : null}
|
|
132
|
+
{mode === "signup" && method === "password" ? (
|
|
111
133
|
<p className="text-[12px] leading-snug text-zinc-500">
|
|
112
134
|
By joining, you agree to our{" "}
|
|
113
135
|
<a href="/company/terms" className="underline underline-offset-2">
|
|
@@ -117,7 +139,7 @@ export function AuthForm({ mode }: { mode: "login" | "signup" }) {
|
|
|
117
139
|
<a href="/company/privacy" className="underline underline-offset-2">
|
|
118
140
|
Privacy
|
|
119
141
|
</a>
|
|
120
|
-
.
|
|
142
|
+
.
|
|
121
143
|
</p>
|
|
122
144
|
) : null}
|
|
123
145
|
{error ? (
|
|
@@ -127,13 +149,34 @@ export function AuthForm({ mode }: { mode: "login" | "signup" }) {
|
|
|
127
149
|
) : null}
|
|
128
150
|
<button
|
|
129
151
|
type="submit"
|
|
130
|
-
disabled={pending}
|
|
152
|
+
disabled={pending || (method === "code" && codeSent && code.length !== 6)}
|
|
131
153
|
className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-zinc-900 text-sm font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-60"
|
|
132
154
|
>
|
|
133
|
-
{pending
|
|
155
|
+
{pending
|
|
156
|
+
? "…"
|
|
157
|
+
: method === "code"
|
|
158
|
+
? codeSent
|
|
159
|
+
? "Continue"
|
|
160
|
+
: "Email me a code"
|
|
161
|
+
: mode === "login"
|
|
162
|
+
? "Sign in"
|
|
163
|
+
: "Create account"}
|
|
134
164
|
</button>
|
|
135
165
|
</form>
|
|
136
166
|
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
onClick={() => {
|
|
170
|
+
setMethod(method === "password" ? "code" : "password");
|
|
171
|
+
setCodeSent(false);
|
|
172
|
+
setCode("");
|
|
173
|
+
setError(null);
|
|
174
|
+
}}
|
|
175
|
+
className="w-full text-center text-[13px] text-zinc-500 underline underline-offset-2 hover:text-zinc-900"
|
|
176
|
+
>
|
|
177
|
+
{method === "password" ? "Email me a sign-in code instead" : "Use a password instead"}
|
|
178
|
+
</button>
|
|
179
|
+
|
|
137
180
|
{/* Social sign-in — only for providers the server reports as
|
|
138
181
|
configured. Enable Google with PYLON_OAUTH_GOOGLE_CLIENT_ID /
|
|
139
182
|
_CLIENT_SECRET / _REDIRECT (GitHub: PYLON_OAUTH_GITHUB_*) and the
|
|
@@ -148,7 +191,7 @@ export function AuthForm({ mode }: { mode: "login" | "signup" }) {
|
|
|
148
191
|
|
|
149
192
|
{providers.includes("google") ? (
|
|
150
193
|
<a
|
|
151
|
-
href=
|
|
194
|
+
href={`/api/auth/login/google?callback=${destination}&redirect=1`}
|
|
152
195
|
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-white text-sm font-medium text-zinc-900 transition-colors hover:bg-zinc-50"
|
|
153
196
|
>
|
|
154
197
|
<GoogleIcon />
|
|
@@ -157,7 +200,7 @@ export function AuthForm({ mode }: { mode: "login" | "signup" }) {
|
|
|
157
200
|
) : null}
|
|
158
201
|
{providers.includes("github") ? (
|
|
159
202
|
<a
|
|
160
|
-
href=
|
|
203
|
+
href={`/api/auth/login/github?callback=${destination}&redirect=1`}
|
|
161
204
|
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-white text-sm font-medium text-zinc-900 transition-colors hover:bg-zinc-50"
|
|
162
205
|
>
|
|
163
206
|
<GitHubIcon />
|
|
@@ -170,44 +213,56 @@ export function AuthForm({ mode }: { mode: "login" | "signup" }) {
|
|
|
170
213
|
);
|
|
171
214
|
}
|
|
172
215
|
|
|
216
|
+
async function postJson<T = Record<string, unknown>>(path: string, body: unknown): Promise<T> {
|
|
217
|
+
const res = await fetch(path, {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: { "content-type": "application/json" },
|
|
220
|
+
credentials: "include",
|
|
221
|
+
body: JSON.stringify(body),
|
|
222
|
+
});
|
|
223
|
+
const data = (await res.json().catch(() => ({}))) as T & {
|
|
224
|
+
error?: { code?: string; message?: string };
|
|
225
|
+
};
|
|
226
|
+
if (!res.ok) {
|
|
227
|
+
throw new Error(data.error?.message ?? `Request failed (${res.status})`);
|
|
228
|
+
}
|
|
229
|
+
return data;
|
|
230
|
+
}
|
|
231
|
+
|
|
173
232
|
function IconField({
|
|
174
233
|
label,
|
|
175
234
|
icon,
|
|
176
235
|
value,
|
|
177
236
|
onChange,
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
placeholder,
|
|
237
|
+
trailing,
|
|
238
|
+
hint,
|
|
239
|
+
...rest
|
|
182
240
|
}: {
|
|
183
241
|
label: string;
|
|
184
242
|
icon: React.ReactNode;
|
|
185
243
|
value: string;
|
|
186
244
|
onChange: (v: string) => void;
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
placeholder?: string;
|
|
191
|
-
}) {
|
|
245
|
+
trailing?: React.ReactNode;
|
|
246
|
+
hint?: string;
|
|
247
|
+
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange">) {
|
|
192
248
|
return (
|
|
193
249
|
<label className="block">
|
|
194
|
-
<span className="mb-1.5
|
|
250
|
+
<span className="mb-1.5 flex items-center justify-between text-[13px] font-medium text-zinc-700">
|
|
195
251
|
{label}
|
|
252
|
+
{trailing}
|
|
196
253
|
</span>
|
|
197
|
-
<
|
|
198
|
-
<span className="pointer-events-none absolute left-3
|
|
254
|
+
<span className="relative block">
|
|
255
|
+
<span className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-zinc-400">
|
|
199
256
|
{icon}
|
|
200
257
|
</span>
|
|
201
258
|
<input
|
|
202
|
-
type={type}
|
|
203
259
|
value={value}
|
|
204
260
|
onChange={(e) => onChange(e.target.value)}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
placeholder={placeholder}
|
|
208
|
-
className="h-10 w-full rounded-lg border border-zinc-300 bg-white pl-9 pr-3 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-zinc-900 focus:ring-2 focus:ring-zinc-900/10"
|
|
261
|
+
className="h-10 w-full rounded-lg border border-zinc-300 bg-white pl-9 pr-3 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-zinc-900 focus:ring-2 focus:ring-zinc-900/10 disabled:bg-zinc-50 disabled:text-zinc-500"
|
|
262
|
+
{...rest}
|
|
209
263
|
/>
|
|
210
|
-
</
|
|
264
|
+
</span>
|
|
265
|
+
{hint ? <span className="mt-1.5 block text-[12px] text-zinc-500">{hint}</span> : null}
|
|
211
266
|
</label>
|
|
212
267
|
);
|
|
213
268
|
}
|
|
@@ -224,7 +279,7 @@ function MailIcon() {
|
|
|
224
279
|
function LockIcon() {
|
|
225
280
|
return (
|
|
226
281
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
227
|
-
<rect x="
|
|
282
|
+
<rect x="4" y="11" width="16" height="10" rx="2" />
|
|
228
283
|
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
|
229
284
|
</svg>
|
|
230
285
|
);
|
|
@@ -233,7 +288,7 @@ function LockIcon() {
|
|
|
233
288
|
function GitHubIcon() {
|
|
234
289
|
return (
|
|
235
290
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
|
236
|
-
<path d="M12
|
|
291
|
+
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
|
237
292
|
</svg>
|
|
238
293
|
);
|
|
239
294
|
}
|
|
@@ -241,28 +296,14 @@ function GitHubIcon() {
|
|
|
241
296
|
function GoogleIcon() {
|
|
242
297
|
return (
|
|
243
298
|
<svg width="16" height="16" viewBox="0 0 24 24" aria-hidden>
|
|
244
|
-
<path
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
/>
|
|
248
|
-
<path
|
|
249
|
-
fill="#34A853"
|
|
250
|
-
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
|
251
|
-
/>
|
|
252
|
-
<path
|
|
253
|
-
fill="#FBBC05"
|
|
254
|
-
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
|
|
255
|
-
/>
|
|
256
|
-
<path
|
|
257
|
-
fill="#EA4335"
|
|
258
|
-
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
|
259
|
-
/>
|
|
299
|
+
<path fill="#4285F4" d="M23.49 12.27c0-.79-.07-1.54-.19-2.27H12v4.51h6.47a5.53 5.53 0 0 1-2.4 3.63v3.01h3.87c2.27-2.09 3.55-5.17 3.55-8.88z" />
|
|
300
|
+
<path fill="#34A853" d="M12 24c3.24 0 5.95-1.08 7.94-2.91l-3.87-3.01c-1.08.72-2.45 1.16-4.07 1.16-3.13 0-5.78-2.11-6.73-4.96H1.29v3.09A12 12 0 0 0 12 24z" />
|
|
301
|
+
<path fill="#FBBC05" d="M5.27 14.28A7.2 7.2 0 0 1 4.89 12c0-.79.14-1.56.38-2.28V6.63H1.29A12 12 0 0 0 0 12c0 1.94.46 3.77 1.29 5.37l3.98-3.09z" />
|
|
302
|
+
<path fill="#EA4335" d="M12 4.75c1.77 0 3.35.61 4.6 1.8l3.44-3.44C17.95 1.19 15.24 0 12 0A12 12 0 0 0 1.29 6.63l3.98 3.09C6.22 6.86 8.87 4.75 12 4.75z" />
|
|
260
303
|
</svg>
|
|
261
304
|
);
|
|
262
305
|
}
|
|
263
306
|
|
|
264
|
-
// Map the framework's auth error codes to friendly copy. `ApiError` carries a
|
|
265
|
-
// stable `.code` (and `.status`) so you branch on the code, not the message.
|
|
266
307
|
function messageFor(err: unknown): string {
|
|
267
308
|
if (err instanceof ApiError) {
|
|
268
309
|
switch (err.code) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type Metadata } from "@pylonsync/react";
|
|
3
|
+
import { ForgotPasswordForm } from "../recovery-forms";
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: "Reset your password — Acme",
|
|
7
|
+
robots: "noindex",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default function ForgotPasswordPage() {
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
<h1 className="mt-5 text-[22px] font-semibold tracking-tight text-zinc-900">Reset your password</h1>
|
|
14
|
+
<p className="mt-1 text-[13px] text-zinc-500">Enter your email and we will send a reset link.</p>
|
|
15
|
+
<div className="mt-6">
|
|
16
|
+
<ForgotPasswordForm />
|
|
17
|
+
</div>
|
|
18
|
+
</>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import { persistSession } from "@pylonsync/client";
|
|
5
|
+
import { Link } from "@pylonsync/react";
|
|
6
|
+
|
|
7
|
+
const inputCls =
|
|
8
|
+
"h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-zinc-900 focus:ring-2 focus:ring-zinc-900/10";
|
|
9
|
+
const buttonCls =
|
|
10
|
+
"inline-flex h-10 w-full items-center justify-center rounded-lg bg-zinc-900 text-sm font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-60";
|
|
11
|
+
|
|
12
|
+
async function post(path: string, body: unknown): Promise<Record<string, unknown>> {
|
|
13
|
+
const res = await fetch(path, {
|
|
14
|
+
method: "POST",
|
|
15
|
+
headers: { "content-type": "application/json" },
|
|
16
|
+
credentials: "include",
|
|
17
|
+
body: JSON.stringify(body),
|
|
18
|
+
});
|
|
19
|
+
const data = (await res.json().catch(() => ({}))) as {
|
|
20
|
+
error?: { code?: string; message?: string };
|
|
21
|
+
};
|
|
22
|
+
if (!res.ok) throw new Error(data.error?.message ?? `Request failed (${res.status})`);
|
|
23
|
+
return data as Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** /forgot-password: request a reset email. Always says "sent" so the form
|
|
27
|
+
* does not reveal whether an address has an account. */
|
|
28
|
+
export function ForgotPasswordForm() {
|
|
29
|
+
const [email, setEmail] = useState("");
|
|
30
|
+
const [sent, setSent] = useState(false);
|
|
31
|
+
const [pending, setPending] = useState(false);
|
|
32
|
+
const [error, setError] = useState<string | null>(null);
|
|
33
|
+
|
|
34
|
+
async function onSubmit(e: React.FormEvent) {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
setPending(true);
|
|
37
|
+
setError(null);
|
|
38
|
+
try {
|
|
39
|
+
await post("/api/auth/password/reset/request", { email });
|
|
40
|
+
setSent(true);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
setError(err instanceof Error ? err.message : "Something went wrong.");
|
|
43
|
+
} finally {
|
|
44
|
+
setPending(false);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (sent) {
|
|
49
|
+
return (
|
|
50
|
+
<p className="text-sm text-zinc-600">
|
|
51
|
+
If an account exists for <span className="font-medium">{email}</span>, a reset link is on its way. The
|
|
52
|
+
link expires in one hour.
|
|
53
|
+
</p>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return (
|
|
57
|
+
<form onSubmit={onSubmit} className="space-y-4">
|
|
58
|
+
<input
|
|
59
|
+
type="email"
|
|
60
|
+
required
|
|
61
|
+
autoComplete="email"
|
|
62
|
+
value={email}
|
|
63
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
64
|
+
placeholder="you@company.com"
|
|
65
|
+
aria-label="Email"
|
|
66
|
+
className={inputCls}
|
|
67
|
+
/>
|
|
68
|
+
{error && <p className="text-[13px] text-red-700">{error}</p>}
|
|
69
|
+
<button type="submit" disabled={pending} className={buttonCls}>
|
|
70
|
+
{pending ? "…" : "Send reset link"}
|
|
71
|
+
</button>
|
|
72
|
+
<p className="text-center text-[13px] text-zinc-500">
|
|
73
|
+
<Link href="/login" className="underline underline-offset-2">
|
|
74
|
+
Back to sign in
|
|
75
|
+
</Link>
|
|
76
|
+
</p>
|
|
77
|
+
</form>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** /reset-password?token=…: set a new password and sign in. */
|
|
82
|
+
export function ResetPasswordForm({ token }: { token: string }) {
|
|
83
|
+
const [password, setPassword] = useState("");
|
|
84
|
+
const [pending, setPending] = useState(false);
|
|
85
|
+
const [error, setError] = useState<string | null>(null);
|
|
86
|
+
|
|
87
|
+
async function onSubmit(e: React.FormEvent) {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
setPending(true);
|
|
90
|
+
setError(null);
|
|
91
|
+
try {
|
|
92
|
+
const data = await post("/api/auth/password/reset/complete", { token, newPassword: password });
|
|
93
|
+
if (typeof data.token === "string") {
|
|
94
|
+
persistSession({ token: data.token, user_id: (data.user_id as string) ?? null });
|
|
95
|
+
window.location.assign("/dashboard");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
window.location.assign("/login?reset=1");
|
|
99
|
+
} catch (err) {
|
|
100
|
+
setError(err instanceof Error ? err.message : "That link is no longer valid.");
|
|
101
|
+
setPending(false);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!token) {
|
|
106
|
+
return (
|
|
107
|
+
<p className="text-sm text-zinc-600">
|
|
108
|
+
This reset link is missing its token.{" "}
|
|
109
|
+
<Link href="/forgot-password" className="underline underline-offset-2">
|
|
110
|
+
Request a new one
|
|
111
|
+
</Link>
|
|
112
|
+
.
|
|
113
|
+
</p>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return (
|
|
117
|
+
<form onSubmit={onSubmit} className="space-y-4">
|
|
118
|
+
<input
|
|
119
|
+
type="password"
|
|
120
|
+
required
|
|
121
|
+
minLength={10}
|
|
122
|
+
autoComplete="new-password"
|
|
123
|
+
value={password}
|
|
124
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
125
|
+
placeholder="New password (10+ characters)"
|
|
126
|
+
aria-label="New password"
|
|
127
|
+
className={inputCls}
|
|
128
|
+
/>
|
|
129
|
+
{error && <p className="text-[13px] text-red-700">{error}</p>}
|
|
130
|
+
<button type="submit" disabled={pending} className={buttonCls}>
|
|
131
|
+
{pending ? "…" : "Set new password"}
|
|
132
|
+
</button>
|
|
133
|
+
</form>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type Metadata, type PageProps } from "@pylonsync/react";
|
|
3
|
+
import { ResetPasswordForm } from "../recovery-forms";
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: "Choose a new password — Acme",
|
|
7
|
+
robots: "noindex",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// The reset email links here with `?token=…` (see PYLON_PUBLIC_URL on the
|
|
11
|
+
// server: it builds `<public url>/reset-password?token=…`).
|
|
12
|
+
export default function ResetPasswordPage({ searchParams }: PageProps) {
|
|
13
|
+
const raw = (searchParams as Record<string, string | string[] | undefined> | undefined)?.token;
|
|
14
|
+
const token = Array.isArray(raw) ? raw[0] ?? "" : raw ?? "";
|
|
15
|
+
return (
|
|
16
|
+
<>
|
|
17
|
+
<h1 className="mt-5 text-[22px] font-semibold tracking-tight text-zinc-900">Choose a new password</h1>
|
|
18
|
+
<p className="mt-1 text-[13px] text-zinc-500">You will be signed in on every device after this.</p>
|
|
19
|
+
<div className="mt-6">
|
|
20
|
+
<ResetPasswordForm token={token} />
|
|
21
|
+
</div>
|
|
22
|
+
</>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -198,7 +198,7 @@ export default function MarketingLayout({ children, auth }: LayoutProps) {
|
|
|
198
198
|
<NavDropdown label="Product" items={PRODUCT_MENU} />
|
|
199
199
|
<NavDropdown label="Resources" items={RESOURCES_MENU} />
|
|
200
200
|
<Link
|
|
201
|
-
href="
|
|
201
|
+
href="/pricing"
|
|
202
202
|
className="text-[13.5px] text-zinc-600 transition-colors hover:text-zinc-900"
|
|
203
203
|
>
|
|
204
204
|
Pricing
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
Terminal,
|
|
15
15
|
} from "@/components/marketing";
|
|
16
16
|
import { siteConfig, productBySlug, type Product } from "@/lib/site.config";
|
|
17
|
+
import { PricingTable } from "@/components/pricing-table";
|
|
17
18
|
|
|
18
19
|
// SEO metadata. Exported `metadata` is rendered into <head> on the server, so
|
|
19
20
|
// this marketing page is fully indexable — view source and the copy is in the
|
|
@@ -306,58 +307,8 @@ export default function LandingPage({ auth }: PageProps) {
|
|
|
306
307
|
<p className="mt-5 max-w-md text-[15px] leading-relaxed text-zinc-500">
|
|
307
308
|
{pricing.body}
|
|
308
309
|
</p>
|
|
309
|
-
<div className="mt-12
|
|
310
|
-
{
|
|
311
|
-
<div
|
|
312
|
-
key={p.name}
|
|
313
|
-
className={`flex flex-col rounded-2xl border p-7 ${
|
|
314
|
-
p.featured
|
|
315
|
-
? "border-zinc-900 bg-white shadow-[0_24px_60px_-30px_rgba(0,0,0,0.3)]"
|
|
316
|
-
: "border-zinc-200 bg-paper"
|
|
317
|
-
}`}
|
|
318
|
-
>
|
|
319
|
-
<div className="flex items-center justify-between">
|
|
320
|
-
<h3 className="text-base font-semibold">{p.name}</h3>
|
|
321
|
-
{p.featured && (
|
|
322
|
-
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-brand">
|
|
323
|
-
Most popular
|
|
324
|
-
</span>
|
|
325
|
-
)}
|
|
326
|
-
</div>
|
|
327
|
-
<p className="mt-1 text-[13px] text-zinc-500">{p.tagline}</p>
|
|
328
|
-
<div className="mt-5 flex items-baseline gap-1">
|
|
329
|
-
<span className="text-4xl font-semibold tracking-tight">
|
|
330
|
-
{p.price}
|
|
331
|
-
</span>
|
|
332
|
-
<span className="text-[13px] text-zinc-500">{p.unit}</span>
|
|
333
|
-
</div>
|
|
334
|
-
<ul className="mt-6 flex-1 space-y-3 text-[14px] text-zinc-600">
|
|
335
|
-
{p.features.map((f) => (
|
|
336
|
-
<li key={f} className="flex gap-2.5">
|
|
337
|
-
<span className="mt-[3px] text-brand">✓</span>
|
|
338
|
-
{f}
|
|
339
|
-
</li>
|
|
340
|
-
))}
|
|
341
|
-
</ul>
|
|
342
|
-
<div className="mt-7">
|
|
343
|
-
{p.featured ? (
|
|
344
|
-
<PrimaryButton
|
|
345
|
-
href={primaryHref}
|
|
346
|
-
className="w-full justify-center"
|
|
347
|
-
>
|
|
348
|
-
{p.cta}
|
|
349
|
-
</PrimaryButton>
|
|
350
|
-
) : (
|
|
351
|
-
<Link
|
|
352
|
-
href={primaryHref}
|
|
353
|
-
className="inline-flex w-full items-center justify-center rounded-full border border-zinc-300 px-5 py-2.5 text-sm font-medium text-zinc-900 transition-colors hover:border-zinc-400 hover:bg-zinc-50"
|
|
354
|
-
>
|
|
355
|
-
{p.cta}
|
|
356
|
-
</Link>
|
|
357
|
-
)}
|
|
358
|
-
</div>
|
|
359
|
-
</div>
|
|
360
|
-
))}
|
|
310
|
+
<div className="mt-12">
|
|
311
|
+
<PricingTable signedIn={signedIn} />
|
|
361
312
|
</div>
|
|
362
313
|
</section>
|
|
363
314
|
|