diff --git a/src/app/project/settings/AccessSettings.tsx b/src/app/project/settings/AccessSettings.tsx
index 0170942..1431dfa 100644
--- a/src/app/project/settings/AccessSettings.tsx
+++ b/src/app/project/settings/AccessSettings.tsx
@@ -1,192 +1,560 @@
-import { useState } from "react";
+import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { Check, Copy, Loader2, Mail, Plus, Shield, Trash2, User, X } from "lucide-react";
import {
- projectInvitations, projectInviteUser, projectCancelInvitation,
- projectJoinSettings, projectUpdateJoinSettings,
- projectJoinRequests, projectProcessJoinRequest,
+ projectCancelInvitation,
+ projectInvitations,
+ projectInviteUser,
+ projectJoinAnswers,
+ projectJoinRequests,
+ projectJoinSettings,
+ projectProcessJoinRequest,
+ projectUpdateJoinSettings,
} from "@/client/api";
-import type { InvitationResponse, JoinSettingsResponse, JoinRequestResponse, MemberRole, QuestionSchema } from "@/client/model";
+import type {
+ InvitationResponse,
+ JoinAnswerResponse,
+ JoinRequestResponse,
+ JoinSettingsResponse,
+ MemberRole,
+ QuestionSchema,
+} from "@/client/model";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Empty,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+} from "@/components/ui/empty";
+import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
-import { Loader2, Mail, X, Check, Shield, User, EyeOff } from "lucide-react";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import { Textarea } from "@/components/ui/textarea";
+
+type ActionKey = `invite:${string}` | `request:${number}:${"approve" | "reject"}` | "settings" | null;
+
+function parseQuestions(settings?: JoinSettingsResponse | null): QuestionSchema[] {
+ if (!settings || !Array.isArray(settings.questions)) return [];
+ return settings.questions
+ .map((item) => {
+ if (typeof item === "string") return { question: item };
+ if (item && typeof item === "object" && "question" in item) {
+ return { question: String((item as { question: unknown }).question ?? "") };
+ }
+ return { question: "" };
+ })
+ .filter((item) => item.question.trim().length > 0);
+}
+
+function formatDate(value?: string | null) {
+ if (!value) return "Unknown";
+ return new Intl.DateTimeFormat(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(new Date(value));
+}
+
+function getInvitationUrl(projectName?: string) {
+ if (!projectName) return "";
+ return `${window.location.origin}/projects/${projectName}/invitations`;
+}
+
+function JoinRequestAnswers({ projectName, requestId }: { projectName: string; requestId: number }) {
+ const { data = [], isLoading } = useQuery({
+ queryKey: ["project-join-answers", projectName, requestId],
+ queryFn: async () => {
+ const res = await projectJoinAnswers(projectName, requestId);
+ return (res.data?.data?.answers ?? []) as JoinAnswerResponse[];
+ },
+ staleTime: 30_000,
+ });
+
+ if (isLoading) {
+ return (
+
+
+ Loading answers...
+
+ );
+ }
+
+ if (data.length === 0) return null;
+
+ return (
+
+ {data.map((answer) => (
+
+ {answer.question}
+ {answer.answer}
+
+ ))}
+
+ );
+}
export function AccessSettings() {
const { projectName } = useParams<{ projectName: string }>();
const queryClient = useQueryClient();
+ const [inviteForm, setInviteForm] = useState({ email: "", scope: "Member" as MemberRole });
+ const [requestRoles, setRequestRoles] = useState>({});
+ const [rejectReasons, setRejectReasons] = useState>({});
+ const [settingsDraft, setSettingsDraft] = useState<{
+ require_approval: boolean;
+ require_questions: boolean;
+ questions: QuestionSchema[];
+ } | null>(null);
+ const [actionKey, setActionKey] = useState(null);
+ const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
- // Invitations
- const { data: invData, isLoading: invLoading } = useQuery({
+ const invitationsQuery = useQuery({
queryKey: ["project-invitations", projectName],
queryFn: async () => {
- const res = await projectInvitations(projectName!, {});
+ const res = await projectInvitations(projectName!, { page: 1, per_page: 50 });
return (res.data?.data?.invitations ?? []) as InvitationResponse[];
},
- enabled: !!projectName, staleTime: 30_000,
+ enabled: !!projectName,
+ staleTime: 30_000,
});
- // Join settings
- const { data: joinSettings, isLoading: jsLoading } = useQuery({
+ const settingsQuery = useQuery({
queryKey: ["project-join-settings", projectName],
queryFn: async () => {
const res = await projectJoinSettings(projectName!);
return res.data?.data as JoinSettingsResponse;
},
- enabled: !!projectName, staleTime: 30_000,
+ enabled: !!projectName,
+ staleTime: 30_000,
});
- // Join requests
- const { data: joinReqs } = useQuery({
+ const requestsQuery = useQuery({
queryKey: ["project-join-requests", projectName],
queryFn: async () => {
- const res = await projectJoinRequests(projectName!, { status: "pending" });
+ const res = await projectJoinRequests(projectName!, { status: "pending", page: 1, per_page: 50 });
return (res.data?.data?.requests ?? []) as JoinRequestResponse[];
},
- enabled: !!projectName, staleTime: 30_000,
+ enabled: !!projectName,
+ staleTime: 30_000,
});
- const [inviteForm, setInviteForm] = useState({ email: "", scope: "Member" as MemberRole });
- const [sending, setSending] = useState(false);
- const [actionLoading, setActionLoading] = useState(null);
- const [jsSaving, setJsSaving] = useState(false);
- const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
+ const currentSettings = useMemo(() => {
+ if (!settingsQuery.data) return null;
+ return {
+ require_approval: settingsQuery.data.require_approval,
+ require_questions: settingsQuery.data.require_questions,
+ questions: parseQuestions(settingsQuery.data),
+ };
+ }, [settingsQuery.data]);
+
+ const draft = settingsDraft ?? currentSettings;
+ const pendingInvitations = (invitationsQuery.data ?? []).filter((item) => !item.accepted && !item.rejected);
+ const pendingRequests = requestsQuery.data ?? [];
const invalidateAll = () => {
queryClient.invalidateQueries({ queryKey: ["project-invitations", projectName] });
queryClient.invalidateQueries({ queryKey: ["project-join-settings", projectName] });
queryClient.invalidateQueries({ queryKey: ["project-join-requests", projectName] });
+ queryClient.invalidateQueries({ queryKey: ["project-members-grouped", projectName] });
};
const handleInvite = async () => {
- if (!inviteForm.email.trim()) return;
- try { setSending(true); setMsg(null); await projectInviteUser(projectName!, { email: inviteForm.email.trim(), scope: inviteForm.scope }); setMsg({ type: "success", text: "Invitation sent" }); setInviteForm({ email: "", scope: "Member" }); invalidateAll(); }
- catch { setMsg({ type: "error", text: "Failed to send invitation" }); }
- finally { setSending(false); }
+ if (!projectName || !inviteForm.email.trim()) return;
+ setActionKey("settings");
+ setMessage(null);
+ try {
+ await projectInviteUser(projectName, {
+ email: inviteForm.email.trim(),
+ scope: inviteForm.scope,
+ });
+ setInviteForm({ email: "", scope: "Member" });
+ setMessage({ type: "success", text: "Invitation sent." });
+ invalidateAll();
+ } catch {
+ setMessage({ type: "error", text: "Failed to send invitation." });
+ } finally {
+ setActionKey(null);
+ }
};
const handleCancelInvite = async (userId: string) => {
- try { setActionLoading(userId); await projectCancelInvitation(projectName!, userId); invalidateAll(); }
- catch { setMsg({ type: "error", text: "Failed to cancel invitation" }); }
- finally { setActionLoading(null); }
+ if (!projectName) return;
+ setActionKey(`invite:${userId}`);
+ setMessage(null);
+ try {
+ await projectCancelInvitation(projectName, userId);
+ setMessage({ type: "success", text: "Invitation cancelled." });
+ invalidateAll();
+ } catch {
+ setMessage({ type: "error", text: "Failed to cancel invitation." });
+ } finally {
+ setActionKey(null);
+ }
};
- const handleProcessRequest = async (reqId: number, approve: boolean) => {
- try { setActionLoading(reqId); await projectProcessJoinRequest(projectName!, reqId, { approve, scope: "Member", reject_reason: approve ? null : "Rejected" }); setMsg({ type: "success", text: approve ? "Request approved" : "Request rejected" }); invalidateAll(); }
- catch { setMsg({ type: "error", text: "Failed to process request" }); }
- finally { setActionLoading(null); }
+ const handleProcessRequest = async (request: JoinRequestResponse, approve: boolean) => {
+ if (!projectName) return;
+ setActionKey(`request:${request.id}:${approve ? "approve" : "reject"}`);
+ setMessage(null);
+ try {
+ await projectProcessJoinRequest(projectName, request.id, {
+ approve,
+ scope: requestRoles[request.id] ?? "Member",
+ reject_reason: approve ? null : rejectReasons[request.id]?.trim() || "Rejected",
+ });
+ setMessage({ type: "success", text: approve ? "Join request approved." : "Join request rejected." });
+ invalidateAll();
+ } catch {
+ setMessage({ type: "error", text: "Failed to process join request." });
+ } finally {
+ setActionKey(null);
+ }
};
- const handleToggleApproval = async () => {
- if (!joinSettings) return;
- try { setJsSaving(true); await projectUpdateJoinSettings(projectName!, { require_approval: !joinSettings.require_approval, require_questions: joinSettings.require_questions, questions: (joinSettings.questions as QuestionSchema[]) || [] }); setMsg({ type: "success", text: "Join settings updated" }); invalidateAll(); }
- catch { setMsg({ type: "error", text: "Failed to update join settings" }); }
- finally { setJsSaving(false); }
+ const handleSaveSettings = async () => {
+ if (!projectName || !draft) return;
+ const questions = draft.require_questions
+ ? draft.questions.map((item) => ({ question: item.question.trim() })).filter((item) => item.question)
+ : [];
+ setActionKey("settings");
+ setMessage(null);
+ try {
+ await projectUpdateJoinSettings(projectName, {
+ require_approval: draft.require_approval,
+ require_questions: draft.require_questions,
+ questions,
+ });
+ setSettingsDraft(null);
+ setMessage({ type: "success", text: "Join settings saved." });
+ invalidateAll();
+ } catch {
+ setMessage({ type: "error", text: "Failed to save join settings." });
+ } finally {
+ setActionKey(null);
+ }
};
+ const handleCopyInviteLink = async () => {
+ if (!projectName) return;
+ await navigator.clipboard.writeText(getInvitationUrl(projectName));
+ setMessage({ type: "success", text: "Invitation link copied." });
+ };
+
+ const isSettingsDirty = settingsDraft !== null;
+ const isSettingsSaving = actionKey === "settings";
+
return (
-
- {msg &&
{msg.text}
}
-
- {/* Invite */}
-
-
- {/* Pending Invitations */}
-
-
- Pending Invitations {invData ? `(${invData.filter(i => !i.accepted && !i.rejected).length})` : ""}
-
- {invLoading ? :
- !invData?.length ? No pending invitations
:
-
- {invData.filter(i => !i.accepted && !i.rejected).map(inv => (
-
-
- {inv.scope === "Admin" ? : }
- {inv.user_uid}
- {inv.scope}
-
-
handleCancelInvite(inv.user_uid)} disabled={actionLoading === inv.user_uid}>
- {actionLoading === inv.user_uid ? : }
-
-
- ))}
-
- }
-
-
- {/* Join Settings */}
-
- Join Settings
- {jsLoading ? : joinSettings ? (
-
-
-
- {joinSettings.require_approval ? "Approval required" : "Open access"}
-
-
- {joinSettings.require_approval ? "New members must be approved by an admin" : "Anyone can join without approval"}
-
-
-
- {jsSaving ? : joinSettings.require_approval ? : }
- {joinSettings.require_approval ? "Disable Approval" : "Require Approval"}
-
-
- ) : null}
-
-
- {/* Pending Join Requests */}
- {joinSettings?.require_approval && (
-
-
- Pending Join Requests {joinReqs ? `(${joinReqs.length})` : ""}
-
- {!joinReqs?.length ? No pending requests
:
-
- {joinReqs.map(req => (
-
-
- {req.username}
- {req.message && {req.message} }
-
-
- handleProcessRequest(req.id, true)} disabled={actionLoading === req.id}>
- {actionLoading === req.id ? : }
-
- handleProcessRequest(req.id, false)} disabled={actionLoading === req.id}>
- {actionLoading === req.id ? : }
-
-
-
- ))}
-
- }
-
+
+ {message && (
+
+ {message.text}
+
)}
+
+
+
+ Invite Member
+ Invite an existing user by email. The invitee can accept or reject from their invitations page.
+
+
+
+ Copy invite link
+
+
+
+
+
+
+ Email
+
+ setInviteForm((current) => ({ ...current, email: event.target.value }))}
+ placeholder="user@example.com"
+ type="email"
+ />
+ setInviteForm((current) => ({ ...current, scope: value as MemberRole }))}
+ >
+
+
+
+
+
+ Member
+ Admin
+
+
+
+
+ {isSettingsSaving ? : }
+ Invite
+
+
+ Copied invitation links open the invite decision page for this project.
+
+
+
+
+
+
+
+ Join Settings
+ Control whether users can apply to join and whether they must answer questions.
+
+
+ {isSettingsSaving ? : }
+ Save
+
+
+
+
+ {settingsQuery.isLoading || !draft ? (
+
+
+
+ ) : (
+
+
+
+ setSettingsDraft({ ...draft, require_approval: checked })
+ }
+ />
+
+ Require admin approval
+ Submitted join requests remain pending until an admin approves them.
+
+
+
+
+
+ setSettingsDraft({
+ ...draft,
+ require_questions: checked,
+ questions: checked && draft.questions.length === 0 ? [{ question: "" }] : draft.questions,
+ })
+ }
+ />
+
+ Require answers
+ Ask applicants to answer project-specific questions.
+
+
+
+ {draft.require_questions && (
+
+ Questions
+
+ {draft.questions.map((item, index) => (
+
+ {
+ const nextQuestions = [...draft.questions];
+ nextQuestions[index] = { question: event.target.value };
+ setSettingsDraft({ ...draft, questions: nextQuestions });
+ }}
+ placeholder="What do you want to ask?"
+ />
+
+ setSettingsDraft({
+ ...draft,
+ questions: draft.questions.filter((_, questionIndex) => questionIndex !== index),
+ })
+ }
+ >
+
+
+
+ ))}
+
setSettingsDraft({ ...draft, questions: [...draft.questions, { question: "" }] })}
+ >
+
+ Add question
+
+
+
+ )}
+
+ )}
+
+
+
+
+
+ Pending Invitations
+ Invitations that have not been accepted or rejected.
+
+
+ {invitationsQuery.isLoading ? (
+
+
+
+ ) : pendingInvitations.length === 0 ? (
+
+
+
+
+
+ No pending invitations
+ Invitations you send will be listed here.
+
+
+ ) : (
+ pendingInvitations.map((invitation) => {
+ const loading = actionKey === `invite:${invitation.user_uid}`;
+ return (
+
+
+
+ {invitation.scope === "Admin" ? : }
+ {invitation.user_uid}
+ {invitation.scope}
+
+
+ Invited by {invitation.invited_by_username ?? "Unknown"} on {formatDate(invitation.created_at)}
+
+
+ handleCancelInvite(invitation.user_uid)}
+ disabled={loading}
+ >
+ {loading ? : }
+ Cancel
+
+
+
+
+ );
+ })
+ )}
+
+
+
+
+
+ Join Requests
+ Approve or reject users who requested project access.
+
+
+ {requestsQuery.isLoading ? (
+
+
+
+ ) : pendingRequests.length === 0 ? (
+
+
+ No pending requests
+ User applications will appear here.
+
+
+ ) : (
+ pendingRequests.map((request) => {
+ const approveLoading = actionKey === `request:${request.id}:approve`;
+ const rejectLoading = actionKey === `request:${request.id}:reject`;
+ return (
+
+
+
+ {request.username}
+ pending
+
+
+ Requested on {formatDate(request.created_at)}
+ {request.message ? ` ยท ${request.message}` : ""}
+
+
+
+ {projectName && }
+
+
+ Approval role
+
+ setRequestRoles((current) => ({ ...current, [request.id]: value as MemberRole }))
+ }
+ >
+
+
+
+
+
+ Member
+ Admin
+
+
+
+
+
+ Reject reason
+
+
+ handleProcessRequest(request, true)}
+ disabled={approveLoading || rejectLoading}
+ >
+ {approveLoading ? : }
+ Approve
+
+ handleProcessRequest(request, false)}
+ disabled={approveLoading || rejectLoading}
+ >
+ {rejectLoading ? : }
+ Reject
+
+
+
+
+
+ );
+ })
+ )}
+
+
);
}
diff --git a/src/contexts/room/room-context.tsx b/src/contexts/room/room-context.tsx
index bd7cc7c..4cb9a0b 100644
--- a/src/contexts/room/room-context.tsx
+++ b/src/contexts/room/room-context.tsx
@@ -10,7 +10,7 @@ import {
} from 'react';
import { useNavigate } from 'react-router-dom';
import { useWsEvent, useWsStatus, getWsClient, useRoomSubscription } from '@/ws';
-import { roomGet, participantList, pinList, threadList } from '@/client/api';
+import { roomGet, participantList, pinAdd, pinRemove, pinList, threadList } from '@/client/api';
import type { AxiosResponse } from 'axios';
import type {
ApiResponseRoomResponse,
@@ -74,8 +74,8 @@ export interface RoomContextValue {
deleteAi: (agentId: string) => void;
/** Pin management */
- addPin: (messageId: string) => void;
- removePin: (messageId: string) => void;
+ addPin: (messageId: string) => Promise
;
+ removePin: (messageId: string) => Promise;
/** Member state */
updateDoNotDisturb: (dnd: boolean) => Promise;
@@ -199,14 +199,18 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
if (client && roomId) client.deleteAi(roomId, agentId);
}, [roomId]);
- const addPin = useCallback((messageId: string) => {
- const client = safeGetClient();
- if (client && roomId) client.emitRaw('pin_add', { room: roomId, message: messageId });
+ const addPin = useCallback(async (messageId: string) => {
+ if (!roomId) return;
+ const res = await pinAdd(roomId, messageId);
+ const pin = res.data?.data;
+ if (!pin) return;
+ setPinnedMessages((prev) => (prev.some((p) => p.message === pin.message) ? prev : [...prev, pin]));
}, [roomId]);
- const removePin = useCallback((messageId: string) => {
- const client = safeGetClient();
- if (client && roomId) client.emitRaw('pin_remove', { room: roomId, message: messageId });
+ const removePin = useCallback(async (messageId: string) => {
+ if (!roomId) return;
+ await pinRemove(roomId, messageId);
+ setPinnedMessages((prev) => prev.filter((p) => p.message !== messageId));
}, [roomId]);
const updateDoNotDisturb = useCallback(async (dnd: boolean) => {
@@ -467,7 +471,11 @@ export function RoomProvider({ roomId, projectName, children }: RoomProviderProp
useWsEvent('pin_added', (event) => {
if (event.room_id !== roomId) return;
const { room, message, pinned_by, pinned_at } = event.data;
- setPinnedMessages((prev: PinnedMessage[]) => [...prev, { room, message, pinned_by, pinned_at }]);
+ setPinnedMessages((prev: PinnedMessage[]) => (
+ prev.some((p) => p.message === message)
+ ? prev
+ : [...prev, { room, message, pinned_by, pinned_at }]
+ ));
});
useWsEvent('pin_removed', (event) => {