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:
riyadhafraa
2026-04-21 11:29:39 +00:00
parent a864b84567
commit 0b7593bbc2
9 changed files with 183 additions and 47 deletions
@@ -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

+3
View File
@@ -174,6 +174,9 @@
"leave": "مغادرة المجموعة",
"leaveConfirmTitle": "مغادرة هذه المجموعة؟",
"leaveConfirmBody": "ستتم إزالتك من \"{{name}}\" ولن تتلقى رسائلها بعد الآن.",
"successorTitle": "اختر المشرف التالي",
"successorHelp": "أنت المشرف الوحيد. اختر من يتولى الإدارة، أو دعنا نرقّي أقدم عضو في المجموعة.",
"successorAuto": "تلقائي (أقدم عضو)",
"muted": "تم كتم الإشعارات",
"unmuted": "الإشعارات مفعّلة",
"archived": "تمت أرشفة المحادثة",
+3
View File
@@ -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",
+110 -38
View File
@@ -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.
+12 -8
View File
@@ -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));
+14
View File
@@ -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
+9
View File
@@ -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(),
});