Improve admin modal design and accessibility with shared component
Refactors AdminFormDialog to create a reusable component for admin modals, including aria-labelledby/describedby attributes and Escape key closing functionality. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: fa532550-4873-409b-840a-ac4c474132f3 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/kI0sxlu Replit-Helium-Checkpoint-Created: true
This commit is contained in:
+277
-190
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useMemo, type ReactNode } from "react";
|
||||
import { useState, useEffect, useRef, useMemo, useId, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "wouter";
|
||||
import {
|
||||
@@ -361,6 +361,118 @@ function DeletionWarningDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function AdminFormDialog({
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
submitLabel,
|
||||
cancelLabel,
|
||||
submitDisabled,
|
||||
isPending,
|
||||
children,
|
||||
testId,
|
||||
submitTestId,
|
||||
maxWidth = "sm",
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: ReactNode;
|
||||
onCancel: () => void;
|
||||
onSubmit: () => void;
|
||||
submitLabel?: string;
|
||||
cancelLabel?: string;
|
||||
submitDisabled?: boolean;
|
||||
isPending?: boolean;
|
||||
children: ReactNode;
|
||||
testId?: string;
|
||||
submitTestId?: string;
|
||||
maxWidth?: "sm" | "md" | "lg";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const titleId = useId();
|
||||
const subtitleId = useId();
|
||||
const widthClass =
|
||||
maxWidth === "lg" ? "max-w-lg" : maxWidth === "md" ? "max-w-md" : "max-w-sm";
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !isPending) {
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [isPending, onCancel]);
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
className={cn(
|
||||
"glass-panel rounded-3xl w-full overflow-hidden shadow-xl flex flex-col max-h-[90vh]",
|
||||
widthClass,
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={subtitle ? subtitleId : undefined}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-5 sm:p-6">
|
||||
<div
|
||||
className="shrink-0 w-10 h-10 rounded-full bg-primary/10 text-primary flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<h2
|
||||
id={titleId}
|
||||
className="text-base sm:text-lg font-semibold text-foreground leading-snug"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p
|
||||
id={subtitleId}
|
||||
className="text-xs text-muted-foreground mt-0.5 leading-relaxed"
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 sm:px-6 pb-5 sm:pb-6 space-y-4 overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-5 sm:px-6 py-4 border-t border-foreground/10 bg-foreground/[0.02]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
disabled={isPending}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{cancelLabel ?? t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
disabled={isPending || submitDisabled}
|
||||
data-testid={submitTestId}
|
||||
className="min-w-[110px] gap-2"
|
||||
>
|
||||
{isPending && (
|
||||
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
|
||||
)}
|
||||
{submitLabel ?? t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Local-state permission picker used by the "Add app" dialog. The full
|
||||
// AppPermissionsEditor below talks to /apps/:id/permissions, which can't
|
||||
// run before the app exists. This picker just collects ids into the form
|
||||
@@ -1754,41 +1866,37 @@ export default function AdminPage() {
|
||||
|
||||
{/* Create User Modal */}
|
||||
{newUserForm && (
|
||||
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-4">
|
||||
<h2 className="font-semibold text-foreground">{t("admin.addUser")}</h2>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("admin.username")}</Label>
|
||||
<Input
|
||||
value={newUserForm.username}
|
||||
onChange={(e) => setNewUserForm({ ...newUserForm, username: e.target.value })}
|
||||
className="bg-white/70 border-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("admin.email")}</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={newUserForm.email}
|
||||
onChange={(e) => setNewUserForm({ ...newUserForm, email: e.target.value })}
|
||||
className="bg-white/70 border-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("admin.password")}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={newUserForm.password}
|
||||
onChange={(e) => setNewUserForm({ ...newUserForm, password: e.target.value })}
|
||||
className="bg-white/70 border-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="flex-1" onClick={() => setNewUserForm(null)}>{t("common.cancel")}</Button>
|
||||
<Button className="flex-1" onClick={handleCreateUser}>{t("common.save")}</Button>
|
||||
</div>
|
||||
<AdminFormDialog
|
||||
title={t("admin.addUser")}
|
||||
icon={<UserPlus size={20} />}
|
||||
onCancel={() => setNewUserForm(null)}
|
||||
onSubmit={handleCreateUser}
|
||||
isPending={createUser.isPending}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("admin.username")}</Label>
|
||||
<Input
|
||||
value={newUserForm.username}
|
||||
onChange={(e) => setNewUserForm({ ...newUserForm, username: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("admin.email")}</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={newUserForm.email}
|
||||
onChange={(e) => setNewUserForm({ ...newUserForm, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("admin.password")}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={newUserForm.password}
|
||||
onChange={(e) => setNewUserForm({ ...newUserForm, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</AdminFormDialog>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex w-full">
|
||||
@@ -4063,80 +4171,75 @@ function UsersPanel({
|
||||
</div>
|
||||
|
||||
{newUserOpen && (
|
||||
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3">
|
||||
<h2 className="font-semibold text-foreground">{t("admin.addUser")}</h2>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("auth.username")}</Label>
|
||||
<Input value={newUserForm.username} onChange={(e) => setNewUserForm({ ...newUserForm, username: e.target.value })} className="bg-white/70 border-slate-200" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("auth.email")}</Label>
|
||||
<Input type="email" value={newUserForm.email} onChange={(e) => setNewUserForm({ ...newUserForm, email: e.target.value })} className="bg-white/70 border-slate-200" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("auth.password")}</Label>
|
||||
<Input type="password" value={newUserForm.password} onChange={(e) => setNewUserForm({ ...newUserForm, password: e.target.value })} className="bg-white/70 border-slate-200" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("admin.users.col.groups")}</Label>
|
||||
<div className="max-h-40 overflow-y-auto rounded-xl border border-slate-200 bg-white/70 p-2 space-y-1">
|
||||
{(groups ?? []).length === 0 && (
|
||||
<div className="text-xs text-muted-foreground px-1 py-2">{t("admin.groups.empty")}</div>
|
||||
)}
|
||||
{(groups ?? []).map((g) => {
|
||||
const checked = newUserForm.groupIds.includes(g.id);
|
||||
return (
|
||||
<label key={g.id} className="flex items-center gap-2 text-sm cursor-pointer px-1 py-1 rounded hover:bg-slate-100/60">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
setNewUserForm((prev) => ({
|
||||
...prev,
|
||||
groupIds: e.target.checked
|
||||
? [...prev.groupIds, g.id]
|
||||
: prev.groupIds.filter((id) => id !== g.id),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<span>{g.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" className="flex-1" onClick={() => setNewUserOpen(false)}>{t("common.cancel")}</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
createUser.mutate(
|
||||
{
|
||||
data: {
|
||||
username: newUserForm.username,
|
||||
email: newUserForm.email,
|
||||
password: newUserForm.password,
|
||||
...(newUserForm.groupIds.length > 0 ? { groupIds: newUserForm.groupIds } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
invalidateUsers();
|
||||
setNewUserOpen(false);
|
||||
setNewUserForm({ username: "", email: "", password: "", groupIds: [] });
|
||||
toast({ title: t("common.success") });
|
||||
},
|
||||
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
<AdminFormDialog
|
||||
title={t("admin.addUser")}
|
||||
icon={<UserPlus size={20} />}
|
||||
onCancel={() => setNewUserOpen(false)}
|
||||
isPending={createUser.isPending}
|
||||
onSubmit={() => {
|
||||
createUser.mutate(
|
||||
{
|
||||
data: {
|
||||
username: newUserForm.username,
|
||||
email: newUserForm.email,
|
||||
password: newUserForm.password,
|
||||
...(newUserForm.groupIds.length > 0 ? { groupIds: newUserForm.groupIds } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
invalidateUsers();
|
||||
setNewUserOpen(false);
|
||||
setNewUserForm({ username: "", email: "", password: "", groupIds: [] });
|
||||
toast({ title: t("common.success") });
|
||||
},
|
||||
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("auth.username")}</Label>
|
||||
<Input value={newUserForm.username} onChange={(e) => setNewUserForm({ ...newUserForm, username: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("auth.email")}</Label>
|
||||
<Input type="email" value={newUserForm.email} onChange={(e) => setNewUserForm({ ...newUserForm, email: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("auth.password")}</Label>
|
||||
<Input type="password" value={newUserForm.password} onChange={(e) => setNewUserForm({ ...newUserForm, password: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("admin.users.col.groups")}</Label>
|
||||
<div className="max-h-44 overflow-y-auto rounded-xl border border-foreground/10 bg-foreground/[0.03] p-1.5 space-y-0.5">
|
||||
{(groups ?? []).length === 0 && (
|
||||
<div className="text-xs text-muted-foreground px-2 py-2">{t("admin.groups.empty")}</div>
|
||||
)}
|
||||
{(groups ?? []).map((g) => {
|
||||
const checked = newUserForm.groupIds.includes(g.id);
|
||||
return (
|
||||
<label key={g.id} className="flex items-center gap-2 text-sm cursor-pointer px-2 py-1.5 rounded-lg hover:bg-foreground/5 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-primary"
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
setNewUserForm((prev) => ({
|
||||
...prev,
|
||||
groupIds: e.target.checked
|
||||
? [...prev.groupIds, g.id]
|
||||
: prev.groupIds.filter((id) => id !== g.id),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<span className="text-foreground">{g.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminFormDialog>
|
||||
)}
|
||||
|
||||
{editingUser && (
|
||||
@@ -4709,42 +4812,36 @@ function GroupsPanel({
|
||||
</div>
|
||||
|
||||
{createOpen && (
|
||||
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3">
|
||||
<h2 className="font-semibold text-foreground">{t("admin.groups.addGroup")}</h2>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("admin.groups.name")}</Label>
|
||||
<Input value={createForm.name} onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })} className="bg-white/70 border-slate-200" data-testid="group-name-input" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("admin.groups.descriptionAr")}</Label>
|
||||
<Input value={createForm.descriptionAr} onChange={(e) => setCreateForm({ ...createForm, descriptionAr: e.target.value })} className="bg-white/70 border-slate-200" dir="rtl" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("admin.groups.descriptionEn")}</Label>
|
||||
<Input value={createForm.descriptionEn} onChange={(e) => setCreateForm({ ...createForm, descriptionEn: e.target.value })} className="bg-white/70 border-slate-200" dir="ltr" />
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" className="flex-1" onClick={() => setCreateOpen(false)}>{t("common.cancel")}</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={!createForm.name.trim()}
|
||||
onClick={() =>
|
||||
createGroup.mutate(
|
||||
{ data: { name: createForm.name.trim(), descriptionAr: createForm.descriptionAr || null, descriptionEn: createForm.descriptionEn || null } },
|
||||
{
|
||||
onSuccess: () => { invalidate(); setCreateOpen(false); toast({ title: t("common.success") }); },
|
||||
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
data-testid="create-group-submit"
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
<AdminFormDialog
|
||||
title={t("admin.groups.addGroup")}
|
||||
icon={<UsersIcon size={20} />}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
submitDisabled={!createForm.name.trim()}
|
||||
isPending={createGroup.isPending}
|
||||
submitTestId="create-group-submit"
|
||||
onSubmit={() =>
|
||||
createGroup.mutate(
|
||||
{ data: { name: createForm.name.trim(), descriptionAr: createForm.descriptionAr || null, descriptionEn: createForm.descriptionEn || null } },
|
||||
{
|
||||
onSuccess: () => { invalidate(); setCreateOpen(false); toast({ title: t("common.success") }); },
|
||||
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("admin.groups.name")}</Label>
|
||||
<Input value={createForm.name} onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })} data-testid="group-name-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("admin.groups.descriptionAr")}</Label>
|
||||
<Input value={createForm.descriptionAr} onChange={(e) => setCreateForm({ ...createForm, descriptionAr: e.target.value })} dir="rtl" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("admin.groups.descriptionEn")}</Label>
|
||||
<Input value={createForm.descriptionEn} onChange={(e) => setCreateForm({ ...createForm, descriptionEn: e.target.value })} dir="ltr" />
|
||||
</div>
|
||||
</AdminFormDialog>
|
||||
)}
|
||||
|
||||
{editingGroupId != null && (
|
||||
@@ -6496,55 +6593,45 @@ function RolesPanel({
|
||||
</div>
|
||||
|
||||
{createOpen && (
|
||||
<div className="fixed inset-0 bg-slate-900/30 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-3" data-testid="create-role-dialog">
|
||||
<h2 className="font-semibold text-foreground">{t("admin.roles.addRole")}</h2>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("admin.roles.name")}</Label>
|
||||
<Input
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
|
||||
className="bg-white/70 border-slate-200"
|
||||
placeholder={t("admin.roles.namePlaceholder")}
|
||||
data-testid="role-name-input"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("admin.roles.nameHint")}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("admin.roles.descriptionAr")}</Label>
|
||||
<Input
|
||||
value={createForm.descriptionAr}
|
||||
onChange={(e) => setCreateForm({ ...createForm, descriptionAr: e.target.value })}
|
||||
className="bg-white/70 border-slate-200"
|
||||
dir="rtl"
|
||||
data-testid="role-desc-ar-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("admin.roles.descriptionEn")}</Label>
|
||||
<Input
|
||||
value={createForm.descriptionEn}
|
||||
onChange={(e) => setCreateForm({ ...createForm, descriptionEn: e.target.value })}
|
||||
className="bg-white/70 border-slate-200"
|
||||
dir="ltr"
|
||||
data-testid="role-desc-en-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" className="flex-1" onClick={() => setCreateOpen(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={!createForm.name.trim() || createRole.isPending}
|
||||
onClick={handleCreate}
|
||||
data-testid="create-role-submit"
|
||||
>
|
||||
{createRole.isPending ? <Loader2 size={14} className="animate-spin" /> : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
<AdminFormDialog
|
||||
title={t("admin.roles.addRole")}
|
||||
icon={<KeyRound size={20} />}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onSubmit={handleCreate}
|
||||
submitDisabled={!createForm.name.trim()}
|
||||
isPending={createRole.isPending}
|
||||
testId="create-role-dialog"
|
||||
submitTestId="create-role-submit"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("admin.roles.name")}</Label>
|
||||
<Input
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
|
||||
placeholder={t("admin.roles.namePlaceholder")}
|
||||
data-testid="role-name-input"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("admin.roles.nameHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("admin.roles.descriptionAr")}</Label>
|
||||
<Input
|
||||
value={createForm.descriptionAr}
|
||||
onChange={(e) => setCreateForm({ ...createForm, descriptionAr: e.target.value })}
|
||||
dir="rtl"
|
||||
data-testid="role-desc-ar-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("admin.roles.descriptionEn")}</Label>
|
||||
<Input
|
||||
value={createForm.descriptionEn}
|
||||
onChange={(e) => setCreateForm({ ...createForm, descriptionEn: e.target.value })}
|
||||
dir="ltr"
|
||||
data-testid="role-desc-en-input"
|
||||
/>
|
||||
</div>
|
||||
</AdminFormDialog>
|
||||
)}
|
||||
|
||||
{editingId != null && (
|
||||
|
||||
Reference in New Issue
Block a user