Archived
Harden demo access and restore clean checks
This commit is contained in:
@@ -82,12 +82,13 @@ bun run db:push # fast iteration during development
|
|||||||
# bun run db:migrate # same migrations the Docker image runs in production
|
# bun run db:migrate # same migrations the Docker image runs in production
|
||||||
```
|
```
|
||||||
|
|
||||||
**Demo account.** For App Store review and local testing, `bun run db:migrate` applies `0014_seed_demo_account.sql`, which creates a pre-populated user (`db:push` does not). Sign in at `/auth/login`:
|
**Demo account.** For App Store review and local testing, `bun run db:migrate` creates a pre-populated but locked `demo@example.com` account (`db:push` does not). Provision a private, temporary password when review access is needed:
|
||||||
|
|
||||||
- Email: `demo@example.com`
|
```bash
|
||||||
- Password: `demo123`
|
DEMO_ACCOUNT_PASSWORD='<private 12+ character password>' bun run demo:provision
|
||||||
|
```
|
||||||
|
|
||||||
The account includes a sample business, clients, and invoices (draft, sent, and paid).
|
Provisioning rotates the credential and invalidates prior sessions. Do not commit or publish the password. Re-run the command to rotate it; migration 0027 disables the previously public credential. The account includes a sample business, clients, and invoices (draft, sent, and paid).
|
||||||
|
|
||||||
### 4. Run
|
### 4. Run
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- The original App Review credential was committed publicly in migration 0014.
|
||||||
|
-- Rotate it to an unknown value and invalidate its sessions. Use
|
||||||
|
-- `bun run demo:provision` with a private DEMO_ACCOUNT_PASSWORD when review
|
||||||
|
-- access is needed.
|
||||||
|
UPDATE "beenvoice_user"
|
||||||
|
SET "password" = '$2b$12$GyM6.bLv2.sZMsWytNz1L.j7pLwc79a55Nww6bSLQJ9OJarqY9oZW',
|
||||||
|
"updatedAt" = NOW()
|
||||||
|
WHERE "id" = 'a0000000-0000-4000-8000-000000000001';
|
||||||
|
|
||||||
|
UPDATE "beenvoice_account"
|
||||||
|
SET "password" = '$2b$12$GyM6.bLv2.sZMsWytNz1L.j7pLwc79a55Nww6bSLQJ9OJarqY9oZW',
|
||||||
|
"updatedAt" = NOW()
|
||||||
|
WHERE "userId" = 'a0000000-0000-4000-8000-000000000001'
|
||||||
|
AND "providerId" = 'credential';
|
||||||
|
|
||||||
|
DELETE FROM "beenvoice_session"
|
||||||
|
WHERE "userId" = 'a0000000-0000-4000-8000-000000000001';
|
||||||
@@ -190,6 +190,13 @@
|
|||||||
"when": 1784000000000,
|
"when": 1784000000000,
|
||||||
"tag": "0026_business_hide_name_with_logo_fix",
|
"tag": "0026_business_hide_name_with_logo_fix",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 27,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786740000000,
|
||||||
|
"tag": "0027_disable_public_demo_password",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:clone": "./scripts/clone-local.sh",
|
"db:clone": "./scripts/clone-local.sh",
|
||||||
|
"demo:provision": "bun scripts/provision-demo-account.ts",
|
||||||
"docker:up": "colima start && docker compose -f docker-compose.dev.yml up -d",
|
"docker:up": "colima start && docker compose -f docker-compose.dev.yml up -d",
|
||||||
"docker:down": "docker compose -f docker-compose.dev.yml down && colima stop",
|
"docker:down": "docker compose -f docker-compose.dev.yml down && colima stop",
|
||||||
"docker:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop",
|
"docker:dev:down": "docker compose -f docker-compose.dev.yml down && colima stop",
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
const DEMO_USER_ID = "a0000000-0000-4000-8000-000000000001";
|
||||||
|
const password = process.env.DEMO_ACCOUNT_PASSWORD?.trim();
|
||||||
|
const databaseUrl = process.env.DATABASE_URL?.trim();
|
||||||
|
|
||||||
|
if (!databaseUrl) {
|
||||||
|
throw new Error("DATABASE_URL is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password || password.length < 12) {
|
||||||
|
throw new Error("DEMO_ACCOUNT_PASSWORD must be at least 12 characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString: databaseUrl, ssl: false });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const userResult = await client.query(
|
||||||
|
`UPDATE "beenvoice_user"
|
||||||
|
SET "password" = $1, "updatedAt" = NOW()
|
||||||
|
WHERE "id" = $2`,
|
||||||
|
[passwordHash, DEMO_USER_ID],
|
||||||
|
);
|
||||||
|
const accountResult = await client.query(
|
||||||
|
`UPDATE "beenvoice_account"
|
||||||
|
SET "password" = $1, "updatedAt" = NOW()
|
||||||
|
WHERE "userId" = $2 AND "providerId" = 'credential'`,
|
||||||
|
[passwordHash, DEMO_USER_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userResult.rowCount !== 1 || accountResult.rowCount !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
"Demo account is missing. Apply database migrations before provisioning it.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(`DELETE FROM "beenvoice_session" WHERE "userId" = $1`, [
|
||||||
|
DEMO_USER_ID,
|
||||||
|
]);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"Demo review account provisioned; previous sessions were invalidated.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
@@ -103,6 +103,9 @@ function PublicInvoiceView({ token }: { token: string }) {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 bg-gray-900 px-8 py-6">
|
<div className="flex items-center gap-3 bg-gray-900 px-8 py-6">
|
||||||
{hasLogo && (
|
{hasLogo && (
|
||||||
|
// Uploaded SVGs are sanitized and served by our route. next/image's
|
||||||
|
// optimizer intentionally rejects SVG, so a native img is required.
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={`/api/business-logo/${invoice.business!.id}`}
|
src={`/api/business-logo/${invoice.business!.id}`}
|
||||||
alt=""
|
alt=""
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { mkdir, readFile, unlink, writeFile } from "fs/promises";
|
import { mkdir, readFile, unlink, writeFile } from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import type * as S3ClientModule from "@aws-sdk/client-s3";
|
||||||
|
|
||||||
// Local dev fallback when S3_* env vars are unset. Files land in .data/receipts/.
|
// Local dev fallback when S3_* env vars are unset. Files land in .data/receipts/.
|
||||||
const LOCAL_RECEIPTS_DIR = path.join(process.cwd(), ".data", "receipts");
|
const LOCAL_RECEIPTS_DIR = path.join(process.cwd(), ".data", "receipts");
|
||||||
@@ -17,7 +18,7 @@ export function getStorageBackend(): "s3" | "local" {
|
|||||||
return isS3Configured() ? "s3" : "local";
|
return isS3Configured() ? "s3" : "local";
|
||||||
}
|
}
|
||||||
|
|
||||||
type S3Module = typeof import("@aws-sdk/client-s3");
|
type S3Module = typeof S3ClientModule;
|
||||||
|
|
||||||
let s3ModulePromise: Promise<S3Module> | null = null;
|
let s3ModulePromise: Promise<S3Module> | null = null;
|
||||||
let s3Client: InstanceType<S3Module["S3Client"]> | null = null;
|
let s3Client: InstanceType<S3Module["S3Client"]> | null = null;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
/** Stored on entries clocked in before empty descriptions were allowed. */
|
/** Stored on entries clocked in before empty descriptions were allowed. */
|
||||||
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
|
export const LEGACY_DEFAULT_CLOCK_DESCRIPTION = "Professional services";
|
||||||
|
|
||||||
|
export function normalizeOptionalId(value?: string | null): string | null {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
return trimmed == null || trimmed === "" ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveEffectiveHourlyRate(
|
export function resolveEffectiveHourlyRate(
|
||||||
enteredRate: number,
|
enteredRate: number,
|
||||||
client?: { defaultHourlyRate?: number | null } | null,
|
client?: { defaultHourlyRate?: number | null } | null,
|
||||||
|
|||||||
@@ -20,9 +20,15 @@ import {
|
|||||||
RECEIPT_MAX_BYTES,
|
RECEIPT_MAX_BYTES,
|
||||||
} from "~/lib/object-storage";
|
} from "~/lib/object-storage";
|
||||||
import { parseReceiptText } from "~/lib/receipt-parse";
|
import { parseReceiptText } from "~/lib/receipt-parse";
|
||||||
|
import type { db } from "~/server/db";
|
||||||
|
|
||||||
export { EXPENSE_CATEGORIES };
|
export { EXPENSE_CATEGORIES };
|
||||||
|
|
||||||
|
type ExpenseContext = {
|
||||||
|
db: typeof db;
|
||||||
|
session: { user: { id: string } };
|
||||||
|
};
|
||||||
|
|
||||||
const createExpenseSchema = z.object({
|
const createExpenseSchema = z.object({
|
||||||
date: z.date(),
|
date: z.date(),
|
||||||
description: z.string().min(1, "Description is required"),
|
description: z.string().min(1, "Description is required"),
|
||||||
@@ -43,7 +49,7 @@ const updateExpenseSchema = createExpenseSchema.partial().extend({
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function verifyClientAccess(
|
async function verifyClientAccess(
|
||||||
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
|
ctx: ExpenseContext,
|
||||||
clientId: string,
|
clientId: string,
|
||||||
) {
|
) {
|
||||||
const client = await ctx.db.query.clients.findFirst({
|
const client = await ctx.db.query.clients.findFirst({
|
||||||
@@ -62,7 +68,7 @@ async function verifyClientAccess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function verifyInvoiceAccess(
|
async function verifyInvoiceAccess(
|
||||||
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
|
ctx: ExpenseContext,
|
||||||
invoiceId: string,
|
invoiceId: string,
|
||||||
) {
|
) {
|
||||||
const invoice = await ctx.db.query.invoices.findFirst({
|
const invoice = await ctx.db.query.invoices.findFirst({
|
||||||
@@ -81,7 +87,7 @@ async function verifyInvoiceAccess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveExpenseBusinessId(
|
async function resolveExpenseBusinessId(
|
||||||
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
|
ctx: ExpenseContext,
|
||||||
businessId: string | null,
|
businessId: string | null,
|
||||||
invoice?: { businessId: string | null } | null,
|
invoice?: { businessId: string | null } | null,
|
||||||
) {
|
) {
|
||||||
@@ -98,7 +104,7 @@ async function resolveExpenseBusinessId(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getOwnedExpense(
|
async function getOwnedExpense(
|
||||||
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
|
ctx: ExpenseContext,
|
||||||
expenseId: string,
|
expenseId: string,
|
||||||
) {
|
) {
|
||||||
const expense = await ctx.db.query.expenses.findFirst({
|
const expense = await ctx.db.query.expenses.findFirst({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import type { db } from "~/server/db";
|
import type { db } from "~/server/db";
|
||||||
import {
|
import {
|
||||||
computeTrackedHours,
|
computeTrackedHours,
|
||||||
|
normalizeOptionalId,
|
||||||
resolveBillingDescription,
|
resolveBillingDescription,
|
||||||
type ClockOutOutcome,
|
type ClockOutOutcome,
|
||||||
} from "~/lib/time-clock";
|
} from "~/lib/time-clock";
|
||||||
@@ -242,7 +243,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientId = input.clientId?.trim() || null;
|
const clientId = normalizeOptionalId(input.clientId);
|
||||||
let clientRecord: { defaultHourlyRate: number | null } | null = null;
|
let clientRecord: { defaultHourlyRate: number | null } | null = null;
|
||||||
if (clientId) {
|
if (clientId) {
|
||||||
const found = await ctx.db.query.clients.findFirst({
|
const found = await ctx.db.query.clients.findFirst({
|
||||||
@@ -514,7 +515,7 @@ export const timeEntriesRouter = createTRPCRouter({
|
|||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(createSchema)
|
.input(createSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const clientId = input.clientId?.trim() || null;
|
const clientId = normalizeOptionalId(input.clientId);
|
||||||
if (clientId) {
|
if (clientId) {
|
||||||
const client = await ctx.db.query.clients.findFirst({
|
const client = await ctx.db.query.clients.findFirst({
|
||||||
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
|
where: and(eq(clients.id, clientId), eq(clients.createdById, ctx.session.user.id)),
|
||||||
|
|||||||
Reference in New Issue
Block a user