Let leaving admins choose who takes over the group (task #46)
Original ask: when the only admin leaves a group, let them pick a
specific successor instead of always auto-promoting the longest-tenured
member. Keep "auto" available, with bilingual labels.
Changes:
- lib/api-spec/openapi.yaml: extend POST /conversations/{id}/leave with
an optional JSON body { successorId?: number | null }.
- Regenerated lib/api-client-react and lib/api-zod via api-spec codegen.
- artifacts/api-server/src/routes/conversations.ts: parse the new body,
and when the leaver is the only admin, promote the requested successor
if one is provided and is a current member; otherwise fall back to the
existing oldest-member auto-promotion. Returns 400 if successorId is
not a remaining member.
- artifacts/teaboy-os/src/pages/chat.tsx:
- Track a successorChoice state ("auto" | userId).
- In the leave confirmation dialog, when the user is the sole admin
and there are other members, render a radio-list chooser with an
"Auto" option (default) plus each remaining member.
- Pass { successorId } to the leave mutation when a specific member
is chosen; pass {} for auto.
- Reset choice on success.
- artifacts/teaboy-os/src/locales/{en,ar}.json: added successorTitle,
successorHelp, successorAuto strings.
Verification:
- pnpm -w run typecheck passes for libs and all artifacts.
- Attempted an end-to-end browser test; the run was interrupted before
completion. Backend logic and UI wiring were validated by reading
through the code paths and type system.
Replit-Task-Id: c9065bc1-ab4e-4a3b-a865-81754f5c2e5a
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
RemoveConversationParticipantParams,
|
||||
UpdateConversationStateParams,
|
||||
UpdateConversationStateBody,
|
||||
LeaveConversationBody,
|
||||
LeaveConversationParams,
|
||||
} from "@workspace/api-zod";
|
||||
|
||||
@@ -631,6 +632,12 @@ router.post(
|
||||
res.status(400).json({ error: params.error.message });
|
||||
return;
|
||||
}
|
||||
const body = LeaveConversationBody.safeParse(req.body ?? {});
|
||||
if (!body.success) {
|
||||
res.status(400).json({ error: body.error.message });
|
||||
return;
|
||||
}
|
||||
const requestedSuccessorId = body.data.successorId ?? null;
|
||||
const userId = req.session.userId!;
|
||||
|
||||
const [conv] = await db
|
||||
@@ -677,7 +684,23 @@ router.post(
|
||||
// Determine if we need to promote a successor admin.
|
||||
const leaverWasOnlyAdmin =
|
||||
leaver.isAdmin && !remaining.some((p) => p.isAdmin);
|
||||
const successor = leaverWasOnlyAdmin ? remaining[0] : null;
|
||||
let successor: (typeof remaining)[number] | null = null;
|
||||
if (leaverWasOnlyAdmin) {
|
||||
if (requestedSuccessorId !== null) {
|
||||
const chosen = remaining.find(
|
||||
(p) => p.userId === requestedSuccessorId,
|
||||
);
|
||||
if (!chosen) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "Chosen successor is not a member of this group" });
|
||||
return;
|
||||
}
|
||||
successor = chosen;
|
||||
} else {
|
||||
successor = remaining[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (successor) {
|
||||
await db
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -174,6 +174,9 @@
|
||||
"leave": "مغادرة المجموعة",
|
||||
"leaveConfirmTitle": "مغادرة هذه المجموعة؟",
|
||||
"leaveConfirmBody": "ستتم إزالتك من \"{{name}}\" ولن تتلقى رسائلها بعد الآن.",
|
||||
"successorTitle": "اختر المشرف التالي",
|
||||
"successorHelp": "أنت المشرف الوحيد. اختر من يتولى الإدارة، أو دعنا نرقّي أقدم عضو في المجموعة.",
|
||||
"successorAuto": "تلقائي (أقدم عضو)",
|
||||
"muted": "تم كتم الإشعارات",
|
||||
"unmuted": "الإشعارات مفعّلة",
|
||||
"archived": "تمت أرشفة المحادثة",
|
||||
|
||||
@@ -171,6 +171,9 @@
|
||||
"leave": "Leave group",
|
||||
"leaveConfirmTitle": "Leave this group?",
|
||||
"leaveConfirmBody": "You will be removed from \"{{name}}\" and stop receiving its messages.",
|
||||
"successorTitle": "Choose the next admin",
|
||||
"successorHelp": "You're the only admin. Pick who should take over, or let us promote the longest-tenured member.",
|
||||
"successorAuto": "Auto (longest-tenured member)",
|
||||
"muted": "Notifications muted",
|
||||
"unmuted": "Notifications on",
|
||||
"archived": "Chat archived",
|
||||
|
||||
@@ -156,6 +156,7 @@ export default function ChatPage() {
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [confirmLeave, setConfirmLeave] = useState(false);
|
||||
const [successorChoice, setSuccessorChoice] = useState<number | "auto">("auto");
|
||||
const [editNameAr, setEditNameAr] = useState("");
|
||||
const [editNameEn, setEditNameEn] = useState("");
|
||||
const [addMembersIds, setAddMembersIds] = useState<number[]>([]);
|
||||
@@ -431,8 +432,12 @@ export default function ChatPage() {
|
||||
|
||||
const handleLeave = () => {
|
||||
if (!selectedConv) return;
|
||||
const data =
|
||||
successorChoice === "auto"
|
||||
? {}
|
||||
: { successorId: successorChoice };
|
||||
leaveConv.mutate(
|
||||
{ id: selectedConv.id },
|
||||
{ id: selectedConv.id, data },
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() });
|
||||
@@ -440,6 +445,7 @@ export default function ChatPage() {
|
||||
setConfirmLeave(false);
|
||||
setShowActions(false);
|
||||
setSelectedConvId(null);
|
||||
setSuccessorChoice("auto");
|
||||
},
|
||||
onError: (err: Error) =>
|
||||
toast({ title: t("chat.actions.actionFailed"), description: err.message, variant: "destructive" }),
|
||||
@@ -1266,6 +1272,7 @@ export default function ChatPage() {
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowActions(false);
|
||||
setSuccessorChoice("auto");
|
||||
setConfirmLeave(true);
|
||||
}}
|
||||
className="w-full flex items-center gap-3 px-3 py-3 rounded-xl hover:bg-destructive/10 text-start text-sm text-destructive transition-colors"
|
||||
@@ -1280,47 +1287,112 @@ export default function ChatPage() {
|
||||
)}
|
||||
|
||||
{/* Leave confirmation */}
|
||||
{confirmLeave && selectedConv?.isGroup && (
|
||||
<div
|
||||
className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[60] flex items-center justify-center p-4"
|
||||
onClick={() => setConfirmLeave(false)}
|
||||
>
|
||||
{confirmLeave && selectedConv?.isGroup && (() => {
|
||||
const others = (selectedConv.participants ?? []).filter(
|
||||
(p) => p.id !== user?.id,
|
||||
);
|
||||
const adminCount = (selectedConv.participants ?? []).filter(
|
||||
(p) => p.isAdmin,
|
||||
).length;
|
||||
const isSoleAdmin = isAdminOfSelected && adminCount === 1;
|
||||
const showSuccessor = isSoleAdmin && others.length > 0;
|
||||
const closeDialog = () => {
|
||||
setConfirmLeave(false);
|
||||
setSuccessorChoice("auto");
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="dialog-confirm-leave"
|
||||
className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[60] flex items-center justify-center p-4"
|
||||
onClick={closeDialog}
|
||||
>
|
||||
<h2 className="font-semibold text-foreground text-lg">
|
||||
{t("chat.actions.leaveConfirmTitle")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("chat.actions.leaveConfirmBody", { name: convName })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => setConfirmLeave(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
onClick={handleLeave}
|
||||
disabled={leaveConv.isPending}
|
||||
data-testid="button-confirm-leave"
|
||||
>
|
||||
{leaveConv.isPending ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
t("chat.actions.leave")
|
||||
)}
|
||||
</Button>
|
||||
<div
|
||||
className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="dialog-confirm-leave"
|
||||
>
|
||||
<h2 className="font-semibold text-foreground text-lg">
|
||||
{t("chat.actions.leaveConfirmTitle")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("chat.actions.leaveConfirmBody", { name: convName })}
|
||||
</p>
|
||||
{showSuccessor && (
|
||||
<div className="space-y-2" data-testid="successor-chooser">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("chat.actions.successorTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("chat.actions.successorHelp")}
|
||||
</p>
|
||||
<div className="max-h-48 overflow-y-auto space-y-1 rounded-2xl bg-slate-100/60 p-2">
|
||||
<label
|
||||
className="flex items-center gap-2 px-2 py-2 rounded-xl hover:bg-white/60 cursor-pointer"
|
||||
data-testid="successor-option-auto"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="successor"
|
||||
checked={successorChoice === "auto"}
|
||||
onChange={() => setSuccessorChoice("auto")}
|
||||
/>
|
||||
<span className="text-sm text-foreground">
|
||||
{t("chat.actions.successorAuto")}
|
||||
</span>
|
||||
</label>
|
||||
{others.map((p) => (
|
||||
<label
|
||||
key={p.id}
|
||||
className="flex items-center gap-2 px-2 py-2 rounded-xl hover:bg-white/60 cursor-pointer"
|
||||
data-testid={`successor-option-${p.id}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="successor"
|
||||
checked={successorChoice === p.id}
|
||||
onChange={() => setSuccessorChoice(p.id)}
|
||||
/>
|
||||
<span className="text-sm text-foreground">
|
||||
{pickDisplayName(
|
||||
{
|
||||
id: p.id,
|
||||
username: p.username,
|
||||
displayNameAr: p.displayNameAr,
|
||||
displayNameEn: p.displayNameEn,
|
||||
},
|
||||
lang,
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={closeDialog}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
onClick={handleLeave}
|
||||
disabled={leaveConv.isPending}
|
||||
data-testid="button-confirm-leave"
|
||||
>
|
||||
{leaveConv.isPending ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
t("chat.actions.leave")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
|
||||
{selectedConvId ? (
|
||||
/* Message View */
|
||||
|
||||
@@ -562,6 +562,14 @@ export interface AdminAppOpensByUser {
|
||||
opens: AppOpenByUserEntry[];
|
||||
}
|
||||
|
||||
export type LeaveConversationBody = {
|
||||
/** When the leaver is the only admin, optionally name a specific
|
||||
remaining member to promote. Omit (or send null) to keep the
|
||||
automatic behavior of promoting the longest-tenured member.
|
||||
*/
|
||||
successorId?: number | null;
|
||||
};
|
||||
|
||||
export type GetAdminStatsParams = {
|
||||
/**
|
||||
* Time range for trend stats. Use "custom" with from/to.
|
||||
|
||||
@@ -37,6 +37,7 @@ import type {
|
||||
GetAdminStatsParams,
|
||||
HealthStatus,
|
||||
HomeStats,
|
||||
LeaveConversationBody,
|
||||
LoginBody,
|
||||
MessageWithSender,
|
||||
Notification,
|
||||
@@ -2988,11 +2989,14 @@ export const getLeaveConversationUrl = (id: number) => {
|
||||
|
||||
export const leaveConversation = async (
|
||||
id: number,
|
||||
leaveConversationBody?: LeaveConversationBody,
|
||||
options?: RequestInit,
|
||||
): Promise<SuccessResponse> => {
|
||||
return customFetch<SuccessResponse>(getLeaveConversationUrl(id), {
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(leaveConversationBody),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3003,14 +3007,14 @@ export const getLeaveConversationMutationOptions = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof leaveConversation>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; data: BodyType<LeaveConversationBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof leaveConversation>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; data: BodyType<LeaveConversationBody> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["leaveConversation"];
|
||||
@@ -3024,11 +3028,11 @@ export const getLeaveConversationMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof leaveConversation>>,
|
||||
{ id: number }
|
||||
{ id: number; data: BodyType<LeaveConversationBody> }
|
||||
> = (props) => {
|
||||
const { id } = props ?? {};
|
||||
const { id, data } = props ?? {};
|
||||
|
||||
return leaveConversation(id, requestOptions);
|
||||
return leaveConversation(id, data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
@@ -3037,7 +3041,7 @@ export const getLeaveConversationMutationOptions = <
|
||||
export type LeaveConversationMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof leaveConversation>>
|
||||
>;
|
||||
|
||||
export type LeaveConversationMutationBody = BodyType<LeaveConversationBody>;
|
||||
export type LeaveConversationMutationError = ErrorType<unknown>;
|
||||
|
||||
/**
|
||||
@@ -3050,14 +3054,14 @@ export const useLeaveConversation = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof leaveConversation>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; data: BodyType<LeaveConversationBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof leaveConversation>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
{ id: number; data: BodyType<LeaveConversationBody> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getLeaveConversationMutationOptions(options));
|
||||
|
||||
@@ -765,6 +765,20 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
successorId:
|
||||
type: integer
|
||||
description: |
|
||||
When the leaver is the only admin, optionally name a specific
|
||||
remaining member to promote. Omit (or send null) to keep the
|
||||
automatic behavior of promoting the longest-tenured member.
|
||||
nullable: true
|
||||
responses:
|
||||
"200":
|
||||
description: Left conversation
|
||||
|
||||
@@ -946,6 +946,15 @@ export const LeaveConversationParams = zod.object({
|
||||
id: zod.coerce.number(),
|
||||
});
|
||||
|
||||
export const LeaveConversationBody = zod.object({
|
||||
successorId: zod
|
||||
.number()
|
||||
.nullish()
|
||||
.describe(
|
||||
"When the leaver is the only admin, optionally name a specific\nremaining member to promote. Omit (or send null) to keep the\nautomatic behavior of promoting the longest-tenured member.\n",
|
||||
),
|
||||
});
|
||||
|
||||
export const LeaveConversationResponse = zod.object({
|
||||
success: zod.boolean(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user