@flaredev/cli 0.11.0 → 0.12.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 (45) hide show
  1. package/dist/index.js +4777 -3372
  2. package/package.json +2 -2
  3. package/templates/app/.dev.vars.example +20 -0
  4. package/templates/app/app/dashboard/import-actions.ts +2 -2
  5. package/templates/app/app/dashboard/invitations/actions.ts +62 -0
  6. package/templates/app/app/dashboard/invitations/page.tsx +121 -0
  7. package/templates/app/app/invite/[token]/route.ts +28 -0
  8. package/templates/app/app/sign-up/page.tsx +61 -42
  9. package/templates/app/components/dashboard/fields/file-field.tsx +132 -12
  10. package/templates/app/components/dashboard/invite-form.tsx +86 -0
  11. package/templates/app/components/dashboard/record-detail.tsx +13 -2
  12. package/templates/app/components/dashboard/resource-form-page.tsx +9 -1
  13. package/templates/app/components/dashboard/resource-form.tsx +3 -1
  14. package/templates/app/components/dashboard/resource-table.tsx +7 -1
  15. package/templates/app/lib/api.ts +41 -3
  16. package/templates/app/lib/auth-config.ts +60 -38
  17. package/templates/app/lib/auth.ts +67 -2
  18. package/templates/app/lib/dashboard-nav.ts +5 -0
  19. package/templates/app/lib/invitations.ts +142 -0
  20. package/templates/app/lib/rate-limit.ts +71 -0
  21. package/templates/app/lib/resource/handlers.ts +9 -2
  22. package/templates/app/lib/resource/http.ts +24 -13
  23. package/templates/app/lib/resource/store.ts +79 -27
  24. package/templates/app/lib/storage/cloudinary.ts +146 -0
  25. package/templates/app/lib/storage/uploadthing.ts +122 -0
  26. package/templates/app/lib/storage.ts +72 -6
  27. package/templates/app/lib/user-titles.ts +52 -0
  28. package/templates/app/worker/index.ts +4 -1
  29. package/templates/app/wrangler.jsonc +11 -0
  30. package/templates/locks/cloudflare/package-lock.json +570 -690
  31. package/templates/locks/cloudflare/pnpm-lock.yaml +1061 -1091
  32. package/templates/locks/cloudflare/yarn.lock +363 -381
  33. package/templates/locks/next/package-lock.json +283 -243
  34. package/templates/locks/next/pnpm-lock.yaml +890 -870
  35. package/templates/locks/next/yarn.lock +338 -295
  36. package/templates/next/README.md +35 -3
  37. package/templates/next/lib/auth.ts +65 -2
  38. package/templates/next/lib/dashboard.ts +3 -1
  39. package/templates/next/lib/invitations.ts +119 -0
  40. package/templates/next/lib/rate-limit.ts +83 -0
  41. package/templates/next/lib/resource/prisma-rows.ts +52 -9
  42. package/templates/next/lib/storage/disk.ts +128 -0
  43. package/templates/next/lib/storage/r2.ts +86 -0
  44. package/templates/next/lib/storage.ts +83 -66
  45. package/templates/next/lib/user-titles.ts +49 -0
@@ -0,0 +1,86 @@
1
+ "use client";
2
+
3
+ import { useState, useTransition } from "react";
4
+ import { CheckIcon, CopyIcon } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
7
+ import { Input } from "@/components/ui/input";
8
+ import { Spinner } from "@/components/ui/spinner";
9
+ import type { IssuedInvitation } from "@/app/dashboard/invitations/actions";
10
+
11
+ /**
12
+ * Issue an invitation and show the link once.
13
+ *
14
+ * Once, because only the hash of the token is stored: there is no second chance to read
15
+ * it. So the link stays on screen until the next one is issued, with a copy button — a
16
+ * link you have to retype from a toast that has already gone is not a feature.
17
+ */
18
+ export function InviteForm({ issue }: { issue: (formData: FormData) => Promise<IssuedInvitation> }) {
19
+ const [pending, startTransition] = useTransition();
20
+ const [issued, setIssued] = useState<IssuedInvitation | null>(null);
21
+ const [error, setError] = useState<string | null>(null);
22
+ const [copied, setCopied] = useState(false);
23
+
24
+ const submit = (formData: FormData) => {
25
+ setError(null);
26
+ startTransition(async () => {
27
+ try {
28
+ setIssued(await issue(formData));
29
+ setCopied(false);
30
+ } catch (cause) {
31
+ setError(cause instanceof Error ? cause.message : "Could not create the invitation.");
32
+ }
33
+ });
34
+ };
35
+
36
+ const copy = async () => {
37
+ if (!issued) return;
38
+ try {
39
+ await navigator.clipboard.writeText(issued.link);
40
+ setCopied(true);
41
+ } catch {
42
+ // A browser that refuses the clipboard still shows the link to select by hand.
43
+ }
44
+ };
45
+
46
+ return (
47
+ <div className="flex flex-col gap-4">
48
+ <form action={submit}>
49
+ <FieldGroup>
50
+ <Field>
51
+ <FieldLabel htmlFor="invite-email">Email address</FieldLabel>
52
+ <Input id="invite-email" name="email" type="email" placeholder="ada@example.com" autoComplete="off" />
53
+ <FieldDescription>Leave it empty for a link anybody may use.</FieldDescription>
54
+ </Field>
55
+ <Field>
56
+ <FieldLabel htmlFor="invite-days">Valid for</FieldLabel>
57
+ <Input id="invite-days" name="days" type="number" min={1} max={90} defaultValue={7} />
58
+ <FieldDescription>Days until the link stops working.</FieldDescription>
59
+ </Field>
60
+ <Button type="submit" disabled={pending}>
61
+ {pending && <Spinner data-icon="inline-start" />}
62
+ Create invitation
63
+ </Button>
64
+ </FieldGroup>
65
+ </form>
66
+
67
+ {error && <p className="text-sm text-destructive">{error}</p>}
68
+
69
+ {issued && (
70
+ <div className="flex flex-col gap-2 rounded-md border bg-muted/40 p-3">
71
+ <p className="text-sm font-medium">
72
+ {issued.email ? `Invitation for ${issued.email}` : "Invitation for anybody with the link"}
73
+ </p>
74
+ <div className="flex items-center gap-2">
75
+ <code className="min-w-0 flex-1 truncate rounded bg-background px-2 py-1 text-xs">{issued.link}</code>
76
+ <Button type="button" variant="outline" size="sm" onClick={copy}>
77
+ {copied ? <CheckIcon data-icon="inline-start" /> : <CopyIcon data-icon="inline-start" />}
78
+ {copied ? "Copied" : "Copy"}
79
+ </Button>
80
+ </div>
81
+ <p className="text-xs text-muted-foreground">Copy it now — it is not stored and cannot be shown again.</p>
82
+ </div>
83
+ )}
84
+ </div>
85
+ );
86
+ }
@@ -1,6 +1,6 @@
1
1
  import Link from "next/link";
2
2
  import { notFound } from "next/navigation";
3
- import { clientResource, formatValue, optionLabel, relationGraph, statusTone, storedFields, type Resource } from "@flaredev/core";
3
+ import { clientResource, formatValue, isAuthUserTarget, optionLabel, relationGraph, statusTone, storedFields, type Resource } from "@flaredev/core";
4
4
  import { Badge } from "@/components/ui/badge";
5
5
  import { FileCell } from "@/components/dashboard/fields/file-cell";
6
6
  import { Markdown } from "@/components/dashboard/fields/markdown";
@@ -13,6 +13,7 @@ import { LocalTime } from "./local-time";
13
13
  import { PageHeader } from "./page-header";
14
14
  import { RecordActions } from "./record-actions";
15
15
  import type { RelationMeta } from "./fields/field-widget";
16
+ import { AUTH_USER_RELATION, userTitles } from "@/lib/user-titles";
16
17
 
17
18
  /** How many of a record's children to show before pointing at the filtered list. */
18
19
  const RELATED_LIMIT = 5;
@@ -43,10 +44,20 @@ export async function RecordDetail({ resource, id }: { resource: Resource; id: s
43
44
  const relations: Record<string, RelationMeta & { initialTitle?: string }> = {};
44
45
  for (const [key, def] of fields) {
45
46
  if (def.kind !== "belongsTo") continue;
47
+ const value = record[key];
48
+ // Better Auth's account table is not a resource, so it has no store to ask: its
49
+ // names come from lib/user-titles.ts and its picker from /api/users.
50
+ if (isAuthUserTarget(def.target)) {
51
+ relations[key] = AUTH_USER_RELATION;
52
+ if (typeof value === "string") {
53
+ const title = (await userTitles([value]))[value];
54
+ if (title) relationTitles[key] = title;
55
+ }
56
+ continue;
57
+ }
46
58
  const target = byName.get(def.target);
47
59
  if (!target) continue;
48
60
  relations[key] = { name: target.name, label: target.label, pluralLabel: target.pluralLabel, slug: target.slug, titleField: target.titleField };
49
- const value = record[key];
50
61
  if (typeof value === "string") {
51
62
  const titles = await dashboardStore(target.name).titles([value]);
52
63
  const title = titles[value];
@@ -1,9 +1,10 @@
1
1
  import { notFound } from "next/navigation";
2
- import { clientResource, formatValue, storedFields, type Resource } from "@flaredev/core";
2
+ import { clientResource, formatValue, isAuthUserTarget, storedFields, type Resource } from "@flaredev/core";
3
3
  import { resourcePath, allResources, confinedField, dashboardStore, requireAccess } from "@/lib/dashboard";
4
4
  import type { RelationMeta } from "./fields/field-widget";
5
5
  import { PageHeader } from "./page-header";
6
6
  import { ResourceForm } from "./resource-form";
7
+ import { AUTH_USER_RELATION, userTitles } from "@/lib/user-titles";
7
8
 
8
9
  /** Server wrapper for create/edit pages: loads the record and relation titles, renders the heading and form. */
9
10
  export async function ResourceFormPage({ resource, id }: { resource: Resource; id?: string }) {
@@ -25,6 +26,13 @@ export async function ResourceFormPage({ resource, id }: { resource: Resource; i
25
26
  const relations: Record<string, RelationMeta & { initialTitle?: string }> = {};
26
27
  for (const [key, def] of storedFields(resource)) {
27
28
  if (def.kind !== "belongsTo") continue;
29
+ if (isAuthUserTarget(def.target)) {
30
+ // The accounts table, whose names and picker come from outside the resource store.
31
+ const current = record?.[key];
32
+ const titles = typeof current === "string" ? await userTitles([current]) : {};
33
+ relations[key] = { ...AUTH_USER_RELATION, initialTitle: typeof current === "string" ? titles[current] : undefined };
34
+ continue;
35
+ }
28
36
  const target = byName.get(def.target);
29
37
  if (!target) continue;
30
38
  const value = record?.[key];
@@ -150,7 +150,9 @@ export function ResourceForm({ resource, mode, id, record, relations = {}, listH
150
150
  router.refresh();
151
151
  return;
152
152
  }
153
- const fieldErrors = result.issues ? issuesByField(result.issues) : result.field ? { [result.field]: result.error } : { _form: result.error };
153
+ // One shape to read: a unique conflict names its column in `issues` now, the same
154
+ // way a failed rule does, so there is no second path for it.
155
+ const fieldErrors = result.issues?.length ? issuesByField(result.issues) : { _form: result.error };
154
156
  setErrors(fieldErrors);
155
157
  const broken = steps.findIndex((entries) => entries.some(([key]) => fieldErrors[key]));
156
158
  if (broken >= 0 && broken !== step) setStep(broken);
@@ -1,7 +1,7 @@
1
1
  import Link from "next/link";
2
2
  import { FileCell } from "@/components/dashboard/fields/file-cell";
3
3
  import { ArrowDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
4
- import { clientResource, formatValue, optionLabel, statusTone, storedFields, type Resource, type StoredField } from "@flaredev/core";
4
+ import { clientResource, formatValue, isAuthUserTarget, optionLabel, statusTone, storedFields, type Resource, type StoredField } from "@flaredev/core";
5
5
  import { isSortable } from "@/lib/resource/query";
6
6
  import { Alert, AlertDescription } from "@/components/ui/alert";
7
7
  import { Badge } from "@/components/ui/badge";
@@ -33,6 +33,7 @@ import { ResourceTableToolbar } from "./resource-table-toolbar";
33
33
  import { SaveViewButton } from "./save-view-button";
34
34
  import { RowActions } from "./row-actions";
35
35
  import { RowCheckbox, SelectAllCheckbox, SelectionBar, TableSelection } from "./table-selection";
36
+ import { AUTH_USER_RELATION } from "@/lib/user-titles";
36
37
 
37
38
  type Column = { key: string; label: string; def: StoredField | { kind: "timestamp" } };
38
39
 
@@ -92,6 +93,11 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
92
93
  const relations: Record<string, RelationMeta & { initialTitle?: string }> = {};
93
94
  for (const [key, def] of fields) {
94
95
  if (def.kind !== "belongsTo") continue;
96
+ if (isAuthUserTarget(def.target)) {
97
+ // The picker fills its own title from /api/users/<id>, so no lookup is needed here.
98
+ relations[key] = AUTH_USER_RELATION;
99
+ continue;
100
+ }
95
101
  const target = byName.get(def.target);
96
102
  if (!target) continue;
97
103
  relations[key] = { name: target.name, label: target.label, pluralLabel: target.pluralLabel, slug: target.slug, titleField: target.titleField };
@@ -1,5 +1,9 @@
1
1
  import { headers } from "next/headers";
2
- import { can, type Policy } from "@flaredev/core";
2
+ import { can, type Policy, rateFor, type Resource } from "@flaredev/core";
3
+ import { PROBLEM_CODES, problemResponse } from "@flaredev/core";
4
+ import { clientIp } from "@flaredev/core/security";
5
+ import type { ResourceAction } from "@/lib/resource";
6
+ import { checkRateLimit } from "@/lib/rate-limit";
3
7
  import type { Authorize } from "@/lib/resource/handlers";
4
8
  import { policies } from "@/policies";
5
9
  import { userFromApiKey } from "./api-keys";
@@ -53,10 +57,44 @@ export function policyFor(resourceName: string): Policy | undefined {
53
57
  export const authorize: Authorize = async ({ request, resource, action }) => {
54
58
  const session = await auth.api.getSession({ headers: request.headers });
55
59
  const viaKey = session ? null : await userFromApiKey(request.headers);
56
- if (!session && !viaKey) return Response.json({ error: "Sign in required." }, { status: 401 });
60
+ if (!session && !viaKey) return problemResponse(401, "Sign in required.", PROBLEM_CODES.unauthenticated);
57
61
 
58
62
  const role = session ? ((session.user as { role?: string | null }).role ?? null) : viaKey!.role;
59
63
  if (!can(policyFor(resource.name), role, action)) {
60
- return Response.json({ error: `Your role can't ${action} ${resource.pluralLabel.toLowerCase()}.` }, { status: 403 });
64
+ return problemResponse(403, `Your role can't ${action} ${resource.pluralLabel.toLowerCase()}.`, PROBLEM_CODES.forbidden);
61
65
  }
62
66
  };
67
+
68
+ /**
69
+ * The rate limit a resource declares for this action, enforced.
70
+ *
71
+ * Returns a 429 to send back, or nothing when the call is within the limit — the same
72
+ * shape as `authorize`, so a route reads the two the same way.
73
+ *
74
+ * Counted per caller: the signed-in account where there is one, the client IP otherwise.
75
+ * So a limit on `create` is a limit on that person rather than on everyone sharing an
76
+ * office connection, and anonymous callers behind one address share a bucket, which is
77
+ * the right trade for an endpoint that does not know who is calling.
78
+ *
79
+ * Checked after `authorize`, deliberately. A request that is going to be refused anyway
80
+ * should not use up somebody's quota, and a flood with no session is the edge limiter's
81
+ * job (`flare gen security`) rather than this one's.
82
+ */
83
+ export async function rateLimitGuard(context: { request: Request; resource: Resource; action: ResourceAction }): Promise<Response | undefined> {
84
+ const rate = rateFor(context.resource.rateLimit, context.action);
85
+ if (!rate) return undefined;
86
+
87
+ const account = await currentUser();
88
+ const caller = account?.id ?? clientIp(context.request) ?? "unknown";
89
+ const decision = await checkRateLimit(context.resource.name, context.action, caller, rate);
90
+ if (decision.allowed) return undefined;
91
+
92
+ return problemResponse(429, "Too many requests. Try again shortly.", PROBLEM_CODES.rateLimited, undefined, {
93
+ headers: {
94
+ "retry-after": String(decision.retryAfter),
95
+ // What the limit was, so a client can pace itself rather than guess.
96
+ "x-ratelimit-limit": String(rate.limit),
97
+ "x-ratelimit-remaining": "0",
98
+ },
99
+ });
100
+ }
@@ -1,38 +1,60 @@
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;
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
+ /**
10
+ * Who may create an account.
11
+ *
12
+ * - `open` — anybody who finds the sign-up page. Right for a public product.
13
+ * - `invite` — only somebody holding an invitation link. An admin issues them from
14
+ * /dashboard/invitations; see lib/invitations.ts.
15
+ * - `closed` — nobody. Accounts are made with `flare user:add <email>`.
16
+ *
17
+ * The first account is always allowed, whatever this says: an app nobody can sign into
18
+ * cannot be set up, and in `invite` mode there would be nobody to issue the invitation.
19
+ * Every later sign-up goes through this.
20
+ *
21
+ * Enforced where the account is created rather than on each sign-up route, so it holds
22
+ * for a password sign-up, a magic link, an email code and a social callback alike.
23
+ */
24
+ signup: "__SIGNUP__" as SignupMode,
25
+ /** A sign-in link by email. */
26
+ magicLink: true,
27
+ /** A 6-digit sign-in code by email. */
28
+ emailOtp: true,
29
+ /** Face ID, Touch ID, Windows Hello or a security key. */
30
+ passkeys: true,
31
+ twoFactor: {
32
+ /** Codes from an authenticator app (TOTP), with backup codes. */
33
+ authenticator: true,
34
+ /** Codes by email as the second step. */
35
+ email: true,
36
+ },
37
+ /**
38
+ * Social sign-in. Each provider also needs its credentials (see .dev.vars.example);
39
+ * without them its button stays hidden.
40
+ */
41
+ social: [] as SocialProvider[],
42
+ /** Refuse password sign-in until the email address is verified. */
43
+ requireEmailVerification: false,
44
+ /**
45
+ * Check new passwords against Have I Been Pwned's breach list. Only the first five
46
+ * characters of the password's hash are sent, and an outage lets the password through.
47
+ */
48
+ checkBreachedPasswords: true,
49
+ };
50
+
51
+ export type SocialProvider = "google" | "github" | "apple" | "microsoft";
52
+
53
+ /** Who may create an account. See `authConfig.signup`. */
54
+ export type SignupMode = "open" | "invite" | "closed";
55
+
56
+ /** Whether the sign-up page should be offered at all. */
57
+ export const signupOffered = authConfig.signup !== "closed";
58
+
59
+ /** Whether any second factor can be set up. */
60
+ export const twoFactorAvailable = authConfig.twoFactor.authenticator || authConfig.twoFactor.email;
@@ -1,4 +1,6 @@
1
1
  import { env, waitUntil } from "cloudflare:workers";
2
+ import { cookies } from "next/headers";
3
+ import { INVITE_COOKIE, invitationAllows, spendInvitation } from "./invitations";
2
4
  import { passkey } from "@better-auth/passkey";
3
5
  import { drizzleAdapter } from "@better-auth/drizzle-adapter";
4
6
  import { betterAuth } from "better-auth";
@@ -108,6 +110,50 @@ async function guardPassword(path: string, body: Record<string, unknown> | undef
108
110
  }
109
111
  }
110
112
 
113
+ /**
114
+ * Whether this account may be created at all — `authConfig.signup`.
115
+ *
116
+ * Checked where the user row is written rather than on each sign-up route, because there
117
+ * are four ways to end up with a new account (a password sign-up, a magic link, an email
118
+ * code, a social callback) and gating them one by one is how one gets missed.
119
+ *
120
+ * **The first account is always allowed.** An app nobody can sign into cannot be set up,
121
+ * and in `invite` mode there would be nobody to issue the invitation. Everybody after
122
+ * them goes through this; the first account also becomes the admin (see below).
123
+ */
124
+ async function assertSignUpAllowed(email: string): Promise<void> {
125
+ if (authConfig.signup === "open") return;
126
+
127
+ const [existing] = await getDb().select({ id: schema.user.id }).from(schema.user).limit(1);
128
+ if (!existing) return;
129
+
130
+ if (authConfig.signup === "closed") {
131
+ throw new APIError("FORBIDDEN", { message: "This app is not accepting new accounts." });
132
+ }
133
+
134
+ // `invite`: the token was put in a cookie when the invitation link was opened.
135
+ const token = (await cookies()).get(INVITE_COOKIE)?.value;
136
+ if (!(await invitationAllows(token, email))) {
137
+ throw new APIError("FORBIDDEN", { message: "You need an invitation to create an account here." });
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Spend the invitation this sign-up used, once the account exists.
143
+ *
144
+ * Separate from the check because the account does not exist yet when the check runs: an
145
+ * invitation has to survive a sign-up that fails validation, and only be used up by one
146
+ * that worked.
147
+ */
148
+ async function spendUsedInvitation(): Promise<void> {
149
+ if (authConfig.signup !== "invite") return;
150
+ const jar = await cookies();
151
+ const token = jar.get(INVITE_COOKIE)?.value;
152
+ if (!token) return;
153
+ await spendInvitation(token);
154
+ jar.delete(INVITE_COOKIE);
155
+ }
156
+
111
157
  /**
112
158
  * The first account to exist becomes the admin.
113
159
  *
@@ -183,6 +229,17 @@ export const auth = betterAuth({
183
229
  // Let emails and other deferred work finish after the response is sent.
184
230
  backgroundTasks: { handler: waitUntil },
185
231
  },
232
+ databaseHooks: {
233
+ user: {
234
+ create: {
235
+ // The one place every sign-up passes through, whichever route it came in on.
236
+ before: async (user: { email: string }) => {
237
+ await assertSignUpAllowed(user.email);
238
+ return { data: user };
239
+ },
240
+ },
241
+ },
242
+ },
186
243
  hooks: {
187
244
  before: createAuthMiddleware(async (ctx) => {
188
245
  if (disabledPath(ctx.path)) throw new APIError("NOT_FOUND", { message: "This sign-in method isn't enabled." });
@@ -192,12 +249,20 @@ export const auth = betterAuth({
192
249
  // link, a social callback — so this asks after all of them rather than guessing which.
193
250
  after: createAuthMiddleware(async () => {
194
251
  await promoteFirstUser();
252
+ await spendUsedInvitation();
195
253
  }),
196
254
  },
197
255
  plugins: [
198
- // Adds user.role (default "user") and admin APIs. The /admin area allows ADMIN_ROLES (lib/admin.ts).
256
+ // Adds user.role (default "user") and admin APIs. The dashboard allows ADMIN_ROLES (lib/dashboard.ts).
199
257
  admin({ defaultRole: "user", adminRoles: ["admin"] }),
200
- magicLink({ sendMagicLink: ({ email, url }) => sendMagicLink({ email, url }), expiresIn: 5 * 60, disableSignUp: false }),
258
+ // disableSignUp follows the mode: a magic link to an address with no account would
259
+ // otherwise create one, which is a sign-up through a side door. In `invite` mode the
260
+ // link is still how an invited person signs *in* — they have an account by then.
261
+ magicLink({
262
+ sendMagicLink: ({ email, url }) => sendMagicLink({ email, url }),
263
+ expiresIn: 5 * 60,
264
+ disableSignUp: authConfig.signup !== "open",
265
+ }),
201
266
  emailOTP({ sendVerificationOTP: (data) => sendEmailCode(data), otpLength: 6, expiresIn: 5 * 60, allowedAttempts: 5 }),
202
267
  twoFactor({
203
268
  issuer: "__APP_NAME__",
@@ -11,9 +11,14 @@ export interface DashboardLink {
11
11
  export const generatedDashboardLinks: DashboardLink[] = [];
12
12
  // generated:end
13
13
 
14
+ import { authConfig } from "./auth-config";
15
+
14
16
  /** Links shown under "Platform" in the dashboard sidebar. */
15
17
  export const dashboardLinks: DashboardLink[] = [
16
18
  { label: "Costs", href: "/dashboard/costs", icon: "wallet" },
19
+ // Only where invitations are the way in; the page explains itself for the other two
20
+ // modes, but there is no reason to send anybody to it.
21
+ ...(authConfig.signup === "invite" ? [{ label: "Invitations", href: "/dashboard/invitations", icon: "mail" }] : []),
17
22
  ...generatedDashboardLinks,
18
23
  { label: "API reference", href: "/api/reference", icon: "book" },
19
24
  ];
@@ -0,0 +1,142 @@
1
+ // This file is yours.
2
+ //
3
+ // Flare copies the code that turns a resource descriptor into a working endpoint into
4
+ // your app rather than hiding it in node_modules, for the same reason shadcn/ui copies
5
+ // a component: you cannot fix, read or reason about what you cannot see.
6
+ //
7
+ // `flare diff` shows how your copy differs from the version Flare ships, and
8
+ // `flare update` applies the parts you choose. Neither runs unless you ask.
9
+
10
+ /**
11
+ * Invitations, for an app whose sign-up is `invite` (see lib/auth-config.ts).
12
+ *
13
+ * **They live in the `verification` table**, which Better Auth already has and already
14
+ * uses for exactly this shape of thing: a single-use token with an expiry. Reusing it
15
+ * means invitations need no table of their own and so no migration — an app switching to
16
+ * `invite` changes one line in auth-config.ts and nothing else.
17
+ *
18
+ * Rows are keyed `invite:<hash>`. Only the hash is stored, never the token itself, for the
19
+ * same reason a password is not: a leaked database should not be a pile of working
20
+ * invitations.
21
+ *
22
+ * The flow:
23
+ *
24
+ * 1. An admin creates one. `createInvitation` returns the token once — it is not
25
+ * recoverable afterwards, which is why the link is shown on screen to copy.
26
+ * 2. The invited person opens the link. `/invite/[token]` puts the token in a
27
+ * short-lived cookie and sends them to the sign-up page.
28
+ * 3. They sign up. `lib/auth.ts` reads the cookie, checks the invitation, and lets the
29
+ * account be created — then spends it, so the link cannot be used twice.
30
+ */
31
+ import { and, eq, like, lt } from "drizzle-orm";
32
+ import { getDb } from "@/db";
33
+ import { verification } from "@/db/auth-schema";
34
+
35
+ /** How long an invitation is good for, unless you say otherwise. */
36
+ export const DEFAULT_INVITE_DAYS = 7;
37
+
38
+ /** The cookie an opened invitation link leaves behind, for the sign-up that follows. */
39
+ export const INVITE_COOKIE = "flare_invite";
40
+
41
+ /** How long that cookie lives: long enough to fill in a form, not long enough to forget. */
42
+ export const INVITE_COOKIE_MAX_AGE = 60 * 60;
43
+
44
+ const KEY_PREFIX = "invite:";
45
+
46
+ /**
47
+ * WebCrypto rather than node:crypto, so the same file runs on Workers and on Node
48
+ * without a compatibility flag deciding whether it does.
49
+ */
50
+ const randomToken = () => {
51
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
52
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
53
+ };
54
+
55
+ /** Only the hash is stored, so a copy of the table is not a pile of working invitations. */
56
+ async function hash(token: string): Promise<string> {
57
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
58
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
59
+ }
60
+
61
+ const key = async (token: string) => `${KEY_PREFIX}${await hash(token)}`;
62
+
63
+ /** What an invitation is for: one address, or anybody who has the link. */
64
+ export type InviteFor = { email: string } | { anyone: true };
65
+
66
+ /** An invitation. The token is returned once and never stored. */
67
+ export interface CreatedInvitation {
68
+ token: string;
69
+ expiresAt: Date;
70
+ /** Who it is for, or null when anybody with the link may use it. */
71
+ email: string | null;
72
+ }
73
+
74
+ /** Create one. The returned token is the only copy. */
75
+ export async function createInvitation(options: { for: InviteFor; days?: number }): Promise<CreatedInvitation> {
76
+ const token = randomToken();
77
+ const email = "email" in options.for ? options.for.email.trim().toLowerCase() : null;
78
+ const expiresAt = new Date(Date.now() + (options.days ?? DEFAULT_INVITE_DAYS) * 86_400_000);
79
+ await getDb()
80
+ .insert(verification)
81
+ .values({
82
+ id: crypto.randomUUID(),
83
+ identifier: await key(token),
84
+ // `*` rather than an empty string, so "for anybody" is a value and not a mistake.
85
+ value: email ?? "*",
86
+ expiresAt,
87
+ createdAt: new Date(),
88
+ updatedAt: new Date(),
89
+ });
90
+ return { token, expiresAt, email };
91
+ }
92
+
93
+ /** The link to send somebody, given the app's own origin. */
94
+ export const invitationLink = (baseUrl: string, token: string) => `${baseUrl.replace(/\/$/, "")}/invite/${token}`;
95
+
96
+ /**
97
+ * Whether this token is a live invitation for this email address.
98
+ *
99
+ * Checked, not spent: the account does not exist yet when this is asked, and an
100
+ * invitation should survive a sign-up that fails validation.
101
+ */
102
+ export async function invitationAllows(token: string | undefined, email: string): Promise<boolean> {
103
+ if (!token) return false;
104
+ const rows = await getDb().select().from(verification).where(eq(verification.identifier, await key(token))).limit(1);
105
+ const row = rows[0];
106
+ if (!row) return false;
107
+ if (row.expiresAt.getTime() <= Date.now()) return false;
108
+ // An invitation addressed to somebody is for them alone; `*` is for whoever has the link.
109
+ return row.value === "*" || row.value === email.trim().toLowerCase();
110
+ }
111
+
112
+ /** Spend it, so the link cannot be used again. Called once the account exists. */
113
+ export async function spendInvitation(token: string | undefined): Promise<void> {
114
+ if (!token) return;
115
+ await getDb().delete(verification).where(eq(verification.identifier, await key(token)));
116
+ }
117
+
118
+ /** Invitations an admin can see: live ones, newest first. Never the tokens. */
119
+ export async function listInvitations(): Promise<{ id: string; email: string | null; expiresAt: Date; createdAt: Date }[]> {
120
+ const rows = await getDb().select().from(verification).where(like(verification.identifier, `${KEY_PREFIX}%`));
121
+ return rows
122
+ .filter((row) => row.expiresAt.getTime() > Date.now())
123
+ .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
124
+ .map((row) => ({ id: row.id, email: row.value === "*" ? null : row.value, expiresAt: row.expiresAt, createdAt: row.createdAt }));
125
+ }
126
+
127
+ /** Revoke one by its row id, so a link sent to the wrong address stops working. */
128
+ export async function revokeInvitation(id: string): Promise<void> {
129
+ await getDb().delete(verification).where(eq(verification.id, id));
130
+ }
131
+
132
+ /**
133
+ * Drop invitations that have expired.
134
+ *
135
+ * Only rows under the `invite:` prefix — the rest of this table is Better Auth's, and
136
+ * deleting its tokens would sign people out and break magic links.
137
+ */
138
+ export async function pruneInvitations(): Promise<void> {
139
+ await getDb()
140
+ .delete(verification)
141
+ .where(and(like(verification.identifier, `${KEY_PREFIX}%`), lt(verification.expiresAt, new Date())));
142
+ }