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.
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
|||||||
RemoveConversationParticipantParams,
|
RemoveConversationParticipantParams,
|
||||||
UpdateConversationStateParams,
|
UpdateConversationStateParams,
|
||||||
UpdateConversationStateBody,
|
UpdateConversationStateBody,
|
||||||
|
LeaveConversationBody,
|
||||||
LeaveConversationParams,
|
LeaveConversationParams,
|
||||||
} from "@workspace/api-zod";
|
} from "@workspace/api-zod";
|
||||||
|
|
||||||
@@ -631,6 +632,12 @@ router.post(
|
|||||||
res.status(400).json({ error: params.error.message });
|
res.status(400).json({ error: params.error.message });
|
||||||
return;
|
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 userId = req.session.userId!;
|
||||||
|
|
||||||
const [conv] = await db
|
const [conv] = await db
|
||||||
@@ -677,7 +684,23 @@ router.post(
|
|||||||
// Determine if we need to promote a successor admin.
|
// Determine if we need to promote a successor admin.
|
||||||
const leaverWasOnlyAdmin =
|
const leaverWasOnlyAdmin =
|
||||||
leaver.isAdmin && !remaining.some((p) => p.isAdmin);
|
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) {
|
if (successor) {
|
||||||
await db
|
await db
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -174,6 +174,9 @@
|
|||||||
"leave": "مغادرة المجموعة",
|
"leave": "مغادرة المجموعة",
|
||||||
"leaveConfirmTitle": "مغادرة هذه المجموعة؟",
|
"leaveConfirmTitle": "مغادرة هذه المجموعة؟",
|
||||||
"leaveConfirmBody": "ستتم إزالتك من \"{{name}}\" ولن تتلقى رسائلها بعد الآن.",
|
"leaveConfirmBody": "ستتم إزالتك من \"{{name}}\" ولن تتلقى رسائلها بعد الآن.",
|
||||||
|
"successorTitle": "اختر المشرف التالي",
|
||||||
|
"successorHelp": "أنت المشرف الوحيد. اختر من يتولى الإدارة، أو دعنا نرقّي أقدم عضو في المجموعة.",
|
||||||
|
"successorAuto": "تلقائي (أقدم عضو)",
|
||||||
"muted": "تم كتم الإشعارات",
|
"muted": "تم كتم الإشعارات",
|
||||||
"unmuted": "الإشعارات مفعّلة",
|
"unmuted": "الإشعارات مفعّلة",
|
||||||
"archived": "تمت أرشفة المحادثة",
|
"archived": "تمت أرشفة المحادثة",
|
||||||
|
|||||||
@@ -171,6 +171,9 @@
|
|||||||
"leave": "Leave group",
|
"leave": "Leave group",
|
||||||
"leaveConfirmTitle": "Leave this group?",
|
"leaveConfirmTitle": "Leave this group?",
|
||||||
"leaveConfirmBody": "You will be removed from \"{{name}}\" and stop receiving its messages.",
|
"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",
|
"muted": "Notifications muted",
|
||||||
"unmuted": "Notifications on",
|
"unmuted": "Notifications on",
|
||||||
"archived": "Chat archived",
|
"archived": "Chat archived",
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export default function ChatPage() {
|
|||||||
const [showActions, setShowActions] = useState(false);
|
const [showActions, setShowActions] = useState(false);
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const [confirmLeave, setConfirmLeave] = useState(false);
|
const [confirmLeave, setConfirmLeave] = useState(false);
|
||||||
|
const [successorChoice, setSuccessorChoice] = useState<number | "auto">("auto");
|
||||||
const [editNameAr, setEditNameAr] = useState("");
|
const [editNameAr, setEditNameAr] = useState("");
|
||||||
const [editNameEn, setEditNameEn] = useState("");
|
const [editNameEn, setEditNameEn] = useState("");
|
||||||
const [addMembersIds, setAddMembersIds] = useState<number[]>([]);
|
const [addMembersIds, setAddMembersIds] = useState<number[]>([]);
|
||||||
@@ -431,8 +432,12 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
const handleLeave = () => {
|
const handleLeave = () => {
|
||||||
if (!selectedConv) return;
|
if (!selectedConv) return;
|
||||||
|
const data =
|
||||||
|
successorChoice === "auto"
|
||||||
|
? {}
|
||||||
|
: { successorId: successorChoice };
|
||||||
leaveConv.mutate(
|
leaveConv.mutate(
|
||||||
{ id: selectedConv.id },
|
{ id: selectedConv.id, data },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListConversationsQueryKey() });
|
||||||
@@ -440,6 +445,7 @@ export default function ChatPage() {
|
|||||||
setConfirmLeave(false);
|
setConfirmLeave(false);
|
||||||
setShowActions(false);
|
setShowActions(false);
|
||||||
setSelectedConvId(null);
|
setSelectedConvId(null);
|
||||||
|
setSuccessorChoice("auto");
|
||||||
},
|
},
|
||||||
onError: (err: Error) =>
|
onError: (err: Error) =>
|
||||||
toast({ title: t("chat.actions.actionFailed"), description: err.message, variant: "destructive" }),
|
toast({ title: t("chat.actions.actionFailed"), description: err.message, variant: "destructive" }),
|
||||||
@@ -1266,6 +1272,7 @@ export default function ChatPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowActions(false);
|
setShowActions(false);
|
||||||
|
setSuccessorChoice("auto");
|
||||||
setConfirmLeave(true);
|
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"
|
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 */}
|
{/* Leave confirmation */}
|
||||||
{confirmLeave && selectedConv?.isGroup && (
|
{confirmLeave && selectedConv?.isGroup && (() => {
|
||||||
<div
|
const others = (selectedConv.participants ?? []).filter(
|
||||||
className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[60] flex items-center justify-center p-4"
|
(p) => p.id !== user?.id,
|
||||||
onClick={() => setConfirmLeave(false)}
|
);
|
||||||
>
|
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
|
<div
|
||||||
className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-4"
|
className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[60] flex items-center justify-center p-4"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={closeDialog}
|
||||||
data-testid="dialog-confirm-leave"
|
|
||||||
>
|
>
|
||||||
<h2 className="font-semibold text-foreground text-lg">
|
<div
|
||||||
{t("chat.actions.leaveConfirmTitle")}
|
className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-4"
|
||||||
</h2>
|
onClick={(e) => e.stopPropagation()}
|
||||||
<p className="text-sm text-muted-foreground">
|
data-testid="dialog-confirm-leave"
|
||||||
{t("chat.actions.leaveConfirmBody", { name: convName })}
|
>
|
||||||
</p>
|
<h2 className="font-semibold text-foreground text-lg">
|
||||||
<div className="flex gap-2">
|
{t("chat.actions.leaveConfirmTitle")}
|
||||||
<Button
|
</h2>
|
||||||
variant="outline"
|
<p className="text-sm text-muted-foreground">
|
||||||
className="flex-1"
|
{t("chat.actions.leaveConfirmBody", { name: convName })}
|
||||||
onClick={() => setConfirmLeave(false)}
|
</p>
|
||||||
>
|
{showSuccessor && (
|
||||||
{t("common.cancel")}
|
<div className="space-y-2" data-testid="successor-chooser">
|
||||||
</Button>
|
<p className="text-sm font-medium text-foreground">
|
||||||
<Button
|
{t("chat.actions.successorTitle")}
|
||||||
variant="destructive"
|
</p>
|
||||||
className="flex-1"
|
<p className="text-xs text-muted-foreground">
|
||||||
onClick={handleLeave}
|
{t("chat.actions.successorHelp")}
|
||||||
disabled={leaveConv.isPending}
|
</p>
|
||||||
data-testid="button-confirm-leave"
|
<div className="max-h-48 overflow-y-auto space-y-1 rounded-2xl bg-slate-100/60 p-2">
|
||||||
>
|
<label
|
||||||
{leaveConv.isPending ? (
|
className="flex items-center gap-2 px-2 py-2 rounded-xl hover:bg-white/60 cursor-pointer"
|
||||||
<Loader2 size={14} className="animate-spin" />
|
data-testid="successor-option-auto"
|
||||||
) : (
|
>
|
||||||
t("chat.actions.leave")
|
<input
|
||||||
)}
|
type="radio"
|
||||||
</Button>
|
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>
|
</div>
|
||||||
</div>
|
);
|
||||||
)}
|
})()}
|
||||||
|
|
||||||
{selectedConvId ? (
|
{selectedConvId ? (
|
||||||
/* Message View */
|
/* Message View */
|
||||||
|
|||||||
@@ -562,6 +562,14 @@ export interface AdminAppOpensByUser {
|
|||||||
opens: AppOpenByUserEntry[];
|
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 = {
|
export type GetAdminStatsParams = {
|
||||||
/**
|
/**
|
||||||
* Time range for trend stats. Use "custom" with from/to.
|
* Time range for trend stats. Use "custom" with from/to.
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import type {
|
|||||||
GetAdminStatsParams,
|
GetAdminStatsParams,
|
||||||
HealthStatus,
|
HealthStatus,
|
||||||
HomeStats,
|
HomeStats,
|
||||||
|
LeaveConversationBody,
|
||||||
LoginBody,
|
LoginBody,
|
||||||
MessageWithSender,
|
MessageWithSender,
|
||||||
Notification,
|
Notification,
|
||||||
@@ -2988,11 +2989,14 @@ export const getLeaveConversationUrl = (id: number) => {
|
|||||||
|
|
||||||
export const leaveConversation = async (
|
export const leaveConversation = async (
|
||||||
id: number,
|
id: number,
|
||||||
|
leaveConversationBody?: LeaveConversationBody,
|
||||||
options?: RequestInit,
|
options?: RequestInit,
|
||||||
): Promise<SuccessResponse> => {
|
): Promise<SuccessResponse> => {
|
||||||
return customFetch<SuccessResponse>(getLeaveConversationUrl(id), {
|
return customFetch<SuccessResponse>(getLeaveConversationUrl(id), {
|
||||||
...options,
|
...options,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||||
|
body: JSON.stringify(leaveConversationBody),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3003,14 +3007,14 @@ export const getLeaveConversationMutationOptions = <
|
|||||||
mutation?: UseMutationOptions<
|
mutation?: UseMutationOptions<
|
||||||
Awaited<ReturnType<typeof leaveConversation>>,
|
Awaited<ReturnType<typeof leaveConversation>>,
|
||||||
TError,
|
TError,
|
||||||
{ id: number },
|
{ id: number; data: BodyType<LeaveConversationBody> },
|
||||||
TContext
|
TContext
|
||||||
>;
|
>;
|
||||||
request?: SecondParameter<typeof customFetch>;
|
request?: SecondParameter<typeof customFetch>;
|
||||||
}): UseMutationOptions<
|
}): UseMutationOptions<
|
||||||
Awaited<ReturnType<typeof leaveConversation>>,
|
Awaited<ReturnType<typeof leaveConversation>>,
|
||||||
TError,
|
TError,
|
||||||
{ id: number },
|
{ id: number; data: BodyType<LeaveConversationBody> },
|
||||||
TContext
|
TContext
|
||||||
> => {
|
> => {
|
||||||
const mutationKey = ["leaveConversation"];
|
const mutationKey = ["leaveConversation"];
|
||||||
@@ -3024,11 +3028,11 @@ export const getLeaveConversationMutationOptions = <
|
|||||||
|
|
||||||
const mutationFn: MutationFunction<
|
const mutationFn: MutationFunction<
|
||||||
Awaited<ReturnType<typeof leaveConversation>>,
|
Awaited<ReturnType<typeof leaveConversation>>,
|
||||||
{ id: number }
|
{ id: number; data: BodyType<LeaveConversationBody> }
|
||||||
> = (props) => {
|
> = (props) => {
|
||||||
const { id } = props ?? {};
|
const { id, data } = props ?? {};
|
||||||
|
|
||||||
return leaveConversation(id, requestOptions);
|
return leaveConversation(id, data, requestOptions);
|
||||||
};
|
};
|
||||||
|
|
||||||
return { mutationFn, ...mutationOptions };
|
return { mutationFn, ...mutationOptions };
|
||||||
@@ -3037,7 +3041,7 @@ export const getLeaveConversationMutationOptions = <
|
|||||||
export type LeaveConversationMutationResult = NonNullable<
|
export type LeaveConversationMutationResult = NonNullable<
|
||||||
Awaited<ReturnType<typeof leaveConversation>>
|
Awaited<ReturnType<typeof leaveConversation>>
|
||||||
>;
|
>;
|
||||||
|
export type LeaveConversationMutationBody = BodyType<LeaveConversationBody>;
|
||||||
export type LeaveConversationMutationError = ErrorType<unknown>;
|
export type LeaveConversationMutationError = ErrorType<unknown>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3050,14 +3054,14 @@ export const useLeaveConversation = <
|
|||||||
mutation?: UseMutationOptions<
|
mutation?: UseMutationOptions<
|
||||||
Awaited<ReturnType<typeof leaveConversation>>,
|
Awaited<ReturnType<typeof leaveConversation>>,
|
||||||
TError,
|
TError,
|
||||||
{ id: number },
|
{ id: number; data: BodyType<LeaveConversationBody> },
|
||||||
TContext
|
TContext
|
||||||
>;
|
>;
|
||||||
request?: SecondParameter<typeof customFetch>;
|
request?: SecondParameter<typeof customFetch>;
|
||||||
}): UseMutationResult<
|
}): UseMutationResult<
|
||||||
Awaited<ReturnType<typeof leaveConversation>>,
|
Awaited<ReturnType<typeof leaveConversation>>,
|
||||||
TError,
|
TError,
|
||||||
{ id: number },
|
{ id: number; data: BodyType<LeaveConversationBody> },
|
||||||
TContext
|
TContext
|
||||||
> => {
|
> => {
|
||||||
return useMutation(getLeaveConversationMutationOptions(options));
|
return useMutation(getLeaveConversationMutationOptions(options));
|
||||||
|
|||||||
@@ -765,6 +765,20 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
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:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Left conversation
|
description: Left conversation
|
||||||
|
|||||||
@@ -946,6 +946,15 @@ export const LeaveConversationParams = zod.object({
|
|||||||
id: zod.coerce.number(),
|
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({
|
export const LeaveConversationResponse = zod.object({
|
||||||
success: zod.boolean(),
|
success: zod.boolean(),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user