@flaredev/cli 0.9.1 → 0.11.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 +360 -59
- package/package.json +2 -2
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +8 -8
- package/templates/app/components/dashboard/resource-form.tsx +14 -3
- package/templates/app/lib/auth.ts +222 -154
- package/templates/app/lib/resource/drizzle-rows.ts +7 -2
- package/templates/app/lib/resource/rows.ts +5 -1
- package/templates/app/lib/resource/store.ts +106 -15
- package/templates/locks/{package-lock.json → cloudflare/package-lock.json} +1211 -1414
- package/templates/locks/{pnpm-lock.yaml → cloudflare/pnpm-lock.yaml} +1088 -1311
- package/templates/locks/{yarn.lock → cloudflare/yarn.lock} +1099 -1229
- package/templates/locks/next/package-lock.json +13673 -0
- package/templates/locks/next/pnpm-lock.yaml +8232 -0
- package/templates/locks/next/yarn.lock +5747 -0
- package/templates/next/.env.example +26 -10
- package/templates/next/docker-compose.yml +74 -0
- package/templates/next/lib/auth.ts +68 -2
- package/templates/next/lib/db.ts +5 -1
- package/templates/next/lib/resource/prisma-rows.ts +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flaredev/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "The flare command-line tool: create, generate, migrate, and deploy Flare apps",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Muke Johnbaptist",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"jiti": "^2.7.0",
|
|
42
42
|
"jsonc-parser": "^3.3.1",
|
|
43
43
|
"picocolors": "^1.1.1",
|
|
44
|
-
"@flaredev/core": "0.
|
|
44
|
+
"@flaredev/core": "0.11.0"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=22"
|
|
@@ -96,8 +96,8 @@ export function DashboardSidebar({
|
|
|
96
96
|
</SidebarMenu>
|
|
97
97
|
</SidebarHeader>
|
|
98
98
|
|
|
99
|
-
<SidebarContent>
|
|
100
|
-
<SidebarGroup>
|
|
99
|
+
<SidebarContent className="gap-0">
|
|
100
|
+
<SidebarGroup className="px-2 py-1">
|
|
101
101
|
<SidebarGroupContent>
|
|
102
102
|
<SidebarMenu>
|
|
103
103
|
<SidebarMenuItem>
|
|
@@ -113,8 +113,8 @@ export function DashboardSidebar({
|
|
|
113
113
|
</SidebarGroup>
|
|
114
114
|
|
|
115
115
|
{byGroup(resources).map(([heading, group]) => (
|
|
116
|
-
<SidebarGroup key={heading}>
|
|
117
|
-
<SidebarGroupLabel>{heading}</SidebarGroupLabel>
|
|
116
|
+
<SidebarGroup key={heading} className="px-2 py-1">
|
|
117
|
+
<SidebarGroupLabel className="mt-1 h-6">{heading}</SidebarGroupLabel>
|
|
118
118
|
<SidebarGroupContent>
|
|
119
119
|
<SidebarMenu>
|
|
120
120
|
{group.map((resource) => {
|
|
@@ -149,8 +149,8 @@ export function DashboardSidebar({
|
|
|
149
149
|
</SidebarGroup>
|
|
150
150
|
))}
|
|
151
151
|
|
|
152
|
-
<SidebarGroup>
|
|
153
|
-
<SidebarGroupLabel>Manage</SidebarGroupLabel>
|
|
152
|
+
<SidebarGroup className="px-2 py-1">
|
|
153
|
+
<SidebarGroupLabel className="mt-1 h-6">Manage</SidebarGroupLabel>
|
|
154
154
|
<SidebarGroupContent>
|
|
155
155
|
<SidebarMenu>
|
|
156
156
|
{manage.map((link) => (
|
|
@@ -168,8 +168,8 @@ export function DashboardSidebar({
|
|
|
168
168
|
</SidebarGroup>
|
|
169
169
|
|
|
170
170
|
{links.length > 0 && (
|
|
171
|
-
<SidebarGroup>
|
|
172
|
-
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
|
171
|
+
<SidebarGroup className="px-2 py-1">
|
|
172
|
+
<SidebarGroupLabel className="mt-1 h-6">Platform</SidebarGroupLabel>
|
|
173
173
|
<SidebarGroupContent>
|
|
174
174
|
<SidebarMenu>
|
|
175
175
|
{links.map((link) => {
|
|
@@ -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
|
|
|
@@ -1,154 +1,222 @@
|
|
|
1
|
-
import { env, waitUntil } from "cloudflare:workers";
|
|
2
|
-
import { passkey } from "@better-auth/passkey";
|
|
3
|
-
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
|
|
4
|
-
import { betterAuth } from "better-auth";
|
|
5
|
-
import { APIError, createAuthMiddleware } from "better-auth/api";
|
|
6
|
-
import { nextCookies } from "better-auth/next-js";
|
|
7
|
-
import { admin } from "better-auth/plugins/admin";
|
|
8
|
-
import { emailOTP } from "better-auth/plugins/email-otp";
|
|
9
|
-
import { magicLink } from "better-auth/plugins/magic-link";
|
|
10
|
-
import { twoFactor } from "better-auth/plugins/two-factor";
|
|
11
|
-
import { hashPassword, verifyPassword } from "@flaredev/core";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (!
|
|
70
|
-
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
1
|
+
import { env, waitUntil } from "cloudflare:workers";
|
|
2
|
+
import { passkey } from "@better-auth/passkey";
|
|
3
|
+
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
|
|
4
|
+
import { betterAuth } from "better-auth";
|
|
5
|
+
import { APIError, createAuthMiddleware } from "better-auth/api";
|
|
6
|
+
import { nextCookies } from "better-auth/next-js";
|
|
7
|
+
import { admin } from "better-auth/plugins/admin";
|
|
8
|
+
import { emailOTP } from "better-auth/plugins/email-otp";
|
|
9
|
+
import { magicLink } from "better-auth/plugins/magic-link";
|
|
10
|
+
import { twoFactor } from "better-auth/plugins/two-factor";
|
|
11
|
+
import { hashPassword, verifyPassword } from "@flaredev/core";
|
|
12
|
+
import { eq } from "drizzle-orm";
|
|
13
|
+
import { getDb, schema } from "@/db";
|
|
14
|
+
import { authConfig, type SocialProvider } from "./auth-config";
|
|
15
|
+
import { checkPassword, isBreachedPassword, PASSWORD_MAX_LENGTH } from "./password-rules";
|
|
16
|
+
import { sendEmailCode, sendMagicLink, sendPasswordReset, sendTwoFactorCode, sendVerificationEmail } from "./auth-emails";
|
|
17
|
+
|
|
18
|
+
const vars = env as unknown as Record<string, string | undefined>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Optional: set when serving from a custom domain (e.g. https://example.com).
|
|
22
|
+
*
|
|
23
|
+
* Better Auth compares the request's Origin header against this string, and an Origin never
|
|
24
|
+
* carries a trailing slash. So `https://example.com/` — which is what you get copying a
|
|
25
|
+
* domain out of a browser bar or the Vercel dashboard — matches nothing, and every sign-in
|
|
26
|
+
* comes back 403 with nothing in the log saying why. Correct it and say so: a warning that
|
|
27
|
+
* leaves sign-in broken is not much better than the silence it replaces.
|
|
28
|
+
*/
|
|
29
|
+
function authBaseURL(raw: string | undefined): string | undefined {
|
|
30
|
+
const value = raw?.trim();
|
|
31
|
+
if (!value) return undefined;
|
|
32
|
+
// Only a trailing slash is removed. A path stays: an app served under one legitimately
|
|
33
|
+
// sets BETTER_AUTH_URL to https://example.com/app.
|
|
34
|
+
const url = value.replace(/\/+$/, "");
|
|
35
|
+
if (url !== value) {
|
|
36
|
+
console.warn(`[auth] BETTER_AUTH_URL has a trailing slash, which never matches an Origin header and makes every sign-in 403. Using "${url}" — set BETTER_AUTH_URL to that to silence this.`);
|
|
37
|
+
}
|
|
38
|
+
if (url && !/^https?:\/\//.test(url)) {
|
|
39
|
+
console.warn(`[auth] BETTER_AUTH_URL ("${url}") has no scheme, so it cannot match an Origin. Set it to a full origin, e.g. https://example.com.`);
|
|
40
|
+
}
|
|
41
|
+
return url || undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const configuredURL = authBaseURL(vars.BETTER_AUTH_URL);
|
|
45
|
+
|
|
46
|
+
/** A provider's credentials from the environment, when both are set. */
|
|
47
|
+
function credentials(prefix: string) {
|
|
48
|
+
const clientId = vars[`${prefix}_CLIENT_ID`];
|
|
49
|
+
const clientSecret = vars[`${prefix}_CLIENT_SECRET`];
|
|
50
|
+
return clientId && clientSecret ? { clientId, clientSecret } : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const providerSettings: Record<SocialProvider, Record<string, string> | undefined> = {
|
|
54
|
+
google: credentials("GOOGLE"),
|
|
55
|
+
github: credentials("GITHUB"),
|
|
56
|
+
// Apple's client secret is a JWT you generate from your key (see the auth guide).
|
|
57
|
+
apple: credentials("APPLE") && { ...credentials("APPLE")!, ...(vars.APPLE_APP_BUNDLE_ID ? { appBundleIdentifier: vars.APPLE_APP_BUNDLE_ID } : {}) },
|
|
58
|
+
// "common" accepts personal and work accounts; set a tenant id to allow only your organisation.
|
|
59
|
+
microsoft: credentials("MICROSOFT") && { ...credentials("MICROSOFT")!, tenantId: vars.MICROSOFT_TENANT_ID || "common" },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Social providers switched on in lib/auth-config.ts that also have credentials. */
|
|
63
|
+
const socialProviders = Object.fromEntries(
|
|
64
|
+
authConfig.social.flatMap((provider) => (providerSettings[provider] ? [[provider, providerSettings[provider]]] : [])),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
/** Endpoints of methods switched off in lib/auth-config.ts: refused, not just hidden. */
|
|
68
|
+
function disabledPath(path: string): boolean {
|
|
69
|
+
if (!authConfig.magicLink && (path === "/sign-in/magic-link" || path === "/magic-link/verify")) return true;
|
|
70
|
+
if (!authConfig.emailOtp && (path.startsWith("/email-otp/") || path === "/sign-in/email-otp" || path === "/forget-password/email-otp")) return true;
|
|
71
|
+
if (!authConfig.passkeys && path.startsWith("/passkey/")) return true;
|
|
72
|
+
const { authenticator, email } = authConfig.twoFactor;
|
|
73
|
+
if (!authenticator && !email && path.startsWith("/two-factor/")) return true;
|
|
74
|
+
if (!authenticator && (path === "/two-factor/get-totp-uri" || path === "/two-factor/verify-totp")) return true;
|
|
75
|
+
if (!email && (path === "/two-factor/send-otp" || path === "/two-factor/verify-otp")) return true;
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Where a new password arrives, and what it's called in the body. */
|
|
80
|
+
const NEW_PASSWORD_FIELDS: Record<string, string> = {
|
|
81
|
+
"/sign-up/email": "password",
|
|
82
|
+
"/reset-password": "newPassword",
|
|
83
|
+
"/change-password": "newPassword",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The same password rules the sign-up and reset forms show as you type
|
|
88
|
+
* (lib/password-rules.ts), enforced here so the API agrees with the screens.
|
|
89
|
+
* Checked on the way in, before the password is hashed or stored.
|
|
90
|
+
*/
|
|
91
|
+
async function guardPassword(path: string, body: Record<string, unknown> | undefined) {
|
|
92
|
+
const field = NEW_PASSWORD_FIELDS[path];
|
|
93
|
+
if (!field) return;
|
|
94
|
+
const password = body?.[field];
|
|
95
|
+
if (typeof password !== "string") return;
|
|
96
|
+
|
|
97
|
+
if (password.length > PASSWORD_MAX_LENGTH) {
|
|
98
|
+
throw new APIError("BAD_REQUEST", { message: `Passwords can be at most ${PASSWORD_MAX_LENGTH} characters.` });
|
|
99
|
+
}
|
|
100
|
+
const check = checkPassword(password, [body?.email as string | undefined, body?.name as string | undefined]);
|
|
101
|
+
if (!check.valid) throw new APIError("BAD_REQUEST", { message: check.advice ?? "That password is too short." });
|
|
102
|
+
|
|
103
|
+
// A password in a public breach list is already guessed, however strong it looks.
|
|
104
|
+
if (authConfig.checkBreachedPasswords && (await isBreachedPassword(password))) {
|
|
105
|
+
throw new APIError("BAD_REQUEST", {
|
|
106
|
+
message: "That password has appeared in a data breach, so it isn't safe to use here. Pick a different one.",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The first account to exist becomes the admin.
|
|
113
|
+
*
|
|
114
|
+
* Generated resources are admin-only, so without this a fresh app has a database, an API
|
|
115
|
+
* and a dashboard that nobody can reach — and the usual fix for that is to open
|
|
116
|
+
* everything up, which is how an admin panel ends up on the internet. The person running
|
|
117
|
+
* sign-up on an empty database is the person setting the app up.
|
|
118
|
+
*
|
|
119
|
+
* Done here rather than in `databaseHooks.user.create`, because the admin plugin already
|
|
120
|
+
* registers one of those (`{ role: defaultRole, ...user }`) and Better Auth does not merge
|
|
121
|
+
* the two — a second one is silently never called. This runs after the row exists and
|
|
122
|
+
* states the rule in SQL, which no plugin can quietly win.
|
|
123
|
+
*
|
|
124
|
+
* Everyone after them keeps the plugin's default role, and no role reaches an admin-only
|
|
125
|
+
* resource until you grant one with `flare user:role them@example.com admin`.
|
|
126
|
+
*
|
|
127
|
+
* Two people signing up in the same instant would both see two rows and neither would be
|
|
128
|
+
* promoted, leaving an app with no admin. It needs a brand-new app and millisecond timing,
|
|
129
|
+
* and `flare user:role you@example.com admin` fixes it, so this stays the simple rule
|
|
130
|
+
* rather than a transaction nobody can read.
|
|
131
|
+
*/
|
|
132
|
+
let adminSettled = false;
|
|
133
|
+
|
|
134
|
+
async function promoteFirstUser() {
|
|
135
|
+
if (adminSettled) return;
|
|
136
|
+
const db = getDb();
|
|
137
|
+
// Two rows is all it takes to answer "is there exactly one?".
|
|
138
|
+
const rows = await db.select({ id: schema.user.id, role: schema.user.role }).from(schema.user).limit(2);
|
|
139
|
+
if (rows.length !== 1) {
|
|
140
|
+
// Nobody yet: the next sign-up asks again. More than one: the question is settled.
|
|
141
|
+
adminSettled = rows.length > 1;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (rows[0]!.role !== "admin") {
|
|
145
|
+
await db.update(schema.user).set({ role: "admin" }).where(eq(schema.user.id, rows[0]!.id));
|
|
146
|
+
}
|
|
147
|
+
adminSettled = true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const auth = betterAuth({
|
|
151
|
+
appName: "__APP_NAME__",
|
|
152
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
153
|
+
// Without BETTER_AUTH_URL, accept local dev on any port, a `flare dev --tunnel` URL and
|
|
154
|
+
// this app's workers.dev host. Cloudflare only routes hosts this Worker serves, so a
|
|
155
|
+
// deployed app never sees a trycloudflare.com host and the allowlist can't be spoofed.
|
|
156
|
+
baseURL: configuredURL || {
|
|
157
|
+
allowedHosts: ["localhost:*", "127.0.0.1:*", "*.trycloudflare.com", "__APP_NAME__.*.workers.dev"],
|
|
158
|
+
},
|
|
159
|
+
// Sign in with Apple posts back from Apple's own origin.
|
|
160
|
+
trustedOrigins: authConfig.social.includes("apple") ? ["https://appleid.apple.com"] : [],
|
|
161
|
+
database: drizzleAdapter(getDb(), { provider: "sqlite", schema }),
|
|
162
|
+
socialProviders,
|
|
163
|
+
account: {
|
|
164
|
+
// Signing in with Google as ada@example.com joins Ada's existing account (the provider has verified the address).
|
|
165
|
+
accountLinking: { enabled: true, trustedProviders: ["google", "github", "apple", "microsoft"] },
|
|
166
|
+
},
|
|
167
|
+
emailAndPassword: {
|
|
168
|
+
enabled: true,
|
|
169
|
+
requireEmailVerification: authConfig.requireEmailVerification,
|
|
170
|
+
// PBKDF2 via WebCrypto: the default scrypt exceeds Workers CPU limits.
|
|
171
|
+
password: { hash: hashPassword, verify: verifyPassword },
|
|
172
|
+
sendResetPassword: ({ user, url }) => sendPasswordReset({ user, url }),
|
|
173
|
+
resetPasswordTokenExpiresIn: 60 * 60,
|
|
174
|
+
// A password reset signs every other device out.
|
|
175
|
+
revokeSessionsOnPasswordReset: true,
|
|
176
|
+
},
|
|
177
|
+
emailVerification: {
|
|
178
|
+
sendVerificationEmail: ({ user, url }) => sendVerificationEmail({ user, url }),
|
|
179
|
+
sendOnSignUp: true,
|
|
180
|
+
autoSignInAfterVerification: true,
|
|
181
|
+
},
|
|
182
|
+
advanced: {
|
|
183
|
+
// Let emails and other deferred work finish after the response is sent.
|
|
184
|
+
backgroundTasks: { handler: waitUntil },
|
|
185
|
+
},
|
|
186
|
+
hooks: {
|
|
187
|
+
before: createAuthMiddleware(async (ctx) => {
|
|
188
|
+
if (disabledPath(ctx.path)) throw new APIError("NOT_FOUND", { message: "This sign-in method isn't enabled." });
|
|
189
|
+
await guardPassword(ctx.path, ctx.body as Record<string, unknown> | undefined);
|
|
190
|
+
}),
|
|
191
|
+
// Any route can be the one that creates the first user — a password sign-up, a magic
|
|
192
|
+
// link, a social callback — so this asks after all of them rather than guessing which.
|
|
193
|
+
after: createAuthMiddleware(async () => {
|
|
194
|
+
await promoteFirstUser();
|
|
195
|
+
}),
|
|
196
|
+
},
|
|
197
|
+
plugins: [
|
|
198
|
+
// Adds user.role (default "user") and admin APIs. The /admin area allows ADMIN_ROLES (lib/admin.ts).
|
|
199
|
+
admin({ defaultRole: "user", adminRoles: ["admin"] }),
|
|
200
|
+
magicLink({ sendMagicLink: ({ email, url }) => sendMagicLink({ email, url }), expiresIn: 5 * 60, disableSignUp: false }),
|
|
201
|
+
emailOTP({ sendVerificationOTP: (data) => sendEmailCode(data), otpLength: 6, expiresIn: 5 * 60, allowedAttempts: 5 }),
|
|
202
|
+
twoFactor({
|
|
203
|
+
issuer: "__APP_NAME__",
|
|
204
|
+
otpOptions: { sendOTP: ({ user, otp }) => sendTwoFactorCode({ user, otp }), allowedAttempts: 5 },
|
|
205
|
+
// With only email codes switched on there's no authenticator app to confirm first.
|
|
206
|
+
skipVerificationOnEnable: !authConfig.twoFactor.authenticator,
|
|
207
|
+
// People who signed up with a social account have no password to confirm with;
|
|
208
|
+
// accounts that do have one must still give it.
|
|
209
|
+
allowPasswordless: true,
|
|
210
|
+
}),
|
|
211
|
+
passkey({ rpName: "__APP_NAME__" }),
|
|
212
|
+
// Must stay last: lets server actions set auth cookies.
|
|
213
|
+
nextCookies(),
|
|
214
|
+
],
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
export type Session = typeof auth.$Infer.Session;
|
|
218
|
+
|
|
219
|
+
/** OAuth providers switched on and configured, e.g. ["github"], for the sign-in buttons. */
|
|
220
|
+
export function enabledSocialProviders(): SocialProvider[] {
|
|
221
|
+
return Object.keys(socialProviders) as SocialProvider[];
|
|
222
|
+
}
|
|
@@ -108,9 +108,14 @@ export function drizzleRows(table: SQLiteTable, getDb: () => AnyDatabase): Resou
|
|
|
108
108
|
return (record as Row | undefined) ?? null;
|
|
109
109
|
},
|
|
110
110
|
|
|
111
|
-
async titles(ids, titleField) {
|
|
111
|
+
async titles(ids, titleField, owner) {
|
|
112
112
|
const titleColumn = columns[titleField] ?? idColumn;
|
|
113
|
-
const
|
|
113
|
+
const ownerColumn = owner ? columns[owner.field] : undefined;
|
|
114
|
+
// An owner naming a column this table does not have would widen the query rather
|
|
115
|
+
// than narrow it, so it answers nothing instead.
|
|
116
|
+
if (owner && !ownerColumn) return [];
|
|
117
|
+
const where = ownerColumn ? and(inArray(idColumn, ids), eq(ownerColumn, owner!.value)) : inArray(idColumn, ids);
|
|
118
|
+
const rows = await getDb().select({ id: idColumn, title: titleColumn }).from(table).where(where);
|
|
114
119
|
return rows as { id: string; title: unknown }[];
|
|
115
120
|
},
|
|
116
121
|
|
|
@@ -78,7 +78,11 @@ export interface ResourceRows {
|
|
|
78
78
|
/** How many rows match, giving up at `limit` so a huge table is never fully counted. */
|
|
79
79
|
countUpTo(query: Pick<RowsQuery, "search" | "filters" | "deleted">, limit: number): Promise<number>;
|
|
80
80
|
byId(id: string): Promise<Row | null>;
|
|
81
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Title values for a set of ids. `owner`, when given, restricts the rows considered —
|
|
83
|
+
* a relation picker shown to a confined user must not name records they cannot see.
|
|
84
|
+
*/
|
|
85
|
+
titles(ids: string[], titleField: string, owner?: { field: string; value: string }): Promise<{ id: string; title: unknown }[]>;
|
|
82
86
|
insert(values: Row): Promise<Row>;
|
|
83
87
|
/** The saved row, or null when there was nothing with that id. */
|
|
84
88
|
update(id: string, values: Row): Promise<Row | null>;
|