@flaredev/cli 0.4.1 → 0.5.1
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/dist/index.js +698 -341
- package/package.json +2 -2
- package/templates/app/app/dashboard/costs/page.tsx +93 -0
- package/templates/app/app/dashboard/page.tsx +4 -7
- package/templates/app/app/page.tsx +10 -0
- package/templates/app/components/dashboard/cost-estimator.tsx +77 -0
- package/templates/app/components/dashboard/fields/field-widget.tsx +237 -186
- package/templates/app/components/dashboard/record-actions.tsx +7 -7
- package/templates/app/components/dashboard/record-detail.tsx +1 -1
- package/templates/app/components/dashboard/resource-form-sheet.tsx +86 -0
- package/templates/app/components/dashboard/resource-form.tsx +129 -33
- package/templates/app/components/dashboard/resource-icon.tsx +87 -85
- package/templates/app/components/dashboard/resource-stats.tsx +10 -9
- package/templates/app/components/dashboard/resource-table.tsx +4 -4
- package/templates/app/components/dashboard/row-actions.tsx +3 -3
- package/templates/app/lib/costs.ts +138 -0
- package/templates/app/lib/dashboard-nav.ts +5 -1
- package/templates/app/lib/dashboard.ts +66 -61
- package/templates/app/lib/number.ts +33 -0
- package/templates/app/lib/site.ts +56 -56
- package/templates/app/lib/suggest.ts +35 -0
- package/templates/app/lib/usage.ts +32 -0
- package/templates/next/.env.example +21 -0
- package/templates/next/README.md +16 -0
- package/templates/next/app/api/health/route.ts +7 -0
- package/templates/next/app/dashboard/account/password/page.tsx +19 -0
- package/templates/next/app/dashboard/account/security/page.tsx +31 -0
- package/templates/next/app/dashboard/actions.ts +277 -0
- package/templates/next/app/dashboard/layout.tsx +64 -0
- package/templates/next/app/dashboard/page.tsx +127 -0
- package/templates/next/components/dashboard/resource-chart.tsx +86 -0
- package/templates/next/lib/analytics.ts +71 -0
- package/templates/next/lib/audit.ts +80 -0
- package/templates/next/lib/auth-emails.ts +92 -0
- package/templates/next/lib/auth.ts +156 -0
- package/templates/next/lib/dashboard.ts +161 -0
- package/templates/next/lib/db.ts +24 -0
- package/templates/next/lib/mail.ts +35 -0
- package/templates/next/lib/realtime.ts +23 -0
- package/templates/next/lib/redis.ts +17 -0
- package/templates/next/lib/storage.ts +57 -0
- package/templates/next/lib/theme.ts +83 -0
- package/templates/next/lib/views.ts +23 -0
- package/templates/next/next.config.ts +13 -0
- package/templates/next/prisma/schema/base.prisma +162 -0
- package/templates/next/prisma.config.ts +15 -0
- package/templates/next/tsconfig.json +24 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +0 -80
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What Cloudflare charges, in one place.
|
|
3
|
+
*
|
|
4
|
+
* Checked against Cloudflare's pricing pages on 24 September 2026. They change; when
|
|
5
|
+
* they do, change them here rather than in the page that displays them. The docs cover
|
|
6
|
+
* the reasoning behind these numbers:
|
|
7
|
+
* https://flare-docs.codetotech.com/guides/costs/
|
|
8
|
+
*
|
|
9
|
+
* Nothing here is a bill. The app can see how much it stores; it cannot see how many
|
|
10
|
+
* requests it served or how many rows it read — those live in Cloudflare's analytics,
|
|
11
|
+
* behind an API token this app deliberately doesn't hold. So this is a model, and the
|
|
12
|
+
* page that shows it says so.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The Workers Paid subscription, which is the whole bill for most apps. */
|
|
16
|
+
export const SUBSCRIPTION_USD = 5;
|
|
17
|
+
|
|
18
|
+
/** Free plan allowances. Daily where Cloudflare counts them daily. */
|
|
19
|
+
export const FREE = {
|
|
20
|
+
requestsPerDay: 100_000,
|
|
21
|
+
/** CPU milliseconds per invocation — the one that bites a server-rendered app. */
|
|
22
|
+
cpuMsPerRequest: 10,
|
|
23
|
+
d1RowsReadPerDay: 5_000_000,
|
|
24
|
+
d1RowsWrittenPerDay: 100_000,
|
|
25
|
+
d1StorageGb: 5,
|
|
26
|
+
r2StorageGb: 10,
|
|
27
|
+
kvReadsPerDay: 100_000,
|
|
28
|
+
/** A thousand a day, and every cache miss is a write. */
|
|
29
|
+
kvWritesPerDay: 1_000,
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
/** Paid plan: what's included each month, then the rate past it. */
|
|
33
|
+
export const PAID = {
|
|
34
|
+
requests: { included: 10_000_000, perMillionUsd: 0.3 },
|
|
35
|
+
cpuMs: { included: 30_000_000, perMillionUsd: 0.02 },
|
|
36
|
+
d1RowsRead: { included: 25_000_000_000, perMillionUsd: 0.001 },
|
|
37
|
+
d1RowsWritten: { included: 50_000_000, perMillionUsd: 1 },
|
|
38
|
+
d1StorageGb: { included: 5, perGbMonthUsd: 0.75 },
|
|
39
|
+
r2StorageGb: { included: 10, perGbMonthUsd: 0.015 },
|
|
40
|
+
kvReads: { included: 10_000_000, perMillionUsd: 0.5 },
|
|
41
|
+
kvWrites: { included: 1_000_000, perMillionUsd: 5 },
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What one page view costs, measured on the example shop.
|
|
46
|
+
*
|
|
47
|
+
* A dashboard page runs the stats query, the chart, the table's page and its capped
|
|
48
|
+
* count, plus one lookup per relation shown — four to six queries, reading around a
|
|
49
|
+
* hundred rows between them once the indexes are doing their job. A page on the public
|
|
50
|
+
* side is lighter. These are the assumptions the estimate below rests on, and they are
|
|
51
|
+
* written down so you can argue with them.
|
|
52
|
+
*/
|
|
53
|
+
export const PER_VIEW = {
|
|
54
|
+
d1RowsRead: 100,
|
|
55
|
+
d1RowsWritten: 0.2,
|
|
56
|
+
kvReads: 4,
|
|
57
|
+
/** Cache misses are rarer than hits; most views read a warm entry. */
|
|
58
|
+
kvWrites: 0.3,
|
|
59
|
+
cpuMs: 25,
|
|
60
|
+
} as const;
|
|
61
|
+
|
|
62
|
+
export interface Estimate {
|
|
63
|
+
monthlyViews: number;
|
|
64
|
+
requests: number;
|
|
65
|
+
d1RowsRead: number;
|
|
66
|
+
d1RowsWritten: number;
|
|
67
|
+
kvReads: number;
|
|
68
|
+
kvWrites: number;
|
|
69
|
+
cpuMs: number;
|
|
70
|
+
/** Dollars on top of the subscription. */
|
|
71
|
+
overageUsd: number;
|
|
72
|
+
totalUsd: number;
|
|
73
|
+
/** Which free-plan allowance this traffic would break first, if any. */
|
|
74
|
+
freePlanVerdict: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const over = (used: number, included: number, perMillionUsd: number) =>
|
|
78
|
+
used <= included ? 0 : ((used - included) / 1_000_000) * perMillionUsd;
|
|
79
|
+
|
|
80
|
+
/** What a month at this traffic would cost, and whether the free plan could carry it. */
|
|
81
|
+
export function estimate(monthlyViews: number, storedGb: { d1: number; r2: number }): Estimate {
|
|
82
|
+
const requests = monthlyViews;
|
|
83
|
+
const d1RowsRead = monthlyViews * PER_VIEW.d1RowsRead;
|
|
84
|
+
const d1RowsWritten = monthlyViews * PER_VIEW.d1RowsWritten;
|
|
85
|
+
const kvReads = monthlyViews * PER_VIEW.kvReads;
|
|
86
|
+
const kvWrites = monthlyViews * PER_VIEW.kvWrites;
|
|
87
|
+
const cpuMs = monthlyViews * PER_VIEW.cpuMs;
|
|
88
|
+
|
|
89
|
+
const overageUsd =
|
|
90
|
+
over(requests, PAID.requests.included, PAID.requests.perMillionUsd) +
|
|
91
|
+
over(cpuMs, PAID.cpuMs.included, PAID.cpuMs.perMillionUsd) +
|
|
92
|
+
over(d1RowsRead, PAID.d1RowsRead.included, PAID.d1RowsRead.perMillionUsd) +
|
|
93
|
+
over(d1RowsWritten, PAID.d1RowsWritten.included, PAID.d1RowsWritten.perMillionUsd) +
|
|
94
|
+
over(kvReads, PAID.kvReads.included, PAID.kvReads.perMillionUsd) +
|
|
95
|
+
over(kvWrites, PAID.kvWrites.included, PAID.kvWrites.perMillionUsd) +
|
|
96
|
+
Math.max(0, storedGb.d1 - PAID.d1StorageGb.included) * PAID.d1StorageGb.perGbMonthUsd +
|
|
97
|
+
Math.max(0, storedGb.r2 - PAID.r2StorageGb.included) * PAID.r2StorageGb.perGbMonthUsd;
|
|
98
|
+
|
|
99
|
+
const daily = monthlyViews / 30;
|
|
100
|
+
const broken: string[] = [];
|
|
101
|
+
if (daily > FREE.requestsPerDay) broken.push("requests");
|
|
102
|
+
if (daily * PER_VIEW.d1RowsRead > FREE.d1RowsReadPerDay) broken.push("D1 rows read");
|
|
103
|
+
if (daily * PER_VIEW.kvWrites > FREE.kvWritesPerDay) broken.push("KV writes");
|
|
104
|
+
if (storedGb.r2 > FREE.r2StorageGb) broken.push("R2 storage");
|
|
105
|
+
if (storedGb.d1 > FREE.d1StorageGb) broken.push("D1 storage");
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
monthlyViews,
|
|
109
|
+
requests,
|
|
110
|
+
d1RowsRead,
|
|
111
|
+
d1RowsWritten,
|
|
112
|
+
kvReads,
|
|
113
|
+
kvWrites,
|
|
114
|
+
cpuMs,
|
|
115
|
+
overageUsd,
|
|
116
|
+
totalUsd: SUBSCRIPTION_USD + overageUsd,
|
|
117
|
+
freePlanVerdict: broken.length === 0
|
|
118
|
+
? "Within the free allowances — but see the CPU note below."
|
|
119
|
+
: `Past the free plan on ${broken.join(", ")}.`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** "1.2M", "34k", "912" — counts that have to fit on a card. */
|
|
124
|
+
export function compact(value: number): string {
|
|
125
|
+
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`;
|
|
126
|
+
if (value >= 1_000) return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)}k`;
|
|
127
|
+
return Math.round(value).toLocaleString();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** "$5.00", "$12.34". */
|
|
131
|
+
export const usd = (value: number) => `$${value.toFixed(2)}`;
|
|
132
|
+
|
|
133
|
+
/** "2.4 GB", "812 MB". */
|
|
134
|
+
export function gb(value: number): string {
|
|
135
|
+
if (value >= 1) return `${value.toFixed(value >= 10 ? 0 : 2)} GB`;
|
|
136
|
+
const mb = value * 1024;
|
|
137
|
+
return mb >= 1 ? `${mb.toFixed(mb >= 10 ? 0 : 1)} MB` : `${Math.round(mb * 1024)} KB`;
|
|
138
|
+
}
|
|
@@ -12,4 +12,8 @@ export const generatedDashboardLinks: DashboardLink[] = [];
|
|
|
12
12
|
// generated:end
|
|
13
13
|
|
|
14
14
|
/** Links shown under "Platform" in the dashboard sidebar. */
|
|
15
|
-
export const dashboardLinks: DashboardLink[] = [
|
|
15
|
+
export const dashboardLinks: DashboardLink[] = [
|
|
16
|
+
{ label: "Costs", href: "/dashboard/costs", icon: "wallet" },
|
|
17
|
+
...generatedDashboardLinks,
|
|
18
|
+
{ label: "API reference", href: "/api/reference", icon: "book" },
|
|
19
|
+
];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { headers } from "next/headers";
|
|
2
2
|
import { notFound, redirect } from "next/navigation";
|
|
3
3
|
import { allowedActions, can, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
|
|
4
|
-
import {
|
|
4
|
+
import { count, sql } from "drizzle-orm";
|
|
5
5
|
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
|
6
6
|
import { createResourceStore, type ResourceStore } from "@flaredev/core/server";
|
|
7
7
|
import { getDb } from "@/db";
|
|
@@ -73,74 +73,79 @@ export function dashboardStore(name: string): ResourceStore {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
76
|
+
* Every number the stats above a resource's table need, in one query.
|
|
77
|
+
*
|
|
78
|
+
* The total, how many arrived in the last week and the week before, and how many hold
|
|
79
|
+
* each value of a status field — all of it from a single grouped scan, summed up here.
|
|
80
|
+
* It was four queries and three cache entries; on the free plan the cache entries were
|
|
81
|
+
* the expensive half, because Workers KV allows a thousand writes a day and every write
|
|
82
|
+
* to a record drops the entries tagged for it.
|
|
83
|
+
*
|
|
84
|
+
* Cached under the resource's tag, so a write through the admin or the API drops it on
|
|
85
|
+
* the way out and the next view recomputes it.
|
|
79
86
|
*/
|
|
80
|
-
export
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const [row] = await getDb().select({ total: count() }).from(entry.table);
|
|
88
|
-
return row?.total ?? 0;
|
|
89
|
-
},
|
|
90
|
-
["flare", "record-count"],
|
|
91
|
-
{ tags: [resourceTag(name)], revalidate: TTL.short },
|
|
92
|
-
);
|
|
93
|
-
return counted(name);
|
|
87
|
+
export interface ResourceStats {
|
|
88
|
+
total: number;
|
|
89
|
+
/** Created in the last `days` days, and in the `days` before that. */
|
|
90
|
+
current: number;
|
|
91
|
+
previous: number;
|
|
92
|
+
/** Rows per value of the grouped field, when one was asked for. */
|
|
93
|
+
values: Record<string, number>;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
*/
|
|
100
|
-
export function valueCounts(name: string, field: string): Promise<Record<string, number>> {
|
|
101
|
-
const counted = cached(
|
|
102
|
-
async (resourceName: string, key: string) => {
|
|
96
|
+
export function resourceStats(name: string, field?: string, days = 7): Promise<ResourceStats> {
|
|
97
|
+
const read = cached(
|
|
98
|
+
async (resourceName: string, key: string | undefined, windowDays: number): Promise<ResourceStats> => {
|
|
103
99
|
const entry = (resourceTables as unknown as Record<string, { table: SQLiteTable } | undefined>)[resourceName];
|
|
104
|
-
|
|
105
|
-
if (!entry || !column) return {};
|
|
106
|
-
const rows = (await getDb()
|
|
107
|
-
.select({ value: column as never, total: count() })
|
|
108
|
-
.from(entry.table)
|
|
109
|
-
.groupBy(column as never)) as { value: unknown; total: number }[];
|
|
110
|
-
return Object.fromEntries(rows.map((row) => [String(row.value ?? ""), row.total]));
|
|
111
|
-
},
|
|
112
|
-
["flare", "value-counts"],
|
|
113
|
-
{ tags: [resourceTag(name)], revalidate: TTL.short },
|
|
114
|
-
);
|
|
115
|
-
return counted(name, field);
|
|
116
|
-
}
|
|
100
|
+
if (!entry) return { total: 0, current: 0, previous: 0, values: {} };
|
|
117
101
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const counts = cached(
|
|
124
|
-
async (resourceName: string, windowDays: number) => {
|
|
125
|
-
const entry = (resourceTables as unknown as Record<string, { table: SQLiteTable } | undefined>)[resourceName];
|
|
126
|
-
const createdAt = entry ? (entry.table as unknown as Record<string, unknown>).createdAt : undefined;
|
|
127
|
-
// A resource whose table has no createdAt (a custom one) simply has no trend.
|
|
128
|
-
if (!entry || !createdAt) return { current: 0, previous: 0 };
|
|
102
|
+
const columns = entry.table as unknown as Record<string, unknown>;
|
|
103
|
+
const createdAt = columns.createdAt;
|
|
104
|
+
const grouped = key ? columns[key] : undefined;
|
|
105
|
+
// Milliseconds, not Dates: these go into a raw `sql` fragment, which doesn't pass
|
|
106
|
+
// through the column's mapper, and D1 refuses to bind an object.
|
|
129
107
|
const window = windowDays * 86_400_000;
|
|
130
|
-
const since =
|
|
131
|
-
const before =
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
108
|
+
const since = Date.now() - window;
|
|
109
|
+
const before = Date.now() - window * 2;
|
|
110
|
+
|
|
111
|
+
// A resource whose table has no createdAt (a custom one) simply has no trend.
|
|
112
|
+
const windows = createdAt
|
|
113
|
+
? {
|
|
114
|
+
current: sql<number>`sum(case when ${createdAt as never} >= ${since} then 1 else 0 end)`,
|
|
115
|
+
previous: sql<number>`sum(case when ${createdAt as never} >= ${before} and ${createdAt as never} < ${since} then 1 else 0 end)`,
|
|
116
|
+
}
|
|
117
|
+
: { current: sql<number>`0`, previous: sql<number>`0` };
|
|
118
|
+
|
|
119
|
+
const select: Record<string, unknown> = { total: count(), ...windows };
|
|
120
|
+
if (grouped) select.value = grouped;
|
|
121
|
+
const query = getDb()
|
|
122
|
+
.select(select as never)
|
|
123
|
+
.from(entry.table);
|
|
124
|
+
const rows = (await (grouped ? query.groupBy(grouped as never) : query)) as {
|
|
125
|
+
total: number;
|
|
126
|
+
current: number;
|
|
127
|
+
previous: number;
|
|
128
|
+
value?: unknown;
|
|
129
|
+
}[];
|
|
130
|
+
|
|
131
|
+
const stats: ResourceStats = { total: 0, current: 0, previous: 0, values: {} };
|
|
132
|
+
for (const row of rows) {
|
|
133
|
+
stats.total += Number(row.total ?? 0);
|
|
134
|
+
stats.current += Number(row.current ?? 0);
|
|
135
|
+
stats.previous += Number(row.previous ?? 0);
|
|
136
|
+
if (grouped) stats.values[String(row.value ?? "")] = Number(row.total ?? 0);
|
|
137
|
+
}
|
|
138
|
+
return stats;
|
|
139
139
|
},
|
|
140
|
-
["flare", "
|
|
141
|
-
{ tags: [resourceTag(name)], revalidate: TTL.
|
|
140
|
+
["flare", "resource-stats"],
|
|
141
|
+
{ tags: [resourceTag(name)], revalidate: TTL.medium },
|
|
142
142
|
);
|
|
143
|
-
return
|
|
143
|
+
return read(name, field, days);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** How many records a resource has. One field of {@link resourceStats}. */
|
|
147
|
+
export async function recordCount(name: string): Promise<number> {
|
|
148
|
+
return (await resourceStats(name)).total;
|
|
144
149
|
}
|
|
145
150
|
|
|
146
151
|
/** Percentage change between two periods, or undefined when there's nothing to compare. */
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Numbers as people read and type them.
|
|
3
|
+
*
|
|
4
|
+
* A price typed as 2000 is easier to check at a glance as 2,000, but the form and the
|
|
5
|
+
* API only ever see the plain number — the grouping is put on for display and taken off
|
|
6
|
+
* again on every keystroke, so nothing downstream has to unpick a comma.
|
|
7
|
+
*
|
|
8
|
+
* Kept free of server imports: the field widgets that use it run in the browser.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** "2000.5" → "2,000.5". A half-typed "1," or "12." is left alone so typing isn't fought. */
|
|
12
|
+
export function groupDigits(value: string): string {
|
|
13
|
+
if (!value) return "";
|
|
14
|
+
const negative = value.startsWith("-");
|
|
15
|
+
const body = negative ? value.slice(1) : value;
|
|
16
|
+
const [whole = "", ...rest] = body.split(".");
|
|
17
|
+
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
18
|
+
// Keep a trailing "." while someone is still typing the decimals.
|
|
19
|
+
const decimals = rest.length > 0 ? `.${rest.join("")}` : "";
|
|
20
|
+
return `${negative ? "-" : ""}${grouped}${decimals}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* "2,000.50" → "2000.50". Everything that isn't a digit goes, along with any minus sign
|
|
25
|
+
* that isn't leading and any second decimal point.
|
|
26
|
+
*/
|
|
27
|
+
export function ungroup(value: string, allowDecimal: boolean): string {
|
|
28
|
+
const negative = value.trimStart().startsWith("-");
|
|
29
|
+
const digits = value.replace(/[^\d.]/g, "");
|
|
30
|
+
const [whole = "", ...rest] = digits.split(".");
|
|
31
|
+
const body = allowDecimal && rest.length > 0 ? `${whole}.${rest.join("")}` : whole;
|
|
32
|
+
return `${negative && body ? "-" : ""}${body}`;
|
|
33
|
+
}
|
|
@@ -1,56 +1,56 @@
|
|
|
1
|
-
import type { ThemeName } from "./theme";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* What the site says about itself: the home page, the sign-in screens' side panels and
|
|
5
|
-
* the page titles read from here. Edit freely.
|
|
6
|
-
*/
|
|
7
|
-
export const site = {
|
|
8
|
-
name: "__APP_NAME__",
|
|
9
|
-
tagline: "The fastest way to run your business",
|
|
10
|
-
description: "__APP_NAME__ keeps your team, your customers and your data in one place, and it's live on the edge in every region.",
|
|
11
|
-
/**
|
|
12
|
-
* The look of the whole app: sign-in screens, dashboard, admin and home page.
|
|
13
|
-
* One of: default, coral, amber, sky, mono, emerald. FLARE_THEME (shell or .env)
|
|
14
|
-
* overrides it when the app is built or run with `flare dev`.
|
|
15
|
-
*/
|
|
16
|
-
theme: "__THEME__" as ThemeName,
|
|
17
|
-
/** How the dashboard behaves. */
|
|
18
|
-
dashboard: {
|
|
19
|
-
/**
|
|
20
|
-
* Where a record is created and edited: in a dialog over the list ("modal", the
|
|
21
|
-
* default, which keeps the list, its filters and your place in it), or on its own
|
|
22
|
-
* page ("page", better for long forms).
|
|
23
|
-
*/
|
|
24
|
-
forms: "
|
|
25
|
-
},
|
|
26
|
-
/** The small pill above the home page headline; set to null to hide it. */
|
|
27
|
-
announcement: { label: "New", text: "Passkeys and two-factor sign-in are here", href: "/sign-up" } as { label: string; text: string; href: string } | null,
|
|
28
|
-
/** The home page's feature grid (icon names from lucide.dev). */
|
|
29
|
-
features: [
|
|
30
|
-
{ icon: "zap", title: "Fast everywhere", description: "Served from the edge in every region, so pages load in milliseconds wherever your customers are." },
|
|
31
|
-
{ icon: "shield-check", title: "Secure by default", description: "Passkeys, two-factor sign-in, roles and rate limits, switched on from day one." },
|
|
32
|
-
{ icon: "layout-dashboard", title: "One place for your data", description: "Every record your team keeps, with search, filters and an admin built in." },
|
|
33
|
-
{ icon: "users", title: "Built for teams", description: "Invite your team, give each person the right access, and see who changed what." },
|
|
34
|
-
{ icon: "plug", title: "Connects to your tools", description: "A documented API for every record, ready for your scripts and integrations." },
|
|
35
|
-
{ icon: "credit-card", title: "Billing when you need it", description: "Plans, checkout and invoices through Stripe, without building a billing system." },
|
|
36
|
-
],
|
|
37
|
-
/** Placeholder names for the logo strip: replace them with your customers (or remove the strip). */
|
|
38
|
-
customers: ["Acme", "Globex", "Initech", "Vandelay", "Northwind", "Brightline"],
|
|
39
|
-
/** Shown beside the sign-up form. */
|
|
40
|
-
highlights: [
|
|
41
|
-
"Set up in minutes, with nothing to install",
|
|
42
|
-
"Secure sign-in with passkeys and two-factor",
|
|
43
|
-
"Your data stays yours: export it any time",
|
|
44
|
-
],
|
|
45
|
-
/** Shown on the sign-in screen of themes with a quote panel. */
|
|
46
|
-
testimonial: {
|
|
47
|
-
quote: "We moved our whole workflow over in an afternoon. The team hasn't looked back.",
|
|
48
|
-
author: "Alex Rivera",
|
|
49
|
-
role: "Head of Operations",
|
|
50
|
-
},
|
|
51
|
-
links: {
|
|
52
|
-
terms: "/terms",
|
|
53
|
-
privacy: "/privacy",
|
|
54
|
-
support: "mailto:support@example.com",
|
|
55
|
-
},
|
|
56
|
-
};
|
|
1
|
+
import type { ThemeName } from "./theme";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the site says about itself: the home page, the sign-in screens' side panels and
|
|
5
|
+
* the page titles read from here. Edit freely.
|
|
6
|
+
*/
|
|
7
|
+
export const site = {
|
|
8
|
+
name: "__APP_NAME__",
|
|
9
|
+
tagline: "The fastest way to run your business",
|
|
10
|
+
description: "__APP_NAME__ keeps your team, your customers and your data in one place, and it's live on the edge in every region.",
|
|
11
|
+
/**
|
|
12
|
+
* The look of the whole app: sign-in screens, dashboard, admin and home page.
|
|
13
|
+
* One of: default, coral, amber, sky, mono, emerald. FLARE_THEME (shell or .env)
|
|
14
|
+
* overrides it when the app is built or run with `flare dev`.
|
|
15
|
+
*/
|
|
16
|
+
theme: "__THEME__" as ThemeName,
|
|
17
|
+
/** How the dashboard behaves. */
|
|
18
|
+
dashboard: {
|
|
19
|
+
/**
|
|
20
|
+
* Where a record is created and edited: in a dialog over the list ("modal", the
|
|
21
|
+
* default, which keeps the list, its filters and your place in it), or on its own
|
|
22
|
+
* page ("page", better for long forms).
|
|
23
|
+
*/
|
|
24
|
+
forms: "sheet" as "sheet" | "page",
|
|
25
|
+
},
|
|
26
|
+
/** The small pill above the home page headline; set to null to hide it. */
|
|
27
|
+
announcement: { label: "New", text: "Passkeys and two-factor sign-in are here", href: "/sign-up" } as { label: string; text: string; href: string } | null,
|
|
28
|
+
/** The home page's feature grid (icon names from lucide.dev). */
|
|
29
|
+
features: [
|
|
30
|
+
{ icon: "zap", title: "Fast everywhere", description: "Served from the edge in every region, so pages load in milliseconds wherever your customers are." },
|
|
31
|
+
{ icon: "shield-check", title: "Secure by default", description: "Passkeys, two-factor sign-in, roles and rate limits, switched on from day one." },
|
|
32
|
+
{ icon: "layout-dashboard", title: "One place for your data", description: "Every record your team keeps, with search, filters and an admin built in." },
|
|
33
|
+
{ icon: "users", title: "Built for teams", description: "Invite your team, give each person the right access, and see who changed what." },
|
|
34
|
+
{ icon: "plug", title: "Connects to your tools", description: "A documented API for every record, ready for your scripts and integrations." },
|
|
35
|
+
{ icon: "credit-card", title: "Billing when you need it", description: "Plans, checkout and invoices through Stripe, without building a billing system." },
|
|
36
|
+
],
|
|
37
|
+
/** Placeholder names for the logo strip: replace them with your customers (or remove the strip). */
|
|
38
|
+
customers: ["Acme", "Globex", "Initech", "Vandelay", "Northwind", "Brightline"],
|
|
39
|
+
/** Shown beside the sign-up form. */
|
|
40
|
+
highlights: [
|
|
41
|
+
"Set up in minutes, with nothing to install",
|
|
42
|
+
"Secure sign-in with passkeys and two-factor",
|
|
43
|
+
"Your data stays yours: export it any time",
|
|
44
|
+
],
|
|
45
|
+
/** Shown on the sign-in screen of themes with a quote panel. */
|
|
46
|
+
testimonial: {
|
|
47
|
+
quote: "We moved our whole workflow over in an afternoon. The team hasn't looked back.",
|
|
48
|
+
author: "Alex Rivera",
|
|
49
|
+
role: "Head of Operations",
|
|
50
|
+
},
|
|
51
|
+
links: {
|
|
52
|
+
terms: "/terms",
|
|
53
|
+
privacy: "/privacy",
|
|
54
|
+
support: "mailto:support@example.com",
|
|
55
|
+
},
|
|
56
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { slugify, type StoredField } from "@flaredev/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Values a form can make up for itself.
|
|
5
|
+
*
|
|
6
|
+
* A unique code — a SKU, a slug, a reference — is the shop's business, not something
|
|
7
|
+
* worth typing by hand, but it isn't something to impose either: these are offered
|
|
8
|
+
* behind a button, and whatever comes out can be typed over.
|
|
9
|
+
*
|
|
10
|
+
* Only unique, unformatted strings get one. An email or a URL is unique too, and
|
|
11
|
+
* inventing one would be nonsense.
|
|
12
|
+
*/
|
|
13
|
+
export function canSuggest(field: StoredField): boolean {
|
|
14
|
+
return field.kind === "string" && field.unique === true && (field.format === undefined || field.format === "slug");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Four digits, so two products added in the same minute don't collide. */
|
|
18
|
+
const tail = () => String(Math.floor(Math.random() * 10_000)).padStart(4, "0");
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A code for `key`, derived from what's been typed so far.
|
|
22
|
+
*
|
|
23
|
+
* "Brushed steel desk lamp" gives the slug `brushed-steel-desk-lamp` and the SKU
|
|
24
|
+
* `BRU-4821` — recognisable at a glance in a list, which is the whole point of a code
|
|
25
|
+
* someone reads out over a counter.
|
|
26
|
+
*/
|
|
27
|
+
export function suggestValue(key: string, field: StoredField, title: string): string {
|
|
28
|
+
const source = title.trim();
|
|
29
|
+
const format = field.kind === "string" ? field.format : undefined;
|
|
30
|
+
if (format === "slug" || /slug/i.test(key)) {
|
|
31
|
+
return source ? slugify(source) : `item-${tail()}`;
|
|
32
|
+
}
|
|
33
|
+
const letters = source.replace(/[^A-Za-z0-9]/g, "").slice(0, 3).toUpperCase();
|
|
34
|
+
return `${letters || "REF"}-${tail()}`;
|
|
35
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { cached, TTL } from "@/lib/cache";
|
|
2
|
+
import { storage } from "@/lib/storage";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How much this app has put in R2.
|
|
6
|
+
*
|
|
7
|
+
* Listing a bucket is a Class A operation — the expensive kind — so it is cached for an
|
|
8
|
+
* hour. A bucket with more objects than one listing returns is reported as far as the
|
|
9
|
+
* cap and no further, because walking a million keys to draw one number would cost more
|
|
10
|
+
* than the number is worth.
|
|
11
|
+
*/
|
|
12
|
+
const MAX_KEYS = 1_000;
|
|
13
|
+
|
|
14
|
+
export const storedBytes = cached(
|
|
15
|
+
async (): Promise<{ files: number; bytes: number; capped: boolean }> => {
|
|
16
|
+
try {
|
|
17
|
+
if (!storage.bucket.list) return { files: 0, bytes: 0, capped: false };
|
|
18
|
+
const listed = await storage.bucket.list({ limit: MAX_KEYS });
|
|
19
|
+
const objects = listed.objects ?? [];
|
|
20
|
+
return {
|
|
21
|
+
files: objects.length,
|
|
22
|
+
bytes: objects.reduce((sum, object) => sum + (object.size ?? 0), 0),
|
|
23
|
+
capped: Boolean(listed.truncated),
|
|
24
|
+
};
|
|
25
|
+
} catch {
|
|
26
|
+
// No bucket bound, or no permission to list it: report nothing rather than fail.
|
|
27
|
+
return { files: 0, bytes: 0, capped: false };
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
["flare", "stored-bytes"],
|
|
31
|
+
{ revalidate: TTL.long },
|
|
32
|
+
);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Postgres, from your Neon project (Dashboard → Connection string).
|
|
2
|
+
DATABASE_URL=
|
|
3
|
+
|
|
4
|
+
# Sessions and signed upload URLs. Generate with: openssl rand -base64 32
|
|
5
|
+
BETTER_AUTH_SECRET=
|
|
6
|
+
BETTER_AUTH_URL=http://localhost:3000
|
|
7
|
+
|
|
8
|
+
# File uploads, on an R2 bucket (Cloudflare → R2 → Manage API tokens).
|
|
9
|
+
# R2 serves what it stores for free, which is why it's here rather than Blob storage.
|
|
10
|
+
R2_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
|
|
11
|
+
R2_BUCKET=
|
|
12
|
+
R2_ACCESS_KEY_ID=
|
|
13
|
+
R2_SECRET_ACCESS_KEY=
|
|
14
|
+
|
|
15
|
+
# Optional. Rate limits and anything that has to be true across every instance.
|
|
16
|
+
UPSTASH_REDIS_REST_URL=
|
|
17
|
+
UPSTASH_REDIS_REST_TOKEN=
|
|
18
|
+
|
|
19
|
+
# Optional. Without these, email is printed to the console instead of sent.
|
|
20
|
+
RESEND_API_KEY=
|
|
21
|
+
MAIL_FROM=
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# __APP_NAME__
|
|
2
|
+
|
|
3
|
+
A [Flare](https://flare-docs.codetotech.com) app on Next.js, Postgres and Prisma.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
cp .env.example .env # DATABASE_URL and BETTER_AUTH_SECRET, at least
|
|
7
|
+
__PM__ run db:migrate # prisma migrate dev: write and apply a migration
|
|
8
|
+
__PM__ run dev # start the dev server on http://localhost:3000
|
|
9
|
+
__PM__ run build # production build
|
|
10
|
+
__PM__ run deploy # deploy to Vercel
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
After `flare gen resource`, run `__PM__ run db:migrate` to turn the new models
|
|
14
|
+
in `prisma/schema/resources.prisma` into a migration.
|
|
15
|
+
|
|
16
|
+
Docs: https://flare-docs.codetotech.com
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { prisma } from "@/lib/db";
|
|
2
|
+
|
|
3
|
+
/** Is the app up, and can it reach the database? */
|
|
4
|
+
export async function GET() {
|
|
5
|
+
const rows = (await prisma.$queryRaw`select 1 as ok`) as { ok: number }[];
|
|
6
|
+
return Response.json({ ok: true, database: rows[0]?.ok === 1 });
|
|
7
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Password } from "@/components/account/sections";
|
|
2
|
+
import { prisma } from "@/lib/db";
|
|
3
|
+
import { requireSession } from "@/lib/session";
|
|
4
|
+
|
|
5
|
+
export const metadata = { title: "Password" };
|
|
6
|
+
|
|
7
|
+
/** Whether this person signs in with a password at all: social and passkey accounts have none. */
|
|
8
|
+
async function hasPassword(userId: string) {
|
|
9
|
+
const credential = await prisma.account.findFirst({
|
|
10
|
+
where: { userId, providerId: "credential" },
|
|
11
|
+
select: { id: true },
|
|
12
|
+
});
|
|
13
|
+
return Boolean(credential);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default async function PasswordPage() {
|
|
17
|
+
const { user } = await requireSession("/dashboard/account/password");
|
|
18
|
+
return <Password hasPassword={await hasPassword(user.id)} email={user.email} />;
|
|
19
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ConnectedAccounts, Passkeys, TwoFactor } from "@/components/account/sections";
|
|
2
|
+
import { prisma } from "@/lib/db";
|
|
3
|
+
import { enabledSocialProviders } from "@/lib/auth";
|
|
4
|
+
import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
|
|
5
|
+
import { requireSession } from "@/lib/session";
|
|
6
|
+
|
|
7
|
+
export const metadata = { title: "Security" };
|
|
8
|
+
|
|
9
|
+
/** Two-factor, passkeys and the accounts you can sign in with. */
|
|
10
|
+
export default async function SecurityPage() {
|
|
11
|
+
const { user } = await requireSession("/dashboard/account/security");
|
|
12
|
+
const credential = await prisma.account.findFirst({
|
|
13
|
+
where: { userId: user.id, providerId: "credential" },
|
|
14
|
+
select: { id: true },
|
|
15
|
+
});
|
|
16
|
+
const providers = enabledSocialProviders();
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<>
|
|
20
|
+
{twoFactorAvailable && (
|
|
21
|
+
<TwoFactor
|
|
22
|
+
enabled={Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled)}
|
|
23
|
+
hasPassword={Boolean(credential)}
|
|
24
|
+
methods={authConfig.twoFactor}
|
|
25
|
+
/>
|
|
26
|
+
)}
|
|
27
|
+
{authConfig.passkeys && <Passkeys />}
|
|
28
|
+
{providers.length > 0 && <ConnectedAccounts providers={providers} />}
|
|
29
|
+
</>
|
|
30
|
+
);
|
|
31
|
+
}
|