@flaredev/cli 0.1.2 → 0.3.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/bin/flare.js +9 -1
- package/dist/index.js +985 -337
- package/package.json +3 -2
- package/templates/app/app/admin/page.tsx +9 -61
- package/templates/app/app/api/openapi.json/route.ts +23 -0
- package/templates/app/app/api/reference/route.ts +36 -0
- package/templates/app/app/dashboard/account/page.tsx +40 -0
- package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
- package/templates/app/app/dashboard/import-actions.ts +91 -0
- package/templates/app/app/dashboard/layout.tsx +42 -0
- package/templates/app/app/dashboard/observability/page.tsx +174 -0
- package/templates/app/app/dashboard/page.tsx +123 -9
- package/templates/app/app/forgot-password/page.tsx +22 -0
- package/templates/app/app/globals.css +109 -4
- package/templates/app/app/layout.tsx +28 -26
- package/templates/app/app/page.tsx +126 -13
- package/templates/app/app/reset-password/page.tsx +31 -0
- package/templates/app/app/sign-in/page.tsx +49 -33
- package/templates/app/app/sign-up/page.tsx +42 -23
- package/templates/app/app/two-factor/page.tsx +25 -0
- package/templates/app/app/verify-email/page.tsx +33 -0
- package/templates/app/components/account/security-settings.tsx +522 -0
- package/templates/app/components/auth/auth-shell.tsx +284 -0
- package/templates/app/components/auth/otp-input.tsx +95 -0
- package/templates/app/components/auth/password-field.tsx +116 -0
- package/templates/app/components/auth/password-forms.tsx +76 -0
- package/templates/app/components/auth/provider-icons.tsx +39 -0
- package/templates/app/components/auth/sign-in-flow.tsx +306 -0
- package/templates/app/components/auth/sign-up-form.tsx +93 -0
- package/templates/app/components/auth/social-buttons.tsx +56 -0
- package/templates/app/components/auth/two-factor-form.tsx +139 -0
- package/templates/app/components/auth/ui.tsx +96 -0
- package/templates/app/components/auth/use-resend.ts +44 -0
- package/templates/app/components/auth/verify-email-form.tsx +135 -0
- package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
- package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +66 -9
- package/templates/app/components/dashboard/export-button.tsx +41 -0
- package/templates/app/components/dashboard/fields/country-field.tsx +152 -0
- package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +186 -128
- package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
- package/templates/app/components/dashboard/fields/multi-select-field.tsx +46 -0
- package/templates/app/components/dashboard/fields/phone-field.tsx +86 -0
- package/templates/app/components/dashboard/import-dialog.tsx +487 -0
- package/templates/app/components/dashboard/page-header.tsx +57 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
- package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
- package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
- package/templates/app/components/dashboard/resource-stats.tsx +53 -0
- package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -251
- package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
- package/templates/app/components/dashboard/stat-card.tsx +69 -0
- package/templates/app/components/dashboard/table-selection.tsx +185 -0
- package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
- package/templates/app/components/marketing/product-preview.tsx +70 -0
- package/templates/app/components/marketing/site-header.tsx +39 -0
- package/templates/app/components/sign-out-button.tsx +1 -1
- package/templates/app/components/ui/checkbox.tsx +25 -0
- package/templates/app/components/ui/radio-group.tsx +29 -0
- package/templates/app/db/auth-schema.ts +167 -118
- package/templates/app/db/flare-schema.ts +33 -1
- package/templates/app/lib/analytics.ts +121 -0
- package/templates/app/lib/api-docs.ts +16 -0
- package/templates/app/lib/audit.ts +80 -0
- package/templates/app/lib/auth-client.ts +16 -1
- package/templates/app/lib/auth-config.ts +38 -0
- package/templates/app/lib/auth-emails.ts +94 -0
- package/templates/app/lib/auth.ts +115 -6
- package/templates/app/lib/cache.ts +83 -83
- package/templates/app/lib/csv.ts +24 -0
- package/templates/app/lib/dashboard-nav.ts +15 -0
- package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
- package/templates/app/lib/password-rules.ts +159 -0
- package/templates/app/lib/site.ts +56 -0
- package/templates/app/lib/theme.ts +79 -0
- package/templates/app/migrations/0000_init.sql +47 -4
- package/templates/app/migrations/meta/0000_snapshot.json +311 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/vite.config.ts +5 -3
- package/templates/app/worker-configuration.d.ts +15764 -0
- package/templates/locks/package-lock.json +9189 -0
- package/templates/locks/pnpm-lock.yaml +6347 -0
- package/templates/app/app/admin/layout.tsx +0 -32
- package/templates/app/components/auth-form.tsx +0 -120
- package/templates/app/lib/admin-nav.ts +0 -15
- /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
|
@@ -0,0 +1,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
|
+
/** 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;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import { getDb } from "@/db";
|
|
3
|
+
import { user } from "@/db/auth-schema";
|
|
4
|
+
import { sendTransactionalEmail } from "./mail";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Every email the sign-in flows send. Change the wording here; the layout comes from
|
|
8
|
+
* lib/mail.ts. Without RESEND_API_KEY these print to the console, links and codes included.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const ignore = "If you didn't ask for this, you can ignore this email.";
|
|
12
|
+
|
|
13
|
+
/** Accounts with two-factor sign-in never get a one-step email sign-in (it would skip the second factor). */
|
|
14
|
+
async function usesTwoFactor(email: string): Promise<boolean> {
|
|
15
|
+
const row = await getDb().select({ on: user.twoFactorEnabled }).from(user).where(eq(user.email, email.toLowerCase())).get();
|
|
16
|
+
return Boolean(row?.on);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function sendTwoFactorNotice(email: string) {
|
|
20
|
+
await sendTransactionalEmail({
|
|
21
|
+
to: email,
|
|
22
|
+
subject: "Sign in with your password or a passkey",
|
|
23
|
+
heading: "Email sign-in is off for your account",
|
|
24
|
+
paragraphs: [
|
|
25
|
+
"Someone asked to sign in to your account by email. Your account uses two-factor authentication, so we don't send sign-in links or codes.",
|
|
26
|
+
"Sign in with your password and second factor, or with a passkey.",
|
|
27
|
+
],
|
|
28
|
+
footer: ignore,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function sendMagicLink({ email, url }: { email: string; url: string }) {
|
|
33
|
+
if (await usesTwoFactor(email)) return sendTwoFactorNotice(email);
|
|
34
|
+
await sendTransactionalEmail({
|
|
35
|
+
to: email,
|
|
36
|
+
subject: "Your sign-in link",
|
|
37
|
+
heading: "Sign in",
|
|
38
|
+
paragraphs: ["Use this link to sign in. It works once and expires in 5 minutes."],
|
|
39
|
+
action: { label: "Sign in", url },
|
|
40
|
+
footer: ignore,
|
|
41
|
+
preheader: "Your sign-in link",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function sendEmailCode({ email, otp, type }: { email: string; otp: string; type: "sign-in" | "email-verification" | "forget-password" | "change-email" }) {
|
|
46
|
+
if (type === "sign-in" && (await usesTwoFactor(email))) return sendTwoFactorNotice(email);
|
|
47
|
+
const purpose = {
|
|
48
|
+
"sign-in": ["Your sign-in code", "Enter this code to sign in."],
|
|
49
|
+
"email-verification": ["Verify your email", "Enter this code to verify your email address."],
|
|
50
|
+
"forget-password": ["Reset your password", "Enter this code to choose a new password."],
|
|
51
|
+
"change-email": ["Confirm your new email", "Enter this code to confirm your new email address."],
|
|
52
|
+
}[type];
|
|
53
|
+
await sendTransactionalEmail({
|
|
54
|
+
to: email,
|
|
55
|
+
subject: `${purpose[0]}: ${otp}`,
|
|
56
|
+
heading: otp,
|
|
57
|
+
paragraphs: [purpose[1], "It expires in 5 minutes."],
|
|
58
|
+
footer: ignore,
|
|
59
|
+
preheader: purpose[0],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function sendTwoFactorCode({ user, otp }: { user: { email: string }; otp: string }) {
|
|
64
|
+
await sendTransactionalEmail({
|
|
65
|
+
to: user.email,
|
|
66
|
+
subject: `Your verification code: ${otp}`,
|
|
67
|
+
heading: otp,
|
|
68
|
+
paragraphs: ["Enter this code to finish signing in. It expires in 5 minutes."],
|
|
69
|
+
footer: "If you didn't just sign in, change your password: someone else knows it.",
|
|
70
|
+
preheader: "Your verification code",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function sendPasswordReset({ user, url }: { user: { email: string }; url: string }) {
|
|
75
|
+
await sendTransactionalEmail({
|
|
76
|
+
to: user.email,
|
|
77
|
+
subject: "Reset your password",
|
|
78
|
+
heading: "Reset your password",
|
|
79
|
+
paragraphs: ["Use this link to choose a new password. It expires in 1 hour."],
|
|
80
|
+
action: { label: "Choose a new password", url },
|
|
81
|
+
footer: ignore,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function sendVerificationEmail({ user, url }: { user: { email: string }; url: string }) {
|
|
86
|
+
await sendTransactionalEmail({
|
|
87
|
+
to: user.email,
|
|
88
|
+
subject: "Verify your email",
|
|
89
|
+
heading: "Verify your email",
|
|
90
|
+
paragraphs: ["Confirm this is your email address."],
|
|
91
|
+
action: { label: "Verify email", url },
|
|
92
|
+
footer: ignore,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -1,13 +1,88 @@
|
|
|
1
1
|
import { env, waitUntil } from "cloudflare:workers";
|
|
2
|
-
import {
|
|
2
|
+
import { passkey } from "@better-auth/passkey";
|
|
3
3
|
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
|
|
4
|
+
import { betterAuth } from "better-auth";
|
|
5
|
+
import { APIError, createAuthMiddleware } from "better-auth/api";
|
|
4
6
|
import { nextCookies } from "better-auth/next-js";
|
|
5
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";
|
|
6
11
|
import { hashPassword, verifyPassword } from "@flaredev/core";
|
|
7
12
|
import { getDb, schema } from "@/db";
|
|
13
|
+
import { authConfig, type SocialProvider } from "./auth-config";
|
|
14
|
+
import { checkPassword, isBreachedPassword, PASSWORD_MAX_LENGTH } from "./password-rules";
|
|
15
|
+
import { sendEmailCode, sendMagicLink, sendPasswordReset, sendTwoFactorCode, sendVerificationEmail } from "./auth-emails";
|
|
16
|
+
|
|
17
|
+
const vars = env as unknown as Record<string, string | undefined>;
|
|
8
18
|
|
|
9
19
|
/** Optional: set when serving from a custom domain (e.g. https://example.com). */
|
|
10
|
-
const configuredURL =
|
|
20
|
+
const configuredURL = vars.BETTER_AUTH_URL;
|
|
21
|
+
|
|
22
|
+
/** A provider's credentials from the environment, when both are set. */
|
|
23
|
+
function credentials(prefix: string) {
|
|
24
|
+
const clientId = vars[`${prefix}_CLIENT_ID`];
|
|
25
|
+
const clientSecret = vars[`${prefix}_CLIENT_SECRET`];
|
|
26
|
+
return clientId && clientSecret ? { clientId, clientSecret } : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const providerSettings: Record<SocialProvider, Record<string, string> | undefined> = {
|
|
30
|
+
google: credentials("GOOGLE"),
|
|
31
|
+
github: credentials("GITHUB"),
|
|
32
|
+
// Apple's client secret is a JWT you generate from your key (see the auth guide).
|
|
33
|
+
apple: credentials("APPLE") && { ...credentials("APPLE")!, ...(vars.APPLE_APP_BUNDLE_ID ? { appBundleIdentifier: vars.APPLE_APP_BUNDLE_ID } : {}) },
|
|
34
|
+
// "common" accepts personal and work accounts; set a tenant id to allow only your organisation.
|
|
35
|
+
microsoft: credentials("MICROSOFT") && { ...credentials("MICROSOFT")!, tenantId: vars.MICROSOFT_TENANT_ID || "common" },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Social providers switched on in lib/auth-config.ts that also have credentials. */
|
|
39
|
+
const socialProviders = Object.fromEntries(
|
|
40
|
+
authConfig.social.flatMap((provider) => (providerSettings[provider] ? [[provider, providerSettings[provider]]] : [])),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/** Endpoints of methods switched off in lib/auth-config.ts: refused, not just hidden. */
|
|
44
|
+
function disabledPath(path: string): boolean {
|
|
45
|
+
if (!authConfig.magicLink && (path === "/sign-in/magic-link" || path === "/magic-link/verify")) return true;
|
|
46
|
+
if (!authConfig.emailOtp && (path.startsWith("/email-otp/") || path === "/sign-in/email-otp" || path === "/forget-password/email-otp")) return true;
|
|
47
|
+
if (!authConfig.passkeys && path.startsWith("/passkey/")) return true;
|
|
48
|
+
const { authenticator, email } = authConfig.twoFactor;
|
|
49
|
+
if (!authenticator && !email && path.startsWith("/two-factor/")) return true;
|
|
50
|
+
if (!authenticator && (path === "/two-factor/get-totp-uri" || path === "/two-factor/verify-totp")) return true;
|
|
51
|
+
if (!email && (path === "/two-factor/send-otp" || path === "/two-factor/verify-otp")) return true;
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Where a new password arrives, and what it's called in the body. */
|
|
56
|
+
const NEW_PASSWORD_FIELDS: Record<string, string> = {
|
|
57
|
+
"/sign-up/email": "password",
|
|
58
|
+
"/reset-password": "newPassword",
|
|
59
|
+
"/change-password": "newPassword",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The same password rules the sign-up and reset forms show as you type
|
|
64
|
+
* (lib/password-rules.ts), enforced here so the API agrees with the screens.
|
|
65
|
+
* Checked on the way in, before the password is hashed or stored.
|
|
66
|
+
*/
|
|
67
|
+
async function guardPassword(path: string, body: Record<string, unknown> | undefined) {
|
|
68
|
+
const field = NEW_PASSWORD_FIELDS[path];
|
|
69
|
+
if (!field) return;
|
|
70
|
+
const password = body?.[field];
|
|
71
|
+
if (typeof password !== "string") return;
|
|
72
|
+
|
|
73
|
+
if (password.length > PASSWORD_MAX_LENGTH) {
|
|
74
|
+
throw new APIError("BAD_REQUEST", { message: `Passwords can be at most ${PASSWORD_MAX_LENGTH} characters.` });
|
|
75
|
+
}
|
|
76
|
+
const check = checkPassword(password, [body?.email as string | undefined, body?.name as string | undefined]);
|
|
77
|
+
if (!check.valid) throw new APIError("BAD_REQUEST", { message: check.advice ?? "That password is too short." });
|
|
78
|
+
|
|
79
|
+
// A password in a public breach list is already guessed, however strong it looks.
|
|
80
|
+
if (authConfig.checkBreachedPasswords && (await isBreachedPassword(password))) {
|
|
81
|
+
throw new APIError("BAD_REQUEST", {
|
|
82
|
+
message: "That password has appeared in a data breach, so it isn't safe to use here. Pick a different one.",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
11
86
|
|
|
12
87
|
export const auth = betterAuth({
|
|
13
88
|
appName: "__APP_NAME__",
|
|
@@ -18,20 +93,54 @@ export const auth = betterAuth({
|
|
|
18
93
|
baseURL: configuredURL || {
|
|
19
94
|
allowedHosts: ["localhost:*", "127.0.0.1:*", "*.trycloudflare.com", "__APP_NAME__.*.workers.dev"],
|
|
20
95
|
},
|
|
96
|
+
// Sign in with Apple posts back from Apple's own origin.
|
|
97
|
+
trustedOrigins: authConfig.social.includes("apple") ? ["https://appleid.apple.com"] : [],
|
|
21
98
|
database: drizzleAdapter(getDb(), { provider: "sqlite", schema }),
|
|
22
|
-
socialProviders
|
|
99
|
+
socialProviders,
|
|
100
|
+
account: {
|
|
101
|
+
// Signing in with Google as ada@example.com joins Ada's existing account (the provider has verified the address).
|
|
102
|
+
accountLinking: { enabled: true, trustedProviders: ["google", "github", "apple", "microsoft"] },
|
|
103
|
+
},
|
|
23
104
|
emailAndPassword: {
|
|
24
105
|
enabled: true,
|
|
106
|
+
requireEmailVerification: authConfig.requireEmailVerification,
|
|
25
107
|
// PBKDF2 via WebCrypto: the default scrypt exceeds Workers CPU limits.
|
|
26
108
|
password: { hash: hashPassword, verify: verifyPassword },
|
|
109
|
+
sendResetPassword: ({ user, url }) => sendPasswordReset({ user, url }),
|
|
110
|
+
resetPasswordTokenExpiresIn: 60 * 60,
|
|
111
|
+
// A password reset signs every other device out.
|
|
112
|
+
revokeSessionsOnPasswordReset: true,
|
|
113
|
+
},
|
|
114
|
+
emailVerification: {
|
|
115
|
+
sendVerificationEmail: ({ user, url }) => sendVerificationEmail({ user, url }),
|
|
116
|
+
sendOnSignUp: true,
|
|
117
|
+
autoSignInAfterVerification: true,
|
|
27
118
|
},
|
|
28
119
|
advanced: {
|
|
29
120
|
// Let emails and other deferred work finish after the response is sent.
|
|
30
121
|
backgroundTasks: { handler: waitUntil },
|
|
31
122
|
},
|
|
123
|
+
hooks: {
|
|
124
|
+
before: createAuthMiddleware(async (ctx) => {
|
|
125
|
+
if (disabledPath(ctx.path)) throw new APIError("NOT_FOUND", { message: "This sign-in method isn't enabled." });
|
|
126
|
+
await guardPassword(ctx.path, ctx.body as Record<string, unknown> | undefined);
|
|
127
|
+
}),
|
|
128
|
+
},
|
|
32
129
|
plugins: [
|
|
33
130
|
// Adds user.role (default "user") and admin APIs. The /admin area allows ADMIN_ROLES (lib/admin.ts).
|
|
34
131
|
admin({ defaultRole: "user", adminRoles: ["admin"] }),
|
|
132
|
+
magicLink({ sendMagicLink: ({ email, url }) => sendMagicLink({ email, url }), expiresIn: 5 * 60, disableSignUp: false }),
|
|
133
|
+
emailOTP({ sendVerificationOTP: (data) => sendEmailCode(data), otpLength: 6, expiresIn: 5 * 60, allowedAttempts: 5 }),
|
|
134
|
+
twoFactor({
|
|
135
|
+
issuer: "__APP_NAME__",
|
|
136
|
+
otpOptions: { sendOTP: ({ user, otp }) => sendTwoFactorCode({ user, otp }), allowedAttempts: 5 },
|
|
137
|
+
// With only email codes switched on there's no authenticator app to confirm first.
|
|
138
|
+
skipVerificationOnEnable: !authConfig.twoFactor.authenticator,
|
|
139
|
+
// People who signed up with a social account have no password to confirm with;
|
|
140
|
+
// accounts that do have one must still give it.
|
|
141
|
+
allowPasswordless: true,
|
|
142
|
+
}),
|
|
143
|
+
passkey({ rpName: "__APP_NAME__" }),
|
|
35
144
|
// Must stay last: lets server actions set auth cookies.
|
|
36
145
|
nextCookies(),
|
|
37
146
|
],
|
|
@@ -39,7 +148,7 @@ export const auth = betterAuth({
|
|
|
39
148
|
|
|
40
149
|
export type Session = typeof auth.$Infer.Session;
|
|
41
150
|
|
|
42
|
-
/** OAuth providers
|
|
43
|
-
export function enabledSocialProviders():
|
|
44
|
-
return Object.keys(
|
|
151
|
+
/** OAuth providers switched on and configured, e.g. ["github"], for the sign-in buttons. */
|
|
152
|
+
export function enabledSocialProviders(): SocialProvider[] {
|
|
153
|
+
return Object.keys(socialProviders) as SocialProvider[];
|
|
45
154
|
}
|
|
@@ -1,83 +1,83 @@
|
|
|
1
|
-
import { revalidateTag, unstable_cache } from "next/cache";
|
|
2
|
-
import type { ChangeEvent } from "@flaredev/core/server";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Caching for data you read far more often than you write.
|
|
6
|
-
*
|
|
7
|
-
* Only the **data cache** (Workers KV) is configured in `vite.config.ts`. It is
|
|
8
|
-
* what `cached()` below, `fetch()` and `"use cache"` write to. Shared by every
|
|
9
|
-
* colo and every isolate, so a value computed once is reused until it expires or
|
|
10
|
-
* its tag is revalidated.
|
|
11
|
-
*
|
|
12
|
-
* vinext's page-level CDN cache (the Workers Cache `cdn` adapter) is off until a
|
|
13
|
-
* vinext fix lands: in the current beta, pages that redirect break behind it.
|
|
14
|
-
*
|
|
15
|
-
* Nothing here caches per-user data: everything keyed by session belongs
|
|
16
|
-
* outside the cache, since these entries are shared between visitors.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
/** How long cached values stay fresh, in seconds. Pick the one that matches how stale you can stand. */
|
|
20
|
-
export const TTL = {
|
|
21
|
-
/** Changes often, cheap to recompute: counts, dashboards. */
|
|
22
|
-
short: 60,
|
|
23
|
-
/** The usual case: lists and detail views behind a resource tag. */
|
|
24
|
-
medium: 5 * 60,
|
|
25
|
-
/** Rarely changes: settings, published content, reference tables. */
|
|
26
|
-
long: 60 * 60,
|
|
27
|
-
} as const;
|
|
28
|
-
|
|
29
|
-
/** Tag for everything derived from a resource, e.g. `resource/Deal`. */
|
|
30
|
-
export const resourceTag = (resource: string) => `resource/${resource}`;
|
|
31
|
-
|
|
32
|
-
/** Tag for one record, e.g. `resource/Deal/0f9…`. */
|
|
33
|
-
export const recordTag = (resource: string, id: string) => `resource/${resource}/${id}`;
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* The KV data cache silently ignores tags containing `:`, `\` or control
|
|
37
|
-
* characters — the entry is stored untagged, so no revalidation ever reaches
|
|
38
|
-
* it. Refuse them here instead, where the mistake is still visible.
|
|
39
|
-
*/
|
|
40
|
-
function checkTag(tag: string): string {
|
|
41
|
-
if (!tag || tag.length > 256 || /[\x00-\x1f\\:]/.test(tag)) {
|
|
42
|
-
throw new Error(`Cache tag "${tag}" can't be used: tags must be 1–256 characters without ":", "\\" or control characters.`);
|
|
43
|
-
}
|
|
44
|
-
return tag;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Cache the result of an async function in the data cache.
|
|
49
|
-
*
|
|
50
|
-
* `keyParts` identify the value (include every argument the result depends on);
|
|
51
|
-
* `tags` say what invalidates it. Cached functions must not read cookies,
|
|
52
|
-
* headers, or anything else request-specific — the value is shared.
|
|
53
|
-
*
|
|
54
|
-
* ```ts
|
|
55
|
-
* const openDeals = cached(
|
|
56
|
-
* () => getDb().select({ total: count() }).from(deals).where(eq(deals.stage, "open")),
|
|
57
|
-
* ["deals", "open-count"],
|
|
58
|
-
* { tags: [resourceTag("Deal")] },
|
|
59
|
-
* );
|
|
60
|
-
* ```
|
|
61
|
-
*/
|
|
62
|
-
export function cached<Args extends unknown[], Value>(
|
|
63
|
-
fn: (...args: Args) => Promise<Value>,
|
|
64
|
-
keyParts: string[],
|
|
65
|
-
options: { tags?: string[]; revalidate?: number } = {},
|
|
66
|
-
) {
|
|
67
|
-
return unstable_cache(fn, keyParts, {
|
|
68
|
-
revalidate: options.revalidate ?? TTL.medium,
|
|
69
|
-
tags: (options.tags ?? []).map(checkTag),
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Drop everything cached for a resource (and for the record that changed).
|
|
75
|
-
* `lib/
|
|
76
|
-
* admin or the REST API invalidate without anyone remembering to call it.
|
|
77
|
-
*/
|
|
78
|
-
export function revalidateResource({ resource, action, id }: ChangeEvent) {
|
|
79
|
-
// { expire: 0 }: drop the entry now rather than serving it stale while it refreshes.
|
|
80
|
-
// A record someone just edited should never come back with its old values.
|
|
81
|
-
revalidateTag(resourceTag(resource), { expire: 0 });
|
|
82
|
-
if (action !== "create") revalidateTag(recordTag(resource, id), { expire: 0 });
|
|
83
|
-
}
|
|
1
|
+
import { revalidateTag, unstable_cache } from "next/cache";
|
|
2
|
+
import type { ChangeEvent } from "@flaredev/core/server";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Caching for data you read far more often than you write.
|
|
6
|
+
*
|
|
7
|
+
* Only the **data cache** (Workers KV) is configured in `vite.config.ts`. It is
|
|
8
|
+
* what `cached()` below, `fetch()` and `"use cache"` write to. Shared by every
|
|
9
|
+
* colo and every isolate, so a value computed once is reused until it expires or
|
|
10
|
+
* its tag is revalidated.
|
|
11
|
+
*
|
|
12
|
+
* vinext's page-level CDN cache (the Workers Cache `cdn` adapter) is off until a
|
|
13
|
+
* vinext fix lands: in the current beta, pages that redirect break behind it.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here caches per-user data: everything keyed by session belongs
|
|
16
|
+
* outside the cache, since these entries are shared between visitors.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** How long cached values stay fresh, in seconds. Pick the one that matches how stale you can stand. */
|
|
20
|
+
export const TTL = {
|
|
21
|
+
/** Changes often, cheap to recompute: counts, dashboards. */
|
|
22
|
+
short: 60,
|
|
23
|
+
/** The usual case: lists and detail views behind a resource tag. */
|
|
24
|
+
medium: 5 * 60,
|
|
25
|
+
/** Rarely changes: settings, published content, reference tables. */
|
|
26
|
+
long: 60 * 60,
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
/** Tag for everything derived from a resource, e.g. `resource/Deal`. */
|
|
30
|
+
export const resourceTag = (resource: string) => `resource/${resource}`;
|
|
31
|
+
|
|
32
|
+
/** Tag for one record, e.g. `resource/Deal/0f9…`. */
|
|
33
|
+
export const recordTag = (resource: string, id: string) => `resource/${resource}/${id}`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The KV data cache silently ignores tags containing `:`, `\` or control
|
|
37
|
+
* characters — the entry is stored untagged, so no revalidation ever reaches
|
|
38
|
+
* it. Refuse them here instead, where the mistake is still visible.
|
|
39
|
+
*/
|
|
40
|
+
function checkTag(tag: string): string {
|
|
41
|
+
if (!tag || tag.length > 256 || /[\x00-\x1f\\:]/.test(tag)) {
|
|
42
|
+
throw new Error(`Cache tag "${tag}" can't be used: tags must be 1–256 characters without ":", "\\" or control characters.`);
|
|
43
|
+
}
|
|
44
|
+
return tag;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Cache the result of an async function in the data cache.
|
|
49
|
+
*
|
|
50
|
+
* `keyParts` identify the value (include every argument the result depends on);
|
|
51
|
+
* `tags` say what invalidates it. Cached functions must not read cookies,
|
|
52
|
+
* headers, or anything else request-specific — the value is shared.
|
|
53
|
+
*
|
|
54
|
+
* ```ts
|
|
55
|
+
* const openDeals = cached(
|
|
56
|
+
* () => getDb().select({ total: count() }).from(deals).where(eq(deals.stage, "open")),
|
|
57
|
+
* ["deals", "open-count"],
|
|
58
|
+
* { tags: [resourceTag("Deal")] },
|
|
59
|
+
* );
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export function cached<Args extends unknown[], Value>(
|
|
63
|
+
fn: (...args: Args) => Promise<Value>,
|
|
64
|
+
keyParts: string[],
|
|
65
|
+
options: { tags?: string[]; revalidate?: number } = {},
|
|
66
|
+
) {
|
|
67
|
+
return unstable_cache(fn, keyParts, {
|
|
68
|
+
revalidate: options.revalidate ?? TTL.medium,
|
|
69
|
+
tags: (options.tags ?? []).map(checkTag),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Drop everything cached for a resource (and for the record that changed).
|
|
75
|
+
* `lib/dashboard.ts` and `lib/api.ts` pass this to the store, so writes through the
|
|
76
|
+
* admin or the REST API invalidate without anyone remembering to call it.
|
|
77
|
+
*/
|
|
78
|
+
export function revalidateResource({ resource, action, id }: ChangeEvent) {
|
|
79
|
+
// { expire: 0 }: drop the entry now rather than serving it stale while it refreshes.
|
|
80
|
+
// A record someone just edited should never come back with its old values.
|
|
81
|
+
revalidateTag(resourceTag(resource), { expire: 0 });
|
|
82
|
+
if (action !== "create") revalidateTag(recordTag(resource, id), { expire: 0 });
|
|
83
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSV, the way spreadsheets expect it (RFC 4180): fields holding a comma, a quote or a
|
|
3
|
+
* line break are quoted, and quotes inside them are doubled.
|
|
4
|
+
*
|
|
5
|
+
* Shared by the table's "export selected" (in the browser) and the export action (on
|
|
6
|
+
* the server), so both produce the same file.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface CsvColumn {
|
|
10
|
+
key: string;
|
|
11
|
+
label: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function csvCell(value: unknown): string {
|
|
15
|
+
if (value === null || value === undefined) return "";
|
|
16
|
+
const text = Array.isArray(value) ? value.join("; ") : value instanceof Date ? value.toISOString() : String(value);
|
|
17
|
+
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function toCsv(rows: Record<string, unknown>[], columns: CsvColumn[]): string {
|
|
21
|
+
const header = columns.map((column) => csvCell(column.label)).join(",");
|
|
22
|
+
const body = rows.map((row) => columns.map((column) => csvCell(row[column.key])).join(","));
|
|
23
|
+
return [header, ...body].join("\r\n");
|
|
24
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Extra dashboard sidebar links (maintained by flare generators such as `flare gen security`).
|
|
2
|
+
// Add your own links outside the generated block.
|
|
3
|
+
// generated:start
|
|
4
|
+
export interface DashboardLink {
|
|
5
|
+
label: string;
|
|
6
|
+
href: string;
|
|
7
|
+
/** A resource icon name, e.g. "shield" (see components/dashboard/resource-icon.tsx). */
|
|
8
|
+
icon: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const generatedDashboardLinks: DashboardLink[] = [];
|
|
12
|
+
// generated:end
|
|
13
|
+
|
|
14
|
+
/** Links shown under "Platform" in the dashboard sidebar. */
|
|
15
|
+
export const dashboardLinks: DashboardLink[] = [...generatedDashboardLinks, { label: "API reference", href: "/api/reference", icon: "book" }];
|