feat: inbox tag+multi-account filter + org gate + invite/join flow

Inbox filter:
- Backend: MailSearchParams gained EmailAccountIDs []uuid.UUID; the
  search SQL filters with `email_id = ANY($)`. /unibox handler now
  accepts both `email_id=<uuid>` (legacy) and `email_ids=<csv>`.
- Frontend: UniboxSearchParams gained accountIds[] and a UI-only
  tagId. searchIncoming sends email_ids=csv. UniboxFilterSheet:
  Accounts section is now (a) a row of tag chips backed by user.tags
  with per-tag account counts and (b) a multi-select list of every
  connected mailbox with an inline checkbox + avatar; accounts that
  belong to the active tag get a "via tag" affordance. Picking a tag
  resolves to the underlying account IDs at Apply time. "Select all"
  / "Clear" inline in the SectionBar header.

Org gate + onboarding:
- New /select-org page. Three sections: pending invitations (one-
  click Join), existing memberships (pick one to enter), and a
  Create New Workspace form (slate-900 primary). Routed at
  /select-org.
- OrgGate hook lives inside RealtimeManager. On load, if the user
  has zero orgs and no current org, it navigates to /select-org
  replace. Renders null so it doesn't displace AppLayout.

Invite + join:
- Team page rebuilt with real data: useMembers + usePendingInvitations,
  plus InviteDialog (email + role popover, slate-900 send button).
  Inline remove on member rows (skip "owner"), inline cancel on
  pending invitations.
- Pending invitations show up on /select-org too — a freshly
  invited user can accept without ever entering the dashboard first.

Response unwrapping:
- Org/member/invitation list clients now tolerate the backend's
  {data: T[] | null} envelope (it's the consistent shape across the
  Go handlers). Map nested membership rows into the flat
  Organization shape the rest of the app expects.

Seeder: re-run verified — dev@warmbly.com still gets "Dev's
Organization" so they don't bounce through /select-org.
This commit is contained in:
Matthew Meszaros
2026-05-23 10:19:56 +00:00
parent f04bd26b44
commit 5845fc3069
15 changed files with 949 additions and 80 deletions
+23 -4
View File
@@ -3,6 +3,7 @@ package handler
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -73,10 +74,28 @@ func (h *Handler) GetUniboxIncoming(c *gin.Context) {
}
}
// Parse email account filter
if emailIDStr := c.Query("email_id"); emailIDStr != "" {
// Note: For email account filtering, we may need to enhance the repository
// This is left as metadata in search params for now
// Parse email account filter. Accepts either:
// - email_id=<uuid> single account (legacy)
// - email_ids=<uuid>,<uuid> comma-separated list (used by the
// tag/multi-account filter sheet)
// Invalid UUIDs are silently dropped; an empty resulting list
// behaves the same as "no account filter".
collectAccountIDs := func(raw string) {
for _, s := range strings.Split(raw, ",") {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if id, err := uuid.Parse(s); err == nil {
params.EmailAccountIDs = append(params.EmailAccountIDs, id)
}
}
}
if v := c.Query("email_id"); v != "" {
collectAccountIDs(v)
}
if v := c.Query("email_ids"); v != "" {
collectAccountIDs(v)
}
resp, xerr := h.UniboxService.Search(c.Request.Context(), uid, params)
+7 -2
View File
@@ -141,8 +141,13 @@ type MailSearchParams struct {
Subject *string
Since *time.Time
Until *time.Time
PageSize int
Cursor string
// EmailAccountIDs restricts results to messages received by one of
// these mailboxes. Empty = no account filter. The frontend tag
// filter resolves client-side to the matching account IDs and
// passes them here.
EmailAccountIDs []uuid.UUID
PageSize int
Cursor string
}
type MarkSeen struct {
+6
View File
@@ -275,6 +275,12 @@ func (r *uniboxRepository) Search(ctx context.Context, userID uuid.UUID, params
argPos++
}
if len(params.EmailAccountIDs) > 0 {
query += fmt.Sprintf(` AND email_id = ANY($%d)`, argPos)
args = append(args, params.EmailAccountIDs)
argPos++
}
if params.Cursor != "" {
cursorID, err := uuid.Parse(params.Cursor)
if err == nil {
+6
View File
@@ -8,6 +8,7 @@ import getToken from "@/lib/helper/getToken";
import { Navigate } from "react-router-dom";
import { DataSyncProvider } from "@/hooks/DataSyncProvider";
import { RealtimeManager } from "@/hooks/RealtimeManager";
import { OrgGate } from "@/hooks/OrgGate";
import TagsModal from "@/components/app/modals/TagsModal";
import FoldersModal from "@/components/app/modals/FoldersModal";
import AddEmailModal from "@/components/app/modals/AddEmailModal";
@@ -28,6 +29,11 @@ export default function RootAppLayout() {
<LinkProvider>
<SocketProvider>
<RealtimeManager>
{/* OrgGate runs ahead of AppLayout — if
the user has no workspaces it redirects
to /select-org before any org-scoped
query (e.g. /unibox) runs with no org. */}
<OrgGate />
<AppLayout />
</RealtimeManager>
</SocketProvider>
+345 -17
View File
@@ -1,32 +1,360 @@
import { PlusIcon } from "lucide-react";
import { EmptyBlock, Page, PageBody, PageTopbar, TopbarAction } from "@/components/layout/Page";
import { comingSoon } from "@/lib/helper/comingSoon";
import React from "react";
import { CheckIcon, Loader2Icon, MailIcon, PlusIcon, TrashIcon, UsersIcon, XIcon } from "lucide-react";
import { AnimatePresence, motion } from "framer-motion";
import toast from "react-hot-toast";
import {
EmptyBlock,
Page,
PageBody,
PageTopbar,
SectionBar,
TopbarAction,
} from "@/components/layout/Page";
import { Label, TextInput } from "@/components/ui/field";
import {
PopoverMenu,
PopoverMenuContent,
PopoverMenuItem,
PopoverMenuLabel,
PopoverMenuTrigger,
SelectButton,
} from "@/components/ui/popover-menu";
import useMembers from "@/lib/api/hooks/app/organizations/useMembers";
import useInviteMember from "@/lib/api/hooks/app/organizations/useInviteMember";
import usePendingInvitations from "@/lib/api/hooks/app/organizations/usePendingInvitations";
import useCancelInvitation from "@/lib/api/hooks/app/organizations/useCancelInvitation";
import useRemoveMember from "@/lib/api/hooks/app/organizations/useRemoveMember";
import { useConfirm } from "@/hooks/context/confirm";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
const ROLES: Array<{ id: "admin" | "member"; label: string; description: string }> = [
{ id: "admin", label: "Admin", description: "Manage team, billing and settings" },
{ id: "member", label: "Member", description: "Send campaigns, manage mail" },
];
export default function TeamPage() {
const confirm = useConfirm();
const [open, setOpen] = React.useState(false);
const members = useMembers();
const invitations = usePendingInvitations();
const cancelInvite = useCancelInvitation();
const removeMember = useRemoveMember();
const memberList = members.data ?? [];
const inviteList = invitations.data ?? [];
const remove = (id: string, name: string) => {
confirm?.show(`Remove ${name}?`, async () => {
try {
await toast.promise(removeMember.mutateAsync(id), {
loading: "Removing…",
success: "Member removed",
error: (e: AppError) => buildError(e),
});
} catch {
/* surfaced */
}
});
};
const cancel = (id: string) => {
confirm?.show(`Cancel this invitation?`, async () => {
try {
await toast.promise(cancelInvite.mutateAsync(id), {
loading: "Cancelling…",
success: "Invitation cancelled",
error: (e: AppError) => buildError(e),
});
} catch {
/* surfaced */
}
});
};
return (
<Page>
<PageTopbar eyebrow="Team" subtitle="Members and roles in this workspace">
<PageTopbar
eyebrow="Team"
subtitle={`${memberList.length} member${memberList.length === 1 ? "" : "s"}${inviteList.length > 0 ? ` · ${inviteList.length} pending` : ""}`}
>
<TopbarAction
icon={<PlusIcon className="w-3 h-3" />}
onClick={() => comingSoon("Team invites")}
onClick={() => setOpen(true)}
>
Invite member
</TopbarAction>
</PageTopbar>
<SectionBar label="Members" count={memberList.length} />
<div className="border-b border-slate-200/60">
{members.isPending ? (
<SkeletonRows />
) : memberList.length === 0 ? (
<EmptyBlock
title="No team members yet"
body="Invite teammates to collaborate on campaigns, mailboxes, and reporting."
cta={
<TopbarAction
icon={<PlusIcon className="w-3 h-3" />}
onClick={() => setOpen(true)}
>
Invite member
</TopbarAction>
}
/>
) : (
<div className="divide-y divide-slate-200/60">
{memberList.map((m) => (
<div
key={m.user_id}
className="group h-11 px-5 flex items-center gap-3 hover:bg-slate-50/80 transition-colors"
>
<div className="size-6 rounded-full bg-slate-100 flex items-center justify-center shrink-0">
<span className="text-[9.5px] font-semibold text-slate-600">
{(m.email ?? m.user_id).slice(0, 2).toUpperCase()}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="text-[12.5px] text-slate-900 font-medium truncate leading-tight">
{m.email}
</div>
<div className="text-[11px] text-slate-400 truncate font-mono leading-tight">
joined {new Date(m.joined_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</div>
</div>
<span className="text-[10.5px] uppercase tracking-[0.1em] font-medium text-slate-500">
{m.role}
</span>
{m.role !== "owner" && (
<button
type="button"
onClick={() => remove(m.user_id, m.email || "this member")}
aria-label="Remove member"
className="size-6 rounded text-slate-400 hover:text-red-600 hover:bg-red-50 inline-flex items-center justify-center transition-colors opacity-0 group-hover:opacity-100"
>
<TrashIcon className="w-3 h-3" />
</button>
)}
</div>
))}
</div>
)}
</div>
<SectionBar label="Pending invitations" count={inviteList.length} />
<PageBody>
<EmptyBlock
title="No team members yet"
body="Invite teammates to collaborate on campaigns, mailboxes, and reporting."
cta={
<TopbarAction
icon={<PlusIcon className="w-3 h-3" />}
onClick={() => comingSoon("Team invites")}
>
Invite member
</TopbarAction>
}
/>
{invitations.isPending ? (
<SkeletonRows />
) : inviteList.length === 0 ? (
<div className="px-5 py-10 text-center">
<p className="text-[12.5px] text-slate-700 font-medium mb-1">
No pending invitations
</p>
<p className="text-[11.5px] text-slate-400">
Invitations show up here until they're accepted.
</p>
</div>
) : (
<div className="divide-y divide-slate-200/60">
{inviteList.map((inv) => (
<div
key={inv.id}
className="group h-11 px-5 flex items-center gap-3"
>
<MailIcon className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<div className="min-w-0 flex-1 flex items-baseline gap-2">
<span className="text-[12.5px] text-slate-900 truncate">
{inv.email}
</span>
<span className="text-[10px] uppercase tracking-[0.1em] font-medium text-slate-500">
{inv.role}
</span>
</div>
<span className="font-mono text-[10.5px] text-slate-400 tabular-nums">
expires {new Date(inv.expires_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })}
</span>
<button
type="button"
onClick={() => cancel(inv.id)}
aria-label="Cancel invitation"
className="size-6 rounded text-slate-400 hover:text-red-600 hover:bg-red-50 inline-flex items-center justify-center transition-colors opacity-0 group-hover:opacity-100"
>
<TrashIcon className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
</PageBody>
<InviteDialog open={open} onClose={() => setOpen(false)} />
</Page>
);
}
function SkeletonRows() {
return (
<div className="divide-y divide-slate-200/60">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-11 px-5 flex items-center gap-3">
<div className="size-6 rounded-full bg-slate-100" />
<div className="h-3 w-32 bg-slate-100 rounded animate-pulse" />
<div className="ml-auto h-3 w-16 bg-slate-100 rounded animate-pulse" />
</div>
))}
</div>
);
}
function InviteDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const invite = useInviteMember();
const [email, setEmail] = React.useState("");
const [role, setRole] = React.useState<"admin" | "member">("member");
React.useEffect(() => {
if (!open) {
setEmail("");
setRole("member");
}
}, [open]);
function isValidEmail(s: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);
}
async function submit() {
const e = email.trim();
if (!isValidEmail(e)) {
toast.error("Enter a valid email");
return;
}
try {
await toast.promise(invite.mutateAsync({ email: e, role }), {
loading: "Sending invite…",
success: "Invitation sent",
error: (err: AppError) => buildError(err),
});
onClose();
} catch {
/* surfaced */
}
}
return (
<AnimatePresence>
{open && (
<motion.div
key="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onClick={onClose}
className="fixed inset-0 z-[110] flex items-center justify-center bg-slate-900/30 backdrop-blur-[2px] px-4"
>
<motion.div
key="card"
initial={{ y: 8, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 8, opacity: 0 }}
transition={{ duration: 0.16 }}
onClick={(e) => e.stopPropagation()}
className="w-full max-w-[460px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18)] overflow-hidden"
>
<div className="h-12 px-4 border-b border-slate-200 flex items-center gap-2.5">
<div className="size-5 rounded bg-slate-100 text-slate-600 flex items-center justify-center">
<UsersIcon className="w-3 h-3" />
</div>
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
Team
</span>
<div className="h-4 w-px bg-slate-200" />
<span className="text-[12.5px] text-slate-900 font-medium">
Invite a member
</span>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="ml-auto size-7 rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors"
>
<XIcon className="w-3.5 h-3.5" />
</button>
</div>
<div className="px-4 py-4 space-y-3">
<div>
<Label>Email</Label>
<TextInput
value={email}
onChange={setEmail}
placeholder="teammate@company.com"
type="email"
autoFocus
className="w-full"
/>
</div>
<div>
<Label>Role</Label>
<PopoverMenu align="start">
<PopoverMenuTrigger asChild>
<SelectButton
label={ROLES.find((r) => r.id === role)?.label ?? "Member"}
className="w-full"
/>
</PopoverMenuTrigger>
<PopoverMenuContent minWidth={300}>
<PopoverMenuLabel>Role</PopoverMenuLabel>
{ROLES.map((r) => (
<PopoverMenuItem
key={r.id}
onSelect={() => setRole(r.id)}
selected={role === r.id}
>
<div className="flex flex-col items-start min-w-0">
<span className="font-medium">{r.label}</span>
<span className="text-[11px] text-slate-400">
{r.description}
</span>
</div>
</PopoverMenuItem>
))}
</PopoverMenuContent>
</PopoverMenu>
</div>
<p className="text-[11px] text-slate-400 leading-relaxed pt-1">
We'll send them a one-click link to join your workspace.
</p>
</div>
<div className="px-3 h-12 border-t border-slate-200 flex items-center gap-1.5">
<button
type="button"
onClick={onClose}
className="ml-auto h-7 px-2.5 rounded-md text-[12px] text-slate-700 hover:text-slate-900 hover:bg-slate-100 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={submit}
disabled={invite.isPending}
className="h-7 px-2.5 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
>
{invite.isPending ? (
<Loader2Icon className="w-3 h-3 animate-spin" />
) : (
<CheckIcon className="w-3 h-3" />
)}
Send invite
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
+261
View File
@@ -0,0 +1,261 @@
// Select / create organization gate.
//
// Shown when a logged-in user has no current organization. Three
// affordances on one page:
//
// 1. Pending invitations — accept-to-join (one-click).
// 2. Existing memberships — pick one to enter the dashboard.
// 3. Create new — name + slate-900 button, mints an org and drops
// the user straight into it.
//
// The router redirects here from /app whenever organizations.length
// is 0 and there's no currentOrganization set.
import React from "react";
import { useNavigate } from "react-router-dom";
import { Loader2Icon, MailIcon, PlusIcon, UsersIcon } from "lucide-react";
import toast from "react-hot-toast";
import useOrganizations from "@/lib/api/hooks/app/organizations/useOrganizations";
import useMyInvitations from "@/lib/api/hooks/app/organizations/useMyInvitations";
import useCreateOrganization from "@/lib/api/hooks/app/organizations/useCreateOrganization";
import useAcceptInvitation from "@/lib/api/hooks/app/organizations/useAcceptInvitation";
import useSwitchOrganization from "@/lib/api/hooks/app/organizations/useSwitchOrganization";
import { useAppStore } from "@/stores";
import { Logo } from "@/components/svg";
import { Label, TextInput } from "@/components/ui/field";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
export default function SelectOrgPage() {
const navigate = useNavigate();
const newOrgRequested =
typeof window !== "undefined" &&
new URLSearchParams(window.location.search).get("new") === "1";
const orgs = useOrganizations();
const invites = useMyInvitations();
const setOrganizations = useAppStore((s) => s.setOrganizations);
const setCurrentOrganization = useAppStore((s) => s.setCurrentOrganization);
const create = useCreateOrganization();
const accept = useAcceptInvitation();
const switchOrg = useSwitchOrganization();
const orgList = orgs.data ?? [];
const inviteList = invites.data ?? [];
// Cache invalidation in the hooks already refreshes the query, but
// we also seed the appStore so the rest of the dashboard sees the
// org immediately on navigation.
React.useEffect(() => {
if (orgList.length > 0) setOrganizations(orgList);
}, [orgList, setOrganizations]);
const [name, setName] = React.useState("");
async function onCreate() {
const t = name.trim();
if (t.length < 2) {
toast.error("Name is required");
return;
}
try {
const org = await toast.promise(create.mutateAsync({ name: t }), {
loading: "Creating workspace…",
success: "Workspace created",
error: (e: AppError) => buildError(e),
});
// Activate the newly created org and enter the dashboard.
await switchOrg.mutateAsync(org.id).catch(() => undefined);
setCurrentOrganization(org);
navigate("/app/emails", { replace: true });
} catch {
/* surfaced */
}
}
async function onAccept(invitationId: string) {
try {
await toast.promise(accept.mutateAsync({ invitation_id: invitationId }), {
loading: "Joining workspace…",
success: "Joined",
error: (e: AppError) => buildError(e),
});
// Re-load orgs after acceptance and route into the first one.
const fresh = await orgs.refetch();
const list = fresh.data ?? [];
if (list.length > 0) {
await switchOrg.mutateAsync(list[0].id).catch(() => undefined);
setOrganizations(list);
setCurrentOrganization(list[0]);
navigate("/app/emails", { replace: true });
}
} catch {
/* surfaced */
}
}
async function onPickExisting(orgId: string) {
try {
await switchOrg.mutateAsync(orgId);
const org = orgList.find((o) => o.id === orgId);
if (org) setCurrentOrganization(org);
navigate("/app/emails", { replace: true });
} catch (e) {
toast.error(buildError(e as AppError));
}
}
const loading = orgs.isPending || invites.isPending;
return (
<div className="min-h-screen bg-[#f5f6f8] flex items-center justify-center px-4 py-12">
<div className="w-full max-w-[480px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.08)] overflow-hidden">
<div className="h-12 px-4 border-b border-slate-200 flex items-center gap-2.5">
<Logo className="w-4 text-slate-900" />
<span
style={{ fontFamily: "var(--font-display)" }}
className="font-bold text-[13px] tracking-tight text-slate-900"
>
Warmbly
</span>
<div className="h-4 w-px bg-slate-200" />
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
Workspaces
</span>
</div>
<div className="px-5 py-5">
<h1 className="text-[18px] font-semibold text-slate-900 mb-1">
Pick a workspace to continue
</h1>
<p className="text-[12.5px] text-slate-500 mb-5 leading-relaxed">
Workspaces hold your campaigns, mailboxes and team. Create one to start,
or join an existing one if you were invited.
</p>
{loading && (
<div className="h-24 flex items-center justify-center">
<Loader2Icon className="w-4 h-4 animate-spin text-slate-400" />
</div>
)}
{/* Pending invitations */}
{!loading && inviteList.length > 0 && (
<div className="mb-5">
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium mb-1.5 flex items-center gap-1.5">
<MailIcon className="w-3 h-3" />
Invitations ({inviteList.length})
</div>
<div className="border border-slate-200 rounded-md overflow-hidden divide-y divide-slate-200/60">
{inviteList.map((inv) => (
<div
key={inv.id}
className="px-3 py-2.5 flex items-center gap-2.5"
>
<div className="size-7 rounded bg-slate-100 text-slate-700 flex items-center justify-center text-[10px] font-semibold shrink-0">
{(inv.organization_name ?? "?").slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="text-[12.5px] font-medium text-slate-900 truncate">
{inv.organization_name ?? "Workspace"}
</div>
<div className="text-[11px] text-slate-500 truncate">
Pending invitation · {inv.role}
</div>
</div>
<button
type="button"
onClick={() => onAccept(inv.id)}
disabled={accept.isPending}
className="h-7 px-2.5 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium transition-colors disabled:opacity-60 shrink-0"
>
Join
</button>
</div>
))}
</div>
</div>
)}
{/* Existing memberships */}
{!loading && orgList.length > 0 && (
<div className="mb-5">
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium mb-1.5 flex items-center gap-1.5">
<UsersIcon className="w-3 h-3" />
Your workspaces ({orgList.length})
</div>
<div className="border border-slate-200 rounded-md overflow-hidden divide-y divide-slate-200/60">
{orgList.map((o) => (
<button
key={o.id}
type="button"
onClick={() => onPickExisting(o.id)}
disabled={switchOrg.isPending}
className="w-full px-3 py-2.5 flex items-center gap-2.5 hover:bg-slate-50/80 transition-colors text-left disabled:opacity-50"
>
<div className="size-7 rounded bg-slate-900 text-white flex items-center justify-center text-[10px] font-semibold shrink-0">
{o.name.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="text-[12.5px] font-medium text-slate-900 truncate">
{o.name}
</div>
<div className="text-[11px] text-slate-500 truncate font-mono">
{o.id.slice(0, 8)}
</div>
</div>
<span className="text-[11px] text-slate-400">Open </span>
</button>
))}
</div>
</div>
)}
{/* Create new */}
<div className="border border-slate-200 rounded-md overflow-hidden">
<div className="h-9 px-3 border-b border-slate-200 flex items-center gap-1.5">
<PlusIcon className="w-3 h-3 text-slate-400" />
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
{newOrgRequested || orgList.length === 0 ? "Create your first workspace" : "Create another workspace"}
</span>
</div>
<div className="px-3 py-3 space-y-2">
<div>
<Label>Name</Label>
<TextInput
value={name}
onChange={setName}
placeholder="Acme outbound"
autoFocus
className="w-full"
onKeyDown={(e) => {
if (e.key === "Enter") onCreate();
}}
/>
</div>
<p className="text-[11px] text-slate-400">
You'll be the owner. You can rename it later.
</p>
<div className="pt-1">
<button
type="button"
onClick={onCreate}
disabled={create.isPending || name.trim().length < 2}
className="h-7 px-2.5 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
>
{create.isPending ? (
<Loader2Icon className="w-3 h-3 animate-spin" />
) : (
<PlusIcon className="w-3 h-3" />
)}
Create workspace
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -15,22 +15,18 @@
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
CheckIcon,
Loader2Icon,
MailIcon,
RotateCcwIcon,
SearchIcon,
TagIcon,
XIcon,
} from "lucide-react";
import { SearchInput, TextInput } from "@/components/ui/field";
import {
PopoverMenu,
PopoverMenuContent,
PopoverMenuItem,
PopoverMenuLabel,
PopoverMenuTrigger,
SelectButton,
} from "@/components/ui/popover-menu";
import { SectionBar } from "@/components/layout/Page";
import { useAppStore } from "@/stores";
import { useUserProfile } from "@/hooks/context/user";
import type { UniboxSearchParams } from "@/lib/api/models/app/unibox/UniboxSearch";
interface Props {
@@ -44,15 +40,64 @@ interface Props {
export function UniboxFilterSheet({ open, setOpen, filters, setFilters, loading }: Props) {
const [draft, setDraft] = React.useState<UniboxSearchParams>(filters);
const emails = useAppStore((s) => s.emails);
const p = useUserProfile();
const tags = p.user.tags ?? [];
React.useEffect(() => {
if (open) setDraft(filters);
}, [open, filters]);
const accountIds = React.useMemo(() => new Set(draft.accountIds ?? []), [draft.accountIds]);
const selectedTagId = draft.tagId;
// Accounts that carry the currently-selected tag. Used to compute
// whether the chip should show as "all picked" and to resolve to
// concrete IDs at apply time.
const accountsByTag = React.useMemo(() => {
if (!selectedTagId) return null;
return emails.filter((e) => (e.tags ?? []).includes(selectedTagId));
}, [emails, selectedTagId]);
function toggleAccount(id: string) {
setDraft((s) => {
const next = new Set(s.accountIds ?? []);
if (next.has(id)) next.delete(id);
else next.add(id);
return { ...s, accountIds: Array.from(next) };
});
}
function selectTag(tagId: string) {
setDraft((s) => {
if (s.tagId === tagId) {
// Clicking the active tag clears it.
return { ...s, tagId: undefined };
}
return { ...s, tagId };
});
}
function selectAllAccounts() {
setDraft((s) => ({ ...s, accountIds: emails.map((e) => e.id), tagId: undefined }));
}
function clearAccounts() {
setDraft((s) => ({ ...s, accountIds: [], tagId: undefined }));
}
const activeCount = countActive(draft);
const apply = () => {
setFilters(draft);
// If a tag is selected, resolve it to the underlying account
// IDs before committing — the server only knows about
// accountIds. We keep tagId in the draft for UI display but
// strip it before passing up.
let resolvedIds = draft.accountIds ?? [];
if (draft.tagId) {
const tagAccountIds = emails
.filter((e) => (e.tags ?? []).includes(draft.tagId!))
.map((e) => e.id);
// Tag selection replaces manual picks — simpler mental model.
resolvedIds = tagAccountIds;
}
setFilters({ ...draft, accountIds: resolvedIds });
setOpen(false);
};
@@ -121,43 +166,134 @@ export function UniboxFilterSheet({ open, setOpen, filters, setFilters, loading
/>
</div>
<SectionBar label="Account" />
<div className="px-4 py-3 space-y-2">
<PopoverMenu align="start">
<PopoverMenuTrigger asChild>
<SelectButton
label={
draft.accountId
? (emails.find((e) => e.id === draft.accountId)?.email ?? "Unknown")
: "All accounts"
}
className="w-full justify-between"
/>
</PopoverMenuTrigger>
<PopoverMenuContent minWidth={260}>
<PopoverMenuLabel>Email accounts</PopoverMenuLabel>
<PopoverMenuItem
onSelect={() => setDraft((s) => ({ ...s, accountId: undefined }))}
selected={!draft.accountId}
>
All accounts
</PopoverMenuItem>
{emails.map((e) => (
<PopoverMenuItem
key={e.id}
onSelect={() =>
setDraft((s) => ({
...s,
accountId: draft.accountId === e.id ? undefined : e.id,
}))
}
selected={draft.accountId === e.id}
>
{e.email}
</PopoverMenuItem>
))}
</PopoverMenuContent>
</PopoverMenu>
<SectionBar
label="Accounts"
count={
selectedTagId
? accountsByTag?.length
: draft.accountIds?.length || undefined
}
>
{(draft.accountIds?.length || draft.tagId) ? (
<button
type="button"
onClick={clearAccounts}
className="text-[11px] text-slate-500 hover:text-slate-900 transition-colors"
>
Clear
</button>
) : (
<button
type="button"
onClick={selectAllAccounts}
className="text-[11px] text-slate-500 hover:text-slate-900 transition-colors"
>
Select all
</button>
)}
</SectionBar>
{/* Tag chips — fastest way to scope to a group of mailboxes.
Picking a tag visually expands the selection: every
account that carries the tag is included at Apply time. */}
{tags.length > 0 && (
<div className="px-4 pt-3">
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium mb-1.5 flex items-center gap-1.5">
<TagIcon className="w-3 h-3" />
Tags
</div>
<div className="flex flex-wrap gap-1.5">
{tags.map((t) => {
const active = selectedTagId === t.id;
const count = emails.filter((e) => (e.tags ?? []).includes(t.id)).length;
return (
<button
key={t.id}
type="button"
onClick={() => selectTag(t.id)}
className={`group h-6 pl-1.5 pr-2 rounded-full inline-flex items-center gap-1.5 text-[11.5px] font-medium border transition-colors ${
active
? "bg-slate-900 text-white border-slate-900"
: "bg-white text-slate-700 border-slate-200 hover:border-slate-300"
}`}
>
<span
aria-hidden
className="size-2 rounded-full"
style={{ backgroundColor: t.color }}
/>
<span className="truncate max-w-[120px]">{t.title}</span>
<span
className={`font-mono tabular-nums text-[10px] ${
active ? "text-white/80" : "text-slate-400"
}`}
>
{count}
</span>
</button>
);
})}
</div>
</div>
)}
{/* Individual account chips with avatars. Multi-select.
When a tag is active, accounts that belong to it get
a subtle ring so the relationship is visible. */}
<div className="px-4 py-3">
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium mb-1.5 flex items-center gap-1.5">
<MailIcon className="w-3 h-3" />
Mailboxes
</div>
{emails.length === 0 ? (
<p className="text-[11.5px] text-slate-400 py-2">
No mailboxes connected yet.
</p>
) : (
<div className="space-y-1">
{emails.map((e) => {
const checked = accountIds.has(e.id);
const tagMatch =
selectedTagId &&
(e.tags ?? []).includes(selectedTagId);
return (
<button
key={e.id}
type="button"
onClick={() => toggleAccount(e.id)}
className={`w-full flex items-center gap-2.5 px-2 py-1.5 rounded-md transition-colors text-left ${
checked
? "bg-sky-50/80 hover:bg-sky-50"
: tagMatch
? "bg-slate-50 hover:bg-slate-100"
: "hover:bg-slate-50"
}`}
>
<span
className={`size-4 rounded border flex items-center justify-center transition-colors shrink-0 ${
checked
? "bg-slate-900 border-slate-900 text-white"
: "bg-white border-slate-300"
}`}
>
{checked && <CheckIcon className="w-2.5 h-2.5" />}
</span>
<span className="size-5 rounded-full bg-slate-100 text-slate-600 flex items-center justify-center text-[9px] font-semibold shrink-0">
{e.email.slice(0, 2).toUpperCase()}
</span>
<span className="text-[12px] text-slate-900 truncate flex-1">
{e.email}
</span>
{tagMatch && !checked && (
<span className="text-[10px] text-slate-400 font-mono">
via tag
</span>
)}
</button>
);
})}
</div>
)}
</div>
<SectionBar label="Status" />
@@ -323,7 +459,8 @@ function countActive(f: UniboxSearchParams): number {
let n = 0;
if (f.query) n++;
if (f.from) n++;
if (f.accountId) n++;
if (f.tagId) n++;
if (f.accountIds && f.accountIds.length > 0) n++;
if (f.unseen !== undefined) n++;
if (f.since) n++;
if (f.until) n++;
+36
View File
@@ -0,0 +1,36 @@
// OrgGate — redirects logged-in users to /select-org when they have
// no workspace yet (or no pending invitations + no current org).
//
// The user can land in this state in three ways:
// - brand-new signup → orgs.length === 0
// - removed from every org they were in
// - they explicitly cleared currentOrganization
//
// We mount this above the AppShell so the redirect happens before any
// org-scoped queries (e.g. /unibox) try to run with a missing context.
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import useOrganizations from "@/lib/api/hooks/app/organizations/useOrganizations";
import { useAppStore } from "@/stores";
// Side-effect only — returns null. Renders alongside AppLayout so the
// effect runs as soon as the organizations query lands; the navigate()
// unmounts the app subtree when no org is present.
export function OrgGate() {
const navigate = useNavigate();
const orgs = useOrganizations();
const setOrganizations = useAppStore((s) => s.setOrganizations);
const currentOrg = useAppStore((s) => s.currentOrganization);
useEffect(() => {
if (orgs.isPending) return;
const list = orgs.data ?? [];
setOrganizations(list);
if (list.length === 0 && !currentOrg) {
navigate("/select-org", { replace: true });
}
}, [orgs.isPending, orgs.data, currentOrg, setOrganizations, navigate]);
return null;
}
@@ -1,10 +1,16 @@
import type OrganizationMember from "@/lib/api/models/app/organizations/OrganizationMember";
import Request from "../../Request";
interface RawResponse {
data: OrganizationMember[] | null;
}
export default async function getMembers(): Promise<OrganizationMember[]> {
return await Request<OrganizationMember[]>({
const res = await Request<RawResponse | OrganizationMember[]>({
method: "GET",
url: `/organization/members`,
authorization: true,
})
});
if (Array.isArray(res)) return res;
return res?.data ?? [];
}
@@ -1,10 +1,16 @@
import type Invitation from "@/lib/api/models/app/organizations/Invitation";
import Request from "../../Request";
interface RawResponse {
data: Invitation[] | null;
}
export default async function getMyInvitations(): Promise<Invitation[]> {
return await Request<Invitation[]>({
const res = await Request<RawResponse | Invitation[]>({
method: "GET",
url: `/invitations`,
authorization: true,
})
});
if (Array.isArray(res)) return res;
return res?.data ?? [];
}
@@ -1,10 +1,42 @@
import type Organization from "@/lib/api/models/app/organizations/Organization";
import Request from "../../Request";
// Backend shape: { data: [{ organization_id, role, organization: {...} }] }
// (membership rows with the org nested). Flatten into the Organization
// shape the rest of the app expects.
interface RawMembership {
organization_id: string;
role: "owner" | "admin" | "member";
organization?: {
id: string;
name: string;
slug?: string;
avatar?: string;
plan?: string;
created_at: string;
};
}
interface RawResponse {
data: RawMembership[] | null;
}
export default async function getOrganizations(): Promise<Organization[]> {
return await Request<Organization[]>({
const res = await Request<RawResponse | Organization[]>({
method: "GET",
url: `/organization`,
authorization: true,
})
});
if (Array.isArray(res)) return res;
const rows = res?.data ?? [];
return rows
.filter((r) => r.organization)
.map<Organization>((r) => ({
id: r.organization!.id,
name: r.organization!.name,
avatar: r.organization!.avatar,
plan: r.organization!.plan,
role: r.role,
created_at: new Date(r.organization!.created_at),
}));
}
@@ -1,10 +1,16 @@
import type Invitation from "@/lib/api/models/app/organizations/Invitation";
import Request from "../../Request";
interface RawResponse {
data: Invitation[] | null;
}
export default async function getPendingInvitations(): Promise<Invitation[]> {
return await Request<Invitation[]>({
const res = await Request<RawResponse | Invitation[]>({
method: "GET",
url: `/organization/invitations`,
authorization: true,
})
});
if (Array.isArray(res)) return res;
return res?.data ?? [];
}
@@ -38,7 +38,11 @@ export default async function searchIncoming(
// here in case the user passed something.
if (p.query) usp.set("subject", p.query);
if (p.from) usp.set("from", p.from);
if (p.accountId) usp.set("email_id", p.accountId);
if (p.accountIds && p.accountIds.length > 0) {
// Backend accepts comma-separated email_ids and falls back to a
// single email_id for legacy callers; we always use the multi form.
usp.set("email_ids", p.accountIds.join(","));
}
if (p.unseen) usp.set("unseen", "true");
if (p.since) usp.set("since", isoDay(p.since));
if (p.until) usp.set("until", isoDay(p.until));
@@ -5,7 +5,19 @@
export interface UniboxSearchParams {
query?: string; // Free text — currently matched as subject ILIKE
from?: string; // Sender substring
accountId?: string; // Filter by an email_accounts row id (server-side filter; reserved)
/**
* Selected account IDs. The server filters with email_id IN (…).
* Use this directly when picking specific mailboxes, or set
* `tagId` instead and let the filter UI resolve it into the set
* of accounts that carry the tag.
*/
accountIds?: string[];
/**
* UI-only convenience: when set, the filter sheet resolves the tag
* to the matching account IDs at apply time. The server never sees
* this field; it sees the resolved `accountIds` instead.
*/
tagId?: string;
unseen?: boolean; // Only unread
since?: Date; // From date
until?: Date; // To date
+5
View File
@@ -55,6 +55,7 @@ import ResetPasswordPage from './app/auth/reset-password/page';
import ResetPasswordConfirmPage from './app/auth/reset-password/confirm/page';
import OnboardingLayout from './app/onboarding/layout';
import OnboardingPage from './app/onboarding/page';
import SelectOrgPage from './app/select-org/page';
import AdminLayout from './app/app/admin/layout';
import AdminPage from './app/app/admin/page';
import AdminWorkersPage from './app/app/admin/workers/page';
@@ -132,6 +133,10 @@ const router = createBrowserRouter([
}
]
},
{
path: "select-org",
element: <SelectOrgPage />,
},
{
path: "app",
element: <RootAppLayout />,