From f4f9a3bf77b2dfd946656a6a0c6b3bfc6ad94b6b Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 24 Mar 2026 18:19:24 +0400 Subject: [PATCH 01/22] [FEAT] Add workspace settings page with members and general sections --- .../pages/dashboard/WorkspaceSettingsPage.tsx | 369 ++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 apps/frontend/src/pages/dashboard/WorkspaceSettingsPage.tsx diff --git a/apps/frontend/src/pages/dashboard/WorkspaceSettingsPage.tsx b/apps/frontend/src/pages/dashboard/WorkspaceSettingsPage.tsx new file mode 100644 index 0000000..a80ed44 --- /dev/null +++ b/apps/frontend/src/pages/dashboard/WorkspaceSettingsPage.tsx @@ -0,0 +1,369 @@ +import { useEffect, useMemo, useState } from "react"; +import { useNavigate, useOutletContext } from "react-router-dom"; +import { useI18n } from "@/context/I18nContext"; +import { Button, Input } from "@/components/ui"; +import { apiFetch, formatApiError, type WorkspaceMember } from "@/lib/api"; +import { useAuthStore } from "@/stores/authStore"; +import type { DashboardOutletContext } from "@/components/layout/DashboardLayout"; + +type SettingsTab = "general" | "members"; + +export function WorkspaceSettingsPage() { + const { t } = useI18n(); + const navigate = useNavigate(); + const { user } = useAuthStore(); + const { + workspaces, + selectedWorkspaceId, + setSelectedWorkspaceId, + fetchWorkspaces, + updateWorkspace, + deleteWorkspace, + } = useOutletContext(); + + const [activeTab, setActiveTab] = useState("members"); + const [members, setMembers] = useState([]); + const [membersLoading, setMembersLoading] = useState(false); + const [membersError, setMembersError] = useState(null); + const [memberSearch, setMemberSearch] = useState(""); + const [inviteEmail, setInviteEmail] = useState(""); + const [inviteError, setInviteError] = useState(null); + const [savingInvite, setSavingInvite] = useState(false); + + const [workspaceNameDrafts, setWorkspaceNameDrafts] = useState< + Record + >({}); + const [generalError, setGeneralError] = useState(null); + const [savingGeneral, setSavingGeneral] = useState(false); + const [deletingWorkspace, setDeletingWorkspace] = useState(false); + + const selectedWorkspace = useMemo( + () => workspaces.find((ws) => ws.id === selectedWorkspaceId) ?? null, + [workspaces, selectedWorkspaceId], + ); + + const isOwner = + Boolean(user && selectedWorkspace) && user?.id === selectedWorkspace?.owner_id; + + const effectiveWorkspaceName = + (selectedWorkspaceId && workspaceNameDrafts[selectedWorkspaceId]) || + selectedWorkspace?.name || + ""; + + const loadMembers = async (workspaceId: string) => { + setMembersLoading(true); + setMembersError(null); + await apiFetch(`/api/workspaces/${workspaceId}/members`) + .then(async (res) => { + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error( + formatApiError(data.detail, "Failed to load workspace members"), + ); + } + return res.json() as Promise<{ items?: WorkspaceMember[] }>; + }) + .then((data) => { + setMembers(data.items ?? []); + }) + .catch((err: unknown) => { + setMembersError( + err instanceof Error ? err.message : "Failed to load workspace members", + ); + }) + .finally(() => { + setMembersLoading(false); + }); + }; + + useEffect(() => { + if (!selectedWorkspaceId) return; + const timer = window.setTimeout(() => { + void loadMembers(selectedWorkspaceId); + }, 0); + return () => window.clearTimeout(timer); + }, [selectedWorkspaceId]); + + const filteredMembers = useMemo(() => { + const q = memberSearch.trim().toLowerCase(); + if (!q) return members; + return members.filter((member) => { + const username = (member.username ?? "").toLowerCase(); + const email = (member.email ?? "").toLowerCase(); + return username.includes(q) || email.includes(q); + }); + }, [members, memberSearch]); + + if (!selectedWorkspaceId || !selectedWorkspace) { + return ( +
+

+ {t("dashboard.workspaceSettings")} +

+

+ {t("dashboard.selectWorkspace")} +

+ +
+ ); + } + + return ( +
+
+

+ {t("dashboard.workspaceSettings")} +

+

+ {selectedWorkspace.name} +

+
+ +
+ +
+ + {activeTab === "members" && ( +
+
+

+ {t("dashboard.members")} ({members.length}) +

+ +
+ +
+ setMemberSearch(e.target.value)} + placeholder={t("dashboard.searchByNameOrEmail")} + /> + +
{ + e.preventDefault(); + if (!inviteEmail.trim()) return; + setInviteError(null); + setSavingInvite(true); + const res = await apiFetch( + `/api/workspaces/${selectedWorkspaceId}/members`, + { + method: "POST", + body: JSON.stringify({ + email: inviteEmail.trim(), + role: "member", + }), + }, + ); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setInviteError( + formatApiError(data.detail, "Failed to invite member"), + ); + setSavingInvite(false); + return; + } + setInviteEmail(""); + await fetchWorkspaces(); + await loadMembers(selectedWorkspaceId); + setSavingInvite(false); + }} + > +
+ setInviteEmail(e.target.value)} + placeholder="user@example.com" + /> +
+ +
+
+ + {inviteError &&

{inviteError}

} + {membersError &&

{membersError}

} + +
+
+ {t("dashboard.name")} + {t("dashboard.role")} + {t("dashboard.actions")} +
+ + {membersLoading ? ( +
Loading...
+ ) : filteredMembers.length === 0 ? ( +
+ {t("dashboard.noMembersFound")} +
+ ) : ( + filteredMembers.map((member) => ( +
+
+

+ {member.username || member.email || member.user_id} +

+ {member.email && ( +

+ {member.email} +

+ )} +
+ + {member.role} + +
+ {member.user_id !== selectedWorkspace.owner_id && ( + + )} +
+
+ )) + )} +
+
+ )} + + {activeTab === "general" && ( +
+
+

+ {t("dashboard.generalTab")} +

+
{ + e.preventDefault(); + setGeneralError(null); + setSavingGeneral(true); + const updated = await updateWorkspace( + selectedWorkspaceId, + effectiveWorkspaceName.trim(), + ); + if (!updated) { + setGeneralError("Failed to update workspace"); + setSavingGeneral(false); + return; + } + setWorkspaceNameDrafts((prev) => { + const next = { ...prev }; + delete next[selectedWorkspaceId]; + return next; + }); + await fetchWorkspaces(); + setSelectedWorkspaceId(updated.id); + setSavingGeneral(false); + }} + > + + setWorkspaceNameDrafts((prev) => ({ + ...prev, + [selectedWorkspaceId]: e.target.value, + })) + } + required + disabled={!isOwner} + /> + + {generalError && ( +

{generalError}

+ )} +
+
+ +
+

+ {t("dashboard.dangerZone")} +

+

+ {t("dashboard.deleteWorkspaceWarning")} +

+ +
+
+ )} +
+ ); +} From adab57c186e0d94ba98ff7ddd7a91d4f5f7258f2 Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 24 Mar 2026 18:19:25 +0400 Subject: [PATCH 02/22] [FEAT] Provide workspace settings context from dashboard layout --- .../src/components/layout/DashboardLayout.tsx | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/frontend/src/components/layout/DashboardLayout.tsx b/apps/frontend/src/components/layout/DashboardLayout.tsx index f6816ce..8817c00 100644 --- a/apps/frontend/src/components/layout/DashboardLayout.tsx +++ b/apps/frontend/src/components/layout/DashboardLayout.tsx @@ -11,6 +11,18 @@ import { useDashboardStore } from "@/stores/dashboardStore"; const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:8000"; +export interface DashboardOutletContext { + workspaces: { id: string; name: string; owner_id: string }[]; + selectedWorkspaceId: string | null; + setSelectedWorkspaceId: (id: string | null) => void; + fetchWorkspaces: () => Promise; + updateWorkspace: (workspaceId: string, name: string) => Promise<{ + id: string; + name: string; + } | null>; + deleteWorkspace: (workspaceId: string) => Promise; +} + export function DashboardLayout() { const { t } = useI18n(); const navigate = useNavigate(); @@ -31,6 +43,8 @@ export function DashboardLayout() { loading: workspacesLoading, createWorkspace, fetchWorkspaces, + updateWorkspace, + deleteWorkspace, } = useWorkspaces(); const [showInviteModal, setShowInviteModal] = useState(false); const [inviteEmail, setInviteEmail] = useState(""); @@ -165,8 +179,22 @@ export function DashboardLayout() { selectedWorkspaceId={selectedWorkspaceId} onSelectWorkspace={setSelectedWorkspaceId} onInviteClick={() => setShowInviteModal(true)} + onWorkspaceSettingsClick={() => navigate("/dashboard/workspace-settings")} > - + ({ + id: w.id, + name: w.name, + owner_id: w.owner_id, + })), + selectedWorkspaceId, + setSelectedWorkspaceId, + fetchWorkspaces, + updateWorkspace, + deleteWorkspace, + }} + /> {showInviteModal && selectedWorkspaceId && ( From 4177b36e481d8294b5e856bff37e5649a1a81fc2 Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 24 Mar 2026 18:19:25 +0400 Subject: [PATCH 03/22] [FEAT] Connect workspace settings action in dashboard shell --- apps/frontend/src/components/layout/DashboardShell.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/frontend/src/components/layout/DashboardShell.tsx b/apps/frontend/src/components/layout/DashboardShell.tsx index fec74b9..37c0d30 100644 --- a/apps/frontend/src/components/layout/DashboardShell.tsx +++ b/apps/frontend/src/components/layout/DashboardShell.tsx @@ -13,6 +13,7 @@ interface DashboardShellProps { selectedWorkspaceId?: string | null; onSelectWorkspace?: (id: string) => void; onInviteClick?: () => void; + onWorkspaceSettingsClick?: () => void; } export function DashboardShell({ @@ -23,6 +24,7 @@ export function DashboardShell({ selectedWorkspaceId, onSelectWorkspace, onInviteClick, + onWorkspaceSettingsClick, }: DashboardShellProps) { const { t } = useI18n(); const navigate = useNavigate(); @@ -118,6 +120,7 @@ export function DashboardShell({ From 6b83d1ba41c92ad95fc50793fb358ee0dd22ee3a Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 24 Mar 2026 18:19:26 +0400 Subject: [PATCH 04/22] [FEAT] Register dashboard workspace settings route --- apps/frontend/src/routes/index.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/frontend/src/routes/index.tsx b/apps/frontend/src/routes/index.tsx index e7dea71..f46738b 100644 --- a/apps/frontend/src/routes/index.tsx +++ b/apps/frontend/src/routes/index.tsx @@ -28,6 +28,11 @@ const RecentPage = lazy(() => const StarredPage = lazy(() => import("@/pages/dashboard").then((m) => ({ default: m.StarredPage })), ); +const WorkspaceSettingsPage = lazy(() => + import("@/pages/dashboard").then((m) => ({ + default: m.WorkspaceSettingsPage, + })), +); const BoardPage = lazy(() => import("@/pages/board").then((m) => ({ default: m.BoardPage })), ); @@ -90,6 +95,14 @@ const router = createBrowserRouter([ ), }, + { + path: "workspace-settings", + element: ( + + + + ), + }, ], }, { From 0f3fd365f5c58d2d995bb34fa673c6b88b33957d Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 24 Mar 2026 18:19:26 +0400 Subject: [PATCH 05/22] [CHORE] Export workspace settings page from dashboard pages --- apps/frontend/src/pages/dashboard/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/frontend/src/pages/dashboard/index.ts b/apps/frontend/src/pages/dashboard/index.ts index 02662e9..9dd7428 100644 --- a/apps/frontend/src/pages/dashboard/index.ts +++ b/apps/frontend/src/pages/dashboard/index.ts @@ -1,3 +1,4 @@ export { DashboardPage } from "./DashboardPage"; export { RecentPage } from "./RecentPage"; export { StarredPage } from "./StarredPage"; +export { WorkspaceSettingsPage } from "./WorkspaceSettingsPage"; From 8a93a0a427f8dc536cfa4543006bbfc9ad291127 Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 24 Mar 2026 18:19:26 +0400 Subject: [PATCH 06/22] [FEAT] Add English labels for workspace settings --- apps/frontend/src/i18n/locales/en.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/frontend/src/i18n/locales/en.json b/apps/frontend/src/i18n/locales/en.json index e081fb7..79a9a16 100644 --- a/apps/frontend/src/i18n/locales/en.json +++ b/apps/frontend/src/i18n/locales/en.json @@ -109,6 +109,20 @@ "deleteWorkspace": "Delete workspace", "inviteByEmail": "Invite by email", "workspaceSettings": "Workspace settings", + "membersTab": "Users", + "generalTab": "General", + "members": "Members", + "manageWorkspace": "Manage workspace", + "searchMembers": "Search members", + "searchByNameOrEmail": "Search by name or email", + "role": "Role", + "actions": "Actions", + "removeMember": "Remove", + "noMembersFound": "No members found", + "workspaceName": "Workspace name", + "dangerZone": "Danger zone", + "deleteWorkspaceWarning": "This action permanently deletes the workspace and all related boards.", + "deleteWorkspaceConfirm": "Delete this workspace permanently?", "lastWeek": "Last week", "older": "Older", "noRecentBoards": "No recently opened boards. Open a board to see it here.", From 152d38e648e92164adef9a40bd7963f8e8b81b0d Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 24 Mar 2026 18:19:27 +0400 Subject: [PATCH 07/22] [FEAT] Add Azerbaijani labels for workspace settings --- apps/frontend/src/i18n/locales/az.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/frontend/src/i18n/locales/az.json b/apps/frontend/src/i18n/locales/az.json index 410fd3a..7d1e53f 100644 --- a/apps/frontend/src/i18n/locales/az.json +++ b/apps/frontend/src/i18n/locales/az.json @@ -109,6 +109,20 @@ "deleteWorkspace": "İş sahəsini sil", "inviteByEmail": "E-poçtla dəvət et", "workspaceSettings": "İş sahəsi parametrləri", + "membersTab": "İstifadəçilər", + "generalTab": "Ümumi", + "members": "Üzvlər", + "manageWorkspace": "İş sahəsini idarə et", + "searchMembers": "Üzvlərdə axtar", + "searchByNameOrEmail": "Ada və ya e-poçta görə axtar", + "role": "Rol", + "actions": "Əməliyyatlar", + "removeMember": "Sil", + "noMembersFound": "Üzv tapılmadı", + "workspaceName": "İş sahəsinin adı", + "dangerZone": "Riskli bölmə", + "deleteWorkspaceWarning": "Bu əməliyyat iş sahəsini və ona bağlı bütün lövhələri tamamilə silir.", + "deleteWorkspaceConfirm": "Bu iş sahəsi həmişəlik silinsin?", "lastWeek": "Keçən həftə", "older": "Daha əvvəl", "noRecentBoards": "Son açılan lövhə yoxdur. Lövhə açanda burada görünəcək.", From 092ad2d156a01dc18f9e80d61af2d41a4ecb9656 Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 24 Mar 2026 18:19:27 +0400 Subject: [PATCH 08/22] [FEAT] Add Russian labels for workspace settings --- apps/frontend/src/i18n/locales/ru.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/frontend/src/i18n/locales/ru.json b/apps/frontend/src/i18n/locales/ru.json index fbc3999..4e07dac 100644 --- a/apps/frontend/src/i18n/locales/ru.json +++ b/apps/frontend/src/i18n/locales/ru.json @@ -109,6 +109,20 @@ "deleteWorkspace": "Удалить пространство", "inviteByEmail": "Пригласить по email", "workspaceSettings": "Настройки пространства", + "membersTab": "Пользователи", + "generalTab": "Общие", + "members": "Участники", + "manageWorkspace": "Управление пространством", + "searchMembers": "Поиск участников", + "searchByNameOrEmail": "Поиск по имени или email", + "role": "Роль", + "actions": "Действия", + "removeMember": "Удалить", + "noMembersFound": "Участники не найдены", + "workspaceName": "Название пространства", + "dangerZone": "Опасная зона", + "deleteWorkspaceWarning": "Это действие навсегда удалит пространство и все связанные доски.", + "deleteWorkspaceConfirm": "Удалить это пространство безвозвратно?", "lastWeek": "На прошлой неделе", "older": "Ранее", "noRecentBoards": "Нет недавно открытых досок. Откройте доску, и она появится здесь.", From 7a7ecfa70ce477915edeac4aa136ad95d703ae19 Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 24 Mar 2026 18:40:12 +0400 Subject: [PATCH 09/22] [FEAT] Add analytics tab and invite link fallback in workspace settings --- .../pages/dashboard/WorkspaceSettingsPage.tsx | 301 +++++++++++++++++- 1 file changed, 296 insertions(+), 5 deletions(-) diff --git a/apps/frontend/src/pages/dashboard/WorkspaceSettingsPage.tsx b/apps/frontend/src/pages/dashboard/WorkspaceSettingsPage.tsx index a80ed44..b40202e 100644 --- a/apps/frontend/src/pages/dashboard/WorkspaceSettingsPage.tsx +++ b/apps/frontend/src/pages/dashboard/WorkspaceSettingsPage.tsx @@ -1,12 +1,32 @@ import { useEffect, useMemo, useState } from "react"; import { useNavigate, useOutletContext } from "react-router-dom"; +import { + Area, + AreaChart, + CartesianGrid, + Cell, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; import { useI18n } from "@/context/I18nContext"; import { Button, Input } from "@/components/ui"; -import { apiFetch, formatApiError, type WorkspaceMember } from "@/lib/api"; +import { + apiFetch, + formatApiError, + type Board, + type BoardListResponse, + type WorkspaceMember, +} from "@/lib/api"; import { useAuthStore } from "@/stores/authStore"; import type { DashboardOutletContext } from "@/components/layout/DashboardLayout"; -type SettingsTab = "general" | "members"; +type SettingsTab = "general" | "members" | "analytics"; + +const ANALYTICS_COLORS = ["#4f46e5", "#0891b2", "#059669", "#d97706"]; export function WorkspaceSettingsPage() { const { t } = useI18n(); @@ -28,7 +48,9 @@ export function WorkspaceSettingsPage() { const [memberSearch, setMemberSearch] = useState(""); const [inviteEmail, setInviteEmail] = useState(""); const [inviteError, setInviteError] = useState(null); + const [inviteInfo, setInviteInfo] = useState(null); const [savingInvite, setSavingInvite] = useState(false); + const [copiedInviteLink, setCopiedInviteLink] = useState(false); const [workspaceNameDrafts, setWorkspaceNameDrafts] = useState< Record @@ -36,6 +58,9 @@ export function WorkspaceSettingsPage() { const [generalError, setGeneralError] = useState(null); const [savingGeneral, setSavingGeneral] = useState(false); const [deletingWorkspace, setDeletingWorkspace] = useState(false); + const [boards, setBoards] = useState([]); + const [boardsLoading, setBoardsLoading] = useState(false); + const [boardsError, setBoardsError] = useState(null); const selectedWorkspace = useMemo( () => workspaces.find((ws) => ws.id === selectedWorkspaceId) ?? null, @@ -76,10 +101,33 @@ export function WorkspaceSettingsPage() { }); }; + const loadBoards = async (workspaceId: string) => { + setBoardsLoading(true); + setBoardsError(null); + await apiFetch(`/api/boards?workspace_id=${workspaceId}&page=1&limit=100`) + .then(async (res) => { + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(formatApiError(data.detail, "Failed to load boards")); + } + return res.json() as Promise; + }) + .then((data) => { + setBoards(data.items ?? []); + }) + .catch((err: unknown) => { + setBoardsError(err instanceof Error ? err.message : "Failed to load boards"); + }) + .finally(() => { + setBoardsLoading(false); + }); + }; + useEffect(() => { if (!selectedWorkspaceId) return; const timer = window.setTimeout(() => { void loadMembers(selectedWorkspaceId); + void loadBoards(selectedWorkspaceId); }, 0); return () => window.clearTimeout(timer); }, [selectedWorkspaceId]); @@ -94,6 +142,66 @@ export function WorkspaceSettingsPage() { }); }, [members, memberSearch]); + const inviteLink = useMemo(() => { + const base = window.location.origin; + const params = new URLSearchParams({ + workspace: selectedWorkspaceId ?? "", + workspaceName: selectedWorkspace?.name ?? "", + }); + return `${base}/register?${params.toString()}`; + }, [selectedWorkspace?.name, selectedWorkspaceId]); + + const monthlyBoardActivity = useMemo(() => { + const byMonth = new Map(); + const now = new Date(); + for (let i = 5; i >= 0; i -= 1) { + const date = new Date(now.getFullYear(), now.getMonth() - i, 1); + const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; + byMonth.set(key, 0); + } + for (const board of boards) { + const date = new Date(board.created_at); + const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; + if (byMonth.has(key)) { + byMonth.set(key, (byMonth.get(key) ?? 0) + 1); + } + } + return Array.from(byMonth.entries()).map(([key, created]) => { + const [year, month] = key.split("-"); + return { + key, + month: `${month}/${year.slice(2)}`, + created, + }; + }); + }, [boards]); + + const boardAgeDistribution = useMemo(() => { + const latestUpdatedMs = boards.reduce((acc, board) => { + const updatedMs = new Date(board.updated_at).getTime(); + return Number.isNaN(updatedMs) ? acc : Math.max(acc, updatedMs); + }, 0); + let lastWeek = 0; + let lastMonth = 0; + let older = 0; + for (const board of boards) { + const updatedMs = new Date(board.updated_at).getTime(); + const days = Math.floor((latestUpdatedMs - updatedMs) / (1000 * 60 * 60 * 24)); + if (days <= 7) lastWeek += 1; + else if (days <= 30) lastMonth += 1; + else older += 1; + } + return [ + { name: t("dashboard.activityLast7Days"), value: lastWeek }, + { name: t("dashboard.activityLast30Days"), value: lastMonth }, + { name: t("dashboard.activityOlder"), value: older }, + ]; + }, [boards, t]); + + const recentMembers = useMemo(() => { + return [...members].slice(0, 5); + }, [members]); + if (!selectedWorkspaceId || !selectedWorkspace) { return (
@@ -134,6 +242,17 @@ export function WorkspaceSettingsPage() { > {t("dashboard.membersTab")} + +
{inviteError &&

{inviteError}

} + {inviteInfo && ( +

{inviteInfo}

+ )} +
+

+ {t("dashboard.shareableInviteLink")} +

+

{inviteLink}

+
{membersError &&

{membersError}

}
@@ -278,6 +429,146 @@ export function WorkspaceSettingsPage() { )} + {activeTab === "analytics" && ( +
+
+
+

+ {t("dashboard.totalBoards")} +

+

+ {boards.length} +

+
+
+

+ {t("dashboard.totalMembers")} +

+

+ {members.length} +

+
+
+

+ {t("dashboard.boardsPerMember")} +

+

+ {members.length > 0 ? (boards.length / members.length).toFixed(1) : "0.0"} +

+
+
+ + {(boardsLoading || membersLoading) && ( +

{t("dashboard.loadingAnalytics")}

+ )} + {boardsError &&

{boardsError}

} + +
+
+

+ {t("dashboard.boardCreationTrend")} +

+
+ + + + + + + + + + + + + + + +
+
+ +
+

+ {t("dashboard.activityDistribution")} +

+
+ + + + {boardAgeDistribution.map((entry, index) => ( + + ))} + + + + +
+
+ {boardAgeDistribution.map((item, index) => ( +
+ + + {item.name} + + {item.value} +
+ ))} +
+
+
+ +
+

+ {t("dashboard.recentMembers")} +

+
+ {recentMembers.map((member) => ( +
+

+ {member.username || member.email || member.user_id} +

+

+ {member.role} +

+
+ ))} + {recentMembers.length === 0 && ( +

+ {t("dashboard.noMembersFound")} +

+ )} +
+
+
+ )} + {activeTab === "general" && (
From 70d99c1f513ea4d3a10f05397520c794340a8c55 Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 24 Mar 2026 18:40:12 +0400 Subject: [PATCH 10/22] [FEAT] Add workspace settings analytics and invite link translations --- apps/frontend/src/i18n/locales/az.json | 18 ++++++++++++++++++ apps/frontend/src/i18n/locales/en.json | 18 ++++++++++++++++++ apps/frontend/src/i18n/locales/ru.json | 18 ++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/apps/frontend/src/i18n/locales/az.json b/apps/frontend/src/i18n/locales/az.json index 7d1e53f..4f06173 100644 --- a/apps/frontend/src/i18n/locales/az.json +++ b/apps/frontend/src/i18n/locales/az.json @@ -110,6 +110,7 @@ "inviteByEmail": "E-poçtla dəvət et", "workspaceSettings": "İş sahəsi parametrləri", "membersTab": "İstifadəçilər", + "analyticsTab": "Analitika", "generalTab": "Ümumi", "members": "Üzvlər", "manageWorkspace": "İş sahəsini idarə et", @@ -123,6 +124,23 @@ "dangerZone": "Riskli bölmə", "deleteWorkspaceWarning": "Bu əməliyyat iş sahəsini və ona bağlı bütün lövhələri tamamilə silir.", "deleteWorkspaceConfirm": "Bu iş sahəsi həmişəlik silinsin?", + "copyInviteLink": "Dəvət linkini kopyala", + "shareableInviteLink": "Paylaşıla bilən dəvət linki", + "inviteLinkCopied": "Dəvət linki kopyalandı. E-poçt çatmazsa komanda ilə paylaşın.", + "inviteLinkCopiedShort": "Kopyalandı", + "inviteLinkCopyFailed": "Dəvət linki kopyalana bilmədi", + "inviteFallbackHint": "Bu e-poçt üçün hesab tapılmadı. Qeydiyyat üçün dəvət linkini paylaşın.", + "inviteSuccess": "Dəvət uğurla göndərildi", + "totalBoards": "Ümumi lövhələr", + "totalMembers": "Ümumi üzvlər", + "boardsPerMember": "Hər üzvə düşən lövhə", + "loadingAnalytics": "Analitika yüklənir...", + "boardCreationTrend": "Lövhə yaradılma trendləri", + "activityDistribution": "Lövhə aktivliyi bölgüsü", + "activityLast7Days": "Son 7 gündə yenilənən", + "activityLast30Days": "Son 30 gündə yenilənən", + "activityOlder": "Daha əvvəl yenilənən", + "recentMembers": "Son üzvlər", "lastWeek": "Keçən həftə", "older": "Daha əvvəl", "noRecentBoards": "Son açılan lövhə yoxdur. Lövhə açanda burada görünəcək.", diff --git a/apps/frontend/src/i18n/locales/en.json b/apps/frontend/src/i18n/locales/en.json index 79a9a16..8747f2f 100644 --- a/apps/frontend/src/i18n/locales/en.json +++ b/apps/frontend/src/i18n/locales/en.json @@ -110,6 +110,7 @@ "inviteByEmail": "Invite by email", "workspaceSettings": "Workspace settings", "membersTab": "Users", + "analyticsTab": "Analytics", "generalTab": "General", "members": "Members", "manageWorkspace": "Manage workspace", @@ -123,6 +124,23 @@ "dangerZone": "Danger zone", "deleteWorkspaceWarning": "This action permanently deletes the workspace and all related boards.", "deleteWorkspaceConfirm": "Delete this workspace permanently?", + "copyInviteLink": "Copy invite link", + "shareableInviteLink": "Shareable invite link", + "inviteLinkCopied": "Invite link copied. Share it with teammates if email delivery fails.", + "inviteLinkCopiedShort": "Copied", + "inviteLinkCopyFailed": "Could not copy invite link", + "inviteFallbackHint": "No account found for this email. Share the invite link so they can sign up first.", + "inviteSuccess": "Invitation sent successfully", + "totalBoards": "Total boards", + "totalMembers": "Total members", + "boardsPerMember": "Boards per member", + "loadingAnalytics": "Loading analytics...", + "boardCreationTrend": "Board creation trend", + "activityDistribution": "Board activity distribution", + "activityLast7Days": "Updated in last 7 days", + "activityLast30Days": "Updated in last 30 days", + "activityOlder": "Updated earlier", + "recentMembers": "Recent members", "lastWeek": "Last week", "older": "Older", "noRecentBoards": "No recently opened boards. Open a board to see it here.", diff --git a/apps/frontend/src/i18n/locales/ru.json b/apps/frontend/src/i18n/locales/ru.json index 4e07dac..b6f7494 100644 --- a/apps/frontend/src/i18n/locales/ru.json +++ b/apps/frontend/src/i18n/locales/ru.json @@ -110,6 +110,7 @@ "inviteByEmail": "Пригласить по email", "workspaceSettings": "Настройки пространства", "membersTab": "Пользователи", + "analyticsTab": "Аналитика", "generalTab": "Общие", "members": "Участники", "manageWorkspace": "Управление пространством", @@ -123,6 +124,23 @@ "dangerZone": "Опасная зона", "deleteWorkspaceWarning": "Это действие навсегда удалит пространство и все связанные доски.", "deleteWorkspaceConfirm": "Удалить это пространство безвозвратно?", + "copyInviteLink": "Скопировать ссылку-приглашение", + "shareableInviteLink": "Ссылка-приглашение для отправки", + "inviteLinkCopied": "Ссылка скопирована. Поделитесь ею, если письмо не дошло.", + "inviteLinkCopiedShort": "Скопировано", + "inviteLinkCopyFailed": "Не удалось скопировать ссылку", + "inviteFallbackHint": "Для этого email нет аккаунта. Отправьте ссылку, чтобы человек сначала зарегистрировался.", + "inviteSuccess": "Приглашение успешно отправлено", + "totalBoards": "Всего досок", + "totalMembers": "Всего участников", + "boardsPerMember": "Досок на участника", + "loadingAnalytics": "Загрузка аналитики...", + "boardCreationTrend": "Динамика создания досок", + "activityDistribution": "Распределение активности досок", + "activityLast7Days": "Обновлялись за 7 дней", + "activityLast30Days": "Обновлялись за 30 дней", + "activityOlder": "Обновлялись раньше", + "recentMembers": "Недавние участники", "lastWeek": "На прошлой неделе", "older": "Ранее", "noRecentBoards": "Нет недавно открытых досок. Откройте доску, и она появится здесь.", From da9b6679673d20f4f0edeebe8f0929b51d7158e5 Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 24 Mar 2026 18:40:13 +0400 Subject: [PATCH 11/22] [CHORE] Add recharts dependency for workspace analytics --- apps/frontend/package-lock.json | 257 +++++++++++++++++++++++++++++++- apps/frontend/package.json | 3 +- 2 files changed, 257 insertions(+), 3 deletions(-) diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 67cf0a8..e1cd407 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "loomy", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loomy", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@excalidraw/excalidraw": "^0.18.0", "@tailwindcss/vite": "^4.2.1", @@ -14,6 +14,7 @@ "react-dom": "^19.2.0", "react-helmet-async": "^3.0.0", "react-router-dom": "^7.13.1", + "recharts": "^3.8.0", "tailwindcss": "^4.2.1", "zustand": "^5.0.11" }, @@ -2162,6 +2163,42 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.2", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", @@ -2494,6 +2531,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/core": { "version": "1.15.18", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.18.tgz", @@ -2977,6 +3026,39 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, "node_modules/@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", @@ -2992,12 +3074,27 @@ "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", "license": "MIT" }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, "node_modules/@types/d3-time": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "license": "MIT" }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -3071,6 +3168,12 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", @@ -4276,6 +4379,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -4370,6 +4479,16 @@ "node": ">=10.13.0" } }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/es6-promise-pool": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/es6-promise-pool/-/es6-promise-pool-2.5.0.tgz", @@ -4627,6 +4746,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4880,6 +5005,16 @@ "pica": "^7.1.0" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/immutable": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", @@ -6387,6 +6522,36 @@ "react": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -6509,6 +6674,66 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/recharts": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz", + "integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -6748,6 +6973,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -7036,6 +7267,28 @@ "node": ">=8" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 721420d..e5f5b6a 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -1,7 +1,7 @@ { "name": "loomy", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", @@ -20,6 +20,7 @@ "react-dom": "^19.2.0", "react-helmet-async": "^3.0.0", "react-router-dom": "^7.13.1", + "recharts": "^3.8.0", "tailwindcss": "^4.2.1", "zustand": "^5.0.11" }, From 3fc952c9b574f83c50628b30c3810218ddcd2b3b Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 24 Mar 2026 18:59:33 +0400 Subject: [PATCH 12/22] [FEAT] Add invite link UX and invitation accept page --- apps/frontend/src/lib/api.ts | 13 ++ apps/frontend/src/pages/auth/InvitePage.tsx | 118 ++++++++++++++++++ apps/frontend/src/pages/auth/index.ts | 1 + .../pages/dashboard/WorkspaceSettingsPage.tsx | 33 +++-- apps/frontend/src/routes/index.tsx | 11 ++ 5 files changed, 158 insertions(+), 18 deletions(-) create mode 100644 apps/frontend/src/pages/auth/InvitePage.tsx diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index 24aea2c..58f67c7 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -101,3 +101,16 @@ export interface WorkspaceMember { avatar_url: string | null; role: string; } + +export interface WorkspaceInvitation { + id: string; + workspace_id: string; + workspace_name: string; + email: string; + role: string; + token: string; + invite_url: string; + created_at: string; + expires_at?: string | null; + accepted_at?: string | null; +} diff --git a/apps/frontend/src/pages/auth/InvitePage.tsx b/apps/frontend/src/pages/auth/InvitePage.tsx new file mode 100644 index 0000000..26b6404 --- /dev/null +++ b/apps/frontend/src/pages/auth/InvitePage.tsx @@ -0,0 +1,118 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { PageTitle } from "@/components/PageTitle"; +import { Header } from "@/components/layout"; +import { Button } from "@/components/ui"; +import { apiFetch, formatApiError, type WorkspaceInvitation } from "@/lib/api"; +import { useAuthStore } from "@/stores/authStore"; + +const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:8000"; + +export function InvitePage() { + const navigate = useNavigate(); + const { token } = useParams<{ token: string }>(); + const authToken = useAuthStore((s) => s.token); + const [loading, setLoading] = useState(Boolean(token)); + const [accepting, setAccepting] = useState(false); + const [error, setError] = useState(null); + const [invite, setInvite] = useState(null); + const [success, setSuccess] = useState(null); + + useEffect(() => { + if (!token) return; + fetch(`${API_BASE}/api/workspaces/invitations/${token}`) + .then(async (res) => { + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(formatApiError(data.detail, "Invitation not found")); + } + return res.json() as Promise; + }) + .then((data) => { + setInvite(data); + }) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : "Invitation not found"); + }) + .finally(() => setLoading(false)); + }, [token]); + + const inviteTokenQuery = useMemo( + () => (token ? `?invite_token=${encodeURIComponent(token)}` : ""), + [token], + ); + + async function acceptInvitation() { + if (!token) return; + setAccepting(true); + setError(null); + const res = await apiFetch(`/api/workspaces/invitations/${token}/accept`, { + method: "POST", + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(formatApiError(data.detail, "Failed to accept invitation")); + setAccepting(false); + return; + } + setSuccess("Invitation accepted. Redirecting to dashboard..."); + window.setTimeout(() => navigate("/dashboard"), 700); + } + + return ( +
+ +
+
+
+

+ Workspace invitation +

+ + {loading &&

Loading...

} + {!loading && !token && ( +

Invitation token is missing

+ )} + {!loading && error &&

{error}

} + + {!loading && invite && ( +
+

+ You are invited to join {invite.workspace_name} as{" "} + {invite.role} for {invite.email}. +

+ + {success && ( +

{success}

+ )} + + {authToken ? ( + + ) : ( +
+ + + + + + +
+ )} +
+ )} +
+
+
+ ); +} diff --git a/apps/frontend/src/pages/auth/index.ts b/apps/frontend/src/pages/auth/index.ts index ddbb8d1..5da71da 100644 --- a/apps/frontend/src/pages/auth/index.ts +++ b/apps/frontend/src/pages/auth/index.ts @@ -1,3 +1,4 @@ export { AuthCallbackPage } from "./AuthCallbackPage"; +export { InvitePage } from "./InvitePage"; export { LoginPage } from "./LoginPage"; export { RegisterPage } from "./RegisterPage"; diff --git a/apps/frontend/src/pages/dashboard/WorkspaceSettingsPage.tsx b/apps/frontend/src/pages/dashboard/WorkspaceSettingsPage.tsx index b40202e..f6d1749 100644 --- a/apps/frontend/src/pages/dashboard/WorkspaceSettingsPage.tsx +++ b/apps/frontend/src/pages/dashboard/WorkspaceSettingsPage.tsx @@ -19,6 +19,7 @@ import { formatApiError, type Board, type BoardListResponse, + type WorkspaceInvitation, type WorkspaceMember, } from "@/lib/api"; import { useAuthStore } from "@/stores/authStore"; @@ -51,6 +52,7 @@ export function WorkspaceSettingsPage() { const [inviteInfo, setInviteInfo] = useState(null); const [savingInvite, setSavingInvite] = useState(false); const [copiedInviteLink, setCopiedInviteLink] = useState(false); + const [latestInviteUrl, setLatestInviteUrl] = useState(null); const [workspaceNameDrafts, setWorkspaceNameDrafts] = useState< Record @@ -142,15 +144,6 @@ export function WorkspaceSettingsPage() { }); }, [members, memberSearch]); - const inviteLink = useMemo(() => { - const base = window.location.origin; - const params = new URLSearchParams({ - workspace: selectedWorkspaceId ?? "", - workspaceName: selectedWorkspace?.name ?? "", - }); - return `${base}/register?${params.toString()}`; - }, [selectedWorkspace?.name, selectedWorkspaceId]); - const monthlyBoardActivity = useMemo(() => { const byMonth = new Map(); const now = new Date(); @@ -295,7 +288,7 @@ export function WorkspaceSettingsPage() { setInviteInfo(null); setSavingInvite(true); const res = await apiFetch( - `/api/workspaces/${selectedWorkspaceId}/members`, + `/api/workspaces/${selectedWorkspaceId}/invitations`, { method: "POST", body: JSON.stringify({ @@ -306,16 +299,14 @@ export function WorkspaceSettingsPage() { ); if (!res.ok) { const data = await res.json().catch(() => ({})); - const message = formatApiError(data.detail, "Failed to invite member"); - setInviteError(message); - if (message.toLowerCase().includes("no user found")) { - setInviteInfo(t("dashboard.inviteFallbackHint")); - } + setInviteError(formatApiError(data.detail, "Failed to create invitation")); setSavingInvite(false); return; } + const invitation = (await res.json()) as WorkspaceInvitation; setInviteEmail(""); - setInviteInfo(t("dashboard.inviteSuccess")); + setLatestInviteUrl(invitation.invite_url); + setInviteInfo(t("dashboard.inviteCreated")); await fetchWorkspaces(); await loadMembers(selectedWorkspaceId); setSavingInvite(false); @@ -338,8 +329,12 @@ export function WorkspaceSettingsPage() { variant="outline" disabled={!isOwner} onClick={async () => { + if (!latestInviteUrl) { + setInviteError(t("dashboard.createInviteFirst")); + return; + } try { - await navigator.clipboard.writeText(inviteLink); + await navigator.clipboard.writeText(latestInviteUrl); setCopiedInviteLink(true); setInviteInfo(t("dashboard.inviteLinkCopied")); window.setTimeout(() => setCopiedInviteLink(false), 1500); @@ -363,7 +358,9 @@ export function WorkspaceSettingsPage() {

{t("dashboard.shareableInviteLink")}

-

{inviteLink}

+

+ {latestInviteUrl ?? t("dashboard.noInviteGenerated")} +

{membersError &&

{membersError}

} diff --git a/apps/frontend/src/routes/index.tsx b/apps/frontend/src/routes/index.tsx index f46738b..d5c3b30 100644 --- a/apps/frontend/src/routes/index.tsx +++ b/apps/frontend/src/routes/index.tsx @@ -19,6 +19,9 @@ const RegisterPage = lazy(() => const AuthCallbackPage = lazy(() => import("@/pages/auth").then((m) => ({ default: m.AuthCallbackPage })), ); +const InvitePage = lazy(() => + import("@/pages/auth").then((m) => ({ default: m.InvitePage })), +); const DashboardPage = lazy(() => import("@/pages/dashboard").then((m) => ({ default: m.DashboardPage })), ); @@ -63,6 +66,14 @@ const router = createBrowserRouter([ ), }, + { + path: "/invite/:token", + element: ( + + + + ), + }, { path: "/dashboard", element: ( From eb9836ad54710fbfc2787c009a620454d49240be Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 24 Mar 2026 18:59:33 +0400 Subject: [PATCH 13/22] [FIX] Preserve invite token through OAuth callbacks --- api/app/app/core/redis.py | 24 ++++++++++------ api/app/app/modules/auth/router.py | 24 ++++++++++++---- .../src/pages/auth/AuthCallbackPage.tsx | 25 +++++++++++++---- apps/frontend/src/pages/auth/LoginPage.tsx | 28 +++++++++++++++++-- apps/frontend/src/pages/auth/RegisterPage.tsx | 21 +++++++++++--- 5 files changed, 96 insertions(+), 26 deletions(-) diff --git a/api/app/app/core/redis.py b/api/app/app/core/redis.py index e634003..dad8e89 100644 --- a/api/app/app/core/redis.py +++ b/api/app/app/core/redis.py @@ -31,24 +31,32 @@ def publish_board_event(board_id: str, event: str, data: Dict[str, Any]) -> int: OAUTH_STATE_TTL = 600 # 10 minutes -def set_oauth_state(state: str) -> bool: +def set_oauth_state(state: str, invite_token: str | None = None) -> bool: """Store OAuth state for CSRF validation.""" try: r = get_redis() - r.setex(f"{OAUTH_STATE_PREFIX}{state}", OAUTH_STATE_TTL, "1") + payload = {"invite_token": invite_token} + r.setex(f"{OAUTH_STATE_PREFIX}{state}", OAUTH_STATE_TTL, json.dumps(payload)) return True except Exception: return False -def validate_oauth_state(state: str) -> bool: - """Validate and consume OAuth state.""" +def validate_oauth_state(state: str) -> Dict[str, Any] | None: + """Validate and consume OAuth state, returning stored payload.""" try: r = get_redis() key = f"{OAUTH_STATE_PREFIX}{state}" - if r.get(key): + raw = r.get(key) + if raw: r.delete(key) - return True - return False + try: + data = json.loads(raw) + if isinstance(data, dict): + return data + except Exception: + return {} + return {} + return None except Exception: - return False + return None diff --git a/api/app/app/modules/auth/router.py b/api/app/app/modules/auth/router.py index bb2801c..5a88a7b 100644 --- a/api/app/app/modules/auth/router.py +++ b/api/app/app/modules/auth/router.py @@ -46,14 +46,14 @@ def login_endpoint( # --- GitHub OAuth --- @router.get("/github") -def github_login() -> RedirectResponse: +def github_login(invite_token: str | None = None) -> RedirectResponse: if not settings.github_client_id: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="GitHub OAuth is not configured", ) state = secrets.token_urlsafe(32) - set_oauth_state(state) + set_oauth_state(state, invite_token=invite_token) return RedirectResponse(url=get_github_authorize_url(state)) @@ -65,7 +65,10 @@ async def github_callback( ) -> RedirectResponse: if not code: raise HTTPException(status_code=400, detail="Missing authorization code") - if not state or not validate_oauth_state(state): + if not state: + raise HTTPException(status_code=400, detail="Invalid or expired OAuth state") + state_data = validate_oauth_state(state) + if state_data is None: raise HTTPException(status_code=400, detail="Invalid or expired OAuth state") info = await get_github_user_info(code) if not info: @@ -76,19 +79,22 @@ async def github_callback( user, _ = get_or_create_oauth_user(db, info) token = create_access_token(subject=str(user.id)) redirect_url = f"{settings.frontend_url.rstrip('/')}/auth/callback?token={token}" + invite_token = state_data.get("invite_token") + if isinstance(invite_token, str) and invite_token: + redirect_url = f"{redirect_url}&invite_token={invite_token}" return RedirectResponse(url=redirect_url) # --- Google OAuth --- @router.get("/google") -def google_login() -> RedirectResponse: +def google_login(invite_token: str | None = None) -> RedirectResponse: if not settings.google_client_id: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Google OAuth is not configured", ) state = secrets.token_urlsafe(32) - set_oauth_state(state) + set_oauth_state(state, invite_token=invite_token) return RedirectResponse(url=get_google_authorize_url(state)) @@ -100,7 +106,10 @@ async def google_callback( ) -> RedirectResponse: if not code: raise HTTPException(status_code=400, detail="Missing authorization code") - if not state or not validate_oauth_state(state): + if not state: + raise HTTPException(status_code=400, detail="Invalid or expired OAuth state") + state_data = validate_oauth_state(state) + if state_data is None: raise HTTPException(status_code=400, detail="Invalid or expired OAuth state") info = await get_google_user_info(code) if not info: @@ -111,4 +120,7 @@ async def google_callback( user, _ = get_or_create_oauth_user(db, info) token = create_access_token(subject=str(user.id)) redirect_url = f"{settings.frontend_url.rstrip('/')}/auth/callback?token={token}" + invite_token = state_data.get("invite_token") + if isinstance(invite_token, str) and invite_token: + redirect_url = f"{redirect_url}&invite_token={invite_token}" return RedirectResponse(url=redirect_url) diff --git a/apps/frontend/src/pages/auth/AuthCallbackPage.tsx b/apps/frontend/src/pages/auth/AuthCallbackPage.tsx index 4ef6b84..0d79fca 100644 --- a/apps/frontend/src/pages/auth/AuthCallbackPage.tsx +++ b/apps/frontend/src/pages/auth/AuthCallbackPage.tsx @@ -12,15 +12,30 @@ export function AuthCallbackPage() { const navigate = useNavigate(); const setToken = useAuthStore((s) => s.setToken); const token = searchParams.get("token"); + const inviteToken = searchParams.get("invite_token"); + const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:8000"; useEffect(() => { - if (token) { - setToken(token); - navigate("/dashboard", { replace: true }); - } else { + if (!token) { navigate("/login", { replace: true }); + return; } - }, [token, navigate, setToken]); + + setToken(token); + const acceptMaybe = async () => { + if (inviteToken) { + await fetch(`${API_BASE}/api/workspaces/invitations/${inviteToken}/accept`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }); + } + navigate("/dashboard", { replace: true }); + }; + void acceptMaybe(); + }, [token, inviteToken, navigate, setToken, API_BASE]); return (
diff --git a/apps/frontend/src/pages/auth/LoginPage.tsx b/apps/frontend/src/pages/auth/LoginPage.tsx index fcd1109..066d47e 100644 --- a/apps/frontend/src/pages/auth/LoginPage.tsx +++ b/apps/frontend/src/pages/auth/LoginPage.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { useI18n } from "@/context/I18nContext"; import { PageTitle } from "@/components/PageTitle"; import { Header } from "@/components/layout"; @@ -12,6 +12,7 @@ const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:8000"; export function LoginPage() { const { t } = useI18n(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const setToken = useAuthStore((s) => s.setToken); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -34,6 +35,23 @@ export function LoginPage() { return; } setToken(data.access_token); + const inviteToken = searchParams.get("invite_token"); + if (inviteToken) { + const acceptRes = await fetch( + `${API_BASE}/api/workspaces/invitations/${inviteToken}/accept`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${data.access_token}`, + }, + }, + ); + if (!acceptRes.ok) { + const acceptData = await acceptRes.json().catch(() => ({})); + setError(formatApiError(acceptData.detail, "Login succeeded, invite was not accepted")); + } + } navigate("/dashboard"); } catch { setError("Network error"); @@ -43,7 +61,11 @@ export function LoginPage() { } function handleOAuth(provider: "github" | "google") { - window.location.href = `${API_BASE}/api/auth/${provider}`; + const inviteToken = searchParams.get("invite_token"); + const suffix = inviteToken + ? `?invite_token=${encodeURIComponent(inviteToken)}` + : ""; + window.location.href = `${API_BASE}/api/auth/${provider}${suffix}`; } return ( @@ -114,7 +136,7 @@ export function LoginPage() {

{t("auth.login.noAccount")}{" "} {t("common.signUp")} diff --git a/apps/frontend/src/pages/auth/RegisterPage.tsx b/apps/frontend/src/pages/auth/RegisterPage.tsx index 84ed98f..516953a 100644 --- a/apps/frontend/src/pages/auth/RegisterPage.tsx +++ b/apps/frontend/src/pages/auth/RegisterPage.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { useI18n } from "@/context/I18nContext"; import { PageTitle } from "@/components/PageTitle"; import { Header } from "@/components/layout"; @@ -11,6 +11,7 @@ const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:8000"; export function RegisterPage() { const { t } = useI18n(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const [firstName, setFirstName] = useState(""); const [lastName, setLastName] = useState(""); const [email, setEmail] = useState(""); @@ -40,7 +41,12 @@ export function RegisterPage() { setError(formatApiError(data.detail, "Registration failed")); return; } - navigate("/login"); + const inviteToken = searchParams.get("invite_token"); + if (inviteToken) { + navigate(`/login?invite_token=${encodeURIComponent(inviteToken)}`); + } else { + navigate("/login"); + } } catch { setError("Network error"); } finally { @@ -49,7 +55,11 @@ export function RegisterPage() { } function handleOAuth(provider: "github" | "google") { - window.location.href = `${API_BASE}/api/auth/${provider}`; + const inviteToken = searchParams.get("invite_token"); + const suffix = inviteToken + ? `?invite_token=${encodeURIComponent(inviteToken)}` + : ""; + window.location.href = `${API_BASE}/api/auth/${provider}${suffix}`; } return ( @@ -146,7 +156,10 @@ export function RegisterPage() {

{t("auth.register.hasAccount")}{" "} - + {t("common.login")}

From d3410f894084d80c8ebbb10a9feda7ee00973a20 Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 24 Mar 2026 18:59:34 +0400 Subject: [PATCH 14/22] [FEAT] Add invitation token flow localization copy --- apps/frontend/src/i18n/locales/az.json | 3 +++ apps/frontend/src/i18n/locales/en.json | 3 +++ apps/frontend/src/i18n/locales/ru.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/apps/frontend/src/i18n/locales/az.json b/apps/frontend/src/i18n/locales/az.json index 4f06173..ad0fd46 100644 --- a/apps/frontend/src/i18n/locales/az.json +++ b/apps/frontend/src/i18n/locales/az.json @@ -126,6 +126,9 @@ "deleteWorkspaceConfirm": "Bu iş sahəsi həmişəlik silinsin?", "copyInviteLink": "Dəvət linkini kopyala", "shareableInviteLink": "Paylaşıla bilən dəvət linki", + "inviteCreated": "Dəvət linki yaradıldı", + "createInviteFirst": "Əvvəlcə dəvət yaradın", + "noInviteGenerated": "Hələ dəvət linki yaradılmayıb", "inviteLinkCopied": "Dəvət linki kopyalandı. E-poçt çatmazsa komanda ilə paylaşın.", "inviteLinkCopiedShort": "Kopyalandı", "inviteLinkCopyFailed": "Dəvət linki kopyalana bilmədi", diff --git a/apps/frontend/src/i18n/locales/en.json b/apps/frontend/src/i18n/locales/en.json index 8747f2f..aab2e61 100644 --- a/apps/frontend/src/i18n/locales/en.json +++ b/apps/frontend/src/i18n/locales/en.json @@ -126,6 +126,9 @@ "deleteWorkspaceConfirm": "Delete this workspace permanently?", "copyInviteLink": "Copy invite link", "shareableInviteLink": "Shareable invite link", + "inviteCreated": "Invitation link created", + "createInviteFirst": "Create an invitation first", + "noInviteGenerated": "No invitation generated yet", "inviteLinkCopied": "Invite link copied. Share it with teammates if email delivery fails.", "inviteLinkCopiedShort": "Copied", "inviteLinkCopyFailed": "Could not copy invite link", diff --git a/apps/frontend/src/i18n/locales/ru.json b/apps/frontend/src/i18n/locales/ru.json index b6f7494..f49df3c 100644 --- a/apps/frontend/src/i18n/locales/ru.json +++ b/apps/frontend/src/i18n/locales/ru.json @@ -126,6 +126,9 @@ "deleteWorkspaceConfirm": "Удалить это пространство безвозвратно?", "copyInviteLink": "Скопировать ссылку-приглашение", "shareableInviteLink": "Ссылка-приглашение для отправки", + "inviteCreated": "Ссылка-приглашение создана", + "createInviteFirst": "Сначала создайте приглашение", + "noInviteGenerated": "Ссылка-приглашение еще не создана", "inviteLinkCopied": "Ссылка скопирована. Поделитесь ею, если письмо не дошло.", "inviteLinkCopiedShort": "Скопировано", "inviteLinkCopyFailed": "Не удалось скопировать ссылку", From 0370efa60c7b071d6cf78fabd54710f32ea8a092 Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 24 Mar 2026 19:04:36 +0400 Subject: [PATCH 15/22] [FEAT] Add workspace invitation token domain and services --- api/app/app/modules/workspaces/invitations.py | 97 +++++++++++++++++++ api/app/app/modules/workspaces/model.py | 34 +++++++ api/app/app/modules/workspaces/repository.py | 43 +++++++- api/app/app/modules/workspaces/router.py | 2 + api/app/app/modules/workspaces/schemas.py | 23 +++++ api/app/app/modules/workspaces/service.py | 51 +++++++++- 6 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 api/app/app/modules/workspaces/invitations.py diff --git a/api/app/app/modules/workspaces/invitations.py b/api/app/app/modules/workspaces/invitations.py new file mode 100644 index 0000000..c3a2e50 --- /dev/null +++ b/api/app/app/modules/workspaces/invitations.py @@ -0,0 +1,97 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from uuid import UUID + +from app.api.deps import get_current_user +from app.config import settings +from app.db.session import get_db +from app.modules.users.model import User +from app.modules.workspaces.schemas import ( + WorkspaceInvitationAcceptResponse, + WorkspaceInvitationCreate, + WorkspaceInvitationResponse, +) +from app.modules.workspaces.service import ( + accept_workspace_invitation_for_user, + create_workspace_invitation_for_user, + get_workspace_invitation_by_token, +) + +router = APIRouter(tags=["workspace-invitations"]) + + +def _to_response(token: str, invitation) -> WorkspaceInvitationResponse: + base_url = settings.frontend_url.rstrip("/") if settings.frontend_url else "http://localhost:5173" + return WorkspaceInvitationResponse( + id=invitation.id, + workspace_id=invitation.workspace_id, + workspace_name=invitation.workspace.name if invitation.workspace else "", + email=invitation.email, + role=invitation.role, + token=token, + invite_url=f"{base_url}/invite/{token}", + created_at=invitation.created_at, + expires_at=invitation.expires_at, + accepted_at=invitation.accepted_at, + ) + + +@router.post( + "/{workspace_id}/invitations", + response_model=WorkspaceInvitationResponse, + status_code=status.HTTP_201_CREATED, +) +def create_workspace_invitation_endpoint( + workspace_id: UUID, + data: WorkspaceInvitationCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> WorkspaceInvitationResponse: + invitation = create_workspace_invitation_for_user( + db, + workspace_id, + current_user, + email=data.email, + role=data.role, + ) + if invitation is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the workspace owner can create invitations", + ) + loaded = get_workspace_invitation_by_token(db, invitation.token) + if loaded is None: + raise HTTPException(status_code=404, detail="Invitation not found") + return _to_response(invitation.token, loaded) + + +@router.get("/invitations/{token}", response_model=WorkspaceInvitationResponse) +def get_workspace_invitation_endpoint( + token: str, + db: Session = Depends(get_db), +) -> WorkspaceInvitationResponse: + invitation = get_workspace_invitation_by_token(db, token) + if invitation is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invitation not found") + return _to_response(token, invitation) + + +@router.post( + "/invitations/{token}/accept", + response_model=WorkspaceInvitationAcceptResponse, +) +def accept_workspace_invitation_endpoint( + token: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> WorkspaceInvitationAcceptResponse: + invitation = accept_workspace_invitation_for_user(db, token, current_user) + if invitation is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invitation is invalid, expired, already used, or email does not match", + ) + return WorkspaceInvitationAcceptResponse( + workspace_id=invitation.workspace_id, + workspace_name=invitation.workspace.name if invitation.workspace else "", + ) diff --git a/api/app/app/modules/workspaces/model.py b/api/app/app/modules/workspaces/model.py index e56963e..f338bff 100644 --- a/api/app/app/modules/workspaces/model.py +++ b/api/app/app/modules/workspaces/model.py @@ -46,6 +46,9 @@ class Workspace(Base): members: Mapped[list["WorkspaceMember"]] = relationship( "WorkspaceMember", back_populates="workspace", cascade="all, delete-orphan" ) + invitations: Mapped[list["WorkspaceInvitation"]] = relationship( + "WorkspaceInvitation", back_populates="workspace", cascade="all, delete-orphan" + ) class WorkspaceMember(Base): @@ -75,3 +78,34 @@ class WorkspaceMember(Base): workspace: Mapped["Workspace"] = relationship("Workspace", back_populates="members") user: Mapped["User"] = relationship("User", back_populates="workspace_memberships") + + +class WorkspaceInvitation(Base): + __tablename__ = "workspace_invitations" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + workspace_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + email: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + role: Mapped[str] = mapped_column(String(50), nullable=False, default="member") + token: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True) + invited_by: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + workspace: Mapped["Workspace"] = relationship("Workspace", back_populates="invitations") diff --git a/api/app/app/modules/workspaces/repository.py b/api/app/app/modules/workspaces/repository.py index 3f7c06c..749cdf1 100644 --- a/api/app/app/modules/workspaces/repository.py +++ b/api/app/app/modules/workspaces/repository.py @@ -1,12 +1,13 @@ import re import secrets +from datetime import datetime, timedelta, timezone from typing import Tuple, List from uuid import UUID from sqlalchemy import func from sqlalchemy.orm import Session, joinedload -from app.modules.workspaces.model import Workspace, WorkspaceMember +from app.modules.workspaces.model import Workspace, WorkspaceInvitation, WorkspaceMember def _slugify(text: str) -> str: @@ -145,3 +146,43 @@ def remove_member(db: Session, workspace_id: UUID, user_id: UUID) -> bool: db.delete(member) db.commit() return True + + +def create_invitation( + db: Session, + *, + workspace_id: UUID, + email: str, + invited_by: UUID, + role: str = "member", + expires_in_days: int = 7, +) -> WorkspaceInvitation: + token = secrets.token_urlsafe(32) + invitation = WorkspaceInvitation( + workspace_id=workspace_id, + email=email.strip().lower(), + role=role, + token=token, + invited_by=invited_by, + expires_at=datetime.now(timezone.utc) + timedelta(days=expires_in_days), + ) + db.add(invitation) + db.commit() + db.refresh(invitation) + return invitation + + +def get_invitation_by_token(db: Session, token: str) -> WorkspaceInvitation | None: + return ( + db.query(WorkspaceInvitation) + .options(joinedload(WorkspaceInvitation.workspace)) + .filter(WorkspaceInvitation.token == token) + .first() + ) + + +def mark_invitation_accepted(db: Session, invitation: WorkspaceInvitation) -> WorkspaceInvitation: + invitation.accepted_at = datetime.now(timezone.utc) + db.commit() + db.refresh(invitation) + return invitation diff --git a/api/app/app/modules/workspaces/router.py b/api/app/app/modules/workspaces/router.py index 10d63ba..1fcf4ed 100644 --- a/api/app/app/modules/workspaces/router.py +++ b/api/app/app/modules/workspaces/router.py @@ -21,9 +21,11 @@ update_workspace_for_user, ) from app.modules.workspaces.members import router as members_router +from app.modules.workspaces.invitations import router as invitations_router router = APIRouter(prefix="/workspaces", tags=["workspaces"]) router.include_router(members_router, prefix="/{workspace_id}") +router.include_router(invitations_router) def _workspace_to_response(workspace: Workspace) -> WorkspaceResponse: diff --git a/api/app/app/modules/workspaces/schemas.py b/api/app/app/modules/workspaces/schemas.py index 6600e3d..207df05 100644 --- a/api/app/app/modules/workspaces/schemas.py +++ b/api/app/app/modules/workspaces/schemas.py @@ -29,3 +29,26 @@ class WorkspaceListResponse(BaseModel): total: int page: int limit: int + + +class WorkspaceInvitationCreate(BaseModel): + email: str + role: str = "member" + + +class WorkspaceInvitationResponse(BaseModel): + id: UUID + workspace_id: UUID + workspace_name: str + email: str + role: str + token: str + invite_url: str + created_at: datetime + expires_at: datetime | None = None + accepted_at: datetime | None = None + + +class WorkspaceInvitationAcceptResponse(BaseModel): + workspace_id: UUID + workspace_name: str diff --git a/api/app/app/modules/workspaces/service.py b/api/app/app/modules/workspaces/service.py index ef6e368..f8ec800 100644 --- a/api/app/app/modules/workspaces/service.py +++ b/api/app/app/modules/workspaces/service.py @@ -1,16 +1,21 @@ from typing import Tuple +from datetime import datetime, timezone from uuid import UUID from sqlalchemy.orm import Session from app.modules.users.model import User -from app.modules.workspaces.model import Workspace +from app.modules.workspaces.model import Workspace, WorkspaceInvitation from app.modules.workspaces.repository import ( + create_invitation, create as create_workspace, delete as delete_workspace, + add_member, + get_invitation_by_token, get_by_id, get_user_workspaces, is_member, + mark_invitation_accepted, make_unique_slug, update as update_workspace, ) @@ -66,3 +71,47 @@ def delete_workspace_for_user(db: Session, workspace_id: UUID, user: User) -> bo return False delete_workspace(db, workspace) return True + + +def create_workspace_invitation_for_user( + db: Session, + workspace_id: UUID, + user: User, + *, + email: str, + role: str = "member", +) -> WorkspaceInvitation | None: + workspace = get_workspace(db, workspace_id, user) + if not workspace or workspace.owner_id != user.id: + return None + return create_invitation( + db, + workspace_id=workspace_id, + email=email, + role=role, + invited_by=user.id, + ) + + +def get_workspace_invitation_by_token( + db: Session, token: str +) -> WorkspaceInvitation | None: + return get_invitation_by_token(db, token) + + +def accept_workspace_invitation_for_user( + db: Session, token: str, user: User +) -> WorkspaceInvitation | None: + invitation = get_invitation_by_token(db, token) + if not invitation: + return None + if invitation.accepted_at is not None: + return None + if invitation.expires_at is not None and invitation.expires_at <= datetime.now(timezone.utc): + return None + if invitation.email.strip().lower() != user.email.strip().lower(): + return None + + if not is_member(db, invitation.workspace_id, user.id): + add_member(db, invitation.workspace_id, user.id, role=invitation.role) + return mark_invitation_accepted(db, invitation) From 954741403da48177b43676041eb2251b8841cb8f Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 24 Mar 2026 19:04:36 +0400 Subject: [PATCH 16/22] [FEAT] Add workspace invitations migration wiring --- api/app/alembic/env.py | 6 ++- ...f9_feat_add_workspace_invitations_table.py | 51 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 api/app/alembic/versions/7d6ba2fe0af9_feat_add_workspace_invitations_table.py diff --git a/api/app/alembic/env.py b/api/app/alembic/env.py index 95047e3..114f11d 100644 --- a/api/app/alembic/env.py +++ b/api/app/alembic/env.py @@ -10,7 +10,11 @@ from app.modules.boards.model import Board, BoardStar, BoardView # noqa: F401 - metadata from app.modules.elements.model import Element # noqa: F401 - metadata from app.modules.users.model import OAuthAccount, User # noqa: F401 - metadata -from app.modules.workspaces.model import Workspace, WorkspaceMember # noqa: F401 - metadata +from app.modules.workspaces.model import ( # noqa: F401 - metadata + Workspace, + WorkspaceInvitation, + WorkspaceMember, +) config = context.config diff --git a/api/app/alembic/versions/7d6ba2fe0af9_feat_add_workspace_invitations_table.py b/api/app/alembic/versions/7d6ba2fe0af9_feat_add_workspace_invitations_table.py new file mode 100644 index 0000000..a39cc59 --- /dev/null +++ b/api/app/alembic/versions/7d6ba2fe0af9_feat_add_workspace_invitations_table.py @@ -0,0 +1,51 @@ +"""[FEAT] Add workspace invitations table + +Revision ID: 7d6ba2fe0af9 +Revises: 24b35055e774 +Create Date: 2026-03-24 18:51:12.834194 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '7d6ba2fe0af9' +down_revision: Union[str, Sequence[str], None] = '24b35055e774' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('workspace_invitations', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('workspace_id', sa.UUID(), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('role', sa.String(length=50), nullable=False), + sa.Column('token', sa.String(length=128), nullable=False), + sa.Column('invited_by', sa.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('accepted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['invited_by'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_workspace_invitations_email'), 'workspace_invitations', ['email'], unique=False) + op.create_index(op.f('ix_workspace_invitations_token'), 'workspace_invitations', ['token'], unique=True) + op.create_index(op.f('ix_workspace_invitations_workspace_id'), 'workspace_invitations', ['workspace_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_workspace_invitations_workspace_id'), table_name='workspace_invitations') + op.drop_index(op.f('ix_workspace_invitations_token'), table_name='workspace_invitations') + op.drop_index(op.f('ix_workspace_invitations_email'), table_name='workspace_invitations') + op.drop_table('workspace_invitations') + # ### end Alembic commands ### From b43b66f594d4c4509fc7a1aba12f8328ae6b1576 Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 24 Mar 2026 19:04:36 +0400 Subject: [PATCH 17/22] [CHORE] Update backend dependencies for invitation flow --- api/app/pyproject.toml | 2 +- api/app/uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/app/pyproject.toml b/api/app/pyproject.toml index 976c5c3..50a171b 100644 --- a/api/app/pyproject.toml +++ b/api/app/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "loomy-api" -version = "0.0.1" +version = "0.1.0" description = "Loomy API" readme = "README.md" requires-python = ">=3.12" diff --git a/api/app/uv.lock b/api/app/uv.lock index 8decdbb..09a9ca4 100644 --- a/api/app/uv.lock +++ b/api/app/uv.lock @@ -488,7 +488,7 @@ wheels = [ [[package]] name = "loomy-api" -version = "0.0.1" +version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "alembic" }, From 390a2ba36c4e93094cdf5ce4ac51dce403e32106 Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 24 Mar 2026 19:04:37 +0400 Subject: [PATCH 18/22] [CHORE] Update gitignore rules --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e4149fc..7c79e54 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ .env.local .env.*.local !.env.example - +scripts/ # IDE and editor .cursor/ .vscode/ From 85da97db85b9fe140fb5c04c217b1fe34a92b1c0 Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 24 Mar 2026 19:25:03 +0400 Subject: [PATCH 19/22] [FEAT] Add pending workspace invitations listing endpoint --- api/app/app/modules/workspaces/invitations.py | 22 +++++++++++++++++++ api/app/app/modules/workspaces/repository.py | 16 ++++++++++++++ api/app/app/modules/workspaces/schemas.py | 4 ++++ api/app/app/modules/workspaces/service.py | 14 ++++++++++++ 4 files changed, 56 insertions(+) diff --git a/api/app/app/modules/workspaces/invitations.py b/api/app/app/modules/workspaces/invitations.py index c3a2e50..8a20995 100644 --- a/api/app/app/modules/workspaces/invitations.py +++ b/api/app/app/modules/workspaces/invitations.py @@ -9,12 +9,14 @@ from app.modules.workspaces.schemas import ( WorkspaceInvitationAcceptResponse, WorkspaceInvitationCreate, + WorkspaceInvitationListResponse, WorkspaceInvitationResponse, ) from app.modules.workspaces.service import ( accept_workspace_invitation_for_user, create_workspace_invitation_for_user, get_workspace_invitation_by_token, + list_workspace_invitations_for_user, ) router = APIRouter(tags=["workspace-invitations"]) @@ -65,6 +67,26 @@ def create_workspace_invitation_endpoint( return _to_response(invitation.token, loaded) +@router.get( + "/{workspace_id}/invitations", + response_model=WorkspaceInvitationListResponse, +) +def list_workspace_invitations_endpoint( + workspace_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> WorkspaceInvitationListResponse: + invitations = list_workspace_invitations_for_user(db, workspace_id, current_user) + if invitations is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the workspace owner can view pending invitations", + ) + return WorkspaceInvitationListResponse( + items=[_to_response(invitation.token, invitation) for invitation in invitations] + ) + + @router.get("/invitations/{token}", response_model=WorkspaceInvitationResponse) def get_workspace_invitation_endpoint( token: str, diff --git a/api/app/app/modules/workspaces/repository.py b/api/app/app/modules/workspaces/repository.py index 749cdf1..96a80c8 100644 --- a/api/app/app/modules/workspaces/repository.py +++ b/api/app/app/modules/workspaces/repository.py @@ -186,3 +186,19 @@ def mark_invitation_accepted(db: Session, invitation: WorkspaceInvitation) -> Wo db.commit() db.refresh(invitation) return invitation + + +def list_workspace_invitations( + db: Session, + *, + workspace_id: UUID, + pending_only: bool = True, +) -> List[WorkspaceInvitation]: + query = ( + db.query(WorkspaceInvitation) + .options(joinedload(WorkspaceInvitation.workspace)) + .filter(WorkspaceInvitation.workspace_id == workspace_id) + ) + if pending_only: + query = query.filter(WorkspaceInvitation.accepted_at.is_(None)) + return query.order_by(WorkspaceInvitation.created_at.desc()).all() diff --git a/api/app/app/modules/workspaces/schemas.py b/api/app/app/modules/workspaces/schemas.py index 207df05..fbea701 100644 --- a/api/app/app/modules/workspaces/schemas.py +++ b/api/app/app/modules/workspaces/schemas.py @@ -52,3 +52,7 @@ class WorkspaceInvitationResponse(BaseModel): class WorkspaceInvitationAcceptResponse(BaseModel): workspace_id: UUID workspace_name: str + + +class WorkspaceInvitationListResponse(BaseModel): + items: list[WorkspaceInvitationResponse] diff --git a/api/app/app/modules/workspaces/service.py b/api/app/app/modules/workspaces/service.py index f8ec800..2cdc362 100644 --- a/api/app/app/modules/workspaces/service.py +++ b/api/app/app/modules/workspaces/service.py @@ -12,6 +12,7 @@ delete as delete_workspace, add_member, get_invitation_by_token, + list_workspace_invitations, get_by_id, get_user_workspaces, is_member, @@ -99,6 +100,19 @@ def get_workspace_invitation_by_token( return get_invitation_by_token(db, token) +def list_workspace_invitations_for_user( + db: Session, + workspace_id: UUID, + user: User, +) -> list[WorkspaceInvitation] | None: + workspace = get_workspace(db, workspace_id, user) + if workspace is None: + return None + if workspace.owner_id != user.id: + return None + return list_workspace_invitations(db, workspace_id=workspace_id, pending_only=True) + + def accept_workspace_invitation_for_user( db: Session, token: str, user: User ) -> WorkspaceInvitation | None: From f93dc116cf8f8cdf6aee5fb8a058a50399e985e1 Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 24 Mar 2026 19:25:04 +0400 Subject: [PATCH 20/22] [FEAT] Replace invite link block with pending invitations UX --- apps/frontend/src/lib/api.ts | 4 + .../pages/dashboard/WorkspaceSettingsPage.tsx | 149 ++++++++++++++---- 2 files changed, 118 insertions(+), 35 deletions(-) diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index 58f67c7..7b46bce 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -114,3 +114,7 @@ export interface WorkspaceInvitation { expires_at?: string | null; accepted_at?: string | null; } + +export interface WorkspaceInvitationListResponse { + items: WorkspaceInvitation[]; +} diff --git a/apps/frontend/src/pages/dashboard/WorkspaceSettingsPage.tsx b/apps/frontend/src/pages/dashboard/WorkspaceSettingsPage.tsx index f6d1749..f3737ab 100644 --- a/apps/frontend/src/pages/dashboard/WorkspaceSettingsPage.tsx +++ b/apps/frontend/src/pages/dashboard/WorkspaceSettingsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate, useOutletContext } from "react-router-dom"; import { Area, @@ -20,6 +20,7 @@ import { type Board, type BoardListResponse, type WorkspaceInvitation, + type WorkspaceInvitationListResponse, type WorkspaceMember, } from "@/lib/api"; import { useAuthStore } from "@/stores/authStore"; @@ -51,8 +52,10 @@ export function WorkspaceSettingsPage() { const [inviteError, setInviteError] = useState(null); const [inviteInfo, setInviteInfo] = useState(null); const [savingInvite, setSavingInvite] = useState(false); - const [copiedInviteLink, setCopiedInviteLink] = useState(false); - const [latestInviteUrl, setLatestInviteUrl] = useState(null); + const [copiedInviteId, setCopiedInviteId] = useState(null); + const [invitations, setInvitations] = useState([]); + const [invitationsLoading, setInvitationsLoading] = useState(false); + const [invitationsError, setInvitationsError] = useState(null); const [workspaceNameDrafts, setWorkspaceNameDrafts] = useState< Record @@ -125,14 +128,43 @@ export function WorkspaceSettingsPage() { }); }; + const loadInvitations = useCallback( + async (workspaceId: string) => { + if (!isOwner) { + setInvitations([]); + setInvitationsError(null); + return; + } + setInvitationsLoading(true); + setInvitationsError(null); + await apiFetch(`/api/workspaces/${workspaceId}/invitations`) + .then(async (res) => { + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(formatApiError(data.detail, "Failed to load pending invitations")); + } + return res.json() as Promise; + }) + .then((data) => setInvitations(data.items ?? [])) + .catch((err: unknown) => { + setInvitationsError( + err instanceof Error ? err.message : "Failed to load pending invitations", + ); + }) + .finally(() => setInvitationsLoading(false)); + }, + [isOwner], + ); + useEffect(() => { if (!selectedWorkspaceId) return; const timer = window.setTimeout(() => { void loadMembers(selectedWorkspaceId); void loadBoards(selectedWorkspaceId); + void loadInvitations(selectedWorkspaceId); }, 0); return () => window.clearTimeout(timer); - }, [selectedWorkspaceId]); + }, [selectedWorkspaceId, isOwner, loadInvitations]); const filteredMembers = useMemo(() => { const q = memberSearch.trim().toLowerCase(); @@ -212,8 +244,8 @@ export function WorkspaceSettingsPage() { } return ( -
-
+
+

{t("dashboard.workspaceSettings")}

@@ -222,12 +254,12 @@ export function WorkspaceSettingsPage() {

-
-