diff --git a/internal/api/handler/unibox.go b/internal/api/handler/unibox.go index 61d8adec..03241954 100644 --- a/internal/api/handler/unibox.go +++ b/internal/api/handler/unibox.go @@ -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= single account (legacy) + // - email_ids=, 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) diff --git a/internal/models/unibox.go b/internal/models/unibox.go index 082f983a..cdc21f6c 100644 --- a/internal/models/unibox.go +++ b/internal/models/unibox.go @@ -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 { diff --git a/internal/repository/pg_unibox.go b/internal/repository/pg_unibox.go index 44c3dc48..30601805 100644 --- a/internal/repository/pg_unibox.go +++ b/internal/repository/pg_unibox.go @@ -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 { diff --git a/web/src/app/app/layout.tsx b/web/src/app/app/layout.tsx index 89f8150e..5b117d53 100644 --- a/web/src/app/app/layout.tsx +++ b/web/src/app/app/layout.tsx @@ -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() { + {/* 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. */} + diff --git a/web/src/app/app/team/page.tsx b/web/src/app/app/team/page.tsx index 91be7ab3..0f476ffc 100644 --- a/web/src/app/app/team/page.tsx +++ b/web/src/app/app/team/page.tsx @@ -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 ( - + 0 ? ` · ${inviteList.length} pending` : ""}`} + > } - onClick={() => comingSoon("Team invites")} + onClick={() => setOpen(true)} > Invite member + + +
+ {members.isPending ? ( + + ) : memberList.length === 0 ? ( + } + onClick={() => setOpen(true)} + > + Invite member + + } + /> + ) : ( +
+ {memberList.map((m) => ( +
+
+ + {(m.email ?? m.user_id).slice(0, 2).toUpperCase()} + +
+
+
+ {m.email} +
+
+ joined {new Date(m.joined_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} +
+
+ + {m.role} + + {m.role !== "owner" && ( + + )} +
+ ))} +
+ )} +
+ + - } - onClick={() => comingSoon("Team invites")} - > - Invite member - - } - /> + {invitations.isPending ? ( + + ) : inviteList.length === 0 ? ( +
+

+ No pending invitations +

+

+ Invitations show up here until they're accepted. +

+
+ ) : ( +
+ {inviteList.map((inv) => ( +
+ +
+ + {inv.email} + + + {inv.role} + +
+ + expires {new Date(inv.expires_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })} + + +
+ ))} +
+ )}
+ + setOpen(false)} />
); } + +function SkeletonRows() { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+ ); +} + +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 ( + + {open && ( + + 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" + > +
+
+ +
+ + Team + +
+ + Invite a member + + +
+ +
+
+ + +
+
+ + + + r.id === role)?.label ?? "Member"} + className="w-full" + /> + + + Role + {ROLES.map((r) => ( + setRole(r.id)} + selected={role === r.id} + > +
+ {r.label} + + {r.description} + +
+
+ ))} +
+
+
+

+ We'll send them a one-click link to join your workspace. +

+
+ +
+ + +
+ + + )} + + ); +} diff --git a/web/src/app/select-org/page.tsx b/web/src/app/select-org/page.tsx new file mode 100644 index 00000000..4df03d24 --- /dev/null +++ b/web/src/app/select-org/page.tsx @@ -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 ( +
+
+
+ + + Warmbly + +
+ + Workspaces + +
+ +
+

+ Pick a workspace to continue +

+

+ Workspaces hold your campaigns, mailboxes and team. Create one to start, + or join an existing one if you were invited. +

+ + {loading && ( +
+ +
+ )} + + {/* Pending invitations */} + {!loading && inviteList.length > 0 && ( +
+
+ + Invitations ({inviteList.length}) +
+
+ {inviteList.map((inv) => ( +
+
+ {(inv.organization_name ?? "?").slice(0, 2).toUpperCase()} +
+
+
+ {inv.organization_name ?? "Workspace"} +
+
+ Pending invitation · {inv.role} +
+
+ +
+ ))} +
+
+ )} + + {/* Existing memberships */} + {!loading && orgList.length > 0 && ( +
+
+ + Your workspaces ({orgList.length}) +
+
+ {orgList.map((o) => ( + + ))} +
+
+ )} + + {/* Create new */} +
+
+ + + {newOrgRequested || orgList.length === 0 ? "Create your first workspace" : "Create another workspace"} + +
+
+
+ + { + if (e.key === "Enter") onCreate(); + }} + /> +
+

+ You'll be the owner. You can rename it later. +

+
+ +
+
+
+
+
+
+ ); +} diff --git a/web/src/components/app/unibox/UniboxFilterSheet.tsx b/web/src/components/app/unibox/UniboxFilterSheet.tsx index 4f332d04..cd1121a4 100644 --- a/web/src/components/app/unibox/UniboxFilterSheet.tsx +++ b/web/src/components/app/unibox/UniboxFilterSheet.tsx @@ -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(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 />
- -
- - - e.id === draft.accountId)?.email ?? "Unknown") - : "All accounts" - } - className="w-full justify-between" - /> - - - Email accounts - setDraft((s) => ({ ...s, accountId: undefined }))} - selected={!draft.accountId} - > - All accounts - - {emails.map((e) => ( - - setDraft((s) => ({ - ...s, - accountId: draft.accountId === e.id ? undefined : e.id, - })) - } - selected={draft.accountId === e.id} - > - {e.email} - - ))} - - + + {(draft.accountIds?.length || draft.tagId) ? ( + + ) : ( + + )} + + + {/* 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 && ( +
+
+ + Tags +
+
+ {tags.map((t) => { + const active = selectedTagId === t.id; + const count = emails.filter((e) => (e.tags ?? []).includes(t.id)).length; + return ( + + ); + })} +
+
+ )} + + {/* 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. */} +
+
+ + Mailboxes +
+ {emails.length === 0 ? ( +

+ No mailboxes connected yet. +

+ ) : ( +
+ {emails.map((e) => { + const checked = accountIds.has(e.id); + const tagMatch = + selectedTagId && + (e.tags ?? []).includes(selectedTagId); + return ( + + ); + })} +
+ )}
@@ -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++; diff --git a/web/src/hooks/OrgGate.tsx b/web/src/hooks/OrgGate.tsx new file mode 100644 index 00000000..3f4c69a4 --- /dev/null +++ b/web/src/hooks/OrgGate.tsx @@ -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; +} diff --git a/web/src/lib/api/client/app/organizations/getMembers.ts b/web/src/lib/api/client/app/organizations/getMembers.ts index e9df755b..a3e9a63c 100644 --- a/web/src/lib/api/client/app/organizations/getMembers.ts +++ b/web/src/lib/api/client/app/organizations/getMembers.ts @@ -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 { - return await Request({ + const res = await Request({ method: "GET", url: `/organization/members`, authorization: true, - }) + }); + if (Array.isArray(res)) return res; + return res?.data ?? []; } diff --git a/web/src/lib/api/client/app/organizations/getMyInvitations.ts b/web/src/lib/api/client/app/organizations/getMyInvitations.ts index 9d3c5a48..065409a0 100644 --- a/web/src/lib/api/client/app/organizations/getMyInvitations.ts +++ b/web/src/lib/api/client/app/organizations/getMyInvitations.ts @@ -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 { - return await Request({ + const res = await Request({ method: "GET", url: `/invitations`, authorization: true, - }) + }); + if (Array.isArray(res)) return res; + return res?.data ?? []; } diff --git a/web/src/lib/api/client/app/organizations/getOrganizations.ts b/web/src/lib/api/client/app/organizations/getOrganizations.ts index e1c97946..c6a48332 100644 --- a/web/src/lib/api/client/app/organizations/getOrganizations.ts +++ b/web/src/lib/api/client/app/organizations/getOrganizations.ts @@ -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 { - return await Request({ + const res = await Request({ method: "GET", url: `/organization`, authorization: true, - }) + }); + if (Array.isArray(res)) return res; + const rows = res?.data ?? []; + return rows + .filter((r) => r.organization) + .map((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), + })); } diff --git a/web/src/lib/api/client/app/organizations/getPendingInvitations.ts b/web/src/lib/api/client/app/organizations/getPendingInvitations.ts index c8f94e88..d0116a4f 100644 --- a/web/src/lib/api/client/app/organizations/getPendingInvitations.ts +++ b/web/src/lib/api/client/app/organizations/getPendingInvitations.ts @@ -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 { - return await Request({ + const res = await Request({ method: "GET", url: `/organization/invitations`, authorization: true, - }) + }); + if (Array.isArray(res)) return res; + return res?.data ?? []; } diff --git a/web/src/lib/api/client/app/unibox/searchIncoming.ts b/web/src/lib/api/client/app/unibox/searchIncoming.ts index ab7a99e5..8af67de0 100644 --- a/web/src/lib/api/client/app/unibox/searchIncoming.ts +++ b/web/src/lib/api/client/app/unibox/searchIncoming.ts @@ -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)); diff --git a/web/src/lib/api/models/app/unibox/UniboxSearch.ts b/web/src/lib/api/models/app/unibox/UniboxSearch.ts index 5b252e58..c97d68d1 100644 --- a/web/src/lib/api/models/app/unibox/UniboxSearch.ts +++ b/web/src/lib/api/models/app/unibox/UniboxSearch.ts @@ -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 diff --git a/web/src/main.tsx b/web/src/main.tsx index b09d4667..0c9bc1f9 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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: , + }, { path: "app", element: ,