feat: enhance invite page with loading state and refactor access management logic
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user