Initial commit - Route Commerce platform

This commit is contained in:
2026-06-01 19:40:55 +00:00
commit 53a9671461
617 changed files with 106132 additions and 0 deletions
+656
View File
@@ -0,0 +1,656 @@
"use client";
import { useState } from "react";
import { AdminUserRow, CreateAdminUserInput, UpdateAdminUserInput } from "@/actions/admin/users";
import { formatDate } from "@/lib/format-date";
type UsersPageProps = {
initialUsers: AdminUserRow[];
brands: { id: string; name: string }[];
currentUser: {
id: string;
role: string;
can_manage_users?: boolean;
};
};
type PasswordModal = {
email: string;
tempPassword: string | null;
loading: boolean;
error: string | null;
};
const FLAG_LABELS: Record<string, string> = {
can_manage_products: "Products",
can_manage_stops: "Stops",
can_manage_orders: "Orders",
can_manage_pickup: "Pickup",
can_manage_messages: "Messages",
can_manage_refunds: "Refunds",
can_manage_users: "Users",
can_manage_water_log: "Water Log",
can_manage_reports: "Reports",
};
const ALL_FLAGS = Object.keys(FLAG_LABELS);
function RoleBadge({ role }: { role: string }) {
const colors: Record<string, string> = {
platform_admin: "bg-purple-100 text-purple-700",
brand_admin: "bg-blue-100 text-blue-700",
store_employee: "bg-emerald-100 text-emerald-700",
staff: "bg-stone-100 text-stone-600",
};
return (
<span className={`rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${colors[role] ?? "bg-stone-100 text-stone-600"}`}>
{role.replace("_", " ")}
</span>
);
}
type EditingUser = {
id?: string;
email?: string;
password?: string;
display_name?: string;
phone_number?: string;
role: "platform_admin" | "brand_admin" | "store_employee";
brand_id: string | null;
flags: Record<string, boolean>;
active?: boolean;
isNew: boolean;
};
const defaultFlags: Record<string, boolean> = {
can_manage_products: false,
can_manage_stops: false,
can_manage_orders: false,
can_manage_pickup: false,
can_manage_messages: false,
can_manage_refunds: false,
can_manage_users: false,
can_manage_water_log: false,
can_manage_reports: false,
};
function emptyEditing(isNew: boolean): EditingUser {
return {
isNew,
role: "store_employee",
brand_id: null,
display_name: "",
phone_number: "",
password: "",
flags: { ...defaultFlags },
};
}
function editingFromRow(row: AdminUserRow): EditingUser {
return {
id: row.id,
email: row.email,
display_name: row.display_name ?? "",
phone_number: row.phone_number ?? "",
role: row.role as "platform_admin" | "brand_admin" | "store_employee",
brand_id: row.brand_id,
flags: {
can_manage_products: row.can_manage_products,
can_manage_stops: row.can_manage_stops,
can_manage_orders: row.can_manage_orders,
can_manage_pickup: row.can_manage_pickup,
can_manage_messages: row.can_manage_messages,
can_manage_refunds: row.can_manage_refunds,
can_manage_users: row.can_manage_users,
can_manage_water_log: row.can_manage_water_log,
can_manage_reports: row.can_manage_reports,
},
active: row.active,
isNew: false,
};
}
export default function UsersPage({ initialUsers, brands, currentUser }: UsersPageProps) {
const [users, setUsers] = useState(initialUsers);
const [panelOpen, setPanelOpen] = useState(false);
const [editing, setEditing] = useState<EditingUser>(emptyEditing(true));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [actionMenuId, setActionMenuId] = useState<string | null>(null);
const [passwordModal, setPasswordModal] = useState<PasswordModal | null>(null);
const [resetLinkLoading, setResetLinkLoading] = useState<string | null>(null);
const canManageUsers =
currentUser.role === "platform_admin" ||
(currentUser.role === "brand_admin" && currentUser.can_manage_users);
function openCreate() {
setEditing(emptyEditing(true));
setError(null);
setPanelOpen(true);
}
function openEdit(row: AdminUserRow) {
setEditing(editingFromRow(row));
setError(null);
setPanelOpen(true);
}
function closePanel() {
setPanelOpen(false);
setEditing(emptyEditing(true));
setError(null);
}
function setRole(role: EditingUser["role"]) {
setEditing((prev) => ({ ...prev, role }));
}
function setBrand(brand_id: string | null) {
setEditing((prev) => ({ ...prev, brand_id }));
}
function toggleFlag(flag: string) {
setEditing((prev) => ({
...prev,
flags: { ...prev.flags, [flag]: !prev.flags[flag] },
}));
}
function setActive(active: boolean) {
setEditing((prev) => ({ ...prev, active }));
}
async function handleSave() {
setSaving(true);
setError(null);
try {
if (editing.isNew) {
const res = await import("@/actions/admin/users").then((m) =>
m.createAdminUser({
email: editing.email!,
password: editing.password!,
display_name: editing.display_name || undefined,
phone_number: editing.phone_number || undefined,
role: editing.role,
brand_id: editing.brand_id,
flags: editing.flags,
mustChangePassword: true,
})
);
if (res.error) {
if (res.error === "Not authenticated" || res.error.includes("session") || res.error.includes("authenticated")) {
setError("Your session may have expired. Please log in again.");
} else if (res.error.includes("permission") || res.error.includes("Insufficient permissions")) {
setError("You do not have permission to edit this user.");
} else {
setError(res.error);
}
return;
}
if (res.user) setUsers((prev) => [res.user!, ...prev]);
} else {
const res = await import("@/actions/admin/users").then((m) =>
m.updateAdminUser({
id: editing.id!,
role: editing.role,
brand_id: editing.brand_id,
flags: editing.flags,
active: editing.active,
display_name: editing.display_name === "" ? null : editing.display_name || undefined,
phone_number: editing.phone_number === "" ? null : editing.phone_number || undefined,
})
);
if (res.error) {
if (res.error === "Not authenticated" || res.error.includes("session") || res.error.includes("authenticated")) {
setError("Your session may have expired. Please log in again.");
} else if (res.error.includes("permission") || res.error.includes("Insufficient permissions")) {
setError("You do not have permission to edit this user.");
} else {
setError(res.error);
}
return;
}
if (res.user) setUsers((prev) => prev.map((u) => (u.id === res.user!.id ? res.user! : u)));
}
closePanel();
} finally {
setSaving(false);
}
}
async function handleDelete(id: string) {
const res = await import("@/actions/admin/users").then((m) => m.deleteAdminUser(id));
if (res.error) { setError(res.error); return; }
setUsers((prev) => prev.filter((u) => u.id !== id));
setDeleteConfirmId(null);
}
async function handleResetPassword(email: string) {
setPasswordModal({ email, tempPassword: null, loading: true, error: null });
try {
const { resetAdminPassword } = await import("@/actions/admin/reset-admin");
const result = await resetAdminPassword(email, "Tuxedo2026!");
if (result.success) {
setPasswordModal({ email, tempPassword: result.tempPassword, loading: false, error: null });
} else {
setPasswordModal({ email, tempPassword: null, loading: false, error: result.error ?? "Failed" });
}
} catch (e: any) {
setPasswordModal({ email, tempPassword: null, loading: false, error: e?.message ?? "Error" });
}
}
async function handleForcePasswordChange(id: string) {
try {
const { setMustChangePassword } = await import("@/actions/admin/users");
const res = await setMustChangePassword(id);
if (res.error) throw new Error(res.error);
setUsers((prev) => prev.map((u) => u.id === id ? { ...u, must_change_password: true } : u));
} catch (e: any) {
setError(e?.message ?? "Failed to require password change");
}
}
async function handleSendResetLink(email: string) {
setResetLinkLoading(email);
try {
const { sendPasswordResetEmail } = await import("@/actions/admin/users");
const result = await sendPasswordResetEmail(email);
setResetLinkLoading(null);
if (result.error) throw new Error(result.error);
setError(null);
} catch (e: any) {
setResetLinkLoading(null);
setError(e?.message ?? "Failed to send reset email");
}
}
function copyTempPassword() {
if (passwordModal?.tempPassword) {
navigator.clipboard.writeText(passwordModal.tempPassword).catch(() => {});
}
}
const showBrandSelect = editing.role === "brand_admin" || editing.role === "store_employee";
const availableRoles: EditingUser["role"][] =
currentUser.role === "platform_admin"
? ["platform_admin", "brand_admin", "store_employee"]
: ["brand_admin", "store_employee"];
return (
<>
{/* User list */}
<div className="mb-6 flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-stone-900">{users.length} user{users.length !== 1 ? "s" : ""}</h2>
</div>
{canManageUsers && (
<button
onClick={openCreate}
className="rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-white hover:bg-stone-800"
>
+ Create User
</button>
)}
</div>
{/* Error banner */}
{error && (
<div className="mb-4 flex items-start justify-between rounded-lg bg-red-50 p-4 text-sm text-red-700 gap-3 border border-red-200">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-600 shrink-0"></button>
</div>
)}
<div className="overflow-visible rounded-xl bg-white shadow-sm ring-1 ring-stone-200">
<table className="w-full text-left text-sm">
<thead className="bg-stone-50 text-stone-500">
<tr>
<th className="px-5 py-4 font-semibold">Name</th>
<th className="px-5 py-4 font-semibold">Phone</th>
<th className="px-5 py-4 font-semibold">Role</th>
<th className="px-5 py-4 font-semibold">Brand</th>
<th className="px-5 py-4 font-semibold">Status</th>
<th className="px-5 py-4 font-semibold">Created</th>
{canManageUsers && <th className="px-5 py-4 font-semibold" />}
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{users.map((user) => (
<tr key={user.id} className="hover:bg-stone-50">
<td className="px-5 py-4">
<div className="font-medium text-stone-900">{user.display_name || user.email}</div>
{user.display_name && user.display_name !== "No Name" && (
<div className="text-xs text-stone-500">{user.email}</div>
)}
</td>
<td className="px-5 py-4 text-stone-500 text-sm">{user.phone_number ?? "—"}</td>
<td className="px-5 py-4">
<RoleBadge role={user.role} />
</td>
<td className="px-5 py-4 text-stone-500">{user.brand_name ?? "—"}</td>
<td className="px-5 py-4">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${user.active ? "bg-emerald-100 text-emerald-700" : "bg-stone-100 text-stone-500"}`}>
{user.active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-5 py-4 text-stone-400 text-xs">
{formatDate(new Date(user.created_at))}
</td>
{canManageUsers && (
<td className="px-5 py-4">
<div className="flex items-center gap-3">
<button
onClick={() => openEdit(user)}
className="text-xs font-medium text-stone-500 hover:text-stone-900"
>
Edit
</button>
<div className="relative overflow-visible">
<button
onClick={() => setActionMenuId(actionMenuId === user.id ? null : user.id)}
className="text-base font-medium text-stone-400 hover:text-stone-600 px-2 py-1"
title="More actions"
>
</button>
{actionMenuId === user.id && (
<>
<div className="fixed inset-0 z-40" onClick={() => setActionMenuId(null)} />
<div className="absolute right-0 z-50 mt-1 w-48 rounded-xl bg-white shadow-xl ring-1 ring-stone-200 border border-stone-100 py-1 text-xs overflow-visible">
<div className="px-3 py-2 text-[10px] font-semibold uppercase tracking-wider text-stone-400 border-b border-stone-100">
Actions
</div>
<button
onClick={() => { setActionMenuId(null); handleResetPassword(user.email); }}
className="w-full text-left px-3 py-2.5 text-stone-700 hover:bg-stone-50 flex items-center gap-2"
>
🔑 Reset Password
</button>
<button
onClick={() => { setActionMenuId(null); handleForcePasswordChange(user.id); }}
className="w-full text-left px-3 py-2.5 text-stone-700 hover:bg-stone-50 flex items-center gap-2"
>
Require Password Change
</button>
<button
onClick={() => { setActionMenuId(null); handleSendResetLink(user.email); }}
className="w-full text-left px-3 py-2.5 text-stone-700 hover:bg-stone-50 flex items-center gap-2"
>
Send Reset Email
</button>
{user.id !== currentUser.id && (
<button
onClick={() => { setActionMenuId(null); setDeleteConfirmId(user.id); }}
className="w-full text-left px-3 py-2.5 text-red-600 hover:bg-red-50 flex items-center gap-2 border-t border-stone-100 mt-1 pt-1"
>
🗑 Delete
</button>
)}
</div>
</>
)}
</div>
</div>
</td>
)}
</tr>
))}
{users.length === 0 && (
<tr>
<td colSpan={7} className="px-5 py-12 text-center text-stone-400">
No users found.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Delete confirm modal */}
{deleteConfirmId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="w-96 rounded-2xl bg-white p-6 shadow-xl ring-1 ring-stone-200">
<h3 className="text-lg font-bold text-stone-900">Delete user?</h3>
<p className="mt-2 text-sm text-stone-600">
This will remove their admin access. Their auth account will remain active.
</p>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setDeleteConfirmId(null)}
className="rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-600 hover:bg-stone-50"
>
Cancel
</button>
<button
onClick={() => handleDelete(deleteConfirmId)}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
)}
{/* Slide-in panel */}
{panelOpen && (
<div className="fixed inset-0 z-50 flex justify-end bg-black/20" onClick={closePanel}>
<div
className="relative flex h-full w-full max-w-md flex-col overflow-y-auto bg-white shadow-xl ring-1 ring-stone-200"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-stone-100 px-6 py-4">
<h2 className="text-lg font-bold text-stone-900">
{editing.isNew ? "Create User" : "Edit User"}
</h2>
<button onClick={closePanel} className="text-stone-400 hover:text-stone-600">
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Saving overlay */}
{saving && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70">
<div className="flex flex-col items-center gap-2">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-stone-200 border-t-stone-900" />
<p className="text-sm text-stone-500">Saving</p>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto px-6 py-6">
<div className="space-y-6">
{/* Email (create only) */}
{editing.isNew && (
<div>
<label className="block text-sm font-medium text-stone-700">Email</label>
<input
type="email"
value={editing.email ?? ""}
onChange={(e) => setEditing((p) => ({ ...p, email: e.target.value }))}
className="mt-1 w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="user@example.com"
/>
</div>
)}
{/* Password (create only) */}
{editing.isNew && (
<div>
<label className="block text-sm font-medium text-stone-700">Password</label>
<p className="mt-1 text-xs text-stone-500 mb-1.5">Minimum 6 characters.</p>
<input
type="password"
value={editing.password ?? ""}
onChange={(e) => setEditing((p) => ({ ...p, password: e.target.value }))}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="Choose a password"
minLength={6}
/>
</div>
)}
{/* Display Name */}
<div>
<label className="block text-sm font-medium text-stone-700">Display Name</label>
<p className="mt-1 text-xs text-stone-500 mb-1.5">Shown in the admin user list.</p>
<input
type="text"
value={editing.display_name ?? ""}
onChange={(e) => setEditing((p) => ({ ...p, display_name: e.target.value }))}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="Kyle Martinez"
/>
</div>
{/* Phone Number */}
<div>
<label className="block text-sm font-medium text-stone-700">Phone Number</label>
<p className="mt-1 text-xs text-stone-500 mb-1.5">Optional.</p>
<input
type="tel"
value={editing.phone_number ?? ""}
onChange={(e) => setEditing((p) => ({ ...p, phone_number: e.target.value }))}
className="w-full rounded-lg border border-stone-200 bg-white px-3 py-2.5 text-sm text-stone-900 placeholder:text-stone-400 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="+1 (555) 000-0000"
/>
</div>
{/* Role */}
<div>
<label className="block text-sm font-medium text-stone-700">Role</label>
<div className="mt-2 space-y-2">
{availableRoles.map((r) => (
<label key={r} className="flex items-center gap-3 rounded-lg border border-stone-200 px-3 py-2.5 cursor-pointer hover:bg-stone-50">
<input
type="radio"
name="role"
value={r}
checked={editing.role === r}
onChange={() => setRole(r)}
className="accent-stone-900"
/>
<div>
<span className="font-medium text-stone-900 capitalize">{r.replace("_", " ")}</span>
<p className="text-xs text-stone-500">
{r === "platform_admin" ? "Full platform access" :
r === "brand_admin" ? "Brand-level admin" :
"Pickup and order operations only"}
</p>
</div>
</label>
))}
</div>
</div>
{/* Brand */}
{showBrandSelect && (
<div>
<label className="block text-sm font-medium text-stone-700">Brand</label>
<select
value={editing.brand_id ?? ""}
onChange={(e) => setBrand(e.target.value || null)}
className="mt-1 w-full rounded-lg border border-stone-200 px-3 py-2.5 text-sm text-stone-900 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
>
<option value="">No brand (full platform scope)</option>
{brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
</div>
)}
{/* Active toggle (edit only) */}
{!editing.isNew && (
<div className="flex items-center justify-between rounded-lg border border-stone-200 px-4 py-3">
<div>
<p className="font-medium text-stone-900">Active</p>
<p className="text-xs text-stone-500">Deactivated users cannot log in</p>
</div>
<button
onClick={() => setActive(!editing.active)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${editing.active ? "bg-emerald-600" : "bg-stone-300"}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${editing.active ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
)}
{/* Permissions */}
<div>
<label className="block text-sm font-medium text-stone-700 mb-3">Permissions</label>
<div className="space-y-2">
{ALL_FLAGS.map((flag) => (
<label key={flag} className="flex items-center justify-between rounded-lg border border-stone-200 px-4 py-2.5 hover:bg-stone-50 cursor-pointer">
<span className="text-sm text-stone-700">{FLAG_LABELS[flag]}</span>
<button
type="button"
onClick={() => toggleFlag(flag)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${editing.flags[flag] ? "bg-emerald-600" : "bg-stone-300"}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${editing.flags[flag] ? "translate-x-6" : "translate-x-1"}`} />
</button>
</label>
))}
</div>
</div>
</div>
</div>
<div className="border-t px-6 py-4">
<div className="flex justify-end gap-3">
<button
onClick={closePanel}
className="rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-600 hover:bg-stone-50"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving || (editing.isNew && (!editing.email?.includes("@") || !editing.password || editing.password.length < 6))}
className="rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-white hover:bg-stone-800 disabled:opacity-50"
>
{saving ? "Saving…" : editing.isNew ? "Create User" : "Save Changes"}
</button>
</div>
</div>
</div>
</div>
)}
{/* Password reset modal */}
{passwordModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="w-96 rounded-2xl bg-white p-6 shadow-xl ring-1 ring-stone-200">
<h3 className="text-lg font-bold text-stone-900">Reset Password</h3>
{passwordModal.loading ? (
<div className="mt-4 flex items-center gap-3 text-sm text-stone-500">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-stone-200 border-t-stone-900" />
Resetting password for {passwordModal.email}...
</div>
) : passwordModal.error ? (
<div>
<p className="mt-3 text-sm text-red-600">{passwordModal.error}</p>
<button onClick={() => setPasswordModal(null)} className="mt-4 w-full rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-600 hover:bg-stone-50">Close</button>
</div>
) : passwordModal.tempPassword ? (
<div>
<p className="mt-3 text-sm text-stone-600">Temporary password for <span className="font-medium text-stone-900">{passwordModal.email}</span>:</p>
<div className="mt-3 flex items-center gap-2 rounded-xl bg-stone-50 p-4 ring-1 ring-stone-200">
<span className="font-mono text-lg font-bold text-stone-900 select-all">{passwordModal.tempPassword}</span>
<button onClick={copyTempPassword} className="shrink-0 rounded-lg bg-stone-200 px-3 py-1 text-xs font-medium text-stone-700 hover:bg-stone-300">Copy</button>
</div>
<p className="mt-2 text-xs text-stone-500">Share this password securely with the user. They will be required to change it on next login.</p>
<button onClick={() => setPasswordModal(null)} className="mt-4 w-full rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-white hover:bg-stone-800">Done</button>
</div>
) : null}
</div>
</div>
)}
</>
);
}