@flaredev/cli 0.1.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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -0
  3. package/bin/flare.js +2 -0
  4. package/dist/index.js +3664 -0
  5. package/package.json +56 -0
  6. package/templates/app/.dev.vars.example +13 -0
  7. package/templates/app/README.md +9 -0
  8. package/templates/app/_gitignore +10 -0
  9. package/templates/app/app/admin/actions.ts +113 -0
  10. package/templates/app/app/admin/layout.tsx +32 -0
  11. package/templates/app/app/admin/page.tsx +63 -0
  12. package/templates/app/app/api/auth/[...all]/route.ts +4 -0
  13. package/templates/app/app/api/health/route.ts +7 -0
  14. package/templates/app/app/api/storage/route.ts +6 -0
  15. package/templates/app/app/dashboard/page.tsx +18 -0
  16. package/templates/app/app/globals.css +156 -0
  17. package/templates/app/app/layout.tsx +26 -0
  18. package/templates/app/app/page.tsx +19 -0
  19. package/templates/app/app/sign-in/page.tsx +33 -0
  20. package/templates/app/app/sign-up/page.tsx +23 -0
  21. package/templates/app/components/admin/admin-header.tsx +78 -0
  22. package/templates/app/components/admin/fields/date-field.tsx +112 -0
  23. package/templates/app/components/admin/fields/field-widget.tsx +128 -0
  24. package/templates/app/components/admin/fields/file-field.tsx +302 -0
  25. package/templates/app/components/admin/fields/relation-field.tsx +132 -0
  26. package/templates/app/components/admin/query.ts +25 -0
  27. package/templates/app/components/admin/resource-form-page.tsx +55 -0
  28. package/templates/app/components/admin/resource-form.tsx +139 -0
  29. package/templates/app/components/admin/resource-icon.tsx +85 -0
  30. package/templates/app/components/admin/resource-nav.tsx +109 -0
  31. package/templates/app/components/admin/resource-table-toolbar.tsx +95 -0
  32. package/templates/app/components/admin/resource-table.tsx +251 -0
  33. package/templates/app/components/admin/row-actions.tsx +111 -0
  34. package/templates/app/components/auth-form.tsx +120 -0
  35. package/templates/app/components/sign-out-button.tsx +18 -0
  36. package/templates/app/components/ui/alert-dialog.tsx +196 -0
  37. package/templates/app/components/ui/alert.tsx +65 -0
  38. package/templates/app/components/ui/avatar.tsx +108 -0
  39. package/templates/app/components/ui/badge.tsx +53 -0
  40. package/templates/app/components/ui/breadcrumb.tsx +110 -0
  41. package/templates/app/components/ui/button.tsx +65 -0
  42. package/templates/app/components/ui/calendar.tsx +220 -0
  43. package/templates/app/components/ui/card.tsx +92 -0
  44. package/templates/app/components/ui/command.tsx +184 -0
  45. package/templates/app/components/ui/dialog.tsx +158 -0
  46. package/templates/app/components/ui/dropdown-menu.tsx +256 -0
  47. package/templates/app/components/ui/empty.tsx +103 -0
  48. package/templates/app/components/ui/field.tsx +248 -0
  49. package/templates/app/components/ui/input-group.tsx +170 -0
  50. package/templates/app/components/ui/input.tsx +20 -0
  51. package/templates/app/components/ui/label.tsx +23 -0
  52. package/templates/app/components/ui/pagination.tsx +127 -0
  53. package/templates/app/components/ui/popover.tsx +88 -0
  54. package/templates/app/components/ui/progress.tsx +30 -0
  55. package/templates/app/components/ui/select.tsx +189 -0
  56. package/templates/app/components/ui/separator.tsx +27 -0
  57. package/templates/app/components/ui/sheet.tsx +142 -0
  58. package/templates/app/components/ui/sidebar.tsx +726 -0
  59. package/templates/app/components/ui/skeleton.tsx +13 -0
  60. package/templates/app/components/ui/sonner.tsx +38 -0
  61. package/templates/app/components/ui/spinner.tsx +15 -0
  62. package/templates/app/components/ui/switch.tsx +34 -0
  63. package/templates/app/components/ui/table.tsx +115 -0
  64. package/templates/app/components/ui/textarea.tsx +17 -0
  65. package/templates/app/components/ui/tooltip.tsx +56 -0
  66. package/templates/app/components.json +21 -0
  67. package/templates/app/db/auth-schema.ts +118 -0
  68. package/templates/app/db/flare-schema.ts +15 -0
  69. package/templates/app/db/index.ts +10 -0
  70. package/templates/app/db/schema.ts +12 -0
  71. package/templates/app/drizzle.config.ts +8 -0
  72. package/templates/app/hooks/use-mobile.ts +19 -0
  73. package/templates/app/lib/admin-nav.ts +15 -0
  74. package/templates/app/lib/admin.ts +103 -0
  75. package/templates/app/lib/api.ts +24 -0
  76. package/templates/app/lib/auth-client.ts +4 -0
  77. package/templates/app/lib/auth.ts +44 -0
  78. package/templates/app/lib/cache.ts +83 -0
  79. package/templates/app/lib/mail.ts +36 -0
  80. package/templates/app/lib/realtime.ts +45 -0
  81. package/templates/app/lib/security.ts +13 -0
  82. package/templates/app/lib/session.ts +24 -0
  83. package/templates/app/lib/storage.ts +26 -0
  84. package/templates/app/lib/utils.ts +6 -0
  85. package/templates/app/migrations/0000_init.sql +66 -0
  86. package/templates/app/migrations/meta/0000_snapshot.json +438 -0
  87. package/templates/app/migrations/meta/_journal.json +13 -0
  88. package/templates/app/next.config.ts +5 -0
  89. package/templates/app/policies/index.ts +4 -0
  90. package/templates/app/postcss.config.mjs +7 -0
  91. package/templates/app/proxy.ts +20 -0
  92. package/templates/app/resources/index.ts +4 -0
  93. package/templates/app/resources/server.ts +6 -0
  94. package/templates/app/tsconfig.json +22 -0
  95. package/templates/app/vite.config.ts +27 -0
  96. package/templates/app/worker/index.ts +40 -0
  97. package/templates/app/wrangler.jsonc +52 -0
@@ -0,0 +1,13 @@
1
+ import { cn } from "@/lib/utils"
2
+
3
+ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
4
+ return (
5
+ <div
6
+ data-slot="skeleton"
7
+ className={cn("animate-pulse rounded-md bg-accent", className)}
8
+ {...props}
9
+ />
10
+ )
11
+ }
12
+
13
+ export { Skeleton }
@@ -0,0 +1,38 @@
1
+ "use client"
2
+
3
+ import {
4
+ CircleCheckIcon,
5
+ InfoIcon,
6
+ Loader2Icon,
7
+ OctagonXIcon,
8
+ TriangleAlertIcon,
9
+ } from "lucide-react"
10
+ import { Toaster as Sonner, type ToasterProps } from "sonner"
11
+
12
+ // The theme comes from the admin layout (cookie-backed), so no theme provider is needed.
13
+ const Toaster = ({ theme = "system", ...props }: ToasterProps) => {
14
+ return (
15
+ <Sonner
16
+ theme={theme as ToasterProps["theme"]}
17
+ className="toaster group"
18
+ icons={{
19
+ success: <CircleCheckIcon className="size-4" />,
20
+ info: <InfoIcon className="size-4" />,
21
+ warning: <TriangleAlertIcon className="size-4" />,
22
+ error: <OctagonXIcon className="size-4" />,
23
+ loading: <Loader2Icon className="size-4 animate-spin" />,
24
+ }}
25
+ style={
26
+ {
27
+ "--normal-bg": "var(--popover)",
28
+ "--normal-text": "var(--popover-foreground)",
29
+ "--normal-border": "var(--border)",
30
+ "--border-radius": "var(--radius)",
31
+ } as React.CSSProperties
32
+ }
33
+ {...props}
34
+ />
35
+ )
36
+ }
37
+
38
+ export { Toaster }
@@ -0,0 +1,15 @@
1
+ import { cn } from "@/lib/utils"
2
+ import { Loader2Icon } from "lucide-react"
3
+
4
+ function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
5
+ return (
6
+ <Loader2Icon
7
+ role="status"
8
+ aria-label="Loading"
9
+ className={cn("size-4 animate-spin", className)}
10
+ {...props}
11
+ />
12
+ )
13
+ }
14
+
15
+ export { Spinner }
@@ -0,0 +1,34 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "@/lib/utils"
5
+ import { Switch as SwitchPrimitive } from "radix-ui"
6
+
7
+ function Switch({
8
+ className,
9
+ size = "default",
10
+ ...props
11
+ }: React.ComponentProps<typeof SwitchPrimitive.Root> & {
12
+ size?: "sm" | "default"
13
+ }) {
14
+ return (
15
+ <SwitchPrimitive.Root
16
+ data-slot="switch"
17
+ data-size={size}
18
+ className={cn(
19
+ "peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
20
+ className
21
+ )}
22
+ {...props}
23
+ >
24
+ <SwitchPrimitive.Thumb
25
+ data-slot="switch-thumb"
26
+ className={cn(
27
+ "pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
28
+ )}
29
+ />
30
+ </SwitchPrimitive.Root>
31
+ )
32
+ }
33
+
34
+ export { Switch }
@@ -0,0 +1,115 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "@/lib/utils"
5
+
6
+ function Table({ className, ...props }: React.ComponentProps<"table">) {
7
+ return (
8
+ <div
9
+ data-slot="table-container"
10
+ className="relative w-full overflow-x-auto"
11
+ >
12
+ <table
13
+ data-slot="table"
14
+ className={cn("w-full caption-bottom text-sm", className)}
15
+ {...props}
16
+ />
17
+ </div>
18
+ )
19
+ }
20
+
21
+ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
22
+ return (
23
+ <thead
24
+ data-slot="table-header"
25
+ className={cn("[&_tr]:border-b", className)}
26
+ {...props}
27
+ />
28
+ )
29
+ }
30
+
31
+ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
32
+ return (
33
+ <tbody
34
+ data-slot="table-body"
35
+ className={cn("[&_tr:last-child]:border-0", className)}
36
+ {...props}
37
+ />
38
+ )
39
+ }
40
+
41
+ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
42
+ return (
43
+ <tfoot
44
+ data-slot="table-footer"
45
+ className={cn(
46
+ "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
47
+ className
48
+ )}
49
+ {...props}
50
+ />
51
+ )
52
+ }
53
+
54
+ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
55
+ return (
56
+ <tr
57
+ data-slot="table-row"
58
+ className={cn(
59
+ "border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
60
+ className
61
+ )}
62
+ {...props}
63
+ />
64
+ )
65
+ }
66
+
67
+ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
68
+ return (
69
+ <th
70
+ data-slot="table-head"
71
+ className={cn(
72
+ "h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
73
+ className
74
+ )}
75
+ {...props}
76
+ />
77
+ )
78
+ }
79
+
80
+ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
81
+ return (
82
+ <td
83
+ data-slot="table-cell"
84
+ className={cn(
85
+ "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
86
+ className
87
+ )}
88
+ {...props}
89
+ />
90
+ )
91
+ }
92
+
93
+ function TableCaption({
94
+ className,
95
+ ...props
96
+ }: React.ComponentProps<"caption">) {
97
+ return (
98
+ <caption
99
+ data-slot="table-caption"
100
+ className={cn("mt-4 text-sm text-muted-foreground", className)}
101
+ {...props}
102
+ />
103
+ )
104
+ }
105
+
106
+ export {
107
+ Table,
108
+ TableHeader,
109
+ TableBody,
110
+ TableFooter,
111
+ TableHead,
112
+ TableRow,
113
+ TableCell,
114
+ TableCaption,
115
+ }
@@ -0,0 +1,17 @@
1
+ import * as React from "react"
2
+ import { cn } from "@/lib/utils"
3
+
4
+ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
5
+ return (
6
+ <textarea
7
+ data-slot="textarea"
8
+ className={cn(
9
+ "flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
10
+ className
11
+ )}
12
+ {...props}
13
+ />
14
+ )
15
+ }
16
+
17
+ export { Textarea }
@@ -0,0 +1,56 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "@/lib/utils"
5
+ import { Tooltip as TooltipPrimitive } from "radix-ui"
6
+
7
+ function TooltipProvider({
8
+ delayDuration = 0,
9
+ ...props
10
+ }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
11
+ return (
12
+ <TooltipPrimitive.Provider
13
+ data-slot="tooltip-provider"
14
+ delayDuration={delayDuration}
15
+ {...props}
16
+ />
17
+ )
18
+ }
19
+
20
+ function Tooltip({
21
+ ...props
22
+ }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
23
+ return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
24
+ }
25
+
26
+ function TooltipTrigger({
27
+ ...props
28
+ }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
29
+ return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
30
+ }
31
+
32
+ function TooltipContent({
33
+ className,
34
+ sideOffset = 0,
35
+ children,
36
+ ...props
37
+ }: React.ComponentProps<typeof TooltipPrimitive.Content>) {
38
+ return (
39
+ <TooltipPrimitive.Portal>
40
+ <TooltipPrimitive.Content
41
+ data-slot="tooltip-content"
42
+ sideOffset={sideOffset}
43
+ className={cn(
44
+ "z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
45
+ className
46
+ )}
47
+ {...props}
48
+ >
49
+ {children}
50
+ <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
51
+ </TooltipPrimitive.Content>
52
+ </TooltipPrimitive.Portal>
53
+ )
54
+ }
55
+
56
+ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "new-york",
4
+ "rsc": true,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "app/globals.css",
9
+ "baseColor": "neutral",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "iconLibrary": "lucide",
14
+ "aliases": {
15
+ "components": "@/components",
16
+ "utils": "@/lib/utils",
17
+ "ui": "@/components/ui",
18
+ "lib": "@/lib",
19
+ "hooks": "@/hooks"
20
+ }
21
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Better Auth tables (user, session, account, verification), including the admin
3
+ * plugin's columns (user.role/banned/ban_reason/ban_expires, session.impersonated_by).
4
+ * Generated with `auth generate --adapter drizzle --dialect sqlite` (better-auth 1.7.5).
5
+ * Re-generate if you add Better Auth plugins that extend the schema.
6
+ */
7
+ import { relations, sql } from "drizzle-orm";
8
+ import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
9
+
10
+ export const user = sqliteTable("user", {
11
+ id: text("id").primaryKey(),
12
+ name: text("name").notNull(),
13
+ email: text("email").notNull().unique(),
14
+ emailVerified: integer("email_verified", { mode: "boolean" })
15
+ .default(false)
16
+ .notNull(),
17
+ image: text("image"),
18
+ createdAt: integer("created_at", { mode: "timestamp_ms" })
19
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
20
+ .notNull(),
21
+ updatedAt: integer("updated_at", { mode: "timestamp_ms" })
22
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
23
+ .$onUpdate(() => /* @__PURE__ */ new Date())
24
+ .notNull(),
25
+ role: text("role"),
26
+ banned: integer("banned", { mode: "boolean" }).default(false),
27
+ banReason: text("ban_reason"),
28
+ banExpires: integer("ban_expires", { mode: "timestamp_ms" }),
29
+ });
30
+
31
+ export const session = sqliteTable(
32
+ "session",
33
+ {
34
+ id: text("id").primaryKey(),
35
+ expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
36
+ token: text("token").notNull().unique(),
37
+ createdAt: integer("created_at", { mode: "timestamp_ms" })
38
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
39
+ .notNull(),
40
+ updatedAt: integer("updated_at", { mode: "timestamp_ms" })
41
+ .$onUpdate(() => /* @__PURE__ */ new Date())
42
+ .notNull(),
43
+ ipAddress: text("ip_address"),
44
+ userAgent: text("user_agent"),
45
+ userId: text("user_id")
46
+ .notNull()
47
+ .references(() => user.id, { onDelete: "cascade" }),
48
+ impersonatedBy: text("impersonated_by"),
49
+ },
50
+ (table) => [index("session_userId_idx").on(table.userId)],
51
+ );
52
+
53
+ export const account = sqliteTable(
54
+ "account",
55
+ {
56
+ id: text("id").primaryKey(),
57
+ accountId: text("account_id").notNull(),
58
+ providerId: text("provider_id").notNull(),
59
+ userId: text("user_id")
60
+ .notNull()
61
+ .references(() => user.id, { onDelete: "cascade" }),
62
+ accessToken: text("access_token"),
63
+ refreshToken: text("refresh_token"),
64
+ idToken: text("id_token"),
65
+ accessTokenExpiresAt: integer("access_token_expires_at", {
66
+ mode: "timestamp_ms",
67
+ }),
68
+ refreshTokenExpiresAt: integer("refresh_token_expires_at", {
69
+ mode: "timestamp_ms",
70
+ }),
71
+ scope: text("scope"),
72
+ password: text("password"),
73
+ createdAt: integer("created_at", { mode: "timestamp_ms" })
74
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
75
+ .notNull(),
76
+ updatedAt: integer("updated_at", { mode: "timestamp_ms" })
77
+ .$onUpdate(() => /* @__PURE__ */ new Date())
78
+ .notNull(),
79
+ },
80
+ (table) => [index("account_userId_idx").on(table.userId)],
81
+ );
82
+
83
+ export const verification = sqliteTable(
84
+ "verification",
85
+ {
86
+ id: text("id").primaryKey(),
87
+ identifier: text("identifier").notNull(),
88
+ value: text("value").notNull(),
89
+ expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
90
+ createdAt: integer("created_at", { mode: "timestamp_ms" })
91
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
92
+ .notNull(),
93
+ updatedAt: integer("updated_at", { mode: "timestamp_ms" })
94
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
95
+ .$onUpdate(() => /* @__PURE__ */ new Date())
96
+ .notNull(),
97
+ },
98
+ (table) => [index("verification_identifier_idx").on(table.identifier)],
99
+ );
100
+
101
+ export const userRelations = relations(user, ({ many }) => ({
102
+ sessions: many(session),
103
+ accounts: many(account),
104
+ }));
105
+
106
+ export const sessionRelations = relations(session, ({ one }) => ({
107
+ user: one(user, {
108
+ fields: [session.userId],
109
+ references: [user.id],
110
+ }),
111
+ }));
112
+
113
+ export const accountRelations = relations(account, ({ one }) => ({
114
+ user: one(user, {
115
+ fields: [account.userId],
116
+ references: [user.id],
117
+ }),
118
+ }));
@@ -0,0 +1,15 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
3
+
4
+ /**
5
+ * Roles a user can hold (`user.role` from Better Auth's admin plugin).
6
+ * Register them with `flare role:add <name>`, assign with `flare user:role <email> <role>`,
7
+ * and grant access per resource with `flare gen policy <Resource> --roles ...`.
8
+ */
9
+ export const role = sqliteTable("role", {
10
+ name: text("name").primaryKey(),
11
+ label: text("label").notNull(),
12
+ createdAt: integer("created_at", { mode: "timestamp_ms" })
13
+ .notNull()
14
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`),
15
+ });
@@ -0,0 +1,10 @@
1
+ import { env } from "cloudflare:workers";
2
+ import { drizzle } from "drizzle-orm/d1";
3
+ import * as schema from "./schema";
4
+
5
+ /** Drizzle client bound to the `DB` D1 binding. Call inside a request (server component, route handler, action). */
6
+ export function getDb() {
7
+ return drizzle(env.DB, { schema });
8
+ }
9
+
10
+ export { schema };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Drizzle schema entry point. drizzle-kit reads this file to generate
3
+ * migrations, and `db/index.ts` passes it to the query builder.
4
+ *
5
+ * Add hand-written tables anywhere outside the generated block below.
6
+ */
7
+
8
+ export * from "./auth-schema";
9
+ export * from "./flare-schema";
10
+
11
+ // generated:start
12
+ // generated:end
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "drizzle-kit";
2
+
3
+ export default defineConfig({
4
+ dialect: "sqlite",
5
+ schema: "./db/schema.ts",
6
+ // Must match `migrations_dir` for the DB binding in wrangler.jsonc.
7
+ out: "./migrations",
8
+ });
@@ -0,0 +1,19 @@
1
+ import * as React from "react"
2
+
3
+ const MOBILE_BREAKPOINT = 768
4
+
5
+ export function useIsMobile() {
6
+ const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
7
+
8
+ React.useEffect(() => {
9
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10
+ const onChange = () => {
11
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12
+ }
13
+ mql.addEventListener("change", onChange)
14
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15
+ return () => mql.removeEventListener("change", onChange)
16
+ }, [])
17
+
18
+ return !!isMobile
19
+ }
@@ -0,0 +1,15 @@
1
+ // Extra admin sidebar links (maintained by flare generators such as `flare gen security`).
2
+ // Add your own links outside the generated block.
3
+ // generated:start
4
+ export interface AdminLink {
5
+ label: string;
6
+ href: string;
7
+ /** A resource icon name, e.g. "shield" (see components/admin/resource-icon.tsx). */
8
+ icon: string;
9
+ }
10
+
11
+ export const generatedAdminLinks: AdminLink[] = [];
12
+ // generated:end
13
+
14
+ /** Links shown under "Platform" in the admin sidebar. */
15
+ export const adminLinks: AdminLink[] = [...generatedAdminLinks];
@@ -0,0 +1,103 @@
1
+ import { headers } from "next/headers";
2
+ import { notFound, redirect } from "next/navigation";
3
+ import { allowedActions, can, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
4
+ import type { SQLiteTable } from "drizzle-orm/sqlite-core";
5
+ import { createResourceStore, type ResourceStore } from "@flaredev/core/server";
6
+ import { getDb } from "@/db";
7
+ import { policies } from "@/policies";
8
+ import { resourceTables } from "@/resources/server";
9
+ import { auth } from "./auth";
10
+ import { cached, resourceTag, revalidateResource, TTL } from "./cache";
11
+
12
+ /**
13
+ * Roles that always reach /admin, whatever the policies say. Other roles get in when a
14
+ * policy grants them read access to at least one resource.
15
+ * Register roles with `flare role:add`, assign with `flare user:role`.
16
+ */
17
+ export const ADMIN_ROLES = ["admin", "staff"];
18
+
19
+ export function policyFor(resourceName: string): Policy | undefined {
20
+ return (policies as Record<string, Policy | undefined>)[resourceName];
21
+ }
22
+
23
+ export async function adminSession() {
24
+ const session = await auth.api.getSession({ headers: await headers() });
25
+ if (!session) return { session: null, role: null, allowed: false } as const;
26
+ const role = (session.user as { role?: string | null }).role ?? null;
27
+ const readable = adminResources().some((resource) => can(policyFor(resource.name), role, "read"));
28
+ return { session, role, allowed: (role !== null && ADMIN_ROLES.includes(role)) || readable } as const;
29
+ }
30
+
31
+ /** For admin pages: redirects signed-out visitors to sign-in and users without access to the home page. */
32
+ export async function requireAdmin(returnTo = "/admin") {
33
+ const { session, role, allowed } = await adminSession();
34
+ if (!session) redirect(`/sign-in?next=${encodeURIComponent(returnTo)}`);
35
+ if (!allowed) redirect("/?error=forbidden");
36
+ return { session, role };
37
+ }
38
+
39
+ /**
40
+ * For resource pages: also requires the policy to allow `action` on this resource.
41
+ * Sends the user somewhere they can actually go — the list when they may read it,
42
+ * the dashboard when they may not.
43
+ */
44
+ export async function requireAccess(resource: Resource, action: PolicyAction) {
45
+ const { session, role } = await requireAdmin(adminPath(resource));
46
+ const policy = policyFor(resource.name);
47
+ if (!can(policy, role, action)) {
48
+ redirect(action !== "read" && can(policy, role, "read") ? adminPath(resource) : "/admin?error=forbidden");
49
+ }
50
+ return { session, role };
51
+ }
52
+
53
+ /** What the current user may do with a resource, for hiding actions they can't use. */
54
+ export async function adminPermissions(resourceName: string) {
55
+ const { role } = await adminSession();
56
+ return allowedActions(policyFor(resourceName), role);
57
+ }
58
+
59
+ const stores = new Map<string, ResourceStore>();
60
+
61
+ /** CRUD store for a resource by name (404 for unknown names). */
62
+ export function adminStore(name: string): ResourceStore {
63
+ const entry = (resourceTables as unknown as Record<string, { resource: Resource; table: SQLiteTable } | undefined>)[name];
64
+ if (!entry) notFound();
65
+ let store = stores.get(name);
66
+ if (!store) {
67
+ store = createResourceStore({ resource: entry.resource, table: entry.table, getDb, onChange: revalidateResource });
68
+ stores.set(name, store);
69
+ }
70
+ return store;
71
+ }
72
+
73
+ /**
74
+ * How many records a resource has. Cached in the data cache under the resource's tag,
75
+ * so the dashboard does not count every table on every visit, and a write through the
76
+ * admin or the API drops the count on its way out.
77
+ */
78
+ export function recordCount(name: string): Promise<number> {
79
+ const count = cached(
80
+ async (resourceName: string) => {
81
+ const result = await adminStore(resourceName).list(new URLSearchParams({ perPage: "1" }));
82
+ return result.ok ? result.data.meta.total : 0;
83
+ },
84
+ ["flare", "record-count"],
85
+ { tags: [resourceTag(name)], revalidate: TTL.short },
86
+ );
87
+ return count(name);
88
+ }
89
+
90
+ /** Every resource descriptor (unfiltered): navigation uses `visibleResources()` instead. */
91
+ export function adminResources(): Resource[] {
92
+ return Object.values(resourceTables as unknown as Record<string, { resource: Resource }>).map((entry) => entry.resource);
93
+ }
94
+
95
+ /** Resources the current user may read, for the sidebar and dashboard. */
96
+ export async function visibleResources(): Promise<Resource[]> {
97
+ const { role } = await adminSession();
98
+ return adminResources().filter((resource) => can(policyFor(resource.name), role, "read"));
99
+ }
100
+
101
+ export function adminPath(resource: Resource, ...parts: string[]) {
102
+ return ["/admin", resource.slug, ...parts].join("/");
103
+ }
@@ -0,0 +1,24 @@
1
+ import { can, type Policy } from "@flaredev/core";
2
+ import type { Authorize } from "@flaredev/core/server";
3
+ import { policies } from "@/policies";
4
+ import { auth } from "./auth";
5
+
6
+ /**
7
+ * Authorization for every generated resource API (`app/api/<resource>/...`).
8
+ * Return a Response to deny the request; return nothing to allow it.
9
+ *
10
+ * A signed-in user is required. Beyond that, a resource's policy decides
11
+ * (`flare gen policy <Resource> --roles admin,staff`); resources without a policy
12
+ * are open to any signed-in user. The admin UI reads the same policies, so the two
13
+ * never drift apart.
14
+ */
15
+ export const authorize: Authorize = async ({ request, resource, action }) => {
16
+ const session = await auth.api.getSession({ headers: request.headers });
17
+ if (!session) return Response.json({ error: "Sign in required." }, { status: 401 });
18
+
19
+ const role = (session.user as { role?: string | null }).role ?? null;
20
+ const policy = (policies as Record<string, Policy | undefined>)[resource.name];
21
+ if (!can(policy, role, action)) {
22
+ return Response.json({ error: `Your role can't ${action} ${resource.pluralLabel.toLowerCase()}.` }, { status: 403 });
23
+ }
24
+ };
@@ -0,0 +1,4 @@
1
+ import { createAuthClient } from "better-auth/react";
2
+
3
+ /** Browser-side auth client. Same origin as the app, so no baseURL is needed. */
4
+ export const authClient = createAuthClient();