feat(settings): add AccessSettings and room context updates
Add AccessSettings component for project access management, update room context with improved state management.
This commit is contained in:
parent
c308fc044d
commit
8702312c32
@ -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 (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="animate-spin" />
|
||||
Loading answers...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-lg bg-muted/30 p-3">
|
||||
{data.map((answer) => (
|
||||
<div key={`${answer.question}-${answer.created_at}`} className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">{answer.question}</span>
|
||||
<span className="text-sm">{answer.answer}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccessSettings() {
|
||||
const { projectName } = useParams<{ projectName: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const [inviteForm, setInviteForm] = useState({ email: "", scope: "Member" as MemberRole });
|
||||
const [requestRoles, setRequestRoles] = useState<Record<number, MemberRole>>({});
|
||||
const [rejectReasons, setRejectReasons] = useState<Record<number, string>>({});
|
||||
const [settingsDraft, setSettingsDraft] = useState<{
|
||||
require_approval: boolean;
|
||||
require_questions: boolean;
|
||||
questions: QuestionSchema[];
|
||||
} | null>(null);
|
||||
const [actionKey, setActionKey] = useState<ActionKey>(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<string | number | null>(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 (
|
||||
<div className="space-y-8">
|
||||
{msg && <div className="text-[13px]" style={{ color: msg.type === "success" ? "var(--success)" : "var(--destructive)" }}>{msg.text}</div>}
|
||||
<div className="flex flex-col gap-6">
|
||||
{message && (
|
||||
<Alert variant={message.type === "error" ? "destructive" : "default"}>
|
||||
<AlertDescription>{message.text}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Invite */}
|
||||
<section className="p-4 rounded-lg" style={{ backgroundColor: "var(--surface-elevated)", border: "1px solid var(--border-default)" }}>
|
||||
<h2 className="text-[14px] font-semibold mb-3" style={{ color: "var(--text-primary)" }}>Invite Member</h2>
|
||||
<div className="flex gap-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Invite Member</CardTitle>
|
||||
<CardDescription>Invite an existing user by email. The invitee can accept or reject from their invitations page.</CardDescription>
|
||||
<CardAction>
|
||||
<Button size="sm" variant="outline" onClick={handleCopyInviteLink}>
|
||||
<Copy data-icon="inline-start" />
|
||||
Copy invite link
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="invite-email">Email</FieldLabel>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
id="invite-email"
|
||||
value={inviteForm.email}
|
||||
onChange={e => setInviteForm(f => ({ ...f, email: e.target.value }))}
|
||||
onChange={(event) => setInviteForm((current) => ({ ...current, email: event.target.value }))}
|
||||
placeholder="user@example.com"
|
||||
className="text-[14px] flex-1"
|
||||
style={{ backgroundColor: "var(--surface-ground)", borderColor: "var(--border-default)", color: "var(--text-primary)" }}
|
||||
type="email"
|
||||
/>
|
||||
<select
|
||||
<Select
|
||||
value={inviteForm.scope}
|
||||
onChange={e => setInviteForm(f => ({ ...f, scope: e.target.value as MemberRole }))}
|
||||
className="text-[14px] px-3 rounded-md"
|
||||
style={{ backgroundColor: "var(--surface-ground)", border: "1px solid var(--border-default)", color: "var(--text-primary)" }}
|
||||
onValueChange={(value) => setInviteForm((current) => ({ ...current, scope: value as MemberRole }))}
|
||||
>
|
||||
<option value="Admin">Admin</option>
|
||||
<option value="Member">Member</option>
|
||||
</select>
|
||||
<Button size="sm" onClick={handleInvite} disabled={sending || !inviteForm.email.trim()} style={{ backgroundColor: "var(--accent)", color: "var(--accent-fg)" }}>
|
||||
{sending ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Mail className="w-3.5 h-3.5 mr-1" />}
|
||||
<SelectTrigger className="w-full sm:w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="Member">Member</SelectItem>
|
||||
<SelectItem value="Admin">Admin</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleInvite} disabled={isSettingsSaving || !inviteForm.email.trim()}>
|
||||
{isSettingsSaving ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Mail data-icon="inline-start" />}
|
||||
Invite
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<FieldDescription>Copied invitation links open the invite decision page for this project.</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pending Invitations */}
|
||||
<section className="p-4 rounded-lg" style={{ backgroundColor: "var(--surface-elevated)", border: "1px solid var(--border-default)" }}>
|
||||
<h2 className="text-[14px] font-semibold mb-3" style={{ color: "var(--text-primary)" }}>
|
||||
Pending Invitations {invData ? `(${invData.filter(i => !i.accepted && !i.rejected).length})` : ""}
|
||||
</h2>
|
||||
{invLoading ? <Loader2 className="w-4 h-4 animate-spin" /> :
|
||||
!invData?.length ? <p className="text-[13px]" style={{ color: "var(--text-muted)" }}>No pending invitations</p> :
|
||||
<div className="space-y-1">
|
||||
{invData.filter(i => !i.accepted && !i.rejected).map(inv => (
|
||||
<div key={inv.user_uid} className="flex items-center justify-between py-2 px-3 rounded" style={{ backgroundColor: "var(--surface-ground)" }}>
|
||||
<div className="flex items-center gap-2">
|
||||
{inv.scope === "Admin" ? <Shield className="w-4 h-4" style={{ color: "var(--role-orange)" }} /> : <User className="w-4 h-4" style={{ color: "var(--role-blue)" }} />}
|
||||
<span className="text-[13px]" style={{ color: "var(--text-primary)" }}>{inv.user_uid}</span>
|
||||
<span className="text-[11px] px-1.5 py-0.5 rounded" style={{ backgroundColor: "var(--hover-bg)", color: "var(--text-muted)" }}>{inv.scope}</span>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Join Settings</CardTitle>
|
||||
<CardDescription>Control whether users can apply to join and whether they must answer questions.</CardDescription>
|
||||
<CardAction>
|
||||
<Button size="sm" onClick={handleSaveSettings} disabled={!isSettingsDirty || isSettingsSaving}>
|
||||
{isSettingsSaving ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Check data-icon="inline-start" />}
|
||||
Save
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{settingsQuery.isLoading || !draft ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" className="h-7 text-[12px]" style={{ color: "var(--destructive)" }} onClick={() => handleCancelInvite(inv.user_uid)} disabled={actionLoading === inv.user_uid}>
|
||||
{actionLoading === inv.user_uid ? <Loader2 className="w-3 h-3 animate-spin" /> : <X className="w-3 h-3" />}
|
||||
) : (
|
||||
<FieldGroup>
|
||||
<Field orientation="horizontal">
|
||||
<Switch
|
||||
checked={draft.require_approval}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettingsDraft({ ...draft, require_approval: checked })
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<FieldLabel>Require admin approval</FieldLabel>
|
||||
<FieldDescription>Submitted join requests remain pending until an admin approves them.</FieldDescription>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field orientation="horizontal">
|
||||
<Switch
|
||||
checked={draft.require_questions}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettingsDraft({
|
||||
...draft,
|
||||
require_questions: checked,
|
||||
questions: checked && draft.questions.length === 0 ? [{ question: "" }] : draft.questions,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<FieldLabel>Require answers</FieldLabel>
|
||||
<FieldDescription>Ask applicants to answer project-specific questions.</FieldDescription>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{draft.require_questions && (
|
||||
<Field>
|
||||
<FieldLabel>Questions</FieldLabel>
|
||||
<div className="flex flex-col gap-2">
|
||||
{draft.questions.map((item, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
value={item.question}
|
||||
onChange={(event) => {
|
||||
const nextQuestions = [...draft.questions];
|
||||
nextQuestions[index] = { question: event.target.value };
|
||||
setSettingsDraft({ ...draft, questions: nextQuestions });
|
||||
}}
|
||||
placeholder="What do you want to ask?"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setSettingsDraft({
|
||||
...draft,
|
||||
questions: draft.questions.filter((_, questionIndex) => questionIndex !== index),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
{/* Join Settings */}
|
||||
<section className="p-4 rounded-lg" style={{ backgroundColor: "var(--surface-elevated)", border: "1px solid var(--border-default)" }}>
|
||||
<h2 className="text-[14px] font-semibold mb-3" style={{ color: "var(--text-primary)" }}>Join Settings</h2>
|
||||
{jsLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : joinSettings ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[14px] font-medium" style={{ color: "var(--text-primary)" }}>
|
||||
{joinSettings.require_approval ? "Approval required" : "Open access"}
|
||||
</p>
|
||||
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>
|
||||
{joinSettings.require_approval ? "New members must be approved by an admin" : "Anyone can join without approval"}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={handleToggleApproval} disabled={jsSaving}>
|
||||
{jsSaving ? <Loader2 className="w-3.5 h-3.5 mr-2 animate-spin" /> : joinSettings.require_approval ? <EyeOff className="w-3.5 h-3.5 mr-2" /> : <Check className="w-3.5 h-3.5 mr-2" />}
|
||||
{joinSettings.require_approval ? "Disable Approval" : "Require Approval"}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setSettingsDraft({ ...draft, questions: [...draft.questions, { question: "" }] })}
|
||||
>
|
||||
<Plus data-icon="inline-start" />
|
||||
Add question
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* Pending Join Requests */}
|
||||
{joinSettings?.require_approval && (
|
||||
<section className="p-4 rounded-lg" style={{ backgroundColor: "var(--surface-elevated)", border: "1px solid var(--border-default)" }}>
|
||||
<h2 className="text-[14px] font-semibold mb-3" style={{ color: "var(--text-primary)" }}>
|
||||
Pending Join Requests {joinReqs ? `(${joinReqs.length})` : ""}
|
||||
</h2>
|
||||
{!joinReqs?.length ? <p className="text-[13px]" style={{ color: "var(--text-muted)" }}>No pending requests</p> :
|
||||
<div className="space-y-1">
|
||||
{joinReqs.map(req => (
|
||||
<div key={req.id} className="flex items-center justify-between py-2 px-3 rounded" style={{ backgroundColor: "var(--surface-ground)" }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px]" style={{ color: "var(--text-primary)" }}>{req.username}</span>
|
||||
{req.message && <span className="text-[12px] truncate max-w-[200px]" style={{ color: "var(--text-muted)" }}>{req.message}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="ghost" className="h-7 text-[12px]" style={{ color: "var(--success)" }} onClick={() => handleProcessRequest(req.id, true)} disabled={actionLoading === req.id}>
|
||||
{actionLoading === req.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-7 text-[12px]" style={{ color: "var(--destructive)" }} onClick={() => handleProcessRequest(req.id, false)} disabled={actionLoading === req.id}>
|
||||
{actionLoading === req.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <X className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</Field>
|
||||
)}
|
||||
</FieldGroup>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Pending Invitations</CardTitle>
|
||||
<CardDescription>Invitations that have not been accepted or rejected.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{invitationsQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
) : pendingInvitations.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Mail />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No pending invitations</EmptyTitle>
|
||||
<EmptyDescription>Invitations you send will be listed here.</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
pendingInvitations.map((invitation) => {
|
||||
const loading = actionKey === `invite:${invitation.user_uid}`;
|
||||
return (
|
||||
<Card key={invitation.user_uid} size="sm" className="bg-muted/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{invitation.scope === "Admin" ? <Shield /> : <User />}
|
||||
<span className="font-mono text-sm">{invitation.user_uid}</span>
|
||||
<Badge variant="secondary">{invitation.scope}</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Invited by {invitation.invited_by_username ?? "Unknown"} on {formatDate(invitation.created_at)}
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleCancelInvite(invitation.user_uid)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <X data-icon="inline-start" />}
|
||||
Cancel
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Join Requests</CardTitle>
|
||||
<CardDescription>Approve or reject users who requested project access.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{requestsQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
) : pendingRequests.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>No pending requests</EmptyTitle>
|
||||
<EmptyDescription>User applications will appear here.</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
pendingRequests.map((request) => {
|
||||
const approveLoading = actionKey === `request:${request.id}:approve`;
|
||||
const rejectLoading = actionKey === `request:${request.id}:reject`;
|
||||
return (
|
||||
<Card key={request.id} size="sm" className="bg-muted/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{request.username}
|
||||
<Badge variant="secondary">pending</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Requested on {formatDate(request.created_at)}
|
||||
{request.message ? ` · ${request.message}` : ""}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{projectName && <JoinRequestAnswers projectName={projectName} requestId={request.id} />}
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end">
|
||||
<Field className="md:max-w-40">
|
||||
<FieldLabel>Approval role</FieldLabel>
|
||||
<Select
|
||||
value={requestRoles[request.id] ?? "Member"}
|
||||
onValueChange={(value) =>
|
||||
setRequestRoles((current) => ({ ...current, [request.id]: value as MemberRole }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="Member">Member</SelectItem>
|
||||
<SelectItem value="Admin">Admin</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field className="flex-1">
|
||||
<FieldLabel>Reject reason</FieldLabel>
|
||||
<Textarea
|
||||
value={rejectReasons[request.id] ?? ""}
|
||||
onChange={(event) =>
|
||||
setRejectReasons((current) => ({ ...current, [request.id]: event.target.value }))
|
||||
}
|
||||
rows={2}
|
||||
placeholder="Optional reason shown in request history."
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => handleProcessRequest(request, true)}
|
||||
disabled={approveLoading || rejectLoading}
|
||||
>
|
||||
{approveLoading ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <Check data-icon="inline-start" />}
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleProcessRequest(request, false)}
|
||||
disabled={approveLoading || rejectLoading}
|
||||
>
|
||||
{rejectLoading ? <Loader2 data-icon="inline-start" className="animate-spin" /> : <X data-icon="inline-start" />}
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<void>;
|
||||
removePin: (messageId: string) => Promise<void>;
|
||||
|
||||
/** Member state */
|
||||
updateDoNotDisturb: (dnd: boolean) => Promise<void>;
|
||||
@ -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) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user