@flaredev/cli 0.1.2 → 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 (89) hide show
  1. package/bin/flare.js +9 -1
  2. package/dist/index.js +985 -337
  3. package/package.json +3 -2
  4. package/templates/app/app/admin/page.tsx +9 -61
  5. package/templates/app/app/api/openapi.json/route.ts +23 -0
  6. package/templates/app/app/api/reference/route.ts +36 -0
  7. package/templates/app/app/dashboard/account/page.tsx +40 -0
  8. package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
  9. package/templates/app/app/dashboard/import-actions.ts +91 -0
  10. package/templates/app/app/dashboard/layout.tsx +42 -0
  11. package/templates/app/app/dashboard/observability/page.tsx +174 -0
  12. package/templates/app/app/dashboard/page.tsx +123 -9
  13. package/templates/app/app/forgot-password/page.tsx +22 -0
  14. package/templates/app/app/globals.css +109 -4
  15. package/templates/app/app/layout.tsx +28 -26
  16. package/templates/app/app/page.tsx +126 -13
  17. package/templates/app/app/reset-password/page.tsx +31 -0
  18. package/templates/app/app/sign-in/page.tsx +49 -33
  19. package/templates/app/app/sign-up/page.tsx +42 -23
  20. package/templates/app/app/two-factor/page.tsx +25 -0
  21. package/templates/app/app/verify-email/page.tsx +33 -0
  22. package/templates/app/components/account/security-settings.tsx +522 -0
  23. package/templates/app/components/auth/auth-shell.tsx +284 -0
  24. package/templates/app/components/auth/otp-input.tsx +95 -0
  25. package/templates/app/components/auth/password-field.tsx +116 -0
  26. package/templates/app/components/auth/password-forms.tsx +76 -0
  27. package/templates/app/components/auth/provider-icons.tsx +39 -0
  28. package/templates/app/components/auth/sign-in-flow.tsx +306 -0
  29. package/templates/app/components/auth/sign-up-form.tsx +93 -0
  30. package/templates/app/components/auth/social-buttons.tsx +56 -0
  31. package/templates/app/components/auth/two-factor-form.tsx +139 -0
  32. package/templates/app/components/auth/ui.tsx +96 -0
  33. package/templates/app/components/auth/use-resend.ts +44 -0
  34. package/templates/app/components/auth/verify-email-form.tsx +135 -0
  35. package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
  36. package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +66 -9
  37. package/templates/app/components/dashboard/export-button.tsx +41 -0
  38. package/templates/app/components/dashboard/fields/country-field.tsx +152 -0
  39. package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +186 -128
  40. package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
  41. package/templates/app/components/dashboard/fields/multi-select-field.tsx +46 -0
  42. package/templates/app/components/dashboard/fields/phone-field.tsx +86 -0
  43. package/templates/app/components/dashboard/import-dialog.tsx +487 -0
  44. package/templates/app/components/dashboard/page-header.tsx +57 -0
  45. package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
  46. package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
  47. package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
  48. package/templates/app/components/dashboard/resource-stats.tsx +53 -0
  49. package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -251
  50. package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
  51. package/templates/app/components/dashboard/stat-card.tsx +69 -0
  52. package/templates/app/components/dashboard/table-selection.tsx +185 -0
  53. package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
  54. package/templates/app/components/marketing/product-preview.tsx +70 -0
  55. package/templates/app/components/marketing/site-header.tsx +39 -0
  56. package/templates/app/components/sign-out-button.tsx +1 -1
  57. package/templates/app/components/ui/checkbox.tsx +25 -0
  58. package/templates/app/components/ui/radio-group.tsx +29 -0
  59. package/templates/app/db/auth-schema.ts +167 -118
  60. package/templates/app/db/flare-schema.ts +33 -1
  61. package/templates/app/lib/analytics.ts +121 -0
  62. package/templates/app/lib/api-docs.ts +16 -0
  63. package/templates/app/lib/audit.ts +80 -0
  64. package/templates/app/lib/auth-client.ts +16 -1
  65. package/templates/app/lib/auth-config.ts +38 -0
  66. package/templates/app/lib/auth-emails.ts +94 -0
  67. package/templates/app/lib/auth.ts +115 -6
  68. package/templates/app/lib/cache.ts +83 -83
  69. package/templates/app/lib/csv.ts +24 -0
  70. package/templates/app/lib/dashboard-nav.ts +15 -0
  71. package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
  72. package/templates/app/lib/password-rules.ts +159 -0
  73. package/templates/app/lib/site.ts +56 -0
  74. package/templates/app/lib/theme.ts +79 -0
  75. package/templates/app/migrations/0000_init.sql +47 -4
  76. package/templates/app/migrations/meta/0000_snapshot.json +311 -1
  77. package/templates/app/migrations/meta/_journal.json +1 -1
  78. package/templates/app/vite.config.ts +5 -3
  79. package/templates/app/worker-configuration.d.ts +15764 -0
  80. package/templates/locks/package-lock.json +9189 -0
  81. package/templates/locks/pnpm-lock.yaml +6347 -0
  82. package/templates/app/app/admin/layout.tsx +0 -32
  83. package/templates/app/components/auth-form.tsx +0 -120
  84. package/templates/app/lib/admin-nav.ts +0 -15
  85. /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
  86. /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
  87. /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
  88. /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
  89. /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
@@ -0,0 +1,25 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { CheckIcon } from "lucide-react"
5
+ import { Checkbox as CheckboxPrimitive } from "radix-ui"
6
+ import { cn } from "@/lib/utils"
7
+
8
+ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
9
+ return (
10
+ <CheckboxPrimitive.Root
11
+ data-slot="checkbox"
12
+ className={cn(
13
+ "peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none 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 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
14
+ className
15
+ )}
16
+ {...props}
17
+ >
18
+ <CheckboxPrimitive.Indicator data-slot="checkbox-indicator" className="grid place-content-center text-current transition-none">
19
+ <CheckIcon className="size-3.5" />
20
+ </CheckboxPrimitive.Indicator>
21
+ </CheckboxPrimitive.Root>
22
+ )
23
+ }
24
+
25
+ export { Checkbox }
@@ -0,0 +1,29 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { CircleIcon } from "lucide-react"
5
+ import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
6
+ import { cn } from "@/lib/utils"
7
+
8
+ function RadioGroup({ className, ...props }: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
9
+ return <RadioGroupPrimitive.Root data-slot="radio-group" className={cn("grid gap-3", className)} {...props} />
10
+ }
11
+
12
+ function RadioGroupItem({ className, ...props }: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
13
+ return (
14
+ <RadioGroupPrimitive.Item
15
+ data-slot="radio-group-item"
16
+ className={cn(
17
+ "aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs transition-[color,box-shadow] outline-none 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 dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
18
+ className
19
+ )}
20
+ {...props}
21
+ >
22
+ <RadioGroupPrimitive.Indicator data-slot="radio-group-indicator" className="relative flex items-center justify-center">
23
+ <CircleIcon className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 fill-primary" />
24
+ </RadioGroupPrimitive.Indicator>
25
+ </RadioGroupPrimitive.Item>
26
+ )
27
+ }
28
+
29
+ export { RadioGroup, RadioGroupItem }
@@ -1,118 +1,167 @@
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
- }));
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
+ * the two-factor plugin (user.two_factor_enabled, two_factor) and passkeys (passkey).
5
+ * Every sign-in method's tables exist whether or not lib/auth-config.ts switches it on.
6
+ * Generated with `auth generate --adapter drizzle --dialect sqlite` (better-auth 1.7.5).
7
+ * Re-generate if you add Better Auth plugins that extend the schema.
8
+ */
9
+ import { relations, sql } from "drizzle-orm";
10
+ import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
11
+
12
+ export const user = sqliteTable("user", {
13
+ id: text("id").primaryKey(),
14
+ name: text("name").notNull(),
15
+ email: text("email").notNull().unique(),
16
+ emailVerified: integer("email_verified", { mode: "boolean" })
17
+ .default(false)
18
+ .notNull(),
19
+ image: text("image"),
20
+ createdAt: integer("created_at", { mode: "timestamp_ms" })
21
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
22
+ .notNull(),
23
+ updatedAt: integer("updated_at", { mode: "timestamp_ms" })
24
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
25
+ .$onUpdate(() => /* @__PURE__ */ new Date())
26
+ .notNull(),
27
+ role: text("role"),
28
+ banned: integer("banned", { mode: "boolean" }).default(false),
29
+ banReason: text("ban_reason"),
30
+ banExpires: integer("ban_expires", { mode: "timestamp_ms" }),
31
+ twoFactorEnabled: integer("two_factor_enabled", { mode: "boolean" }).default(false),
32
+ });
33
+
34
+ export const session = sqliteTable(
35
+ "session",
36
+ {
37
+ id: text("id").primaryKey(),
38
+ expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
39
+ token: text("token").notNull().unique(),
40
+ createdAt: integer("created_at", { mode: "timestamp_ms" })
41
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
42
+ .notNull(),
43
+ updatedAt: integer("updated_at", { mode: "timestamp_ms" })
44
+ .$onUpdate(() => /* @__PURE__ */ new Date())
45
+ .notNull(),
46
+ ipAddress: text("ip_address"),
47
+ userAgent: text("user_agent"),
48
+ userId: text("user_id")
49
+ .notNull()
50
+ .references(() => user.id, { onDelete: "cascade" }),
51
+ impersonatedBy: text("impersonated_by"),
52
+ },
53
+ (table) => [index("session_userId_idx").on(table.userId)],
54
+ );
55
+
56
+ export const account = sqliteTable(
57
+ "account",
58
+ {
59
+ id: text("id").primaryKey(),
60
+ accountId: text("account_id").notNull(),
61
+ providerId: text("provider_id").notNull(),
62
+ userId: text("user_id")
63
+ .notNull()
64
+ .references(() => user.id, { onDelete: "cascade" }),
65
+ accessToken: text("access_token"),
66
+ refreshToken: text("refresh_token"),
67
+ idToken: text("id_token"),
68
+ accessTokenExpiresAt: integer("access_token_expires_at", {
69
+ mode: "timestamp_ms",
70
+ }),
71
+ refreshTokenExpiresAt: integer("refresh_token_expires_at", {
72
+ mode: "timestamp_ms",
73
+ }),
74
+ scope: text("scope"),
75
+ password: text("password"),
76
+ createdAt: integer("created_at", { mode: "timestamp_ms" })
77
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
78
+ .notNull(),
79
+ updatedAt: integer("updated_at", { mode: "timestamp_ms" })
80
+ .$onUpdate(() => /* @__PURE__ */ new Date())
81
+ .notNull(),
82
+ },
83
+ (table) => [index("account_userId_idx").on(table.userId)],
84
+ );
85
+
86
+ export const verification = sqliteTable(
87
+ "verification",
88
+ {
89
+ id: text("id").primaryKey(),
90
+ identifier: text("identifier").notNull(),
91
+ value: text("value").notNull(),
92
+ expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
93
+ createdAt: integer("created_at", { mode: "timestamp_ms" })
94
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
95
+ .notNull(),
96
+ updatedAt: integer("updated_at", { mode: "timestamp_ms" })
97
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
98
+ .$onUpdate(() => /* @__PURE__ */ new Date())
99
+ .notNull(),
100
+ },
101
+ (table) => [index("verification_identifier_idx").on(table.identifier)],
102
+ );
103
+
104
+ export const twoFactor = sqliteTable(
105
+ "two_factor",
106
+ {
107
+ id: text("id").primaryKey(),
108
+ secret: text("secret").notNull(),
109
+ backupCodes: text("backup_codes").notNull(),
110
+ userId: text("user_id")
111
+ .notNull()
112
+ .references(() => user.id, { onDelete: "cascade" }),
113
+ verified: integer("verified", { mode: "boolean" }).default(true),
114
+ failedVerificationCount: integer("failed_verification_count").default(0),
115
+ lockedUntil: integer("locked_until", { mode: "timestamp_ms" }),
116
+ },
117
+ (table) => [index("twoFactor_secret_idx").on(table.secret), index("twoFactor_userId_idx").on(table.userId)],
118
+ );
119
+
120
+ export const passkey = sqliteTable(
121
+ "passkey",
122
+ {
123
+ id: text("id").primaryKey(),
124
+ name: text("name"),
125
+ publicKey: text("public_key").notNull(),
126
+ userId: text("user_id")
127
+ .notNull()
128
+ .references(() => user.id, { onDelete: "cascade" }),
129
+ credentialID: text("credential_id").notNull(),
130
+ counter: integer("counter").notNull(),
131
+ deviceType: text("device_type").notNull(),
132
+ backedUp: integer("backed_up", { mode: "boolean" }).notNull(),
133
+ transports: text("transports"),
134
+ createdAt: integer("created_at", { mode: "timestamp_ms" }),
135
+ aaguid: text("aaguid"),
136
+ },
137
+ (table) => [index("passkey_userId_idx").on(table.userId), index("passkey_credentialID_idx").on(table.credentialID)],
138
+ );
139
+
140
+ export const userRelations = relations(user, ({ many }) => ({
141
+ sessions: many(session),
142
+ accounts: many(account),
143
+ twoFactors: many(twoFactor),
144
+ passkeys: many(passkey),
145
+ }));
146
+
147
+ export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
148
+ user: one(user, { fields: [twoFactor.userId], references: [user.id] }),
149
+ }));
150
+
151
+ export const passkeyRelations = relations(passkey, ({ one }) => ({
152
+ user: one(user, { fields: [passkey.userId], references: [user.id] }),
153
+ }));
154
+
155
+ export const sessionRelations = relations(session, ({ one }) => ({
156
+ user: one(user, {
157
+ fields: [session.userId],
158
+ references: [user.id],
159
+ }),
160
+ }));
161
+
162
+ export const accountRelations = relations(account, ({ one }) => ({
163
+ user: one(user, {
164
+ fields: [account.userId],
165
+ references: [user.id],
166
+ }),
167
+ }));
@@ -1,5 +1,5 @@
1
1
  import { sql } from "drizzle-orm";
2
- import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2
+ import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
3
3
 
4
4
  /**
5
5
  * Roles a user can hold (`user.role` from Better Auth's admin plugin).
@@ -13,3 +13,35 @@ export const role = sqliteTable("role", {
13
13
  .notNull()
14
14
  .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`),
15
15
  });
16
+
17
+ /**
18
+ * Who changed what, and when. Written by the dashboard's actions (lib/audit.ts) for
19
+ * every create, update, delete and import, and read by /dashboard/observability.
20
+ *
21
+ * `changes` holds only the fields that moved, as JSON, never the whole record: an audit
22
+ * trail that copies every row is a second database of the same data, and a place for
23
+ * secrets to leak into.
24
+ */
25
+ export const auditLog = sqliteTable(
26
+ "audit_log",
27
+ {
28
+ id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
29
+ /** "create", "update", "delete", "import" or "bulk-delete". */
30
+ action: text("action").notNull(),
31
+ /** The resource's descriptor name, e.g. "Contact". */
32
+ resource: text("resource").notNull(),
33
+ /** The record, when the action was about one. */
34
+ recordId: text("record_id"),
35
+ /** How the record reads afterwards, for the log's own listing. */
36
+ recordLabel: text("record_label"),
37
+ userId: text("user_id"),
38
+ userEmail: text("user_email"),
39
+ /** Fields that changed: { field: { from, to } }, or a count for bulk actions. */
40
+ changes: text("changes", { mode: "json" }).$type<Record<string, unknown> | null>(),
41
+ ip: text("ip"),
42
+ createdAt: integer("created_at", { mode: "timestamp_ms" })
43
+ .notNull()
44
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`),
45
+ },
46
+ (table) => [index("audit_log_created_at_idx").on(table.createdAt), index("audit_log_resource_idx").on(table.resource)],
47
+ );
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Traffic, errors and latency for this Worker, from Cloudflare's GraphQL Analytics API.
3
+ *
4
+ * Needs two secrets, and works without them: `CLOUDFLARE_ACCOUNT_ID` and
5
+ * `CLOUDFLARE_API_TOKEN` (a token with Account Analytics: Read). Without them the
6
+ * dashboard says so and shows the rest of the page, because analytics is the one part
7
+ * of an app that can't be read from its own database.
8
+ *
9
+ * wrangler secret put CLOUDFLARE_API_TOKEN
10
+ */
11
+ import { env } from "cloudflare:workers";
12
+
13
+ const ENDPOINT = "https://api.cloudflare.com/client/v4/graphql";
14
+
15
+ export interface RequestPoint {
16
+ /** "2026-09-22" */
17
+ date: string;
18
+ requests: number;
19
+ errors: number;
20
+ /** Median CPU time in milliseconds. */
21
+ medianCpuMs: number;
22
+ }
23
+
24
+ export interface WorkerAnalytics {
25
+ points: RequestPoint[];
26
+ requests: number;
27
+ errors: number;
28
+ /** Errors as a percentage of requests. */
29
+ errorRate: number;
30
+ medianCpuMs: number;
31
+ }
32
+
33
+ /** Why there's nothing to show, when there's nothing to show. */
34
+ export type AnalyticsResult = { ok: true; data: WorkerAnalytics } | { ok: false; reason: "unconfigured" | "failed"; message: string };
35
+
36
+ interface GraphQlResponse {
37
+ data?: {
38
+ viewer?: {
39
+ accounts?: {
40
+ workersInvocationsAdaptive?: {
41
+ dimensions: { date: string };
42
+ sum: { requests: number; errors: number };
43
+ quantiles: { cpuTimeP50: number };
44
+ }[];
45
+ }[];
46
+ };
47
+ };
48
+ errors?: { message: string }[];
49
+ }
50
+
51
+ const QUERY = `query WorkerTraffic($accountTag: String!, $scriptName: String!, $since: Date!, $until: Date!) {
52
+ viewer {
53
+ accounts(filter: { accountTag: $accountTag }) {
54
+ workersInvocationsAdaptive(
55
+ limit: 100
56
+ filter: { scriptName: $scriptName, date_geq: $since, date_leq: $until }
57
+ orderBy: [date_ASC]
58
+ ) {
59
+ dimensions { date }
60
+ sum { requests errors }
61
+ quantiles { cpuTimeP50 }
62
+ }
63
+ }
64
+ }
65
+ }`;
66
+
67
+ /** Traffic for the last `days` days, a point per day. */
68
+ export async function workerAnalytics(scriptName: string, days = 7): Promise<AnalyticsResult> {
69
+ const vars = env as unknown as Record<string, string | undefined>;
70
+ const accountTag = vars.CLOUDFLARE_ACCOUNT_ID;
71
+ const token = vars.CLOUDFLARE_API_TOKEN;
72
+ if (!accountTag || !token) {
73
+ return {
74
+ ok: false,
75
+ reason: "unconfigured",
76
+ message: "Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN to read traffic, errors and CPU time from Cloudflare.",
77
+ };
78
+ }
79
+
80
+ const day = (offset: number) => new Date(Date.now() - offset * 86_400_000).toISOString().slice(0, 10);
81
+ try {
82
+ const response = await fetch(ENDPOINT, {
83
+ method: "POST",
84
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
85
+ body: JSON.stringify({
86
+ query: QUERY,
87
+ variables: { accountTag, scriptName, since: day(days - 1), until: day(0) },
88
+ }),
89
+ signal: AbortSignal.timeout(8000),
90
+ });
91
+ if (!response.ok) {
92
+ return { ok: false, reason: "failed", message: `Cloudflare answered ${response.status}. Check the token's Account Analytics: Read permission.` };
93
+ }
94
+ const body = (await response.json()) as GraphQlResponse;
95
+ if (body.errors?.length) return { ok: false, reason: "failed", message: body.errors[0]!.message };
96
+
97
+ const series = body.data?.viewer?.accounts?.[0]?.workersInvocationsAdaptive ?? [];
98
+ const points: RequestPoint[] = series.map((entry) => ({
99
+ date: entry.dimensions.date,
100
+ requests: entry.sum.requests,
101
+ errors: entry.sum.errors,
102
+ // Cloudflare reports CPU time in microseconds.
103
+ medianCpuMs: Math.round((entry.quantiles.cpuTimeP50 / 1000) * 100) / 100,
104
+ }));
105
+ const requests = points.reduce((total, point) => total + point.requests, 0);
106
+ const errors = points.reduce((total, point) => total + point.errors, 0);
107
+ const medians = points.map((point) => point.medianCpuMs).sort((a, b) => a - b);
108
+ return {
109
+ ok: true,
110
+ data: {
111
+ points,
112
+ requests,
113
+ errors,
114
+ errorRate: requests > 0 ? (errors / requests) * 100 : 0,
115
+ medianCpuMs: medians.length ? medians[Math.floor(medians.length / 2)]! : 0,
116
+ },
117
+ };
118
+ } catch (error) {
119
+ return { ok: false, reason: "failed", message: error instanceof Error ? error.message : "Couldn't reach Cloudflare." };
120
+ }
121
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The API reference at /api/reference (Scalar), built from the OpenAPI document at
3
+ * /api/openapi.json. Both are generated from resources/ and policies/ on every
4
+ * request, so they always match the API.
5
+ */
6
+ export const apiDocs = {
7
+ title: "__APP_NAME__ API",
8
+ description: "",
9
+ version: "1.0.0",
10
+ /**
11
+ * "role": a signed-in user sees the endpoints their role may call (admins see
12
+ * everything), a visitor only the sign-in endpoints.
13
+ * "public": everyone sees every endpoint (requests still need the right role).
14
+ */
15
+ visibility: "role" as "role" | "public",
16
+ };
@@ -0,0 +1,80 @@
1
+ import { headers } from "next/headers";
2
+ import { desc, eq, gte, sql } from "drizzle-orm";
3
+ import { getDb } from "@/db";
4
+ import { auditLog } from "@/db/flare-schema";
5
+ import { dashboardSession } from "./dashboard";
6
+
7
+ export type AuditAction = "create" | "update" | "delete" | "import" | "bulk-delete";
8
+
9
+ export interface AuditEntry {
10
+ action: AuditAction;
11
+ resource: string;
12
+ recordId?: string | null;
13
+ recordLabel?: string | null;
14
+ changes?: Record<string, unknown> | null;
15
+ }
16
+
17
+ /**
18
+ * Record a change in the audit log.
19
+ *
20
+ * Never throws: an audit trail that can take the write down with it is worse than one
21
+ * with a gap, and the caller has already changed the database by the time we're here.
22
+ */
23
+ export async function recordAudit(entry: AuditEntry): Promise<void> {
24
+ try {
25
+ const { session } = await dashboardSession();
26
+ const headerList = await headers();
27
+ await getDb()
28
+ .insert(auditLog)
29
+ .values({
30
+ action: entry.action,
31
+ resource: entry.resource,
32
+ recordId: entry.recordId ?? null,
33
+ recordLabel: entry.recordLabel ?? null,
34
+ userId: session?.user.id ?? null,
35
+ userEmail: session?.user.email ?? null,
36
+ changes: entry.changes ?? null,
37
+ ip: headerList.get("cf-connecting-ip") ?? null,
38
+ });
39
+ } catch {
40
+ // Deliberately quiet.
41
+ }
42
+ }
43
+
44
+ /** Which fields moved, and from what to what. Only the differences, never the whole record. */
45
+ export function diffFields(before: Record<string, unknown> | null | undefined, after: Record<string, unknown>): Record<string, { from: unknown; to: unknown }> {
46
+ const changes: Record<string, { from: unknown; to: unknown }> = {};
47
+ for (const [key, value] of Object.entries(after)) {
48
+ if (key === "updatedAt" || key === "createdAt") continue;
49
+ const previous = before?.[key];
50
+ const same = JSON.stringify(previous ?? null) === JSON.stringify(value ?? null);
51
+ if (!same) changes[key] = { from: previous ?? null, to: value ?? null };
52
+ }
53
+ return changes;
54
+ }
55
+
56
+ export interface AuditRow {
57
+ id: string;
58
+ action: string;
59
+ resource: string;
60
+ recordId: string | null;
61
+ recordLabel: string | null;
62
+ userEmail: string | null;
63
+ changes: Record<string, unknown> | null;
64
+ createdAt: Date;
65
+ }
66
+
67
+ /** The most recent entries, newest first. */
68
+ export async function recentAudit(limit = 25, resource?: string): Promise<AuditRow[]> {
69
+ const db = getDb();
70
+ const query = db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).limit(limit);
71
+ const rows = resource ? await query.where(eq(auditLog.resource, resource)) : await query;
72
+ return rows as AuditRow[];
73
+ }
74
+
75
+ /** How many changes were recorded in the last `days` days, for the observability summary. */
76
+ export async function auditCount(days = 7): Promise<number> {
77
+ const since = new Date(Date.now() - days * 86_400_000);
78
+ const [row] = await getDb().select({ total: sql<number>`count(*)` }).from(auditLog).where(gte(auditLog.createdAt, since));
79
+ return row?.total ?? 0;
80
+ }
@@ -1,4 +1,19 @@
1
+ import { passkeyClient } from "@better-auth/passkey/client";
2
+ import { emailOTPClient, magicLinkClient, twoFactorClient } from "better-auth/client/plugins";
1
3
  import { createAuthClient } from "better-auth/react";
2
4
 
3
5
  /** Browser-side auth client. Same origin as the app, so no baseURL is needed. */
4
- export const authClient = createAuthClient();
6
+ export const authClient = createAuthClient({
7
+ plugins: [
8
+ magicLinkClient(),
9
+ emailOTPClient(),
10
+ passkeyClient(),
11
+ // A password sign-in that needs a second factor continues on /two-factor.
12
+ twoFactorClient({
13
+ onTwoFactorRedirect: () => {
14
+ const next = new URLSearchParams(window.location.search).get("next");
15
+ window.location.href = `/two-factor${next ? `?next=${encodeURIComponent(next)}` : ""}`;
16
+ },
17
+ }),
18
+ ],
19
+ });