@flaredev/cli 0.12.2 → 0.14.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 (61) hide show
  1. package/dist/index.js +126 -57
  2. package/package.json +2 -2
  3. package/templates/app/README.md +1 -1
  4. package/templates/app/app/dashboard/error.tsx +18 -5
  5. package/templates/app/app/dashboard/layout.tsx +2 -2
  6. package/templates/app/app/dashboard/page.tsx +101 -26
  7. package/templates/app/app/dashboard/roles/actions.ts +57 -0
  8. package/templates/app/app/dashboard/roles/page.tsx +56 -0
  9. package/templates/app/app/dashboard/users/actions.ts +86 -0
  10. package/templates/app/app/dashboard/users/page.tsx +54 -0
  11. package/templates/app/app/error.tsx +14 -5
  12. package/templates/app/components/account/sections.tsx +23 -3
  13. package/templates/app/components/auth/password-field.tsx +51 -32
  14. package/templates/app/components/auth/sign-in-flow.tsx +306 -306
  15. package/templates/app/components/dashboard/bulk-add-sheet.tsx +377 -0
  16. package/templates/app/components/dashboard/cell-value.ts +67 -0
  17. package/templates/app/components/dashboard/dashboard-header.tsx +2 -2
  18. package/templates/app/components/dashboard/dashboard-sidebar.tsx +14 -10
  19. package/templates/app/components/dashboard/fields/field-widget.tsx +20 -16
  20. package/templates/app/components/dashboard/fields/file-field.tsx +23 -1
  21. package/templates/app/components/dashboard/import-dialog.tsx +1 -58
  22. package/templates/app/components/dashboard/resource-table.tsx +2 -0
  23. package/templates/app/components/dashboard/role-matrix.tsx +268 -0
  24. package/templates/app/components/dashboard/user-actions.tsx +217 -0
  25. package/templates/app/components/dashboard/user-menu.tsx +17 -23
  26. package/templates/app/components/dashboard/users-table.tsx +109 -0
  27. package/templates/app/components/ui/combobox.tsx +133 -0
  28. package/templates/app/db/flare-schema.ts +27 -1
  29. package/templates/app/lib/api-docs.ts +1 -1
  30. package/templates/app/lib/api.ts +4 -1
  31. package/templates/app/lib/auth.ts +3 -3
  32. package/templates/app/lib/dashboard.ts +20 -5
  33. package/templates/app/lib/mail.ts +2 -2
  34. package/templates/app/lib/permissions.ts +105 -0
  35. package/templates/app/lib/site.ts +2 -2
  36. package/templates/app/lib/stale-chunk.ts +50 -0
  37. package/templates/app/migrations/0000_init.sql +9 -0
  38. package/templates/app/migrations/meta/0000_snapshot.json +57 -1
  39. package/templates/app/migrations/meta/_journal.json +1 -1
  40. package/templates/app/vite.config.ts +3 -1
  41. package/templates/locks/cloudflare/pnpm-lock.yaml +8 -8
  42. package/templates/locks/next/pnpm-lock.yaml +4 -4
  43. package/templates/next/README.md +1 -1
  44. package/templates/next/app/dashboard/layout.tsx +2 -2
  45. package/templates/next/app/dashboard/observability/page.tsx +156 -0
  46. package/templates/next/app/dashboard/page.tsx +87 -23
  47. package/templates/next/app/dashboard/users/page.tsx +51 -0
  48. package/templates/next/lib/auth.ts +3 -3
  49. package/templates/next/lib/dashboard-nav.ts +29 -0
  50. package/templates/next/lib/dashboard.ts +20 -5
  51. package/templates/next/lib/mail.ts +2 -2
  52. package/templates/next/lib/permissions.ts +100 -0
  53. package/templates/next/prisma/schema/base.prisma +37 -0
  54. package/templates/next/tsconfig.json +2 -2
  55. /package/templates/app/app/{forgot-password → (auth)/forgot-password}/page.tsx +0 -0
  56. /package/templates/app/app/{invite → (auth)/invite}/[token]/route.ts +0 -0
  57. /package/templates/app/app/{reset-password → (auth)/reset-password}/page.tsx +0 -0
  58. /package/templates/app/app/{sign-in → (auth)/sign-in}/page.tsx +0 -0
  59. /package/templates/app/app/{sign-up → (auth)/sign-up}/page.tsx +0 -0
  60. /package/templates/app/app/{two-factor → (auth)/two-factor}/page.tsx +0 -0
  61. /package/templates/app/app/{verify-email → (auth)/verify-email}/page.tsx +0 -0
@@ -0,0 +1,109 @@
1
+ import { CheckCircle2Icon, MinusIcon } from "lucide-react";
2
+ import { Badge } from "@/components/ui/badge";
3
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
4
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
5
+ import { LocalTime } from "./local-time";
6
+ import { UserAvatar } from "./user-menu";
7
+ import { UserRowMenu } from "./user-actions";
8
+
9
+ /**
10
+ * The accounts table, shared by both stacks: the page above it only decides where the
11
+ * rows come from (Prisma on Next.js, Drizzle on Cloudflare).
12
+ */
13
+ export interface AccountRow {
14
+ id: string;
15
+ name: string;
16
+ email: string;
17
+ image?: string | null;
18
+ /** The admin plugin's default role is "user"; null means the same. */
19
+ role?: string | null;
20
+ banned?: boolean | null;
21
+ emailVerified: boolean;
22
+ twoFactorEnabled?: boolean | null;
23
+ createdAt: Date;
24
+ }
25
+
26
+ /** Roles that stand out: everything else is the ordinary "user" and stays quiet. */
27
+ function roleTone(role: string): "default" | "secondary" {
28
+ return role === "admin" ? "default" : "secondary";
29
+ }
30
+
31
+ export function UsersTable({ rows, total, currentUserId }: { rows: AccountRow[]; total: number; currentUserId: string }) {
32
+ return (
33
+ <Card>
34
+ <CardHeader>
35
+ <CardTitle className="text-base">Accounts</CardTitle>
36
+ <CardDescription>
37
+ {total.toLocaleString()} {total === 1 ? "account" : "accounts"}
38
+ {total > rows.length ? `, newest ${rows.length} shown` : ""}.
39
+ </CardDescription>
40
+ </CardHeader>
41
+ <CardContent>
42
+ <div className="overflow-x-auto">
43
+ <Table>
44
+ <TableHeader>
45
+ <TableRow>
46
+ <TableHead>Who</TableHead>
47
+ <TableHead>Role</TableHead>
48
+ <TableHead>Verified</TableHead>
49
+ <TableHead>Two-factor</TableHead>
50
+ <TableHead className="text-right">Joined</TableHead>
51
+ <TableHead className="w-10" aria-label="Actions" />
52
+ </TableRow>
53
+ </TableHeader>
54
+ <TableBody>
55
+ {rows.map((account) => (
56
+ <TableRow key={account.id}>
57
+ <TableCell>
58
+ <div className="flex items-center gap-3">
59
+ <UserAvatar user={account} className="size-8 rounded-md" />
60
+ <div className="grid min-w-0 leading-tight">
61
+ <span className="truncate text-sm font-medium">{account.name || "—"}</span>
62
+ <span className="truncate text-xs text-muted-foreground">{account.email}</span>
63
+ </div>
64
+ </div>
65
+ </TableCell>
66
+ <TableCell>
67
+ {account.banned ? (
68
+ <Badge variant="destructive">banned</Badge>
69
+ ) : (
70
+ <Badge variant={roleTone(account.role ?? "user")}>{account.role ?? "user"}</Badge>
71
+ )}
72
+ </TableCell>
73
+ <TableCell>
74
+ {account.emailVerified ? (
75
+ <CheckCircle2Icon aria-label="Email verified" className="size-4.5 text-success" />
76
+ ) : (
77
+ <MinusIcon aria-label="Not verified" className="size-4.5 text-muted-foreground" />
78
+ )}
79
+ </TableCell>
80
+ <TableCell>
81
+ {account.twoFactorEnabled ? (
82
+ <CheckCircle2Icon aria-label="Two-factor on" className="size-4.5 text-success" />
83
+ ) : (
84
+ <MinusIcon aria-label="Two-factor off" className="size-4.5 text-muted-foreground" />
85
+ )}
86
+ </TableCell>
87
+ <TableCell className="text-right whitespace-nowrap text-muted-foreground tabular-nums">
88
+ <LocalTime value={account.createdAt} />
89
+ </TableCell>
90
+ <TableCell>
91
+ {/* Not on your own row: demoting or banning yourself is a lockout, not a feature. */}
92
+ {account.id !== currentUserId && (
93
+ <UserRowMenu userId={account.id} role={account.role ?? "user"} banned={Boolean(account.banned)} />
94
+ )}
95
+ </TableCell>
96
+ </TableRow>
97
+ ))}
98
+ </TableBody>
99
+ </Table>
100
+ </div>
101
+ <p className="mt-4 text-xs text-muted-foreground">
102
+ A role is a name your <a className="underline underline-offset-2" href="https://flare-docs.codetotech.com/guides/roles-and-policies/" target="_blank" rel="noreferrer">policies</a> give
103
+ meaning: <code>npx flare gen policy Contact --roles admin,staff</code> decides what each role may do. The terminal does all
104
+ of this too — <code>npx flare user:add</code>, <code>npx flare user:role</code> — and writes the same rows.
105
+ </p>
106
+ </CardContent>
107
+ </Card>
108
+ );
109
+ }
@@ -0,0 +1,133 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+ import { CheckIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
7
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
8
+ import { cn } from "@/lib/utils";
9
+
10
+ export interface ComboboxOption {
11
+ value: string;
12
+ label: string;
13
+ /** A second line, for options whose label is not enough on its own. */
14
+ hint?: string;
15
+ }
16
+
17
+ /**
18
+ * A select you can type into.
19
+ *
20
+ * A plain `<select>` is fine for three options and miserable for thirty: you cannot
21
+ * search it, and on a touch device it becomes a native wheel. This is the pattern the
22
+ * relation field already used — a popover over a command list — made general, so every
23
+ * place in the app that chooses one thing from a list behaves the same way.
24
+ *
25
+ * `allowCustom` lets the typed text be the answer. That is for values which are not a
26
+ * fixed set — a role is just a string your policies name, so the list is a convenience
27
+ * rather than a constraint, and the field must not stop you inventing one.
28
+ */
29
+ export function Combobox({
30
+ options,
31
+ value,
32
+ onChange,
33
+ id,
34
+ name,
35
+ placeholder = "Select…",
36
+ searchPlaceholder = "Search…",
37
+ emptyMessage = "Nothing found.",
38
+ allowCustom = false,
39
+ customLabel = (typed: string) => `Use "${typed}"`,
40
+ disabled,
41
+ invalid,
42
+ className,
43
+ }: {
44
+ options: ComboboxOption[];
45
+ value: string;
46
+ onChange: (value: string) => void;
47
+ id?: string;
48
+ /** Mirrors the value into a hidden input, so plain `<form>` submission sees it. */
49
+ name?: string;
50
+ placeholder?: string;
51
+ searchPlaceholder?: string;
52
+ emptyMessage?: string;
53
+ allowCustom?: boolean;
54
+ customLabel?: (typed: string) => string;
55
+ disabled?: boolean;
56
+ invalid?: boolean;
57
+ className?: string;
58
+ }) {
59
+ const [open, setOpen] = useState(false);
60
+ const [query, setQuery] = useState("");
61
+
62
+ const selected = options.find((option) => option.value === value);
63
+ // A custom value is shown as itself rather than falling back to the placeholder,
64
+ // which would make a perfectly good answer look like no answer.
65
+ const display = selected?.label ?? (value || null);
66
+
67
+ const typed = query.trim();
68
+ const canCreate = allowCustom && typed.length > 0 && !options.some((option) => option.value.toLowerCase() === typed.toLowerCase());
69
+
70
+ const choose = (next: string) => {
71
+ onChange(next);
72
+ setQuery("");
73
+ setOpen(false);
74
+ };
75
+
76
+ const filtered = useMemo(() => {
77
+ if (!typed) return options;
78
+ const needle = typed.toLowerCase();
79
+ return options.filter((option) => option.label.toLowerCase().includes(needle) || option.value.toLowerCase().includes(needle));
80
+ }, [options, typed]);
81
+
82
+ return (
83
+ <>
84
+ {name && <input type="hidden" name={name} value={value} />}
85
+ <Popover open={open} onOpenChange={setOpen}>
86
+ <PopoverTrigger asChild>
87
+ <Button
88
+ id={id}
89
+ type="button"
90
+ variant="outline"
91
+ role="combobox"
92
+ aria-expanded={open}
93
+ aria-invalid={invalid || undefined}
94
+ disabled={disabled}
95
+ className={cn("w-full justify-between font-normal", !display && "text-muted-foreground", className)}
96
+ >
97
+ <span className="truncate">{display ?? placeholder}</span>
98
+ <ChevronsUpDownIcon className="opacity-50" />
99
+ </Button>
100
+ </PopoverTrigger>
101
+ <PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
102
+ <Command shouldFilter={false}>
103
+ <CommandInput placeholder={searchPlaceholder} value={query} onValueChange={setQuery} />
104
+ <CommandList>
105
+ {filtered.length === 0 && !canCreate && <CommandEmpty>{emptyMessage}</CommandEmpty>}
106
+ {filtered.length > 0 && (
107
+ <CommandGroup>
108
+ {filtered.map((option) => (
109
+ <CommandItem key={option.value} value={option.value} onSelect={() => choose(option.value)}>
110
+ <span className="flex min-w-0 flex-col">
111
+ <span className="truncate">{option.label}</span>
112
+ {option.hint && <span className="truncate text-xs text-muted-foreground">{option.hint}</span>}
113
+ </span>
114
+ <CheckIcon className={cn("ml-auto", option.value === value ? "opacity-100" : "opacity-0")} />
115
+ </CommandItem>
116
+ ))}
117
+ </CommandGroup>
118
+ )}
119
+ {canCreate && (
120
+ <CommandGroup>
121
+ <CommandItem value={`__create__${typed}`} onSelect={() => choose(typed)}>
122
+ <PlusIcon />
123
+ {customLabel(typed)}
124
+ </CommandItem>
125
+ </CommandGroup>
126
+ )}
127
+ </CommandList>
128
+ </Command>
129
+ </PopoverContent>
130
+ </Popover>
131
+ </>
132
+ );
133
+ }
@@ -1,5 +1,5 @@
1
1
  import { sql } from "drizzle-orm";
2
- import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2
+ import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
3
3
 
4
4
  /**
5
5
  * Roles a user can hold (`user.role` from Better Auth's admin plugin).
@@ -14,6 +14,32 @@ export const role = sqliteTable("role", {
14
14
  .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`),
15
15
  });
16
16
 
17
+ /**
18
+ * Permissions granted from the dashboard, on top of the policies in your code.
19
+ *
20
+ * A row says "this role may do this to this resource". It can only ever *widen* what
21
+ * `policies/*.policy.ts` already allows — see `withGrants` in @flaredev/core. A
22
+ * resource with no policy file is open to every signed-in user, and no row here changes
23
+ * that; the roles screen says so rather than pretending otherwise.
24
+ */
25
+ export const rolePermission = sqliteTable(
26
+ "role_permission",
27
+ {
28
+ id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
29
+ /** The role's name, as in the `role` table and `user.role`. */
30
+ role: text("role").notNull(),
31
+ /** The resource's descriptor name, e.g. "Invoice". */
32
+ resource: text("resource").notNull(),
33
+ /** "read", "create", "update" or "delete". */
34
+ action: text("action").notNull(),
35
+ createdAt: integer("created_at", { mode: "timestamp_ms" })
36
+ .notNull()
37
+ .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`),
38
+ },
39
+ // One row per cell of the matrix: ticking a box twice is the same tick.
40
+ (table) => [uniqueIndex("role_permission_unique").on(table.role, table.resource, table.action)],
41
+ );
42
+
17
43
  /**
18
44
  * Who changed what, and when. Written by the dashboard's actions (lib/audit.ts) for
19
45
  * every create, update, delete and import, and read by /dashboard/observability.
@@ -4,7 +4,7 @@
4
4
  * request, so they always match the API.
5
5
  */
6
6
  export const apiDocs = {
7
- title: "__APP_NAME__ API",
7
+ title: "__APP_TITLE__ API",
8
8
  description: "",
9
9
  version: "1.0.0",
10
10
  /**
@@ -6,6 +6,7 @@ import type { ResourceAction } from "@/lib/resource";
6
6
  import { checkRateLimit } from "@/lib/rate-limit";
7
7
  import type { Authorize } from "@/lib/resource/handlers";
8
8
  import { policies } from "@/policies";
9
+ import { effectivePolicy } from "./permissions";
9
10
  import { userFromApiKey } from "./api-keys";
10
11
  import { auth } from "./auth";
11
12
 
@@ -60,7 +61,9 @@ export const authorize: Authorize = async ({ request, resource, action }) => {
60
61
  if (!session && !viaKey) return problemResponse(401, "Sign in required.", PROBLEM_CODES.unauthenticated);
61
62
 
62
63
  const role = session ? ((session.user as { role?: string | null }).role ?? null) : viaKey!.role;
63
- if (!can(policyFor(resource.name), role, action)) {
64
+ // The effective policy: your policy file, widened by whatever the dashboard's Roles
65
+ // screen granted. The API and the admin read the same one, so they cannot drift.
66
+ if (!can(await effectivePolicy(resource.name), role, action)) {
64
67
  return problemResponse(403, `Your role can't ${action} ${resource.pluralLabel.toLowerCase()}.`, PROBLEM_CODES.forbidden);
65
68
  }
66
69
  };
@@ -194,7 +194,7 @@ async function promoteFirstUser() {
194
194
  }
195
195
 
196
196
  export const auth = betterAuth({
197
- appName: "__APP_NAME__",
197
+ appName: "__APP_TITLE__",
198
198
  secret: env.BETTER_AUTH_SECRET,
199
199
  // Without BETTER_AUTH_URL, accept local dev on any port, a `flare dev --tunnel` URL and
200
200
  // this app's workers.dev host. Cloudflare only routes hosts this Worker serves, so a
@@ -265,7 +265,7 @@ export const auth = betterAuth({
265
265
  }),
266
266
  emailOTP({ sendVerificationOTP: (data) => sendEmailCode(data), otpLength: 6, expiresIn: 5 * 60, allowedAttempts: 5 }),
267
267
  twoFactor({
268
- issuer: "__APP_NAME__",
268
+ issuer: "__APP_TITLE__",
269
269
  otpOptions: { sendOTP: ({ user, otp }) => sendTwoFactorCode({ user, otp }), allowedAttempts: 5 },
270
270
  // With only email codes switched on there's no authenticator app to confirm first.
271
271
  skipVerificationOnEnable: !authConfig.twoFactor.authenticator,
@@ -273,7 +273,7 @@ export const auth = betterAuth({
273
273
  // accounts that do have one must still give it.
274
274
  allowPasswordless: true,
275
275
  }),
276
- passkey({ rpName: "__APP_NAME__" }),
276
+ passkey({ rpName: "__APP_TITLE__" }),
277
277
  // Must stay last: lets server actions set auth cookies.
278
278
  nextCookies(),
279
279
  ],
@@ -6,6 +6,7 @@ import type { SQLiteTable } from "drizzle-orm/sqlite-core";
6
6
  import { createResourceStore, drizzleRows, type ResourceStore } from "@/lib/resource";
7
7
  import { getDb } from "@/db";
8
8
  import { policies } from "@/policies";
9
+ import { effectivePolicy } from "./permissions";
9
10
  import { resourceTables } from "@/resources/server";
10
11
  import { currentUser } from "./api";
11
12
  import { auth } from "./auth";
@@ -18,6 +19,13 @@ import { cached, resourceTag, revalidateResource, TTL } from "./cache";
18
19
  */
19
20
  export const ADMIN_ROLES = ["admin", "staff"];
20
21
 
22
+ /**
23
+ * The policy in your code, before the dashboard's grants.
24
+ *
25
+ * The store takes this one, because the half of a policy the store enforces is `own` —
26
+ * per-record ownership — which is a code decision and not something the roles screen
27
+ * can change. Role checks go through {@link effectivePolicy} instead.
28
+ */
21
29
  export function policyFor(resourceName: string): Policy | undefined {
22
30
  return (policies as Record<string, Policy | undefined>)[resourceName];
23
31
  }
@@ -26,7 +34,9 @@ export async function dashboardSession() {
26
34
  const session = await auth.api.getSession({ headers: await headers() });
27
35
  if (!session) return { session: null, role: null, allowed: false } as const;
28
36
  const role = (session.user as { role?: string | null }).role ?? null;
29
- const readable = allResources().some((resource) => can(policyFor(resource.name), role, "read"));
37
+ const readable = (await Promise.all(allResources().map((resource) => effectivePolicy(resource.name)))).some((policy) =>
38
+ can(policy, role, "read"),
39
+ );
30
40
  return { session, role, allowed: (role !== null && ADMIN_ROLES.includes(role)) || readable } as const;
31
41
  }
32
42
 
@@ -45,7 +55,7 @@ export async function requireDashboard(returnTo = "/dashboard") {
45
55
  */
46
56
  export async function requireAccess(resource: Resource, action: PolicyAction) {
47
57
  const { session, role } = await requireDashboard(resourcePath(resource));
48
- const policy = policyFor(resource.name);
58
+ const policy = await effectivePolicy(resource.name);
49
59
  if (!can(policy, role, action)) {
50
60
  redirect(action !== "read" && can(policy, role, "read") ? resourcePath(resource) : "/dashboard?error=forbidden");
51
61
  }
@@ -55,7 +65,7 @@ export async function requireAccess(resource: Resource, action: PolicyAction) {
55
65
  /** What the current user may do with a resource, for hiding actions they can't use. */
56
66
  export async function adminPermissions(resourceName: string) {
57
67
  const { role } = await dashboardSession();
58
- return allowedActions(policyFor(resourceName), role);
68
+ return allowedActions(await effectivePolicy(resourceName), role);
59
69
  }
60
70
 
61
71
  /**
@@ -167,7 +177,11 @@ export async function resourceStats(name: string, field?: string, days = 7): Pro
167
177
  return stats;
168
178
  },
169
179
  ["flare", "resource-stats"],
170
- { tags: [resourceTag(name)], revalidate: TTL.medium },
180
+ // TTL.short, by the TTL table's own words: "changes often, cheap to recompute:
181
+ // counts, dashboards". These are those counts — and `flare seed:resource` writes
182
+ // from outside the app, where no tag revalidates them, so at medium the overview
183
+ // said "0 records" for five minutes after seeding a thousand.
184
+ { tags: [resourceTag(name)], revalidate: TTL.short },
171
185
  );
172
186
  return read(name, field, days, limit ?? undefined);
173
187
  }
@@ -191,7 +205,8 @@ export function allResources(): Resource[] {
191
205
  /** Resources the current user may read, for the sidebar and dashboard. */
192
206
  export async function visibleResources(): Promise<Resource[]> {
193
207
  const { role } = await dashboardSession();
194
- return allResources().filter((resource) => can(policyFor(resource.name), role, "read"));
208
+ const policies = await Promise.all(allResources().map((resource) => effectivePolicy(resource.name)));
209
+ return allResources().filter((resource, index) => can(policies[index], role, "read"));
195
210
  }
196
211
 
197
212
  export function resourcePath(resource: Resource, ...parts: string[]) {
@@ -10,7 +10,7 @@ import { createMailer, renderTransactionalEmail, type TransactionalEmail } from
10
10
  */
11
11
  export const mailer = createMailer({
12
12
  apiKey: env.RESEND_API_KEY,
13
- from: env.MAIL_FROM || "__APP_NAME__ <onboarding@resend.dev>",
13
+ from: env.MAIL_FROM || "__APP_TITLE__ <onboarding@resend.dev>",
14
14
  });
15
15
 
16
16
  /**
@@ -31,6 +31,6 @@ export async function sendTransactionalEmail({
31
31
  idempotencyKey,
32
32
  ...content
33
33
  }: Omit<TransactionalEmail, "appName"> & { to: string | string[]; subject: string; idempotencyKey?: string }) {
34
- const { html, text } = renderTransactionalEmail({ appName: "__APP_NAME__", ...content });
34
+ const { html, text } = renderTransactionalEmail({ appName: "__APP_TITLE__", ...content });
35
35
  return mailer.send({ to, subject, html, text, idempotencyKey });
36
36
  }
@@ -0,0 +1,105 @@
1
+ import { revalidateTag } from "next/cache";
2
+ import { and, asc, eq } from "drizzle-orm";
3
+ import { withGrants, type PermissionGrant, type Policy, type PolicyAction } from "@flaredev/core";
4
+ import { getDb } from "@/db";
5
+ import { role as roleTable, rolePermission } from "@/db/flare-schema";
6
+ import { user } from "@/db/auth-schema";
7
+ import { policies } from "@/policies";
8
+ import { cached, TTL } from "./cache";
9
+
10
+ /**
11
+ * Permissions managed from the dashboard, on top of the policies in your code.
12
+ *
13
+ * `policies/*.policy.ts` is the floor and this only ever widens it — the reasoning is
14
+ * in `withGrants` (@flaredev/core), and the short version is that a row in a table must
15
+ * never be able to lock an app out or quietly restrict something the code left open.
16
+ *
17
+ * Everything here is cached under one tag and revalidated on every write, so a role
18
+ * change takes effect on the editor's next request rather than in a minute's time.
19
+ */
20
+
21
+ export const GRANTS_TAG = "flare/permissions";
22
+
23
+ /** The four things a role can be granted. "list" is read, so it is not one of them. */
24
+ export const GRANTABLE: Exclude<PolicyAction, "list">[] = ["read", "create", "update", "delete"];
25
+
26
+ const readGrants = cached(
27
+ async (): Promise<PermissionGrant[]> => {
28
+ const rows = await getDb()
29
+ .select({ role: rolePermission.role, resource: rolePermission.resource, action: rolePermission.action })
30
+ .from(rolePermission);
31
+ return rows as PermissionGrant[];
32
+ },
33
+ ["flare", "permissions"],
34
+ { tags: [GRANTS_TAG], revalidate: TTL.medium },
35
+ );
36
+
37
+ /** Every grant in the database. */
38
+ export function allGrants(): Promise<PermissionGrant[]> {
39
+ return readGrants();
40
+ }
41
+
42
+ /** The policy in your code, before the dashboard's grants. */
43
+ export function codePolicy(resourceName: string): Policy | undefined {
44
+ return (policies as Record<string, Policy | undefined>)[resourceName];
45
+ }
46
+
47
+ /**
48
+ * The policy actually enforced for a resource: the file, widened by the grants.
49
+ *
50
+ * Undefined when there is no policy file, which means the resource is open to every
51
+ * signed-in user — and grants on it do nothing. The roles screen says so.
52
+ */
53
+ export async function effectivePolicy(resourceName: string): Promise<Policy | undefined> {
54
+ return withGrants(codePolicy(resourceName), resourceName, await allGrants());
55
+ }
56
+
57
+ /** Roles that exist, newest last. Registering one is what makes it offerable in the UI. */
58
+ export async function listRoles(): Promise<{ name: string; label: string }[]> {
59
+ return getDb().select({ name: roleTable.name, label: roleTable.label }).from(roleTable).orderBy(asc(roleTable.createdAt));
60
+ }
61
+
62
+ /**
63
+ * Roles that accounts actually hold, registered or not.
64
+ *
65
+ * `flare user:role someone@example.com auditor` is a complete way to create a role —
66
+ * the string on the account is the role. If this screen only listed the `role` table it
67
+ * would hide the ones already in use, and you could not grant anything to them. So the
68
+ * two lists are merged, and holding a role is enough to appear.
69
+ */
70
+ export async function rolesInUse(): Promise<string[]> {
71
+ const rows = await getDb().selectDistinct({ role: user.role }).from(user);
72
+ return rows.map((row) => row.role).filter((role): role is string => Boolean(role));
73
+ }
74
+
75
+ export async function createRole(name: string, label: string): Promise<void> {
76
+ await getDb().insert(roleTable).values({ name, label }).onConflictDoUpdate({ target: roleTable.name, set: { label } });
77
+ revalidateTag(GRANTS_TAG, { expire: 0 });
78
+ }
79
+
80
+ /**
81
+ * Forget a role.
82
+ *
83
+ * Its grants go with it — a grant naming a role nobody holds is noise that will outlive
84
+ * everyone who remembers what it was for. Accounts keep the role string they have;
85
+ * `can()` simply stops finding grants for it, and the policy file still decides.
86
+ */
87
+ export async function deleteRole(name: string): Promise<void> {
88
+ const db = getDb();
89
+ await db.delete(rolePermission).where(eq(rolePermission.role, name));
90
+ await db.delete(roleTable).where(eq(roleTable.name, name));
91
+ revalidateTag(GRANTS_TAG, { expire: 0 });
92
+ }
93
+
94
+ /** Tick or untick one cell of the matrix. */
95
+ export async function setGrant(role: string, resource: string, action: string, granted: boolean): Promise<void> {
96
+ const db = getDb();
97
+ if (granted) {
98
+ await db.insert(rolePermission).values({ role, resource, action }).onConflictDoNothing();
99
+ } else {
100
+ await db
101
+ .delete(rolePermission)
102
+ .where(and(eq(rolePermission.role, role), eq(rolePermission.resource, resource), eq(rolePermission.action, action)));
103
+ }
104
+ revalidateTag(GRANTS_TAG, { expire: 0 });
105
+ }
@@ -5,9 +5,9 @@ import type { ThemeName } from "./theme";
5
5
  * the page titles read from here. Edit freely.
6
6
  */
7
7
  export const site = {
8
- name: "__APP_NAME__",
8
+ name: "__APP_TITLE__",
9
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.",
10
+ description: "__APP_TITLE__ keeps your team, your customers and your data in one place, and it's live on the edge in every region.",
11
11
  /**
12
12
  * The look of the whole app: sign-in screens, dashboard, admin and home page.
13
13
  * One of: default, coral, amber, sky, mono, emerald. FLARE_THEME (shell or .env)
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Telling "the code in this tab is out of date" apart from a real failure.
3
+ *
4
+ * A browser that has loaded one build and is handed another — you updated the app and
5
+ * it rebuilt, or you started a different Flare app on the same localhost port — can end
6
+ * up executing a module graph that no longer matches what the server is serving. The
7
+ * bundler says so in its own words:
8
+ *
9
+ * "... was instantiated because it was required from module <file>, but the module
10
+ * factory is not available."
11
+ *
12
+ * It reads like a crash in your code, and it is not: nothing is wrong with the app, the
13
+ * tab is simply holding stale JavaScript. It matters that the error boundary knows the
14
+ * difference, because the usual remedy — React's `reset()`, which re-renders the
15
+ * segment without a page load — cannot fix this one. The stale modules are still in
16
+ * memory afterwards. Only a real reload replaces them.
17
+ */
18
+
19
+ /** Phrases every bundler in this family uses for "the code in this tab is out of date". */
20
+ const STALE_CHUNK = [
21
+ "module factory is not available",
22
+ "chunkloaderror",
23
+ "loading chunk",
24
+ "loading css chunk",
25
+ "failed to fetch dynamically imported module",
26
+ "error loading dynamically imported module",
27
+ "importing a module script failed",
28
+ ];
29
+
30
+ /** Whether this error means the tab's JavaScript is stale rather than the app is broken. */
31
+ export function isStaleChunkError(error: unknown): boolean {
32
+ if (!error) return false;
33
+ const name = (error as { name?: unknown }).name;
34
+ if (typeof name === "string" && name.toLowerCase() === "chunkloaderror") return true;
35
+ const message = (error as { message?: unknown }).message;
36
+ if (typeof message !== "string") return false;
37
+ const haystack = message.toLowerCase();
38
+ return STALE_CHUNK.some((phrase) => haystack.includes(phrase));
39
+ }
40
+
41
+ /**
42
+ * Reload the page, bypassing the cached modules.
43
+ *
44
+ * `location.reload()` is enough for the common case, and is what the bundler's own
45
+ * advice amounts to. It is called from a click handler so the browser treats it as a
46
+ * user-initiated navigation rather than a redirect loop.
47
+ */
48
+ export function hardReload(): void {
49
+ if (typeof window !== "undefined") window.location.reload();
50
+ }
@@ -108,6 +108,15 @@ CREATE TABLE `role` (
108
108
  `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
109
109
  );
110
110
  --> statement-breakpoint
111
+ CREATE TABLE `role_permission` (
112
+ `id` text PRIMARY KEY NOT NULL,
113
+ `role` text NOT NULL,
114
+ `resource` text NOT NULL,
115
+ `action` text NOT NULL,
116
+ `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
117
+ );
118
+ --> statement-breakpoint
119
+ CREATE UNIQUE INDEX `role_permission_unique` ON `role_permission` (`role`,`resource`,`action`);--> statement-breakpoint
111
120
  CREATE TABLE `saved_view` (
112
121
  `id` text PRIMARY KEY NOT NULL,
113
122
  `user_id` text NOT NULL,