feat: enhance invite page with loading state and refactor access management logic

This commit is contained in:
Abhimanyu Saharan
2026-02-08 21:27:19 +05:30
parent 289452b341
commit 8c12add7d7
3 changed files with 113 additions and 51 deletions

View File

@@ -2,7 +2,7 @@
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
import { useEffect, useMemo, useState } from "react"; import { Suspense, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk"; import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
@@ -13,7 +13,7 @@ import { BrandMark } from "@/components/atoms/BrandMark";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
export default function InvitePage() { function InviteContent() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { isSignedIn } = useAuth(); const { isSignedIn } = useAuth();
@@ -146,3 +146,26 @@ export default function InvitePage() {
</div> </div>
); );
} }
export default function InvitePage() {
return (
<Suspense
fallback={
<div className="min-h-screen bg-app text-strong">
<header className="border-b border-[color:var(--border)] bg-white">
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
<BrandMark />
</div>
</header>
<main className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-16">
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-8 shadow-sm">
<div className="text-sm text-muted">Loading invite</div>
</div>
</main>
</div>
}
>
<InviteContent />
</Suspense>
);
}

View File

@@ -2,7 +2,7 @@
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
import { useEffect, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk"; import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
@@ -328,12 +328,11 @@ export default function OrganizationPage() {
const [accessDialogOpen, setAccessDialogOpen] = useState(false); const [accessDialogOpen, setAccessDialogOpen] = useState(false);
const [activeMemberId, setActiveMemberId] = useState<string | null>(null); const [activeMemberId, setActiveMemberId] = useState<string | null>(null);
const [accessScope, setAccessScope] = useState<AccessScope>("all"); const [accessScope, setAccessScope] = useState<AccessScope | null>(null);
const [accessAllRead, setAccessAllRead] = useState(false); const [accessAllRead, setAccessAllRead] = useState<boolean | null>(null);
const [accessAllWrite, setAccessAllWrite] = useState(false); const [accessAllWrite, setAccessAllWrite] = useState<boolean | null>(null);
const [accessRole, setAccessRole] = useState("member"); const [accessRole, setAccessRole] = useState<string | null>(null);
const [accessMap, setAccessMap] = const [accessMap, setAccessMap] = useState<BoardAccessState | null>(null);
useState<BoardAccessState>(defaultBoardAccess);
const [accessError, setAccessError] = useState<string | null>(null); const [accessError, setAccessError] = useState<string | null>(null);
const orgQuery = useGetMyOrgApiV1OrganizationsMeGet< const orgQuery = useGetMyOrgApiV1OrganizationsMeGet<
@@ -426,6 +425,45 @@ export default function OrganizationPage() {
}, },
}); });
const memberDetails =
memberDetailsQuery.data?.status === 200
? memberDetailsQuery.data.data
: null;
const defaultAccess = useMemo(() => {
if (!memberDetails) {
return {
role: "member",
scope: "all" as AccessScope,
allRead: false,
allWrite: false,
access: {},
};
}
const isAll =
memberDetails.all_boards_read || memberDetails.all_boards_write;
const nextAccess: BoardAccessState = {};
for (const entry of memberDetails.board_access ?? []) {
nextAccess[entry.board_id] = {
read: entry.can_read || entry.can_write,
write: entry.can_write,
};
}
return {
role: memberDetails.role,
scope: isAll ? "all" : ("custom" as AccessScope),
allRead: memberDetails.all_boards_read,
allWrite: memberDetails.all_boards_write,
access: nextAccess,
};
}, [memberDetails]);
const resolvedAccessRole = accessRole ?? defaultAccess.role;
const resolvedAccessScope = accessScope ?? defaultAccess.scope;
const resolvedAccessAllRead = accessAllRead ?? defaultAccess.allRead;
const resolvedAccessAllWrite = accessAllWrite ?? defaultAccess.allWrite;
const resolvedAccessMap = accessMap ?? defaultAccess.access;
const createInviteMutation = const createInviteMutation =
useCreateOrgInviteApiV1OrganizationsMeInvitesPost<ApiError>({ useCreateOrgInviteApiV1OrganizationsMeInvitesPost<ApiError>({
mutation: { mutation: {
@@ -505,37 +543,31 @@ export default function OrganizationPage() {
}, },
}); });
useEffect(() => { const resetAccessState = () => {
if (memberDetailsQuery.data?.status !== 200) return; setAccessRole(null);
const member = memberDetailsQuery.data.data; setAccessScope(null);
setAccessRole(member.role); setAccessAllRead(null);
const isAll = member.all_boards_read || member.all_boards_write; setAccessAllWrite(null);
setAccessScope(isAll ? "all" : "custom"); setAccessMap(null);
setAccessAllRead(member.all_boards_read);
setAccessAllWrite(member.all_boards_write);
const nextAccess: BoardAccessState = {};
for (const entry of member.board_access ?? []) {
nextAccess[entry.board_id] = {
read: entry.can_read || entry.can_write,
write: entry.can_write,
};
}
setAccessMap(nextAccess);
setAccessError(null); setAccessError(null);
}, [memberDetailsQuery.data]); };
useEffect(() => { const handleAccessDialogChange = (open: boolean) => {
if (!accessDialogOpen) { setAccessDialogOpen(open);
if (!open) {
setActiveMemberId(null); setActiveMemberId(null);
setAccessError(null); setAccessError(null);
return;
} }
}, [accessDialogOpen]); resetAccessState();
};
useEffect(() => { const handleInviteDialogChange = (open: boolean) => {
if (!inviteDialogOpen) { setInviteDialogOpen(open);
if (!open) {
setInviteError(null); setInviteError(null);
} }
}, [inviteDialogOpen]); };
const orgName = const orgName =
orgQuery.data?.status === 200 ? orgQuery.data.data.name : "Organization"; orgQuery.data?.status === 200 ? orgQuery.data.data.name : "Organization";
@@ -620,15 +652,18 @@ export default function OrganizationPage() {
const openAccessDialog = (memberId: string) => { const openAccessDialog = (memberId: string) => {
setActiveMemberId(memberId); setActiveMemberId(memberId);
setAccessDialogOpen(true); setAccessDialogOpen(true);
resetAccessState();
}; };
const handleSaveAccess = async () => { const handleSaveAccess = async () => {
if (!activeMemberId || !isAdmin) return; if (!activeMemberId || !isAdmin) return;
const hasAllAccess = const hasAllAccess =
accessScope === "all" && (accessAllRead || accessAllWrite); resolvedAccessScope === "all" &&
const accessList = buildAccessList(accessMap); (resolvedAccessAllRead || resolvedAccessAllWrite);
const hasCustomAccess = accessScope === "custom" && accessList.length > 0; const accessList = buildAccessList(resolvedAccessMap);
const hasCustomAccess =
resolvedAccessScope === "custom" && accessList.length > 0;
if (!hasAllAccess && !hasCustomAccess) { if (!hasAllAccess && !hasCustomAccess) {
setAccessError("Select read or write access for at least one board."); setAccessError("Select read or write access for at least one board.");
@@ -638,12 +673,11 @@ export default function OrganizationPage() {
setAccessError(null); setAccessError(null);
try { try {
if (memberDetailsQuery.data?.status === 200) { if (memberDetails) {
const member = memberDetailsQuery.data.data; if (memberDetails.role !== resolvedAccessRole) {
if (member.role !== accessRole) {
await updateMemberRoleMutation.mutateAsync({ await updateMemberRoleMutation.mutateAsync({
memberId: member.id, memberId: memberDetails.id,
data: { role: accessRole }, data: { role: resolvedAccessRole },
}); });
} }
} }
@@ -651,9 +685,11 @@ export default function OrganizationPage() {
await updateMemberAccessMutation.mutateAsync({ await updateMemberAccessMutation.mutateAsync({
memberId: activeMemberId, memberId: activeMemberId,
data: { data: {
all_boards_read: accessScope === "all" ? accessAllRead : false, all_boards_read:
all_boards_write: accessScope === "all" ? accessAllWrite : false, resolvedAccessScope === "all" ? resolvedAccessAllRead : false,
board_access: accessScope === "custom" ? accessList : [], all_boards_write:
resolvedAccessScope === "all" ? resolvedAccessAllWrite : false,
board_access: resolvedAccessScope === "custom" ? accessList : [],
}, },
}); });
@@ -953,7 +989,7 @@ export default function OrganizationPage() {
</main> </main>
</SignedIn> </SignedIn>
<Dialog open={inviteDialogOpen} onOpenChange={setInviteDialogOpen}> <Dialog open={inviteDialogOpen} onOpenChange={handleInviteDialogChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Invite a member</DialogTitle> <DialogTitle>Invite a member</DialogTitle>
@@ -1037,7 +1073,7 @@ export default function OrganizationPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={accessDialogOpen} onOpenChange={setAccessDialogOpen}> <Dialog open={accessDialogOpen} onOpenChange={handleAccessDialogChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Manage member access</DialogTitle> <DialogTitle>Manage member access</DialogTitle>
@@ -1069,7 +1105,10 @@ export default function OrganizationPage() {
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500"> <label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Role Role
</label> </label>
<Select value={accessRole} onValueChange={setAccessRole}> <Select
value={resolvedAccessRole}
onValueChange={setAccessRole}
>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select role" /> <SelectValue placeholder="Select role" />
</SelectTrigger> </SelectTrigger>
@@ -1083,13 +1122,13 @@ export default function OrganizationPage() {
<BoardAccessEditor <BoardAccessEditor
boards={boards} boards={boards}
scope={accessScope} scope={resolvedAccessScope}
onScopeChange={setAccessScope} onScopeChange={setAccessScope}
allRead={accessAllRead} allRead={resolvedAccessAllRead}
allWrite={accessAllWrite} allWrite={resolvedAccessAllWrite}
onAllReadChange={setAccessAllRead} onAllReadChange={setAccessAllRead}
onAllWriteChange={setAccessAllWrite} onAllWriteChange={setAccessAllWrite}
access={accessMap} access={resolvedAccessMap}
onAccessChange={setAccessMap} onAccessChange={setAccessMap}
emptyMessage={ emptyMessage={
boardsQuery.isLoading ? "Loading boards..." : undefined boardsQuery.isLoading ? "Loading boards..." : undefined

View File

@@ -38,7 +38,7 @@ function BoardChatComposerImpl({
if (ok) { if (ok) {
setValue(""); setValue("");
} }
}, [isSending, onSend, value]); }, [disabled, isSending, onSend, value]);
return ( return (
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">