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 */} -
-

Invite Member

-
- setInviteForm(f => ({ ...f, email: e.target.value }))} - placeholder="user@example.com" - className="text-[14px] flex-1" - style={{ backgroundColor: "var(--surface-ground)", borderColor: "var(--border-default)", color: "var(--text-primary)" }} - /> - - -
-
- - {/* 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} -
- -
- ))} -
- } -
- - {/* 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"} -

-
- -
- ) : 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}} -
-
- - -
-
- ))} -
- } -
+
+ {message && ( + + {message.text} + )} + + + + Invite Member + Invite an existing user by email. The invitee can accept or reject from their invitations page. + + + + + + + + Email +
+ setInviteForm((current) => ({ ...current, email: event.target.value }))} + placeholder="user@example.com" + type="email" + /> + + +
+ 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. + + + + + + {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?" + /> + +
+ ))} + +
+
+ )} +
+ )} +
+
+ + + + 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)} + + + + + + + ); + }) + )} +
+
+ + + + 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 + + + + Reject reason +