@flaredev/cli 0.13.0 → 0.14.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 +72 -37
- package/package.json +2 -2
- package/templates/app/app/dashboard/error.tsx +18 -5
- package/templates/app/app/dashboard/roles/actions.ts +57 -0
- package/templates/app/app/dashboard/roles/page.tsx +56 -0
- package/templates/app/app/error.tsx +14 -5
- package/templates/app/components/account/sections.tsx +23 -3
- package/templates/app/components/auth/password-field.tsx +51 -32
- package/templates/app/components/auth/sign-in-flow.tsx +306 -306
- package/templates/app/components/dashboard/bulk-add-sheet.tsx +377 -0
- package/templates/app/components/dashboard/cell-value.ts +67 -0
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +2 -1
- package/templates/app/components/dashboard/fields/field-widget.tsx +20 -16
- package/templates/app/components/dashboard/fields/file-field.tsx +23 -1
- package/templates/app/components/dashboard/import-dialog.tsx +1 -58
- package/templates/app/components/dashboard/resource-table.tsx +2 -0
- package/templates/app/components/dashboard/role-matrix.tsx +268 -0
- package/templates/app/components/dashboard/user-actions.tsx +55 -9
- package/templates/app/components/dashboard/user-menu.tsx +1 -1
- package/templates/app/components/ui/combobox.tsx +133 -0
- package/templates/app/db/flare-schema.ts +27 -1
- package/templates/app/lib/api.ts +4 -1
- package/templates/app/lib/dashboard.ts +15 -4
- package/templates/app/lib/permissions.ts +105 -0
- package/templates/app/lib/stale-chunk.ts +50 -0
- package/templates/app/migrations/0000_init.sql +9 -0
- package/templates/app/migrations/meta/0000_snapshot.json +57 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/locks/cloudflare/pnpm-lock.yaml +8 -8
- package/templates/locks/next/pnpm-lock.yaml +4 -4
- package/templates/next/lib/dashboard.ts +15 -4
- package/templates/next/lib/permissions.ts +100 -0
- package/templates/next/prisma/schema/base.prisma +31 -0
package/dist/index.js
CHANGED
|
@@ -730,7 +730,7 @@ function printNextSteps(result, installed) {
|
|
|
730
730
|
[
|
|
731
731
|
`${pc.cyan(SEED_ADMIN.email)} ${pc.dim("\xB7")} ${pc.cyan(SEED_ADMIN.password)}`,
|
|
732
732
|
"",
|
|
733
|
-
pc.dim(
|
|
733
|
+
pc.dim("Made by npx flare db:seed, as an admin. Change it before this app is anywhere but your machine.")
|
|
734
734
|
].join("\n"),
|
|
735
735
|
"Sign in with"
|
|
736
736
|
);
|
|
@@ -3116,7 +3116,7 @@ async function diagnose(options2 = {}) {
|
|
|
3116
3116
|
}
|
|
3117
3117
|
}
|
|
3118
3118
|
if (stack === "next") {
|
|
3119
|
-
findings.push({ check: "Migrations", verdict: "skip", detail: "Prisma tracks these in the database; run `npx
|
|
3119
|
+
findings.push({ check: "Migrations", verdict: "skip", detail: "Prisma tracks these in the database; run `npx flare migrate --deploy` to apply any that are pending." });
|
|
3120
3120
|
} else if (options2.offline) {
|
|
3121
3121
|
findings.push({ check: "Migrations", verdict: "skip", detail: "Skipped (--offline)." });
|
|
3122
3122
|
} else {
|
|
@@ -3889,7 +3889,7 @@ async function genApiKeys(options2 = {}) {
|
|
|
3889
3889
|
}
|
|
3890
3890
|
const steps = [
|
|
3891
3891
|
...installed ? [`${pc10.bold("install")} (pnpm install) to fetch ${API_KEY_PACKAGE}.`] : [],
|
|
3892
|
-
stack === "next" ? `${pc10.bold("npx
|
|
3892
|
+
stack === "next" ? `${pc10.bold("npx flare migrate --name apikeys")} to create the table.` : `${pc10.bold("flare migrate")} to create the apikey table.`,
|
|
3893
3893
|
`${pc10.bold("flare dev")}, then ${pc10.bold("/dashboard/api-keys")} to mint one.`,
|
|
3894
3894
|
`Call the API with it: ${pc10.bold("-H 'Authorization: Bearer <key>'")} (or ${pc10.bold("x-api-key")}).`
|
|
3895
3895
|
];
|
|
@@ -4241,15 +4241,20 @@ function renderFieldsBlock(fields) {
|
|
|
4241
4241
|
function renderDescriptor(name, fields, options2 = {}) {
|
|
4242
4242
|
defineResource({ name, fields: Object.fromEntries(fields.map((f) => [f.key, toField(f)])) });
|
|
4243
4243
|
const block = renderFieldsBlock(fields);
|
|
4244
|
-
const
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4244
|
+
const set = (key, value) => ` ${key}: ${value},
|
|
4245
|
+
`;
|
|
4246
|
+
const suggestion = (code, note2 = "") => {
|
|
4247
|
+
const line = ` // ${code}`;
|
|
4248
|
+
return note2 && line.length <= 52 ? `${line.padEnd(53)}// ${note2}
|
|
4249
|
+
` : `${line}
|
|
4250
|
+
`;
|
|
4251
|
+
};
|
|
4252
|
+
const heading = (options2.icon ? set("icon", literal(options2.icon)) : suggestion(`icon: "file-text",`, "any lucide-react name")) + (options2.group ? set("group", literal(options2.group)) : suggestion(`group: "Sales",`, "sidebar heading")) + suggestion(`titleField: "${fields[0]?.key ?? "name"}",`, "what lists and pickers show") + suggestion(`defaultSort: { field: "createdAt", direction: "desc" },`) + suggestion(`perPage: 25,`, "rows per page") + (options2.softDelete ? ` // A delete stamps deletedAt instead of removing the row; the dashboard grows a Trash view.
|
|
4250
4253
|
softDelete: true,
|
|
4251
|
-
` : ""
|
|
4252
|
-
|
|
4254
|
+
` : suggestion(`softDelete: true,`, "keep deleted rows, with a Trash view")) + suggestion(`rateLimit: { create: "10/min", "*": "120/min" },`) + ` // Uncomment what you need. Hooks, computed values, table and slug:
|
|
4255
|
+
// https://flare-docs.codetotech.com/concepts/resource-descriptor/
|
|
4256
|
+
|
|
4257
|
+
`;
|
|
4253
4258
|
return joinMarkers({
|
|
4254
4259
|
before: `import { defineResource, field } from "@flaredev/core";
|
|
4255
4260
|
|
|
@@ -5761,7 +5766,7 @@ async function askCreateQuestions(given, appName) {
|
|
|
5761
5766
|
}
|
|
5762
5767
|
|
|
5763
5768
|
// src/commands/gen.ts
|
|
5764
|
-
import { existsSync as existsSync20, mkdirSync as mkdirSync8, writeFileSync as writeFileSync14 } from "fs";
|
|
5769
|
+
import { existsSync as existsSync20, mkdirSync as mkdirSync8, readFileSync as readFileSync15, writeFileSync as writeFileSync14 } from "fs";
|
|
5765
5770
|
import { dirname as dirname9, join as join20 } from "path";
|
|
5766
5771
|
import { AUTH_USER as AUTH_USER2, camelCase as camelCase5, defineResource as defineResource2, relationGraph as relationGraph2 } from "@flaredev/core";
|
|
5767
5772
|
import pc15 from "picocolors";
|
|
@@ -5785,11 +5790,37 @@ async function genResource(rawName, options2) {
|
|
|
5785
5790
|
const relativePath = descriptorPath(name);
|
|
5786
5791
|
const path = join20(appRoot, relativePath);
|
|
5787
5792
|
const exists = existsSync20(path);
|
|
5788
|
-
if (!exists && !options2.fields) {
|
|
5793
|
+
if (!exists && !options2.fields && !options2.add) {
|
|
5789
5794
|
throw new Error(`--fields is required, e.g. flare gen resource ${name} --fields "name:string, email:string"`);
|
|
5790
5795
|
}
|
|
5796
|
+
if (options2.add && options2.fields) {
|
|
5797
|
+
throw new Error("--add appends to the fields you have; --fields replaces them. Use one or the other.");
|
|
5798
|
+
}
|
|
5799
|
+
if (options2.add && !exists) {
|
|
5800
|
+
throw new Error(`There is no ${relativePath} to add to. Create it first: flare gen resource ${name} --fields "${options2.add}"`);
|
|
5801
|
+
}
|
|
5791
5802
|
const existing = await loadResources(appRoot);
|
|
5792
5803
|
const others = existing.filter(({ resource }) => resource.name !== name).map(({ resource }) => resource);
|
|
5804
|
+
if (options2.add) {
|
|
5805
|
+
const additions = parseFields(options2.add);
|
|
5806
|
+
const current = existing.find(({ resource }) => resource.name === name)?.resource;
|
|
5807
|
+
const taken = new Set(Object.keys(current?.fields ?? {}));
|
|
5808
|
+
const clash = additions.find((candidate) => taken.has(candidate.key));
|
|
5809
|
+
if (clash) {
|
|
5810
|
+
throw new Error(
|
|
5811
|
+
`${name} already has a field called "${clash.key}". Edit it in ${relativePath}, or rename it there with \`${clash.key}: field.string({ rename: { from: "old" } })\`.`
|
|
5812
|
+
);
|
|
5813
|
+
}
|
|
5814
|
+
validateFields(name, additions, others, log2);
|
|
5815
|
+
const source = readFileSync15(path, "utf8");
|
|
5816
|
+
const marked = splitMarkers(source);
|
|
5817
|
+
if (!marked) throw new Error(`${relativePath} has no generated block to add to. Add the field by hand.`);
|
|
5818
|
+
const existingBlock = marked.block === "" || marked.block.endsWith("\n") ? marked.block : `${marked.block}
|
|
5819
|
+
`;
|
|
5820
|
+
const appended = `${existingBlock}${renderFieldsBlock(additions)}`;
|
|
5821
|
+
const outcome = writeGenerated(path, appended, { force: true });
|
|
5822
|
+
log2(`${pc15.yellow(outcome.padEnd(9))} ${relativePath} ${pc15.dim(`(+${additions.map((f) => f.key).join(", ")})`)}`);
|
|
5823
|
+
}
|
|
5793
5824
|
if (options2.fields) {
|
|
5794
5825
|
const fields = parseFields(options2.fields);
|
|
5795
5826
|
validateFields(name, fields, others, log2);
|
|
@@ -5842,7 +5873,7 @@ Skipped ${skipped.length} hand-edited file(s); run \`flare sync-types\` for deta
|
|
|
5842
5873
|
if (!options2.skipMigration) {
|
|
5843
5874
|
if (readStack(appRoot) === "next") {
|
|
5844
5875
|
log2(`
|
|
5845
|
-
Next: ${pc15.bold("npx
|
|
5876
|
+
Next: ${pc15.bold("npx flare migrate")} to turn the schema into a migration and apply it.`);
|
|
5846
5877
|
} else {
|
|
5847
5878
|
const renamed = await applyDeclaredRenames(appRoot, await loadResources(appRoot));
|
|
5848
5879
|
for (const [index, file] of renamed.files.entries()) {
|
|
@@ -5937,7 +5968,7 @@ ${body}
|
|
|
5937
5968
|
}
|
|
5938
5969
|
|
|
5939
5970
|
// src/commands/gen-migration.ts
|
|
5940
|
-
import { existsSync as existsSync22, mkdirSync as mkdirSync10, readFileSync as
|
|
5971
|
+
import { existsSync as existsSync22, mkdirSync as mkdirSync10, readFileSync as readFileSync16, readdirSync as readdirSync8, writeFileSync as writeFileSync16 } from "fs";
|
|
5941
5972
|
import { basename as basename2, join as join22 } from "path";
|
|
5942
5973
|
import { snakeCase } from "@flaredev/core";
|
|
5943
5974
|
import pc17 from "picocolors";
|
|
@@ -6132,7 +6163,7 @@ ${applied.output.trim()}`);
|
|
|
6132
6163
|
const relative3 = `prisma/migrations/${entry}/migration.sql`;
|
|
6133
6164
|
written.push(relative3);
|
|
6134
6165
|
log2(`${pc17.green("create".padEnd(9))} ${relative3}`);
|
|
6135
|
-
const found = destructiveStatements(
|
|
6166
|
+
const found = destructiveStatements(readFileSync16(file, "utf8"));
|
|
6136
6167
|
if (found.length > 0) destructive.push({ file: relative3, found });
|
|
6137
6168
|
}
|
|
6138
6169
|
if (destructive.length > 0) {
|
|
@@ -6246,16 +6277,19 @@ ${pc18.dim("Roles come from `flare role:add`; the API and the admin both enforce
|
|
|
6246
6277
|
}
|
|
6247
6278
|
|
|
6248
6279
|
// src/commands/rm.ts
|
|
6249
|
-
import { existsSync as existsSync24, readdirSync as readdirSync9, readFileSync as
|
|
6280
|
+
import { existsSync as existsSync24, readdirSync as readdirSync9, readFileSync as readFileSync17, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
|
|
6250
6281
|
import { dirname as dirname11, join as join24 } from "path";
|
|
6251
6282
|
import { relationGraph as relationGraph3 } from "@flaredev/core";
|
|
6252
6283
|
import pc19 from "picocolors";
|
|
6253
6284
|
var squash = (text) => text.replace(/\r\n/g, "\n").replace(/\s+/g, " ").trim();
|
|
6285
|
+
var squashCode = (text) => squash(
|
|
6286
|
+
text.replace(/\r\n/g, "\n").split("\n").filter((line) => !line.trim().startsWith("//")).join("\n")
|
|
6287
|
+
);
|
|
6254
6288
|
function handWrittenReason(source, header2, options2 = {}) {
|
|
6255
6289
|
const parts = splitMarkers(source);
|
|
6256
6290
|
if (!parts) return "has no generated block (entirely hand-written)";
|
|
6257
6291
|
if (options2.descriptorWrapper) {
|
|
6258
|
-
if (
|
|
6292
|
+
if (squashCode(parts.before) !== squashCode(options2.descriptorWrapper.before) || squashCode(parts.after) !== squashCode(options2.descriptorWrapper.after)) {
|
|
6259
6293
|
return "has hand-written changes outside the fields block";
|
|
6260
6294
|
}
|
|
6261
6295
|
return null;
|
|
@@ -6307,7 +6341,7 @@ async function rmResource(rawName, options2 = {}) {
|
|
|
6307
6341
|
if (!entry && owned.size === 0) throw new Error(`No resource "${name}" (no ${descriptorPath(name)} and no generated files).`);
|
|
6308
6342
|
const findings = [];
|
|
6309
6343
|
for (const path of owned) {
|
|
6310
|
-
const source =
|
|
6344
|
+
const source = readFileSync17(join24(appRoot, path), "utf8");
|
|
6311
6345
|
let wrapper;
|
|
6312
6346
|
if (entry && path === entry.file) {
|
|
6313
6347
|
const standard = splitMarkers(renderDescriptor(name, [{ key: "placeholder", kind: "string", required: true, unique: false }]));
|
|
@@ -6336,7 +6370,7 @@ Move what you want to keep, or pass --force to delete anyway.`);
|
|
|
6336
6370
|
const table = entry ? tableExport(entry.resource) : void 0;
|
|
6337
6371
|
if (table && existsSync24(join24(appRoot, "seeds"))) {
|
|
6338
6372
|
const seeds = readdirSync9(join24(appRoot, "seeds")).filter(
|
|
6339
|
-
(file) => new RegExp(`\\b${table}\\b`).test(
|
|
6373
|
+
(file) => new RegExp(`\\b${table}\\b`).test(readFileSync17(join24(appRoot, "seeds", file), "utf8"))
|
|
6340
6374
|
);
|
|
6341
6375
|
if (seeds.length) log2(pc19.yellow(`
|
|
6342
6376
|
These seeds still use ${table}: ${seeds.map((s) => `seeds/${s}`).join(", ")}.`));
|
|
@@ -6344,7 +6378,7 @@ These seeds still use ${table}: ${seeds.map((s) => `seeds/${s}`).join(", ")}.`))
|
|
|
6344
6378
|
let migrations = [];
|
|
6345
6379
|
if (readStack(appRoot) === "next") {
|
|
6346
6380
|
if (entry) log2(`
|
|
6347
|
-
Next: ${pc19.bold("npx
|
|
6381
|
+
Next: ${pc19.bold("npx flare migrate")} to drop the table.`);
|
|
6348
6382
|
return { removed, migrations };
|
|
6349
6383
|
}
|
|
6350
6384
|
if (!options2.skipMigration && entry) {
|
|
@@ -6359,7 +6393,7 @@ Next: ${pc19.bold("flare migrate")} to drop the table locally.`);
|
|
|
6359
6393
|
}
|
|
6360
6394
|
|
|
6361
6395
|
// src/commands/eject.ts
|
|
6362
|
-
import { existsSync as existsSync25, mkdirSync as mkdirSync12, readFileSync as
|
|
6396
|
+
import { existsSync as existsSync25, mkdirSync as mkdirSync12, readFileSync as readFileSync18, readdirSync as readdirSync10, writeFileSync as writeFileSync18 } from "fs";
|
|
6363
6397
|
import { dirname as dirname12, join as join25, relative as relative2 } from "path";
|
|
6364
6398
|
import pc20 from "picocolors";
|
|
6365
6399
|
var TRACKED = ["app", "lib", "components"];
|
|
@@ -6369,7 +6403,7 @@ var prunedOn = (path, stack) => stack === "next" && CLOUDFLARE_ONLY.some((only)
|
|
|
6369
6403
|
function upstreamFile(path, stack) {
|
|
6370
6404
|
if (prunedOn(path, stack) || WRITTEN_PER_APP.has(path)) return void 0;
|
|
6371
6405
|
const found = locate(path, stack);
|
|
6372
|
-
if (found && PER_APP.test(
|
|
6406
|
+
if (found && PER_APP.test(readFileSync18(found, "utf8"))) return void 0;
|
|
6373
6407
|
return found;
|
|
6374
6408
|
}
|
|
6375
6409
|
function locate(path, stack) {
|
|
@@ -6396,10 +6430,10 @@ function trackedPaths(appRoot, stack) {
|
|
|
6396
6430
|
function compareTracked(appRoot, stack, filter) {
|
|
6397
6431
|
return trackedPaths(appRoot, stack).filter((path) => !filter || path.includes(filter)).map((path) => {
|
|
6398
6432
|
const source = upstreamFile(path, stack);
|
|
6399
|
-
const theirs =
|
|
6433
|
+
const theirs = readFileSync18(source, "utf8");
|
|
6400
6434
|
const target = join25(appRoot, path);
|
|
6401
6435
|
if (!existsSync25(target)) return { path, state: "missing", theirs };
|
|
6402
|
-
const mine =
|
|
6436
|
+
const mine = readFileSync18(target, "utf8");
|
|
6403
6437
|
if (mine === theirs) return { path, state: "same", mine, theirs };
|
|
6404
6438
|
const merged = mergeGeneratedBlock(mine, theirs);
|
|
6405
6439
|
if (merged !== void 0) {
|
|
@@ -6589,7 +6623,7 @@ function createInsertMany(sink, drizzle, onProgress) {
|
|
|
6589
6623
|
}
|
|
6590
6624
|
|
|
6591
6625
|
// src/seed/local-d1.ts
|
|
6592
|
-
import { readFileSync as
|
|
6626
|
+
import { readFileSync as readFileSync19, rmSync as rmSync5, writeFileSync as writeFileSync19 } from "fs";
|
|
6593
6627
|
import { createRequire } from "module";
|
|
6594
6628
|
import { join as join26 } from "path";
|
|
6595
6629
|
import { pathToFileURL } from "url";
|
|
@@ -6622,7 +6656,7 @@ async function openLocalD1(appRoot, binding) {
|
|
|
6622
6656
|
}
|
|
6623
6657
|
function seedConfig(appRoot) {
|
|
6624
6658
|
const source = join26(appRoot, "wrangler.jsonc");
|
|
6625
|
-
const config = parseJsonc3(
|
|
6659
|
+
const config = parseJsonc3(readFileSync19(source, "utf8")) ?? {};
|
|
6626
6660
|
delete config.durable_objects;
|
|
6627
6661
|
delete config.migrations;
|
|
6628
6662
|
const path = join26(appRoot, ".flare-seed.wrangler.json");
|
|
@@ -7171,7 +7205,7 @@ function* seedRows(resource, count, options2 = {}) {
|
|
|
7171
7205
|
|
|
7172
7206
|
// src/commands/seed-resource-prisma.ts
|
|
7173
7207
|
import { createWriteStream } from "fs";
|
|
7174
|
-
import { mkdtempSync as mkdtempSync3, readFileSync as
|
|
7208
|
+
import { mkdtempSync as mkdtempSync3, readFileSync as readFileSync20, rmSync as rmSync8, writeFileSync as writeFileSync22 } from "fs";
|
|
7175
7209
|
import { tmpdir as tmpdir3 } from "os";
|
|
7176
7210
|
import { join as join29 } from "path";
|
|
7177
7211
|
import { once } from "events";
|
|
@@ -7295,7 +7329,7 @@ async function seedResourceWithPrisma(appRoot, resource, options2, log2) {
|
|
|
7295
7329
|
const { code: code2, output } = await runNodeCapturing([tsx, runner, "probe", planFile], appRoot);
|
|
7296
7330
|
if (code2 !== 0) throw new Error(`Could not read the database before seeding.
|
|
7297
7331
|
${output.trim()}`);
|
|
7298
|
-
probe = JSON.parse(
|
|
7332
|
+
probe = JSON.parse(readFileSync20(outFile, "utf8"));
|
|
7299
7333
|
}
|
|
7300
7334
|
const parentIds = new Map(Object.entries(probe.parents));
|
|
7301
7335
|
for (const target of options2.parentTargets) {
|
|
@@ -7308,7 +7342,7 @@ ${output.trim()}`);
|
|
|
7308
7342
|
writeFileSync22(planFile, JSON.stringify({ model, table: resource.table, out: outFile, rows: rowsFile, truncate: !!options2.truncate, total: options2.count }));
|
|
7309
7343
|
const code = await runNode([tsx, runner, "insert", planFile], appRoot);
|
|
7310
7344
|
if (code !== 0) throw new Error("Seeding stopped; the reason is above.");
|
|
7311
|
-
const { written } = JSON.parse(
|
|
7345
|
+
const { written } = JSON.parse(readFileSync20(outFile, "utf8"));
|
|
7312
7346
|
return written;
|
|
7313
7347
|
} finally {
|
|
7314
7348
|
rmSync8(runner, { force: true });
|
|
@@ -7490,7 +7524,7 @@ ${result.output.trim()}`);
|
|
|
7490
7524
|
|
|
7491
7525
|
// src/commands/billing-sync.ts
|
|
7492
7526
|
import { createRequire as createRequire2 } from "module";
|
|
7493
|
-
import { existsSync as existsSync27, mkdtempSync as mkdtempSync5, readFileSync as
|
|
7527
|
+
import { existsSync as existsSync27, mkdtempSync as mkdtempSync5, readFileSync as readFileSync21, rmSync as rmSync10, writeFileSync as writeFileSync24 } from "fs";
|
|
7494
7528
|
import { tmpdir as tmpdir5 } from "os";
|
|
7495
7529
|
import { join as join31 } from "path";
|
|
7496
7530
|
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
@@ -7500,7 +7534,7 @@ function stripeKeys(appRoot) {
|
|
|
7500
7534
|
const map = {};
|
|
7501
7535
|
const path = join31(appRoot, ".dev.vars");
|
|
7502
7536
|
if (existsSync27(path)) {
|
|
7503
|
-
for (const line of
|
|
7537
|
+
for (const line of readFileSync21(path, "utf8").split(/\r?\n/)) {
|
|
7504
7538
|
const match = /^\s*([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
|
7505
7539
|
if (match) map[match[1]] = match[2].trim();
|
|
7506
7540
|
}
|
|
@@ -7586,7 +7620,7 @@ async function syncPlans(options2 = {}) {
|
|
|
7586
7620
|
const appRequire = createRequire2(join31(appRoot, "package.json"));
|
|
7587
7621
|
const stripeModule = await import(pathToFileURL2(appRequire.resolve("stripe")).href);
|
|
7588
7622
|
const stripe = new stripeModule.default(key, { apiVersion: "2026-08-26.dahlia" });
|
|
7589
|
-
const appName = JSON.parse(
|
|
7623
|
+
const appName = JSON.parse(readFileSync21(join31(appRoot, "package.json"), "utf8")).name ?? "app";
|
|
7590
7624
|
const everything = await stripe.products.list({ active: true, limit: 100 }).autoPagingToArray({ limit: 1e4 });
|
|
7591
7625
|
const products = options2.all ? everything : everything.filter((product) => product.metadata?.flare_app === appName);
|
|
7592
7626
|
if (products.length === 0) {
|
|
@@ -7761,13 +7795,13 @@ async function setUserRoleOnPrisma(appRoot, email, role, log2) {
|
|
|
7761
7795
|
|
|
7762
7796
|
// src/utils/install.ts
|
|
7763
7797
|
import { spawn as spawn5 } from "child_process";
|
|
7764
|
-
import { existsSync as existsSync29, readFileSync as
|
|
7798
|
+
import { existsSync as existsSync29, readFileSync as readFileSync22, readdirSync as readdirSync12 } from "fs";
|
|
7765
7799
|
import { join as join34 } from "path";
|
|
7766
7800
|
function expectedPackages(dir) {
|
|
7767
7801
|
const npmLock = join34(dir, "package-lock.json");
|
|
7768
7802
|
if (existsSync29(npmLock)) {
|
|
7769
7803
|
try {
|
|
7770
|
-
const lock = JSON.parse(
|
|
7804
|
+
const lock = JSON.parse(readFileSync22(npmLock, "utf8"));
|
|
7771
7805
|
return Math.max(0, Object.keys(lock.packages ?? {}).length - 1);
|
|
7772
7806
|
} catch {
|
|
7773
7807
|
return 0;
|
|
@@ -7776,7 +7810,7 @@ function expectedPackages(dir) {
|
|
|
7776
7810
|
const pnpmLock = join34(dir, "pnpm-lock.yaml");
|
|
7777
7811
|
if (existsSync29(pnpmLock)) {
|
|
7778
7812
|
try {
|
|
7779
|
-
const text =
|
|
7813
|
+
const text = readFileSync22(pnpmLock, "utf8");
|
|
7780
7814
|
const packages = text.slice(text.indexOf("\npackages:"));
|
|
7781
7815
|
return (packages.match(/\n {2}[^\s:]+:/g) ?? []).length;
|
|
7782
7816
|
} catch {
|
|
@@ -7882,7 +7916,7 @@ function createCli() {
|
|
|
7882
7916
|
printNextSteps(result, installed);
|
|
7883
7917
|
}
|
|
7884
7918
|
);
|
|
7885
|
-
cli.command("gen <generator> [name] [second]", `Generate code. Generators: ${GENERATORS.join(", ")}`).option("--fields <fields>", "resource: fields, e.g. 'name:string, email:string!, status:enum(lead,customer)' (single quotes: bash treats ! in double quotes as history)").option("--group <name>", "resource: sidebar heading to file it under, e.g. Sales").option("--icon <name>", "resource: lucide icon for the sidebar, e.g. users").option("--soft-delete", "resource: keep deleted rows and hide them, with a Trash view in the dashboard").option("--method <verb>", "endpoint: GET (default), POST, PATCH, PUT or DELETE").option("--action <action>", "endpoint: policy action to require (read, create, update, delete)").option("--record", "endpoint: put it under one record, /api/<resource>/[id]/<name>").option("--force", "resource/policy/billing/security: overwrite hand-edited generated blocks").option("--from-schema", "migration: diff the current tables instead of a blank migration; on Next.js, also apply declared renames").example("flare gen migration add_phone --from-schema # Cloudflare: diff the Drizzle tables").example("flare gen migration add_phone # Next.js: prisma migrate dev, refusing a drop").option("--roles <roles>", "policy: roles allowed to read, create and update, e.g. admin,staff").option("--delete-roles <roles>", "policy: roles allowed to delete (default: the first --roles entry)").option("--own <field>", "policy: confine each user to rows whose field holds their id, e.g. --own userId").option("--own-except <roles>", "policy: roles that see every row despite --own, e.g. admin").option("--provider <provider>", "billing: payment provider (default: stripe)").option("--mode <mode>", "billing: subscriptions (the default; includes one-time checkout)").option("--skip-install", "billing/apikeys: write files without adding the dependency").option("--skip-migration", "billing/security/apikeys: skip generating the schema migration").example("flare gen resource Contact --fields 'name:string, email:string!, company:belongsTo(Company)?'").example("flare gen resource Invoice --fields 'number:string!' --soft-delete # a delete goes to the Trash").example("flare gen endpoint Order publish --method POST --record").example("flare gen migration backfill_contact_status").example("flare gen migration add_phone_to_contacts --from-schema").example("flare gen policy Invoice --roles admin,staff --delete-roles admin").example("flare gen policy Invoice --roles staff --own userId --own-except admin # each user sees only their own").example("flare gen billing --provider stripe --mode subscriptions").example("flare gen security").example("flare gen apikeys # API keys for cron jobs, scripts and mobile apps").example("flare gen apikeys # bearer keys for crons, scripts and mobile apps").action(
|
|
7919
|
+
cli.command("gen <generator> [name] [second]", `Generate code. Generators: ${GENERATORS.join(", ")}`).option("--fields <fields>", "resource: fields, e.g. 'name:string, email:string!, status:enum(lead,customer)' (single quotes: bash treats ! in double quotes as history)").option("--add <fields>", "resource: add fields to one that exists, in the same grammar as --fields").option("--group <name>", "resource: sidebar heading to file it under, e.g. Sales").option("--icon <name>", "resource: lucide icon for the sidebar, e.g. users").option("--soft-delete", "resource: keep deleted rows and hide them, with a Trash view in the dashboard").option("--method <verb>", "endpoint: GET (default), POST, PATCH, PUT or DELETE").option("--action <action>", "endpoint: policy action to require (read, create, update, delete)").option("--record", "endpoint: put it under one record, /api/<resource>/[id]/<name>").option("--force", "resource/policy/billing/security: overwrite hand-edited generated blocks").option("--from-schema", "migration: diff the current tables instead of a blank migration; on Next.js, also apply declared renames").example("flare gen migration add_phone --from-schema # Cloudflare: diff the Drizzle tables").example("flare gen migration add_phone # Next.js: prisma migrate dev, refusing a drop").option("--roles <roles>", "policy: roles allowed to read, create and update, e.g. admin,staff").option("--delete-roles <roles>", "policy: roles allowed to delete (default: the first --roles entry)").option("--own <field>", "policy: confine each user to rows whose field holds their id, e.g. --own userId").option("--own-except <roles>", "policy: roles that see every row despite --own, e.g. admin").option("--provider <provider>", "billing: payment provider (default: stripe)").option("--mode <mode>", "billing: subscriptions (the default; includes one-time checkout)").option("--skip-install", "billing/apikeys: write files without adding the dependency").option("--skip-migration", "billing/security/apikeys: skip generating the schema migration").example("flare gen resource Contact --fields 'name:string, email:string!, company:belongsTo(Company)?'").example("flare gen resource Contact --add 'phone:phone?, notes:text?' # add columns to one you have").example("flare gen resource Invoice --fields 'number:string!' --soft-delete # a delete goes to the Trash").example("flare gen endpoint Order publish --method POST --record").example("flare gen migration backfill_contact_status").example("flare gen migration add_phone_to_contacts --from-schema").example("flare gen policy Invoice --roles admin,staff --delete-roles admin").example("flare gen policy Invoice --roles staff --own userId --own-except admin # each user sees only their own").example("flare gen billing --provider stripe --mode subscriptions").example("flare gen security").example("flare gen apikeys # API keys for cron jobs, scripts and mobile apps").example("flare gen apikeys # bearer keys for crons, scripts and mobile apps").action(
|
|
7886
7920
|
async (generator, name, second, options2) => {
|
|
7887
7921
|
if (generator === "billing")
|
|
7888
7922
|
return genBilling({ provider: options2.provider, mode: options2.mode, force: options2.force, skipInstall: options2.skipInstall, skipMigration: options2.skipMigration });
|
|
@@ -7895,6 +7929,7 @@ function createCli() {
|
|
|
7895
7929
|
if (generator === "resource")
|
|
7896
7930
|
return genResource(name, {
|
|
7897
7931
|
fields: options2.fields,
|
|
7932
|
+
add: options2.add,
|
|
7898
7933
|
force: options2.force,
|
|
7899
7934
|
group: options2.group,
|
|
7900
7935
|
icon: options2.icon,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flaredev/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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.14.0"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=22"
|
|
@@ -4,33 +4,46 @@ import { useEffect } from "react";
|
|
|
4
4
|
import { RefreshCwIcon, TriangleAlertIcon } from "lucide-react";
|
|
5
5
|
import { Button } from "@/components/ui/button";
|
|
6
6
|
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
|
|
7
|
+
import { hardReload, isStaleChunkError } from "@/lib/stale-chunk";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* A dashboard page that threw.
|
|
10
11
|
*
|
|
11
12
|
* Inside the layout, so the sidebar stays and `reset` retries just this page — no
|
|
12
13
|
* sign-out, no lost place in the app.
|
|
14
|
+
*
|
|
15
|
+
* Except when the tab is running stale JavaScript, where `reset` re-renders the same
|
|
16
|
+
* stale modules and can only fail again. That case gets a reload instead, which is the
|
|
17
|
+
* thing that actually works. See lib/stale-chunk.ts.
|
|
13
18
|
*/
|
|
14
19
|
export default function DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
|
15
20
|
useEffect(() => {
|
|
16
21
|
console.error(error);
|
|
17
22
|
}, [error]);
|
|
18
23
|
|
|
24
|
+
const stale = isStaleChunkError(error);
|
|
25
|
+
|
|
19
26
|
return (
|
|
20
27
|
<Empty className="min-h-[60vh]">
|
|
21
28
|
<EmptyHeader>
|
|
22
29
|
<EmptyMedia variant="icon">
|
|
23
30
|
<TriangleAlertIcon />
|
|
24
31
|
</EmptyMedia>
|
|
25
|
-
<EmptyTitle>This page didn
|
|
32
|
+
<EmptyTitle>{stale ? "This tab is running old code" : "This page didn't load"}</EmptyTitle>
|
|
26
33
|
<EmptyDescription>
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
{stale ? (
|
|
35
|
+
"Nothing is wrong with the app. This tab loaded one build and the server is serving another — usually after an update, or after starting a different app on this port. Reloading fixes it."
|
|
36
|
+
) : (
|
|
37
|
+
<>
|
|
38
|
+
Something went wrong fetching it.
|
|
39
|
+
{error.digest && <> Reference: <span className="font-mono select-all">{error.digest}</span>.</>}
|
|
40
|
+
</>
|
|
41
|
+
)}
|
|
29
42
|
</EmptyDescription>
|
|
30
43
|
</EmptyHeader>
|
|
31
|
-
<Button onClick={reset}>
|
|
44
|
+
<Button onClick={stale ? hardReload : reset}>
|
|
32
45
|
<RefreshCwIcon data-icon="inline-start" />
|
|
33
|
-
Try again
|
|
46
|
+
{stale ? "Reload the page" : "Try again"}
|
|
34
47
|
</Button>
|
|
35
48
|
</Empty>
|
|
36
49
|
);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { revalidatePath } from "next/cache";
|
|
4
|
+
import { createRole, deleteRole, setGrant } from "@/lib/permissions";
|
|
5
|
+
import { ADMIN_ROLES, dashboardSession } from "@/lib/dashboard";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Changing who may do what.
|
|
9
|
+
*
|
|
10
|
+
* Admin only, and deliberately not policy-driven: these actions decide what the
|
|
11
|
+
* policies themselves allow, so gating them on a policy would be circular. ADMIN_ROLES
|
|
12
|
+
* (lib/dashboard.ts) is the one list that governs the app's own settings.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type RoleActionResult = { ok: true } | { ok: false; error: string };
|
|
16
|
+
|
|
17
|
+
const ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
18
|
+
const ACTIONS = new Set(["read", "create", "update", "delete"]);
|
|
19
|
+
|
|
20
|
+
async function requireAdmin(): Promise<RoleActionResult | undefined> {
|
|
21
|
+
const { session, role } = await dashboardSession();
|
|
22
|
+
if (!session) return { ok: false, error: "You're signed out." };
|
|
23
|
+
if (!role || !ADMIN_ROLES.includes(role)) return { ok: false, error: "Only an admin can change roles." };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function createRoleAction(input: { name: string; label: string }): Promise<RoleActionResult> {
|
|
27
|
+
const denied = await requireAdmin();
|
|
28
|
+
if (denied) return denied;
|
|
29
|
+
const name = input.name.trim().toLowerCase();
|
|
30
|
+
if (!ROLE_NAME.test(name)) {
|
|
31
|
+
return { ok: false, error: "A role is lowercase letters, digits, _ or -, starting with a letter — e.g. support-agent." };
|
|
32
|
+
}
|
|
33
|
+
await createRole(name, input.label.trim() || name);
|
|
34
|
+
revalidatePath("/dashboard/roles");
|
|
35
|
+
return { ok: true };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function deleteRoleAction(name: string): Promise<RoleActionResult> {
|
|
39
|
+
const denied = await requireAdmin();
|
|
40
|
+
if (denied) return denied;
|
|
41
|
+
// Not admin: the role this screen is gated on cannot be removed from this screen,
|
|
42
|
+
// which is the one mistake here that cannot be undone from the dashboard.
|
|
43
|
+
if (ADMIN_ROLES.includes(name)) return { ok: false, error: `"${name}" runs this screen, so it can't be deleted here.` };
|
|
44
|
+
await deleteRole(name);
|
|
45
|
+
revalidatePath("/dashboard/roles");
|
|
46
|
+
return { ok: true };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function setGrantAction(input: { role: string; resource: string; action: string; granted: boolean }): Promise<RoleActionResult> {
|
|
50
|
+
const denied = await requireAdmin();
|
|
51
|
+
if (denied) return denied;
|
|
52
|
+
if (!ROLE_NAME.test(input.role)) return { ok: false, error: `"${input.role}" is not a role name.` };
|
|
53
|
+
if (!ACTIONS.has(input.action)) return { ok: false, error: `"${input.action}" is not an action.` };
|
|
54
|
+
await setGrant(input.role, input.resource, input.action, input.granted);
|
|
55
|
+
revalidatePath("/dashboard/roles");
|
|
56
|
+
return { ok: true };
|
|
57
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { redirect } from "next/navigation";
|
|
2
|
+
import { grantIndex } from "@flaredev/core";
|
|
3
|
+
import { PageHeader } from "@/components/dashboard/page-header";
|
|
4
|
+
import { RoleMatrix, type MatrixResource } from "@/components/dashboard/role-matrix";
|
|
5
|
+
import { ADMIN_ROLES, allResources, dashboardSession } from "@/lib/dashboard";
|
|
6
|
+
import { allGrants, codePolicy, GRANTABLE, listRoles, rolesInUse } from "@/lib/permissions";
|
|
7
|
+
|
|
8
|
+
export const metadata = { title: "Roles & permissions" };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Who may do what, per role.
|
|
12
|
+
*
|
|
13
|
+
* Admin only, and not governed by a resource policy: this screen decides what the
|
|
14
|
+
* policies allow, so gating it on one would be circular. It reads the policy files and
|
|
15
|
+
* the grants together, and shows which ticks came from which — a tick from a file is
|
|
16
|
+
* locked, because the file is the floor and the dashboard only widens it.
|
|
17
|
+
*/
|
|
18
|
+
export default async function RolesPage() {
|
|
19
|
+
const { session, role } = await dashboardSession();
|
|
20
|
+
if (!session) redirect("/sign-in?next=/dashboard/roles");
|
|
21
|
+
if (!role || !ADMIN_ROLES.includes(role)) redirect("/dashboard");
|
|
22
|
+
|
|
23
|
+
const [roles, grants, inUse] = await Promise.all([listRoles(), allGrants(), rolesInUse()]);
|
|
24
|
+
const index = grantIndex(grants);
|
|
25
|
+
|
|
26
|
+
// Every role an account could hold, not only the registered ones: the admin plugin's
|
|
27
|
+
// default is "user", and a role assigned with `flare user:role` before anybody opened
|
|
28
|
+
// this screen exists whether or not it was registered here. Showing it is how you
|
|
29
|
+
// discover you have one.
|
|
30
|
+
const known = new Map(roles.map((entry) => [entry.name, entry]));
|
|
31
|
+
for (const name of [...ADMIN_ROLES, "user", ...inUse]) {
|
|
32
|
+
if (!known.has(name)) known.set(name, { name, label: name });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const resources: MatrixResource[] = allResources().map((resource) => {
|
|
36
|
+
const policy = codePolicy(resource.name);
|
|
37
|
+
const fromCode: Record<string, string[]> = {};
|
|
38
|
+
const granted: Record<string, string[]> = {};
|
|
39
|
+
for (const action of GRANTABLE) {
|
|
40
|
+
fromCode[action] = policy ? policy[action] : [];
|
|
41
|
+
granted[action] = [...(index.get(`${resource.name}.${action}`) ?? [])];
|
|
42
|
+
}
|
|
43
|
+
return { name: resource.name, pluralLabel: resource.pluralLabel, restricted: Boolean(policy), fromCode, granted };
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<>
|
|
48
|
+
<PageHeader
|
|
49
|
+
title="Roles & permissions"
|
|
50
|
+
description="What each role may do, per resource. Policy files set the floor; what you tick here widens it."
|
|
51
|
+
crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Roles & permissions" }]}
|
|
52
|
+
/>
|
|
53
|
+
<RoleMatrix roles={[...known.values()]} resources={resources} adminRoles={ADMIN_ROLES} />
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useEffect } from "react";
|
|
4
4
|
import Link from "next/link";
|
|
5
5
|
import { RefreshCwIcon, TriangleAlertIcon } from "lucide-react";
|
|
6
|
+
import { hardReload, isStaleChunkError } from "@/lib/stale-chunk";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The public error boundary.
|
|
@@ -11,6 +12,10 @@ import { RefreshCwIcon, TriangleAlertIcon } from "lucide-react";
|
|
|
11
12
|
* needs somewhere to hand `reset`, which re-renders the segment without a full page
|
|
12
13
|
* load, and that is usually all a transient failure needs.
|
|
13
14
|
*
|
|
15
|
+
* Usually, but not always: a tab holding stale JavaScript throws too, and `reset` can
|
|
16
|
+
* never fix that one — it re-renders the same stale modules. So that case is told
|
|
17
|
+
* apart and offered a reload, which does fix it. See lib/stale-chunk.ts.
|
|
18
|
+
*
|
|
14
19
|
* `error.digest` is the only detail shown. The message itself is deliberately not:
|
|
15
20
|
* in production it is replaced by React anyway, and in development the overlay is
|
|
16
21
|
* more use than anything printed here.
|
|
@@ -21,17 +26,21 @@ export default function Error({ error, reset }: { error: Error & { digest?: stri
|
|
|
21
26
|
console.error(error);
|
|
22
27
|
}, [error]);
|
|
23
28
|
|
|
29
|
+
const stale = isStaleChunkError(error);
|
|
30
|
+
|
|
24
31
|
return (
|
|
25
32
|
<main className="mx-auto flex min-h-[70vh] max-w-2xl flex-col items-center justify-center gap-6 px-6 py-20 text-center">
|
|
26
33
|
<div className="flex size-14 items-center justify-center rounded-2xl border border-border bg-surface">
|
|
27
34
|
<TriangleAlertIcon className="size-6 text-danger" aria-hidden="true" />
|
|
28
35
|
</div>
|
|
29
36
|
<div className="flex flex-col gap-3">
|
|
30
|
-
<h1 className="text-3xl text-balance md:text-4xl">Something went wrong</h1>
|
|
37
|
+
<h1 className="text-3xl text-balance md:text-4xl">{stale ? "This tab is running old code" : "Something went wrong"}</h1>
|
|
31
38
|
<p className="text-foreground-muted text-pretty">
|
|
32
|
-
|
|
39
|
+
{stale
|
|
40
|
+
? "Nothing is wrong with the app. This tab loaded one build and the server is serving another — usually after an update, or after starting a different app on this port. Reloading fixes it."
|
|
41
|
+
: "This one is on us. Trying again often works; if it doesn't, the error has been logged."}
|
|
33
42
|
</p>
|
|
34
|
-
{error.digest && (
|
|
43
|
+
{!stale && error.digest && (
|
|
35
44
|
<p className="font-mono text-xs text-foreground-muted">
|
|
36
45
|
Reference: <span className="select-all">{error.digest}</span>
|
|
37
46
|
</p>
|
|
@@ -40,11 +49,11 @@ export default function Error({ error, reset }: { error: Error & { digest?: stri
|
|
|
40
49
|
<div className="flex flex-wrap justify-center gap-3">
|
|
41
50
|
<button
|
|
42
51
|
type="button"
|
|
43
|
-
onClick={reset}
|
|
52
|
+
onClick={stale ? hardReload : reset}
|
|
44
53
|
className="inline-flex h-11 items-center gap-2 rounded-[var(--button-radius)] bg-brand px-5 font-medium text-brand-foreground [background-image:var(--brand-gradient,none)] hover:brightness-95"
|
|
45
54
|
>
|
|
46
55
|
<RefreshCwIcon className="size-4" aria-hidden="true" />
|
|
47
|
-
Try again
|
|
56
|
+
{stale ? "Reload the page" : "Try again"}
|
|
48
57
|
</button>
|
|
49
58
|
<Link href="/" className="inline-flex h-11 items-center rounded-[var(--button-radius)] border border-border px-5 font-medium hover:bg-accent">
|
|
50
59
|
Back home
|
|
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
|
|
5
5
|
import { renderSVG } from "uqr";
|
|
6
6
|
import { CheckIcon, CopyIcon, FingerprintIcon, LaptopIcon, ShieldCheckIcon, Trash2Icon } from "lucide-react";
|
|
7
7
|
import { AuthInput, AuthLabel, FormMessage, PrimaryButton, SecondaryButton, TextButton } from "@/components/auth/ui";
|
|
8
|
-
import { PasswordField } from "@/components/auth/password-field";
|
|
8
|
+
import { PasswordField, PasswordInput } from "@/components/auth/password-field";
|
|
9
9
|
import { authErrorMessage } from "@/components/auth/sign-in-flow";
|
|
10
10
|
import { PROVIDER_LABELS, ProviderIcon } from "@/components/auth/provider-icons";
|
|
11
11
|
import { authClient } from "@/lib/auth-client";
|
|
@@ -50,7 +50,7 @@ function PasswordConfirm({ value, onChange, id }: { value: string; onChange: (va
|
|
|
50
50
|
return (
|
|
51
51
|
<div className="flex flex-col gap-2">
|
|
52
52
|
<AuthLabel htmlFor={id}>Your password</AuthLabel>
|
|
53
|
-
<
|
|
53
|
+
<PasswordInput id={id} autoComplete="current-password" value={value} onChange={(event) => onChange(event.target.value)} required />
|
|
54
54
|
</div>
|
|
55
55
|
);
|
|
56
56
|
}
|
|
@@ -459,6 +459,26 @@ function describeDevice(agent?: string | null): string {
|
|
|
459
459
|
return system ? `${browser} on ${system}` : browser;
|
|
460
460
|
}
|
|
461
461
|
|
|
462
|
+
/**
|
|
463
|
+
* Where a session came from, in words rather than a number nobody can read.
|
|
464
|
+
*
|
|
465
|
+
* Better Auth records whatever address the request arrived from, and on a machine
|
|
466
|
+
* talking to itself that is the loopback — which IPv6 writes in full as
|
|
467
|
+
* 0000:0000:0000:0000:0000:0000:0000:0001. Printing that in a list headed "Sign out of
|
|
468
|
+
* devices you don't recognise" is worse than printing nothing: it is unreadable, it is
|
|
469
|
+
* identical on every row, and it looks like a bug. Say "this computer" instead, which
|
|
470
|
+
* is both true and useful.
|
|
471
|
+
*/
|
|
472
|
+
function describeOrigin(ip?: string | null): string | null {
|
|
473
|
+
if (!ip) return null;
|
|
474
|
+
const address = ip.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
475
|
+
// Every spelling of "no address" and "here": IPv4 and IPv6 loopback, the unspecified
|
|
476
|
+
// address, and the IPv4-mapped forms a dual-stack listener reports.
|
|
477
|
+
if (/^(::1?|0+(:0+)*|127(\.\d+){3}|::ffff:127(\.\d+){3}|localhost)$/.test(address)) return "this computer";
|
|
478
|
+
if (/^(10|127)\.|^192\.168\.|^172\.(1[6-9]|2\d|3[01])\.|^fe80:/.test(address)) return `${ip} (local network)`;
|
|
479
|
+
return ip;
|
|
480
|
+
}
|
|
481
|
+
|
|
462
482
|
export function Sessions() {
|
|
463
483
|
const [list, setList] = useState<SessionRow[] | null>(null);
|
|
464
484
|
const [current, setCurrent] = useState<string | null>(null);
|
|
@@ -488,7 +508,7 @@ export function Sessions() {
|
|
|
488
508
|
<span className="flex-1">
|
|
489
509
|
<span className="font-medium">{describeDevice(item.userAgent)}</span>
|
|
490
510
|
<span className="block text-xs text-foreground-muted">
|
|
491
|
-
{item.ipAddress
|
|
511
|
+
{describeOrigin(item.ipAddress) ?? "Unknown location"}
|
|
492
512
|
{item.updatedAt && `, last active ${new Date(item.updatedAt).toLocaleString()}`}
|
|
493
513
|
</span>
|
|
494
514
|
</span>
|