@flaredev/cli 0.10.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.
- package/dist/index.js +4516 -3014
- package/package.json +2 -2
- package/templates/app/.dev.vars.example +20 -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 +17 -4
- 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 +112 -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/drizzle-rows.ts +7 -2
- package/templates/app/lib/resource/handlers.ts +9 -2
- package/templates/app/lib/resource/http.ts +24 -13
- package/templates/app/lib/resource/rows.ts +5 -1
- package/templates/app/lib/resource/store.ts +126 -38
- package/templates/app/lib/storage/cloudinary.ts +146 -0
- package/templates/app/lib/storage/uploadthing.ts +122 -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/{package-lock.json → cloudflare/package-lock.json} +1674 -1997
- package/templates/locks/{pnpm-lock.yaml → cloudflare/pnpm-lock.yaml} +1591 -1844
- package/templates/locks/{yarn.lock → cloudflare/yarn.lock} +1312 -1460
- package/templates/locks/next/package-lock.json +13713 -0
- package/templates/locks/next/pnpm-lock.yaml +8252 -0
- package/templates/locks/next/yarn.lock +5790 -0
- package/templates/next/.env.example +26 -10
- package/templates/next/README.md +35 -3
- package/templates/next/docker-compose.yml +74 -0
- package/templates/next/lib/auth.ts +108 -2
- package/templates/next/lib/dashboard.ts +3 -1
- package/templates/next/lib/db.ts +5 -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 +55 -11
- 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];
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { useRouter } from "next/navigation";
|
|
5
5
|
import { useMemo, useState, useTransition, type FormEvent, type MouseEvent } from "react";
|
|
6
|
-
import { createValidators, formValuesToInput, initialFormValues, issuesByField, storedFields, type ClientResource, type StoredField } from "@flaredev/core";
|
|
6
|
+
import { conditionHolds, createValidators, formValuesToInput, initialFormValues, issuesByField, storedFields, type ClientResource, type StoredField } from "@flaredev/core";
|
|
7
7
|
import { toast } from "sonner";
|
|
8
8
|
import { createRecordAction, updateRecordAction } from "@/app/dashboard/actions";
|
|
9
9
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
@@ -69,10 +69,21 @@ export function ResourceForm({ resource, mode, id, record, relations = {}, listH
|
|
|
69
69
|
const router = useRouter();
|
|
70
70
|
const validators = useMemo(() => createValidators(resource), [resource]);
|
|
71
71
|
const hidden = useMemo(() => new Set(omit ?? []), [omit]);
|
|
72
|
-
const fields = useMemo(() => storedFields(resource).filter(([key]) => !hidden.has(key)), [resource, hidden]);
|
|
73
|
-
const steps = useMemo(() => toSteps(fields), [fields]);
|
|
74
72
|
const [step, setStep] = useState(0);
|
|
75
73
|
const [values, setValues] = useState(() => initialFormValues(resource, record));
|
|
74
|
+
/**
|
|
75
|
+
* The fields on show. A conditional field appears only while its condition holds, so
|
|
76
|
+
* this is recomputed as the form is filled in: choose "recurring" and the billing cycle
|
|
77
|
+
* appears, choose "one-off" and it goes away again.
|
|
78
|
+
*
|
|
79
|
+
* The same condition decides what the server requires, so a field that is not on show
|
|
80
|
+
* is never one the save is about to fail on.
|
|
81
|
+
*/
|
|
82
|
+
const fields = useMemo(
|
|
83
|
+
() => storedFields(resource).filter(([key, def]) => !hidden.has(key) && conditionHolds(def.showWhen, values)),
|
|
84
|
+
[resource, hidden, values],
|
|
85
|
+
);
|
|
86
|
+
const steps = useMemo(() => toSteps(fields), [fields]);
|
|
76
87
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
77
88
|
const [pending, startTransition] = useTransition();
|
|
78
89
|
|
|
@@ -139,7 +150,9 @@ export function ResourceForm({ resource, mode, id, record, relations = {}, listH
|
|
|
139
150
|
router.refresh();
|
|
140
151
|
return;
|
|
141
152
|
}
|
|
142
|
-
|
|
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 };
|
|
143
156
|
setErrors(fieldErrors);
|
|
144
157
|
const broken = steps.findIndex((entries) => entries.some(([key]) => fieldErrors[key]));
|
|
145
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";
|
|
@@ -9,6 +11,7 @@ import { emailOTP } from "better-auth/plugins/email-otp";
|
|
|
9
11
|
import { magicLink } from "better-auth/plugins/magic-link";
|
|
10
12
|
import { twoFactor } from "better-auth/plugins/two-factor";
|
|
11
13
|
import { hashPassword, verifyPassword } from "@flaredev/core";
|
|
14
|
+
import { eq } from "drizzle-orm";
|
|
12
15
|
import { getDb, schema } from "@/db";
|
|
13
16
|
import { authConfig, type SocialProvider } from "./auth-config";
|
|
14
17
|
import { checkPassword, isBreachedPassword, PASSWORD_MAX_LENGTH } from "./password-rules";
|
|
@@ -107,6 +110,89 @@ async function guardPassword(path: string, body: Record<string, unknown> | undef
|
|
|
107
110
|
}
|
|
108
111
|
}
|
|
109
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
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The first account to exist becomes the admin.
|
|
159
|
+
*
|
|
160
|
+
* Generated resources are admin-only, so without this a fresh app has a database, an API
|
|
161
|
+
* and a dashboard that nobody can reach — and the usual fix for that is to open
|
|
162
|
+
* everything up, which is how an admin panel ends up on the internet. The person running
|
|
163
|
+
* sign-up on an empty database is the person setting the app up.
|
|
164
|
+
*
|
|
165
|
+
* Done here rather than in `databaseHooks.user.create`, because the admin plugin already
|
|
166
|
+
* registers one of those (`{ role: defaultRole, ...user }`) and Better Auth does not merge
|
|
167
|
+
* the two — a second one is silently never called. This runs after the row exists and
|
|
168
|
+
* states the rule in SQL, which no plugin can quietly win.
|
|
169
|
+
*
|
|
170
|
+
* Everyone after them keeps the plugin's default role, and no role reaches an admin-only
|
|
171
|
+
* resource until you grant one with `flare user:role them@example.com admin`.
|
|
172
|
+
*
|
|
173
|
+
* Two people signing up in the same instant would both see two rows and neither would be
|
|
174
|
+
* promoted, leaving an app with no admin. It needs a brand-new app and millisecond timing,
|
|
175
|
+
* and `flare user:role you@example.com admin` fixes it, so this stays the simple rule
|
|
176
|
+
* rather than a transaction nobody can read.
|
|
177
|
+
*/
|
|
178
|
+
let adminSettled = false;
|
|
179
|
+
|
|
180
|
+
async function promoteFirstUser() {
|
|
181
|
+
if (adminSettled) return;
|
|
182
|
+
const db = getDb();
|
|
183
|
+
// Two rows is all it takes to answer "is there exactly one?".
|
|
184
|
+
const rows = await db.select({ id: schema.user.id, role: schema.user.role }).from(schema.user).limit(2);
|
|
185
|
+
if (rows.length !== 1) {
|
|
186
|
+
// Nobody yet: the next sign-up asks again. More than one: the question is settled.
|
|
187
|
+
adminSettled = rows.length > 1;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (rows[0]!.role !== "admin") {
|
|
191
|
+
await db.update(schema.user).set({ role: "admin" }).where(eq(schema.user.id, rows[0]!.id));
|
|
192
|
+
}
|
|
193
|
+
adminSettled = true;
|
|
194
|
+
}
|
|
195
|
+
|
|
110
196
|
export const auth = betterAuth({
|
|
111
197
|
appName: "__APP_NAME__",
|
|
112
198
|
secret: env.BETTER_AUTH_SECRET,
|
|
@@ -143,16 +229,40 @@ export const auth = betterAuth({
|
|
|
143
229
|
// Let emails and other deferred work finish after the response is sent.
|
|
144
230
|
backgroundTasks: { handler: waitUntil },
|
|
145
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
|
+
},
|
|
146
243
|
hooks: {
|
|
147
244
|
before: createAuthMiddleware(async (ctx) => {
|
|
148
245
|
if (disabledPath(ctx.path)) throw new APIError("NOT_FOUND", { message: "This sign-in method isn't enabled." });
|
|
149
246
|
await guardPassword(ctx.path, ctx.body as Record<string, unknown> | undefined);
|
|
150
247
|
}),
|
|
248
|
+
// Any route can be the one that creates the first user — a password sign-up, a magic
|
|
249
|
+
// link, a social callback — so this asks after all of them rather than guessing which.
|
|
250
|
+
after: createAuthMiddleware(async () => {
|
|
251
|
+
await promoteFirstUser();
|
|
252
|
+
await spendUsedInvitation();
|
|
253
|
+
}),
|
|
151
254
|
},
|
|
152
255
|
plugins: [
|
|
153
|
-
// 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).
|
|
154
257
|
admin({ defaultRole: "user", adminRoles: ["admin"] }),
|
|
155
|
-
|
|
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
|
+
}),
|
|
156
266
|
emailOTP({ sendVerificationOTP: (data) => sendEmailCode(data), otpLength: 6, expiresIn: 5 * 60, allowedAttempts: 5 }),
|
|
157
267
|
twoFactor({
|
|
158
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
|
];
|