@flaredev/cli 0.2.0 → 0.3.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.
Files changed (64) hide show
  1. package/bin/flare.js +9 -1
  2. package/dist/index.js +751 -280
  3. package/package.json +2 -2
  4. package/templates/app/app/admin/page.tsx +9 -61
  5. package/templates/app/app/dashboard/account/page.tsx +6 -4
  6. package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
  7. package/templates/app/app/dashboard/import-actions.ts +91 -0
  8. package/templates/app/app/dashboard/layout.tsx +42 -39
  9. package/templates/app/app/dashboard/observability/page.tsx +174 -0
  10. package/templates/app/app/dashboard/page.tsx +106 -25
  11. package/templates/app/app/verify-email/page.tsx +33 -0
  12. package/templates/app/components/account/security-settings.tsx +6 -9
  13. package/templates/app/components/auth/otp-input.tsx +95 -0
  14. package/templates/app/components/auth/password-field.tsx +116 -0
  15. package/templates/app/components/auth/password-forms.tsx +76 -79
  16. package/templates/app/components/auth/sign-in-flow.tsx +306 -315
  17. package/templates/app/components/auth/sign-up-form.tsx +93 -101
  18. package/templates/app/components/auth/two-factor-form.tsx +139 -124
  19. package/templates/app/components/auth/use-resend.ts +44 -0
  20. package/templates/app/components/auth/verify-email-form.tsx +135 -0
  21. package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
  22. package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +166 -120
  23. package/templates/app/components/dashboard/export-button.tsx +41 -0
  24. package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
  25. package/templates/app/components/dashboard/import-dialog.tsx +487 -0
  26. package/templates/app/components/dashboard/page-header.tsx +57 -0
  27. package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
  28. package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
  29. package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
  30. package/templates/app/components/dashboard/resource-stats.tsx +53 -0
  31. package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -281
  32. package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
  33. package/templates/app/components/dashboard/stat-card.tsx +69 -0
  34. package/templates/app/components/dashboard/table-selection.tsx +185 -0
  35. package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
  36. package/templates/app/db/flare-schema.ts +33 -1
  37. package/templates/app/lib/analytics.ts +121 -0
  38. package/templates/app/lib/audit.ts +80 -0
  39. package/templates/app/lib/auth-config.ts +38 -33
  40. package/templates/app/lib/auth.ts +34 -0
  41. package/templates/app/lib/cache.ts +83 -83
  42. package/templates/app/lib/csv.ts +24 -0
  43. package/templates/app/lib/dashboard-nav.ts +15 -0
  44. package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
  45. package/templates/app/lib/password-rules.ts +159 -0
  46. package/templates/app/lib/site.ts +56 -47
  47. package/templates/app/migrations/0000_init.sql +15 -3
  48. package/templates/app/migrations/meta/0000_snapshot.json +97 -1
  49. package/templates/app/migrations/meta/_journal.json +1 -1
  50. package/templates/app/worker-configuration.d.ts +15764 -0
  51. package/templates/locks/package-lock.json +9189 -0
  52. package/templates/locks/pnpm-lock.yaml +6347 -0
  53. package/templates/app/app/admin/layout.tsx +0 -32
  54. package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
  55. package/templates/app/lib/admin-nav.ts +0 -15
  56. /package/templates/app/components/{admin → dashboard}/fields/country-field.tsx +0 -0
  57. /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
  58. /package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +0 -0
  59. /package/templates/app/components/{admin → dashboard}/fields/multi-select-field.tsx +0 -0
  60. /package/templates/app/components/{admin → dashboard}/fields/phone-field.tsx +0 -0
  61. /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
  62. /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
  63. /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
  64. /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
@@ -1,78 +1,78 @@
1
- "use client";
2
-
3
- import { useState } from "react";
4
- import { LogOutIcon, MoonIcon, SunIcon } from "lucide-react";
5
- import { authClient } from "@/lib/auth-client";
6
- import { Button } from "@/components/ui/button";
7
- import {
8
- DropdownMenu,
9
- DropdownMenuContent,
10
- DropdownMenuGroup,
11
- DropdownMenuItem,
12
- DropdownMenuLabel,
13
- DropdownMenuSeparator,
14
- DropdownMenuTrigger,
15
- } from "@/components/ui/dropdown-menu";
16
- import { Separator } from "@/components/ui/separator";
17
- import { SidebarTrigger } from "@/components/ui/sidebar";
18
-
19
- export type Theme = "light" | "dark" | "system";
20
-
21
- /** Persist the choice for SSR (so the first paint matches) and apply it now. */
22
- function applyTheme(theme: Theme) {
23
- document.cookie = `flare-theme=${theme}; path=/; max-age=31536000; samesite=lax`;
24
- const root = document.documentElement;
25
- root.classList.remove("light", "dark");
26
- if (theme !== "system") root.classList.add(theme);
27
- }
28
-
29
- export function AdminHeader({ user, initialTheme }: { user: { name?: string | null; email: string }; initialTheme: Theme }) {
30
- const [theme, setTheme] = useState<Theme>(initialTheme);
31
- const dark = theme === "dark" || (theme === "system" && typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches);
32
-
33
- return (
34
- <header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background px-4">
35
- <SidebarTrigger />
36
- <Separator orientation="vertical" className="mr-1 h-4" />
37
- <div className="ml-auto flex items-center gap-1">
38
- <Button
39
- variant="ghost"
40
- size="icon"
41
- aria-label={dark ? "Switch to light theme" : "Switch to dark theme"}
42
- onClick={() => {
43
- const next: Theme = dark ? "light" : "dark";
44
- setTheme(next);
45
- applyTheme(next);
46
- }}
47
- >
48
- {dark ? <SunIcon /> : <MoonIcon />}
49
- </Button>
50
- <DropdownMenu>
51
- <DropdownMenuTrigger asChild>
52
- <Button variant="ghost" size="sm" className="gap-2">
53
- <span className="max-w-40 truncate">{user.name || user.email}</span>
54
- </Button>
55
- </DropdownMenuTrigger>
56
- <DropdownMenuContent align="end" className="w-56">
57
- <DropdownMenuLabel className="font-normal">
58
- <span className="block truncate text-sm font-medium">{user.name || "Signed in"}</span>
59
- <span className="block truncate text-xs text-muted-foreground">{user.email}</span>
60
- </DropdownMenuLabel>
61
- <DropdownMenuSeparator />
62
- <DropdownMenuGroup>
63
- <DropdownMenuItem
64
- onSelect={async () => {
65
- await authClient.signOut();
66
- window.location.assign("/sign-in");
67
- }}
68
- >
69
- <LogOutIcon />
70
- Sign out
71
- </DropdownMenuItem>
72
- </DropdownMenuGroup>
73
- </DropdownMenuContent>
74
- </DropdownMenu>
75
- </div>
76
- </header>
77
- );
78
- }
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { LogOutIcon, MoonIcon, SunIcon } from "lucide-react";
5
+ import { authClient } from "@/lib/auth-client";
6
+ import { Button } from "@/components/ui/button";
7
+ import {
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuGroup,
11
+ DropdownMenuItem,
12
+ DropdownMenuLabel,
13
+ DropdownMenuSeparator,
14
+ DropdownMenuTrigger,
15
+ } from "@/components/ui/dropdown-menu";
16
+ import { Separator } from "@/components/ui/separator";
17
+ import { SidebarTrigger } from "@/components/ui/sidebar";
18
+
19
+ export type Theme = "light" | "dark" | "system";
20
+
21
+ /** Persist the choice for SSR (so the first paint matches) and apply it now. */
22
+ function applyTheme(theme: Theme) {
23
+ document.cookie = `flare-theme=${theme}; path=/; max-age=31536000; samesite=lax`;
24
+ const root = document.documentElement;
25
+ root.classList.remove("light", "dark");
26
+ if (theme !== "system") root.classList.add(theme);
27
+ }
28
+
29
+ export function DashboardHeader({ user, initialTheme }: { user: { name?: string | null; email: string }; initialTheme: Theme }) {
30
+ const [theme, setTheme] = useState<Theme>(initialTheme);
31
+ const dark = theme === "dark" || (theme === "system" && typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches);
32
+
33
+ return (
34
+ <header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background px-4">
35
+ <SidebarTrigger />
36
+ <Separator orientation="vertical" className="mr-1 h-4" />
37
+ <div className="ml-auto flex items-center gap-1">
38
+ <Button
39
+ variant="ghost"
40
+ size="icon"
41
+ aria-label={dark ? "Switch to light theme" : "Switch to dark theme"}
42
+ onClick={() => {
43
+ const next: Theme = dark ? "light" : "dark";
44
+ setTheme(next);
45
+ applyTheme(next);
46
+ }}
47
+ >
48
+ {dark ? <SunIcon /> : <MoonIcon />}
49
+ </Button>
50
+ <DropdownMenu>
51
+ <DropdownMenuTrigger asChild>
52
+ <Button variant="ghost" size="sm" className="gap-2">
53
+ <span className="max-w-40 truncate">{user.name || user.email}</span>
54
+ </Button>
55
+ </DropdownMenuTrigger>
56
+ <DropdownMenuContent align="end" className="w-56">
57
+ <DropdownMenuLabel className="font-normal">
58
+ <span className="block truncate text-sm font-medium">{user.name || "Signed in"}</span>
59
+ <span className="block truncate text-xs text-muted-foreground">{user.email}</span>
60
+ </DropdownMenuLabel>
61
+ <DropdownMenuSeparator />
62
+ <DropdownMenuGroup>
63
+ <DropdownMenuItem
64
+ onSelect={async () => {
65
+ await authClient.signOut();
66
+ window.location.assign("/sign-in");
67
+ }}
68
+ >
69
+ <LogOutIcon />
70
+ Sign out
71
+ </DropdownMenuItem>
72
+ </DropdownMenuGroup>
73
+ </DropdownMenuContent>
74
+ </DropdownMenu>
75
+ </div>
76
+ </header>
77
+ );
78
+ }
@@ -1,120 +1,166 @@
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
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { usePathname } from "next/navigation";
5
+ import { ActivityIcon, LayoutDashboardIcon, UserCircleIcon } 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
+ /** Sections every dashboard has, whatever resources the app defines. */
34
+ const ACCOUNT_LINKS = [{ href: "/dashboard/account", label: "Account", icon: UserCircleIcon }];
35
+ // Security has its own page once `flare gen security` runs, which adds its link to
36
+ // lib/dashboard-nav.ts; observability ships with every app.
37
+ const ADMIN_LINKS = [{ href: "/dashboard/observability", label: "Observability", icon: ActivityIcon }];
38
+
39
+ export function DashboardSidebar({
40
+ appName,
41
+ resources,
42
+ links = [],
43
+ isAdmin = false,
44
+ }: {
45
+ appName: string;
46
+ resources: NavResource[];
47
+ /** Extra pages (see lib/dashboard-nav.ts), shown under "Platform". */
48
+ links?: Array<{ label: string; href: string; icon: string }>;
49
+ /** Whether to show the sections that manage the app itself. */
50
+ isAdmin?: boolean;
51
+ }) {
52
+ const pathname = usePathname();
53
+ const manage = [...ACCOUNT_LINKS, ...(isAdmin ? ADMIN_LINKS : [])];
54
+
55
+ return (
56
+ <Sidebar collapsible="icon">
57
+ <SidebarHeader>
58
+ <SidebarMenu>
59
+ <SidebarMenuItem>
60
+ <SidebarMenuButton asChild size="lg" tooltip={appName}>
61
+ <Link href="/dashboard">
62
+ <div className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground">
63
+ <LayoutDashboardIcon className="size-4" />
64
+ </div>
65
+ <span className="truncate font-semibold">{appName}</span>
66
+ </Link>
67
+ </SidebarMenuButton>
68
+ </SidebarMenuItem>
69
+ </SidebarMenu>
70
+ </SidebarHeader>
71
+
72
+ <SidebarContent>
73
+ <SidebarGroup>
74
+ <SidebarGroupContent>
75
+ <SidebarMenu>
76
+ <SidebarMenuItem>
77
+ <SidebarMenuButton asChild isActive={pathname === "/dashboard"} tooltip="Overview">
78
+ <Link href="/dashboard">
79
+ <LayoutDashboardIcon />
80
+ <span>Overview</span>
81
+ </Link>
82
+ </SidebarMenuButton>
83
+ </SidebarMenuItem>
84
+ </SidebarMenu>
85
+ </SidebarGroupContent>
86
+ </SidebarGroup>
87
+
88
+ {resources.length > 0 && (
89
+ <SidebarGroup>
90
+ <SidebarGroupLabel>Resources</SidebarGroupLabel>
91
+ <SidebarGroupContent>
92
+ <SidebarMenu>
93
+ {resources.map((resource) => {
94
+ const href = `/dashboard/${resource.slug}`;
95
+ const Icon = resourceIcon(resource.icon);
96
+ return (
97
+ <SidebarMenuItem key={resource.name}>
98
+ <SidebarMenuButton asChild isActive={pathname === href || pathname.startsWith(`${href}/`)} tooltip={resource.pluralLabel}>
99
+ <Link href={href}>
100
+ <Icon />
101
+ <span>{resource.pluralLabel}</span>
102
+ </Link>
103
+ </SidebarMenuButton>
104
+ </SidebarMenuItem>
105
+ );
106
+ })}
107
+ </SidebarMenu>
108
+ </SidebarGroupContent>
109
+ </SidebarGroup>
110
+ )}
111
+
112
+ <SidebarGroup>
113
+ <SidebarGroupLabel>Manage</SidebarGroupLabel>
114
+ <SidebarGroupContent>
115
+ <SidebarMenu>
116
+ {manage.map((link) => (
117
+ <SidebarMenuItem key={link.href}>
118
+ <SidebarMenuButton asChild isActive={pathname === link.href || pathname.startsWith(`${link.href}/`)} tooltip={link.label}>
119
+ <Link href={link.href}>
120
+ <link.icon />
121
+ <span>{link.label}</span>
122
+ </Link>
123
+ </SidebarMenuButton>
124
+ </SidebarMenuItem>
125
+ ))}
126
+ </SidebarMenu>
127
+ </SidebarGroupContent>
128
+ </SidebarGroup>
129
+
130
+ {links.length > 0 && (
131
+ <SidebarGroup>
132
+ <SidebarGroupLabel>Platform</SidebarGroupLabel>
133
+ <SidebarGroupContent>
134
+ <SidebarMenu>
135
+ {links.map((link) => {
136
+ const Icon = resourceIcon(link.icon);
137
+ // Route handlers (/api/...) and other sites are whole pages: open them in a new tab.
138
+ const external = link.href.startsWith("/api/") || /^https?:\/\//.test(link.href);
139
+ const content = (
140
+ <>
141
+ <Icon />
142
+ <span>{link.label}</span>
143
+ </>
144
+ );
145
+ return (
146
+ <SidebarMenuItem key={link.href}>
147
+ <SidebarMenuButton asChild isActive={pathname === link.href || pathname.startsWith(`${link.href}/`)} tooltip={link.label}>
148
+ {external ? (
149
+ <a href={link.href} target="_blank" rel="noreferrer">
150
+ {content}
151
+ </a>
152
+ ) : (
153
+ <Link href={link.href}>{content}</Link>
154
+ )}
155
+ </SidebarMenuButton>
156
+ </SidebarMenuItem>
157
+ );
158
+ })}
159
+ </SidebarMenu>
160
+ </SidebarGroupContent>
161
+ </SidebarGroup>
162
+ )}
163
+ </SidebarContent>
164
+ </Sidebar>
165
+ );
166
+ }
@@ -0,0 +1,41 @@
1
+ "use client";
2
+
3
+ import { useTransition } from "react";
4
+ import { useSearchParams } from "next/navigation";
5
+ import { DownloadIcon } from "lucide-react";
6
+ import { toast } from "sonner";
7
+ import { exportRecordsAction } from "@/app/dashboard/actions";
8
+ import { Button } from "@/components/ui/button";
9
+ import { Spinner } from "@/components/ui/spinner";
10
+ import { downloadCsv } from "./table-selection";
11
+
12
+ /**
13
+ * Exports what the table is showing — the same search, filters and sort, not just the
14
+ * page on screen. The server builds the CSV because the browser only ever holds one page.
15
+ */
16
+ export function ExportButton({ resourceName, pluralLabel }: { resourceName: string; pluralLabel: string }) {
17
+ const params = useSearchParams();
18
+ const [pending, startTransition] = useTransition();
19
+
20
+ const run = () =>
21
+ startTransition(async () => {
22
+ const query = new URLSearchParams(params.toString());
23
+ query.delete("page");
24
+ const result = await exportRecordsAction(resourceName, query.toString());
25
+ if (!result.ok) {
26
+ toast.error(result.error);
27
+ return;
28
+ }
29
+ const { csv, rows, truncated } = result.data;
30
+ downloadCsv(`${resourceName.toLowerCase()}-${new Date().toISOString().slice(0, 10)}.csv`, csv);
31
+ if (truncated) toast.warning(`Exported the first ${rows.toLocaleString()} ${pluralLabel.toLowerCase()}. Filter down for the rest.`);
32
+ else toast.success(`Exported ${rows.toLocaleString()} ${pluralLabel.toLowerCase()}.`);
33
+ });
34
+
35
+ return (
36
+ <Button variant="outline" onClick={run} disabled={pending}>
37
+ {pending ? <Spinner data-icon="inline-start" /> : <DownloadIcon data-icon="inline-start" />}
38
+ Export
39
+ </Button>
40
+ );
41
+ }