@flaredev/cli 0.1.2 → 0.2.0
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/dist/index.js +340 -163
- package/package.json +3 -2
- package/templates/app/app/api/openapi.json/route.ts +23 -0
- package/templates/app/app/api/reference/route.ts +36 -0
- package/templates/app/app/dashboard/account/page.tsx +38 -0
- package/templates/app/app/dashboard/layout.tsx +39 -0
- package/templates/app/app/dashboard/page.tsx +42 -9
- package/templates/app/app/forgot-password/page.tsx +22 -0
- package/templates/app/app/globals.css +109 -4
- package/templates/app/app/layout.tsx +28 -26
- package/templates/app/app/page.tsx +126 -13
- package/templates/app/app/reset-password/page.tsx +31 -0
- package/templates/app/app/sign-in/page.tsx +49 -33
- package/templates/app/app/sign-up/page.tsx +42 -23
- package/templates/app/app/two-factor/page.tsx +25 -0
- package/templates/app/components/account/security-settings.tsx +525 -0
- package/templates/app/components/admin/fields/country-field.tsx +152 -0
- package/templates/app/components/admin/fields/field-widget.tsx +186 -128
- package/templates/app/components/admin/fields/multi-select-field.tsx +46 -0
- package/templates/app/components/admin/fields/phone-field.tsx +86 -0
- package/templates/app/components/admin/resource-nav.tsx +120 -109
- package/templates/app/components/admin/resource-table.tsx +31 -1
- package/templates/app/components/auth/auth-shell.tsx +284 -0
- package/templates/app/components/auth/password-forms.tsx +79 -0
- package/templates/app/components/auth/provider-icons.tsx +39 -0
- package/templates/app/components/auth/sign-in-flow.tsx +315 -0
- package/templates/app/components/auth/sign-up-form.tsx +101 -0
- package/templates/app/components/auth/social-buttons.tsx +56 -0
- package/templates/app/components/auth/two-factor-form.tsx +124 -0
- package/templates/app/components/auth/ui.tsx +96 -0
- package/templates/app/components/dashboard/dashboard-nav.tsx +35 -0
- package/templates/app/components/marketing/product-preview.tsx +70 -0
- package/templates/app/components/marketing/site-header.tsx +39 -0
- package/templates/app/components/sign-out-button.tsx +1 -1
- package/templates/app/components/ui/checkbox.tsx +25 -0
- package/templates/app/components/ui/radio-group.tsx +29 -0
- package/templates/app/db/auth-schema.ts +167 -118
- package/templates/app/lib/admin-nav.ts +1 -1
- package/templates/app/lib/api-docs.ts +16 -0
- package/templates/app/lib/auth-client.ts +16 -1
- package/templates/app/lib/auth-config.ts +33 -0
- package/templates/app/lib/auth-emails.ts +94 -0
- package/templates/app/lib/auth.ts +81 -6
- package/templates/app/lib/site.ts +47 -0
- package/templates/app/lib/theme.ts +79 -0
- package/templates/app/migrations/0000_init.sql +32 -1
- package/templates/app/migrations/meta/0000_snapshot.json +215 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/vite.config.ts +5 -3
- package/templates/app/components/auth-form.tsx +0 -120
|
@@ -1,109 +1,120 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import Link from "next/link";
|
|
4
|
-
import { usePathname } from "next/navigation";
|
|
5
|
-
import { LayoutDashboardIcon } from "lucide-react";
|
|
6
|
-
import {
|
|
7
|
-
Sidebar,
|
|
8
|
-
SidebarContent,
|
|
9
|
-
SidebarGroup,
|
|
10
|
-
SidebarGroupContent,
|
|
11
|
-
SidebarGroupLabel,
|
|
12
|
-
SidebarHeader,
|
|
13
|
-
SidebarMenu,
|
|
14
|
-
SidebarMenuButton,
|
|
15
|
-
SidebarMenuItem,
|
|
16
|
-
} from "@/components/ui/sidebar";
|
|
17
|
-
import { resourceIcon } from "./resource-icon";
|
|
18
|
-
|
|
19
|
-
export interface NavResource {
|
|
20
|
-
name: string;
|
|
21
|
-
pluralLabel: string;
|
|
22
|
-
slug: string;
|
|
23
|
-
icon?: string;
|
|
24
|
-
/** Optional count badge (off by default). */
|
|
25
|
-
badge?: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Sidebar navigation, one item per registered resource. The icon comes from the
|
|
30
|
-
* descriptor's `icon`, the label from its plural label, and the active item from the
|
|
31
|
-
* current path.
|
|
32
|
-
*/
|
|
33
|
-
export function ResourceNav({
|
|
34
|
-
appName,
|
|
35
|
-
resources,
|
|
36
|
-
links = [],
|
|
37
|
-
}: {
|
|
38
|
-
appName: string;
|
|
39
|
-
resources: NavResource[];
|
|
40
|
-
/** Extra pages (see lib/admin-nav.ts), shown under "Platform". */
|
|
41
|
-
links?: Array<{ label: string; href: string; icon: string }>;
|
|
42
|
-
}) {
|
|
43
|
-
const pathname = usePathname();
|
|
44
|
-
|
|
45
|
-
return (
|
|
46
|
-
<Sidebar collapsible="icon">
|
|
47
|
-
<SidebarHeader>
|
|
48
|
-
<SidebarMenu>
|
|
49
|
-
<SidebarMenuItem>
|
|
50
|
-
<SidebarMenuButton asChild size="lg" tooltip={appName}>
|
|
51
|
-
<Link href="/admin">
|
|
52
|
-
<div className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
|
53
|
-
<LayoutDashboardIcon className="size-4" />
|
|
54
|
-
</div>
|
|
55
|
-
<span className="truncate font-semibold">{appName}</span>
|
|
56
|
-
</Link>
|
|
57
|
-
</SidebarMenuButton>
|
|
58
|
-
</SidebarMenuItem>
|
|
59
|
-
</SidebarMenu>
|
|
60
|
-
</SidebarHeader>
|
|
61
|
-
|
|
62
|
-
<SidebarContent>
|
|
63
|
-
<SidebarGroup>
|
|
64
|
-
<SidebarGroupLabel>Resources</SidebarGroupLabel>
|
|
65
|
-
<SidebarGroupContent>
|
|
66
|
-
<SidebarMenu>
|
|
67
|
-
{resources.map((resource) => {
|
|
68
|
-
const href = `/admin/${resource.slug}`;
|
|
69
|
-
const Icon = resourceIcon(resource.icon);
|
|
70
|
-
return (
|
|
71
|
-
<SidebarMenuItem key={resource.name}>
|
|
72
|
-
<SidebarMenuButton asChild isActive={pathname === href || pathname.startsWith(`${href}/`)} tooltip={resource.pluralLabel}>
|
|
73
|
-
<Link href={href}>
|
|
74
|
-
<Icon />
|
|
75
|
-
<span>{resource.pluralLabel}</span>
|
|
76
|
-
</Link>
|
|
77
|
-
</SidebarMenuButton>
|
|
78
|
-
</SidebarMenuItem>
|
|
79
|
-
);
|
|
80
|
-
})}
|
|
81
|
-
</SidebarMenu>
|
|
82
|
-
</SidebarGroupContent>
|
|
83
|
-
</SidebarGroup>
|
|
84
|
-
{links.length > 0 && (
|
|
85
|
-
<SidebarGroup>
|
|
86
|
-
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
|
87
|
-
<SidebarGroupContent>
|
|
88
|
-
<SidebarMenu>
|
|
89
|
-
{links.map((link) => {
|
|
90
|
-
const Icon = resourceIcon(link.icon);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import { LayoutDashboardIcon } from "lucide-react";
|
|
6
|
+
import {
|
|
7
|
+
Sidebar,
|
|
8
|
+
SidebarContent,
|
|
9
|
+
SidebarGroup,
|
|
10
|
+
SidebarGroupContent,
|
|
11
|
+
SidebarGroupLabel,
|
|
12
|
+
SidebarHeader,
|
|
13
|
+
SidebarMenu,
|
|
14
|
+
SidebarMenuButton,
|
|
15
|
+
SidebarMenuItem,
|
|
16
|
+
} from "@/components/ui/sidebar";
|
|
17
|
+
import { resourceIcon } from "./resource-icon";
|
|
18
|
+
|
|
19
|
+
export interface NavResource {
|
|
20
|
+
name: string;
|
|
21
|
+
pluralLabel: string;
|
|
22
|
+
slug: string;
|
|
23
|
+
icon?: string;
|
|
24
|
+
/** Optional count badge (off by default). */
|
|
25
|
+
badge?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Sidebar navigation, one item per registered resource. The icon comes from the
|
|
30
|
+
* descriptor's `icon`, the label from its plural label, and the active item from the
|
|
31
|
+
* current path.
|
|
32
|
+
*/
|
|
33
|
+
export function ResourceNav({
|
|
34
|
+
appName,
|
|
35
|
+
resources,
|
|
36
|
+
links = [],
|
|
37
|
+
}: {
|
|
38
|
+
appName: string;
|
|
39
|
+
resources: NavResource[];
|
|
40
|
+
/** Extra pages (see lib/admin-nav.ts), shown under "Platform". */
|
|
41
|
+
links?: Array<{ label: string; href: string; icon: string }>;
|
|
42
|
+
}) {
|
|
43
|
+
const pathname = usePathname();
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<Sidebar collapsible="icon">
|
|
47
|
+
<SidebarHeader>
|
|
48
|
+
<SidebarMenu>
|
|
49
|
+
<SidebarMenuItem>
|
|
50
|
+
<SidebarMenuButton asChild size="lg" tooltip={appName}>
|
|
51
|
+
<Link href="/admin">
|
|
52
|
+
<div className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
|
53
|
+
<LayoutDashboardIcon className="size-4" />
|
|
54
|
+
</div>
|
|
55
|
+
<span className="truncate font-semibold">{appName}</span>
|
|
56
|
+
</Link>
|
|
57
|
+
</SidebarMenuButton>
|
|
58
|
+
</SidebarMenuItem>
|
|
59
|
+
</SidebarMenu>
|
|
60
|
+
</SidebarHeader>
|
|
61
|
+
|
|
62
|
+
<SidebarContent>
|
|
63
|
+
<SidebarGroup>
|
|
64
|
+
<SidebarGroupLabel>Resources</SidebarGroupLabel>
|
|
65
|
+
<SidebarGroupContent>
|
|
66
|
+
<SidebarMenu>
|
|
67
|
+
{resources.map((resource) => {
|
|
68
|
+
const href = `/admin/${resource.slug}`;
|
|
69
|
+
const Icon = resourceIcon(resource.icon);
|
|
70
|
+
return (
|
|
71
|
+
<SidebarMenuItem key={resource.name}>
|
|
72
|
+
<SidebarMenuButton asChild isActive={pathname === href || pathname.startsWith(`${href}/`)} tooltip={resource.pluralLabel}>
|
|
73
|
+
<Link href={href}>
|
|
74
|
+
<Icon />
|
|
75
|
+
<span>{resource.pluralLabel}</span>
|
|
76
|
+
</Link>
|
|
77
|
+
</SidebarMenuButton>
|
|
78
|
+
</SidebarMenuItem>
|
|
79
|
+
);
|
|
80
|
+
})}
|
|
81
|
+
</SidebarMenu>
|
|
82
|
+
</SidebarGroupContent>
|
|
83
|
+
</SidebarGroup>
|
|
84
|
+
{links.length > 0 && (
|
|
85
|
+
<SidebarGroup>
|
|
86
|
+
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
|
87
|
+
<SidebarGroupContent>
|
|
88
|
+
<SidebarMenu>
|
|
89
|
+
{links.map((link) => {
|
|
90
|
+
const Icon = resourceIcon(link.icon);
|
|
91
|
+
// Route handlers (/api/...) and other sites are whole pages: open them in a new tab.
|
|
92
|
+
const external = link.href.startsWith("/api/") || /^https?:\/\//.test(link.href);
|
|
93
|
+
const content = (
|
|
94
|
+
<>
|
|
95
|
+
<Icon />
|
|
96
|
+
<span>{link.label}</span>
|
|
97
|
+
</>
|
|
98
|
+
);
|
|
99
|
+
return (
|
|
100
|
+
<SidebarMenuItem key={link.href}>
|
|
101
|
+
<SidebarMenuButton asChild isActive={pathname === link.href || pathname.startsWith(`${link.href}/`)} tooltip={link.label}>
|
|
102
|
+
{external ? (
|
|
103
|
+
<a href={link.href} target="_blank" rel="noreferrer">
|
|
104
|
+
{content}
|
|
105
|
+
</a>
|
|
106
|
+
) : (
|
|
107
|
+
<Link href={link.href}>{content}</Link>
|
|
108
|
+
)}
|
|
109
|
+
</SidebarMenuButton>
|
|
110
|
+
</SidebarMenuItem>
|
|
111
|
+
);
|
|
112
|
+
})}
|
|
113
|
+
</SidebarMenu>
|
|
114
|
+
</SidebarGroupContent>
|
|
115
|
+
</SidebarGroup>
|
|
116
|
+
)}
|
|
117
|
+
</SidebarContent>
|
|
118
|
+
</Sidebar>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import Link from "next/link";
|
|
2
2
|
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
|
|
3
|
-
import { formatValue, statusTone, storedFields, type Resource, type StoredField } from "@flaredev/core";
|
|
3
|
+
import { formatValue, optionLabel, statusTone, storedFields, type Resource, type StoredField } from "@flaredev/core";
|
|
4
4
|
import { isSortable } from "@flaredev/core/server";
|
|
5
5
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
6
6
|
import { Badge } from "@/components/ui/badge";
|
|
@@ -157,6 +157,36 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
|
|
|
157
157
|
if (column.def.kind === "enum" && value != null) {
|
|
158
158
|
const tone = statusTone(value);
|
|
159
159
|
content = <Badge variant={tone === "neutral" ? "secondary" : tone}>{content}</Badge>;
|
|
160
|
+
} else if (column.def.kind === "multiselect" && Array.isArray(value) && value.length) {
|
|
161
|
+
const def = column.def;
|
|
162
|
+
content = (
|
|
163
|
+
<span className="flex flex-wrap gap-1">
|
|
164
|
+
{value.map((item) => (
|
|
165
|
+
<Badge key={String(item)} variant="secondary">
|
|
166
|
+
{optionLabel(def, String(item))}
|
|
167
|
+
</Badge>
|
|
168
|
+
))}
|
|
169
|
+
</span>
|
|
170
|
+
);
|
|
171
|
+
} else if (index !== 0 && column.def.kind === "string" && typeof value === "string" && value) {
|
|
172
|
+
// Contact-style values are links; a colour shows its swatch.
|
|
173
|
+
const format = column.def.format;
|
|
174
|
+
const href =
|
|
175
|
+
format === "email" ? `mailto:${value}` : format === "tel" ? `tel:${value}` : format === "url" ? value : format === "domain" ? `https://${value}` : undefined;
|
|
176
|
+
if (href) {
|
|
177
|
+
content = (
|
|
178
|
+
<a href={href} className="hover:underline" {...(format === "url" || format === "domain" ? { target: "_blank", rel: "noreferrer" } : {})}>
|
|
179
|
+
{content}
|
|
180
|
+
</a>
|
|
181
|
+
);
|
|
182
|
+
} else if (format === "color") {
|
|
183
|
+
content = (
|
|
184
|
+
<span className="inline-flex items-center gap-2 font-mono text-xs">
|
|
185
|
+
<span className="size-3.5 rounded-sm border" style={{ backgroundColor: value }} aria-hidden="true" />
|
|
186
|
+
{value}
|
|
187
|
+
</span>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
160
190
|
} else if (column.def.kind === "belongsTo" && typeof value === "string") {
|
|
161
191
|
const target = byName.get(column.def.target);
|
|
162
192
|
content = target ? (
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { CheckIcon, XIcon } from "lucide-react";
|
|
3
|
+
import { site } from "@/lib/site";
|
|
4
|
+
import { authLayout } from "@/lib/theme";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
interface ShellProps {
|
|
8
|
+
title: string;
|
|
9
|
+
subtitle?: string;
|
|
10
|
+
children: React.ReactNode;
|
|
11
|
+
/** Links under the form ("No account? Sign up"). */
|
|
12
|
+
footer?: React.ReactNode;
|
|
13
|
+
/** Sign-up screens get the highlights panel in the default theme. */
|
|
14
|
+
page?: "sign-in" | "sign-up" | "flow";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The app's mark: its initial on the brand colour. Swap in your logo here. It links
|
|
19
|
+
* home unless `link={false}` (inside another link, where a nested <a> isn't valid HTML).
|
|
20
|
+
*/
|
|
21
|
+
export function BrandMark({ className, link = true }: { className?: string; link?: boolean }) {
|
|
22
|
+
const mark = (
|
|
23
|
+
<span className="grid size-9 place-items-center rounded-[calc(var(--radius)+2px)] bg-brand text-lg font-bold text-brand-foreground [background-image:var(--brand-gradient,none)]">
|
|
24
|
+
{site.name.charAt(0).toUpperCase()}
|
|
25
|
+
</span>
|
|
26
|
+
);
|
|
27
|
+
if (!link) return <span className={cn("inline-flex items-center gap-2.5", className)}>{mark}</span>;
|
|
28
|
+
return (
|
|
29
|
+
<Link href="/" aria-label={`${site.name} home`} className={cn("inline-flex items-center gap-2.5", className)}>
|
|
30
|
+
{mark}
|
|
31
|
+
</Link>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function Wordmark({ className }: { className?: string }) {
|
|
36
|
+
return (
|
|
37
|
+
<Link href="/" className={cn("text-xl font-bold tracking-tight text-foreground", className)}>
|
|
38
|
+
{site.name}
|
|
39
|
+
</Link>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function Legal({ className }: { className?: string }) {
|
|
44
|
+
return (
|
|
45
|
+
<p className={cn("text-xs leading-5 text-foreground-muted", className)}>
|
|
46
|
+
By continuing, you agree to the{" "}
|
|
47
|
+
<Link href={site.links.terms} className="underline underline-offset-2 hover:text-foreground">
|
|
48
|
+
Terms of Service
|
|
49
|
+
</Link>{" "}
|
|
50
|
+
and{" "}
|
|
51
|
+
<Link href={site.links.privacy} className="underline underline-offset-2 hover:text-foreground">
|
|
52
|
+
Privacy Policy
|
|
53
|
+
</Link>
|
|
54
|
+
.
|
|
55
|
+
</p>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function Heading({ title, subtitle, align = "center", size = "text-2xl" }: { title: string; subtitle?: string; align?: "center" | "left"; size?: string }) {
|
|
60
|
+
return (
|
|
61
|
+
<div className={cn("flex flex-col gap-1.5", align === "center" && "items-center text-center")}>
|
|
62
|
+
<h1 className={cn(size, "leading-tight text-foreground text-balance")}>{title}</h1>
|
|
63
|
+
{subtitle && <p className="text-sm text-foreground-muted">{subtitle}</p>}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function Highlights() {
|
|
69
|
+
return (
|
|
70
|
+
<div className="flex max-w-md flex-col gap-8">
|
|
71
|
+
<h2 className="text-3xl leading-tight text-balance">
|
|
72
|
+
{site.name}, <span className="text-link">{site.tagline.toLowerCase()}</span>.
|
|
73
|
+
</h2>
|
|
74
|
+
<ul className="flex flex-col gap-4">
|
|
75
|
+
{site.highlights.map((item) => (
|
|
76
|
+
<li key={item} className="flex items-start gap-3 text-[15px] text-foreground-muted">
|
|
77
|
+
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-brand/15 text-link">
|
|
78
|
+
<CheckIcon className="size-3.5" strokeWidth={3} />
|
|
79
|
+
</span>
|
|
80
|
+
{item}
|
|
81
|
+
</li>
|
|
82
|
+
))}
|
|
83
|
+
</ul>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function Testimonial({ large = false }: { large?: boolean }) {
|
|
89
|
+
const initials = site.testimonial.author
|
|
90
|
+
.split(" ")
|
|
91
|
+
.map((part) => part.charAt(0))
|
|
92
|
+
.join("");
|
|
93
|
+
return (
|
|
94
|
+
<figure className="flex max-w-lg flex-col gap-6">
|
|
95
|
+
<blockquote className={cn("leading-snug text-foreground text-balance", large ? "text-3xl" : "text-xl")}>
|
|
96
|
+
<span aria-hidden="true" className="mb-2 block font-serif text-6xl leading-none text-foreground-muted/40">
|
|
97
|
+
“
|
|
98
|
+
</span>
|
|
99
|
+
{site.testimonial.quote}
|
|
100
|
+
</blockquote>
|
|
101
|
+
<figcaption className="flex items-center gap-3">
|
|
102
|
+
<span className="grid size-10 place-items-center rounded-full bg-brand/15 text-sm font-semibold text-link">{initials}</span>
|
|
103
|
+
<span className="flex flex-col text-sm">
|
|
104
|
+
<span className="font-medium text-foreground">{site.testimonial.author}</span>
|
|
105
|
+
<span className="text-foreground-muted">{site.testimonial.role}</span>
|
|
106
|
+
</span>
|
|
107
|
+
</figcaption>
|
|
108
|
+
</figure>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The frame around every sign-in screen. The active theme (lib/theme.ts) picks one of
|
|
114
|
+
* six layouts; the form inside is the same everywhere.
|
|
115
|
+
*/
|
|
116
|
+
export function AuthShell({ title, subtitle, children, footer, page = "flow" }: ShellProps) {
|
|
117
|
+
const form = (
|
|
118
|
+
<div className="flex w-full flex-col gap-5">
|
|
119
|
+
{children}
|
|
120
|
+
{footer && <div className="text-center text-sm text-foreground-muted">{footer}</div>}
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
switch (authLayout) {
|
|
125
|
+
// Coral: a big rounded card floating over a soft, blurred glimpse of the app.
|
|
126
|
+
case "modal":
|
|
127
|
+
return (
|
|
128
|
+
<main className="relative grid min-h-dvh place-items-center overflow-hidden bg-surface-muted p-4">
|
|
129
|
+
<div aria-hidden="true" className="absolute inset-0 grid grid-cols-2 gap-6 p-8 opacity-60 blur-[2px] sm:grid-cols-4">
|
|
130
|
+
{Array.from({ length: 8 }, (_, index) => (
|
|
131
|
+
<div key={index} className="flex flex-col gap-3">
|
|
132
|
+
<div className="aspect-[4/3] rounded-2xl bg-gradient-to-br from-brand/25 via-brand/10 to-surface" />
|
|
133
|
+
<div className="h-3 w-3/4 rounded bg-border" />
|
|
134
|
+
<div className="h-3 w-1/2 rounded bg-border" />
|
|
135
|
+
</div>
|
|
136
|
+
))}
|
|
137
|
+
</div>
|
|
138
|
+
<div className="absolute inset-0 bg-foreground/35" aria-hidden="true" />
|
|
139
|
+
<div className="relative w-full max-w-[560px] rounded-[28px] bg-surface px-6 py-10 shadow-2xl sm:px-12">
|
|
140
|
+
<Link href="/" aria-label="Close" className="absolute top-5 right-5 grid size-9 place-items-center rounded-full text-foreground hover:bg-surface-muted">
|
|
141
|
+
<XIcon className="size-5" />
|
|
142
|
+
</Link>
|
|
143
|
+
<div className="mb-7 flex flex-col items-center gap-4">
|
|
144
|
+
<BrandMark />
|
|
145
|
+
<Heading title={title} subtitle={subtitle} size="text-[28px]" />
|
|
146
|
+
</div>
|
|
147
|
+
{form}
|
|
148
|
+
</div>
|
|
149
|
+
</main>
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Amber: wordmark on top, a plain bordered box, footer links under a rule.
|
|
153
|
+
case "boxed":
|
|
154
|
+
return (
|
|
155
|
+
<main className="flex min-h-dvh flex-col items-center bg-surface">
|
|
156
|
+
<div className="flex w-full flex-col items-center px-4 pt-6">
|
|
157
|
+
<Wordmark className="mb-4" />
|
|
158
|
+
<div className="w-full max-w-[400px] rounded-lg border border-border px-6 py-7 sm:px-8">
|
|
159
|
+
<h1 className="mb-5 text-[28px] leading-tight">{title}</h1>
|
|
160
|
+
{form}
|
|
161
|
+
<Legal className="mt-5" />
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
<footer className="mt-10 flex w-full flex-col items-center gap-2 border-t border-border py-6 text-xs">
|
|
165
|
+
<nav className="flex gap-4">
|
|
166
|
+
<Link className="text-link hover:underline" href={site.links.terms}>Conditions of use</Link>
|
|
167
|
+
<Link className="text-link hover:underline" href={site.links.privacy}>Privacy notice</Link>
|
|
168
|
+
<a className="text-link hover:underline" href={site.links.support}>Help</a>
|
|
169
|
+
</nav>
|
|
170
|
+
<p className="text-foreground-muted">© {new Date().getFullYear()} {site.name}</p>
|
|
171
|
+
</footer>
|
|
172
|
+
</main>
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Sky: a top bar and a bold, left-aligned heading; social sign-in first.
|
|
176
|
+
case "banner":
|
|
177
|
+
return (
|
|
178
|
+
<main className="min-h-dvh bg-surface">
|
|
179
|
+
<header className="flex items-center justify-between px-6 py-5">
|
|
180
|
+
<BrandMark />
|
|
181
|
+
<a href={site.links.support} className="rounded-[var(--button-radius)] border border-border px-4 py-2 text-sm font-medium hover:bg-surface-muted">
|
|
182
|
+
Support
|
|
183
|
+
</a>
|
|
184
|
+
</header>
|
|
185
|
+
<div className="mx-auto flex w-full max-w-[560px] flex-col gap-8 px-6 pt-6 pb-16">
|
|
186
|
+
<Heading title={title} subtitle={subtitle ?? site.tagline} align="left" size="text-4xl" />
|
|
187
|
+
{form}
|
|
188
|
+
<Legal />
|
|
189
|
+
</div>
|
|
190
|
+
</main>
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// Mono: a fine grid with a soft glow behind the form, a showcase panel beside it.
|
|
194
|
+
case "showcase":
|
|
195
|
+
return (
|
|
196
|
+
<main className="grid min-h-dvh lg:grid-cols-[1.25fr_1fr]">
|
|
197
|
+
<section className="relative flex flex-col items-center overflow-hidden px-6 py-8">
|
|
198
|
+
<div
|
|
199
|
+
aria-hidden="true"
|
|
200
|
+
className="absolute inset-0 [background-image:linear-gradient(var(--border)_1px,transparent_1px),linear-gradient(90deg,var(--border)_1px,transparent_1px)] [background-size:72px_72px] [mask-image:radial-gradient(ellipse_at_top,black_20%,transparent_70%)]"
|
|
201
|
+
/>
|
|
202
|
+
<div aria-hidden="true" className="absolute -top-40 left-1/2 h-80 w-[36rem] -translate-x-1/2 rounded-full bg-[conic-gradient(from_90deg,#fde68a,#f9a8d4,#a5b4fc,#99f6e4,#fde68a)] opacity-25 blur-3xl" />
|
|
203
|
+
<Wordmark className="relative text-2xl" />
|
|
204
|
+
<div className="relative my-auto flex w-full max-w-[420px] flex-col gap-7 py-12">
|
|
205
|
+
<Heading title={title} subtitle={subtitle} size="text-2xl" />
|
|
206
|
+
{form}
|
|
207
|
+
</div>
|
|
208
|
+
<Legal className="relative max-w-[420px] text-center" />
|
|
209
|
+
</section>
|
|
210
|
+
<aside className="hidden flex-col justify-center gap-10 border-l border-border bg-surface-muted p-12 lg:flex">
|
|
211
|
+
<div className="flex flex-col gap-6 rounded-2xl bg-gradient-to-br from-foreground to-foreground/80 p-8 text-background shadow-xl">
|
|
212
|
+
<p className="text-2xl leading-snug font-semibold text-balance">“{site.testimonial.quote}”</p>
|
|
213
|
+
<p className="text-sm opacity-80">
|
|
214
|
+
{site.testimonial.author}, {site.testimonial.role}
|
|
215
|
+
</p>
|
|
216
|
+
</div>
|
|
217
|
+
<ul className="grid grid-cols-1 gap-3">
|
|
218
|
+
{site.highlights.map((item) => (
|
|
219
|
+
<li key={item} className="flex items-center gap-3 text-sm text-foreground-muted">
|
|
220
|
+
<CheckIcon className="size-4 text-foreground" />
|
|
221
|
+
{item}
|
|
222
|
+
</li>
|
|
223
|
+
))}
|
|
224
|
+
</ul>
|
|
225
|
+
</aside>
|
|
226
|
+
</main>
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// Emerald: the form in a narrow column, a customer quote filling the rest.
|
|
230
|
+
case "quote":
|
|
231
|
+
return (
|
|
232
|
+
<main className="grid min-h-dvh lg:grid-cols-[minmax(0,560px)_1fr]">
|
|
233
|
+
<section className="flex flex-col px-8 py-8 sm:px-16">
|
|
234
|
+
<Link href="/" className="flex items-center gap-2 text-xl font-semibold">
|
|
235
|
+
<span className="grid size-7 place-items-center rounded-md bg-brand text-sm font-bold text-brand-foreground">{site.name.charAt(0).toUpperCase()}</span>
|
|
236
|
+
{site.name}
|
|
237
|
+
</Link>
|
|
238
|
+
<div className="my-auto flex w-full max-w-[400px] flex-col gap-7 py-12">
|
|
239
|
+
<Heading title={title} subtitle={subtitle} align="left" size="text-[32px]" />
|
|
240
|
+
{form}
|
|
241
|
+
</div>
|
|
242
|
+
<Legal className="max-w-[400px] text-center" />
|
|
243
|
+
</section>
|
|
244
|
+
<aside className="hidden items-center justify-center border-l border-border bg-surface-muted p-16 lg:flex">
|
|
245
|
+
<Testimonial large />
|
|
246
|
+
</aside>
|
|
247
|
+
</main>
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Default: centred and quiet; sign-up adds the highlights panel.
|
|
251
|
+
default:
|
|
252
|
+
if (page === "sign-up") {
|
|
253
|
+
return (
|
|
254
|
+
<main className="grid min-h-dvh lg:grid-cols-2">
|
|
255
|
+
<section className="flex flex-col items-center bg-surface-muted px-6 py-10">
|
|
256
|
+
<div className="my-auto flex w-full max-w-[400px] flex-col gap-8">
|
|
257
|
+
<div className="flex flex-col items-center gap-5">
|
|
258
|
+
<BrandMark />
|
|
259
|
+
<Heading title={title} subtitle={subtitle} />
|
|
260
|
+
</div>
|
|
261
|
+
{form}
|
|
262
|
+
</div>
|
|
263
|
+
<Legal className="max-w-[400px] text-center" />
|
|
264
|
+
</section>
|
|
265
|
+
<aside className="hidden items-center justify-center p-16 lg:flex">
|
|
266
|
+
<Highlights />
|
|
267
|
+
</aside>
|
|
268
|
+
</main>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return (
|
|
272
|
+
<main className="flex min-h-dvh flex-col items-center px-6 py-10">
|
|
273
|
+
<div className="my-auto flex w-full max-w-[400px] flex-col gap-8">
|
|
274
|
+
<div className="flex flex-col items-center gap-5">
|
|
275
|
+
<BrandMark />
|
|
276
|
+
<Heading title={title} subtitle={subtitle} />
|
|
277
|
+
</div>
|
|
278
|
+
{form}
|
|
279
|
+
</div>
|
|
280
|
+
<Legal className="max-w-[400px] text-center" />
|
|
281
|
+
</main>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { authClient } from "@/lib/auth-client";
|
|
5
|
+
import { authErrorMessage } from "./sign-in-flow";
|
|
6
|
+
import { AuthInput, AuthLabel, FormMessage, PrimaryButton } from "./ui";
|
|
7
|
+
|
|
8
|
+
/** Asks for a reset link. Says the same thing whether or not the address has an account. */
|
|
9
|
+
export function ForgotPasswordForm({ initialEmail }: { initialEmail: string }) {
|
|
10
|
+
const [email, setEmail] = useState(initialEmail);
|
|
11
|
+
const [pending, setPending] = useState(false);
|
|
12
|
+
const [sent, setSent] = useState(false);
|
|
13
|
+
const [error, setError] = useState<string | null>(null);
|
|
14
|
+
|
|
15
|
+
const submit = async (event: React.FormEvent) => {
|
|
16
|
+
event.preventDefault();
|
|
17
|
+
setPending(true);
|
|
18
|
+
setError(null);
|
|
19
|
+
const { error } = await authClient.requestPasswordReset({ email, redirectTo: "/reset-password" });
|
|
20
|
+
setPending(false);
|
|
21
|
+
if (error) return setError(authErrorMessage(error));
|
|
22
|
+
setSent(true);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
if (sent) {
|
|
26
|
+
return <FormMessage tone="success">If {email} has an account, a reset link is on its way. It expires in 1 hour.</FormMessage>;
|
|
27
|
+
}
|
|
28
|
+
return (
|
|
29
|
+
<form className="flex flex-col gap-4" onSubmit={submit}>
|
|
30
|
+
<div className="flex flex-col gap-2">
|
|
31
|
+
<AuthLabel htmlFor="email">Email</AuthLabel>
|
|
32
|
+
<AuthInput id="email" type="email" autoComplete="email" value={email} onChange={(event) => setEmail(event.target.value)} required autoFocus />
|
|
33
|
+
</div>
|
|
34
|
+
<FormMessage>{error}</FormMessage>
|
|
35
|
+
<PrimaryButton type="submit" pending={pending}>
|
|
36
|
+
Send reset link
|
|
37
|
+
</PrimaryButton>
|
|
38
|
+
</form>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Sets the new password from the emailed link's token. */
|
|
43
|
+
export function ResetPasswordForm({ token }: { token: string }) {
|
|
44
|
+
const [password, setPassword] = useState("");
|
|
45
|
+
const [confirm, setConfirm] = useState("");
|
|
46
|
+
const [pending, setPending] = useState(false);
|
|
47
|
+
const [error, setError] = useState<string | null>(null);
|
|
48
|
+
|
|
49
|
+
const submit = async (event: React.FormEvent) => {
|
|
50
|
+
event.preventDefault();
|
|
51
|
+
if (password.length < 8) return setError("Use at least 8 characters.");
|
|
52
|
+
if (password !== confirm) return setError("The two passwords don't match.");
|
|
53
|
+
setPending(true);
|
|
54
|
+
setError(null);
|
|
55
|
+
const { error } = await authClient.resetPassword({ newPassword: password, token });
|
|
56
|
+
setPending(false);
|
|
57
|
+
if (error) {
|
|
58
|
+
return setError(error.code === "INVALID_TOKEN" ? "This link has expired or was already used. Ask for a new one." : authErrorMessage(error));
|
|
59
|
+
}
|
|
60
|
+
window.location.href = "/sign-in?reset=1";
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<form className="flex flex-col gap-4" onSubmit={submit}>
|
|
65
|
+
<div className="flex flex-col gap-2">
|
|
66
|
+
<AuthLabel htmlFor="password">New password</AuthLabel>
|
|
67
|
+
<AuthInput id="password" type="password" autoComplete="new-password" value={password} onChange={(event) => setPassword(event.target.value)} required minLength={8} autoFocus />
|
|
68
|
+
</div>
|
|
69
|
+
<div className="flex flex-col gap-2">
|
|
70
|
+
<AuthLabel htmlFor="confirm">Confirm it</AuthLabel>
|
|
71
|
+
<AuthInput id="confirm" type="password" autoComplete="new-password" value={confirm} onChange={(event) => setConfirm(event.target.value)} required />
|
|
72
|
+
</div>
|
|
73
|
+
<FormMessage>{error}</FormMessage>
|
|
74
|
+
<PrimaryButton type="submit" pending={pending}>
|
|
75
|
+
Set new password
|
|
76
|
+
</PrimaryButton>
|
|
77
|
+
</form>
|
|
78
|
+
);
|
|
79
|
+
}
|