v1.6.7: add settings.system permission, move numbering/VAT to company tab, rename security tab to roles

- New settings.system permission with migration (idempotent INSERT)
- requireAnyPermission helper for route guards accepting multiple perms
- Move document numbering + currency/VAT cards from system tab to CompanySettings
- Rename security tab to roles, add canManageSystem alongside canManageCompany
- TOTP required endpoint and system-info now use settings.system
- Roles list now includes user_count
- Sidebar Settings link includes settings.system

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-05-18 14:03:31 +02:00
parent 55648c9a30
commit 66b98e2765
10 changed files with 1041 additions and 962 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "1.6.6", "version": "1.6.7",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {

View File

@@ -0,0 +1,4 @@
-- Insert the settings.system permission (also available via seed)
INSERT INTO `permissions` (`name`, `display_name`, `module`, `description`)
VALUES ('settings.system', 'Systémová nastavení', 'settings', 'Spravovat systémová nastavení, 2FA, e-maily, limity')
ON DUPLICATE KEY UPDATE `name` = `name`;

View File

@@ -248,7 +248,13 @@ const PERMISSIONS: {
name: "settings.company", name: "settings.company",
display_name: "Nastavení společnosti", display_name: "Nastavení společnosti",
module: "settings", module: "settings",
description: "Upravovat údaje o společnosti, logo, 2FA", description: "Upravovat údaje o společnosti, logo",
},
{
name: "settings.system",
display_name: "Systémová nastavení",
module: "settings",
description: "Spravovat systémová nastavení, 2FA, e-maily, limity",
}, },
{ {
name: "settings.banking", name: "settings.banking",

View File

@@ -360,6 +360,7 @@ const menuSections: MenuSection[] = [
"settings.company", "settings.company",
"settings.banking", "settings.banking",
"settings.roles", "settings.roles",
"settings.system",
"settings.templates", "settings.templates",
], ],
icon: ( icon: (

View File

@@ -48,6 +48,16 @@ interface CompanyForm {
country: string; country: string;
company_id: string; company_id: string;
vat_id: string; vat_id: string;
offer_number_pattern: string;
order_number_pattern: string;
invoice_number_pattern: string;
quotation_prefix: string;
order_type_code: string;
invoice_type_code: string;
default_currency: string;
default_vat_rate: number;
available_currencies: string[];
available_vat_rates: number[];
} }
interface BankForm { interface BankForm {
@@ -65,6 +75,8 @@ export default function CompanySettings({
}: { embedded?: boolean } = {}) { }: { embedded?: boolean } = {}) {
const alert = useAlert(); const alert = useAlert();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canEditCompany = hasPermission("settings.company");
const canManageBanking = hasPermission("settings.banking");
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false); const [uploadingLogo, setUploadingLogo] = useState(false);
@@ -81,17 +93,21 @@ export default function CompanySettings({
country: "", country: "",
company_id: "", company_id: "",
vat_id: "", vat_id: "",
offer_number_pattern: "",
order_number_pattern: "",
invoice_number_pattern: "",
quotation_prefix: "",
order_type_code: "",
invoice_type_code: "",
default_currency: "CZK",
default_vat_rate: 21,
available_currencies: ["CZK", "EUR", "USD", "GBP"],
available_vat_rates: [0, 10, 12, 15, 21],
}); });
const [customFields, setCustomFields] = useState<CustomField[]>([]); const [customFields, setCustomFields] = useState<CustomField[]>([]);
const [fieldOrder, setFieldOrder] = useState<string[]>([ const [fieldOrder, setFieldOrder] = useState<string[]>([
...DEFAULT_FIELD_ORDER, ...DEFAULT_FIELD_ORDER,
]); ]);
const [availableCurrencies, setAvailableCurrencies] = useState<string[]>([
"CZK",
"EUR",
"USD",
"GBP",
]);
const [bankSaving, setBankSaving] = useState(false); const [bankSaving, setBankSaving] = useState(false);
const [editingBank, setEditingBank] = useState<number | null>(null); const [editingBank, setEditingBank] = useState<number | null>(null);
const [bankDeleteConfirm, setBankDeleteConfirm] = useState<{ const [bankDeleteConfirm, setBankDeleteConfirm] = useState<{
@@ -202,6 +218,24 @@ export default function CompanySettings({
country: settingsData.country || "", country: settingsData.country || "",
company_id: settingsData.company_id || "", company_id: settingsData.company_id || "",
vat_id: settingsData.vat_id || "", vat_id: settingsData.vat_id || "",
offer_number_pattern: settingsData.offer_number_pattern || "",
order_number_pattern: settingsData.order_number_pattern || "",
invoice_number_pattern: settingsData.invoice_number_pattern || "",
quotation_prefix: settingsData.quotation_prefix || "",
order_type_code: settingsData.order_type_code || "",
invoice_type_code: settingsData.invoice_type_code || "",
default_currency: settingsData.default_currency || "CZK",
default_vat_rate: settingsData.default_vat_rate ?? 21,
available_currencies:
Array.isArray(settingsData.available_currencies) &&
settingsData.available_currencies.length > 0
? settingsData.available_currencies
: ["CZK", "EUR", "USD", "GBP"],
available_vat_rates:
Array.isArray(settingsData.available_vat_rates) &&
settingsData.available_vat_rates.length > 0
? settingsData.available_vat_rates
: [0, 10, 12, 15, 21],
}); });
const cf: CustomField[] = const cf: CustomField[] =
Array.isArray(settingsData.custom_fields) && Array.isArray(settingsData.custom_fields) &&
@@ -221,12 +255,6 @@ export default function CompanySettings({
} else { } else {
setFieldOrder([...DEFAULT_FIELD_ORDER]); setFieldOrder([...DEFAULT_FIELD_ORDER]);
} }
if (
Array.isArray(settingsData.available_currencies) &&
settingsData.available_currencies.length > 0
) {
setAvailableCurrencies(settingsData.available_currencies);
}
if (settingsData.has_logo) { if (settingsData.has_logo) {
fetchLogo("light"); fetchLogo("light");
} }
@@ -406,7 +434,7 @@ export default function CompanySettings({
setForm((prev) => ({ ...prev, [field]: value })); setForm((prev) => ({ ...prev, [field]: value }));
}; };
if (!embedded && !hasPermission("settings.company")) return <Forbidden />; if (!embedded && !canEditCompany) return <Forbidden />;
if (settingsLoading) { if (settingsLoading) {
return ( return (
@@ -482,6 +510,7 @@ export default function CompanySettings({
<div className="admin-settings-grid"> <div className="admin-settings-grid">
{/* Company Info */} {/* Company Info */}
{(canEditCompany || !embedded) && (
<motion.div <motion.div
className="admin-card" className="admin-card"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
@@ -497,7 +526,9 @@ export default function CompanySettings({
<input <input
type="text" type="text"
value={form.company_name} value={form.company_name}
onChange={(e) => updateField("company_name", e.target.value)} onChange={(e) =>
updateField("company_name", e.target.value)
}
className="admin-form-input" className="admin-form-input"
/> />
</FormField> </FormField>
@@ -524,7 +555,9 @@ export default function CompanySettings({
<input <input
type="text" type="text"
value={form.postal_code} value={form.postal_code}
onChange={(e) => updateField("postal_code", e.target.value)} onChange={(e) =>
updateField("postal_code", e.target.value)
}
className="admin-form-input" className="admin-form-input"
/> />
</FormField> </FormField>
@@ -542,7 +575,9 @@ export default function CompanySettings({
<input <input
type="text" type="text"
value={form.company_id} value={form.company_id}
onChange={(e) => updateField("company_id", e.target.value)} onChange={(e) =>
updateField("company_id", e.target.value)
}
className="admin-form-input" className="admin-form-input"
/> />
</FormField> </FormField>
@@ -705,8 +740,10 @@ export default function CompanySettings({
</div> </div>
</div> </div>
</motion.div> </motion.div>
)}
{/* Bank Accounts */} {/* Bank Accounts */}
{(canManageBanking || !embedded) && (
<motion.div <motion.div
className="admin-card" className="admin-card"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
@@ -754,7 +791,9 @@ export default function CompanySettings({
> >
<td>{acc.account_name}</td> <td>{acc.account_name}</td>
<td>{acc.bank_name}</td> <td>{acc.bank_name}</td>
<td className="admin-mono">{acc.account_number}</td> <td className="admin-mono">
{acc.account_number}
</td>
<td className="admin-mono">{acc.iban}</td> <td className="admin-mono">{acc.iban}</td>
<td className="admin-mono">{acc.bic}</td> <td className="admin-mono">{acc.bic}</td>
<td>{acc.currency}</td> <td>{acc.currency}</td>
@@ -825,7 +864,9 @@ export default function CompanySettings({
className="text-secondary" className="text-secondary"
style={{ margin: "0 0 12px", fontSize: "0.9rem" }} style={{ margin: "0 0 12px", fontSize: "0.9rem" }}
> >
{editingBank !== null ? "Upravit účet" : "Přidat nový účet"} {editingBank !== null
? "Upravit účet"
: "Přidat nový účet"}
</h4> </h4>
<div className="admin-form"> <div className="admin-form">
<div className="admin-form-row"> <div className="admin-form-row">
@@ -884,7 +925,7 @@ export default function CompanySettings({
} }
className="admin-form-select" className="admin-form-select"
> >
{availableCurrencies.map((c) => ( {form.available_currencies.map((c) => (
<option key={c} value={c}> <option key={c} value={c}>
{c} {c}
</option> </option>
@@ -898,7 +939,10 @@ export default function CompanySettings({
type="text" type="text"
value={bankForm.iban} value={bankForm.iban}
onChange={(e) => onChange={(e) =>
setBankForm((f) => ({ ...f, iban: e.target.value })) setBankForm((f) => ({
...f,
iban: e.target.value,
}))
} }
className="admin-form-input" className="admin-form-input"
placeholder="CZ65 0800 0000 1920 0014 5399" placeholder="CZ65 0800 0000 1920 0014 5399"
@@ -909,7 +953,10 @@ export default function CompanySettings({
type="text" type="text"
value={bankForm.bic} value={bankForm.bic}
onChange={(e) => onChange={(e) =>
setBankForm((f) => ({ ...f, bic: e.target.value })) setBankForm((f) => ({
...f,
bic: e.target.value,
}))
} }
className="admin-form-input" className="admin-form-input"
placeholder="GIBACZPX" placeholder="GIBACZPX"
@@ -959,8 +1006,10 @@ export default function CompanySettings({
)} )}
</div> </div>
</motion.div> </motion.div>
)}
{/* PDF Field Order */} {/* PDF Field Order */}
{(canEditCompany || !embedded) && (
<motion.div <motion.div
className="admin-card" className="admin-card"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
@@ -1030,13 +1079,247 @@ export default function CompanySettings({
</div> </div>
</div> </div>
</motion.div> </motion.div>
)}
{/* Logo */} {/* Document Numbering */}
{(canEditCompany || !embedded) && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.1 }}
>
<div className="admin-card-header">
<h3 className="admin-card-title">Číslování dokladů</h3>
</div>
<div className="admin-card-body">
<div className="admin-form">
<div
style={{
padding: "0.75rem",
background: "var(--bg-tertiary)",
borderRadius: 8,
fontSize: "0.8rem",
color: "var(--text-secondary)",
marginBottom: "1rem",
}}
>
<strong>Dostupné zástupné znaky:</strong>{" "}
<code>{"{YYYY}"}</code> rok, <code>{"{YY}"}</code> rok (2
číslice), <code>{"{PREFIX}"}</code> prefix nabídky,{" "}
<code>{"{CODE}"}</code> typový kód, <code>{"{NNN}"}</code>{" "}
pořadí (3 číslice), <code>{"{NNNN}"}</code> pořadí (4
číslice), <code>{"{NNNNN}"}</code> pořadí (5 číslic)
</div>
{[
{
label: "Nabídky",
patternKey: "offer_number_pattern" as const,
defaultPattern: "{YYYY}/{PREFIX}/{NNN}",
prefixKey: "quotation_prefix" as const,
prefixLabel: "Prefix",
codeKey: null,
},
{
label: "Objednávky a projekty",
patternKey: "order_number_pattern" as const,
defaultPattern: "{YY}{CODE}{NNNN}",
prefixKey: null,
codeKey: "order_type_code" as const,
codeLabel: "Typový kód",
},
{
label: "Faktury",
patternKey: "invoice_number_pattern" as const,
defaultPattern: "{YY}{CODE}{NNNN}",
prefixKey: null,
codeKey: "invoice_type_code" as const,
codeLabel: "Typový kód",
},
].map((cfg, idx) => {
const pattern = form[cfg.patternKey] || cfg.defaultPattern;
const yyyy = String(new Date().getFullYear());
const yy = yyyy.slice(-2);
const prefix = cfg.prefixKey
? form[cfg.prefixKey] || "NA"
: "";
const code = cfg.codeKey ? form[cfg.codeKey] || "" : "";
const preview = pattern.replace(
/\{(\w+)\}/g,
(m: string, k: string) => {
if (k === "YYYY") return yyyy;
if (k === "YY") return yy;
if (k === "PREFIX") return prefix;
if (k === "CODE") return code;
if (/^N+$/.test(k)) return "1".padStart(k.length, "0");
return m;
},
);
return (
<div key={cfg.patternKey}>
{idx > 0 && (
<hr
style={{
border: "none",
borderTop: "1px solid var(--border-color)",
margin: "1rem 0",
}}
/>
)}
<div
className="fw-600 text-md"
style={{ marginBottom: "0.5rem" }}
>
{cfg.label}
</div>
<div className="admin-form-row">
<FormField label="Formát">
<input
type="text"
value={form[cfg.patternKey]}
onChange={(e) =>
updateField(cfg.patternKey, e.target.value)
}
className="admin-form-input"
placeholder={cfg.defaultPattern}
/>
</FormField>
{cfg.prefixKey && (
<FormField label={cfg.prefixLabel || "Prefix"}>
<input
type="text"
value={form[cfg.prefixKey]}
onChange={(e) =>
updateField(cfg.prefixKey, e.target.value)
}
className="admin-form-input"
style={{ maxWidth: 100 }}
/>
</FormField>
)}
{cfg.codeKey && (
<FormField label={cfg.codeLabel || "Kód"}>
<input
type="text"
value={form[cfg.codeKey]}
onChange={(e) =>
updateField(cfg.codeKey, e.target.value)
}
className="admin-form-input"
style={{ maxWidth: 100 }}
/>
</FormField>
)}
</div>
<small
style={{
color: "var(--text-tertiary)",
fontSize: "0.8rem",
}}
>
Ukázka:{" "}
<strong style={{ color: "var(--text-primary)" }}>
{preview}
</strong>
</small>
</div>
);
})}
</div>
</div>
</motion.div>
)}
{/* Currency & VAT */}
{(canEditCompany || !embedded) && (
<motion.div <motion.div
className="admin-card" className="admin-card"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }} transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-header">
<h3 className="admin-card-title">Měna a DPH</h3>
</div>
<div className="admin-card-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Výchozí měna">
<select
value={form.default_currency}
onChange={(e) =>
updateField("default_currency", e.target.value)
}
className="admin-form-input"
>
{form.available_currencies.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</FormField>
<FormField label="Výchozí sazba DPH (%)">
<input
type="number"
value={form.default_vat_rate}
onChange={(e) =>
updateField("default_vat_rate", Number(e.target.value))
}
className="admin-form-input"
min={0}
step={1}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Dostupné měny">
<input
type="text"
value={form.available_currencies.join(", ")}
onChange={(e) =>
setForm((prev) => ({
...prev,
available_currencies: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
}))
}
className="admin-form-input"
placeholder="CZK, EUR, USD"
/>
</FormField>
<FormField label="Dostupné sazby DPH (%)">
<input
type="text"
value={form.available_vat_rates.join(", ")}
onChange={(e) =>
setForm((prev) => ({
...prev,
available_vat_rates: e.target.value
.split(",")
.map((s) => Number(s.trim()))
.filter((n) => !isNaN(n)),
}))
}
className="admin-form-input"
placeholder="0, 12, 21"
/>
</FormField>
</div>
</div>
</div>
</motion.div>
)}
{/* Logo */}
{(canEditCompany || !embedded) && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.14 }}
> >
<div className="admin-card-header"> <div className="admin-card-header">
<h3 className="admin-card-title">Logo</h3> <h3 className="admin-card-title">Logo</h3>
@@ -1146,9 +1429,10 @@ export default function CompanySettings({
</div> </div>
</div> </div>
</motion.div> </motion.div>
)}
</div> </div>
{embedded && ( {embedded && canEditCompany && (
<motion.div <motion.div
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}

View File

@@ -71,6 +71,7 @@ interface Role {
description: string | null; description: string | null;
permissions: Permission[]; permissions: Permission[];
role_permissions?: unknown[]; role_permissions?: unknown[];
user_count?: number;
} }
interface RoleForm { interface RoleForm {
@@ -109,38 +110,44 @@ export default function Settings() {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canManage = hasPermission("settings.roles"); const canManageRoles = hasPermission("settings.roles");
const canManageCompany = hasPermission("settings.company");
const canManageSystem = hasPermission("settings.system");
const canManageBanking = hasPermission("settings.banking");
const canAccessSettings =
canManageRoles || canManageCompany || canManageSystem || canManageBanking;
// ── TanStack Query: roles, permissions, users ── const availableTabs = useMemo(() => {
const tabs: Array<"roles" | "system" | "firma"> = [];
if (canManageRoles) tabs.push("roles");
if (canManageSystem) tabs.push("system");
if (canManageCompany || canManageBanking) tabs.push("firma");
return tabs;
}, [canManageRoles, canManageCompany, canManageSystem, canManageBanking]);
// ── TanStack Query: roles, permissions ──
const { data: rolesData, isPending: rolesLoading } = useQuery({ const { data: rolesData, isPending: rolesLoading } = useQuery({
queryKey: ["settings", "roles"], queryKey: ["settings", "roles"],
queryFn: async () => { queryFn: async () => {
const [rolesRes, permsRes, usersRes] = await Promise.all([ const [rolesRes, permsRes] = await Promise.all([
apiFetch(`${API_BASE}/roles`), apiFetch(`${API_BASE}/roles`),
apiFetch(`${API_BASE}/roles/permissions`), apiFetch(`${API_BASE}/roles/permissions`),
apiFetch(`${API_BASE}/users`),
]); ]);
const rolesResult = await rolesRes.json(); const rolesResult = await rolesRes.json();
const permsResult = await permsRes.json(); const permsResult = await permsRes.json();
const usersResult = await usersRes.json();
if (!rolesResult.success) if (!rolesResult.success)
throw new Error(rolesResult.error || "Nepodařilo se načíst role"); throw new Error(rolesResult.error || "Nepodařilo se načíst role");
if (!permsResult.success) if (!permsResult.success)
throw new Error(permsResult.error || "Nepodařilo se načíst oprávnění"); throw new Error(permsResult.error || "Nepodařilo se načíst oprávnění");
if (!usersResult.success)
throw new Error(usersResult.error || "Nepodařilo se načíst uživatele");
return { return {
roles: Array.isArray(rolesResult.data) ? rolesResult.data : [], roles: Array.isArray(rolesResult.data) ? rolesResult.data : [],
permissions: Array.isArray(permsResult.data) ? permsResult.data : [], permissions: Array.isArray(permsResult.data) ? permsResult.data : [],
users: Array.isArray(usersResult.data) ? usersResult.data : [],
}; };
}, },
enabled: canManage, enabled: canManageRoles,
}); });
const roles = rolesData?.roles ?? []; const roles = rolesData?.roles ?? [];
const users = rolesData?.users ?? [];
// Group permissions by module // Group permissions by module
const permissionGroups = useMemo<Record<string, Permission[]>>(() => { const permissionGroups = useMemo<Record<string, Permission[]>>(() => {
const perms: Permission[] = rolesData?.permissions ?? []; const perms: Permission[] = rolesData?.permissions ?? [];
@@ -158,27 +165,32 @@ export default function Settings() {
useQuery(require2FAOptions()); useQuery(require2FAOptions());
const require2FA = totpData?.require_2fa ?? false; const require2FA = totpData?.require_2fa ?? false;
// ── TanStack Query: system settings (lazy, only on system/security tab) ── // ── TanStack Query: system settings (lazy, only on system/roles tab) ──
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get("tab"); const tabParam = searchParams.get("tab");
const defaultTab = availableTabs[0] ?? "roles";
const activeTab = ( const activeTab = (
tabParam === "system" tabParam === "roles" && availableTabs.includes("roles")
? "roles"
: tabParam === "system" && availableTabs.includes("system")
? "system" ? "system"
: tabParam === "firma" : tabParam === "firma" && availableTabs.includes("firma")
? "firma" ? "firma"
: "security" : defaultTab
) as "security" | "system" | "firma"; ) as "roles" | "system" | "firma";
const setActiveTab = (tab: "security" | "system" | "firma") => const setActiveTab = (tab: "roles" | "system" | "firma") =>
setSearchParams({ tab }, { replace: true }); setSearchParams({ tab }, { replace: true });
const { data: sysSettingsData, isPending: sysSettingsLoading } = useQuery({ const { data: sysSettingsData, isPending: sysSettingsLoading } = useQuery({
...companySettingsOptions(), ...companySettingsOptions(),
enabled: canManage && (activeTab === "system" || activeTab === "security"), enabled:
(canManageRoles || canManageSystem) &&
(activeTab === "system" || activeTab === "roles"),
}); });
const { data: systemInfo } = useQuery({ const { data: systemInfo } = useQuery({
...systemInfoOptions(), ...systemInfoOptions(),
enabled: canManage && activeTab === "system", enabled: canManageSystem && activeTab === "system",
}); });
// ── Local state ── // ── Local state ──
@@ -247,7 +259,7 @@ export default function Settings() {
useModalLock(showModal); useModalLock(showModal);
// ── Early return after all hooks ── // ── Early return after all hooks ──
if (!canManage) { if (!canAccessSettings) {
return <Navigate to="/" replace />; return <Navigate to="/" replace />;
} }
@@ -453,7 +465,7 @@ export default function Settings() {
} }
}; };
if (rolesLoading) { if (canManageRoles && rolesLoading) {
return ( return (
<Skeleton <Skeleton
name="settings" name="settings"
@@ -516,36 +528,42 @@ export default function Settings() {
? "Systémová nastavení" ? "Systémová nastavení"
: activeTab === "firma" : activeTab === "firma"
? "Informace o firmě" ? "Informace o firmě"
: "Zabezpečení a správa rolí"} : "Role"}
</p> </p>
</div> </div>
</motion.div> </motion.div>
{canManage && ( {availableTabs.length > 0 && (
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem" }}> <div style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem" }}>
{availableTabs.includes("roles") && (
<button <button
className={`admin-btn admin-btn-sm ${activeTab === "security" ? "admin-btn-primary" : "admin-btn-secondary"}`} className={`admin-btn admin-btn-sm ${activeTab === "roles" ? "admin-btn-primary" : "admin-btn-secondary"}`}
onClick={() => setActiveTab("security")} onClick={() => setActiveTab("roles")}
> >
Zabezpečení a role Role
</button> </button>
)}
{availableTabs.includes("system") && (
<button <button
className={`admin-btn admin-btn-sm ${activeTab === "system" ? "admin-btn-primary" : "admin-btn-secondary"}`} className={`admin-btn admin-btn-sm ${activeTab === "system" ? "admin-btn-primary" : "admin-btn-secondary"}`}
onClick={() => setActiveTab("system")} onClick={() => setActiveTab("system")}
> >
Systémová nastavení Systémová nastavení
</button> </button>
)}
{availableTabs.includes("firma") && (
<button <button
className={`admin-btn admin-btn-sm ${activeTab === "firma" ? "admin-btn-primary" : "admin-btn-secondary"}`} className={`admin-btn admin-btn-sm ${activeTab === "firma" ? "admin-btn-primary" : "admin-btn-secondary"}`}
onClick={() => setActiveTab("firma")} onClick={() => setActiveTab("firma")}
> >
Firma Firma
</button> </button>
)}
</div> </div>
)} )}
{/* Security Settings */} {/* Security Settings */}
{activeTab === "security" && canManage && ( {activeTab === "system" && canManageSystem && (
<motion.div <motion.div
className="admin-card" className="admin-card"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
@@ -629,7 +647,7 @@ export default function Settings() {
)} )}
{/* Login Security */} {/* Login Security */}
{activeTab === "security" && canManage && ( {activeTab === "system" && canManageSystem && (
<motion.div <motion.div
className="admin-card" className="admin-card"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
@@ -686,7 +704,7 @@ export default function Settings() {
)} )}
{/* Roles Table */} {/* Roles Table */}
{activeTab === "security" && canManage && ( {activeTab === "roles" && (
<motion.div <motion.div
className="admin-card" className="admin-card"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
@@ -765,11 +783,7 @@ export default function Settings() {
</td> </td>
<td> <td>
<span className="admin-badge admin-badge-secondary"> <span className="admin-badge admin-badge-secondary">
{ {role.user_count ?? 0}
users.filter(
(u: { role_id: number }) => u.role_id === role.id,
).length
}
</span> </span>
</td> </td>
<td> <td>
@@ -799,27 +813,16 @@ export default function Settings() {
} }
className="admin-btn-icon danger" className="admin-btn-icon danger"
title={ title={
users.filter( (role.user_count ?? 0) > 0
(u: { role_id: number }) =>
u.role_id === role.id,
).length > 0
? "Nelze smazat roli s přiřazenými uživateli" ? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat" : "Smazat"
} }
aria-label={ aria-label={
users.filter( (role.user_count ?? 0) > 0
(u: { role_id: number }) =>
u.role_id === role.id,
).length > 0
? "Nelze smazat roli s přiřazenými uživateli" ? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat" : "Smazat"
} }
disabled={ disabled={(role.user_count ?? 0) > 0}
users.filter(
(u: { role_id: number }) =>
u.role_id === role.id,
).length > 0
}
> >
<svg <svg
width="16" width="16"
@@ -846,7 +849,7 @@ export default function Settings() {
)} )}
{/* System Settings Tab */} {/* System Settings Tab */}
{activeTab === "system" && canManage && ( {activeTab === "system" && (
<> <>
{sysSettingsLoading && !sysFormInitialized ? ( {sysSettingsLoading && !sysFormInitialized ? (
<Skeleton <Skeleton
@@ -1052,260 +1055,7 @@ export default function Settings() {
</div> </div>
</motion.div> </motion.div>
{/* Section 5: Číslování dokladů */} {/* Section 5: Informace o aplikaci */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.28 }}
>
<div className="admin-card-header">
<h2 className="admin-card-title">Číslování dokladů</h2>
</div>
<div className="admin-card-body">
<div className="admin-form">
<div
style={{
padding: "0.75rem",
background: "var(--bg-tertiary)",
borderRadius: 8,
fontSize: "0.8rem",
color: "var(--text-secondary)",
marginBottom: "1rem",
}}
>
<strong>Dostupné zástupné znaky:</strong>{" "}
<code>{"{YYYY}"}</code> rok, <code>{"{YY}"}</code> rok (2
číslice), <code>{"{PREFIX}"}</code> prefix nabídky,{" "}
<code>{"{CODE}"}</code> typový kód, <code>{"{NNN}"}</code>{" "}
pořadí (3 číslice), <code>{"{NNNN}"}</code> pořadí (4
číslice), <code>{"{NNNNN}"}</code> pořadí (5 číslic)
</div>
{[
{
label: "Nabídky",
patternKey: "offer_number_pattern" as const,
defaultPattern: "{YYYY}/{PREFIX}/{NNN}",
prefixKey: "quotation_prefix" as const,
prefixLabel: "Prefix",
codeKey: null,
},
{
label: "Objednávky a projekty",
patternKey: "order_number_pattern" as const,
defaultPattern: "{YY}{CODE}{NNNN}",
prefixKey: null,
codeKey: "order_type_code" as const,
codeLabel: "Typový kód",
},
{
label: "Faktury",
patternKey: "invoice_number_pattern" as const,
defaultPattern: "{YY}{CODE}{NNNN}",
prefixKey: null,
codeKey: "invoice_type_code" as const,
codeLabel: "Typový kód",
},
].map((cfg, idx) => {
const pattern =
sysForm[cfg.patternKey] || cfg.defaultPattern;
const yyyy = String(new Date().getFullYear());
const yy = yyyy.slice(-2);
const prefix = cfg.prefixKey
? sysForm[cfg.prefixKey] || "NA"
: "";
const code = cfg.codeKey
? sysForm[cfg.codeKey] || ""
: "";
const preview = pattern.replace(
/\{(\w+)\}/g,
(m: string, k: string) => {
if (k === "YYYY") return yyyy;
if (k === "YY") return yy;
if (k === "PREFIX") return prefix;
if (k === "CODE") return code;
if (/^N+$/.test(k))
return "1".padStart(k.length, "0");
return m;
},
);
return (
<div key={cfg.patternKey}>
{idx > 0 && (
<hr
style={{
border: "none",
borderTop: "1px solid var(--border-color)",
margin: "1rem 0",
}}
/>
)}
<div
className="fw-600 text-md"
style={{
marginBottom: "0.5rem",
}}
>
{cfg.label}
</div>
<div className="admin-form-row">
<FormField label="Formát">
<input
type="text"
value={sysForm[cfg.patternKey]}
onChange={(e) =>
setSysForm((p) => ({
...p,
[cfg.patternKey]: e.target.value,
}))
}
className="admin-form-input"
placeholder={cfg.defaultPattern}
/>
</FormField>
{cfg.prefixKey && (
<FormField label="Prefix">
<input
type="text"
value={sysForm[cfg.prefixKey]}
onChange={(e) =>
setSysForm((p) => ({
...p,
[cfg.prefixKey!]: e.target.value,
}))
}
className="admin-form-input"
style={{ maxWidth: 100 }}
/>
</FormField>
)}
{cfg.codeKey && (
<FormField label={cfg.codeLabel || "Kód"}>
<input
type="text"
value={sysForm[cfg.codeKey]}
onChange={(e) =>
setSysForm((p) => ({
...p,
[cfg.codeKey!]: e.target.value,
}))
}
className="admin-form-input"
style={{ maxWidth: 100 }}
/>
</FormField>
)}
</div>
<small
style={{
color: "var(--text-tertiary)",
fontSize: "0.8rem",
}}
>
Ukázka:{" "}
<strong style={{ color: "var(--text-primary)" }}>
{preview}
</strong>
</small>
</div>
);
})}
</div>
</div>
</motion.div>
{/* Section 6: Měna a DPH */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.3 }}
>
<div className="admin-card-header">
<h2 className="admin-card-title">Měna a DPH</h2>
</div>
<div className="admin-card-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Výchozí měna">
<select
value={sysForm.default_currency}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
default_currency: e.target.value,
}))
}
className="admin-form-input"
>
{sysForm.available_currencies.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</FormField>
<FormField label="Výchozí sazba DPH (%)">
<input
type="number"
value={sysForm.default_vat_rate}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
default_vat_rate: Number(e.target.value),
}))
}
className="admin-form-input"
min={0}
step={1}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Dostupné měny">
<input
type="text"
value={sysForm.available_currencies.join(", ")}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
available_currencies: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
}))
}
className="admin-form-input"
placeholder="CZK, EUR, USD"
/>
</FormField>
<FormField label="Dostupné sazby DPH (%)">
<input
type="text"
value={sysForm.available_vat_rates.join(", ")}
onChange={(e) =>
setSysForm((prev) => ({
...prev,
available_vat_rates: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map(Number)
.filter((n) => !isNaN(n)),
}))
}
className="admin-form-input"
placeholder="0, 12, 21"
/>
</FormField>
</div>
</div>
</div>
</motion.div>
{/* Section 6: Informace o aplikaci */}
<motion.div <motion.div
className="admin-card" className="admin-card"
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
@@ -1615,7 +1365,7 @@ export default function Settings() {
)} )}
{/* Firma tab */} {/* Firma tab */}
{activeTab === "firma" && canManage && <CompanySettings embedded />} {activeTab === "firma" && <CompanySettings embedded />}
{/* Create/Edit Modal */} {/* Create/Edit Modal */}
<AnimatePresence> <AnimatePresence>

View File

@@ -54,3 +54,25 @@ export function requirePermission(...permissionNames: string[]) {
} }
}; };
} }
export function requireAnyPermission(...permissionNames: string[]) {
return async (
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> => {
await requireAuth(request, reply);
if (reply.sent) return;
const authData = request.authData!;
// Admin has all permissions
if (authData.roleName === "admin") return;
const hasAny = permissionNames.some((p) =>
authData.permissions.includes(p),
);
if (!hasAny) {
return error(reply, "Nedostatečná oprávnění", 403);
}
};
}

View File

@@ -3,6 +3,7 @@ import prisma from "../../config/database";
import { import {
requireAuth, requireAuth,
requirePermission, requirePermission,
requireAnyPermission,
optionalAuth, optionalAuth,
} from "../../middleware/auth"; } from "../../middleware/auth";
import { logAudit } from "../../services/audit"; import { logAudit } from "../../services/audit";
@@ -333,7 +334,7 @@ export default async function companySettingsRoutes(
// GET /api/admin/company-settings/system-info // GET /api/admin/company-settings/system-info
fastify.get( fastify.get(
"/system-info", "/system-info",
{ preHandler: requirePermission("settings.company") }, { preHandler: requirePermission("settings.system") },
async (request, reply) => { async (request, reply) => {
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const pkg = require("../../../package.json") as { version: string }; const pkg = require("../../../package.json") as { version: string };
@@ -404,7 +405,7 @@ export default async function companySettingsRoutes(
fastify.put( fastify.put(
"/", "/",
{ preHandler: requirePermission("settings.company") }, { preHandler: requireAnyPermission("settings.company", "settings.system") },
async (request, reply) => { async (request, reply) => {
const parsed = parseBody(UpdateCompanySettingsSchema, request.body); const parsed = parseBody(UpdateCompanySettingsSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);

View File

@@ -14,18 +14,29 @@ export default async function rolesRoutes(
"/", "/",
{ preHandler: requirePermission("settings.roles") }, { preHandler: requirePermission("settings.roles") },
async (request, reply) => { async (request, reply) => {
const roles = await prisma.roles.findMany({ const [roles, userCounts] = await Promise.all([
prisma.roles.findMany({
include: { include: {
role_permissions: { role_permissions: {
include: { permissions: true }, include: { permissions: true },
}, },
}, },
orderBy: { id: "asc" }, orderBy: { id: "asc" },
}); }),
prisma.users.groupBy({
by: ["role_id"],
_count: { id: true },
}),
]);
const countMap = new Map(
userCounts.map((u) => [u.role_id, Number(u._count.id)]),
);
const data = roles.map((r) => ({ const data = roles.map((r) => ({
...r, ...r,
permissions: r.role_permissions.map((rp) => rp.permissions), permissions: r.role_permissions.map((rp) => rp.permissions),
user_count: countMap.get(r.id) || 0,
})); }));
return success(reply, data); return success(reply, data);
@@ -38,7 +49,7 @@ export default async function rolesRoutes(
{ preHandler: requirePermission("settings.roles") }, { preHandler: requirePermission("settings.roles") },
async (_request, reply) => { async (_request, reply) => {
const permissions = await prisma.permissions.findMany({ const permissions = await prisma.permissions.findMany({
orderBy: [{ module: "asc" }, { id: "asc"}] orderBy: [{ module: "asc" }, { id: "asc" }],
}); });
return success(reply, permissions); return success(reply, permissions);
}, },

View File

@@ -207,7 +207,7 @@ export default async function totpRoutes(
fastify.post( fastify.post(
"/required", "/required",
{ {
preHandler: [requireAuth, requirePermission("settings.company")], preHandler: [requireAuth, requirePermission("settings.system")],
bodyLimit: 10240, bodyLimit: 10240,
}, },
async (request, reply) => { async (request, reply) => {