@flaredev/cli 0.2.0 → 0.4.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 (88) hide show
  1. package/bin/flare.js +9 -1
  2. package/dist/index.js +991 -344
  3. package/package.json +2 -2
  4. package/templates/app/app/admin/page.tsx +9 -61
  5. package/templates/app/app/dashboard/account/layout.tsx +30 -0
  6. package/templates/app/app/dashboard/account/page.tsx +11 -31
  7. package/templates/app/app/dashboard/account/password/page.tsx +22 -0
  8. package/templates/app/app/dashboard/account/security/page.tsx +34 -0
  9. package/templates/app/app/dashboard/account/sessions/page.tsx +9 -0
  10. package/templates/app/app/dashboard/actions.ts +246 -0
  11. package/templates/app/app/dashboard/import-actions.ts +91 -0
  12. package/templates/app/app/dashboard/layout.tsx +66 -39
  13. package/templates/app/app/dashboard/observability/page.tsx +175 -0
  14. package/templates/app/app/dashboard/page.tsx +108 -27
  15. package/templates/app/app/verify-email/page.tsx +33 -0
  16. package/templates/app/components/account/account-nav.tsx +39 -0
  17. package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -525
  18. package/templates/app/components/auth/otp-input.tsx +95 -0
  19. package/templates/app/components/auth/password-field.tsx +116 -0
  20. package/templates/app/components/auth/password-forms.tsx +76 -79
  21. package/templates/app/components/auth/sign-in-flow.tsx +306 -315
  22. package/templates/app/components/auth/sign-up-form.tsx +93 -101
  23. package/templates/app/components/auth/two-factor-form.tsx +139 -124
  24. package/templates/app/components/auth/use-resend.ts +44 -0
  25. package/templates/app/components/auth/verify-email-form.tsx +135 -0
  26. package/templates/app/components/dashboard/column-menu.tsx +66 -0
  27. package/templates/app/components/dashboard/dashboard-header.tsx +115 -0
  28. package/templates/app/components/dashboard/dashboard-sidebar.tsx +228 -0
  29. package/templates/app/components/dashboard/dropzone.tsx +92 -0
  30. package/templates/app/components/dashboard/editable.ts +12 -0
  31. package/templates/app/components/dashboard/export-button.tsx +41 -0
  32. package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +22 -18
  33. package/templates/app/components/dashboard/import-dialog.tsx +540 -0
  34. package/templates/app/components/dashboard/inline-cell.tsx +165 -0
  35. package/templates/app/components/dashboard/local-time.tsx +77 -0
  36. package/templates/app/components/dashboard/page-header.tsx +57 -0
  37. package/templates/app/components/{admin → dashboard}/query.ts +2 -0
  38. package/templates/app/components/dashboard/record-actions.tsx +113 -0
  39. package/templates/app/components/dashboard/record-detail.tsx +200 -0
  40. package/templates/app/components/dashboard/resource-chart.tsx +91 -0
  41. package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
  42. package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
  43. package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
  44. package/templates/app/components/dashboard/resource-stats.tsx +48 -0
  45. package/templates/app/components/{admin → dashboard}/resource-table.tsx +152 -28
  46. package/templates/app/components/{admin → dashboard}/row-actions.tsx +56 -11
  47. package/templates/app/components/dashboard/save-view-button.tsx +86 -0
  48. package/templates/app/components/dashboard/stat-card.tsx +69 -0
  49. package/templates/app/components/dashboard/table-selection.tsx +185 -0
  50. package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
  51. package/templates/app/components/dashboard/user-menu.tsx +91 -0
  52. package/templates/app/components/ui/sonner.tsx +33 -13
  53. package/templates/app/db/flare-schema.ts +55 -1
  54. package/templates/app/lib/analytics.ts +121 -0
  55. package/templates/app/lib/api.ts +12 -0
  56. package/templates/app/lib/audit.ts +80 -0
  57. package/templates/app/lib/auth-config.ts +38 -33
  58. package/templates/app/lib/auth.ts +34 -0
  59. package/templates/app/lib/cache.ts +83 -83
  60. package/templates/app/lib/csv.ts +24 -0
  61. package/templates/app/lib/dashboard-nav.ts +15 -0
  62. package/templates/app/lib/dashboard.ts +165 -0
  63. package/templates/app/lib/password-rules.ts +159 -0
  64. package/templates/app/lib/site.ts +56 -47
  65. package/templates/app/lib/views.ts +29 -0
  66. package/templates/app/migrations/0000_init.sql +25 -2
  67. package/templates/app/migrations/meta/0000_snapshot.json +159 -1
  68. package/templates/app/migrations/meta/_journal.json +1 -1
  69. package/templates/app/vite.config.ts +41 -31
  70. package/templates/app/worker-configuration.d.ts +15764 -0
  71. package/templates/locks/package-lock.json +9189 -0
  72. package/templates/locks/pnpm-lock.yaml +6347 -0
  73. package/templates/locks/yarn.lock +3548 -0
  74. package/templates/app/app/admin/actions.ts +0 -113
  75. package/templates/app/app/admin/layout.tsx +0 -32
  76. package/templates/app/components/admin/admin-header.tsx +0 -78
  77. package/templates/app/components/admin/resource-nav.tsx +0 -120
  78. package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
  79. package/templates/app/lib/admin-nav.ts +0 -15
  80. package/templates/app/lib/admin.ts +0 -103
  81. /package/templates/app/components/{admin → dashboard}/fields/country-field.tsx +0 -0
  82. /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
  83. /package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +0 -0
  84. /package/templates/app/components/{admin → dashboard}/fields/multi-select-field.tsx +0 -0
  85. /package/templates/app/components/{admin → dashboard}/fields/phone-field.tsx +0 -0
  86. /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
  87. /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
  88. /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
@@ -1,33 +1,38 @@
1
- /**
2
- * How people sign in to this app. `flare create` wrote the choices you made; change
3
- * them here any time. Every method's tables already exist, so switching one on or off
4
- * needs no migration, and a method that's off is refused by the API, not just hidden.
5
- *
6
- * Email and password sign-in is always available.
7
- */
8
- export const authConfig = {
9
- /** A sign-in link by email. */
10
- magicLink: true,
11
- /** A 6-digit sign-in code by email. */
12
- emailOtp: true,
13
- /** Face ID, Touch ID, Windows Hello or a security key. */
14
- passkeys: true,
15
- twoFactor: {
16
- /** Codes from an authenticator app (TOTP), with backup codes. */
17
- authenticator: true,
18
- /** Codes by email as the second step. */
19
- email: true,
20
- },
21
- /**
22
- * Social sign-in. Each provider also needs its credentials (see .dev.vars.example);
23
- * without them its button stays hidden.
24
- */
25
- social: [] as SocialProvider[],
26
- /** Refuse password sign-in until the email address is verified. */
27
- requireEmailVerification: false,
28
- };
29
-
30
- export type SocialProvider = "google" | "github" | "apple" | "microsoft";
31
-
32
- /** Whether any second factor can be set up. */
33
- export const twoFactorAvailable = authConfig.twoFactor.authenticator || authConfig.twoFactor.email;
1
+ /**
2
+ * How people sign in to this app. `flare create` wrote the choices you made; change
3
+ * them here any time. Every method's tables already exist, so switching one on or off
4
+ * needs no migration, and a method that's off is refused by the API, not just hidden.
5
+ *
6
+ * Email and password sign-in is always available.
7
+ */
8
+ export const authConfig = {
9
+ /** A sign-in link by email. */
10
+ magicLink: true,
11
+ /** A 6-digit sign-in code by email. */
12
+ emailOtp: true,
13
+ /** Face ID, Touch ID, Windows Hello or a security key. */
14
+ passkeys: true,
15
+ twoFactor: {
16
+ /** Codes from an authenticator app (TOTP), with backup codes. */
17
+ authenticator: true,
18
+ /** Codes by email as the second step. */
19
+ email: true,
20
+ },
21
+ /**
22
+ * Social sign-in. Each provider also needs its credentials (see .dev.vars.example);
23
+ * without them its button stays hidden.
24
+ */
25
+ social: [] as SocialProvider[],
26
+ /** Refuse password sign-in until the email address is verified. */
27
+ requireEmailVerification: false,
28
+ /**
29
+ * Check new passwords against Have I Been Pwned's breach list. Only the first five
30
+ * characters of the password's hash are sent, and an outage lets the password through.
31
+ */
32
+ checkBreachedPasswords: true,
33
+ };
34
+
35
+ export type SocialProvider = "google" | "github" | "apple" | "microsoft";
36
+
37
+ /** Whether any second factor can be set up. */
38
+ export const twoFactorAvailable = authConfig.twoFactor.authenticator || authConfig.twoFactor.email;
@@ -11,6 +11,7 @@ import { twoFactor } from "better-auth/plugins/two-factor";
11
11
  import { hashPassword, verifyPassword } from "@flaredev/core";
12
12
  import { getDb, schema } from "@/db";
13
13
  import { authConfig, type SocialProvider } from "./auth-config";
14
+ import { checkPassword, isBreachedPassword, PASSWORD_MAX_LENGTH } from "./password-rules";
14
15
  import { sendEmailCode, sendMagicLink, sendPasswordReset, sendTwoFactorCode, sendVerificationEmail } from "./auth-emails";
15
16
 
16
17
  const vars = env as unknown as Record<string, string | undefined>;
@@ -51,6 +52,38 @@ function disabledPath(path: string): boolean {
51
52
  return false;
52
53
  }
53
54
 
55
+ /** Where a new password arrives, and what it's called in the body. */
56
+ const NEW_PASSWORD_FIELDS: Record<string, string> = {
57
+ "/sign-up/email": "password",
58
+ "/reset-password": "newPassword",
59
+ "/change-password": "newPassword",
60
+ };
61
+
62
+ /**
63
+ * The same password rules the sign-up and reset forms show as you type
64
+ * (lib/password-rules.ts), enforced here so the API agrees with the screens.
65
+ * Checked on the way in, before the password is hashed or stored.
66
+ */
67
+ async function guardPassword(path: string, body: Record<string, unknown> | undefined) {
68
+ const field = NEW_PASSWORD_FIELDS[path];
69
+ if (!field) return;
70
+ const password = body?.[field];
71
+ if (typeof password !== "string") return;
72
+
73
+ if (password.length > PASSWORD_MAX_LENGTH) {
74
+ throw new APIError("BAD_REQUEST", { message: `Passwords can be at most ${PASSWORD_MAX_LENGTH} characters.` });
75
+ }
76
+ const check = checkPassword(password, [body?.email as string | undefined, body?.name as string | undefined]);
77
+ if (!check.valid) throw new APIError("BAD_REQUEST", { message: check.advice ?? "That password is too short." });
78
+
79
+ // A password in a public breach list is already guessed, however strong it looks.
80
+ if (authConfig.checkBreachedPasswords && (await isBreachedPassword(password))) {
81
+ throw new APIError("BAD_REQUEST", {
82
+ message: "That password has appeared in a data breach, so it isn't safe to use here. Pick a different one.",
83
+ });
84
+ }
85
+ }
86
+
54
87
  export const auth = betterAuth({
55
88
  appName: "__APP_NAME__",
56
89
  secret: env.BETTER_AUTH_SECRET,
@@ -90,6 +123,7 @@ export const auth = betterAuth({
90
123
  hooks: {
91
124
  before: createAuthMiddleware(async (ctx) => {
92
125
  if (disabledPath(ctx.path)) throw new APIError("NOT_FOUND", { message: "This sign-in method isn't enabled." });
126
+ await guardPassword(ctx.path, ctx.body as Record<string, unknown> | undefined);
93
127
  }),
94
128
  },
95
129
  plugins: [
@@ -1,83 +1,83 @@
1
- import { revalidateTag, unstable_cache } from "next/cache";
2
- import type { ChangeEvent } from "@flaredev/core/server";
3
-
4
- /**
5
- * Caching for data you read far more often than you write.
6
- *
7
- * Only the **data cache** (Workers KV) is configured in `vite.config.ts`. It is
8
- * what `cached()` below, `fetch()` and `"use cache"` write to. Shared by every
9
- * colo and every isolate, so a value computed once is reused until it expires or
10
- * its tag is revalidated.
11
- *
12
- * vinext's page-level CDN cache (the Workers Cache `cdn` adapter) is off until a
13
- * vinext fix lands: in the current beta, pages that redirect break behind it.
14
- *
15
- * Nothing here caches per-user data: everything keyed by session belongs
16
- * outside the cache, since these entries are shared between visitors.
17
- */
18
-
19
- /** How long cached values stay fresh, in seconds. Pick the one that matches how stale you can stand. */
20
- export const TTL = {
21
- /** Changes often, cheap to recompute: counts, dashboards. */
22
- short: 60,
23
- /** The usual case: lists and detail views behind a resource tag. */
24
- medium: 5 * 60,
25
- /** Rarely changes: settings, published content, reference tables. */
26
- long: 60 * 60,
27
- } as const;
28
-
29
- /** Tag for everything derived from a resource, e.g. `resource/Deal`. */
30
- export const resourceTag = (resource: string) => `resource/${resource}`;
31
-
32
- /** Tag for one record, e.g. `resource/Deal/0f9…`. */
33
- export const recordTag = (resource: string, id: string) => `resource/${resource}/${id}`;
34
-
35
- /**
36
- * The KV data cache silently ignores tags containing `:`, `\` or control
37
- * characters — the entry is stored untagged, so no revalidation ever reaches
38
- * it. Refuse them here instead, where the mistake is still visible.
39
- */
40
- function checkTag(tag: string): string {
41
- if (!tag || tag.length > 256 || /[\x00-\x1f\\:]/.test(tag)) {
42
- throw new Error(`Cache tag "${tag}" can't be used: tags must be 1–256 characters without ":", "\\" or control characters.`);
43
- }
44
- return tag;
45
- }
46
-
47
- /**
48
- * Cache the result of an async function in the data cache.
49
- *
50
- * `keyParts` identify the value (include every argument the result depends on);
51
- * `tags` say what invalidates it. Cached functions must not read cookies,
52
- * headers, or anything else request-specific — the value is shared.
53
- *
54
- * ```ts
55
- * const openDeals = cached(
56
- * () => getDb().select({ total: count() }).from(deals).where(eq(deals.stage, "open")),
57
- * ["deals", "open-count"],
58
- * { tags: [resourceTag("Deal")] },
59
- * );
60
- * ```
61
- */
62
- export function cached<Args extends unknown[], Value>(
63
- fn: (...args: Args) => Promise<Value>,
64
- keyParts: string[],
65
- options: { tags?: string[]; revalidate?: number } = {},
66
- ) {
67
- return unstable_cache(fn, keyParts, {
68
- revalidate: options.revalidate ?? TTL.medium,
69
- tags: (options.tags ?? []).map(checkTag),
70
- });
71
- }
72
-
73
- /**
74
- * Drop everything cached for a resource (and for the record that changed).
75
- * `lib/admin.ts` and `lib/api.ts` pass this to the store, so writes through the
76
- * admin or the REST API invalidate without anyone remembering to call it.
77
- */
78
- export function revalidateResource({ resource, action, id }: ChangeEvent) {
79
- // { expire: 0 }: drop the entry now rather than serving it stale while it refreshes.
80
- // A record someone just edited should never come back with its old values.
81
- revalidateTag(resourceTag(resource), { expire: 0 });
82
- if (action !== "create") revalidateTag(recordTag(resource, id), { expire: 0 });
83
- }
1
+ import { revalidateTag, unstable_cache } from "next/cache";
2
+ import type { ChangeEvent } from "@flaredev/core/server";
3
+
4
+ /**
5
+ * Caching for data you read far more often than you write.
6
+ *
7
+ * Only the **data cache** (Workers KV) is configured in `vite.config.ts`. It is
8
+ * what `cached()` below, `fetch()` and `"use cache"` write to. Shared by every
9
+ * colo and every isolate, so a value computed once is reused until it expires or
10
+ * its tag is revalidated.
11
+ *
12
+ * vinext's page-level CDN cache (the Workers Cache `cdn` adapter) is off until a
13
+ * vinext fix lands: in the current beta, pages that redirect break behind it.
14
+ *
15
+ * Nothing here caches per-user data: everything keyed by session belongs
16
+ * outside the cache, since these entries are shared between visitors.
17
+ */
18
+
19
+ /** How long cached values stay fresh, in seconds. Pick the one that matches how stale you can stand. */
20
+ export const TTL = {
21
+ /** Changes often, cheap to recompute: counts, dashboards. */
22
+ short: 60,
23
+ /** The usual case: lists and detail views behind a resource tag. */
24
+ medium: 5 * 60,
25
+ /** Rarely changes: settings, published content, reference tables. */
26
+ long: 60 * 60,
27
+ } as const;
28
+
29
+ /** Tag for everything derived from a resource, e.g. `resource/Deal`. */
30
+ export const resourceTag = (resource: string) => `resource/${resource}`;
31
+
32
+ /** Tag for one record, e.g. `resource/Deal/0f9…`. */
33
+ export const recordTag = (resource: string, id: string) => `resource/${resource}/${id}`;
34
+
35
+ /**
36
+ * The KV data cache silently ignores tags containing `:`, `\` or control
37
+ * characters — the entry is stored untagged, so no revalidation ever reaches
38
+ * it. Refuse them here instead, where the mistake is still visible.
39
+ */
40
+ function checkTag(tag: string): string {
41
+ if (!tag || tag.length > 256 || /[\x00-\x1f\\:]/.test(tag)) {
42
+ throw new Error(`Cache tag "${tag}" can't be used: tags must be 1–256 characters without ":", "\\" or control characters.`);
43
+ }
44
+ return tag;
45
+ }
46
+
47
+ /**
48
+ * Cache the result of an async function in the data cache.
49
+ *
50
+ * `keyParts` identify the value (include every argument the result depends on);
51
+ * `tags` say what invalidates it. Cached functions must not read cookies,
52
+ * headers, or anything else request-specific — the value is shared.
53
+ *
54
+ * ```ts
55
+ * const openDeals = cached(
56
+ * () => getDb().select({ total: count() }).from(deals).where(eq(deals.stage, "open")),
57
+ * ["deals", "open-count"],
58
+ * { tags: [resourceTag("Deal")] },
59
+ * );
60
+ * ```
61
+ */
62
+ export function cached<Args extends unknown[], Value>(
63
+ fn: (...args: Args) => Promise<Value>,
64
+ keyParts: string[],
65
+ options: { tags?: string[]; revalidate?: number } = {},
66
+ ) {
67
+ return unstable_cache(fn, keyParts, {
68
+ revalidate: options.revalidate ?? TTL.medium,
69
+ tags: (options.tags ?? []).map(checkTag),
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Drop everything cached for a resource (and for the record that changed).
75
+ * `lib/dashboard.ts` and `lib/api.ts` pass this to the store, so writes through the
76
+ * admin or the REST API invalidate without anyone remembering to call it.
77
+ */
78
+ export function revalidateResource({ resource, action, id }: ChangeEvent) {
79
+ // { expire: 0 }: drop the entry now rather than serving it stale while it refreshes.
80
+ // A record someone just edited should never come back with its old values.
81
+ revalidateTag(resourceTag(resource), { expire: 0 });
82
+ if (action !== "create") revalidateTag(recordTag(resource, id), { expire: 0 });
83
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * CSV, the way spreadsheets expect it (RFC 4180): fields holding a comma, a quote or a
3
+ * line break are quoted, and quotes inside them are doubled.
4
+ *
5
+ * Shared by the table's "export selected" (in the browser) and the export action (on
6
+ * the server), so both produce the same file.
7
+ */
8
+
9
+ export interface CsvColumn {
10
+ key: string;
11
+ label: string;
12
+ }
13
+
14
+ export function csvCell(value: unknown): string {
15
+ if (value === null || value === undefined) return "";
16
+ const text = Array.isArray(value) ? value.join("; ") : value instanceof Date ? value.toISOString() : String(value);
17
+ return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
18
+ }
19
+
20
+ export function toCsv(rows: Record<string, unknown>[], columns: CsvColumn[]): string {
21
+ const header = columns.map((column) => csvCell(column.label)).join(",");
22
+ const body = rows.map((row) => columns.map((column) => csvCell(row[column.key])).join(","));
23
+ return [header, ...body].join("\r\n");
24
+ }
@@ -0,0 +1,15 @@
1
+ // Extra dashboard sidebar links (maintained by flare generators such as `flare gen security`).
2
+ // Add your own links outside the generated block.
3
+ // generated:start
4
+ export interface DashboardLink {
5
+ label: string;
6
+ href: string;
7
+ /** A resource icon name, e.g. "shield" (see components/dashboard/resource-icon.tsx). */
8
+ icon: string;
9
+ }
10
+
11
+ export const generatedDashboardLinks: DashboardLink[] = [];
12
+ // generated:end
13
+
14
+ /** Links shown under "Platform" in the dashboard sidebar. */
15
+ export const dashboardLinks: DashboardLink[] = [...generatedDashboardLinks, { label: "API reference", href: "/api/reference", icon: "book" }];
@@ -0,0 +1,165 @@
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 { currentUser } from "./api";
11
+ import { auth } from "./auth";
12
+ import { cached, resourceTag, revalidateResource, TTL } from "./cache";
13
+
14
+ /**
15
+ * Roles that always reach the dashboard, whatever the policies say. Other roles get in when a
16
+ * policy grants them read access to at least one resource.
17
+ * Register roles with `flare role:add`, assign with `flare user:role`.
18
+ */
19
+ export const ADMIN_ROLES = ["admin", "staff"];
20
+
21
+ export function policyFor(resourceName: string): Policy | undefined {
22
+ return (policies as Record<string, Policy | undefined>)[resourceName];
23
+ }
24
+
25
+ export async function dashboardSession() {
26
+ const session = await auth.api.getSession({ headers: await headers() });
27
+ if (!session) return { session: null, role: null, allowed: false } as const;
28
+ const role = (session.user as { role?: string | null }).role ?? null;
29
+ const readable = allResources().some((resource) => can(policyFor(resource.name), role, "read"));
30
+ return { session, role, allowed: (role !== null && ADMIN_ROLES.includes(role)) || readable } as const;
31
+ }
32
+
33
+ /** For admin pages: redirects signed-out visitors to sign-in and users without access to the home page. */
34
+ export async function requireDashboard(returnTo = "/dashboard") {
35
+ const { session, role, allowed } = await dashboardSession();
36
+ if (!session) redirect(`/sign-in?next=${encodeURIComponent(returnTo)}`);
37
+ if (!allowed) redirect("/?error=forbidden");
38
+ return { session, role };
39
+ }
40
+
41
+ /**
42
+ * For resource pages: also requires the policy to allow `action` on this resource.
43
+ * Sends the user somewhere they can actually go — the list when they may read it,
44
+ * the dashboard when they may not.
45
+ */
46
+ export async function requireAccess(resource: Resource, action: PolicyAction) {
47
+ const { session, role } = await requireDashboard(resourcePath(resource));
48
+ const policy = policyFor(resource.name);
49
+ if (!can(policy, role, action)) {
50
+ redirect(action !== "read" && can(policy, role, "read") ? resourcePath(resource) : "/dashboard?error=forbidden");
51
+ }
52
+ return { session, role };
53
+ }
54
+
55
+ /** What the current user may do with a resource, for hiding actions they can't use. */
56
+ export async function adminPermissions(resourceName: string) {
57
+ const { role } = await dashboardSession();
58
+ return allowedActions(policyFor(resourceName), role);
59
+ }
60
+
61
+ const stores = new Map<string, ResourceStore>();
62
+
63
+ /** CRUD store for a resource by name (404 for unknown names). */
64
+ export function dashboardStore(name: string): ResourceStore {
65
+ const entry = (resourceTables as unknown as Record<string, { resource: Resource; table: SQLiteTable } | undefined>)[name];
66
+ if (!entry) notFound();
67
+ let store = stores.get(name);
68
+ if (!store) {
69
+ store = createResourceStore({ resource: entry.resource, table: entry.table, getDb, onChange: revalidateResource, currentUser });
70
+ stores.set(name, store);
71
+ }
72
+ return store;
73
+ }
74
+
75
+ /**
76
+ * How many records a resource has. Cached in the data cache under the resource's tag,
77
+ * so the dashboard does not count every table on every visit, and a write through the
78
+ * admin or the API drops the count on its way out.
79
+ */
80
+ export function recordCount(name: string): Promise<number> {
81
+ const counted = cached(
82
+ async (resourceName: string) => {
83
+ // Counted straight off the table, not through the store: a list stops counting at
84
+ // 10,000 so that paging stays quick, and a stat wants the real number.
85
+ const entry = (resourceTables as unknown as Record<string, { table: SQLiteTable } | undefined>)[resourceName];
86
+ if (!entry) return 0;
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);
94
+ }
95
+
96
+ /**
97
+ * How many records hold each value of a field, in one grouped query rather than one
98
+ * count per option. Used by the stats above a resource's table.
99
+ */
100
+ export function valueCounts(name: string, field: string): Promise<Record<string, number>> {
101
+ const counted = cached(
102
+ async (resourceName: string, key: string) => {
103
+ const entry = (resourceTables as unknown as Record<string, { table: SQLiteTable } | undefined>)[resourceName];
104
+ const column = entry ? (entry.table as unknown as Record<string, unknown>)[key] : undefined;
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
+ }
117
+
118
+ /**
119
+ * How many records a resource gained in the last `days` days, and in the `days` before
120
+ * that, so a stat can show which way it's going. Cached like the totals.
121
+ */
122
+ export function recentCounts(name: string, days = 7): Promise<{ current: number; previous: number }> {
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 };
129
+ const window = windowDays * 86_400_000;
130
+ const since = new Date(Date.now() - window);
131
+ const before = new Date(Date.now() - window * 2);
132
+ const column = createdAt as never;
133
+ const [current] = await getDb().select({ total: count() }).from(entry.table).where(gte(column, since));
134
+ const [previous] = await getDb()
135
+ .select({ total: count() })
136
+ .from(entry.table)
137
+ .where(and(gte(column, before), lt(column, since)));
138
+ return { current: current?.total ?? 0, previous: previous?.total ?? 0 };
139
+ },
140
+ ["flare", "recent-count"],
141
+ { tags: [resourceTag(name)], revalidate: TTL.short },
142
+ );
143
+ return counts(name, days);
144
+ }
145
+
146
+ /** Percentage change between two periods, or undefined when there's nothing to compare. */
147
+ export function trend(current: number, previous: number): number | undefined {
148
+ if (previous === 0) return current === 0 ? undefined : 100;
149
+ return ((current - previous) / previous) * 100;
150
+ }
151
+
152
+ /** Every resource descriptor (unfiltered): navigation uses `visibleResources()` instead. */
153
+ export function allResources(): Resource[] {
154
+ return Object.values(resourceTables as unknown as Record<string, { resource: Resource }>).map((entry) => entry.resource);
155
+ }
156
+
157
+ /** Resources the current user may read, for the sidebar and dashboard. */
158
+ export async function visibleResources(): Promise<Resource[]> {
159
+ const { role } = await dashboardSession();
160
+ return allResources().filter((resource) => can(policyFor(resource.name), role, "read"));
161
+ }
162
+
163
+ export function resourcePath(resource: Resource, ...parts: string[]) {
164
+ return ["/dashboard", resource.slug, ...parts].join("/");
165
+ }
@@ -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
+ }