@flaredev/cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/bin/flare.js +9 -1
  2. package/dist/index.js +751 -280
  3. package/package.json +2 -2
  4. package/templates/app/app/admin/page.tsx +9 -61
  5. package/templates/app/app/dashboard/account/page.tsx +6 -4
  6. package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
  7. package/templates/app/app/dashboard/import-actions.ts +91 -0
  8. package/templates/app/app/dashboard/layout.tsx +42 -39
  9. package/templates/app/app/dashboard/observability/page.tsx +174 -0
  10. package/templates/app/app/dashboard/page.tsx +106 -25
  11. package/templates/app/app/verify-email/page.tsx +33 -0
  12. package/templates/app/components/account/security-settings.tsx +6 -9
  13. package/templates/app/components/auth/otp-input.tsx +95 -0
  14. package/templates/app/components/auth/password-field.tsx +116 -0
  15. package/templates/app/components/auth/password-forms.tsx +76 -79
  16. package/templates/app/components/auth/sign-in-flow.tsx +306 -315
  17. package/templates/app/components/auth/sign-up-form.tsx +93 -101
  18. package/templates/app/components/auth/two-factor-form.tsx +139 -124
  19. package/templates/app/components/auth/use-resend.ts +44 -0
  20. package/templates/app/components/auth/verify-email-form.tsx +135 -0
  21. package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
  22. package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +166 -120
  23. package/templates/app/components/dashboard/export-button.tsx +41 -0
  24. package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
  25. package/templates/app/components/dashboard/import-dialog.tsx +487 -0
  26. package/templates/app/components/dashboard/page-header.tsx +57 -0
  27. package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
  28. package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
  29. package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
  30. package/templates/app/components/dashboard/resource-stats.tsx +53 -0
  31. package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -281
  32. package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
  33. package/templates/app/components/dashboard/stat-card.tsx +69 -0
  34. package/templates/app/components/dashboard/table-selection.tsx +185 -0
  35. package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
  36. package/templates/app/db/flare-schema.ts +33 -1
  37. package/templates/app/lib/analytics.ts +121 -0
  38. package/templates/app/lib/audit.ts +80 -0
  39. package/templates/app/lib/auth-config.ts +38 -33
  40. package/templates/app/lib/auth.ts +34 -0
  41. package/templates/app/lib/cache.ts +83 -83
  42. package/templates/app/lib/csv.ts +24 -0
  43. package/templates/app/lib/dashboard-nav.ts +15 -0
  44. package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
  45. package/templates/app/lib/password-rules.ts +159 -0
  46. package/templates/app/lib/site.ts +56 -47
  47. package/templates/app/migrations/0000_init.sql +15 -3
  48. package/templates/app/migrations/meta/0000_snapshot.json +97 -1
  49. package/templates/app/migrations/meta/_journal.json +1 -1
  50. package/templates/app/worker-configuration.d.ts +15764 -0
  51. package/templates/locks/package-lock.json +9189 -0
  52. package/templates/locks/pnpm-lock.yaml +6347 -0
  53. package/templates/app/app/admin/layout.tsx +0 -32
  54. package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
  55. package/templates/app/lib/admin-nav.ts +0 -15
  56. /package/templates/app/components/{admin → dashboard}/fields/country-field.tsx +0 -0
  57. /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
  58. /package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +0 -0
  59. /package/templates/app/components/{admin → dashboard}/fields/multi-select-field.tsx +0 -0
  60. /package/templates/app/components/{admin → dashboard}/fields/phone-field.tsx +0 -0
  61. /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
  62. /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
  63. /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
  64. /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
@@ -1,103 +1,138 @@
1
- import { headers } from "next/headers";
2
- import { notFound, redirect } from "next/navigation";
3
- import { allowedActions, can, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
4
- import type { SQLiteTable } from "drizzle-orm/sqlite-core";
5
- import { createResourceStore, type ResourceStore } from "@flaredev/core/server";
6
- import { getDb } from "@/db";
7
- import { policies } from "@/policies";
8
- import { resourceTables } from "@/resources/server";
9
- import { auth } from "./auth";
10
- import { cached, resourceTag, revalidateResource, TTL } from "./cache";
11
-
12
- /**
13
- * Roles that always reach /admin, whatever the policies say. Other roles get in when a
14
- * policy grants them read access to at least one resource.
15
- * Register roles with `flare role:add`, assign with `flare user:role`.
16
- */
17
- export const ADMIN_ROLES = ["admin", "staff"];
18
-
19
- export function policyFor(resourceName: string): Policy | undefined {
20
- return (policies as Record<string, Policy | undefined>)[resourceName];
21
- }
22
-
23
- export async function adminSession() {
24
- const session = await auth.api.getSession({ headers: await headers() });
25
- if (!session) return { session: null, role: null, allowed: false } as const;
26
- const role = (session.user as { role?: string | null }).role ?? null;
27
- const readable = adminResources().some((resource) => can(policyFor(resource.name), role, "read"));
28
- return { session, role, allowed: (role !== null && ADMIN_ROLES.includes(role)) || readable } as const;
29
- }
30
-
31
- /** For admin pages: redirects signed-out visitors to sign-in and users without access to the home page. */
32
- export async function requireAdmin(returnTo = "/admin") {
33
- const { session, role, allowed } = await adminSession();
34
- if (!session) redirect(`/sign-in?next=${encodeURIComponent(returnTo)}`);
35
- if (!allowed) redirect("/?error=forbidden");
36
- return { session, role };
37
- }
38
-
39
- /**
40
- * For resource pages: also requires the policy to allow `action` on this resource.
41
- * Sends the user somewhere they can actually go — the list when they may read it,
42
- * the dashboard when they may not.
43
- */
44
- export async function requireAccess(resource: Resource, action: PolicyAction) {
45
- const { session, role } = await requireAdmin(adminPath(resource));
46
- const policy = policyFor(resource.name);
47
- if (!can(policy, role, action)) {
48
- redirect(action !== "read" && can(policy, role, "read") ? adminPath(resource) : "/admin?error=forbidden");
49
- }
50
- return { session, role };
51
- }
52
-
53
- /** What the current user may do with a resource, for hiding actions they can't use. */
54
- export async function adminPermissions(resourceName: string) {
55
- const { role } = await adminSession();
56
- return allowedActions(policyFor(resourceName), role);
57
- }
58
-
59
- const stores = new Map<string, ResourceStore>();
60
-
61
- /** CRUD store for a resource by name (404 for unknown names). */
62
- export function adminStore(name: string): ResourceStore {
63
- const entry = (resourceTables as unknown as Record<string, { resource: Resource; table: SQLiteTable } | undefined>)[name];
64
- if (!entry) notFound();
65
- let store = stores.get(name);
66
- if (!store) {
67
- store = createResourceStore({ resource: entry.resource, table: entry.table, getDb, onChange: revalidateResource });
68
- stores.set(name, store);
69
- }
70
- return store;
71
- }
72
-
73
- /**
74
- * How many records a resource has. Cached in the data cache under the resource's tag,
75
- * so the dashboard does not count every table on every visit, and a write through the
76
- * admin or the API drops the count on its way out.
77
- */
78
- export function recordCount(name: string): Promise<number> {
79
- const count = cached(
80
- async (resourceName: string) => {
81
- const result = await adminStore(resourceName).list(new URLSearchParams({ perPage: "1" }));
82
- return result.ok ? result.data.meta.total : 0;
83
- },
84
- ["flare", "record-count"],
85
- { tags: [resourceTag(name)], revalidate: TTL.short },
86
- );
87
- return count(name);
88
- }
89
-
90
- /** Every resource descriptor (unfiltered): navigation uses `visibleResources()` instead. */
91
- export function adminResources(): Resource[] {
92
- return Object.values(resourceTables as unknown as Record<string, { resource: Resource }>).map((entry) => entry.resource);
93
- }
94
-
95
- /** Resources the current user may read, for the sidebar and dashboard. */
96
- export async function visibleResources(): Promise<Resource[]> {
97
- const { role } = await adminSession();
98
- return adminResources().filter((resource) => can(policyFor(resource.name), role, "read"));
99
- }
100
-
101
- export function adminPath(resource: Resource, ...parts: string[]) {
102
- return ["/admin", resource.slug, ...parts].join("/");
103
- }
1
+ import { headers } from "next/headers";
2
+ import { notFound, redirect } from "next/navigation";
3
+ import { allowedActions, can, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
4
+ import { and, count, gte, lt } from "drizzle-orm";
5
+ import type { SQLiteTable } from "drizzle-orm/sqlite-core";
6
+ import { createResourceStore, type ResourceStore } from "@flaredev/core/server";
7
+ import { getDb } from "@/db";
8
+ import { policies } from "@/policies";
9
+ import { resourceTables } from "@/resources/server";
10
+ import { auth } from "./auth";
11
+ import { cached, resourceTag, revalidateResource, TTL } from "./cache";
12
+
13
+ /**
14
+ * Roles that always reach the dashboard, whatever the policies say. Other roles get in when a
15
+ * policy grants them read access to at least one resource.
16
+ * Register roles with `flare role:add`, assign with `flare user:role`.
17
+ */
18
+ export const ADMIN_ROLES = ["admin", "staff"];
19
+
20
+ export function policyFor(resourceName: string): Policy | undefined {
21
+ return (policies as Record<string, Policy | undefined>)[resourceName];
22
+ }
23
+
24
+ export async function dashboardSession() {
25
+ const session = await auth.api.getSession({ headers: await headers() });
26
+ if (!session) return { session: null, role: null, allowed: false } as const;
27
+ const role = (session.user as { role?: string | null }).role ?? null;
28
+ const readable = allResources().some((resource) => can(policyFor(resource.name), role, "read"));
29
+ return { session, role, allowed: (role !== null && ADMIN_ROLES.includes(role)) || readable } as const;
30
+ }
31
+
32
+ /** For admin pages: redirects signed-out visitors to sign-in and users without access to the home page. */
33
+ export async function requireDashboard(returnTo = "/dashboard") {
34
+ const { session, role, allowed } = await dashboardSession();
35
+ if (!session) redirect(`/sign-in?next=${encodeURIComponent(returnTo)}`);
36
+ if (!allowed) redirect("/?error=forbidden");
37
+ return { session, role };
38
+ }
39
+
40
+ /**
41
+ * For resource pages: also requires the policy to allow `action` on this resource.
42
+ * Sends the user somewhere they can actually go — the list when they may read it,
43
+ * the dashboard when they may not.
44
+ */
45
+ export async function requireAccess(resource: Resource, action: PolicyAction) {
46
+ const { session, role } = await requireDashboard(resourcePath(resource));
47
+ const policy = policyFor(resource.name);
48
+ if (!can(policy, role, action)) {
49
+ redirect(action !== "read" && can(policy, role, "read") ? resourcePath(resource) : "/dashboard?error=forbidden");
50
+ }
51
+ return { session, role };
52
+ }
53
+
54
+ /** What the current user may do with a resource, for hiding actions they can't use. */
55
+ export async function adminPermissions(resourceName: string) {
56
+ const { role } = await dashboardSession();
57
+ return allowedActions(policyFor(resourceName), role);
58
+ }
59
+
60
+ const stores = new Map<string, ResourceStore>();
61
+
62
+ /** CRUD store for a resource by name (404 for unknown names). */
63
+ export function dashboardStore(name: string): ResourceStore {
64
+ const entry = (resourceTables as unknown as Record<string, { resource: Resource; table: SQLiteTable } | undefined>)[name];
65
+ if (!entry) notFound();
66
+ let store = stores.get(name);
67
+ if (!store) {
68
+ store = createResourceStore({ resource: entry.resource, table: entry.table, getDb, onChange: revalidateResource });
69
+ stores.set(name, store);
70
+ }
71
+ return store;
72
+ }
73
+
74
+ /**
75
+ * How many records a resource has. Cached in the data cache under the resource's tag,
76
+ * so the dashboard does not count every table on every visit, and a write through the
77
+ * admin or the API drops the count on its way out.
78
+ */
79
+ export function recordCount(name: string): Promise<number> {
80
+ const count = cached(
81
+ async (resourceName: string) => {
82
+ const result = await dashboardStore(resourceName).list(new URLSearchParams({ perPage: "1" }));
83
+ return result.ok ? result.data.meta.total : 0;
84
+ },
85
+ ["flare", "record-count"],
86
+ { tags: [resourceTag(name)], revalidate: TTL.short },
87
+ );
88
+ return count(name);
89
+ }
90
+
91
+ /**
92
+ * How many records a resource gained in the last `days` days, and in the `days` before
93
+ * that, so a stat can show which way it's going. Cached like the totals.
94
+ */
95
+ export function recentCounts(name: string, days = 7): Promise<{ current: number; previous: number }> {
96
+ const counts = cached(
97
+ async (resourceName: string, windowDays: number) => {
98
+ const entry = (resourceTables as unknown as Record<string, { table: SQLiteTable } | undefined>)[resourceName];
99
+ const createdAt = entry ? (entry.table as unknown as Record<string, unknown>).createdAt : undefined;
100
+ // A resource whose table has no createdAt (a custom one) simply has no trend.
101
+ if (!entry || !createdAt) return { current: 0, previous: 0 };
102
+ const window = windowDays * 86_400_000;
103
+ const since = new Date(Date.now() - window);
104
+ const before = new Date(Date.now() - window * 2);
105
+ const column = createdAt as never;
106
+ const [current] = await getDb().select({ total: count() }).from(entry.table).where(gte(column, since));
107
+ const [previous] = await getDb()
108
+ .select({ total: count() })
109
+ .from(entry.table)
110
+ .where(and(gte(column, before), lt(column, since)));
111
+ return { current: current?.total ?? 0, previous: previous?.total ?? 0 };
112
+ },
113
+ ["flare", "recent-count"],
114
+ { tags: [resourceTag(name)], revalidate: TTL.short },
115
+ );
116
+ return counts(name, days);
117
+ }
118
+
119
+ /** Percentage change between two periods, or undefined when there's nothing to compare. */
120
+ export function trend(current: number, previous: number): number | undefined {
121
+ if (previous === 0) return current === 0 ? undefined : 100;
122
+ return ((current - previous) / previous) * 100;
123
+ }
124
+
125
+ /** Every resource descriptor (unfiltered): navigation uses `visibleResources()` instead. */
126
+ export function allResources(): Resource[] {
127
+ return Object.values(resourceTables as unknown as Record<string, { resource: Resource }>).map((entry) => entry.resource);
128
+ }
129
+
130
+ /** Resources the current user may read, for the sidebar and dashboard. */
131
+ export async function visibleResources(): Promise<Resource[]> {
132
+ const { role } = await dashboardSession();
133
+ return allResources().filter((resource) => can(policyFor(resource.name), role, "read"));
134
+ }
135
+
136
+ export function resourcePath(resource: Resource, ...parts: string[]) {
137
+ return ["/dashboard", resource.slug, ...parts].join("/");
138
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * What makes a password acceptable here, in one place: the sign-up, reset and change
3
+ * forms show these rules as you type, and the server checks the same ones before it
4
+ * accepts a password (lib/auth.ts).
5
+ *
6
+ * Only the length is required. The rest are advice, shown as a strength reading — length
7
+ * beats punctuation, and a form that refuses "correct horse battery staple" while
8
+ * accepting "P@ssw0rd" has its rules the wrong way round.
9
+ */
10
+
11
+ export const PASSWORD_MIN_LENGTH = 8;
12
+ /** Better Auth's own ceiling; long passphrases are the point, so it's generous. */
13
+ export const PASSWORD_MAX_LENGTH = 128;
14
+
15
+ /** Passwords guessed first in every credential-stuffing list, and the words they're built from. */
16
+ const COMMON = new Set([
17
+ "password", "passw0rd", "p@ssword", "p@ssw0rd", "letmein", "welcome", "monkey", "dragon", "football", "baseball",
18
+ "iloveyou", "sunshine", "princess", "admin", "administrator", "login", "master", "hello", "freedom", "whatever",
19
+ "qwerty", "qwertyuiop", "asdfgh", "zxcvbn", "111111", "123123", "123456", "1234567", "12345678", "123456789",
20
+ "1234567890", "abc123", "trustno1", "superman", "batman", "starwars", "pokemon", "chocolate", "internet", "computer",
21
+ "samsung", "google", "facebook", "flare", "changeme", "secret", "test", "temp", "default", "guest",
22
+ ]);
23
+
24
+ const KEYBOARD_RUNS = ["qwertyuiop", "asdfghjkl", "zxcvbnm", "1234567890", "abcdefghijklmnopqrstuvwxyz"];
25
+
26
+ export type RuleId = "length" | "variety" | "notCommon" | "notPersonal";
27
+
28
+ export interface PasswordRule {
29
+ id: RuleId;
30
+ label: string;
31
+ met: boolean;
32
+ /** A password can't be used until every required rule is met. */
33
+ required: boolean;
34
+ }
35
+
36
+ export interface PasswordCheck {
37
+ rules: PasswordRule[];
38
+ /** 0 (hopeless) to 4 (strong), the way zxcvbn scores. */
39
+ score: 0 | 1 | 2 | 3 | 4;
40
+ label: "Too short" | "Weak" | "Fair" | "Good" | "Strong";
41
+ /** The one thing most worth fixing, or null when there's nothing to say. */
42
+ advice: string | null;
43
+ /** Every required rule is met. */
44
+ valid: boolean;
45
+ }
46
+
47
+ /** Strip the digits and punctuation people add to the end: "Password123!" is "password". */
48
+ const stem = (value: string) =>
49
+ value
50
+ .toLowerCase()
51
+ .replace(/[^a-z]/g, "")
52
+ .replace(/(.)\1{2,}/g, "$1");
53
+
54
+ const looksCommon = (password: string): boolean => {
55
+ const lower = password.toLowerCase();
56
+ if (COMMON.has(lower) || COMMON.has(stem(lower))) return true;
57
+ // A common word with anything tacked on either end is still that word.
58
+ for (const word of COMMON) {
59
+ if (word.length >= 5 && lower.includes(word)) return true;
60
+ }
61
+ return KEYBOARD_RUNS.some((run) => {
62
+ for (let i = 0; i + 5 <= run.length; i++) {
63
+ const slice = run.slice(i, i + 5);
64
+ if (lower.includes(slice) || lower.includes([...slice].reverse().join(""))) return true;
65
+ }
66
+ return false;
67
+ });
68
+ };
69
+
70
+ /** Characters that carry information: repeats and runs count once. */
71
+ function effectiveLength(password: string): number {
72
+ let length = 0;
73
+ let previous = "";
74
+ let run = 0;
75
+ for (const character of password) {
76
+ if (character === previous) {
77
+ run++;
78
+ length += run > 1 ? 0.25 : 0.5;
79
+ } else {
80
+ run = 0;
81
+ length += 1;
82
+ }
83
+ previous = character;
84
+ }
85
+ return length;
86
+ }
87
+
88
+ const classes = (password: string) =>
89
+ [/[a-z]/, /[A-Z]/, /\d/, /[^\w\s]/, /\s/].filter((pattern) => pattern.test(password)).length;
90
+
91
+ /**
92
+ * Check a password, for the form as you type and for the server on submit.
93
+ * `avoid` is the things it shouldn't contain — the person's name and email.
94
+ */
95
+ export function checkPassword(password: string, avoid: (string | null | undefined)[] = []): PasswordCheck {
96
+ const personal = avoid
97
+ .filter((value): value is string => Boolean(value && value.length > 2))
98
+ .flatMap((value) => [value.toLowerCase(), ...value.toLowerCase().split(/[@.\s]+/)])
99
+ .filter((part) => part.length > 2);
100
+ const lower = password.toLowerCase();
101
+
102
+ const rules: PasswordRule[] = [
103
+ { id: "length", label: `At least ${PASSWORD_MIN_LENGTH} characters`, met: password.length >= PASSWORD_MIN_LENGTH, required: true },
104
+ { id: "variety", label: "Letters and something else: a number, a symbol or a space", met: classes(password) >= 2, required: false },
105
+ { id: "notCommon", label: "Not a password everyone tries first", met: password.length > 0 && !looksCommon(password), required: false },
106
+ { id: "notPersonal", label: "Nothing from your name or email", met: !personal.some((part) => lower.includes(part)), required: false },
107
+ ];
108
+
109
+ const valid = rules.every((rule) => !rule.required || rule.met) && password.length <= PASSWORD_MAX_LENGTH;
110
+ const failed = rules.find((rule) => !rule.met);
111
+
112
+ let score: PasswordCheck["score"] = 0;
113
+ if (password.length >= PASSWORD_MIN_LENGTH) {
114
+ const length = effectiveLength(password);
115
+ const variety = classes(password);
116
+ const guessable = !rules.find((rule) => rule.id === "notCommon")!.met || !rules.find((rule) => rule.id === "notPersonal")!.met;
117
+ const raw = length >= 20 ? 4 : length >= 16 ? 3.5 : length >= 12 ? 3 : length >= 10 ? 2 : 1;
118
+ const adjusted = raw + (variety >= 3 ? 0.5 : 0) - (guessable ? 2 : 0);
119
+ score = Math.max(1, Math.min(4, Math.round(adjusted))) as PasswordCheck["score"];
120
+ }
121
+
122
+ const label = password.length < PASSWORD_MIN_LENGTH ? "Too short" : (["Weak", "Weak", "Fair", "Good", "Strong"] as const)[score];
123
+
124
+ let advice: string | null = null;
125
+ if (password.length === 0) advice = null;
126
+ else if (password.length < PASSWORD_MIN_LENGTH) advice = `${PASSWORD_MIN_LENGTH - password.length} more to go.`;
127
+ else if (password.length > PASSWORD_MAX_LENGTH) advice = `That's over ${PASSWORD_MAX_LENGTH} characters.`;
128
+ else if (failed?.id === "notCommon") advice = "That's close to a password attackers try first. A few unrelated words work better.";
129
+ else if (failed?.id === "notPersonal") advice = "Anyone who knows your email can guess this. Try something unrelated to you.";
130
+ else if (score < 3) advice = "Longer is stronger: three or four unrelated words beat a short password with symbols.";
131
+
132
+ return { rules, score, label, advice, valid };
133
+ }
134
+
135
+ /**
136
+ * Has this password appeared in a breach? Asks Have I Been Pwned with the k-anonymity
137
+ * range API: only the first five characters of the hash leave the Worker, and the
138
+ * password itself never does.
139
+ *
140
+ * Fails open — an outage shouldn't stop people signing up.
141
+ */
142
+ export async function isBreachedPassword(password: string, fetchImpl: typeof fetch = fetch): Promise<boolean> {
143
+ try {
144
+ const bytes = new Uint8Array(await crypto.subtle.digest("SHA-1", new TextEncoder().encode(password)));
145
+ const hash = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("").toUpperCase();
146
+ const response = await fetchImpl(`https://api.pwnedpasswords.com/range/${hash.slice(0, 5)}`, {
147
+ headers: { "Add-Padding": "true" },
148
+ signal: AbortSignal.timeout(2500),
149
+ });
150
+ if (!response.ok) return false;
151
+ const suffix = hash.slice(5);
152
+ return (await response.text()).split("\n").some((line) => {
153
+ const [candidate, count] = line.trim().split(":");
154
+ return candidate === suffix && Number(count) > 0;
155
+ });
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
@@ -1,47 +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
- /** The small pill above the home page headline; set to null to hide it. */
18
- announcement: { label: "New", text: "Passkeys and two-factor sign-in are here", href: "/sign-up" } as { label: string; text: string; href: string } | null,
19
- /** The home page's feature grid (icon names from lucide.dev). */
20
- features: [
21
- { icon: "zap", title: "Fast everywhere", description: "Served from the edge in every region, so pages load in milliseconds wherever your customers are." },
22
- { icon: "shield-check", title: "Secure by default", description: "Passkeys, two-factor sign-in, roles and rate limits, switched on from day one." },
23
- { icon: "layout-dashboard", title: "One place for your data", description: "Every record your team keeps, with search, filters and an admin built in." },
24
- { icon: "users", title: "Built for teams", description: "Invite your team, give each person the right access, and see who changed what." },
25
- { icon: "plug", title: "Connects to your tools", description: "A documented API for every record, ready for your scripts and integrations." },
26
- { icon: "credit-card", title: "Billing when you need it", description: "Plans, checkout and invoices through Stripe, without building a billing system." },
27
- ],
28
- /** Placeholder names for the logo strip: replace them with your customers (or remove the strip). */
29
- customers: ["Acme", "Globex", "Initech", "Vandelay", "Northwind", "Brightline"],
30
- /** Shown beside the sign-up form. */
31
- highlights: [
32
- "Set up in minutes, with nothing to install",
33
- "Secure sign-in with passkeys and two-factor",
34
- "Your data stays yours: export it any time",
35
- ],
36
- /** Shown on the sign-in screen of themes with a quote panel. */
37
- testimonial: {
38
- quote: "We moved our whole workflow over in an afternoon. The team hasn't looked back.",
39
- author: "Alex Rivera",
40
- role: "Head of Operations",
41
- },
42
- links: {
43
- terms: "/terms",
44
- privacy: "/privacy",
45
- support: "mailto:support@example.com",
46
- },
47
- };
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: "modal" as "modal" | "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
+ };
@@ -87,11 +87,23 @@ CREATE TABLE `verification` (
87
87
  );
88
88
  --> statement-breakpoint
89
89
  CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);--> statement-breakpoint
90
+ CREATE TABLE `audit_log` (
91
+ `id` text PRIMARY KEY NOT NULL,
92
+ `action` text NOT NULL,
93
+ `resource` text NOT NULL,
94
+ `record_id` text,
95
+ `record_label` text,
96
+ `user_id` text,
97
+ `user_email` text,
98
+ `changes` text,
99
+ `ip` text,
100
+ `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
101
+ );
102
+ --> statement-breakpoint
103
+ CREATE INDEX `audit_log_created_at_idx` ON `audit_log` (`created_at`);--> statement-breakpoint
104
+ CREATE INDEX `audit_log_resource_idx` ON `audit_log` (`resource`);--> statement-breakpoint
90
105
  CREATE TABLE `role` (
91
106
  `name` text PRIMARY KEY NOT NULL,
92
107
  `label` text NOT NULL,
93
108
  `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
94
109
  );
95
- --> statement-breakpoint
96
- -- Built-in roles: lib/admin.ts lets both into /admin. Add more with `flare role:add`.
97
- INSERT INTO `role` (`name`, `label`) VALUES ('admin', 'Admin'), ('staff', 'Staff');