@flaredev/cli 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +4858 -3395
- package/package.json +2 -2
- package/templates/app/.dev.vars.example +19 -0
- package/templates/app/app/dashboard/import-actions.ts +2 -2
- package/templates/app/app/dashboard/invitations/actions.ts +62 -0
- package/templates/app/app/dashboard/invitations/page.tsx +121 -0
- package/templates/app/app/invite/[token]/route.ts +28 -0
- package/templates/app/app/sign-up/page.tsx +61 -42
- package/templates/app/components/dashboard/fields/file-field.tsx +132 -12
- package/templates/app/components/dashboard/invite-form.tsx +86 -0
- package/templates/app/components/dashboard/record-detail.tsx +13 -2
- package/templates/app/components/dashboard/resource-form-page.tsx +9 -1
- package/templates/app/components/dashboard/resource-form.tsx +3 -1
- package/templates/app/components/dashboard/resource-table.tsx +7 -1
- package/templates/app/lib/api.ts +41 -3
- package/templates/app/lib/auth-config.ts +60 -38
- package/templates/app/lib/auth.ts +67 -2
- package/templates/app/lib/dashboard-nav.ts +5 -0
- package/templates/app/lib/invitations.ts +142 -0
- package/templates/app/lib/rate-limit.ts +71 -0
- package/templates/app/lib/resource/handlers.ts +9 -2
- package/templates/app/lib/resource/http.ts +24 -13
- package/templates/app/lib/resource/store.ts +79 -27
- package/templates/app/lib/storage/cloudinary.ts +146 -0
- package/templates/app/lib/storage/uploadthing.ts +160 -0
- package/templates/app/lib/storage.ts +72 -6
- package/templates/app/lib/user-titles.ts +52 -0
- package/templates/app/worker/index.ts +4 -1
- package/templates/app/wrangler.jsonc +11 -0
- package/templates/locks/cloudflare/package-lock.json +571 -691
- package/templates/locks/cloudflare/pnpm-lock.yaml +1065 -1095
- package/templates/locks/cloudflare/yarn.lock +363 -381
- package/templates/locks/next/package-lock.json +284 -243
- package/templates/locks/next/pnpm-lock.yaml +898 -878
- package/templates/locks/next/yarn.lock +338 -295
- package/templates/next/README.md +35 -3
- package/templates/next/lib/auth.ts +65 -2
- package/templates/next/lib/dashboard.ts +3 -1
- package/templates/next/lib/invitations.ts +119 -0
- package/templates/next/lib/rate-limit.ts +83 -0
- package/templates/next/lib/resource/prisma-rows.ts +52 -9
- package/templates/next/lib/storage/disk.ts +128 -0
- package/templates/next/lib/storage/r2.ts +86 -0
- package/templates/next/lib/storage.ts +83 -66
- 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
|
-
|
|
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 };
|
package/templates/app/lib/api.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
*
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
/**
|
|
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
|
+
/**
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|