@flaredev/cli 0.3.0 → 0.4.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 (42) hide show
  1. package/dist/index.js +385 -209
  2. package/package.json +2 -2
  3. package/templates/app/app/dashboard/account/layout.tsx +30 -0
  4. package/templates/app/app/dashboard/account/page.tsx +11 -33
  5. package/templates/app/app/dashboard/account/password/page.tsx +22 -0
  6. package/templates/app/app/dashboard/account/security/page.tsx +34 -0
  7. package/templates/app/app/dashboard/account/sessions/page.tsx +9 -0
  8. package/templates/app/app/dashboard/actions.ts +35 -0
  9. package/templates/app/app/dashboard/layout.tsx +28 -4
  10. package/templates/app/app/dashboard/observability/page.tsx +4 -3
  11. package/templates/app/app/dashboard/page.tsx +2 -2
  12. package/templates/app/components/account/account-nav.tsx +39 -0
  13. package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -522
  14. package/templates/app/components/dashboard/column-menu.tsx +66 -0
  15. package/templates/app/components/dashboard/dashboard-header.tsx +115 -78
  16. package/templates/app/components/dashboard/dashboard-sidebar.tsx +86 -24
  17. package/templates/app/components/dashboard/dropzone.tsx +92 -0
  18. package/templates/app/components/dashboard/editable.ts +12 -0
  19. package/templates/app/components/dashboard/fields/file-field.tsx +306 -302
  20. package/templates/app/components/dashboard/import-dialog.tsx +60 -7
  21. package/templates/app/components/dashboard/inline-cell.tsx +165 -0
  22. package/templates/app/components/dashboard/local-time.tsx +77 -0
  23. package/templates/app/components/dashboard/query.ts +2 -0
  24. package/templates/app/components/dashboard/record-actions.tsx +113 -0
  25. package/templates/app/components/dashboard/record-detail.tsx +200 -0
  26. package/templates/app/components/dashboard/resource-chart.tsx +91 -0
  27. package/templates/app/components/dashboard/resource-stats.tsx +6 -11
  28. package/templates/app/components/dashboard/resource-table.tsx +405 -331
  29. package/templates/app/components/dashboard/row-actions.tsx +156 -144
  30. package/templates/app/components/dashboard/save-view-button.tsx +86 -0
  31. package/templates/app/components/dashboard/user-menu.tsx +91 -0
  32. package/templates/app/components/ui/sonner.tsx +33 -13
  33. package/templates/app/db/flare-schema.ts +22 -0
  34. package/templates/app/lib/api.ts +12 -0
  35. package/templates/app/lib/dashboard.ts +165 -138
  36. package/templates/app/lib/views.ts +29 -0
  37. package/templates/app/migrations/0000_init.sql +11 -0
  38. package/templates/app/migrations/meta/0000_snapshot.json +63 -1
  39. package/templates/app/vite.config.ts +41 -31
  40. package/templates/locks/package-lock.json +3 -3
  41. package/templates/locks/pnpm-lock.yaml +4 -4
  42. package/templates/locks/yarn.lock +3548 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flaredev/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "The flare command-line tool: create, generate, migrate, and deploy Flare apps",
5
5
  "license": "MIT",
6
6
  "author": "Muke Johnbaptist",
@@ -41,7 +41,7 @@
41
41
  "jiti": "^2.7.0",
42
42
  "jsonc-parser": "^3.3.1",
43
43
  "picocolors": "^1.1.1",
44
- "@flaredev/core": "0.3.0"
44
+ "@flaredev/core": "0.4.0"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=22"
@@ -0,0 +1,30 @@
1
+ import { AccountNav } from "@/components/account/account-nav";
2
+ import { PageHeader } from "@/components/dashboard/page-header";
3
+ import { twoFactorAvailable, authConfig } from "@/lib/auth-config";
4
+ import { enabledSocialProviders } from "@/lib/auth";
5
+ import { requireSession } from "@/lib/session";
6
+
7
+ /** The frame the account's pages share: one header, one set of links. */
8
+ export default async function AccountLayout({ children }: { children: React.ReactNode }) {
9
+ await requireSession("/dashboard/account");
10
+ const security = twoFactorAvailable || authConfig.passkeys || enabledSocialProviders().length > 0;
11
+
12
+ return (
13
+ <>
14
+ <PageHeader
15
+ title="Account"
16
+ description="Your profile, how you sign in, and where you're signed in."
17
+ crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Account" }]}
18
+ />
19
+ <AccountNav
20
+ links={[
21
+ { href: "/dashboard/account", label: "Profile" },
22
+ { href: "/dashboard/account/password", label: "Password" },
23
+ ...(security ? [{ href: "/dashboard/account/security", label: "Security" }] : []),
24
+ { href: "/dashboard/account/sessions", label: "Devices" },
25
+ ]}
26
+ />
27
+ <div className="flex max-w-3xl flex-col gap-6">{children}</div>
28
+ </>
29
+ );
30
+ }
@@ -1,40 +1,18 @@
1
- import { and, eq } from "drizzle-orm";
2
- import { SecuritySettings } from "@/components/account/security-settings";
3
- import { PageHeader } from "@/components/dashboard/page-header";
4
- import { getDb } from "@/db";
5
- import { account } from "@/db/auth-schema";
6
- import { enabledSocialProviders } from "@/lib/auth";
7
- import { authConfig } from "@/lib/auth-config";
1
+ import { Profile } from "@/components/account/sections";
8
2
  import { requireSession } from "@/lib/session";
9
3
 
10
- export const metadata = { title: "Account" };
4
+ export const metadata = { title: "Profile" };
11
5
 
12
- export default async function AccountPage() {
6
+ export default async function ProfilePage() {
13
7
  const { user } = await requireSession("/dashboard/account");
14
- const credential = await getDb()
15
- .select({ id: account.id })
16
- .from(account)
17
- .where(and(eq(account.userId, user.id), eq(account.providerId, "credential")))
18
- .get();
19
-
20
8
  return (
21
- <>
22
- <PageHeader
23
- title="Account"
24
- description="Your profile, how you sign in, and where you're signed in."
25
- crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Account" }]}
26
- />
27
- <SecuritySettings
28
- user={{
29
- name: user.name,
30
- email: user.email,
31
- emailVerified: user.emailVerified,
32
- twoFactorEnabled: Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled),
33
- }}
34
- hasPassword={Boolean(credential)}
35
- methods={{ passkeys: authConfig.passkeys, twoFactor: authConfig.twoFactor }}
36
- socialProviders={enabledSocialProviders()}
37
- />
38
- </>
9
+ <Profile
10
+ user={{
11
+ name: user.name,
12
+ email: user.email,
13
+ emailVerified: user.emailVerified,
14
+ twoFactorEnabled: Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled),
15
+ }}
16
+ />
39
17
  );
40
18
  }
@@ -0,0 +1,22 @@
1
+ import { and, eq } from "drizzle-orm";
2
+ import { Password } from "@/components/account/sections";
3
+ import { getDb } from "@/db";
4
+ import { account } from "@/db/auth-schema";
5
+ import { requireSession } from "@/lib/session";
6
+
7
+ export const metadata = { title: "Password" };
8
+
9
+ /** Whether this person signs in with a password at all: social and passkey accounts have none. */
10
+ async function hasPassword(userId: string) {
11
+ const credential = await getDb()
12
+ .select({ id: account.id })
13
+ .from(account)
14
+ .where(and(eq(account.userId, userId), eq(account.providerId, "credential")))
15
+ .get();
16
+ return Boolean(credential);
17
+ }
18
+
19
+ export default async function PasswordPage() {
20
+ const { user } = await requireSession("/dashboard/account/password");
21
+ return <Password hasPassword={await hasPassword(user.id)} email={user.email} />;
22
+ }
@@ -0,0 +1,34 @@
1
+ import { and, eq } from "drizzle-orm";
2
+ import { ConnectedAccounts, Passkeys, TwoFactor } from "@/components/account/sections";
3
+ import { getDb } from "@/db";
4
+ import { account } from "@/db/auth-schema";
5
+ import { enabledSocialProviders } from "@/lib/auth";
6
+ import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
7
+ import { requireSession } from "@/lib/session";
8
+
9
+ export const metadata = { title: "Security" };
10
+
11
+ /** Two-factor, passkeys and the accounts you can sign in with. */
12
+ export default async function SecurityPage() {
13
+ const { user } = await requireSession("/dashboard/account/security");
14
+ const credential = await getDb()
15
+ .select({ id: account.id })
16
+ .from(account)
17
+ .where(and(eq(account.userId, user.id), eq(account.providerId, "credential")))
18
+ .get();
19
+ const providers = enabledSocialProviders();
20
+
21
+ return (
22
+ <>
23
+ {twoFactorAvailable && (
24
+ <TwoFactor
25
+ enabled={Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled)}
26
+ hasPassword={Boolean(credential)}
27
+ methods={authConfig.twoFactor}
28
+ />
29
+ )}
30
+ {authConfig.passkeys && <Passkeys />}
31
+ {providers.length > 0 && <ConnectedAccounts providers={providers} />}
32
+ </>
33
+ );
34
+ }
@@ -0,0 +1,9 @@
1
+ import { Sessions } from "@/components/account/sections";
2
+ import { requireSession } from "@/lib/session";
3
+
4
+ export const metadata = { title: "Devices" };
5
+
6
+ export default async function SessionsPage() {
7
+ await requireSession("/dashboard/account/sessions");
8
+ return <Sessions />;
9
+ }
@@ -4,6 +4,10 @@ import { revalidatePath } from "next/cache";
4
4
  import { createObjectKey, fileKeyPrefix, fileMaxBytes, matchesContentType, mimeTypesFor, type FileField } from "@flaredev/core";
5
5
  import type { FieldIssue } from "@flaredev/core/server";
6
6
  import { diffFields, recordAudit } from "@/lib/audit";
7
+ import { listViews, MAX_VIEWS } from "@/lib/views";
8
+ import { and, eq } from "drizzle-orm";
9
+ import { getDb } from "@/db";
10
+ import { savedView } from "@/db/flare-schema";
7
11
  import { toCsv } from "@/lib/csv";
8
12
  import { storage } from "@/lib/storage";
9
13
  import { can, storedFields, type PolicyAction } from "@flaredev/core";
@@ -109,6 +113,37 @@ export async function exportRecordsAction(resourceName: string, query: string):
109
113
  return { ok: true, data: { csv, rows: rows.length, truncated } };
110
114
  }
111
115
 
116
+ /** Keep the table's current query as a named view, for this person. */
117
+ export async function saveViewAction(resourceName: string, name: string, query: string): Promise<ActionResult<{ id: string }>> {
118
+ const denied = await allowed(resourceName, "read");
119
+ if (denied) return denied;
120
+ const { session } = await dashboardSession();
121
+ if (!session) return { ok: false, status: 401, error: "Sign in to save a view." };
122
+
123
+ const label = name.trim().slice(0, 60);
124
+ if (!label) return { ok: false, status: 400, error: "Give the view a name." };
125
+ const existing = await listViews(resourceName);
126
+ if (existing.length >= MAX_VIEWS) {
127
+ return { ok: false, status: 400, error: `That's ${MAX_VIEWS} views for ${resourceName}. Delete one first.` };
128
+ }
129
+
130
+ const [row] = await getDb()
131
+ .insert(savedView)
132
+ .values({ userId: session.user.id, resource: resourceName, name: label, query: query.replace(/^\?/, "").slice(0, 2000) })
133
+ .returning({ id: savedView.id });
134
+ revalidatePath("/dashboard", "layout");
135
+ return { ok: true, data: { id: row!.id } };
136
+ }
137
+
138
+ /** Views belong to the person who saved them, so only they can remove one. */
139
+ export async function deleteViewAction(id: string): Promise<ActionResult<{ id: string }>> {
140
+ const { session } = await dashboardSession();
141
+ if (!session) return { ok: false, status: 401, error: "Sign in first." };
142
+ await getDb().delete(savedView).where(and(eq(savedView.id, id), eq(savedView.userId, session.user.id)));
143
+ revalidatePath("/dashboard", "layout");
144
+ return { ok: true, data: { id } };
145
+ }
146
+
112
147
  export async function createRecordAction(resourceName: string, input: unknown): Promise<ActionResult<Record<string, unknown>>> {
113
148
  const denied = await allowed(resourceName, "create");
114
149
  if (denied) return denied;
@@ -1,15 +1,20 @@
1
1
  import { cookies } from "next/headers";
2
+ import { count, eq } from "drizzle-orm";
2
3
  import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
3
4
  import { Toaster } from "@/components/ui/sonner";
4
- import { DashboardHeader, type Theme } from "@/components/dashboard/dashboard-header";
5
+ import { DashboardHeader, type Notice, type Theme } from "@/components/dashboard/dashboard-header";
5
6
  import { DashboardSidebar } from "@/components/dashboard/dashboard-sidebar";
7
+ import { getDb } from "@/db";
8
+ import { passkey } from "@/db/auth-schema";
9
+ import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
6
10
  import { dashboardLinks } from "@/lib/dashboard-nav";
7
11
  import { ADMIN_ROLES, visibleResources } from "@/lib/dashboard";
8
12
  import { requireSession } from "@/lib/session";
13
+ import { listViews } from "@/lib/views";
9
14
 
10
15
  /**
11
16
  * The frame around every signed-in page: the app's resources in the sidebar, the
12
- * account menu in the header.
17
+ * account menu in the header and in the sidebar's footer.
13
18
  *
14
19
  * Everyone signed in gets a dashboard. What's in it depends on the policies: the
15
20
  * sidebar lists only the resources this person may read, and the sections that manage
@@ -22,18 +27,37 @@ export default async function DashboardLayout({ children }: { children: React.Re
22
27
  const collapsed = jar.get("sidebar_state")?.value === "false";
23
28
  const role = (user as { role?: string | null }).role ?? "";
24
29
 
30
+ const views = await listViews();
25
31
  const resources = (await visibleResources()).map((resource) => ({
26
32
  name: resource.name,
27
33
  pluralLabel: resource.pluralLabel,
28
34
  slug: resource.slug,
29
35
  icon: resource.icon,
36
+ group: resource.group,
37
+ views: views.filter((view) => view.resource === resource.name).map(({ id, name, query }) => ({ id, name, query })),
30
38
  }));
31
39
 
40
+ // What the bell has to say: the things about this account that aren't done yet.
41
+ const [{ passkeys } = { passkeys: 0 }] = await getDb().select({ passkeys: count() }).from(passkey).where(eq(passkey.userId, user.id));
42
+ const notices: Notice[] = [
43
+ ...(user.emailVerified
44
+ ? []
45
+ : [{ id: "verify-email", title: "Verify your email address", description: "We'll send a new link or code.", href: "/verify-email", tone: "warning" as const }]),
46
+ ...(twoFactorAvailable && !(user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled
47
+ ? [{ id: "two-factor", title: "Turn on two-factor authentication", description: "A second step after your password.", href: "/dashboard/account/security" }]
48
+ : []),
49
+ ...(authConfig.passkeys && passkeys === 0
50
+ ? [{ id: "passkey", title: "Add a passkey", description: "Sign in with Face ID, Touch ID or Windows Hello.", href: "/dashboard/account/security" }]
51
+ : []),
52
+ ];
53
+
54
+ const account = { name: user.name, email: user.email, image: (user as { image?: string | null }).image };
55
+
32
56
  return (
33
57
  <SidebarProvider defaultOpen={!collapsed}>
34
- <DashboardSidebar appName="__APP_NAME__" resources={resources} links={dashboardLinks} isAdmin={ADMIN_ROLES.includes(role)} />
58
+ <DashboardSidebar appName="__APP_NAME__" resources={resources} links={dashboardLinks} isAdmin={ADMIN_ROLES.includes(role)} user={account} />
35
59
  <SidebarInset>
36
- <DashboardHeader user={{ name: user.name, email: user.email }} initialTheme={theme} />
60
+ <DashboardHeader user={account} initialTheme={theme} notices={notices} />
37
61
  <main className="flex flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">{children}</main>
38
62
  </SidebarInset>
39
63
  <Toaster theme={theme} />
@@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
6
6
  import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
7
7
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
8
8
  import { PageHeader } from "@/components/dashboard/page-header";
9
+ import { LocalTime } from "@/components/dashboard/local-time";
9
10
  import { StatCards, type Stat } from "@/components/dashboard/stat-card";
10
11
  import { TrafficChart } from "@/components/dashboard/traffic-chart";
11
12
  import { auditCount, recentAudit } from "@/lib/audit";
@@ -23,8 +24,6 @@ const ACTION_TONE: Record<string, "secondary" | "destructive" | "outline"> = {
23
24
  import: "secondary",
24
25
  };
25
26
 
26
- const when = (date: Date) => new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
27
-
28
27
  /** How the app is behaving: traffic from Cloudflare, size from the database, and who changed what. */
29
28
  export default async function ObservabilityPage() {
30
29
  await requireDashboard("/dashboard/observability");
@@ -151,7 +150,9 @@ export default async function ObservabilityPage() {
151
150
  </TableCell>
152
151
  <TableCell className="max-w-56 truncate">{entry.recordLabel ?? describeBulk(entry.changes)}</TableCell>
153
152
  <TableCell className="max-w-48 truncate text-muted-foreground">{entry.userEmail ?? "—"}</TableCell>
154
- <TableCell className="text-right whitespace-nowrap text-muted-foreground tabular-nums">{when(entry.createdAt)}</TableCell>
153
+ <TableCell className="text-right whitespace-nowrap text-muted-foreground tabular-nums">
154
+ <LocalTime value={entry.createdAt} />
155
+ </TableCell>
155
156
  </TableRow>
156
157
  ))}
157
158
  </TableBody>
@@ -44,8 +44,8 @@ export default async function DashboardPage() {
44
44
  const twoFactorOn = Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled);
45
45
  const steps = [
46
46
  { done: user.emailVerified, label: "Verify your email address", href: "/verify-email" },
47
- ...(twoFactorAvailable ? [{ done: twoFactorOn, label: "Turn on two-factor authentication", href: "/dashboard/account#two-factor" }] : []),
48
- ...(authConfig.passkeys ? [{ done: passkeys > 0, label: "Add a passkey for one-tap sign-in", href: "/dashboard/account#passkeys" }] : []),
47
+ ...(twoFactorAvailable ? [{ done: twoFactorOn, label: "Turn on two-factor authentication", href: "/dashboard/account/security" }] : []),
48
+ ...(authConfig.passkeys ? [{ done: passkeys > 0, label: "Add a passkey for one-tap sign-in", href: "/dashboard/account/security" }] : []),
49
49
  ];
50
50
  const remaining = steps.filter((step) => !step.done).length;
51
51
 
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { usePathname } from "next/navigation";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ export interface AccountLink {
8
+ href: string;
9
+ label: string;
10
+ }
11
+
12
+ /**
13
+ * The account's own navigation. Each part of an account is its own page: a single page
14
+ * of everything means scrolling past two-factor to reach your devices, and no way to
15
+ * send someone straight to the part that needs their attention.
16
+ */
17
+ export function AccountNav({ links }: { links: AccountLink[] }) {
18
+ const pathname = usePathname();
19
+ return (
20
+ <nav aria-label="Account" className="-mb-px flex gap-1 overflow-x-auto border-b">
21
+ {links.map((link) => {
22
+ const current = pathname === link.href;
23
+ return (
24
+ <Link
25
+ key={link.href}
26
+ href={link.href}
27
+ aria-current={current ? "page" : undefined}
28
+ className={cn(
29
+ "border-b-2 px-3 py-2.5 text-sm font-medium whitespace-nowrap transition-colors",
30
+ current ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground",
31
+ )}
32
+ >
33
+ {link.label}
34
+ </Link>
35
+ );
36
+ })}
37
+ </nav>
38
+ );
39
+ }