Files
backspace/packages/web/src/components/modals/CategorySettingsModal.tsx
T
devsyncwrldandClaude Opus 5 94f5da4013
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
feat(i18n): translate five more systems, and unify the permission labels
- Space settings > Overview — identity, icon, banner, transfer, delete
  - Space settings > Members and Bans
  - Space settings > Roles, including the whole permission table
  - Category settings — rename, privacy, overrides, delete
  - Instance settings > General — name, discovery, GIF search

The permission labels were duplicated: the roles panel carried one English
list and the channel/category override editors carried another. Both now
read a single `perm.*` set, so renaming a permission lands in all three at
once instead of one and a half. This removes the `channel.perm.*` keys added
two commits ago rather than adding a third copy beside them.

Same pattern as before for anything chosen outside the render: the roles
panel's permission groups and the category's override definitions hold keys
and resolve them where they render. The category delete dialog emphasises
the category name, so its sentence is split around the placeholder rather
than interpolated.

Dictionary is at 723 keys, en and pt-BR at exact parity.

Verified: web build (tsc + vite), 490 web tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 20:49:25 -03:00

394 lines
16 KiB
TypeScript

import React, { useState, useEffect, useCallback } from 'react';
import { Modal } from '../ui/Modal';
import { ConfirmDialog } from '../ui/ConfirmDialog';
import { useUIStore } from '../../stores/uiStore';
import { useSpaceStore, getApiForOrigin } from '../../stores/spaceStore';
import { PermissionBits, permissionsToString, stringToPermissions, hasPermissionBit } from '../../utils/permissions';
import { Toggle } from '../ui/Toggle';
import { PermissionsEditor } from '../ui/PermissionsEditor';
import type { PermissionDef } from '../ui/OverrideEntry';
type CategoryPermissionDef = Omit<PermissionDef, 'label'> & { labelKey: TranslationKey };
import { useT, translate, useLocaleStore, type TranslationKey } from '../../i18n';
// ─── Permission Definitions for Category Overrides ──────────────────────────────
const CATEGORY_PERMISSIONS: CategoryPermissionDef[] = [
{ key: 'VIEW_CHANNEL', labelKey: 'perm.viewChannel', bit: PermissionBits.VIEW_CHANNEL },
{ key: 'SEND_MESSAGES', labelKey: 'perm.sendMessages', bit: PermissionBits.SEND_MESSAGES },
{ key: 'MANAGE_MESSAGES', labelKey: 'perm.manageMessages', bit: PermissionBits.MANAGE_MESSAGES },
{ key: 'ATTACH_FILES', labelKey: 'perm.attachFiles', bit: PermissionBits.ATTACH_FILES },
{ key: 'READ_MESSAGE_HISTORY', labelKey: 'perm.readHistory', bit: PermissionBits.READ_MESSAGE_HISTORY },
{ key: 'ADD_REACTIONS', labelKey: 'perm.addReactions', bit: PermissionBits.ADD_REACTIONS },
{ key: 'CONNECT', labelKey: 'perm.connect', bit: PermissionBits.CONNECT },
{ key: 'SPEAK', labelKey: 'perm.speak', bit: PermissionBits.SPEAK },
{ key: 'STREAM', labelKey: 'perm.stream', bit: PermissionBits.STREAM },
{ key: 'MUTE_MEMBERS', labelKey: 'perm.muteMembers', bit: PermissionBits.MUTE_MEMBERS },
{ key: 'DEAFEN_MEMBERS', labelKey: 'perm.deafenMembers', bit: PermissionBits.DEAFEN_MEMBERS },
{ key: 'MOVE_MEMBERS', labelKey: 'perm.moveMembers', bit: PermissionBits.MOVE_MEMBERS },
{ key: 'DISCONNECT_MEMBERS', labelKey: 'perm.disconnectMembers', bit: PermissionBits.DISCONNECT_MEMBERS },
];
// ─── Overview Tab ───────────────────────────────────────────────────────────────
function OverviewTab({
categoryId,
categoryName,
isPrivate,
isFetching,
isLoading,
error,
canManageChannels,
canManageRoles,
onTogglePrivate,
onDeleteCategory,
onRename,
}: {
categoryId: string;
categoryName: string;
isPrivate: boolean;
isFetching: boolean;
isLoading: boolean;
error: string;
canManageChannels: boolean;
canManageRoles: boolean;
onTogglePrivate: () => void;
onDeleteCategory: () => void;
onRename: (name: string) => void;
}) {
const t = useT();
const [editName, setEditName] = useState(categoryName);
const [isSavingName, setIsSavingName] = useState(false);
// Sync edit name when category name changes externally
useEffect(() => {
setEditName(categoryName);
}, [categoryName]);
const handleNameBlur = () => {
const trimmed = editName.trim();
if (trimmed && trimmed !== categoryName) {
setIsSavingName(true);
onRename(trimmed);
// The parent will update the store; we just reset the saving state after a short delay
setTimeout(() => setIsSavingName(false), 500);
} else {
setEditName(categoryName);
}
};
const handleNameKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
(e.target as HTMLInputElement).blur();
} else if (e.key === 'Escape') {
setEditName(categoryName);
(e.target as HTMLInputElement).blur();
}
};
return (
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
{t('category.label')}
</label>
{canManageChannels ? (
<div className="flex items-center gap-2">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="opacity-60 flex-shrink-0 text-txt-primary">
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" />
</svg>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={handleNameBlur}
onKeyDown={handleNameKeyDown}
disabled={isSavingName}
className="input-standard flex-1 py-1.5 px-2 text-sm"
maxLength={100}
/>
</div>
) : (
<div className="flex items-center gap-2 text-txt-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="opacity-60 flex-shrink-0">
<path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" />
</svg>
<span className="text-sm font-medium">{categoryName}</span>
</div>
)}
</div>
{error && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
{error}
</div>
)}
<div className="pt-2 border-t border-border-soft">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-txt-primary">{t('category.private.title')}</div>
<div className="text-xs text-txt-tertiary mt-0.5">
{t('category.private.description')}
</div>
</div>
<div className={`flex-shrink-0 ml-4 ${(isLoading || isFetching || !canManageRoles) ? 'opacity-50 pointer-events-none' : ''}`}>
<Toggle enabled={isPrivate} onChange={onTogglePrivate} />
</div>
</div>
</div>
{isPrivate && !isFetching && (
<div className="flex items-start gap-2 p-2 bg-surface-input/50 rounded text-xs text-txt-tertiary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 mt-0.5 text-txt-secondary">
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" />
</svg>
<span>
{t('category.private.note')}
</span>
</div>
)}
{canManageChannels && (
<div className="pt-4 border-t border-border-soft">
<label className="block text-xs font-bold text-accent-rose uppercase mb-2">{t('category.danger')}</label>
<button
onClick={onDeleteCategory}
className="w-full px-3 py-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-accent-rose text-sm font-medium hover:bg-accent-rose/20 transition-colors"
>
{t('category.delete')}
</button>
</div>
)}
</div>
);
}
// ─── Main Modal ─────────────────────────────────────────────────────────────────
export function CategorySettingsModal() {
const t = useT();
const activeModal = useUIStore((s) => s.activeModal);
const modalData = useUIStore((s) => s.modalData);
const closeModal = useUIStore((s) => s.closeModal);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const categories = useSpaceStore((s) => s.categories);
const spaces = useSpaceStore((s) => s.spaces);
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
const [tab, setTab] = useState<'overview' | 'permissions'>('overview');
const [isPrivate, setIsPrivate] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isFetching, setIsFetching] = useState(true);
const [error, setError] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isOpen = activeModal === 'categorySettings';
const categoryId = modalData?.categoryId as string | undefined;
const category = categories.find(c => c.id === categoryId);
const myPerms = currentSpaceId ? spacePermissions.get(currentSpaceId) : undefined;
const canManageChannels = myPerms !== undefined && hasPermissionBit(myPerms, PermissionBits.MANAGE_CHANNELS);
const canManageRoles = myPerms !== undefined && hasPermissionBit(myPerms, PermissionBits.MANAGE_ROLES);
// Reset state when modal closes
useEffect(() => {
if (!isOpen) {
setShowDeleteConfirm(false);
setIsDeleting(false);
setTab('overview');
}
}, [isOpen]);
// Fetch overrides for the private toggle (overview tab)
const fetchPrivateState = useCallback(() => {
if (!categoryId || !currentSpaceId) return;
setIsFetching(true);
setError('');
const space = spaces.find(s => s.id === currentSpaceId);
const catApi = getApiForOrigin(space?._instanceOrigin ?? '');
catApi.categories.getOverrides(categoryId)
.then((data: { targetType: string; targetId: string; allow: string; deny: string }[]) => {
// Check if @everyone role (id === spaceId) has VIEW_CHANNEL denied
const everyoneOverride = data.find(
o => o.targetType === 'role' && o.targetId === currentSpaceId
);
if (everyoneOverride) {
const denyBits = stringToPermissions(everyoneOverride.deny);
setIsPrivate((denyBits & PermissionBits.VIEW_CHANNEL) !== 0n);
} else {
setIsPrivate(false);
}
})
.catch((err: Error) => {
setError(err.message || translate(useLocaleStore.getState().locale, 'category.private.loadFailed'));
})
.finally(() => {
setIsFetching(false);
});
}, [categoryId, currentSpaceId, spaces]);
useEffect(() => {
if (isOpen && categoryId && currentSpaceId) {
fetchPrivateState();
} else {
setIsFetching(false);
}
}, [isOpen, categoryId, currentSpaceId, fetchPrivateState]);
if (!isOpen || !category || !categoryId || !currentSpaceId) return null;
const space = spaces.find(s => s.id === currentSpaceId);
const handleToggle = async () => {
setError('');
setIsLoading(true);
const catApi = getApiForOrigin(space?._instanceOrigin ?? '');
try {
if (!isPrivate) {
// Make private: deny VIEW_CHANNEL for @everyone role
await catApi.categories.putOverride(categoryId, {
targetType: 'role',
targetId: currentSpaceId,
allow: '0',
deny: permissionsToString(PermissionBits.VIEW_CHANNEL),
});
setIsPrivate(true);
} else {
// Make public: remove the @everyone VIEW_CHANNEL deny override
await catApi.categories.deleteOverride(categoryId, 'role', currentSpaceId);
setIsPrivate(false);
}
// Re-fetch to keep in sync
fetchPrivateState();
} catch (err) {
setError(err instanceof Error ? err.message : t('category.private.updateFailed'));
} finally {
setIsLoading(false);
}
};
const handleRename = async (name: string) => {
setError('');
try {
await useSpaceStore.getState().updateCategory(categoryId, { name });
} catch (err) {
setError(err instanceof Error ? err.message : t('category.renameFailed'));
}
};
const handleDeleteCategory = async () => {
if (!categoryId) return;
setIsDeleting(true);
try {
await useSpaceStore.getState().deleteCategory(categoryId);
closeModal();
} catch (err) {
setError(err instanceof Error ? err.message : t('category.delete.failed'));
setIsDeleting(false);
}
};
const showTabs = canManageRoles;
const tabClass = (t: typeof tab) =>
`w-full text-left px-2.5 py-1.5 rounded text-sm transition-colors ${
tab === t ? 'bg-interactive-selected text-txt-primary' : 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
}`;
return (
<>
<Modal isOpen={isOpen} onClose={closeModal} title={t('category.settings.title')} mobileStyle="fullscreen" maxWidth={showTabs ? 'max-w-2xl' : 'max-w-md'}>
{showTabs ? (
<div className="flex gap-4 h-[min(520px,70vh)]">
{/* Tabs */}
<div className="w-32 flex-shrink-0 self-start z-10">
<div className="glass-bubble rounded-lg p-1.5 space-y-0.5">
<button onClick={() => setTab('overview')} className={tabClass('overview')}>
{t('category.tab.overview')}
</button>
<button onClick={() => setTab('permissions')} className={tabClass('permissions')}>
{t('category.tab.permissions')}
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin">
{tab === 'overview' && (
<OverviewTab
categoryId={categoryId}
categoryName={category.name}
isPrivate={isPrivate}
isFetching={isFetching}
isLoading={isLoading}
error={error}
canManageChannels={canManageChannels}
canManageRoles={canManageRoles}
onTogglePrivate={handleToggle}
onDeleteCategory={() => setShowDeleteConfirm(true)}
onRename={handleRename}
/>
)}
{tab === 'permissions' && (
<PermissionsEditor
entityId={categoryId}
spaceId={currentSpaceId}
instanceOrigin={space?._instanceOrigin}
permDefs={CATEGORY_PERMISSIONS.map(({ labelKey, ...rest }) => ({ ...rest, label: t(labelKey) }))}
getOverrides={() => {
const catApi = getApiForOrigin(space?._instanceOrigin ?? '');
return catApi.categories.getOverrides(categoryId);
}}
putOverride={(data) => {
const catApi = getApiForOrigin(space?._instanceOrigin ?? '');
return catApi.categories.putOverride(categoryId, data);
}}
deleteOverride={(targetType, targetId) => {
const catApi = getApiForOrigin(space?._instanceOrigin ?? '');
return catApi.categories.deleteOverride(categoryId, targetType, targetId);
}}
/>
)}
</div>
</div>
) : (
<OverviewTab
categoryId={categoryId}
categoryName={category.name}
isPrivate={isPrivate}
isFetching={isFetching}
isLoading={isLoading}
error={error}
canManageChannels={canManageChannels}
canManageRoles={canManageRoles}
onTogglePrivate={handleToggle}
onDeleteCategory={() => setShowDeleteConfirm(true)}
onRename={handleRename}
/>
)}
</Modal>
<ConfirmDialog
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
onConfirm={handleDeleteCategory}
title={t('category.delete.title', { name: category.name })}
description={(() => {
const [before, after] = t('category.delete.body').split('{name}');
return (<>{before}<strong>{category.name}</strong>{after}</>);
})()}
confirmLabel={t('category.delete')}
variant="danger"
loading={isDeleting}
/>
</>
);
}