@flaredev/cli 0.4.0 → 0.5.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 (59) hide show
  1. package/dist/index.js +851 -405
  2. package/package.json +2 -2
  3. package/templates/app/app/dashboard/actions.ts +38 -5
  4. package/templates/app/app/dashboard/costs/page.tsx +93 -0
  5. package/templates/app/app/dashboard/page.tsx +4 -7
  6. package/templates/app/app/globals.css +0 -6
  7. package/templates/app/app/layout.tsx +37 -28
  8. package/templates/app/app/page.tsx +10 -0
  9. package/templates/app/components/dashboard/cost-estimator.tsx +77 -0
  10. package/templates/app/components/dashboard/fields/field-widget.tsx +237 -186
  11. package/templates/app/components/dashboard/fields/file-field.tsx +5 -1
  12. package/templates/app/components/dashboard/fields/relation-field.tsx +25 -3
  13. package/templates/app/components/dashboard/import-dialog.tsx +2 -2
  14. package/templates/app/components/dashboard/record-actions.tsx +9 -9
  15. package/templates/app/components/dashboard/record-detail.tsx +6 -3
  16. package/templates/app/components/dashboard/resource-form-page.tsx +64 -61
  17. package/templates/app/components/dashboard/resource-form-sheet.tsx +86 -0
  18. package/templates/app/components/dashboard/resource-form.tsx +261 -165
  19. package/templates/app/components/dashboard/resource-icon.tsx +87 -85
  20. package/templates/app/components/dashboard/resource-stats.tsx +10 -9
  21. package/templates/app/components/dashboard/resource-table-toolbar.tsx +2 -2
  22. package/templates/app/components/dashboard/resource-table.tsx +12 -9
  23. package/templates/app/components/dashboard/row-actions.tsx +5 -5
  24. package/templates/app/lib/costs.ts +138 -0
  25. package/templates/app/lib/dashboard-nav.ts +5 -1
  26. package/templates/app/lib/dashboard.ts +66 -61
  27. package/templates/app/lib/number.ts +33 -0
  28. package/templates/app/lib/site.ts +56 -56
  29. package/templates/app/lib/suggest.ts +35 -0
  30. package/templates/app/lib/usage.ts +32 -0
  31. package/templates/locks/package-lock.json +9 -9
  32. package/templates/locks/pnpm-lock.yaml +12 -12
  33. package/templates/locks/yarn.lock +3 -3
  34. package/templates/next/.env.example +21 -0
  35. package/templates/next/README.md +16 -0
  36. package/templates/next/app/api/health/route.ts +7 -0
  37. package/templates/next/app/dashboard/account/password/page.tsx +19 -0
  38. package/templates/next/app/dashboard/account/security/page.tsx +31 -0
  39. package/templates/next/app/dashboard/actions.ts +277 -0
  40. package/templates/next/app/dashboard/layout.tsx +64 -0
  41. package/templates/next/app/dashboard/page.tsx +127 -0
  42. package/templates/next/components/dashboard/resource-chart.tsx +86 -0
  43. package/templates/next/lib/analytics.ts +71 -0
  44. package/templates/next/lib/audit.ts +80 -0
  45. package/templates/next/lib/auth-emails.ts +92 -0
  46. package/templates/next/lib/auth.ts +156 -0
  47. package/templates/next/lib/dashboard.ts +161 -0
  48. package/templates/next/lib/db.ts +24 -0
  49. package/templates/next/lib/mail.ts +35 -0
  50. package/templates/next/lib/realtime.ts +23 -0
  51. package/templates/next/lib/redis.ts +17 -0
  52. package/templates/next/lib/storage.ts +57 -0
  53. package/templates/next/lib/theme.ts +83 -0
  54. package/templates/next/lib/views.ts +23 -0
  55. package/templates/next/next.config.ts +13 -0
  56. package/templates/next/prisma/schema/base.prisma +162 -0
  57. package/templates/next/prisma.config.ts +15 -0
  58. package/templates/next/tsconfig.json +24 -0
  59. package/templates/app/components/dashboard/resource-form-dialog.tsx +0 -80
@@ -0,0 +1,161 @@
1
+ import { headers } from "next/headers";
2
+ import { notFound, redirect } from "next/navigation";
3
+ import { allowedActions, can, columnName, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
4
+ import { createResourceStore, prismaRows, type PrismaDelegate, type ResourceStore } from "@flaredev/core/server";
5
+ import { prisma } from "@/lib/db";
6
+ import { policies } from "@/policies";
7
+ import { resourceTables } from "@/resources/server";
8
+ import { currentUser } from "./api";
9
+ import { auth } from "./auth";
10
+ import { cached, resourceTag, revalidateResource, TTL } from "./cache";
11
+
12
+ /**
13
+ * The dashboard's view of a resource, on the Next.js stack.
14
+ *
15
+ * The same shape as the Cloudflare one — same exports, same behaviour — over Prisma and
16
+ * Postgres instead of Drizzle and D1. The store itself is shared: only where its rows
17
+ * come from differs.
18
+ */
19
+
20
+ export const ADMIN_ROLES = ["admin", "staff"];
21
+
22
+ export function policyFor(resourceName: string): Policy | undefined {
23
+ return (policies as Record<string, Policy | undefined>)[resourceName];
24
+ }
25
+
26
+ export async function dashboardSession() {
27
+ const session = await auth.api.getSession({ headers: await headers() });
28
+ if (!session) return { session: null, role: null, allowed: false } as const;
29
+ const role = (session.user as { role?: string | null }).role ?? null;
30
+ const readable = allResources().some((resource) => can(policyFor(resource.name), role, "read"));
31
+ return { session, role, allowed: (role !== null && ADMIN_ROLES.includes(role)) || readable } as const;
32
+ }
33
+
34
+ /** For admin pages: redirects signed-out visitors to sign-in and users without access to the home page. */
35
+ export async function requireDashboard(returnTo = "/dashboard") {
36
+ const { session, role, allowed } = await dashboardSession();
37
+ if (!session) redirect(`/sign-in?next=${encodeURIComponent(returnTo)}`);
38
+ if (!allowed) redirect("/?error=forbidden");
39
+ return { session, role };
40
+ }
41
+
42
+ /**
43
+ * For resource pages: also requires the policy to allow `action` on this resource.
44
+ * Sends the user somewhere they can actually go — the list when they may read it,
45
+ * the dashboard when they may not.
46
+ */
47
+ export async function requireAccess(resource: Resource, action: PolicyAction) {
48
+ const { session, role } = await requireDashboard(resourcePath(resource));
49
+ const policy = policyFor(resource.name);
50
+ if (!can(policy, role, action)) {
51
+ redirect(action !== "read" && can(policy, role, "read") ? resourcePath(resource) : "/dashboard?error=forbidden");
52
+ }
53
+ return { session, role };
54
+ }
55
+
56
+ /** What the current user may do with a resource, for hiding actions they can't use. */
57
+ export async function adminPermissions(resourceName: string) {
58
+ const { role } = await dashboardSession();
59
+ return allowedActions(policyFor(resourceName), role);
60
+ }
61
+
62
+ const stores = new Map<string, ResourceStore>();
63
+
64
+ /** CRUD store for a resource by name (404 for unknown names). */
65
+ export function dashboardStore(name: string): ResourceStore {
66
+ const entry = (resourceTables as unknown as Record<string, { resource: Resource; delegate: PrismaDelegate } | undefined>)[name];
67
+ if (!entry) notFound();
68
+ let store = stores.get(name);
69
+ if (!store) {
70
+ store = createResourceStore({
71
+ resource: entry.resource,
72
+ rows: prismaRows(entry.delegate, prisma),
73
+ onChange: revalidateResource,
74
+ currentUser,
75
+ });
76
+ stores.set(name, store);
77
+ }
78
+ return store;
79
+ }
80
+
81
+ /**
82
+ * Every number the stats above a resource's table need, in one query.
83
+ *
84
+ * The total, how many arrived in the last week and the week before, and how many hold
85
+ * each value of a status field — one grouped scan, summed up here. Written as SQL
86
+ * because Prisma's aggregation API can't express "count these rows, and also count the
87
+ * subset of them created since Tuesday" in a single round trip.
88
+ *
89
+ * The table and column names come from the descriptor, never from a request, and are
90
+ * quoted; nothing a visitor types reaches this.
91
+ */
92
+ export interface ResourceStats {
93
+ total: number;
94
+ /** Created in the last `days` days, and in the `days` before that. */
95
+ current: number;
96
+ previous: number;
97
+ /** Rows per value of the grouped field, when one was asked for. */
98
+ values: Record<string, number>;
99
+ }
100
+
101
+ export function resourceStats(name: string, field?: string, days = 7): Promise<ResourceStats> {
102
+ const read = cached(
103
+ async (resourceName: string, key: string | undefined, windowDays: number): Promise<ResourceStats> => {
104
+ const entry = (resourceTables as unknown as Record<string, { resource: Resource } | undefined>)[resourceName];
105
+ if (!entry) return { total: 0, current: 0, previous: 0, values: {} };
106
+
107
+ const table = `"${entry.resource.table}"`;
108
+ const grouped = key && entry.resource.fields[key] ? `"${columnName(key)}"` : undefined;
109
+ const window = windowDays * 86_400_000;
110
+ const since = new Date(Date.now() - window).toISOString();
111
+ const before = new Date(Date.now() - window * 2).toISOString();
112
+
113
+ const select = [
114
+ "count(*)::int as total",
115
+ `count(*) filter (where "created_at" >= '${since}')::int as current`,
116
+ `count(*) filter (where "created_at" >= '${before}' and "created_at" < '${since}')::int as previous`,
117
+ ...(grouped ? [`${grouped} as value`] : []),
118
+ ].join(", ");
119
+ const query = `select ${select} from ${table}${grouped ? ` group by ${grouped}` : ""}`;
120
+
121
+ const rows = (await prisma.$queryRawUnsafe(query)) as { total: number; current: number; previous: number; value?: unknown }[];
122
+ const stats: ResourceStats = { total: 0, current: 0, previous: 0, values: {} };
123
+ for (const row of rows) {
124
+ stats.total += Number(row.total ?? 0);
125
+ stats.current += Number(row.current ?? 0);
126
+ stats.previous += Number(row.previous ?? 0);
127
+ if (grouped) stats.values[String(row.value ?? "")] = Number(row.total ?? 0);
128
+ }
129
+ return stats;
130
+ },
131
+ ["flare", "resource-stats"],
132
+ { tags: [resourceTag(name)], revalidate: TTL.medium },
133
+ );
134
+ return read(name, field, days);
135
+ }
136
+
137
+ /** How many records a resource has. One field of {@link resourceStats}. */
138
+ export async function recordCount(name: string): Promise<number> {
139
+ return (await resourceStats(name)).total;
140
+ }
141
+
142
+ /** Percentage change between two periods, or undefined when there's nothing to compare. */
143
+ export function trend(current: number, previous: number): number | undefined {
144
+ if (previous === 0) return current === 0 ? undefined : 100;
145
+ return ((current - previous) / previous) * 100;
146
+ }
147
+
148
+ /** Every resource descriptor (unfiltered): navigation uses `visibleResources()` instead. */
149
+ export function allResources(): Resource[] {
150
+ return Object.values(resourceTables as unknown as Record<string, { resource: Resource }>).map((entry) => entry.resource);
151
+ }
152
+
153
+ /** Resources the current user may read, for the sidebar and dashboard. */
154
+ export async function visibleResources(): Promise<Resource[]> {
155
+ const { role } = await dashboardSession();
156
+ return allResources().filter((resource) => can(policyFor(resource.name), role, "read"));
157
+ }
158
+
159
+ export function resourcePath(resource: Resource, ...parts: string[]) {
160
+ return ["/dashboard", resource.slug, ...parts].join("/");
161
+ }
@@ -0,0 +1,24 @@
1
+ import { PrismaPg } from "@prisma/adapter-pg";
2
+ import { PrismaClient } from "@/lib/generated/prisma/client";
3
+
4
+ /**
5
+ * The Prisma client, one per process.
6
+ *
7
+ * Prisma 7 needs a driver adapter rather than a connection string in the schema. This
8
+ * one is the ordinary Postgres driver, which talks to anything that speaks Postgres:
9
+ * Neon's pooled endpoint, Supabase, RDS, or the container you run while developing.
10
+ * Next runs on Node, so there is nothing to gain from a serverless HTTP driver and a
11
+ * great deal to lose — `@prisma/adapter-neon` can't reach a database on localhost.
12
+ *
13
+ * Next reloads modules on every edit in development, and a fresh client each time would
14
+ * exhaust the connection limit within a few saves; hence the global.
15
+ */
16
+ const makeClient = () => new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) });
17
+
18
+ const globalForPrisma = globalThis as unknown as { prisma?: ReturnType<typeof makeClient> };
19
+
20
+ export const prisma = globalForPrisma.prisma ?? makeClient();
21
+ if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
22
+
23
+ /** Named to match the Cloudflare stack, so code that takes a database reads the same. */
24
+ export const getDb = () => prisma;
@@ -0,0 +1,35 @@
1
+ import { createMailer, renderTransactionalEmail, type TransactionalEmail } from "@flaredev/core";
2
+
3
+ /**
4
+ * Transactional email via Resend.
5
+ *
6
+ * Without RESEND_API_KEY (e.g. local dev) emails are printed to the console
7
+ * instead of sent. MAIL_FROM must use a domain verified in Resend. The fallback
8
+ * sandbox sender only delivers to your own Resend account address.
9
+ */
10
+ export const mailer = createMailer({
11
+ apiKey: process.env.RESEND_API_KEY,
12
+ from: process.env.MAIL_FROM || "__APP_NAME__ <onboarding@resend.dev>",
13
+ });
14
+
15
+ /**
16
+ * Render and send a transactional email. Resolves to `{ data, error }` and never throws for API errors.
17
+ *
18
+ * const { error } = await sendTransactionalEmail({
19
+ * to: user.email,
20
+ * subject: "Your order has shipped",
21
+ * heading: "On its way",
22
+ * paragraphs: [`Order #${order.id} left our warehouse today.`],
23
+ * action: { label: "Track order", url: `https://example.com/orders/${order.id}` },
24
+ * idempotencyKey: `order-shipped/${order.id}`,
25
+ * });
26
+ */
27
+ export async function sendTransactionalEmail({
28
+ to,
29
+ subject,
30
+ idempotencyKey,
31
+ ...content
32
+ }: Omit<TransactionalEmail, "appName"> & { to: string | string[]; subject: string; idempotencyKey?: string }) {
33
+ const { html, text } = renderTransactionalEmail({ appName: "__APP_NAME__", ...content });
34
+ return mailer.send({ to, subject, html, text, idempotencyKey });
35
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Realtime, on the Next.js stack.
3
+ *
4
+ * The Cloudflare stack gives every channel a Durable Object: one address, one thread,
5
+ * its own storage, and websockets that stay open. Vercel has no equivalent — functions
6
+ * don't hold connections — so rather than pretend, this says so, and publishing is a
7
+ * no-op that never breaks a write.
8
+ *
9
+ * If you need it here, the shape to reach for is a hosted pub/sub (Ably, Pusher,
10
+ * Upstash) behind these same two functions.
11
+ */
12
+ import type { RealtimeAuthorize } from "@flaredev/core/realtime/server";
13
+
14
+ export const authorizeRealtime: RealtimeAuthorize = () => false;
15
+
16
+ export interface RealtimeChannel {
17
+ publish(event: string, data: unknown): Promise<void>;
18
+ }
19
+
20
+ /** A channel that accepts publishes and drops them, so callers need no special case. */
21
+ export function realtimeChannel(_name: string): RealtimeChannel {
22
+ return { publish: async () => undefined };
23
+ }
@@ -0,0 +1,17 @@
1
+ import { Redis } from "@upstash/redis";
2
+
3
+ /**
4
+ * Upstash Redis, over HTTP.
5
+ *
6
+ * Next's own data cache (`cached()` in lib/cache.ts) handles query caching. This is for
7
+ * the things a cache can't do: rate limits, locks, anything that has to be true across
8
+ * every instance at once. It talks HTTP rather than a socket, so a serverless function
9
+ * needs no connection pool.
10
+ *
11
+ * Unset credentials give you `null` rather than a crash, so an app that doesn't use it
12
+ * doesn't have to configure it.
13
+ */
14
+ export const redis =
15
+ process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN
16
+ ? new Redis({ url: process.env.UPSTASH_REDIS_REST_URL, token: process.env.UPSTASH_REDIS_REST_TOKEN })
17
+ : null;
@@ -0,0 +1,57 @@
1
+ import { AwsClient } from "aws4fetch";
2
+ import { createObjectKey, createStorage } from "@flaredev/core";
3
+
4
+ /**
5
+ * File storage on R2, over its S3-compatible API.
6
+ *
7
+ * R2 rather than Vercel Blob for one reason: R2 charges nothing to serve what it
8
+ * stores. On a file-heavy app that is the difference between a small bill and a large
9
+ * one, and it costs nothing to use from outside Cloudflare — see the cost guide.
10
+ *
11
+ * `aws4fetch` signs the requests; it is a few kilobytes and works in any runtime, unlike
12
+ * the AWS SDK. Everything above this — the upload widget, the signed-URL helpers, the
13
+ * file field — is the same code the Cloudflare stack runs.
14
+ */
15
+ const client = new AwsClient({
16
+ accessKeyId: process.env.R2_ACCESS_KEY_ID ?? "",
17
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? "",
18
+ service: "s3",
19
+ region: "auto",
20
+ });
21
+
22
+ const endpoint = `${process.env.R2_ENDPOINT ?? ""}/${process.env.R2_BUCKET ?? ""}`;
23
+
24
+ /** The bucket, shaped the way @flaredev/core expects, over plain HTTP calls. */
25
+ const bucket = {
26
+ async get(key: string) {
27
+ const response = await client.fetch(`${endpoint}/${encodeURIComponent(key)}`);
28
+ if (!response.ok || !response.body) return null;
29
+ return {
30
+ body: response.body,
31
+ httpMetadata: { contentType: response.headers.get("content-type") ?? undefined },
32
+ size: Number(response.headers.get("content-length") ?? 0),
33
+ // R2 returns the object's ETag quoted, which is what the header wants back.
34
+ httpEtag: response.headers.get("etag") ?? "",
35
+ };
36
+ },
37
+ async put(key: string, value: ReadableStream | ArrayBuffer | string | null, options?: { httpMetadata?: { contentType?: string } }) {
38
+ await client.fetch(`${endpoint}/${encodeURIComponent(key)}`, {
39
+ method: "PUT",
40
+ body: value,
41
+ headers: options?.httpMetadata?.contentType ? { "content-type": options.httpMetadata.contentType } : undefined,
42
+ });
43
+ },
44
+ async delete(keys: string | string[]) {
45
+ for (const key of [keys].flat()) {
46
+ await client.fetch(`${endpoint}/${encodeURIComponent(key)}`, { method: "DELETE" });
47
+ }
48
+ },
49
+ };
50
+
51
+ export const storage = createStorage({
52
+ bucket,
53
+ // Signing keys are derived from the auth secret with a storage-specific label.
54
+ secret: process.env.BETTER_AUTH_SECRET ?? "",
55
+ });
56
+
57
+ export { createObjectKey };
@@ -0,0 +1,83 @@
1
+ import { site } from "./site";
2
+
3
+ /**
4
+ * The six looks every Flare app ships with. The active one sets `data-theme` on <html>;
5
+ * app/globals.css maps it to colours, radius and type, and the auth screens pick the
6
+ * layout below.
7
+ */
8
+ export const THEMES = {
9
+ default: {
10
+ label: "Default",
11
+ description: "Calm and centred, indigo accents.",
12
+ auth: "centered",
13
+ social: "after",
14
+ socialStyle: "full",
15
+ signIn: (name: string) => ({ title: `Sign in to ${name}` }),
16
+ signUp: (name: string) => ({ title: `Create your ${name} account` }),
17
+ },
18
+ coral: {
19
+ label: "Coral",
20
+ description: "Warm and rounded, a card floating over the page.",
21
+ auth: "modal",
22
+ social: "after",
23
+ socialStyle: "icon",
24
+ signIn: () => ({ title: "Log in or sign up" }),
25
+ signUp: () => ({ title: "Create your account" }),
26
+ },
27
+ amber: {
28
+ label: "Amber",
29
+ description: "Plain and direct, a boxed form with pill buttons.",
30
+ auth: "boxed",
31
+ social: "after",
32
+ socialStyle: "full",
33
+ signIn: () => ({ title: "Sign in" }),
34
+ signUp: () => ({ title: "Create account" }),
35
+ },
36
+ sky: {
37
+ label: "Sky",
38
+ description: "Crisp blue, a bold heading with social sign-in first.",
39
+ auth: "banner",
40
+ social: "before",
41
+ socialStyle: "full",
42
+ signIn: (name: string) => ({ title: `Log in to ${name}` }),
43
+ signUp: (name: string) => ({ title: `Get started with ${name}` }),
44
+ },
45
+ mono: {
46
+ label: "Mono",
47
+ description: "Black and white on a fine grid, a showcase beside the form.",
48
+ auth: "showcase",
49
+ social: "after",
50
+ socialStyle: "full",
51
+ signIn: (name: string) => ({ title: `Log in to your ${name} account` }),
52
+ signUp: (name: string) => ({ title: `Create your ${name} account` }),
53
+ },
54
+ emerald: {
55
+ label: "Emerald",
56
+ description: "Fresh green, the form beside a customer quote.",
57
+ auth: "quote",
58
+ social: "before",
59
+ socialStyle: "full",
60
+ signIn: () => ({ title: "Welcome back", subtitle: "Sign in to your account" }),
61
+ signUp: () => ({ title: "Get started", subtitle: "Create a new account" }),
62
+ },
63
+ } as const;
64
+
65
+ export type ThemeName = keyof typeof THEMES;
66
+ export type AuthLayout = (typeof THEMES)[ThemeName]["auth"];
67
+
68
+ /** Set from FLARE_THEME by vite.config.ts when the app is built or run. */
69
+ /**
70
+ * On Cloudflare this comes from a Vite define, so `FLARE_THEME=mono flare dev` can
71
+ * switch the look without editing code. Next has no such constant, so the theme is
72
+ * whatever lib/site.ts says — change it there, or with `flare theme <name>`.
73
+ */
74
+
75
+ export function isThemeName(value: unknown): value is ThemeName {
76
+ return typeof value === "string" && value in THEMES;
77
+ }
78
+
79
+ /** The theme in use: FLARE_THEME if it names one, else lib/site.ts. */
80
+ export const activeTheme: ThemeName = isThemeName(site.theme) ? site.theme : "default";
81
+
82
+ export const theme = THEMES[activeTheme];
83
+ export const authLayout: AuthLayout = theme.auth;
@@ -0,0 +1,23 @@
1
+ import { prisma } from "@/lib/db";
2
+ import { dashboardSession } from "./dashboard";
3
+
4
+ export interface SavedView {
5
+ id: string;
6
+ resource: string;
7
+ name: string;
8
+ query: string;
9
+ }
10
+
11
+ /** How many views one person may keep per resource, so the sidebar stays a sidebar. */
12
+ export const MAX_VIEWS = 12;
13
+
14
+ /** This person's saved views, oldest first so the sidebar doesn't reshuffle. */
15
+ export async function listViews(resource?: string): Promise<SavedView[]> {
16
+ const { session } = await dashboardSession();
17
+ if (!session) return [];
18
+ return prisma.savedView.findMany({
19
+ where: { userId: session.user.id, ...(resource ? { resource } : {}) },
20
+ select: { id: true, resource: true, name: true, query: true },
21
+ orderBy: { createdAt: "asc" },
22
+ });
23
+ }
@@ -0,0 +1,13 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ const config: NextConfig = {
4
+ // The generated Prisma client is a real dependency of the server bundle; without this
5
+ // Next tries to trace it as application code and misses the query engine beside it.
6
+ serverExternalPackages: ["@prisma/client", "@prisma/adapter-pg"],
7
+ // Compile @flaredev/core from source rather than resolving it as an opaque package.
8
+ // Needed when it is linked from a checkout or a monorepo: the bundler won't follow a
9
+ // symlink out of the project root, and every import from it fails to resolve.
10
+ transpilePackages: ["@flaredev/core"],
11
+ };
12
+
13
+ export default config;
@@ -0,0 +1,162 @@
1
+ // The part of the schema that is yours.
2
+ //
3
+ // Your resources live in resources.prisma beside this file, written by
4
+ // `flare gen resource` from the descriptors in resources/. This file is never
5
+ // regenerated: the generator, the datasource, the tables Better Auth needs and the two
6
+ // Flare's own dashboard uses are all here, and yours to edit.
7
+
8
+ generator client {
9
+ provider = "prisma-client"
10
+ output = "../../lib/generated/prisma"
11
+ }
12
+
13
+ datasource db {
14
+ provider = "postgresql"
15
+ }
16
+
17
+ /// Better Auth: the signed-in person.
18
+ model User {
19
+ id String @id @default(uuid())
20
+ name String
21
+ email String @unique
22
+ emailVerified Boolean @default(false) @map("email_verified")
23
+ image String?
24
+ /// Set with `flare user:role`; the dashboard's policies read it.
25
+ role String?
26
+ banned Boolean?
27
+ banReason String? @map("ban_reason")
28
+ banExpires DateTime? @map("ban_expires")
29
+ twoFactorEnabled Boolean? @map("two_factor_enabled")
30
+ createdAt DateTime @default(now()) @map("created_at")
31
+ updatedAt DateTime @updatedAt @map("updated_at")
32
+
33
+ sessions Session[]
34
+ accounts Account[]
35
+ passkeys Passkey[]
36
+ twoFactors TwoFactor[]
37
+ auditLogs AuditLog[]
38
+ savedViews SavedView[]
39
+
40
+ @@map("users")
41
+ }
42
+
43
+ model Session {
44
+ id String @id @default(uuid())
45
+ expiresAt DateTime @map("expires_at")
46
+ token String @unique
47
+ ipAddress String? @map("ip_address")
48
+ userAgent String? @map("user_agent")
49
+ userId String @map("user_id")
50
+ impersonatedBy String? @map("impersonated_by")
51
+ createdAt DateTime @default(now()) @map("created_at")
52
+ updatedAt DateTime @updatedAt @map("updated_at")
53
+
54
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
55
+
56
+ @@index([userId])
57
+ @@map("sessions")
58
+ }
59
+
60
+ model Account {
61
+ id String @id @default(uuid())
62
+ accountId String @map("account_id")
63
+ providerId String @map("provider_id")
64
+ userId String @map("user_id")
65
+ accessToken String? @map("access_token")
66
+ refreshToken String? @map("refresh_token")
67
+ idToken String? @map("id_token")
68
+ accessTokenExpiresAt DateTime? @map("access_token_expires_at")
69
+ refreshTokenExpiresAt DateTime? @map("refresh_token_expires_at")
70
+ scope String?
71
+ password String?
72
+ createdAt DateTime @default(now()) @map("created_at")
73
+ updatedAt DateTime @updatedAt @map("updated_at")
74
+
75
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
76
+
77
+ @@index([userId])
78
+ @@map("accounts")
79
+ }
80
+
81
+ model Verification {
82
+ id String @id @default(uuid())
83
+ identifier String
84
+ value String
85
+ expiresAt DateTime @map("expires_at")
86
+ createdAt DateTime @default(now()) @map("created_at")
87
+ updatedAt DateTime @updatedAt @map("updated_at")
88
+
89
+ @@index([identifier])
90
+ @@map("verifications")
91
+ }
92
+
93
+ model Passkey {
94
+ id String @id @default(uuid())
95
+ name String?
96
+ publicKey String @map("public_key")
97
+ userId String @map("user_id")
98
+ credentialID String @map("credential_id")
99
+ counter Int
100
+ deviceType String @map("device_type")
101
+ backedUp Boolean @map("backed_up")
102
+ transports String?
103
+ aaguid String?
104
+ createdAt DateTime? @default(now()) @map("created_at")
105
+
106
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
107
+
108
+ @@index([userId])
109
+ @@map("passkeys")
110
+ }
111
+
112
+ model TwoFactor {
113
+ id String @id @default(uuid())
114
+ secret String
115
+ backupCodes String @map("backup_codes")
116
+ userId String @map("user_id")
117
+ /// Better Auth checks for these three at startup and refuses to run without them.
118
+ verified Boolean? @default(true)
119
+ failedVerificationCount Int? @default(0) @map("failed_verification_count")
120
+ lockedUntil DateTime? @map("locked_until")
121
+
122
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
123
+
124
+ @@index([secret])
125
+ @@index([userId])
126
+ @@map("two_factors")
127
+ }
128
+
129
+ /// Who changed what, written by the dashboard's actions.
130
+ model AuditLog {
131
+ id String @id @default(uuid())
132
+ action String
133
+ resource String
134
+ recordId String? @map("record_id")
135
+ recordLabel String? @map("record_label")
136
+ userId String? @map("user_id")
137
+ userEmail String? @map("user_email")
138
+ changes Json?
139
+ ip String?
140
+ createdAt DateTime @default(now()) @map("created_at")
141
+
142
+ user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
143
+
144
+ @@index([resource])
145
+ @@index([createdAt])
146
+ @@map("audit_log")
147
+ }
148
+
149
+ /// A saved set of filters on a resource's table.
150
+ model SavedView {
151
+ id String @id @default(uuid())
152
+ name String
153
+ resource String
154
+ query String
155
+ userId String @map("user_id")
156
+ createdAt DateTime @default(now()) @map("created_at")
157
+
158
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
159
+
160
+ @@index([userId, resource])
161
+ @@map("saved_views")
162
+ }
@@ -0,0 +1,15 @@
1
+ import "dotenv/config";
2
+ import { defineConfig, env } from "prisma/config";
3
+
4
+ /**
5
+ * Prisma 7 keeps the connection string out of the schema and loads env itself.
6
+ *
7
+ * `schema` points at a folder, not a file: `base.prisma` holds the generator, the
8
+ * datasource and the tables Flare's own features need, and `resources.prisma` is written
9
+ * by `flare gen resource` from your descriptors.
10
+ */
11
+ export default defineConfig({
12
+ schema: "prisma/schema",
13
+ migrations: { path: "prisma/migrations" },
14
+ datasource: { url: env("DATABASE_URL") },
15
+ });
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": false,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "incremental": true,
15
+ "jsx": "preserve",
16
+ "types": ["node"],
17
+ "plugins": [{ "name": "next" }],
18
+ "paths": {
19
+ "@/*": ["./*"]
20
+ }
21
+ },
22
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
23
+ "exclude": ["node_modules", ".next", "lib/generated"]
24
+ }