design(verwaltung): Einstellungen und Verwaltung aufgeraeumt

Gemeinsame Navigation fuer Einstellungen und Verwaltung (Verwaltung in
Benutzer und Zugang / Module / Anbindungen gegliedert), einheitlicher
Seitenkopf und Abschnittskarten, Speichern in der Kartenfusszeile.
Menuepunkt 'Administrator' heisst 'Verwaltung'; Seitentitel gleich den
Navigationseintraegen. Kein 'Zurueck zum Dashboard' mehr. Tabellen ohne
waagerechte Seitenscrollleiste; auf dem Handy Auswahlliste statt Liste.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-05 13:36:05 +02:00
parent 831c7b8f52
commit 0a35a32afb
32 changed files with 2579 additions and 2170 deletions
+33 -33
View File
@@ -2,6 +2,8 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { SettingsSection } from '@/components/control-center/settings-section';
import { useAuthStore } from '@/lib/stores/auth-store';
import { GroupFormModal } from './components/GroupFormModal';
import { GroupMembersModal } from './components/GroupMembersModal';
@@ -30,6 +32,7 @@ export interface Group {
export default function AdminGroupsPage() {
const t = useTranslations('admin.groups');
const tCommon = useTranslations('common');
const tCc = useTranslations('controlCenter');
const currentUser = useAuthStore((s) => s.user);
const [groups, setGroups] = useState<Group[]>([]);
@@ -122,20 +125,19 @@ export default function AdminGroupsPage() {
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<button
type="button"
onClick={openCreate}
className="btn btn-primary"
>
<div>
<PageHeader
title={t('title')}
description={tCc('descriptions.groups')}
actions={
<button type="button" onClick={openCreate} className="btn btn-primary">
{t('create')}
</button>
</div>
}
/>
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<div className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
@@ -143,35 +145,33 @@ export default function AdminGroupsPage() {
{loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p>
) : groups.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<SettingsSection>
<div className="flex flex-col items-center justify-center py-10 text-center">
<h2 className="text-lg font-semibold text-foreground mb-2">{t('noGroups')}</h2>
<p className="text-sm text-muted-foreground mb-6">{t('noGroupsBody')}</p>
<button
type="button"
onClick={openCreate}
className="btn btn-primary"
>
<button type="button" onClick={openCreate} className="btn btn-secondary">
{t('create')}
</button>
</div>
</SettingsSection>
) : (
<div className="overflow-x-auto rounded-md border border-border">
<SettingsSection flush>
<table className="w-full text-sm">
<thead className="bg-muted/50">
<thead className="border-b border-border">
<tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('name')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('ldapBinding')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('defaultGroup')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('memberCount')}
</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
<th className="px-4 pb-2 text-right text-xs font-medium text-muted-foreground">
{t('actions')}
</th>
</tr>
@@ -179,10 +179,10 @@ export default function AdminGroupsPage() {
<tbody className="divide-y divide-border">
{groups.map((group) => (
<tr key={group.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-medium text-foreground">
<td className="px-4 py-2.5 font-medium text-foreground">
<span title={group.name}>{group.internalName ?? group.name}</span>
</td>
<td className="px-4 py-3">
<td className="px-4 py-2.5">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
group.ldapDn
@@ -193,7 +193,7 @@ export default function AdminGroupsPage() {
{group.ldapDn ? t('boundBadge') : t('manualBadge')}
</span>
</td>
<td className="px-4 py-3">
<td className="px-4 py-2.5">
<button
type="button"
onClick={() => handleToggleDefault(group)}
@@ -222,27 +222,27 @@ export default function AdminGroupsPage() {
</svg>
</button>
</td>
<td className="px-4 py-3 text-muted-foreground">{group.memberCount}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<td className="px-4 py-2.5 text-muted-foreground">{group.memberCount}</td>
<td className="px-4 py-2.5 text-right">
<div className="flex items-center justify-end gap-1">
<button
type="button"
onClick={() => openEdit(group)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
className="btn btn-subtle h-7 px-2 text-xs font-medium"
>
{tCommon('edit')}
</button>
<button
type="button"
onClick={() => setMembersGroup(group)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
className="btn btn-subtle h-7 px-2 text-xs font-medium"
>
{t('membersButton')}
</button>
<button
type="button"
onClick={() => setDeleteTarget(group)}
className="rounded px-2 py-1 text-xs text-destructive hover:bg-destructive/10 transition-colors"
className="btn btn-subtle h-7 px-2 text-xs font-medium text-destructive hover:bg-destructive/10"
>
{tCommon('delete')}
</button>
@@ -252,7 +252,7 @@ export default function AdminGroupsPage() {
))}
</tbody>
</table>
</div>
</SettingsSection>
)}
{showFormModal && (
+7 -40
View File
@@ -1,42 +1,9 @@
'use client';
import { ControlCenterLayout } from '@/components/control-center/control-center-layout';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { AdminSidebar } from '@/components/admin/admin-sidebar';
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const t = useTranslations('settings');
return (
<div className="flex h-full flex-col md:flex-row">
<AdminSidebar />
<div className="min-w-0 flex-1 overflow-y-auto p-3 sm:p-6">
<Link
href="/"
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
{t('backToDashboard')}
</Link>
{children}
</div>
</div>
);
/**
* Einstellungen und Verwaltung teilen sich einen Rahmen mit einer
* gemeinsamen Navigation (siehe ControlCenterLayout).
*/
export default function Layout({ children }: { children: React.ReactNode }) {
return <ControlCenterLayout>{children}</ControlCenterLayout>;
}
+235 -226
View File
@@ -3,6 +3,8 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/lib/stores/auth-store';
import { PageHeader } from '@/components/control-center/page-header';
import { SettingsSection } from '@/components/control-center/settings-section';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -89,6 +91,7 @@ interface SyncResult {
export default function AdminLdapPage() {
const t = useTranslations('admin.ldap');
const tCommon = useTranslations('common');
const tCc = useTranslations('controlCenter');
const currentUser = useAuthStore((s) => s.user);
const [config, setConfig] = useState<LdapConfig | null>(null);
@@ -545,19 +548,40 @@ export default function AdminLdapPage() {
);
}
const inputClass =
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm';
const labelClass = 'mb-1 block text-sm font-medium text-foreground';
const smallLabelClass = 'mb-1 block text-xs font-medium text-muted-foreground';
return (
<div className="space-y-8">
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<div>
<PageHeader title={t('title')} description={tCc('descriptions.ldap')} />
{/* Section 1: Connection Settings */}
<section className="rounded-lg border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-4">
{t('connectionTitle')}
</h2>
<form onSubmit={handleSave} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label htmlFor="ldap-server-url" className="text-sm font-medium text-foreground">
<form onSubmit={handleSave}>
<SettingsSection
id="ldap-connection"
title={t('connectionTitle')}
description={tCc('ldap.connectionHelp')}
footer={
<>
<button
type="button"
onClick={handleTestConnection}
disabled={!config && !formData.serverUrl}
className="btn btn-secondary"
>
{t('testConnection')}
</button>
<button type="submit" disabled={saving} className="btn btn-primary">
{saving ? tCommon('loading') : t('save')}
</button>
</>
}
>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="ldap-server-url" className={labelClass}>
{t('serverUrl')}
</label>
<input
@@ -566,12 +590,12 @@ export default function AdminLdapPage() {
value={formData.serverUrl}
onChange={(e) => setFormData({ ...formData, serverUrl: e.target.value })}
placeholder="ldap://ldap.example.com"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
className={inputClass}
required
/>
</div>
<div className="space-y-2">
<label htmlFor="ldap-base-dn" className="text-sm font-medium text-foreground">
<div>
<label htmlFor="ldap-base-dn" className={labelClass}>
{t('baseDn')}
</label>
<textarea
@@ -583,10 +607,10 @@ export default function AdminLdapPage() {
className="flex min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
required
/>
<p className="text-xs text-muted-foreground">{t('baseDnHint')}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('baseDnHint')}</p>
</div>
<div className="space-y-2">
<label htmlFor="ldap-bind-dn" className="text-sm font-medium text-foreground">
<div>
<label htmlFor="ldap-bind-dn" className={labelClass}>
{t('bindDn')}
</label>
<input
@@ -595,11 +619,11 @@ export default function AdminLdapPage() {
value={formData.bindDn}
onChange={(e) => setFormData({ ...formData, bindDn: e.target.value })}
placeholder="cn=admin,dc=example,dc=com (leer = anonymous bind)"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
className={inputClass}
/>
</div>
<div className="space-y-2">
<label htmlFor="ldap-bind-password" className="text-sm font-medium text-foreground">
<div>
<label htmlFor="ldap-bind-password" className={labelClass}>
{t('bindPassword')}
</label>
<input
@@ -608,12 +632,14 @@ export default function AdminLdapPage() {
value={formData.bindPassword}
onChange={(e) => setFormData({ ...formData, bindPassword: e.target.value })}
placeholder={config?.bindPassword ? '********' : ''}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
className={inputClass}
/>
</div>
</div>
<div className="space-y-2">
<label htmlFor="ldap-search-filter" className="text-sm font-medium text-foreground">
<div className="mt-4 space-y-4">
<div>
<label htmlFor="ldap-search-filter" className={labelClass}>
{t('searchFilter')}
</label>
<input
@@ -621,11 +647,11 @@ export default function AdminLdapPage() {
type="text"
value={formData.searchFilter}
onChange={(e) => setFormData({ ...formData, searchFilter: e.target.value })}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
className={`${inputClass} max-w-md`}
/>
</div>
<div className="space-y-1">
<div>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
@@ -639,30 +665,12 @@ export default function AdminLdapPage() {
/>
{t('tlsSkip.label')}
</label>
<p className="text-xs text-muted-foreground">{t('tlsSkip.hint')}</p>
</div>
<div className="flex items-center gap-4 pt-2">
<button
type="submit"
disabled={saving}
className="btn btn-primary"
>
{saving ? tCommon('loading') : t('save')}
</button>
<button
type="button"
onClick={handleTestConnection}
disabled={!config && !formData.serverUrl}
className="btn btn-secondary"
>
{t('testConnection')}
</button>
<p className="mt-1 text-xs text-muted-foreground">{t('tlsSkip.hint')}</p>
</div>
{testResult && (
<div
className={`mt-2 rounded-md px-4 py-2 text-sm ${
className={`rounded-md px-4 py-2 text-sm ${
testResult.success
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
@@ -671,39 +679,41 @@ export default function AdminLdapPage() {
{testResult.success ? t('testSuccess') : `${t('testFailed')}: ${testResult.error}`}
</div>
)}
</div>
</SettingsSection>
</form>
</section>
{/* Section 2: Field Mapping (D-16, D-17) */}
{config && (
<section className="rounded-lg border border-border p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-foreground">
{t('fieldMapping.title')}
</h2>
<SettingsSection
id="ldap-field-mapping"
flush
title={t('fieldMapping.title')}
description={tCc('ldap.fieldMappingHelp')}
actions={
<button
type="button"
onClick={() => setShowMappingForm(true)}
className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90 transition-opacity"
className="btn btn-secondary"
>
{t('fieldMapping.add')}
</button>
</div>
<div className="overflow-x-auto rounded-md border border-border">
}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-5 py-2.5 text-left font-medium text-muted-foreground">
{t('fieldMapping.ldapField')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-5 py-2.5 text-left font-medium text-muted-foreground">
{t('fieldMapping.tesseraField')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-5 py-2.5 text-left font-medium text-muted-foreground">
{t('fieldMapping.default')}
</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
<th className="px-5 py-2.5 text-right font-medium text-muted-foreground">
{tCommon('actions')}
</th>
</tr>
@@ -711,13 +721,13 @@ export default function AdminLdapPage() {
<tbody className="divide-y divide-border">
{config.fieldMappings.map((mapping) => (
<tr key={mapping.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-mono text-foreground">
<td className="px-5 py-2.5 font-mono text-foreground">
{mapping.ldapField}
</td>
<td className="px-4 py-3 font-mono text-foreground">
<td className="px-5 py-2.5 font-mono text-foreground">
{mapping.tesseraField}
</td>
<td className="px-4 py-3">
<td className="px-5 py-2.5">
{mapping.isDefault && (
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -737,7 +747,7 @@ export default function AdminLdapPage() {
</svg>
)}
</td>
<td className="px-4 py-3 text-right">
<td className="px-5 py-2.5 text-right">
{!mapping.isDefault && (
<button
type="button"
@@ -756,9 +766,12 @@ export default function AdminLdapPage() {
{/* Add mapping form */}
{showMappingForm && (
<form onSubmit={handleAddMapping} className="mt-4 flex items-end gap-3">
<div className="space-y-1">
<label htmlFor="ldap-mapping-ldap-field" className="text-xs font-medium text-muted-foreground">
<form
onSubmit={handleAddMapping}
className="flex flex-wrap items-end gap-3 border-t border-border px-5 py-4"
>
<div>
<label htmlFor="ldap-mapping-ldap-field" className={smallLabelClass}>
{t('fieldMapping.ldapField')}
</label>
<input
@@ -770,8 +783,8 @@ export default function AdminLdapPage() {
required
/>
</div>
<div className="space-y-1">
<label htmlFor="ldap-mapping-tessera-field" className="text-xs font-medium text-muted-foreground">
<div>
<label htmlFor="ldap-mapping-tessera-field" className={smallLabelClass}>
{t('fieldMapping.tesseraField')}
</label>
<input
@@ -783,35 +796,40 @@ export default function AdminLdapPage() {
required
/>
</div>
<button
type="submit"
className="h-9 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:opacity-90 transition-opacity"
>
{tCommon('save')}
</button>
<button
type="button"
onClick={() => setShowMappingForm(false)}
className="h-9 rounded-md border border-border px-3 text-xs text-foreground hover:bg-muted transition-colors"
className="btn btn-secondary"
>
{tCommon('cancel')}
</button>
<button type="submit" className="btn btn-primary">
{tCommon('save')}
</button>
</form>
)}
</section>
</SettingsSection>
)}
{/* Section 2.5: Group/OU import filter (selective sync) */}
{config && (
<section className="rounded-lg border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{t('groupFilter.title')}
</h2>
<p className="text-sm text-muted-foreground mb-4">
{t('groupFilter.description')}
</p>
<div className="flex items-center gap-3 mb-4">
<SettingsSection
id="ldap-group-filter"
title={t('groupFilter.title')}
description={t('groupFilter.description')}
footer={
<button
type="button"
onClick={handleSaveGroupFilter}
disabled={savingFilter}
className="btn btn-primary"
>
{savingFilter ? tCommon('loading') : t('groupFilter.save')}
</button>
}
>
<div className="space-y-4">
<div>
<button
type="button"
onClick={handleDiscoverGroups}
@@ -823,13 +841,13 @@ export default function AdminLdapPage() {
</div>
{discovered && discovered.length > 0 && (
<div className="mb-4 space-y-2">
<div className="space-y-2">
<input
type="text"
value={discoverSearch}
onChange={(e) => setDiscoverSearch(e.target.value)}
placeholder={t('groupFilter.searchPlaceholder')}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
className={inputClass}
/>
<div className="max-h-64 overflow-y-auto rounded-md border border-border divide-y divide-border">
{filteredDiscovered && filteredDiscovered.length > 0 ? (
@@ -862,12 +880,12 @@ export default function AdminLdapPage() {
)}
{discovered && discovered.length === 0 && (
<p className="mb-4 text-sm text-muted-foreground">{t('groupFilter.noneFound')}</p>
<p className="text-sm text-muted-foreground">{t('groupFilter.noneFound')}</p>
)}
<div className="flex items-end gap-3 mb-4">
<div className="flex-1 space-y-1">
<label htmlFor="ldap-group-manual-dn" className="text-xs font-medium text-muted-foreground">
<div className="flex items-end gap-3">
<div className="flex-1">
<label htmlFor="ldap-group-manual-dn" className={smallLabelClass}>
{t('groupFilter.manualDn')}
</label>
<input
@@ -876,19 +894,15 @@ export default function AdminLdapPage() {
value={manualDn}
onChange={(e) => setManualDn(e.target.value)}
placeholder="CN=Beispiel,OU=Gruppen,DC=ctl,DC=local"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm font-mono"
className={`${inputClass} font-mono`}
/>
</div>
<button
type="button"
onClick={handleAddManualDn}
className="h-9 rounded-md border border-border px-3 text-xs font-medium text-foreground hover:bg-muted transition-colors"
>
<button type="button" onClick={handleAddManualDn} className="btn btn-secondary">
{t('groupFilter.addDn')}
</button>
</div>
<div className="mb-4">
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">
{t('groupFilter.selected')}
</p>
@@ -914,29 +928,33 @@ export default function AdminLdapPage() {
</ul>
)}
</div>
<button
type="button"
onClick={handleSaveGroupFilter}
disabled={savingFilter}
className="btn btn-primary"
>
{savingFilter ? tCommon('loading') : t('groupFilter.save')}
</button>
</section>
</div>
</SettingsSection>
)}
{/* Section 2.52: AD group import (SC-1/SC-2, D-01/D-02) */}
{config && (
<section className="rounded-lg border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{t('groupImport.title')}
</h2>
<p className="text-sm text-muted-foreground mb-4">
{t('groupImport.description')}
</p>
<div className="flex items-center gap-3 mb-4">
<SettingsSection
id="ldap-group-import"
title={t('groupImport.title')}
description={t('groupImport.description')}
footer={
groupImportCandidates && groupImportCandidates.length > 0 ? (
<button
type="button"
onClick={handleImportGroups}
disabled={importingGroups || selectedImportDns.length === 0}
className="btn btn-primary"
>
{importingGroups
? tCommon('loading')
: `${t('groupImport.importSelected')} (${selectedImportDns.length})`}
</button>
) : undefined
}
>
<div className="space-y-4">
<div>
<button
type="button"
onClick={handleDiscoverGroupsToImport}
@@ -950,19 +968,19 @@ export default function AdminLdapPage() {
</div>
{groupImportError && (
<div className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{groupImportError}
</div>
)}
{groupImportCandidates && groupImportCandidates.length > 0 && (
<div className="mb-4 space-y-2">
<div className="space-y-2">
<input
type="text"
value={groupImportSearch}
onChange={(e) => setGroupImportSearch(e.target.value)}
placeholder={t('groupImport.searchPlaceholder')}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
className={inputClass}
/>
<div className="max-h-64 overflow-y-auto rounded-md border border-border divide-y divide-border">
{filteredGroupImportCandidates &&
@@ -1005,26 +1023,13 @@ export default function AdminLdapPage() {
)}
{groupImportCandidates && groupImportCandidates.length === 0 && (
<p className="mb-4 text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground">
{t('groupImport.noneFound')}
</p>
)}
{groupImportCandidates && groupImportCandidates.length > 0 && (
<button
type="button"
onClick={handleImportGroups}
disabled={importingGroups || selectedImportDns.length === 0}
className="btn btn-primary"
>
{importingGroups
? tCommon('loading')
: `${t('groupImport.importSelected')} (${selectedImportDns.length})`}
</button>
)}
{groupImportResult && (
<div className="mt-3">
<div>
<p className="text-sm text-muted-foreground">
{t('groupImport.resultSummary', {
imported: groupImportResult.imported,
@@ -1057,21 +1062,34 @@ export default function AdminLdapPage() {
</p>
</div>
)}
</section>
</div>
</SettingsSection>
)}
{/* Section 2.55: Individual user search & import */}
{config && (
<section className="rounded-lg border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{t('userSearch.title')}
</h2>
<p className="text-sm text-muted-foreground mb-4">
{t('userSearch.description')}
</p>
<div className="flex items-end gap-3 mb-4">
<div className="flex-1 space-y-1">
<SettingsSection
id="ldap-user-search"
title={t('userSearch.title')}
description={t('userSearch.description')}
footer={
userSearchResults && userSearchResults.length > 0 ? (
<button
type="button"
onClick={() => handleImportUsers(selectedUserDns)}
disabled={importingUsers || selectedUserDns.length === 0}
className="btn btn-primary"
>
{importingUsers
? tCommon('loading')
: `${t('userSearch.importSelected')} (${selectedUserDns.length})`}
</button>
) : undefined
}
>
<div className="space-y-4">
<div className="flex items-end gap-3">
<div className="flex-1">
<input
type="text"
value={userSearchQuery}
@@ -1080,21 +1098,21 @@ export default function AdminLdapPage() {
if (e.key === 'Enter') handleSearchUsers();
}}
placeholder={t('userSearch.placeholder')}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
className={inputClass}
/>
</div>
<button
type="button"
onClick={handleSearchUsers}
disabled={userSearching || !userSearchQuery.trim()}
className="h-9 rounded-md border border-border px-4 text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
className="btn btn-secondary"
>
{userSearching ? tCommon('loading') : t('userSearch.search')}
</button>
</div>
{userSearchResults && userSearchResults.length > 0 && (
<div className="mb-4 max-h-64 overflow-y-auto rounded-md border border-border divide-y divide-border">
<div className="max-h-64 overflow-y-auto rounded-md border border-border divide-y divide-border">
{userSearchResults.map((u) => (
<label
key={u.dn}
@@ -1132,26 +1150,13 @@ export default function AdminLdapPage() {
)}
{userSearchResults && userSearchResults.length === 0 && (
<p className="mb-4 text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground">
{t('userSearch.noResults')}
</p>
)}
{userSearchResults && userSearchResults.length > 0 && (
<button
type="button"
onClick={() => handleImportUsers(selectedUserDns)}
disabled={importingUsers || selectedUserDns.length === 0}
className="btn btn-primary"
>
{importingUsers
? tCommon('loading')
: `${t('userSearch.importSelected')} (${selectedUserDns.length})`}
</button>
)}
{userImportResult && (
<p className="mt-3 text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground">
{userImportResult.created} {t('userSearch.created')},{' '}
{userImportResult.skipped} {t('userSearch.skipped')}
{userImportResult.errors.length > 0 && (
@@ -1161,22 +1166,31 @@ export default function AdminLdapPage() {
)}
</p>
)}
</section>
</div>
</SettingsSection>
)}
{/* Section 2.6: Per-user exclude/denylist */}
{config && (
<section className="rounded-lg border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
{t('userExclude.title')}
</h2>
<p className="text-sm text-muted-foreground mb-4">
{t('userExclude.description')}
</p>
<div className="flex items-end gap-3 mb-4">
<div className="flex-1 space-y-1">
<label htmlFor="ldap-exclude-username" className="text-xs font-medium text-muted-foreground">
<SettingsSection
id="ldap-user-exclude"
title={t('userExclude.title')}
description={t('userExclude.description')}
footer={
<button
type="button"
onClick={handleSaveExcludeList}
disabled={savingExclude}
className="btn btn-primary"
>
{savingExclude ? tCommon('loading') : t('userExclude.save')}
</button>
}
>
<div className="space-y-4">
<div className="flex items-end gap-3">
<div className="flex-1">
<label htmlFor="ldap-exclude-username" className={smallLabelClass}>
{t('userExclude.username')}
</label>
<input
@@ -1191,19 +1205,15 @@ export default function AdminLdapPage() {
}
}}
placeholder="administrator, krbtgt, guest, ldap$ ..."
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm font-mono"
className={`${inputClass} font-mono`}
/>
</div>
<button
type="button"
onClick={handleAddExcludeUser}
className="h-9 rounded-md border border-border px-3 text-xs font-medium text-foreground hover:bg-muted transition-colors"
>
<button type="button" onClick={handleAddExcludeUser} className="btn btn-secondary">
{t('userExclude.add')}
</button>
</div>
<div className="mb-4">
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">
{t('userExclude.excluded')}
</p>
@@ -1229,30 +1239,31 @@ export default function AdminLdapPage() {
</ul>
)}
</div>
<button
type="button"
onClick={handleSaveExcludeList}
disabled={savingExclude}
className="btn btn-primary"
>
{savingExclude ? tCommon('loading') : t('userExclude.save')}
</button>
</section>
</div>
</SettingsSection>
)}
{/* Section 3: Sync Settings (D-14) */}
{/* Section 3a: Sync schedule (D-14) */}
{config && (
<section className="rounded-lg border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-4">
{t('sync.title')}
</h2>
<SettingsSection
id="ldap-schedule"
title={tCc('ldap.schedule')}
description={tCc('ldap.scheduleHelp')}
footer={
<button
type="button"
onClick={handleSave}
disabled={saving}
className="btn btn-primary"
>
{t('save')}
</button>
}
>
<div className="space-y-4">
{/* Sync interval */}
<div className="flex items-center gap-4">
<div className="space-y-1">
<label htmlFor="ldap-sync-interval" className="text-sm font-medium text-foreground">
<div>
<label htmlFor="ldap-sync-interval" className={labelClass}>
{t('sync.interval')}
</label>
<div className="flex items-center gap-2">
@@ -1264,7 +1275,7 @@ export default function AdminLdapPage() {
onChange={(e) =>
setFormData({ ...formData, syncIntervalMin: parseInt(e.target.value, 10) || 0 })
}
className="flex h-10 w-24 rounded-md border border-input bg-background px-3 py-2 text-sm"
className="flex h-9 w-24 rounded-md border border-input bg-background px-3 py-1 text-sm"
/>
<span className="text-sm text-muted-foreground">min</span>
{formData.syncIntervalMin === 0 && (
@@ -1274,7 +1285,6 @@ export default function AdminLdapPage() {
)}
</div>
</div>
</div>
{/* Enable/Disable toggle */}
<div className="flex items-center gap-3">
@@ -1291,30 +1301,17 @@ export default function AdminLdapPage() {
{formData.isActive ? t('enable') : t('disable')}
</span>
</div>
{/* Save interval changes */}
<button
type="button"
onClick={handleSave}
disabled={saving}
className="btn btn-primary"
>
{t('save')}
</button>
{/* Last sync info */}
<div className="border-t border-border pt-4">
<p className="text-sm text-muted-foreground">
{t('sync.lastSync')}:{' '}
<span className="font-medium text-foreground">
{config.lastSyncAt
? new Date(config.lastSyncAt).toLocaleString()
: t('sync.neverSynced')}
</span>
</p>
</div>
</SettingsSection>
)}
{/* Manual sync button (D-14) */}
{/* Section 3b: Manual sync (D-14) */}
{config && (
<SettingsSection
id="ldap-sync"
title={t('sync.title')}
description={tCc('ldap.syncHelp')}
footer={
<button
type="button"
onClick={handleSync}
@@ -1323,6 +1320,18 @@ export default function AdminLdapPage() {
>
{syncing ? t('sync.syncing') : t('sync.trigger')}
</button>
}
>
<div className="space-y-4">
{/* Last sync info */}
<p className="text-sm text-muted-foreground">
{t('sync.lastSync')}:{' '}
<span className="font-medium text-foreground">
{config.lastSyncAt
? new Date(config.lastSyncAt).toLocaleString()
: t('sync.neverSynced')}
</span>
</p>
{/* Sync request failure (Owner-Entscheidung 2026-08-06): a failed
sync request must never render as a report full of zeros. */}
@@ -1411,7 +1420,7 @@ export default function AdminLdapPage() {
</div>
)}
</div>
</section>
</SettingsSection>
)}
</div>
);
@@ -151,10 +151,9 @@ describe('Kategorien-Verwaltungsseite', () => {
expect(
within(card('procurement')).getByText('Außerdem 2 persönliche Einträge von Benutzern'),
).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Zurück zu Module' })).toHaveAttribute(
'href',
'/admin/modules',
);
// Die Navigation links reicht — kein eigener Ruecksprung-Link mehr.
expect(screen.queryByRole('link', { name: 'Zurück zu Module' })).toBeNull();
expect(screen.getByRole('heading', { level: 1, name: 'Kategorien' })).toBeInTheDocument();
});
it('sperrt Pfeil nach oben bei der ersten und nach unten bei der letzten Kategorie und beim Eintrag', async () => {
@@ -1,8 +1,9 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { PageHeader } from '@/components/control-center/page-header';
import { SettingsSection } from '@/components/control-center/settings-section';
import {
assignModuleCategory,
createModuleCategory,
@@ -40,8 +41,7 @@ function Arrow({ direction }: { direction: 'up' | 'down' }) {
);
}
const arrowButtonClass =
'inline-flex h-8 w-8 items-center justify-center rounded-md border border-border bg-card text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40';
const arrowButtonClass = 'btn btn-subtle btn-icon disabled:opacity-40';
/** Vertauscht zwei Nachbarn einer Liste (Pfeil nach oben/unten). */
function swapped<T>(list: T[], index: number, direction: -1 | 1): T[] {
@@ -100,7 +100,7 @@ function DeleteCategoryDialog({
aria-labelledby="delete-category-title"
className="w-full max-w-md rounded-lg bg-card p-6 shadow-lg dark:border dark:border-border"
>
<h2 id="delete-category-title" className="mb-4 text-lg font-semibold text-foreground">
<h2 id="delete-category-title" className="cc-section-title mb-4">
{t('deleteDialog.title')}
</h2>
<p className="mb-4 text-sm text-foreground">
@@ -164,6 +164,7 @@ function DeleteCategoryDialog({
export default function AdminModuleCategoriesPage() {
const t = useTranslations('adminModules.categories');
const tCommon = useTranslations('common');
const tCc = useTranslations('controlCenter');
const categoryLabel = useCategoryLabel();
const currentUser = useAuthStore((s) => s.user);
const bumpSidebarRefresh = useMarketplaceStore((s) => s.bumpSidebarRefresh);
@@ -277,26 +278,22 @@ export default function AdminModuleCategoriesPage() {
}
return (
<div className="space-y-6">
<div>
<Link href="/admin/modules" className="text-sm font-medium link">
{t('backToModules')}
</Link>
<h1 className="mt-2 text-2xl font-semibold text-foreground">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
</div>
<PageHeader title={t('title')} description={tCc('descriptions.categories')} />
{(loadError || actionError) && (
<div
role="alert"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{actionError ?? t('loadError')}
</div>
)}
<div className="space-y-4">
<SettingsSection>
<form onSubmit={handleCreate} className="flex flex-wrap items-end gap-3">
<div className="space-y-2">
<div className="w-full max-w-md space-y-2">
<label htmlFor="new-category-name" className="text-sm font-medium text-foreground">
{t('createLabel')}
</label>
@@ -307,30 +304,67 @@ export default function AdminModuleCategoriesPage() {
value={newName}
placeholder={t('createPlaceholder')}
onChange={(e) => setNewName(e.target.value)}
className="flex h-10 w-72 max-w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<button type="submit" disabled={busy || newName.trim() === ''} className="btn btn-primary">
<button
type="submit"
disabled={busy || newName.trim() === ''}
className="btn btn-primary"
>
{t('createButton')}
</button>
</form>
</SettingsSection>
{loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p>
) : (
<ul className="grid gap-4">
{overview.map((category, index) => {
overview.map((category, index) => {
const label = labelOf(category);
const renaming = renamingKey === category.key;
return (
<li
key={category.key}
data-testid={`category-${category.key}`}
className="rounded-lg border border-border bg-card p-4"
// Huelle nur fuer den Testanker; Abstand kommt von space-y-4.
<div key={category.key} data-testid={`category-${category.key}`}>
<SettingsSection
id={`category-section-${category.key}`}
title={label}
description={category.isSystem ? t('deleteBlocked') : undefined}
flush
actions={
renaming ? (
<form
className="flex flex-wrap items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
saveRename(category);
}}
>
<section aria-label={label}>
<div className="flex flex-wrap items-center gap-2">
<div className="flex gap-1">
<input
type="text"
aria-label={t('renameLabel')}
maxLength={60}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
className="flex h-8 w-56 max-w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
/>
<button
type="button"
onClick={() => setRenamingKey(null)}
className="btn btn-secondary"
>
{t('renameCancel')}
</button>
<button
type="submit"
disabled={busy || renameValue.trim() === ''}
className="btn btn-primary"
>
{t('renameSave')}
</button>
</form>
) : (
<>
<button
type="button"
aria-label={t('moveUp')}
@@ -351,47 +385,11 @@ export default function AdminModuleCategoriesPage() {
>
<Arrow direction="down" />
</button>
</div>
{renaming ? (
<form
className="flex flex-1 flex-wrap items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
saveRename(category);
}}
>
<input
type="text"
aria-label={t('renameLabel')}
maxLength={60}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
className="flex h-9 w-64 max-w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
/>
<button
type="submit"
disabled={busy || renameValue.trim() === ''}
className="btn btn-primary"
>
{t('renameSave')}
</button>
<button
type="button"
onClick={() => setRenamingKey(null)}
className="btn btn-secondary"
>
{t('renameCancel')}
</button>
</form>
) : (
<>
<h2 className="flex-1 text-base font-semibold text-foreground">{label}</h2>
<button
type="button"
disabled={busy}
onClick={() => startRename(category)}
className="btn btn-secondary"
className="btn btn-subtle"
>
{t('rename')}
</button>
@@ -400,25 +398,22 @@ export default function AdminModuleCategoriesPage() {
disabled={busy || category.isSystem}
title={category.isSystem ? t('deleteBlocked') : undefined}
onClick={() => setDeleteTarget(category)}
className="btn btn-secondary"
className="btn btn-subtle"
>
{t('delete')}
</button>
</>
)}
</div>
{category.isSystem && (
<p className="mt-1 text-xs text-muted-foreground">{t('deleteBlocked')}</p>
)}
)
}
>
{category.items.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">{t('empty')}</p>
<p className="px-5 pb-4 text-sm text-muted-foreground">{t('empty')}</p>
) : (
<ul className="mt-3 divide-y divide-border rounded-md border border-border">
<ul className="divide-y divide-border border-t border-border">
{category.items.map((item, itemIndex) => (
<li
key={`${item.type}:${item.id}`}
className="flex flex-wrap items-center gap-2 px-3 py-2"
className="flex flex-wrap items-center gap-2 px-5 py-2"
>
<div className="flex gap-1">
<button
@@ -456,7 +451,7 @@ export default function AdminModuleCategoriesPage() {
value={category.key}
disabled={busy}
onChange={(e) => assign(item, e.target.value)}
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
>
{overview.map((c) => (
<option key={c.key} value={c.key}>
@@ -470,16 +465,16 @@ export default function AdminModuleCategoriesPage() {
)}
{category.personalCount > 0 && (
<p className="mt-2 text-xs text-muted-foreground">
<p className="border-t border-border px-5 py-2 text-xs text-muted-foreground">
{t('personalNote', { count: category.personalCount })}
</p>
)}
</section>
</li>
</SettingsSection>
</div>
);
})}
</ul>
})
)}
</div>
{deleteTarget && (
<DeleteCategoryDialog
@@ -3,6 +3,8 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { SettingsSection } from '@/components/control-center/settings-section';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useCategoryLabel } from '@/lib/use-category-label';
@@ -48,6 +50,7 @@ export default function AdminModuleGrantsPage() {
const t = useTranslations('adminModules.grants');
const tGrantLabel = useTranslations('admin.groups.grants');
const tCommon = useTranslations('common');
const tCc = useTranslations('controlCenter');
const categoryLabel = useCategoryLabel();
const currentUser = useAuthStore((s) => s.user);
@@ -231,13 +234,11 @@ export default function AdminModuleGrantsPage() {
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
</div>
<PageHeader title={t('title')} description={tCc('descriptions.grants')} />
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<div className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
@@ -245,7 +246,8 @@ export default function AdminModuleGrantsPage() {
{loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p>
) : modules.length === 0 ? (
<div className="rounded-md border border-border bg-card p-6 text-center">
<SettingsSection>
<div className="text-center">
<p className="text-sm text-muted-foreground">{t('emptyModules')}</p>
<Link
href="/admin/modules"
@@ -254,22 +256,34 @@ export default function AdminModuleGrantsPage() {
{t('emptyModulesLink')}
</Link>
</div>
</SettingsSection>
) : (
<>
<SettingsSection
flush
footer={
<div className="mr-auto space-y-1">
<p className="text-xs text-muted-foreground">{t('levelExplanation')}</p>
<p className="text-xs text-muted-foreground">{t('adminNote')}</p>
</div>
}
>
<div className="px-5 pb-3">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('searchPlaceholder')}
aria-label={t('searchPlaceholder')}
className="flex h-9 w-full max-w-sm rounded-md border border-input bg-background px-3 py-1 text-sm"
/>
</div>
{noMatch ? (
<div className="rounded-md border border-border bg-card p-6 text-center">
<div className="border-t border-border p-6 text-center">
<p className="text-sm text-muted-foreground">{t('noSearchResults', { search })}</p>
</div>
) : (
<div className="overflow-x-auto overflow-y-auto max-h-[70vh] rounded-md border border-border">
<div className="overflow-x-auto overflow-y-auto max-h-[70vh] border-t border-border">
<table className="w-full text-sm border-collapse">
<thead>
<tr>
@@ -360,10 +374,7 @@ export default function AdminModuleGrantsPage() {
</table>
</div>
)}
<p className="text-xs text-muted-foreground">{t('levelExplanation')}</p>
<p className="text-xs text-muted-foreground">{t('adminNote')}</p>
</>
</SettingsSection>
)}
</div>
);
@@ -1,10 +1,12 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { SettingsSection } from '@/components/control-center/settings-section';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
import { useCategoryLabel } from '@/lib/use-category-label';
import { ActivateModuleDialog } from './components/ActivateModuleDialog';
@@ -30,6 +32,7 @@ interface TenantActivation {
export default function AdminModulesPage() {
const t = useTranslations('adminModules');
const tCommon = useTranslations('common');
const tCc = useTranslations('controlCenter');
const categoryLabel = useCategoryLabel();
const currentUser = useAuthStore((s) => s.user);
// Shared signal that makes the sidebar refetch its active-module list
@@ -49,6 +52,9 @@ export default function AdminModulesPage() {
const [activateDialogModule, setActivateDialogModule] = useState<Module | null>(null);
const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
// Nur zum Sortieren der Kategorie-Abschnitte; der Stand wird von der
// Seitenleiste geladen (kein zusaetzlicher Abruf von dieser Seite).
const storedCategories = useModuleCategoryStore((s) => s.categories);
const fetchModules = useCallback(async () => {
try {
@@ -119,6 +125,25 @@ export default function AdminModulesPage() {
}
};
// Module je Kategorie (Kategorie steht schon in den Daten). Reihenfolge der
// Abschnitte: gespeicherte Kategorien-Reihenfolge, sonst erstes Auftreten.
const groupedModules = useMemo(() => {
const groups = new Map<string, Module[]>();
for (const mod of modules) {
const list = groups.get(mod.category);
if (list) list.push(mod);
else groups.set(mod.category, [mod]);
}
const order = new Map(storedCategories.map((c, i) => [c.key, i]));
return [...groups.entries()]
.map(([category, mods]) => ({ category, mods }))
.sort(
(a, b) =>
(order.get(a.category) ?? Number.MAX_SAFE_INTEGER) -
(order.get(b.category) ?? Number.MAX_SAFE_INTEGER),
);
}, [modules, storedCategories]);
const getDescription = (desc: Record<string, string> | string): string => {
if (typeof desc === 'string') return desc;
return desc.de || desc.en || Object.values(desc)[0] || '';
@@ -133,30 +158,11 @@ export default function AdminModulesPage() {
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
</div>
<div className="flex items-center gap-2">
<Link
href="/admin/modules/categories"
className="btn btn-secondary"
>
{t('categoriesLink')}
</Link>
<Link
href="/admin/modules/grants"
className="btn btn-secondary"
>
{t('grantsLink')}
</Link>
</div>
</div>
<PageHeader title={t('title')} description={tCc('descriptions.modules')} />
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<div className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
@@ -166,20 +172,19 @@ export default function AdminModulesPage() {
) : modules.length === 0 ? (
<p className="text-muted-foreground">{t('noModules')}</p>
) : (
<div className="grid gap-4">
{modules.map((mod) => {
groupedModules.map(({ category, mods }) => (
<SettingsSection key={category} id={`module-category-${category}`} title={categoryLabel(category)} flush>
<ul className="divide-y divide-border">
{mods.map((mod) => {
const isActive = activations.has(mod.id);
const isToggling = toggling === mod.id;
return (
<div
key={mod.id}
className="flex items-center justify-between rounded-lg border border-border bg-card p-4 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-foreground">{mod.name}</h3>
<span className="text-xs text-muted-foreground">
<li key={mod.id} className="flex items-center justify-between gap-4 px-5 py-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-medium text-foreground">{mod.name}</h3>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
v{mod.version}
</span>
{mod.isSystem && (
@@ -187,18 +192,15 @@ export default function AdminModulesPage() {
{t('system')}
</span>
)}
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{categoryLabel(mod.category)}
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground truncate">
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{getDescription(mod.description)}
</p>
</div>
<div className="ml-4 flex items-center gap-3">
<div className="flex shrink-0 items-center gap-3">
<span
className={`text-xs font-medium ${
className={`w-20 text-right text-xs font-medium ${
isActive
? 'text-green-600 dark:text-green-400'
: 'text-muted-foreground'
@@ -224,6 +226,7 @@ export default function AdminModulesPage() {
}`}
role="switch"
aria-checked={isActive}
aria-label={mod.name}
>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform duration-200 ${
@@ -232,10 +235,12 @@ export default function AdminModulesPage() {
/>
</button>
</div>
</div>
</li>
);
})}
</div>
</ul>
</SettingsSection>
))
)}
{activateDialogModule && (
@@ -1,16 +1,16 @@
'use client';
import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { SmtpSettingsForm } from '@/components/settings/smtp-settings-form';
export default function AdminSmtpPage() {
const t = useTranslations('settings');
const tCc = useTranslations('controlCenter');
return (
<div>
<h1 className="mb-6 text-lg font-semibold text-foreground">
{t('smtp.title')}
</h1>
<PageHeader title={t('smtp.title')} description={tCc('descriptions.smtp')} />
<SmtpSettingsForm />
</div>
);
@@ -2,6 +2,8 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { SettingsSection } from '@/components/control-center/settings-section';
import { useAuthStore } from '@/lib/stores/auth-store';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -26,6 +28,7 @@ interface TenantFormData {
export default function AdminTenantsPage() {
const t = useTranslations('admin.tenants');
const tCommon = useTranslations('common');
const tCc = useTranslations('controlCenter');
const currentUser = useAuthStore((s) => s.user);
const [tenants, setTenants] = useState<Tenant[]>([]);
@@ -143,18 +146,16 @@ export default function AdminTenantsPage() {
}
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<button
type="button"
onClick={openCreate}
className="btn btn-primary"
>
<div>
<PageHeader
title={t('title')}
description={tCc('descriptions.tenants')}
actions={
<button type="button" onClick={openCreate} className="btn btn-primary">
{t('create')}
</button>
</div>
}
/>
{/* Tenants table */}
{loading ? (
@@ -162,23 +163,23 @@ export default function AdminTenantsPage() {
) : tenants.length === 0 ? (
<p className="text-muted-foreground">{t('noTenants')}</p>
) : (
<div className="overflow-x-auto rounded-md border border-border">
<SettingsSection flush>
<table className="w-full text-sm">
<thead className="bg-muted/50">
<thead className="border-b border-border">
<tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('name')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('slug')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('status')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('userCount')}
</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
<th className="px-4 pb-2 text-right text-xs font-medium text-muted-foreground">
{t('actions')}
</th>
</tr>
@@ -189,46 +190,46 @@ export default function AdminTenantsPage() {
key={tenant.id}
className="hover:bg-muted/30 transition-colors"
>
<td className="px-4 py-3 font-medium text-foreground">
<td className="px-4 py-2.5 font-medium text-foreground">
{tenant.name}
</td>
<td className="px-4 py-3 text-muted-foreground font-mono text-xs">
<td className="px-4 py-2.5 text-muted-foreground font-mono text-xs">
{tenant.slug}
</td>
<td className="px-4 py-3">
<td className="px-4 py-2.5">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
tenant.isActive
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500'
? 'bg-status-ok/12 text-status-ok-fg'
: 'bg-muted text-muted-foreground'
}`}
>
{tenant.isActive ? tCommon('active') : tCommon('inactive')}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">
<td className="px-4 py-2.5 text-muted-foreground">
{tenant.userCount}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<td className="px-4 py-2.5 text-right">
<div className="flex items-center justify-end gap-1">
<button
type="button"
onClick={() => openEdit(tenant)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
className="btn btn-subtle h-7 px-2 text-xs font-medium"
>
{tCommon('edit')}
</button>
<button
type="button"
onClick={() => handleToggleActive(tenant)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
className="btn btn-subtle h-7 px-2 text-xs font-medium"
>
{tenant.isActive ? tCommon('inactive') : tCommon('active')}
</button>
<button
type="button"
onClick={() => setDeleteConfirm(tenant.id)}
className="rounded px-2 py-1 text-xs text-destructive hover:bg-destructive/10 transition-colors"
className="btn btn-subtle h-7 px-2 text-xs font-medium text-destructive hover:bg-destructive/10"
>
{tCommon('delete')}
</button>
@@ -238,14 +239,14 @@ export default function AdminTenantsPage() {
))}
</tbody>
</table>
</div>
</SettingsSection>
)}
{/* Create/Edit modal */}
{showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 className="text-lg font-bold text-foreground mb-4">
<h2 className="text-lg font-semibold text-foreground mb-4">
{editingTenant ? t('edit') : t('create')}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
+55 -39
View File
@@ -2,6 +2,8 @@
import { useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { PageHeader } from '@/components/control-center/page-header';
import { SettingsSection } from '@/components/control-center/settings-section';
import { useAuthStore } from '@/lib/stores/auth-store';
import { UserAccessModal } from './components/UserAccessModal';
@@ -62,6 +64,7 @@ export default function AdminUsersPage() {
const tCommon = useTranslations('common');
const tHeader = useTranslations('header');
const tWelcome = useTranslations('admin.users.welcomeMail');
const tCc = useTranslations('controlCenter');
const locale = useLocale();
const currentUser = useAuthStore((s) => s.user);
@@ -304,19 +307,21 @@ export default function AdminUsersPage() {
};
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<div>
<PageHeader
title={t('title')}
description={tCc('descriptions.users')}
actions={
<button type="button" onClick={openCreate} className="btn btn-primary">
{t('create')}
</button>
</div>
}
/>
{loadError && (
<div
role="alert"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{loadError}
</div>
@@ -325,7 +330,7 @@ export default function AdminUsersPage() {
{welcomeSuccess && (
<div
role="status"
className="flex items-start justify-between gap-3 rounded-md border border-status-ok/40 bg-status-ok/12 p-3 text-sm text-status-ok-fg"
className="mb-4 flex items-start justify-between gap-3 rounded-md border border-status-ok/40 bg-status-ok/12 p-3 text-sm text-status-ok-fg"
>
<span>{welcomeSuccess}</span>
<button
@@ -339,72 +344,83 @@ export default function AdminUsersPage() {
</div>
)}
{/* Users table */}
{/* Users table: feste Spaltenbreiten, Nebeninfos als zweite Zeile —
passt ohne waagerechtes Scrollen in die Inhaltsbreite (~64rem). */}
{loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p>
) : loadError ? null : users.length === 0 ? (
<p className="text-muted-foreground">{t('noUsers')}</p>
) : (
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<SettingsSection flush>
<table className="w-full table-fixed text-sm">
<thead className="border-b border-border">
<tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('username')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('email')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="w-[15%] px-3 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('displayName')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="w-28 px-3 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('role')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="w-36 px-3 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('status')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="w-32 px-3 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('lastLogin')}
</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
<th className="w-[9.5rem] px-4 pb-2 text-right text-xs font-medium text-muted-foreground">
{t('actions')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{users.map((user) => (
<tr key={user.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-medium text-foreground">{user.username}</td>
<td className="px-4 py-3 text-muted-foreground">{user.email ?? '–'}</td>
<td className="px-4 py-3 text-muted-foreground">{user.displayName ?? '-'}</td>
<td className="px-4 py-3">
<tr key={user.id} className="align-top hover:bg-muted/30 transition-colors">
<td className="px-4 py-2.5">
<div className="truncate font-medium text-foreground" title={user.username}>
{user.username}
</div>
<div
className="truncate text-xs text-muted-foreground"
title={user.email ?? undefined}
>
{user.email ?? '–'}
</div>
</td>
<td className="px-3 py-2.5 text-muted-foreground">
<div className="truncate" title={user.displayName ?? undefined}>
{user.displayName ?? '-'}
</div>
</td>
<td className="px-3 py-2.5">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(user.role)}`}
className={`inline-block whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(user.role)}`}
>
{tHeader(`role.${user.role}`)}
</span>
</td>
<td className="px-4 py-3">
<td className="px-3 py-2.5">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
user.isActive
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500'
? 'bg-status-ok/12 text-status-ok-fg'
: 'bg-muted text-muted-foreground'
}`}
>
{user.isActive ? tCommon('active') : tCommon('inactive')}
</span>
{user.welcomeMailSentAt && (
<div className="mt-1 text-xs text-muted-foreground">
<div className="mt-1 text-xs leading-4 text-muted-foreground">
{tWelcome('sentAt', { date: formatDateTime(user.welcomeMailSentAt) })}
</div>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">
<td className="px-3 py-2.5 text-xs leading-5 text-muted-foreground">
{user.lastLoginAt ? formatDateTime(user.lastLoginAt) : t('neverLoggedIn')}
</td>
<td className="px-4 py-3 text-right">
<td className="px-4 py-2 text-right">
<div className="flex items-center justify-end gap-0.5">
{canSendWelcome(user) && (
<button
@@ -415,7 +431,7 @@ export default function AdminUsersPage() {
welcomeBlockedReason(user) ??
(user.welcomeMailSentAt ? tWelcome('resend') : tWelcome('button'))
}
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
className="btn btn-subtle btn-icon text-muted-foreground hover:text-foreground disabled:opacity-40"
>
<ActionIcon kind="mail" />
<span className="sr-only">
@@ -427,7 +443,7 @@ export default function AdminUsersPage() {
type="button"
onClick={() => setDetailsUser(user)}
title={t('grants.detailsButton')}
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
className="btn btn-subtle btn-icon text-muted-foreground hover:text-foreground"
>
<ActionIcon kind="details" />
<span className="sr-only">{t('grants.detailsButton')}</span>
@@ -437,7 +453,7 @@ export default function AdminUsersPage() {
type="button"
onClick={() => openEdit(user)}
title={tCommon('edit')}
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
className="btn btn-subtle btn-icon text-muted-foreground hover:text-foreground"
>
<ActionIcon kind="edit" />
<span className="sr-only">{tCommon('edit')}</span>
@@ -452,7 +468,7 @@ export default function AdminUsersPage() {
}}
disabled={user.id === currentUser?.id}
title={tCommon('delete')}
className="rounded p-1.5 text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"
className="btn btn-subtle btn-icon text-destructive hover:bg-destructive/10 disabled:opacity-30 disabled:pointer-events-none"
>
<ActionIcon kind="delete" />
<span className="sr-only">{tCommon('delete')}</span>
@@ -464,14 +480,14 @@ export default function AdminUsersPage() {
))}
</tbody>
</table>
</div>
</SettingsSection>
)}
{/* Create/Edit modal */}
{showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 className="text-lg font-bold text-foreground mb-4">
<h2 className="text-lg font-semibold text-foreground mb-4">
{editingUser ? t('edit') : t('create')}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
@@ -619,7 +635,7 @@ export default function AdminUsersPage() {
aria-labelledby="welcome-mail-title"
className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg"
>
<h2 id="welcome-mail-title" className="text-lg font-bold text-foreground mb-3">
<h2 id="welcome-mail-title" className="text-lg font-semibold text-foreground mb-3">
{tWelcome('dialogTitle')}
</h2>
<p className="text-sm text-foreground">{tWelcome('dialogText')}</p>
@@ -1,6 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { CalendarSettingsPanel } from '@/components/settings/calendar-settings-panel';
/**
@@ -9,12 +10,11 @@ import { CalendarSettingsPanel } from '@/components/settings/calendar-settings-p
*/
export default function CalendarSettingsPage() {
const t = useTranslations('settings');
const tCc = useTranslations('controlCenter');
return (
<div>
<h1 className="mb-6 text-lg font-semibold text-foreground">
{t('categoryCalendar')}
</h1>
<PageHeader title={t('categoryCalendar')} description={tCc('descriptions.calendar')} />
<CalendarSettingsPanel />
</div>
);
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { WidgetSettingsPanel } from '@/components/settings/widget-settings-panel';
import { fetchDashboards, fetchWidgets } from '@/lib/dashboard-api';
@@ -20,6 +21,7 @@ import { fetchDashboards, fetchWidgets } from '@/lib/dashboard-api';
*/
export default function WidgetSettingsPage() {
const t = useTranslations('settings');
const tCc = useTranslations('controlCenter');
const tCommon = useTranslations('common');
const [widgets, setWidgets] = useState<
Array<{ id: string; widgetType: string; config: Record<string, unknown> }>
@@ -42,9 +44,7 @@ export default function WidgetSettingsPage() {
return (
<div>
<h1 className="mb-6 text-lg font-semibold text-foreground">
{t('categoryWidgets')}
</h1>
<PageHeader title={t('categoryWidgets')} description={tCc('descriptions.widgets')} />
{isLoading ? (
<p className="text-sm text-muted-foreground">{tCommon('loading')}</p>
@@ -1,6 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { AccountSettingsForm } from '@/components/settings/account-settings-form';
/**
@@ -9,12 +10,11 @@ import { AccountSettingsForm } from '@/components/settings/account-settings-form
*/
export default function AccountSettingsPage() {
const t = useTranslations('settings');
const tCc = useTranslations('controlCenter');
return (
<div>
<h1 className="mb-6 text-lg font-semibold text-foreground">
{t('account.title')}
</h1>
<PageHeader title={t('account.title')} description={tCc('descriptions.account')} />
<AccountSettingsForm />
</div>
);
@@ -1,6 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { DesktopAppSettings } from '@/components/settings/desktop-app-settings';
/**
@@ -9,12 +10,11 @@ import { DesktopAppSettings } from '@/components/settings/desktop-app-settings';
*/
export default function DesktopSettingsPage() {
const t = useTranslations('settings');
const tCc = useTranslations('controlCenter');
return (
<div>
<h1 className="mb-6 text-lg font-semibold text-foreground">
{t('desktop.title')}
</h1>
<PageHeader title={t('desktop.title')} description={tCc('descriptions.desktop')} />
<DesktopAppSettings />
</div>
);
+5 -44
View File
@@ -1,48 +1,9 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { SettingsSidebar } from '@/components/settings/settings-sidebar';
import { ControlCenterLayout } from '@/components/control-center/control-center-layout';
/**
* Settings nested layout (D-19, D-20).
* Renders within the portal AppShell, adding its own sub-sidebar on the left.
* Back-to-dashboard link at top of content area per UI-SPEC.
* Einstellungen und Verwaltung teilen sich einen Rahmen mit einer
* gemeinsamen Navigation (siehe ControlCenterLayout).
*/
export default function SettingsLayout({
children,
}: {
children: React.ReactNode;
}) {
const t = useTranslations('settings');
return (
<div className="flex h-full flex-col md:flex-row">
<SettingsSidebar />
<div className="min-w-0 flex-1 overflow-y-auto p-3 sm:p-6">
{/* Back to Dashboard link */}
<Link
href="/"
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
{t('backToDashboard')}
</Link>
{children}
</div>
</div>
);
export default function Layout({ children }: { children: React.ReactNode }) {
return <ControlCenterLayout>{children}</ControlCenterLayout>;
}
+2 -2
View File
@@ -1,8 +1,8 @@
import { redirect } from 'next/navigation';
/**
* Settings index page — redirects to /settings/dashboard (the default category).
* Settings index page — leitet auf Konto um (erster Eintrag der Navigation).
*/
export default function SettingsPage() {
redirect('/settings/dashboard');
redirect('/settings/general/account');
}
+182
View File
@@ -426,6 +426,188 @@ body {
background-color: var(--primary-strong);
}
/* ---------------------------------------------------------------------------
Einstellungen und Verwaltung (Design-Runde „Verwaltung aufgeraeumt“)
Eine Navigation fuer beide Bereiche, ein Seitenkopf, eine Abschnittskarte.
Inhalt mit begrenzter Lesebreite statt ueber den ganzen Bildschirm.
------------------------------------------------------------------------- */
.cc-aside {
flex-shrink: 0;
border-bottom: 1px solid var(--border);
background: var(--background);
overflow-y: auto;
}
@media (min-width: 768px) {
.cc-aside {
width: 248px;
border-bottom: 0;
border-right: 1px solid var(--border);
}
}
.cc-nav-mobile {
padding: 0.75rem 1rem;
}
@media (min-width: 768px) {
.cc-nav-mobile {
display: none;
}
}
@media (max-width: 767px) {
.cc-nav {
display: none !important;
}
}
.cc-nav {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1.25rem 0.75rem 1.5rem;
}
.cc-nav-block {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.cc-nav-title {
padding: 0 0.625rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--foreground);
}
.cc-nav-block + .cc-nav-block {
border-top: 1px solid var(--border);
padding-top: 1.25rem;
}
.cc-nav-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.cc-nav-heading {
padding: 0 0.625rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--muted-foreground);
}
.cc-nav-item {
display: flex;
height: 2rem;
align-items: center;
gap: 0.625rem;
border-radius: var(--radius);
padding: 0 0.625rem;
font-size: 0.875rem;
color: var(--sidebar-foreground);
transition: background-color 120ms ease;
}
.cc-nav-item:hover {
background: var(--sidebar-hover);
}
.cc-nav-item.is-active {
background: var(--sidebar-accent);
color: var(--sidebar-accent-foreground);
font-weight: 600;
}
.cc-nav-item:focus-visible {
outline: 2px solid var(--ring);
outline-offset: -2px;
}
.cc-nav-icon {
display: inline-flex;
color: var(--muted-foreground);
}
.cc-nav-item.is-active .cc-nav-icon {
color: var(--foreground);
}
.cc-content {
max-width: 64rem;
padding: 1.5rem 1rem 3rem;
}
@media (min-width: 640px) {
.cc-content {
padding: 2rem 2.5rem 4rem;
}
}
.cc-page-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem 1.5rem;
margin-bottom: 1.5rem;
}
.cc-page-title {
font-size: 1.375rem;
line-height: 1.75rem;
font-weight: 600;
color: var(--foreground);
}
.cc-page-description {
margin-top: 0.375rem;
max-width: 42rem;
font-size: 0.875rem;
line-height: 1.375rem;
color: var(--muted-foreground);
}
.cc-section {
border: 1px solid var(--border);
border-radius: calc(var(--radius) + 2px);
background: var(--card);
box-shadow: var(--shadow-sm);
}
.cc-section + .cc-section {
margin-top: 1rem;
}
.cc-section-head {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1rem 1.25rem 0;
}
.cc-section-title {
font-size: 0.9375rem;
font-weight: 600;
color: var(--foreground);
}
.cc-section-description {
margin-top: 0.25rem;
max-width: 40rem;
font-size: 0.8125rem;
line-height: 1.25rem;
color: var(--muted-foreground);
}
.cc-section-body {
padding: 1rem 1.25rem 1.25rem;
}
.cc-section-body-flush {
padding-top: 0.75rem;
overflow-x: auto;
}
/* Tabellen schrumpfen in schmalen Karten (Handy, kleines Fenster) nicht bis
zur Unlesbarkeit, sondern lassen sich innerhalb der Karte seitlich
schieben. Gemessen an der Kartenbreite, nicht am Bildschirm, weil zwei
Seitenleisten davon schon fast 500 px belegen. */
.cc-section-body-flush {
container-type: inline-size;
}
@container (max-width: 50rem) {
.cc-section-body-flush table {
min-width: 50rem;
}
}
.cc-section-footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
border-top: 1px solid var(--border);
padding: 0.75rem 1.25rem;
background: var(--well);
border-radius: 0 0 calc(var(--radius) + 2px) calc(var(--radius) + 2px);
}
/* Checkboxen und Optionsfelder in der Link-Farbe statt Browser-Blau bzw.
Gelb (Gelb mit weissem Haken waere kaum lesbar). */
input[type="checkbox"], input[type="radio"] {
@@ -1,147 +0,0 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/lib/stores/auth-store';
export function AdminSidebar() {
const t = useTranslations('header');
const pathname = usePathname();
const user = useAuthStore((s) => s.user);
const isSuperAdmin = user?.role === 'SUPER_ADMIN';
const items = [
{
label: t('admin.users'),
href: '/admin/users',
show: true,
icon: (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
),
},
{
label: t('admin.tenants'),
href: '/admin/tenants',
show: isSuperAdmin,
icon: (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
),
},
{
label: t('admin.modules'),
href: '/admin/modules',
show: true,
icon: (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
),
},
{
label: t('admin.customModules'),
href: '/admin/custom-modules',
show: true,
icon: (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
),
},
{
label: t('admin.ldap'),
href: '/admin/ldap',
show: true,
icon: (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
),
},
{
label: t('admin.smtp'),
href: '/admin/smtp',
show: true,
icon: (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
),
},
{
label: t('admin.welcomeMail'),
href: '/admin/welcome-mail',
show: true,
icon: (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 13V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
<path d="m16 19 2 2 4-4" />
</svg>
),
},
{
label: t('admin.groups'),
href: '/admin/groups',
show: true,
icon: (
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="8" x2="21" y1="6" y2="6" />
<line x1="8" x2="21" y1="12" y2="12" />
<line x1="8" x2="21" y1="18" y2="18" />
<line x1="3" x2="3.01" y1="6" y2="6" />
<line x1="3" x2="3.01" y1="12" y2="12" />
<line x1="3" x2="3.01" y1="18" y2="18" />
</svg>
),
},
];
return (
// a11y/useSemanticElements: role="navigation" nicht auf das <aside> gehoben --
// das <nav> darunter trAegt die Navigationssemantik schon; ein zweites
// Navigations-Landmark auf dem Rahmen waere ein doppeltes Landmark fuer
// dieselbe Sache. Der aria-label sitzt darum am <nav>, nicht am <aside>.
<aside className="flex w-full shrink-0 flex-col border-b border-border py-2 md:w-[220px] md:border-b-0">
<div className="px-5 pb-1 pt-4">
<h2 className="text-xs font-semibold text-muted-foreground">
{t('admin.label')}
</h2>
</div>
<nav className="flex flex-col gap-0.5 px-3" aria-label={t('admin.label')}>
{items
.filter((item) => item.show)
.map((item) => (
<Link
key={item.href}
href={item.href}
className={`nav-item flex h-9 items-center gap-2 rounded-lg px-2.5 text-sm transition-colors ${
pathname.startsWith(item.href)
? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold'
: 'text-sidebar-foreground hover:bg-sidebar-hover'
}`}
aria-current={pathname.startsWith(item.href) ? 'page' : undefined}
>
{item.icon}
{item.label}
</Link>
))}
</nav>
</aside>
);
}
@@ -19,6 +19,8 @@ import {
WelcomeMailTemplateRequestError,
type WelcomeMailTemplateState,
} from '@/lib/welcome-mail-template-api';
import { PageHeader } from '@/components/control-center/page-header';
import { SettingsSection } from '@/components/control-center/settings-section';
type Field = keyof WelcomeMailTexts;
type PreviewAccount = 'directory' | 'local';
@@ -79,6 +81,7 @@ const PREVIEW_DELAY_MS = 400;
*/
export function WelcomeMailTemplateEditor() {
const t = useTranslations('admin.welcomeMail');
const tCc = useTranslations('controlCenter');
const locale = useLocale();
const [state, setState] = useState<WelcomeMailTemplateState | null>(null);
@@ -255,34 +258,55 @@ export function WelcomeMailTemplateEditor() {
: t('statusDefault');
return (
<div className="space-y-6">
<div>
<h1 className="mb-2 text-lg font-semibold text-foreground">{t('title')}</h1>
<p className="max-w-3xl text-sm text-muted-foreground">{t('description')}</p>
<PageHeader title={t('title')} description={t('description')} />
{/* Feste Bausteine: ruhiger Hinweis direkt unter dem Kopf */}
<div className="mb-6 max-w-3xl rounded-md bg-muted px-4 py-3 text-sm">
<p className="mb-1 font-medium text-foreground">{t('fixed.title')}</p>
<p className="text-muted-foreground">{t('fixed.body')}</p>
</div>
{/* Feste Bausteine */}
<section
aria-labelledby="welcome-fixed-title"
className="max-w-3xl rounded-lg border border-border bg-muted/40 p-4"
>
<h2 id="welcome-fixed-title" className="mb-1 text-sm font-semibold text-foreground">
{t('fixed.title')}
</h2>
<p className="text-sm text-muted-foreground">{t('fixed.body')}</p>
</section>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
{/* Formular */}
<form onSubmit={handleSave} className="min-w-0 space-y-4" noValidate>
<p
className={`text-sm ${state.custom ? 'text-foreground' : 'text-muted-foreground'}`}
<form onSubmit={handleSave} className="min-w-0" noValidate>
<SettingsSection
id="welcome-texts"
title={tCc('welcomeMail.textsTitle')}
description={
<span
className={state.custom ? 'text-foreground' : undefined}
data-testid="welcome-template-status"
>
{statusText}
{dirty && <span className="ml-2 font-medium">· {t('unsaved')}</span>}
</p>
</span>
}
footer={
<>
<button
type="button"
disabled={resetting}
onClick={() => setConfirmReset(true)}
className="btn btn-subtle"
>
{t('reset')}
</button>
<button
type="button"
disabled={testing || hasErrors}
onClick={handleTest}
className="btn btn-secondary"
>
{testing ? t('testSending') : t('test')}
</button>
<button type="submit" disabled={saving || hasErrors} className="btn btn-primary">
{saving ? t('saving') : t('save')}
</button>
</>
}
>
<div className="space-y-4">
{FIELDS.map((field) => {
const id = `welcome-${field}`;
const errors = errorsByField[field];
@@ -331,7 +355,10 @@ export function WelcomeMailTemplateEditor() {
<div className="mt-1 flex justify-between gap-3 text-xs text-muted-foreground">
<span id={`${id}-help`}>{t(`fields.${field}Help`)}</span>
<span className="shrink-0 tabular-nums">
{t('counter', { count: form[field].length, max: WELCOME_MAIL_LIMITS[field] })}
{t('counter', {
count: form[field].length,
max: WELCOME_MAIL_LIMITS[field],
})}
</span>
</div>
{errors.length > 0 && (
@@ -343,27 +370,6 @@ export function WelcomeMailTemplateEditor() {
);
})}
<div className="flex flex-wrap gap-3 pt-2">
<button type="submit" disabled={saving || hasErrors} className="btn btn-primary">
{saving ? t('saving') : t('save')}
</button>
<button
type="button"
disabled={testing || hasErrors}
onClick={handleTest}
className="btn btn-secondary"
>
{testing ? t('testSending') : t('test')}
</button>
<button
type="button"
disabled={resetting}
onClick={() => setConfirmReset(true)}
className="btn btn-secondary"
>
{t('reset')}
</button>
</div>
<p className="text-xs text-muted-foreground">{t('testHelp')}</p>
{confirmReset && (
@@ -373,7 +379,10 @@ export function WelcomeMailTemplateEditor() {
aria-describedby="welcome-reset-body"
className="max-w-xl rounded-lg border border-border bg-background p-4"
>
<h2 id="welcome-reset-title" className="mb-1 text-sm font-semibold text-foreground">
<h2
id="welcome-reset-title"
className="mb-1 text-sm font-semibold text-foreground"
>
{t('resetConfirm.title')}
</h2>
<p id="welcome-reset-body" className="mb-3 text-sm text-muted-foreground">
@@ -384,7 +393,7 @@ export function WelcomeMailTemplateEditor() {
type="button"
disabled={resetting}
onClick={handleReset}
className="btn btn-primary"
className="btn btn-secondary"
>
{t('resetConfirm.confirm')}
</button>
@@ -403,24 +412,70 @@ export function WelcomeMailTemplateEditor() {
<p
role={feedback.type === 'error' ? 'alert' : 'status'}
className={`text-sm ${feedback.type === 'error' ? 'text-destructive' : ''}`}
style={feedback.type === 'success' ? { color: 'oklch(0.40 0.15 148)' } : undefined}
style={
feedback.type === 'success' ? { color: 'oklch(0.40 0.15 148)' } : undefined
}
>
{feedback.message}
</p>
)}
</div>
</SettingsSection>
</form>
{/* Vorschau */}
<div className="min-w-0">
<SettingsSection
id="welcome-preview"
title={t('preview.title')}
actions={
<fieldset className="flex gap-1">
<legend className="sr-only">{t('preview.accountLabel')}</legend>
{(['directory', 'local'] as const).map((kind) => (
<button
key={kind}
type="button"
aria-pressed={account === kind}
onClick={() => setAccount(kind)}
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
account === kind
? 'border-foreground bg-foreground text-background'
: 'border-border text-foreground hover:bg-sidebar-hover'
}`}
>
{t(`preview.${kind}`)}
</button>
))}
</fieldset>
}
>
<p className="mb-2 text-sm text-muted-foreground">
{t('preview.subject')}{' '}
<span className="font-medium text-foreground" data-testid="welcome-preview-subject">
{preview?.subject ?? ''}
</span>
</p>
{hasErrors && <p className="mb-2 text-sm text-destructive">{t('preview.blocked')}</p>}
{previewFailed && <p className="mb-2 text-sm text-destructive">{t('preview.error')}</p>}
<iframe
title={t('preview.frameTitle')}
sandbox=""
srcDoc={preview?.html ?? ''}
className="h-[760px] w-full rounded-lg border border-border bg-white"
/>
<p className="mt-2 text-xs text-muted-foreground">{t('preview.sampleHint')}</p>
</SettingsSection>
</div>
</div>
<div className="mt-4">
{/* Platzhalter */}
<section
aria-labelledby="welcome-placeholders-title"
className="rounded-lg border border-border p-4"
<SettingsSection
id="welcome-placeholders"
title={t('placeholders.title')}
description={t('placeholders.intro')}
>
<h2
id="welcome-placeholders-title"
className="mb-1 text-sm font-semibold text-foreground"
>
{t('placeholders.title')}
</h2>
<p className="mb-3 text-sm text-muted-foreground">{t('placeholders.intro')}</p>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
@@ -463,50 +518,7 @@ export function WelcomeMailTemplateEditor() {
</table>
</div>
<p className="mt-3 text-xs text-muted-foreground">{t('placeholders.rules')}</p>
</section>
</form>
{/* Vorschau */}
<section aria-labelledby="welcome-preview-title" className="min-w-0">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h2 id="welcome-preview-title" className="text-sm font-semibold text-foreground">
{t('preview.title')}
</h2>
<fieldset className="flex gap-1">
<legend className="sr-only">{t('preview.accountLabel')}</legend>
{(['directory', 'local'] as const).map((kind) => (
<button
key={kind}
type="button"
aria-pressed={account === kind}
onClick={() => setAccount(kind)}
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
account === kind
? 'border-foreground bg-foreground text-background'
: 'border-border text-foreground hover:bg-sidebar-hover'
}`}
>
{t(`preview.${kind}`)}
</button>
))}
</fieldset>
</div>
<p className="mb-2 text-sm text-muted-foreground">
{t('preview.subject')}{' '}
<span className="font-medium text-foreground" data-testid="welcome-preview-subject">
{preview?.subject ?? ''}
</span>
</p>
{hasErrors && <p className="mb-2 text-sm text-destructive">{t('preview.blocked')}</p>}
{previewFailed && <p className="mb-2 text-sm text-destructive">{t('preview.error')}</p>}
<iframe
title={t('preview.frameTitle')}
sandbox=""
srcDoc={preview?.html ?? ''}
className="h-[760px] w-full rounded-lg border border-border bg-white"
/>
<p className="mt-2 text-xs text-muted-foreground">{t('preview.sampleHint')}</p>
</section>
</SettingsSection>
</div>
</div>
);
@@ -0,0 +1,23 @@
'use client';
import type { ReactNode } from 'react';
import { ControlCenterNav } from './control-center-nav';
/**
* Rahmen fuer Einstellungen UND Verwaltung: links die gemeinsame
* Navigation, rechts der Inhalt mit begrenzter Lesebreite. Ersetzt die
* zwei getrennten Layouts samt „Zurueck zum Dashboard“-Link auf jeder
* Seite (das Dashboard steht ohnehin oben in der App-Seitenleiste).
*/
export function ControlCenterLayout({ children }: { children: ReactNode }) {
return (
<div className="flex h-full flex-col md:flex-row">
<aside className="cc-aside">
<ControlCenterNav />
</aside>
<div className="min-w-0 flex-1 overflow-y-auto">
<div className="cc-content">{children}</div>
</div>
</div>
);
}
@@ -0,0 +1,278 @@
'use client';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import type { ReactNode } from 'react';
import { useAuthStore } from '@/lib/stores/auth-store';
/**
* Gemeinsame Navigation fuer Einstellungen und Verwaltung (Design-Runde
* „Verwaltung aufgeraeumt“). Vorher hatten beide Bereiche je eine eigene,
* unterschiedlich gebaute Seitenleiste (eine mit, eine ohne Symbole, die
* Verwaltung als flache Liste mit acht Eintraegen). Jetzt: ein Aufbau,
* oben die persoenlichen Einstellungen, darunter — nur fuer Administratoren
* — die Verwaltung in drei Gruppen nach Aufgabe.
*/
interface NavItem {
href: string;
label: string;
icon: ReactNode;
/** Exakter Treffer statt Praefix (fuer Eltern-Routen mit eigenen Unterseiten). */
exact?: boolean;
show?: boolean;
}
interface NavGroup {
id: string;
label: string;
items: NavItem[];
}
const ICON_PROPS = {
'aria-hidden': true,
width: 16,
height: 16,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.75,
strokeLinecap: 'round',
strokeLinejoin: 'round',
} as const;
const ICONS = {
account: (
<svg {...ICON_PROPS}>
<circle cx="12" cy="8" r="4" />
<path d="M4 21a8 8 0 0 1 16 0" />
</svg>
),
widgets: (
<svg {...ICON_PROPS}>
<rect x="3" y="3" width="7" height="9" rx="1.5" />
<rect x="14" y="3" width="7" height="5" rx="1.5" />
<rect x="14" y="12" width="7" height="9" rx="1.5" />
<rect x="3" y="16" width="7" height="5" rx="1.5" />
</svg>
),
calendar: (
<svg {...ICON_PROPS}>
<rect x="3" y="4" width="18" height="17" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</svg>
),
link: (
<svg {...ICON_PROPS}>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
),
desktop: (
<svg {...ICON_PROPS}>
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg>
),
users: (
<svg {...ICON_PROPS}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
),
groups: (
<svg {...ICON_PROPS}>
<circle cx="7" cy="8" r="3" />
<circle cx="17" cy="8" r="3" />
<path d="M2 20a5 5 0 0 1 10 0M12 20a5 5 0 0 1 10 0" />
</svg>
),
mail: (
<svg {...ICON_PROPS}>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
),
welcome: (
<svg {...ICON_PROPS}>
<path d="M22 13V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7M16 19l2 2 4-4" />
</svg>
),
tenants: (
<svg {...ICON_PROPS}>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<path d="M9 22V12h6v10" />
</svg>
),
modules: (
<svg {...ICON_PROPS}>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<path d="M3.27 6.96 12 12.01l8.73-5.05M12 22.08V12" />
</svg>
),
categories: (
<svg {...ICON_PROPS}>
<path d="M3 6h18M3 12h12M3 18h7" />
</svg>
),
grants: (
<svg {...ICON_PROPS}>
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
),
directory: (
<svg {...ICON_PROPS}>
<path d="M12 2 2 7l10 5 10-5-10-5z" />
<path d="m2 17 10 5 10-5M2 12l10 5 10-5" />
</svg>
),
} as const;
function isActive(pathname: string, item: NavItem): boolean {
if (item.exact) return pathname === item.href;
return pathname === item.href || pathname.startsWith(`${item.href}/`);
}
export function ControlCenterNav() {
const t = useTranslations('controlCenter');
const pathname = usePathname();
const router = useRouter();
const role = useAuthStore((s) => s.user?.role);
const isAdmin = role === 'ADMIN' || role === 'SUPER_ADMIN';
const isSuperAdmin = role === 'SUPER_ADMIN';
const personal: NavGroup = {
id: 'personal',
label: '',
items: [
{ href: '/settings/general/account', label: t('items.account'), icon: ICONS.account },
{ href: '/settings/dashboard', label: t('items.widgets'), icon: ICONS.widgets, exact: true },
{ href: '/settings/dashboard/calendar', label: t('items.calendar'), icon: ICONS.calendar },
{ href: '/settings/custom-modules', label: t('items.myLinks'), icon: ICONS.link },
{ href: '/settings/general/desktop', label: t('items.desktop'), icon: ICONS.desktop },
],
};
const admin: NavGroup[] = [
{
id: 'people',
label: t('groups.people'),
items: [
{ href: '/admin/users', label: t('items.users'), icon: ICONS.users },
{ href: '/admin/groups', label: t('items.groups'), icon: ICONS.groups },
{ href: '/admin/welcome-mail', label: t('items.welcomeMail'), icon: ICONS.welcome },
{
href: '/admin/tenants',
label: t('items.tenants'),
icon: ICONS.tenants,
show: isSuperAdmin,
},
],
},
{
id: 'modules',
label: t('groups.modules'),
items: [
{ href: '/admin/modules', label: t('items.modules'), icon: ICONS.modules, exact: true },
{ href: '/admin/modules/grants', label: t('items.grants'), icon: ICONS.grants },
{ href: '/admin/modules/categories', label: t('items.categories'), icon: ICONS.categories },
{ href: '/admin/custom-modules', label: t('items.sharedLinks'), icon: ICONS.link },
],
},
{
id: 'connections',
label: t('groups.connections'),
items: [
{ href: '/admin/ldap', label: t('items.directory'), icon: ICONS.directory },
{ href: '/admin/smtp', label: t('items.mailServer'), icon: ICONS.mail },
],
},
];
const renderGroup = (group: NavGroup) => {
const items = group.items.filter((item) => item.show !== false);
if (items.length === 0) return null;
return (
<div key={group.id} className="cc-nav-group">
{group.label && <h3 className="cc-nav-heading">{group.label}</h3>}
<ul className="flex flex-col gap-px">
{items.map((item) => {
const active = isActive(pathname, item);
return (
<li key={item.href}>
<Link
href={item.href}
aria-current={active ? 'page' : undefined}
className={`cc-nav-item nav-item ${active ? 'is-active' : ''}`}
>
<span className="cc-nav-icon">{item.icon}</span>
<span className="truncate">{item.label}</span>
</Link>
</li>
);
})}
</ul>
</div>
);
};
// Schmale Bildschirme: eine Auswahlliste statt der ganzen Liste, damit
// der Inhalt nicht erst nach zwanzig Eintraegen beginnt.
const blocks: Array<{ title: string; groups: NavGroup[] }> = [
{ title: t('settingsTitle'), groups: [personal] },
...(isAdmin ? [{ title: t('adminTitle'), groups: admin }] : []),
];
const allItems = blocks.flatMap((b) =>
b.groups.flatMap((g) => g.items.filter((item) => item.show !== false)),
);
const current = allItems.find((item) => isActive(pathname, item))?.href ?? '';
return (
<>
<div className="cc-nav-mobile">
<label htmlFor="cc-nav-select" className="sr-only">
{t('navLabel')}
</label>
<select
id="cc-nav-select"
value={current}
onChange={(e) => router.push(e.target.value)}
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground"
>
{blocks.flatMap((b) =>
b.groups.map((g) => (
<optgroup
key={`${b.title}-${g.id}`}
label={g.label ? `${b.title}: ${g.label}` : b.title}
>
{g.items
.filter((item) => item.show !== false)
.map((item) => (
<option key={item.href} value={item.href}>
{item.label}
</option>
))}
</optgroup>
)),
)}
</select>
</div>
<nav aria-label={t('navLabel')} className="cc-nav">
<div className="cc-nav-block">
<h2 className="cc-nav-title">{t('settingsTitle')}</h2>
{renderGroup(personal)}
</div>
{isAdmin && (
<div className="cc-nav-block">
<h2 className="cc-nav-title">{t('adminTitle')}</h2>
{admin.map(renderGroup)}
</div>
)}
</nav>
</>
);
}
@@ -0,0 +1,30 @@
import type { ReactNode } from 'react';
/**
* Einheitlicher Seitenkopf fuer Einstellungen und Verwaltung: Titel, ein
* Satz Erklaerung, rechts die Hauptaktion der Seite. Vorher hatte jede
* Seite ihre eigene Ueberschriftengroesse (mal text-lg, mal text-2xl).
*/
export function PageHeader({
title,
description,
actions,
children,
}: {
title: ReactNode;
description?: ReactNode;
actions?: ReactNode;
/** Zusatz unter der Beschreibung, z. B. ein Hinweis. */
children?: ReactNode;
}) {
return (
<header className="cc-page-header">
<div className="min-w-0 flex-1">
<h1 className="cc-page-title">{title}</h1>
{description && <p className="cc-page-description">{description}</p>}
{children}
</div>
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
</header>
);
}
@@ -0,0 +1,47 @@
import type { ReactNode } from 'react';
/**
* Ein Abschnitt einer Einstellungsseite: Karte mit Titel, kurzer
* Erklaerung, Inhalt und optional einer Fusszeile fuer die Schaltflaechen
* („Speichern“ immer unten rechts in derselben Karte).
*/
export function SettingsSection({
title,
description,
actions,
footer,
children,
id,
flush = false,
}: {
title?: ReactNode;
description?: ReactNode;
/** Kleine Aktion rechts im Kopf, z. B. „Bearbeiten“. */
actions?: ReactNode;
footer?: ReactNode;
children: ReactNode;
id?: string;
/** Inhalt ohne Innenabstand (fuer Tabellen und Listen bis an den Rand). */
flush?: boolean;
}) {
const headingId = id ? `${id}-title` : undefined;
return (
<section className="cc-section" aria-labelledby={title ? headingId : undefined} id={id}>
{(title || actions) && (
<div className="cc-section-head">
<div className="min-w-0 flex-1">
{title && (
<h2 id={headingId} className="cc-section-title">
{title}
</h2>
)}
{description && <p className="cc-section-description">{description}</p>}
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
)}
<div className={flush ? 'cc-section-body-flush' : 'cc-section-body'}>{children}</div>
{footer && <div className="cc-section-footer">{footer}</div>}
</section>
);
}
@@ -6,6 +6,8 @@ import { useCallback, useEffect, useState } from 'react';
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useCategoryLabel } from '@/lib/use-category-label';
import { PageHeader } from '@/components/control-center/page-header';
import { SettingsSection } from '@/components/control-center/settings-section';
import { CustomModuleFormModal } from './custom-module-form-modal';
import { DeleteCustomModuleDialog } from './delete-custom-module-dialog';
@@ -84,20 +86,19 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className={`${shared ? 'text-2xl' : 'text-lg'} font-semibold text-foreground`}>
{texts.title}
</h1>
<div>
<PageHeader
title={texts.title}
description={texts.description}
actions={
<button type="button" onClick={openCreate} className="btn btn-primary">
{texts.create}
</button>
</div>
<p className="text-sm text-muted-foreground">{texts.description}</p>
}
/>
{loadFailed && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<div className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{tManage('loadError')}
</div>
)}
@@ -105,30 +106,34 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
{/* Bei einem Ladefehler ohne Eintraege steht nur die Fehlermeldung da —
„keine Eintraege“ waere dann falsch. */}
{loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p>
<p className="text-sm text-muted-foreground">{tCommon('loading')}</p>
) : modules.length === 0 && loadFailed ? null : modules.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<h2 className="mb-2 text-lg font-semibold text-foreground">{texts.noModules}</h2>
<p className="mb-6 text-sm text-muted-foreground">{texts.noModulesBody}</p>
<button type="button" onClick={openCreate} className="btn btn-primary">
<SettingsSection>
<div className="flex flex-col items-start gap-3">
<div>
<p className="text-sm font-medium text-foreground">{texts.noModules}</p>
<p className="mt-1 text-sm text-muted-foreground">{texts.noModulesBody}</p>
</div>
<button type="button" onClick={openCreate} className="btn btn-secondary">
{texts.create}
</button>
</div>
</SettingsSection>
) : (
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<SettingsSection flush>
<table className="w-full table-fixed text-sm">
<thead>
<tr className="border-b border-border">
<th className="w-[28%] px-5 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('name')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-5 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('url')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="w-[18%] px-5 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('category')}
</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
<th className="w-[10rem] px-5 pb-2 text-right text-xs font-medium text-muted-foreground">
{tManage('actions')}
</th>
</tr>
@@ -136,30 +141,32 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
<tbody className="divide-y divide-border">
{modules.map((mod) => (
<tr key={mod.id} className="transition-colors hover:bg-muted/30">
<td className="px-4 py-3 font-medium text-foreground">
<td className="truncate px-5 py-3 font-medium text-foreground">
<Link href={`/modules/custom/${mod.id}`} className="link">
{mod.name}
</Link>
</td>
<td className="max-w-[24rem] px-4 py-3 text-muted-foreground">
<td className="px-5 py-3 text-muted-foreground">
<span className="block truncate" title={mod.url}>
{mod.url}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">{categoryLabel(mod.category)}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<td className="truncate px-5 py-3 text-muted-foreground">
{categoryLabel(mod.category)}
</td>
<td className="px-5 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button
type="button"
onClick={() => openEdit(mod)}
className="rounded px-2 py-1 text-xs text-foreground transition-colors hover:bg-muted"
className="btn btn-subtle text-xs"
>
{tCommon('edit')}
</button>
<button
type="button"
onClick={() => setDeleteTarget(mod)}
className="rounded px-2 py-1 text-xs text-destructive transition-colors hover:bg-destructive/10"
className="btn btn-subtle text-xs text-destructive"
>
{tCommon('delete')}
</button>
@@ -169,7 +176,7 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
))}
</tbody>
</table>
</div>
</SettingsSection>
)}
{showFormModal && (
@@ -1,5 +1,6 @@
'use client';
import { SettingsSection } from '@/components/control-center/settings-section';
import { useState, useTransition, useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
import {
@@ -149,12 +150,9 @@ export function AccountSettingsForm() {
const userInitial = username.charAt(0).toUpperCase() || '?';
return (
<div className="space-y-8 max-w-lg">
<div>
{/* ── Avatar section ─────────────────────────────────────── */}
<section>
<h2 className="text-sm font-semibold text-foreground mb-4">
{t('account.avatarLabel')}
</h2>
<SettingsSection id="account-avatar" title={t('account.avatarLabel')}>
<div className="flex items-center gap-4 mb-4">
<div className="relative h-16 w-16 shrink-0">
{hasAvatar ? (
@@ -197,7 +195,7 @@ export function AccountSettingsForm() {
disabled={isAvatarPending}
onChange={handleAvatarUpload}
/>
<span className="btn btn-primary">
<span className="btn btn-secondary">
{isAvatarPending ? '...' : t('account.uploadCta')}
</span>
</label>
@@ -206,20 +204,30 @@ export function AccountSettingsForm() {
type="button"
disabled={isAvatarPending}
onClick={handleDeleteAvatar}
className="inline-flex items-center justify-center rounded-md border border-destructive/40 px-4 py-2 text-sm font-medium text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-50"
className="btn btn-subtle text-destructive"
>
{t('account.deleteAvatarCta')}
</button>
)}
</div>
</section>
</SettingsSection>
{/* ── Accent color section ────────────────────────────────── */}
<section>
<h2 className="text-sm font-semibold text-foreground mb-1">
{t('account.accentColorLabel')}
</h2>
<p className="text-xs text-muted-foreground mb-4">{t('account.accentColorHelp')}</p>
<SettingsSection
id="account-accent"
title={t('account.accentColorLabel')}
description={t('account.accentColorHelp')}
footer={
<button
type="button"
onClick={handleSaveAccentColor}
disabled={isAccentPending || !isHexValid}
className="btn btn-primary"
>
{isAccentPending ? '...' : t('account.accentColorSave')}
</button>
}
>
<div className="flex items-center gap-3 mb-1">
<input
type="color"
@@ -277,21 +285,32 @@ export function AccountSettingsForm() {
{t('account.accentColorError')}
</div>
)}
<button
type="button"
onClick={handleSaveAccentColor}
disabled={isAccentPending || !isHexValid}
className="btn btn-primary"
>
{isAccentPending ? '...' : t('account.accentColorSave')}
</button>
</section>
</SettingsSection>
{/* ── Password section ────────────────────────────────────── */}
<section>
<h2 className="text-sm font-semibold text-foreground mb-4">
{t('account.passwordSectionTitle')}
</h2>
<SettingsSection
id="account-password"
title={t('account.passwordSectionTitle')}
footer={
isLocalUser === true ? (
<button
type="submit"
form="account-password-form"
disabled={isPwPending}
className="btn btn-primary"
>
{isPwPending ? (
<svg aria-hidden="true" className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
tAuth('changePassword.submit')
)}
</button>
) : undefined
}
>
{isLocalUser === false && (
<div className="rounded-md bg-muted px-4 py-3 text-sm text-muted-foreground">
@@ -317,7 +336,12 @@ export function AccountSettingsForm() {
</div>
)}
<form ref={pwFormRef} onSubmit={handlePasswordSubmit} className="space-y-4">
<form
id="account-password-form"
ref={pwFormRef}
onSubmit={handlePasswordSubmit}
className="max-w-md space-y-4"
>
<div className="space-y-2">
<label htmlFor="currentPassword" className="text-sm font-medium text-foreground">
{tAuth('changePassword.currentPassword')}
@@ -362,24 +386,10 @@ export function AccountSettingsForm() {
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<button
type="submit"
disabled={isPwPending}
className="btn btn-primary"
>
{isPwPending ? (
<svg aria-hidden="true" className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
tAuth('changePassword.submit')
)}
</button>
</form>
</>
)}
</section>
</SettingsSection>
</div>
);
}
@@ -10,6 +10,7 @@ import {
testSource,
} from '@/lib/calendar-api';
import type { CalendarSource, CreateSourcePayload } from '@/lib/calendar-api';
import { SettingsSection } from '@/components/control-center/settings-section';
import { CalendarSourceForm } from './calendar-source-form';
/**
@@ -30,6 +31,7 @@ const TYPE_LABELS: Record<string, string> = {
export function CalendarSettingsPanel() {
const t = useTranslations('widgets');
const ts = useTranslations('settings');
const tCc = useTranslations('controlCenter');
const [sources, setSources] = useState<CalendarSource[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -40,7 +42,9 @@ export function CalendarSettingsPanel() {
const [saveError, setSaveError] = useState<string | null>(null);
const [editSaveError, setEditSaveError] = useState<string | null>(null);
const [editSaveSuccess, setEditSaveSuccess] = useState(false);
const [testResults, setTestResults] = useState<Record<string, 'success' | 'error' | 'testing'>>({});
const [testResults, setTestResults] = useState<Record<string, 'success' | 'error' | 'testing'>>(
{},
);
// Load sources on mount
useEffect(() => {
@@ -53,31 +57,25 @@ export function CalendarSettingsPanel() {
}, []);
// Toggle visibility (CAL-02)
const handleVisibilityToggle = useCallback(
async (source: CalendarSource) => {
const handleVisibilityToggle = useCallback(async (source: CalendarSource) => {
const newIsVisible = !source.isVisible;
// Optimistic update
setSources((prev) =>
prev.map((s) =>
s.id === source.id ? { ...s, isVisible: newIsVisible } : s,
),
prev.map((s) => (s.id === source.id ? { ...s, isVisible: newIsVisible } : s)),
);
try {
await updateSource(source.id, { isVisible: newIsVisible });
} catch {
// Revert on error
setSources((prev) =>
prev.map((s) =>
s.id === source.id ? { ...s, isVisible: source.isVisible } : s,
),
prev.map((s) => (s.id === source.id ? { ...s, isVisible: source.isVisible } : s)),
);
}
},
[],
);
}, []);
// Add new source
const handleAddSource = useCallback(async (payload: CreateSourcePayload) => {
const handleAddSource = useCallback(
async (payload: CreateSourcePayload) => {
setIsSaving(true);
setSaveError(null);
try {
@@ -101,7 +99,9 @@ export function CalendarSettingsPanel() {
} finally {
setIsSaving(false);
}
}, [t]);
},
[t],
);
// Delete source
const handleDeleteSource = useCallback(async (id: string) => {
@@ -116,27 +116,32 @@ export function CalendarSettingsPanel() {
// Loading state
if (isLoading) {
return (
<div className="text-sm text-muted-foreground">Loading...</div>
);
return <div className="text-sm text-muted-foreground">Loading...</div>;
}
return (
<div className="space-y-6">
<div>
{/* Source list */}
{sources.length === 0 && !showAddForm && (
<p className="text-sm text-muted-foreground">
{t('calendar.sourceEmpty')}
</p>
<SettingsSection
flush
id="calendar-sources"
title={tCc('calendar.sourcesTitle')}
footer={
!showAddForm ? (
<button type="button" onClick={() => setShowAddForm(true)} className="btn btn-primary">
{t('calendar.addSource')}
</button>
) : undefined
}
>
{sources.length === 0 && (
<p className="px-5 pb-4 text-sm text-muted-foreground">{t('calendar.sourceEmpty')}</p>
)}
{sources.length > 0 && (
<div className="space-y-2">
<div className="divide-y divide-border border-t border-border">
{sources.map((source) => (
<div
key={source.id}
className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3"
>
<div key={source.id} className="flex items-center gap-3 px-5 py-3">
{/* Color dot */}
<span
className="h-3 w-3 shrink-0 rounded-full"
@@ -145,9 +150,7 @@ export function CalendarSettingsPanel() {
/>
{/* Name */}
<span className="flex-1 text-sm font-medium text-foreground">
{source.name}
</span>
<span className="flex-1 text-sm font-medium text-foreground">{source.name}</span>
{/* Type badge */}
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
@@ -302,19 +305,17 @@ export function CalendarSettingsPanel() {
))}
</div>
)}
</SettingsSection>
{/* Edit form (below the source being edited) */}
{/* Edit form (below the source list) */}
{editingId && (
<div className="rounded-md border border-border bg-card p-4">
{editSaveError && (
<p className="mb-3 text-sm text-destructive">{editSaveError}</p>
)}
<SettingsSection id="calendar-edit-source" title="Edit Source">
{editSaveError && <p className="mb-3 text-sm text-destructive">{editSaveError}</p>}
{editSaveSuccess && (
<p className="mb-3 text-sm text-green-600 dark:text-green-400">{t('calendar.saveSuccess') || 'Gespeichert'}</p>
<p className="mb-3 text-sm text-green-600 dark:text-green-400">
{t('calendar.saveSuccess') || 'Gespeichert'}
</p>
)}
<h3 className="mb-3 text-sm font-semibold text-foreground">
Edit Source
</h3>
<CalendarSourceForm
initialValues={(() => {
const s = sources.find((src) => src.id === editingId);
@@ -337,9 +338,7 @@ export function CalendarSettingsPanel() {
setEditSaveSuccess(false);
try {
const updated = await updateSource(editingId, payload);
setSources((prev) =>
prev.map((s) => (s.id === editingId ? updated : s)),
);
setSources((prev) => prev.map((s) => (s.id === editingId ? updated : s)));
setEditSaveSuccess(true);
} catch {
setEditSaveError(t('calendar.saveError') || 'Fehler beim Speichern');
@@ -350,35 +349,19 @@ export function CalendarSettingsPanel() {
onCancel={() => setEditingId(null)}
isSaving={isSaving}
/>
</div>
)}
{/* Add source button */}
{!showAddForm && (
<button
type="button"
onClick={() => setShowAddForm(true)}
className="btn btn-primary"
>
{t('calendar.addSource')}
</button>
</SettingsSection>
)}
{/* Add source form */}
{showAddForm && (
<div className="rounded-md border border-border bg-card p-4">
<h3 className="mb-3 text-sm font-semibold text-foreground">
{t('calendar.addSource')}
</h3>
{saveError && (
<p className="mb-3 text-sm text-destructive">{saveError}</p>
)}
<SettingsSection id="calendar-add-source" title={t('calendar.addSource')}>
{saveError && <p className="mb-3 text-sm text-destructive">{saveError}</p>}
<CalendarSourceForm
onSave={handleAddSource}
onCancel={() => setShowAddForm(false)}
isSaving={isSaving}
/>
</div>
</SettingsSection>
)}
{/* Delete confirmation dialog (UI-SPEC destructive pattern) */}
@@ -399,12 +382,8 @@ export function CalendarSettingsPanel() {
aria-modal="true"
aria-label={t('calendar.deleteDialogLabel')}
>
<h3 className="mb-2 text-lg font-semibold text-foreground">
{ts('sourceDeleteCta')}
</h3>
<p className="mb-4 text-sm text-muted-foreground">
{ts('sourceDeleteConfirm')}
</p>
<h3 className="mb-2 text-lg font-semibold text-foreground">{ts('sourceDeleteCta')}</h3>
<p className="mb-4 text-sm text-muted-foreground">{ts('sourceDeleteConfirm')}</p>
<div className="flex justify-end gap-3">
<button
type="button"
@@ -2,11 +2,15 @@
import { useEffect, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { desktopDownloadUrl, type DesktopFileInfo, type DesktopLatestInfo, formatFileSize, loadDesktopLatest } from '@/lib/desktop';
import {
desktopDownloadUrl,
type DesktopFileInfo,
type DesktopLatestInfo,
formatFileSize,
loadDesktopLatest,
} from '@/lib/desktop';
import { useIsDesktopClient } from '@/lib/desktop-client';
const BUTTON_CLASS =
'inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90';
import { SettingsSection } from '@/components/control-center/settings-section';
function WindowsIcon() {
return (
@@ -21,7 +25,15 @@ function WindowsIcon() {
function LinuxIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<rect x="1" y="2" width="14" height="12" rx="1.5" />
<path d="M4 6l2.5 2L4 10" strokeLinecap="round" strokeLinejoin="round" />
<line x1="8" y1="10" x2="11" y2="10" strokeLinecap="round" />
@@ -36,6 +48,7 @@ function DownloadButton({
testId,
locale,
fileInfoText,
primary = false,
}: {
file: DesktopFileInfo;
label: string;
@@ -43,10 +56,17 @@ function DownloadButton({
testId: string;
locale: string;
fileInfoText: (values: { name: string; size: string }) => string;
/** Hauptknopf der Section (gelb); nur einer je Section. */
primary?: boolean;
}) {
return (
<div>
<a href={desktopDownloadUrl(file)} download data-testid={testId} className={BUTTON_CLASS}>
<div className="text-right">
<a
href={desktopDownloadUrl(file)}
download
data-testid={testId}
className={`btn ${primary ? 'btn-primary' : 'btn-secondary'}`}
>
{icon}
{label}
</a>
@@ -85,42 +105,9 @@ export function DesktopAppSettings() {
const fileInfoText = (values: { name: string; size: string }) => t('desktop.fileInfo', values);
return (
<div>
<p className="text-sm text-muted-foreground">{t('desktop.intro')}</p>
<p className="text-sm text-muted-foreground">{t('desktop.firstStart')}</p>
<p className="text-sm text-muted-foreground">{t('desktop.tray')}</p>
<p className="text-sm text-muted-foreground">{t('desktop.update')}</p>
{isDesktop && (
<div
data-testid="desktop-connected"
className="mt-4 space-y-1 rounded border border-border bg-muted/30 p-3 text-sm"
>
<p className="text-foreground">{t('desktop.connectedTo', { origin: window.location.origin })}</p>
<p className="text-muted-foreground">{t('desktop.changeHint')}</p>
</div>
)}
{info === null && <p className="mt-4 text-sm text-muted-foreground">{t('desktop.unavailable')}</p>}
{info && (
const footer =
info && (info.files.windows || info.files.linux) ? (
<>
<p className="mt-4 text-sm text-foreground">{t('desktop.versionLabel', { version: info.version })}</p>
{info.channel === 'beta' && (
<p className="text-sm text-muted-foreground">{t('desktop.channelBeta', { commit: info.commit })}</p>
)}
<div className="mt-4 flex flex-wrap gap-4">
{info.files.windows && (
<DownloadButton
file={info.files.windows}
label={t('desktop.downloadWindows')}
icon={<WindowsIcon />}
testId="desktop-download-windows"
locale={locale}
fileInfoText={fileInfoText}
/>
)}
{info.files.linux && (
<DownloadButton
file={info.files.linux}
@@ -129,11 +116,62 @@ export function DesktopAppSettings() {
testId="desktop-download-linux"
locale={locale}
fileInfoText={fileInfoText}
primary={!info.files.windows}
/>
)}
{info.files.windows && (
<DownloadButton
file={info.files.windows}
label={t('desktop.downloadWindows')}
icon={<WindowsIcon />}
testId="desktop-download-windows"
locale={locale}
fileInfoText={fileInfoText}
primary
/>
)}
</div>
</>
) : undefined;
return (
<SettingsSection footer={footer}>
<div className="max-w-prose space-y-2 text-sm text-muted-foreground">
<p>{t('desktop.intro')}</p>
<p>{t('desktop.firstStart')}</p>
<p>{t('desktop.tray')}</p>
<p>{t('desktop.update')}</p>
</div>
{isDesktop && (
<div
data-testid="desktop-connected"
className="mt-4 max-w-prose space-y-1 rounded-md bg-muted px-4 py-3 text-sm"
>
<p className="text-foreground">
{t('desktop.connectedTo', { origin: window.location.origin })}
</p>
<p className="text-muted-foreground">{t('desktop.changeHint')}</p>
</div>
)}
{info === null && (
<p className="mt-4 border-t border-border pt-4 text-sm text-muted-foreground">
{t('desktop.unavailable')}
</p>
)}
{info && (
<div className="mt-4 border-t border-border pt-4">
<p className="text-sm font-medium text-foreground">
{t('desktop.versionLabel', { version: info.version })}
</p>
{info.channel === 'beta' && (
<p className="mt-0.5 text-xs text-muted-foreground">
{t('desktop.channelBeta', { commit: info.commit })}
</p>
)}
</div>
)}
</SettingsSection>
);
}
@@ -1,113 +0,0 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
/**
* Settings sub-sidebar (D-19, D-20).
* Phase 05 categories: Dashboard > Widgets, Calendar.
* Active item uses sidebar-accent pattern with aria-current="page".
*/
export function SettingsSidebar() {
const t = useTranslations('settings');
const pathname = usePathname();
const items = [
{
label: t('categoryWidgets'),
href: '/settings/dashboard',
},
{
label: t('categoryCalendar'),
href: '/settings/dashboard/calendar',
},
];
const isActive = (href: string) => {
if (href === '/settings/dashboard') {
// Active when exactly on /settings/dashboard (not on sub-routes like /calendar)
return pathname === '/settings/dashboard';
}
return pathname.startsWith(href);
};
return (
// a11y/useSemanticElements: role="navigation" nicht auf das <aside> gehoben --
// darunter stehen zwei <nav>-Bereiche (Allgemein, Dashboard); ein drittes
// Navigations-Landmark auf dem Rahmen waere doppelt. Jedes <nav> traegt
// stattdessen die Beschriftung seiner eigenen Kategorie.
<aside className="flex w-full shrink-0 flex-col border-b border-border py-2 md:w-[220px] md:border-b-0">
{/* Allgemein category — above Dashboard (Surface C, 07-06) */}
<div className="px-5 pb-1 pt-3">
<h2 className="text-xs font-semibold text-muted-foreground">
{t('categoryGeneral')}
</h2>
</div>
<nav className="mb-2 flex flex-col gap-0.5 px-3" aria-label={t('categoryGeneral')}>
<Link
href="/settings/general/account"
className={`nav-item flex h-9 items-center rounded-lg px-2.5 text-sm transition-colors ${
isActive('/settings/general/account')
? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold'
: 'text-sidebar-foreground hover:bg-sidebar-hover'
}`}
aria-current={
isActive('/settings/general/account') ? 'page' : undefined
}
>
{t('categoryAccount')}
</Link>
<Link
href="/settings/general/desktop"
className={`nav-item flex h-9 items-center rounded-lg px-2.5 text-sm transition-colors ${
isActive('/settings/general/desktop')
? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold'
: 'text-sidebar-foreground hover:bg-sidebar-hover'
}`}
aria-current={
isActive('/settings/general/desktop') ? 'page' : undefined
}
>
{t('categoryDesktopApp')}
</Link>
<Link
href="/settings/custom-modules"
className={`nav-item flex h-9 items-center rounded-lg px-2.5 text-sm transition-colors ${
isActive('/settings/custom-modules')
? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold'
: 'text-sidebar-foreground hover:bg-sidebar-hover'
}`}
aria-current={
isActive('/settings/custom-modules') ? 'page' : undefined
}
>
{t('categoryCustomModules')}
</Link>
</nav>
{/* Dashboard category — existing items unchanged */}
<div className="px-5 pb-1 pt-4">
<h2 className="text-xs font-semibold text-muted-foreground">
{t('categoryDashboard')}
</h2>
</div>
<nav className="flex flex-col gap-0.5 px-3" aria-label={t('categoryDashboard')}>
{items.map((item) => (
<Link
key={item.href}
href={item.href}
className={`nav-item flex h-9 items-center rounded-lg px-2.5 text-sm transition-colors ${
isActive(item.href)
? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold'
: 'text-sidebar-foreground hover:bg-sidebar-hover'
}`}
aria-current={isActive(item.href) ? 'page' : undefined}
>
{item.label}
</Link>
))}
</nav>
</aside>
);
}
@@ -1,5 +1,6 @@
'use client';
import { SettingsSection } from '@/components/control-center/settings-section';
import { useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import {
@@ -51,6 +52,7 @@ type TestFeedback =
*/
export function SmtpSettingsForm() {
const t = useTranslations('settings');
const tCc = useTranslations('controlCenter');
const [form, setForm] = useState<FormState>(DEFAULT_FORM);
const [testTo, setTestTo] = useState('');
@@ -137,11 +139,13 @@ export function SmtpSettingsForm() {
};
const inputClass =
'h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground';
'h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground';
const labelClass = 'mb-1 block text-sm text-foreground';
return (
<form onSubmit={handleSave} className="space-y-4">
<form onSubmit={handleSave}>
<SettingsSection id="smtp-server" title={tCc('smtp.server')} description={tCc('smtp.serverHelp')}>
<div className="grid max-w-2xl gap-4 sm:grid-cols-[minmax(0,1fr)_8rem]">
{/* Host */}
<div>
<label htmlFor="smtp-host" className={labelClass}>
@@ -159,7 +163,6 @@ export function SmtpSettingsForm() {
}
/>
</div>
{/* Port */}
<div>
<label htmlFor="smtp-port" className={labelClass}>
@@ -181,7 +184,8 @@ export function SmtpSettingsForm() {
}
/>
</div>
</div>
<div className="mt-4 max-w-2xl space-y-4">
{/* Verschlüsselung */}
<div>
<label htmlFor="smtp-encryption" className={labelClass}>
@@ -206,7 +210,8 @@ export function SmtpSettingsForm() {
))}
</select>
</div>
</div>
<div className="mt-4 grid max-w-2xl gap-4 sm:grid-cols-2">
{/* Benutzername */}
<div>
<label htmlFor="smtp-username" className={labelClass}>
@@ -222,13 +227,12 @@ export function SmtpSettingsForm() {
}
/>
</div>
{/* Passwort + show/hide toggle */}
<div>
<label htmlFor="smtp-password" className={labelClass}>
{t('smtp.password')}
</label>
<div className="relative flex max-w-md">
<div className="relative flex">
<input
id="smtp-password"
type={showPassword ? 'text' : 'password'}
@@ -283,7 +287,11 @@ export function SmtpSettingsForm() {
</button>
</div>
</div>
</div>
</SettingsSection>
<SettingsSection id="smtp-sender" title={tCc('smtp.sender')} description={tCc('smtp.senderHelp')}>
<div className="max-w-2xl space-y-4">
{/* Absenderadresse */}
<div>
<label htmlFor="smtp-from-address" className={labelClass}>
@@ -304,7 +312,6 @@ export function SmtpSettingsForm() {
{t('smtp.fromAddressHelp')}
</p>
</div>
{/* Fehlermeldungen an (quick-260914-m97) */}
<div>
<label htmlFor="smtp-bug-report-recipient" className={labelClass}>
@@ -324,9 +331,26 @@ export function SmtpSettingsForm() {
{t('smtp.bugReportRecipientHelp')}
</p>
</div>
</div>
</SettingsSection>
<SettingsSection
id="smtp-check"
title={tCc('smtp.check')}
description={tCc('smtp.checkHelp')}
footer={
<>
<button type="button" disabled={isTesting} onClick={handleTest} className="btn btn-secondary">
{t('smtp.test')}
</button>
<button type="submit" disabled={isSaving} className="btn btn-primary">
{t('smtp.save')}
</button>
</>
}
>
{/* Test-E-Mail Empfänger */}
<div>
<div className="max-w-2xl">
<label htmlFor="smtp-test-to" className={labelClass}>
{t('smtp.testToLabel')}
</label>
@@ -342,26 +366,6 @@ export function SmtpSettingsForm() {
{t('smtp.testToHelp')}
</p>
</div>
{/* Action row */}
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={isSaving}
className="btn btn-primary"
>
{t('smtp.save')}
</button>
<button
type="button"
disabled={isTesting}
onClick={handleTest}
className="btn btn-secondary"
>
{t('smtp.test')}
</button>
</div>
{/* Connection test feedback */}
{testFeedback && (
<p
@@ -381,6 +385,7 @@ export function SmtpSettingsForm() {
{testFeedback.message}
</p>
)}
</SettingsSection>
</form>
);
}
@@ -3,6 +3,7 @@
import { useCallback, useState } from 'react';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import { SettingsSection } from '@/components/control-center/settings-section';
import { WIDGET_REGISTRY, type WidgetType } from '@/components/dashboard/widget-registry';
import { PictureFrameConfigForm } from '@/components/settings/picture-frame-config-form';
import { ProxmoxWidgetConfigForm } from '@/components/settings/proxmox-widget-config-form';
@@ -53,10 +54,7 @@ interface WidgetSettingsPanelProps {
* Lists all placed widget instances grouped by type.
* Each instance is expandable to its type-specific config form.
*/
export function WidgetSettingsPanel({
widgets,
onWidgetUpdate,
}: WidgetSettingsPanelProps) {
export function WidgetSettingsPanel({ widgets, onWidgetUpdate }: WidgetSettingsPanelProps) {
const t = useTranslations('widgets');
const [expandedId, setExpandedId] = useState<string | null>(null);
const [saving, setSaving] = useState<string | null>(null);
@@ -88,36 +86,35 @@ export function WidgetSettingsPanel({
);
return (
<div className="space-y-6">
<div>
{Object.entries(groupedByType).map(([type, instances]) => {
const def = WIDGET_REGISTRY[type as WidgetType];
if (!def) return null;
return (
<div key={type}>
{/* Type group heading */}
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
<SettingsSection
key={type}
id={`widget-type-${type}`}
flush
title={
<span className="flex items-center gap-2">
<def.icon className="h-4 w-4 text-muted-foreground" />
{t(def.nameKey)}
</h2>
<div className="space-y-2">
</span>
}
>
<div className="divide-y divide-border overflow-hidden rounded-b-[calc(var(--radius)+2px)] border-t border-border">
{instances.map((widget, idx) => {
const isExpanded = expandedId === widget.id;
const isSaving = saving === widget.id;
return (
<div
key={widget.id}
className="rounded-md border border-border bg-card"
>
<div key={widget.id}>
{/* Instance header (click to expand) */}
<button
type="button"
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm text-foreground hover:bg-muted/50 transition-colors"
onClick={() =>
setExpandedId(isExpanded ? null : widget.id)
}
className="flex w-full items-center justify-between px-5 py-3 text-left text-sm text-foreground hover:bg-muted/50 transition-colors"
onClick={() => setExpandedId(isExpanded ? null : widget.id)}
>
<span>
{t(def.nameKey)} #{idx + 1}
@@ -163,35 +160,27 @@ export function WidgetSettingsPanel({
{/* Expanded config form */}
{isExpanded && (
<div className="border-t border-border px-4 py-4">
<div className="border-t border-border bg-muted/20 px-5 py-4">
{isSaving && (
<p className="mb-2 text-xs text-muted-foreground">
Saving...
</p>
<p className="mb-2 text-xs text-muted-foreground">Saving...</p>
)}
{/* Clock config (D-12, D-13) */}
{widget.widgetType === 'clock' && (
<ClockConfig
config={widget.config}
onChange={(cfg) =>
handleConfigChange(widget.id, cfg)
}
onChange={(cfg) => handleConfigChange(widget.id, cfg)}
/>
)}
{/* Search config (D-15) */}
{widget.widgetType === 'search' && (
<SearchProviderForm />
)}
{widget.widgetType === 'search' && <SearchProviderForm />}
{/* Note config (D-17) */}
{widget.widgetType === 'note' && (
<NoteConfig
config={widget.config}
onChange={(cfg) =>
handleConfigChange(widget.id, cfg)
}
onChange={(cfg) => handleConfigChange(widget.id, cfg)}
/>
)}
@@ -199,9 +188,7 @@ export function WidgetSettingsPanel({
{widget.widgetType === 'calendar' && (
<CalendarConfig
config={widget.config}
onChange={(cfg) =>
handleConfigChange(widget.id, cfg)
}
onChange={(cfg) => handleConfigChange(widget.id, cfg)}
/>
)}
@@ -209,9 +196,7 @@ export function WidgetSettingsPanel({
{widget.widgetType === 'favorites' && (
<FavoritesConfig
config={widget.config}
onChange={(cfg) =>
handleConfigChange(widget.id, cfg)
}
onChange={(cfg) => handleConfigChange(widget.id, cfg)}
/>
)}
@@ -219,9 +204,7 @@ export function WidgetSettingsPanel({
{widget.widgetType === 'picture-frame' && (
<PictureFrameConfigForm
config={widget.config}
onChange={(cfg) =>
handleConfigChange(widget.id, cfg)
}
onChange={(cfg) => handleConfigChange(widget.id, cfg)}
/>
)}
@@ -229,9 +212,7 @@ export function WidgetSettingsPanel({
{widget.widgetType === 'xframe' && (
<XframeConfigForm
config={widget.config}
onChange={(cfg) =>
handleConfigChange(widget.id, cfg)
}
onChange={(cfg) => handleConfigChange(widget.id, cfg)}
/>
)}
@@ -239,9 +220,7 @@ export function WidgetSettingsPanel({
{widget.widgetType === 'proxmox' && (
<ProxmoxWidgetConfigForm
config={widget.config}
onChange={(cfg) =>
handleConfigChange(widget.id, cfg)
}
onChange={(cfg) => handleConfigChange(widget.id, cfg)}
/>
)}
</div>
@@ -250,7 +229,7 @@ export function WidgetSettingsPanel({
);
})}
</div>
</div>
</SettingsSection>
);
})}
</div>
@@ -303,10 +282,7 @@ function ClockConfig({
<div className="space-y-4">
{/* Timezone select (D-12) */}
<div>
<label
htmlFor="timezone-select"
className="mb-1 block text-sm text-foreground"
>
<label htmlFor="timezone-select" className="mb-1 block text-sm text-foreground">
Timezone
</label>
<select
@@ -449,10 +425,7 @@ function CalendarConfig({
{/* Hinweis auf die Kalenderquellenverwaltung */}
<p className="text-xs text-muted-foreground">
{t('calendar.configSourcesHint')}{' '}
<Link
href="/settings/dashboard/calendar"
className="link"
>
<Link href="/settings/dashboard/calendar" className="link">
{t('calendar.configSourcesLink')}
</Link>
</p>
@@ -502,10 +475,7 @@ function FavoritesConfig({
return (
<div>
<label
htmlFor="favorites-title"
className="mb-1 block text-sm text-foreground"
>
<label htmlFor="favorites-title" className="mb-1 block text-sm text-foreground">
{t('favorites.titleLabel')}
</label>
<input
+71 -9
View File
@@ -81,7 +81,7 @@
"settings": "Einstellungen",
"logout": "Abmelden",
"admin": {
"label": "Administrator",
"label": "Verwaltung",
"users": "Benutzer",
"tenants": "Mandanten",
"modules": "Module",
@@ -210,7 +210,7 @@
"providerDeleteCta": "Anbieter löschen",
"cancel": "Abbrechen",
"smtp": {
"title": "SMTP-Konfiguration",
"title": "E-Mail-Versand",
"host": "Host",
"port": "Port",
"encryption": "Verschlüsselung",
@@ -531,7 +531,7 @@
},
"admin": {
"users": {
"title": "Benutzerverwaltung",
"title": "Benutzer",
"create": "Benutzer erstellen",
"edit": "Benutzer bearbeiten",
"delete": "Benutzer löschen",
@@ -574,7 +574,7 @@
"resend": "Erneut senden",
"sentAt": "Willkommensmail gesendet am {date}",
"noEmail": "Keine E-Mail-Adresse hinterlegt – bitte zuerst eine Adresse eintragen.",
"noSmtp": "Es ist noch kein SMTP-Server eingerichtet (Administrator → SMTP).",
"noSmtp": "Es ist noch kein SMTP-Server eingerichtet (Verwaltung → E-Mail-Versand).",
"inactive": "Das Konto ist deaktiviert.",
"dialogTitle": "Willkommensmail senden",
"dialogText": "Tessera schickt eine Willkommensmail mit Adresse, Benutzername und Hinweisen zur ersten Anmeldung an:",
@@ -598,7 +598,7 @@
"cannotDeleteActive": "Mandanten mit aktiven Benutzern können nicht gelöscht werden"
},
"ldap": {
"title": "LDAP-Konfiguration",
"title": "Verzeichnis (LDAP)",
"connectionTitle": "Verbindungseinstellungen",
"serverUrl": "Server-URL",
"baseDn": "Basis-DN",
@@ -745,7 +745,7 @@
}
},
"customModules": {
"title": "Eigene Module",
"title": "Eigene Module für alle",
"description": "Nehmen Sie andere Webseiten als Einträge in die Seitenleiste auf. Diese gemeinsamen Einträge sehen alle Benutzer unter der gewählten Kategorie, ein Klick zeigt die Seite direkt in Tessera. Persönliche Einträge nur für Sie legen Sie unter „Einstellungen → Eigene Module“ an.",
"create": "Gemeinsamen Eintrag anlegen",
"noModules": "Noch keine gemeinsamen Einträge",
@@ -853,7 +853,7 @@
}
},
"adminModules": {
"title": "Modulverwaltung",
"title": "Module",
"description": "Module aktivieren oder deaktivieren",
"activate": "Aktivieren",
"deactivate": "Deaktivieren",
@@ -868,7 +868,7 @@
"grantsLink": "Freigaben-Matrix",
"categoriesLink": "Kategorien",
"grants": {
"title": "Freigaben-Matrix",
"title": "Freigaben",
"searchPlaceholder": "Module oder Gruppen durchsuchen...",
"emptyModules": "Es sind noch keine Module aktiviert. Aktiviere zuerst ein Modul unter Module.",
"emptyModulesLink": "Zu Module",
@@ -940,7 +940,7 @@
"close": "Schließen",
"sent": "Vielen Dank, die Meldung wurde gesendet.",
"errorNotConfigured": "Für Fehlermeldungen ist noch kein Postfach eingerichtet.",
"errorNotConfiguredAdminHint": "Legen Sie die Adresse unter Administrator → SMTP im Feld „Fehlermeldungen an\" fest.",
"errorNotConfiguredAdminHint": "Legen Sie die Adresse unter Verwaltung → E-Mail-Versand im Feld „Fehlermeldungen an\" fest.",
"errorNotConfiguredAdminLink": "Zu den SMTP-Einstellungen",
"errorTooMany": "Zu viele Meldungen in kurzer Zeit. Bitte versuchen Sie es in einigen Minuten erneut.",
"errorTooLarge": "Das Bild ist zu groß. Bitte senden Sie die Meldung ohne Bildschirmfoto.",
@@ -1985,5 +1985,67 @@
"countsLabel": "Anzahl der Clouds je Ampelfarbe",
"open": "Nextcloud-Status öffnen"
}
},
"controlCenter": {
"navLabel": "Einstellungen und Verwaltung",
"settingsTitle": "Einstellungen",
"adminTitle": "Verwaltung",
"groups": {
"people": "Benutzer und Zugang",
"modules": "Module",
"connections": "Anbindungen"
},
"items": {
"account": "Konto",
"widgets": "Widgets",
"calendar": "Kalender",
"myLinks": "Eigene Module",
"desktop": "Desktop-App",
"users": "Benutzer",
"groups": "Gruppen",
"welcomeMail": "Willkommensmail",
"tenants": "Mandanten",
"modules": "Module",
"grants": "Freigaben",
"categories": "Kategorien",
"sharedLinks": "Eigene Module für alle",
"directory": "Verzeichnis (LDAP)",
"mailServer": "E-Mail-Versand (SMTP)"
},
"descriptions": {
"account": "Profilbild, Akzentfarbe und Passwort Ihres Tessera-Kontos.",
"widgets": "Inhalte und Darstellung der Widgets auf Ihrem Dashboard.",
"calendar": "Kalender, deren Termine im Kalender-Widget erscheinen.",
"desktop": "Tessera als eigenes Programm mit Symbol im Infobereich der Taskleiste.",
"smtp": "Über diesen Server verschickt Tessera alle E-Mails, zum Beispiel Willkommensmails, Erinnerungen und Benachrichtigungen.",
"users": "Konten, Rollen und Zugriff aller Personen, die Tessera nutzen.",
"groups": "Gruppen bündeln Benutzer, damit Sie Module an viele auf einmal freigeben können.",
"tenants": "Organisationen, die diese Tessera-Installation getrennt voneinander nutzen.",
"modules": "Schalten Sie Module für alle ein oder aus. Wer ein Modul nutzen darf, legen Sie unter Freigaben fest.",
"grants": "Legen Sie fest, welche Gruppen ein Modul benutzen oder verwalten dürfen.",
"categories": "Legen Sie fest, wie die Module in der Seitenleiste und im Marktplatz gruppiert und sortiert werden. Die Einstellung gilt für alle Benutzer.",
"ldap": "Tessera übernimmt Benutzer und Gruppen aus Ihrem Active Directory. Tessera liest dort nur, es ändert nichts."
},
"smtp": {
"server": "Server",
"serverHelp": "Adresse und Zugang des Mailservers, über den Tessera E-Mails verschickt.",
"sender": "Absender und Empfänger",
"senderHelp": "Unter welcher Adresse Tessera schreibt und wohin Fehlermeldungen gehen.",
"check": "Prüfen und speichern",
"checkHelp": "Testen Sie die Angaben, bevor Sie sie speichern. Der Test verwendet die Werte in diesem Formular."
},
"ldap": {
"connectionHelp": "Wo Tessera Ihr Active Directory erreicht und mit welchem Konto es dort liest.",
"fieldMappingHelp": "Welche Angabe aus dem Verzeichnis in welches Feld des Tessera-Kontos übernommen wird.",
"schedule": "Zeitplan",
"scheduleHelp": "Wie oft Tessera das Verzeichnis selbstständig abgleicht und ob die Anbindung eingeschaltet ist. 0 Minuten bedeutet: nur von Hand.",
"syncHelp": "Gleicht Benutzer und Gruppen sofort mit dem Verzeichnis ab, ohne auf den Zeitplan zu warten."
},
"welcomeMail": {
"textsTitle": "Texte der Mail"
},
"calendar": {
"sourcesTitle": "Kalenderquellen"
}
}
}
+68 -6
View File
@@ -210,7 +210,7 @@
"providerDeleteCta": "Delete provider",
"cancel": "Cancel",
"smtp": {
"title": "SMTP Configuration",
"title": "Email delivery",
"host": "Host",
"port": "Port",
"encryption": "Encryption",
@@ -531,7 +531,7 @@
},
"admin": {
"users": {
"title": "User Management",
"title": "Users",
"create": "Create User",
"edit": "Edit User",
"delete": "Delete User",
@@ -598,7 +598,7 @@
"cannotDeleteActive": "Cannot delete tenants with active users"
},
"ldap": {
"title": "LDAP configuration",
"title": "Directory (LDAP)",
"connectionTitle": "Connection settings",
"serverUrl": "Server URL",
"baseDn": "Base DN",
@@ -745,7 +745,7 @@
}
},
"customModules": {
"title": "Custom modules",
"title": "Custom modules for everyone",
"description": "Add other websites as entries in the sidebar. All users see these shared entries under the chosen category, and a click shows the page right inside Tessera. Personal entries only you can see are added under “Settings → Custom modules”.",
"create": "Add shared entry",
"noModules": "No shared entries yet",
@@ -853,7 +853,7 @@
}
},
"adminModules": {
"title": "Module Management",
"title": "Modules",
"description": "Activate or deactivate modules",
"activate": "Activate",
"deactivate": "Deactivate",
@@ -868,7 +868,7 @@
"grantsLink": "Grants Matrix",
"categoriesLink": "Categories",
"grants": {
"title": "Grants Matrix",
"title": "Permissions",
"searchPlaceholder": "Search modules or groups...",
"emptyModules": "No modules are activated yet. Activate a module under Modules first.",
"emptyModulesLink": "Go to Modules",
@@ -1985,5 +1985,67 @@
"countsLabel": "Number of clouds per traffic-light color",
"open": "Open Nextcloud status"
}
},
"controlCenter": {
"navLabel": "Settings and administration",
"settingsTitle": "Settings",
"adminTitle": "Administration",
"groups": {
"people": "Users and access",
"modules": "Modules",
"connections": "Connections"
},
"items": {
"account": "Account",
"widgets": "Widgets",
"calendar": "Calendar",
"myLinks": "Custom modules",
"desktop": "Desktop app",
"users": "Users",
"groups": "Groups",
"welcomeMail": "Welcome mail",
"tenants": "Tenants",
"modules": "Modules",
"grants": "Permissions",
"categories": "Categories",
"sharedLinks": "Custom modules for everyone",
"directory": "Directory (LDAP)",
"mailServer": "Email delivery (SMTP)"
},
"descriptions": {
"account": "Profile picture, accent colour and password of your Tessera account.",
"widgets": "Content and appearance of the widgets on your dashboard.",
"calendar": "Calendars whose events appear in the calendar widget.",
"desktop": "Tessera as its own program with an icon in the system tray.",
"smtp": "Tessera sends all emails through this server, for example welcome mails, reminders and notifications.",
"users": "Accounts, roles and access for everyone who uses Tessera.",
"groups": "Groups bundle users so you can grant modules to many people at once.",
"tenants": "Organisations that use this Tessera installation separately from one another.",
"modules": "Turn modules on or off for everyone. Who may use a module is set under Permissions.",
"grants": "Choose which groups may use or manage each module.",
"categories": "Decide how modules are grouped and sorted in the sidebar and in the marketplace. The setting applies to all users.",
"ldap": "Tessera takes over users and groups from your Active Directory. Tessera only reads there and never changes anything."
},
"smtp": {
"server": "Server",
"serverHelp": "Address and login of the outgoing mail server Tessera sends through.",
"sender": "Sender and recipients",
"senderHelp": "Which address Tessera writes from and where bug reports go.",
"check": "Check and save",
"checkHelp": "Test the settings before saving them. The test uses the values in this form."
},
"ldap": {
"connectionHelp": "Where Tessera reaches your Active Directory and which account it uses to read it.",
"fieldMappingHelp": "Which directory entry is copied into which field of the Tessera account.",
"schedule": "Schedule",
"scheduleHelp": "How often Tessera syncs with the directory on its own, and whether the connection is switched on. 0 minutes means manual only.",
"syncHelp": "Syncs users and groups with the directory right away, without waiting for the schedule."
},
"welcomeMail": {
"textsTitle": "Mail texts"
},
"calendar": {
"sourcesTitle": "Calendar sources"
}
}
}