@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.
- package/bin/flare.js +9 -1
- package/dist/index.js +985 -337
- package/package.json +3 -2
- package/templates/app/app/admin/page.tsx +9 -61
- package/templates/app/app/api/openapi.json/route.ts +23 -0
- package/templates/app/app/api/reference/route.ts +36 -0
- package/templates/app/app/dashboard/account/page.tsx +40 -0
- package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
- package/templates/app/app/dashboard/import-actions.ts +91 -0
- package/templates/app/app/dashboard/layout.tsx +42 -0
- package/templates/app/app/dashboard/observability/page.tsx +174 -0
- package/templates/app/app/dashboard/page.tsx +123 -9
- package/templates/app/app/forgot-password/page.tsx +22 -0
- package/templates/app/app/globals.css +109 -4
- package/templates/app/app/layout.tsx +28 -26
- package/templates/app/app/page.tsx +126 -13
- package/templates/app/app/reset-password/page.tsx +31 -0
- package/templates/app/app/sign-in/page.tsx +49 -33
- package/templates/app/app/sign-up/page.tsx +42 -23
- package/templates/app/app/two-factor/page.tsx +25 -0
- package/templates/app/app/verify-email/page.tsx +33 -0
- package/templates/app/components/account/security-settings.tsx +522 -0
- package/templates/app/components/auth/auth-shell.tsx +284 -0
- package/templates/app/components/auth/otp-input.tsx +95 -0
- package/templates/app/components/auth/password-field.tsx +116 -0
- package/templates/app/components/auth/password-forms.tsx +76 -0
- package/templates/app/components/auth/provider-icons.tsx +39 -0
- package/templates/app/components/auth/sign-in-flow.tsx +306 -0
- package/templates/app/components/auth/sign-up-form.tsx +93 -0
- package/templates/app/components/auth/social-buttons.tsx +56 -0
- package/templates/app/components/auth/two-factor-form.tsx +139 -0
- package/templates/app/components/auth/ui.tsx +96 -0
- package/templates/app/components/auth/use-resend.ts +44 -0
- package/templates/app/components/auth/verify-email-form.tsx +135 -0
- package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
- package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +66 -9
- package/templates/app/components/dashboard/export-button.tsx +41 -0
- package/templates/app/components/dashboard/fields/country-field.tsx +152 -0
- package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +186 -128
- package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
- package/templates/app/components/dashboard/fields/multi-select-field.tsx +46 -0
- package/templates/app/components/dashboard/fields/phone-field.tsx +86 -0
- package/templates/app/components/dashboard/import-dialog.tsx +487 -0
- package/templates/app/components/dashboard/page-header.tsx +57 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
- package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
- package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
- package/templates/app/components/dashboard/resource-stats.tsx +53 -0
- package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -251
- package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
- package/templates/app/components/dashboard/stat-card.tsx +69 -0
- package/templates/app/components/dashboard/table-selection.tsx +185 -0
- package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
- package/templates/app/components/marketing/product-preview.tsx +70 -0
- package/templates/app/components/marketing/site-header.tsx +39 -0
- package/templates/app/components/sign-out-button.tsx +1 -1
- package/templates/app/components/ui/checkbox.tsx +25 -0
- package/templates/app/components/ui/radio-group.tsx +29 -0
- package/templates/app/db/auth-schema.ts +167 -118
- package/templates/app/db/flare-schema.ts +33 -1
- package/templates/app/lib/analytics.ts +121 -0
- package/templates/app/lib/api-docs.ts +16 -0
- package/templates/app/lib/audit.ts +80 -0
- package/templates/app/lib/auth-client.ts +16 -1
- package/templates/app/lib/auth-config.ts +38 -0
- package/templates/app/lib/auth-emails.ts +94 -0
- package/templates/app/lib/auth.ts +115 -6
- package/templates/app/lib/cache.ts +83 -83
- package/templates/app/lib/csv.ts +24 -0
- package/templates/app/lib/dashboard-nav.ts +15 -0
- package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
- package/templates/app/lib/password-rules.ts +159 -0
- package/templates/app/lib/site.ts +56 -0
- package/templates/app/lib/theme.ts +79 -0
- package/templates/app/migrations/0000_init.sql +47 -4
- package/templates/app/migrations/meta/0000_snapshot.json +311 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/vite.config.ts +5 -3
- package/templates/app/worker-configuration.d.ts +15764 -0
- package/templates/locks/package-lock.json +9189 -0
- package/templates/locks/pnpm-lock.yaml +6347 -0
- package/templates/app/app/admin/layout.tsx +0 -32
- package/templates/app/components/auth-form.tsx +0 -120
- package/templates/app/lib/admin-nav.ts +0 -15
- /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
- /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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
.
|
|
23
|
-
|
|
24
|
-
.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
.notNull(),
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
mode: "timestamp_ms",
|
|
70
|
-
}),
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
.notNull(),
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
+
});
|