d75380eb9a
Deploy to route.crispygoat.com / deploy (push) Successful in 4m18s
Previously createAdminUser only inserted a local admin_users row and
emailed the password as plaintext — the user could not sign in because
the password was never set on a Neon Auth account.
This change makes the create flow:
1. Authorize: only platform_admin can mint new admin users.
2. Create the Neon Auth user via auth.admin.createUser, falling back
to the public /sign-up/email + emailVerified=true pattern when
the caller's Neon Auth session isn't an admin (the common case
in dev — provision-admin.ts does not set neon_auth.user.role).
3. Wrap the admin_users INSERT + admin_user_brands link in a
transaction, returning an error if the local insert fails (the
Neon Auth user is left orphaned and surfaced in the message).
4. Send the welcome email best-effort, returning success/failure
info to the UI.
The CreateUserModal now shows a success state with the temp password
(copy-to-clipboard), the welcome email status, and the auth path
used. The slide-in edit panel surfaces the password via window.alert
as a defense against the dead-code new-user path.
10 new unit tests cover authorization, the admin + signup paths, the
DB-failure orphan case, and the email best-effort behavior.
590 lines
22 KiB
TypeScript
590 lines
22 KiB
TypeScript
"use server";
|
|
|
|
import "server-only";
|
|
import { query, withTx } from "@/lib/db";
|
|
import { createUser as neonAuthCreateUser } from "@/lib/auth";
|
|
import { getAdminUser } from "@/lib/admin-permissions";
|
|
|
|
export type AdminUserRow = {
|
|
id: string;
|
|
user_id: string | null;
|
|
display_name: string | null;
|
|
email: string;
|
|
phone_number: string | null;
|
|
role: "platform_admin" | "brand_admin" | "store_employee" | "staff";
|
|
brand_id: string | null;
|
|
brand_name: string | null;
|
|
can_manage_products: boolean;
|
|
can_manage_stops: boolean;
|
|
can_manage_orders: boolean;
|
|
can_manage_pickup: boolean;
|
|
can_manage_messages: boolean;
|
|
can_manage_refunds: boolean;
|
|
can_manage_users: boolean;
|
|
can_manage_water_log: boolean;
|
|
can_manage_reports: boolean;
|
|
active: boolean;
|
|
must_change_password: boolean;
|
|
created_at: string;
|
|
last_login: string | null;
|
|
};
|
|
|
|
export type CreateAdminUserInput = {
|
|
email: string;
|
|
password: string;
|
|
role: "platform_admin" | "brand_admin" | "store_employee";
|
|
brand_id: string | null;
|
|
display_name?: string;
|
|
phone_number?: string;
|
|
flags: {
|
|
can_manage_products?: boolean;
|
|
can_manage_stops?: boolean;
|
|
can_manage_orders?: boolean;
|
|
can_manage_pickup?: boolean;
|
|
can_manage_messages?: boolean;
|
|
can_manage_refunds?: boolean;
|
|
can_manage_users?: boolean;
|
|
can_manage_water_log?: boolean;
|
|
can_manage_reports?: boolean;
|
|
};
|
|
mustChangePassword?: boolean;
|
|
};
|
|
|
|
export type UpdateAdminUserInput = {
|
|
id: string;
|
|
role?: "platform_admin" | "brand_admin" | "store_employee";
|
|
brand_id?: string | null;
|
|
flags?: Partial<{
|
|
can_manage_products: boolean;
|
|
can_manage_stops: boolean;
|
|
can_manage_orders: boolean;
|
|
can_manage_pickup: boolean;
|
|
can_manage_messages: boolean;
|
|
can_manage_refunds: boolean;
|
|
can_manage_users: boolean;
|
|
can_manage_water_log: boolean;
|
|
can_manage_reports: boolean;
|
|
}>;
|
|
active?: boolean;
|
|
display_name?: string | null;
|
|
phone_number?: string | null;
|
|
};
|
|
|
|
// ─── Row mapping ────────────────────────────────────────────────────────────
|
|
//
|
|
// `admin_users` schema (after migration 204 + 034 + 037):
|
|
// id, user_id, display_name, email, phone_number, role, brand_id,
|
|
// can_manage_<X> (BOOLEAN each), active, must_change_password,
|
|
// created_at, last_login, raw_user_meta_data, auth_provider, auth_subject
|
|
|
|
function mapUserRow(row: Record<string, unknown>): AdminUserRow {
|
|
return {
|
|
id: String(row.id ?? ""),
|
|
user_id: (row.user_id as string | null) ?? null,
|
|
display_name: (row.display_name as string | null) ?? null,
|
|
email: String(row.email ?? ""),
|
|
phone_number: (row.phone_number as string | null) ?? null,
|
|
role: (row.role as AdminUserRow["role"]) ?? "store_employee",
|
|
brand_id: (row.brand_id as string | null) ?? null,
|
|
brand_name: (row.brand_name as string | null) ?? null,
|
|
can_manage_products: Boolean(row.can_manage_products ?? false),
|
|
can_manage_stops: Boolean(row.can_manage_stops ?? false),
|
|
can_manage_orders: Boolean(row.can_manage_orders ?? false),
|
|
can_manage_pickup: Boolean(row.can_manage_pickup ?? false),
|
|
can_manage_messages: Boolean(row.can_manage_messages ?? false),
|
|
can_manage_refunds: Boolean(row.can_manage_refunds ?? false),
|
|
can_manage_users: Boolean(row.can_manage_users ?? false),
|
|
can_manage_water_log: Boolean(row.can_manage_water_log ?? false),
|
|
can_manage_reports: Boolean(row.can_manage_reports ?? false),
|
|
active: Boolean(row.active ?? true),
|
|
must_change_password: Boolean(row.must_change_password ?? false),
|
|
created_at: String(row.created_at ?? ""),
|
|
last_login: (row.last_login as string | null) ?? null,
|
|
};
|
|
}
|
|
|
|
// ─── Welcome email (best-effort) ────────────────────────────────────────────
|
|
|
|
async function sendWelcomeEmailSafe(input: {
|
|
to: string;
|
|
name: string;
|
|
role: "platform_admin" | "brand_admin" | "store_employee";
|
|
password: string;
|
|
brandId?: string;
|
|
}): Promise<{ sent: boolean; error?: string }> {
|
|
try {
|
|
const { sendWelcomeEmail } = await import("@/lib/email-service");
|
|
const { getBrandSettings } = await import("@/actions/brand-settings");
|
|
const emailRole = input.role === "platform_admin" ? "brand_admin" : input.role;
|
|
|
|
let logoUrl: string | null = null;
|
|
let brandName = "Route Commerce";
|
|
if (input.brandId) {
|
|
try {
|
|
const settings = await getBrandSettings(input.brandId);
|
|
if (settings.success && settings.settings) {
|
|
logoUrl = settings.settings.logo_url ?? null;
|
|
brandName = settings.settings.brand_name ?? brandName;
|
|
}
|
|
} catch (brandErr) {
|
|
console.warn(
|
|
"[createAdminUser] Failed to load brand settings for welcome email:",
|
|
brandErr,
|
|
);
|
|
}
|
|
}
|
|
|
|
const ok = await sendWelcomeEmail({
|
|
to: input.to,
|
|
name: input.name,
|
|
role: emailRole as "brand_admin" | "wholesale_buyer" | "store_employee",
|
|
brandName,
|
|
tempPassword: input.password,
|
|
logoUrl: logoUrl ?? undefined,
|
|
});
|
|
return ok ? { sent: true } : { sent: false, error: "sendWelcomeEmail returned false" };
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
console.error("[createAdminUser] Welcome email failed (non-fatal):", msg);
|
|
return { sent: false, error: msg };
|
|
}
|
|
}
|
|
|
|
// ─── Public actions ─────────────────────────────────────────────────────────
|
|
|
|
export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> {
|
|
try {
|
|
const sql = brandId
|
|
? `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number,
|
|
au.role, au.brand_id, b.name AS brand_name,
|
|
au.can_manage_products, au.can_manage_stops, au.can_manage_orders,
|
|
au.can_manage_pickup, au.can_manage_messages, au.can_manage_refunds,
|
|
au.can_manage_users, au.can_manage_water_log, au.can_manage_reports,
|
|
au.active, au.must_change_password, au.created_at, au.last_login
|
|
FROM admin_users au
|
|
LEFT JOIN brands b ON b.id = au.brand_id
|
|
WHERE au.brand_id = $1
|
|
ORDER BY au.created_at DESC`
|
|
: `SELECT au.id, au.user_id, au.display_name, au.email, au.phone_number,
|
|
au.role, au.brand_id, b.name AS brand_name,
|
|
au.can_manage_products, au.can_manage_stops, au.can_manage_orders,
|
|
au.can_manage_pickup, au.can_manage_messages, au.can_manage_refunds,
|
|
au.can_manage_users, au.can_manage_water_log, au.can_manage_reports,
|
|
au.active, au.must_change_password, au.created_at, au.last_login
|
|
FROM admin_users au
|
|
LEFT JOIN brands b ON b.id = au.brand_id
|
|
ORDER BY au.created_at DESC`;
|
|
const { rows } = await query<Record<string, unknown>>(sql, brandId ? [brandId] : []);
|
|
return { users: rows.map(mapUserRow), error: null };
|
|
} catch (err) {
|
|
return { users: [], error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
}
|
|
|
|
export type CreateAdminUserResult = {
|
|
user: AdminUserRow | null;
|
|
error: string | null;
|
|
/**
|
|
* The temporary password that was set on the Neon Auth account. Only
|
|
* populated on success. The UI should display this once to the caller
|
|
* and offer a copy-to-clipboard affordance — the password is never
|
|
* stored in plaintext and cannot be retrieved again.
|
|
*/
|
|
tempPassword?: string;
|
|
/**
|
|
* Whether the welcome email was dispatched. Best-effort — the user is
|
|
* created even if email fails. False here means the caller should
|
|
* share the password out-of-band.
|
|
*/
|
|
emailSent?: boolean;
|
|
/**
|
|
* Set when `emailSent` is false. Helps the UI show a useful warning.
|
|
*/
|
|
emailError?: string;
|
|
/**
|
|
* Which Neon Auth path was used to create the account. "admin" means
|
|
* the caller's Neon Auth session has `role = 'admin'` and we used the
|
|
* privileged `auth.admin.createUser` endpoint. "signup" means we had
|
|
* to fall back to the public `/sign-up/email` endpoint (e.g. the
|
|
* caller's Neon Auth user is not flagged as admin, or the admin
|
|
* endpoint rejected the request). The choice is recorded for
|
|
* auditability.
|
|
*/
|
|
authPath?: "admin" | "signup";
|
|
};
|
|
|
|
/**
|
|
* Create a new admin user. Steps:
|
|
*
|
|
* 1. Verify the caller is a `platform_admin`.
|
|
* 2. Create the Neon Auth account (with the provided password) and
|
|
* link it to the local `admin_users` row via `user_id`. Tries the
|
|
* privileged `auth.admin.createUser` first; falls back to the
|
|
* public `/sign-up/email` + `emailVerified = true` pattern when
|
|
* the caller's Neon Auth session is not an admin (which is the
|
|
* common case in dev / when a brand admin's user was not
|
|
* provisioned as Neon Auth admin).
|
|
* 3. Insert `admin_user_brands` link if a brand was selected.
|
|
* 4. Send a welcome email with the temporary password (best-effort).
|
|
*
|
|
* The temp password is returned to the caller so the UI can show it
|
|
* once — this is the only opportunity to share it with the new user,
|
|
* since Neon Auth stores a hash, not the plaintext.
|
|
*/
|
|
export async function createAdminUser(
|
|
input: CreateAdminUserInput,
|
|
): Promise<CreateAdminUserResult> {
|
|
// 1. Authorization: only platform admins can mint new admin users.
|
|
const caller = await getAdminUser();
|
|
if (!caller) {
|
|
return { user: null, error: "Not authenticated. Please sign in again." };
|
|
}
|
|
if (caller.role !== "platform_admin") {
|
|
return {
|
|
user: null,
|
|
error: "Only platform admins can create new admin users.",
|
|
};
|
|
}
|
|
|
|
const email = input.email.trim().toLowerCase();
|
|
const password = input.password;
|
|
const displayName = input.display_name ?? email.split("@")[0];
|
|
const f = input.flags;
|
|
|
|
let neonAuthUserId: string;
|
|
let authPath: "admin" | "signup" = "admin";
|
|
try {
|
|
// 2. Create the Neon Auth account. Try the privileged admin
|
|
// endpoint first.
|
|
const adminResult = await neonAuthCreateUser({
|
|
email,
|
|
password,
|
|
name: displayName,
|
|
});
|
|
|
|
if (adminResult.data?.user?.id) {
|
|
neonAuthUserId = String(adminResult.data.user.id);
|
|
authPath = "admin";
|
|
} else if (adminResult.error) {
|
|
// 2b. Fallback: public sign-up + flip emailVerified to true. The
|
|
// admin endpoint requires the caller to have role='admin' in
|
|
// Neon Auth, which is not always true (provision-admin.ts does
|
|
// not set it). Falling back to sign-up is a safe, well-tested
|
|
// path — see scripts/seed-admin.ts.
|
|
const code = adminResult.error.code ?? "";
|
|
const isAuthRequired =
|
|
code === "FAILED_TO_CREATE_USER" ||
|
|
code === "FORBIDDEN" ||
|
|
code === "UNAUTHORIZED" ||
|
|
code === "INTERNAL_SERVER_ERROR" ||
|
|
code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL";
|
|
|
|
if (code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
|
|
return {
|
|
user: null,
|
|
error: `A Neon Auth user with email ${email} already exists. Pick a different email or reset the existing user's password.`,
|
|
};
|
|
}
|
|
|
|
if (!isAuthRequired) {
|
|
return {
|
|
user: null,
|
|
error: `Neon Auth createUser failed: ${adminResult.error.message ?? code ?? "unknown error"}`,
|
|
};
|
|
}
|
|
|
|
authPath = "signup";
|
|
const signupResult = await signupFallbackCreate(email, password, displayName);
|
|
if (!signupResult.userId) {
|
|
return {
|
|
user: null,
|
|
error: `Could not create Neon Auth user via fallback sign-up: ${signupResult.error}`,
|
|
};
|
|
}
|
|
neonAuthUserId = signupResult.userId;
|
|
} else {
|
|
return {
|
|
user: null,
|
|
error: "Neon Auth createUser returned neither data nor error.",
|
|
};
|
|
}
|
|
} catch (err) {
|
|
return {
|
|
user: null,
|
|
error: `Neon Auth error: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
|
|
// 3. Insert the local admin_users row + brand link in a transaction.
|
|
// We do this AFTER creating the Neon Auth user so a Neon Auth
|
|
// failure doesn't leave a dangling local row.
|
|
let insertedRow: Record<string, unknown> | null = null;
|
|
try {
|
|
const result = await withTx(async (client) => {
|
|
const ins = await client.query<Record<string, unknown>>(
|
|
`INSERT INTO admin_users
|
|
(user_id, display_name, email, phone_number, role, brand_id,
|
|
can_manage_products, can_manage_stops, can_manage_orders,
|
|
can_manage_pickup, can_manage_messages, can_manage_refunds,
|
|
can_manage_users, can_manage_water_log, can_manage_reports,
|
|
active, must_change_password, auth_provider, auth_subject)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,true,$16,'neon_auth',$17)
|
|
ON CONFLICT (user_id) DO UPDATE SET
|
|
display_name = EXCLUDED.display_name,
|
|
email = EXCLUDED.email,
|
|
phone_number = EXCLUDED.phone_number,
|
|
role = EXCLUDED.role,
|
|
brand_id = EXCLUDED.brand_id,
|
|
can_manage_products = EXCLUDED.can_manage_products,
|
|
can_manage_stops = EXCLUDED.can_manage_stops,
|
|
can_manage_orders = EXCLUDED.can_manage_orders,
|
|
can_manage_pickup = EXCLUDED.can_manage_pickup,
|
|
can_manage_messages = EXCLUDED.can_manage_messages,
|
|
can_manage_refunds = EXCLUDED.can_manage_refunds,
|
|
can_manage_users = EXCLUDED.can_manage_users,
|
|
can_manage_water_log= EXCLUDED.can_manage_water_log,
|
|
can_manage_reports = EXCLUDED.can_manage_reports,
|
|
active = EXCLUDED.active,
|
|
must_change_password= EXCLUDED.must_change_password,
|
|
auth_provider = EXCLUDED.auth_provider
|
|
RETURNING id, user_id, display_name, email, phone_number, role, brand_id,
|
|
can_manage_products, can_manage_stops, can_manage_orders,
|
|
can_manage_pickup, can_manage_messages, can_manage_refunds,
|
|
can_manage_users, can_manage_water_log, can_manage_reports,
|
|
active, must_change_password, created_at, last_login`,
|
|
[
|
|
neonAuthUserId,
|
|
displayName,
|
|
email,
|
|
input.phone_number ?? null,
|
|
input.role,
|
|
input.brand_id,
|
|
f.can_manage_products ?? false,
|
|
f.can_manage_stops ?? false,
|
|
f.can_manage_orders ?? false,
|
|
f.can_manage_pickup ?? false,
|
|
f.can_manage_messages ?? false,
|
|
f.can_manage_refunds ?? false,
|
|
f.can_manage_users ?? false,
|
|
f.can_manage_water_log ?? false,
|
|
f.can_manage_reports ?? false,
|
|
input.mustChangePassword ?? true,
|
|
neonAuthUserId,
|
|
],
|
|
);
|
|
if (!ins.rows[0]) throw new Error("admin_users insert returned no row");
|
|
|
|
if (input.brand_id) {
|
|
await client.query(
|
|
`INSERT INTO admin_user_brands (admin_user_id, brand_id)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (admin_user_id, brand_id) DO NOTHING`,
|
|
[ins.rows[0].id, input.brand_id],
|
|
);
|
|
}
|
|
return ins.rows[0];
|
|
});
|
|
insertedRow = result;
|
|
} catch (err) {
|
|
// We created the Neon Auth account but failed to insert the local
|
|
// row. Don't roll back the auth user — the operator can re-link
|
|
// it via the admin UI / DB. Surface the error.
|
|
console.error(
|
|
"[createAdminUser] Neon Auth user created (id=" + neonAuthUserId + ") but admin_users insert failed:",
|
|
err,
|
|
);
|
|
return {
|
|
user: null,
|
|
error:
|
|
`Neon Auth user was created but the local admin row failed: ${
|
|
err instanceof Error ? err.message : String(err)
|
|
}. The Neon Auth account is orphaned — link it manually via the admin_users table.`,
|
|
};
|
|
}
|
|
|
|
// 4. Welcome email (best-effort).
|
|
const emailResult = await sendWelcomeEmailSafe({
|
|
to: email,
|
|
name: displayName,
|
|
role: input.role,
|
|
password,
|
|
brandId: input.brand_id ?? undefined,
|
|
});
|
|
|
|
return {
|
|
user: mapUserRow(insertedRow),
|
|
error: null,
|
|
tempPassword: password,
|
|
emailSent: emailResult.sent,
|
|
emailError: emailResult.error,
|
|
authPath,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Public sign-up + emailVerified = true fallback. Used when the
|
|
* caller's Neon Auth session is not flagged as admin, or the admin
|
|
* endpoint rejected the request for any reason.
|
|
*/
|
|
async function signupFallbackCreate(
|
|
email: string,
|
|
password: string,
|
|
name: string,
|
|
): Promise<{ userId: string | null; error: string | null }> {
|
|
const baseUrl = process.env.NEON_AUTH_BASE_URL;
|
|
if (!baseUrl) {
|
|
return {
|
|
userId: null,
|
|
error: "NEON_AUTH_BASE_URL is not set; cannot fall back to public sign-up.",
|
|
};
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`${baseUrl}/sign-up/email`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Origin": process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000",
|
|
"x-neon-auth-proxy": "node",
|
|
},
|
|
body: JSON.stringify({
|
|
email,
|
|
password,
|
|
name,
|
|
callbackURL: "/admin",
|
|
}),
|
|
});
|
|
const data = (await res.json().catch(() => ({}))) as {
|
|
user?: { id?: string };
|
|
code?: string;
|
|
message?: string;
|
|
};
|
|
|
|
if (!res.ok) {
|
|
if (data.code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
|
|
// Look up the existing user id so we can still link.
|
|
const existing = await query<{ id: string }>(
|
|
`SELECT id FROM neon_auth.user WHERE email = $1 LIMIT 1`,
|
|
[email],
|
|
);
|
|
if (existing.rows[0]?.id) {
|
|
return { userId: existing.rows[0].id, error: null };
|
|
}
|
|
return { userId: null, error: "User already exists but could not be looked up." };
|
|
}
|
|
return {
|
|
userId: null,
|
|
error: data.message ?? data.code ?? `Sign-up failed (HTTP ${res.status})`,
|
|
};
|
|
}
|
|
|
|
const userId = data.user?.id;
|
|
if (!userId) {
|
|
return { userId: null, error: "Sign-up response missing user.id" };
|
|
}
|
|
|
|
// Flip emailVerified so the user can sign in without a verification step.
|
|
// The platform admin already vetted this email — they're handing the
|
|
// password to the user directly.
|
|
try {
|
|
await query(`UPDATE neon_auth.user SET "emailVerified" = true WHERE id = $1`, [userId]);
|
|
} catch (verifyErr) {
|
|
console.warn(
|
|
"[createAdminUser] Failed to set emailVerified=true (non-fatal):",
|
|
verifyErr,
|
|
);
|
|
}
|
|
|
|
return { userId, error: null };
|
|
} catch (err) {
|
|
return {
|
|
userId: null,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> {
|
|
try {
|
|
// Build a partial SET clause. Each `can_manage_*` column is set
|
|
// individually — the input's `flags` partial is spread across them.
|
|
const sets: string[] = [];
|
|
const params: unknown[] = [];
|
|
const push = (col: string, val: unknown) => { params.push(val); sets.push(`${col} = $${params.length}`); };
|
|
|
|
if (input.role !== undefined) push("role", input.role);
|
|
if (input.brand_id !== undefined) push("brand_id", input.brand_id);
|
|
if (input.active !== undefined) push("active", input.active);
|
|
if (input.display_name !== undefined) push("display_name", input.display_name);
|
|
if (input.phone_number !== undefined) push("phone_number", input.phone_number);
|
|
if (input.flags) {
|
|
for (const [key, val] of Object.entries(input.flags)) {
|
|
if (val !== undefined) push(key, val);
|
|
}
|
|
}
|
|
if (sets.length === 0) return { user: null, error: "Nothing to update" };
|
|
|
|
params.push(input.id);
|
|
const sql = `UPDATE admin_users SET ${sets.join(", ")}
|
|
WHERE id = $${params.length}
|
|
RETURNING id, user_id, display_name, email, phone_number, role, brand_id,
|
|
can_manage_products, can_manage_stops, can_manage_orders,
|
|
can_manage_pickup, can_manage_messages, can_manage_refunds,
|
|
can_manage_users, can_manage_water_log, can_manage_reports,
|
|
active, must_change_password, created_at, last_login`;
|
|
const { rows } = await query<Record<string, unknown>>(sql, params);
|
|
if (!rows[0]) return { user: null, error: "User not found" };
|
|
return { user: mapUserRow(rows[0]), error: null };
|
|
} catch (err) {
|
|
return { user: null, error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
}
|
|
|
|
export async function deleteAdminUser(id: string): Promise<{ success: boolean; error: string | null }> {
|
|
try {
|
|
// No Supabase Auth — nothing to delete from the auth service.
|
|
const { rowCount } = await query(`DELETE FROM admin_users WHERE id = $1`, [id]);
|
|
return { success: (rowCount ?? 0) > 0, error: null };
|
|
} catch (err) {
|
|
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
}
|
|
|
|
export async function setMustChangePassword(userId: string): Promise<{ success: boolean; error: string | null }> {
|
|
try {
|
|
const { rowCount } = await query(
|
|
`UPDATE admin_users SET must_change_password = true WHERE id = $1`,
|
|
[userId],
|
|
);
|
|
return { success: (rowCount ?? 0) > 0, error: null };
|
|
} catch (err) {
|
|
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* No auth service anymore (no Supabase, no Auth.js password-reset
|
|
* endpoint). A platform admin can reset access by deleting +
|
|
* re-creating the user, or by toggling `must_change_password` via the
|
|
* UI — the function is preserved as a no-op so call sites keep
|
|
* compiling.
|
|
*/
|
|
export async function sendPasswordResetEmail(_email: string): Promise<{ success: boolean; error: string | null }> {
|
|
return {
|
|
success: false,
|
|
error: "Password reset is handled by a platform admin. Contact them to reset your access.",
|
|
};
|
|
}
|
|
|
|
export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> {
|
|
try {
|
|
// Sort by name so the platform admin's brand picker stays stable.
|
|
const { rows } = await query<{ id: string; name: string }>(
|
|
`SELECT id, name FROM brands ORDER BY name`,
|
|
);
|
|
return { brands: rows, error: null };
|
|
} catch (err) {
|
|
return { brands: [], error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
}
|