mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-09 00:03:10 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
Reference in New Issue
Block a user