Merge GitHub main into Gitea main
Brings in: - .gitea/workflows/build.yml (build + typecheck + lint on self-hosted runner) - scripts/e2e-test.sh (local Postgres + PostgREST + MinIO + Next.js validation) - Codex review round 2 fixes (buyer/billing/comms/a11y) - 207 multi-brand admin migration - Locations table + UI - Various product/stops/auth refinements Resolved 7 conflicts by taking Gitea's version to preserve production behavior: - package.json (deps) - src/actions/products/upload-image.ts (storage) - src/app/admin/taxes/page.tsx - src/app/checkout/CheckoutClient.tsx - src/components/admin/AdminSidebar.tsx - src/components/admin/StopProductAssignment.tsx - src/lib/admin-permissions.ts Our new dev_session auth + MinIO storage changes are deferred to a focused follow-up to avoid breaking the production deploy.
This commit is contained in:
@@ -1,9 +1,19 @@
|
||||
// Shared AdminUser type — safe to import from both server and client components
|
||||
//
|
||||
// `brand_id` is the active brand (one of `brand_ids`, or null for platform_admin).
|
||||
// `brand_ids` is the full list of brands the admin can act in.
|
||||
// - platform_admin: `brand_id = null`, `brand_ids = []` (in dev) or all brands
|
||||
// (resolved by `listBrandsForAdmin`).
|
||||
// - multi_brand_admin: `brand_id` = selected/cookie brand, `brand_ids` = 2+.
|
||||
// - brand_admin / store_employee / staff: `brand_id` = their single brand,
|
||||
// `brand_ids = [that one]`.
|
||||
export type AdminUser = {
|
||||
id?: string;
|
||||
user_id: string;
|
||||
brand_id: string | null;
|
||||
role: "platform_admin" | "brand_admin" | "store_employee" | "staff";
|
||||
brand_ids: string[];
|
||||
role: "platform_admin" | "brand_admin" | "multi_brand_admin" | "store_employee" | "staff";
|
||||
active: boolean;
|
||||
can_manage_products: boolean;
|
||||
can_manage_stops: boolean;
|
||||
can_manage_orders: boolean;
|
||||
@@ -15,4 +25,4 @@ export type AdminUser = {
|
||||
can_manage_reports: boolean;
|
||||
can_manage_settings: boolean;
|
||||
must_change_password?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Brand-scope helpers for multi-brand admin support.
|
||||
*
|
||||
* Resolution order (documented in
|
||||
* docs/superpowers/specs/2026-06-04-multi-brand-admin-design.md):
|
||||
* 1. URL/explicit `requested` brand id (highest priority)
|
||||
* 2. `active_brand_id` cookie (the persistent "what brand am I in right now")
|
||||
* 3. `adminUser.brand_id` (legacy single-brand fallback)
|
||||
* 4. First of `adminUser.brand_ids`
|
||||
* 5. (platform_admin only) `null` → "all brands"
|
||||
*
|
||||
* For non-platform-admins, the returned brand is validated against
|
||||
* `adminUser.brand_ids` — if `requested` or the cookie brand is not in the
|
||||
* admin's accessible brands, the resolver falls through to a brand the admin
|
||||
* does have access to (silent recovery).
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import type { AdminUser } from "./admin-permissions-types";
|
||||
|
||||
export const ACTIVE_BRAND_COOKIE = "active_brand_id";
|
||||
|
||||
/**
|
||||
* Resolve the active brand id for the given admin user.
|
||||
*
|
||||
* @param adminUser - The current admin user (must already be loaded).
|
||||
* @param requested - Optional explicit brand id (e.g. from a URL param).
|
||||
* When set and the admin has access, wins over cookie.
|
||||
* @returns The brand id to act in, or `null` for platform_admin "all brands".
|
||||
*/
|
||||
export async function getActiveBrandId(
|
||||
adminUser: AdminUser,
|
||||
requested?: string | null
|
||||
): Promise<string | null> {
|
||||
const cookieStore = await cookies();
|
||||
const cookieBrand = cookieStore.get(ACTIVE_BRAND_COOKIE)?.value ?? null;
|
||||
|
||||
// platform_admin: requested > cookie > null (all brands)
|
||||
if (adminUser.role === "platform_admin") {
|
||||
return requested ?? cookieBrand ?? null;
|
||||
}
|
||||
|
||||
// Non-platform-admin: validate that requested/cookie brands are accessible
|
||||
if (requested && adminUser.brand_ids.includes(requested)) {
|
||||
return requested;
|
||||
}
|
||||
if (cookieBrand && adminUser.brand_ids.includes(cookieBrand)) {
|
||||
return cookieBrand;
|
||||
}
|
||||
|
||||
// Fall back to the legacy single brand, then first of the membership list
|
||||
return adminUser.brand_id ?? adminUser.brand_ids[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the persistent active-brand cookie. Should only be called after
|
||||
* validating the admin has access (use `assertBrandAccess`).
|
||||
*/
|
||||
export async function setActiveBrandCookie(brandId: string): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(ACTIVE_BRAND_COOKIE, brandId, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the active-brand cookie. Used when platform_admin selects
|
||||
* "All brands" (the cookie absence = "no specific brand pinned").
|
||||
*/
|
||||
export async function clearActiveBrandCookie(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(ACTIVE_BRAND_COOKIE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws if the admin user is not a platform_admin and does not have the
|
||||
* given brand in their membership list. Use this for server actions and
|
||||
* API routes that receive a brandId from URL/form/RPC return rather than
|
||||
* `getActiveBrandId`.
|
||||
*/
|
||||
export function assertBrandAccess(adminUser: AdminUser, brandId: string): void {
|
||||
if (adminUser.role === "platform_admin") return;
|
||||
if (!adminUser.brand_ids.includes(brandId)) {
|
||||
throw new Error("Brand access denied");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Browser-only Stripe.js loader.
|
||||
*
|
||||
* Reads `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` and returns a cached
|
||||
* `loadStripe` promise. Never call this from a server component — it
|
||||
* pulls in `@stripe/stripe-js` which depends on `window`.
|
||||
*
|
||||
* The published key is safe to ship to the browser; the secret key
|
||||
* never leaves the server (see `src/actions/billing/retail-checkout.ts`).
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { loadStripe, type Stripe } from "@stripe/stripe-js";
|
||||
|
||||
let stripePromise: Promise<Stripe | null> | null = null;
|
||||
|
||||
/**
|
||||
* Returns the cached Stripe.js promise, creating it on the first call.
|
||||
* Returns `null` if the publishable key is not configured — callers
|
||||
* should fall back to the hosted Stripe Checkout flow in that case.
|
||||
*/
|
||||
export function getStripe(): Promise<Stripe | null> | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const key = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
|
||||
if (!key) return null;
|
||||
if (!stripePromise) {
|
||||
stripePromise = loadStripe(key);
|
||||
}
|
||||
return stripePromise;
|
||||
}
|
||||
|
||||
/** Synchronous check — useful for deciding whether to render Stripe Elements. */
|
||||
export function hasStripePublishableKey(): boolean {
|
||||
return Boolean(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
|
||||
}
|
||||
Reference in New Issue
Block a user