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 { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl'; 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 { useAuthStore } from '@/lib/stores/auth-store';
import { GroupFormModal } from './components/GroupFormModal'; import { GroupFormModal } from './components/GroupFormModal';
import { GroupMembersModal } from './components/GroupMembersModal'; import { GroupMembersModal } from './components/GroupMembersModal';
@@ -30,6 +32,7 @@ export interface Group {
export default function AdminGroupsPage() { export default function AdminGroupsPage() {
const t = useTranslations('admin.groups'); const t = useTranslations('admin.groups');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const tCc = useTranslations('controlCenter');
const currentUser = useAuthStore((s) => s.user); const currentUser = useAuthStore((s) => s.user);
const [groups, setGroups] = useState<Group[]>([]); const [groups, setGroups] = useState<Group[]>([]);
@@ -122,20 +125,19 @@ export default function AdminGroupsPage() {
} }
return ( return (
<div className="space-y-6"> <div>
<div className="flex items-center justify-between"> <PageHeader
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1> title={t('title')}
<button description={tCc('descriptions.groups')}
type="button" actions={
onClick={openCreate} <button type="button" onClick={openCreate} className="btn btn-primary">
className="btn btn-primary"
>
{t('create')} {t('create')}
</button> </button>
</div> }
/>
{error && ( {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} {error}
</div> </div>
)} )}
@@ -143,35 +145,33 @@ export default function AdminGroupsPage() {
{loading ? ( {loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p> <p className="text-muted-foreground">{tCommon('loading')}</p>
) : groups.length === 0 ? ( ) : 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> <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> <p className="text-sm text-muted-foreground mb-6">{t('noGroupsBody')}</p>
<button <button type="button" onClick={openCreate} className="btn btn-secondary">
type="button"
onClick={openCreate}
className="btn btn-primary"
>
{t('create')} {t('create')}
</button> </button>
</div> </div>
</SettingsSection>
) : ( ) : (
<div className="overflow-x-auto rounded-md border border-border"> <SettingsSection flush>
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-muted/50"> <thead className="border-b border-border">
<tr> <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')} {t('name')}
</th> </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')} {t('ldapBinding')}
</th> </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')} {t('defaultGroup')}
</th> </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')} {t('memberCount')}
</th> </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')} {t('actions')}
</th> </th>
</tr> </tr>
@@ -179,10 +179,10 @@ export default function AdminGroupsPage() {
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{groups.map((group) => ( {groups.map((group) => (
<tr key={group.id} className="hover:bg-muted/30 transition-colors"> <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> <span title={group.name}>{group.internalName ?? group.name}</span>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-2.5">
<span <span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${ className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
group.ldapDn group.ldapDn
@@ -193,7 +193,7 @@ export default function AdminGroupsPage() {
{group.ldapDn ? t('boundBadge') : t('manualBadge')} {group.ldapDn ? t('boundBadge') : t('manualBadge')}
</span> </span>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-2.5">
<button <button
type="button" type="button"
onClick={() => handleToggleDefault(group)} onClick={() => handleToggleDefault(group)}
@@ -222,27 +222,27 @@ export default function AdminGroupsPage() {
</svg> </svg>
</button> </button>
</td> </td>
<td className="px-4 py-3 text-muted-foreground">{group.memberCount}</td> <td className="px-4 py-2.5 text-muted-foreground">{group.memberCount}</td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-2.5 text-right">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-1">
<button <button
type="button" type="button"
onClick={() => openEdit(group)} 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')} {tCommon('edit')}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setMembersGroup(group)} 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')} {t('membersButton')}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setDeleteTarget(group)} 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')} {tCommon('delete')}
</button> </button>
@@ -252,7 +252,7 @@ export default function AdminGroupsPage() {
))} ))}
</tbody> </tbody>
</table> </table>
</div> </SettingsSection>
)} )}
{showFormModal && ( {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'; * Einstellungen und Verwaltung teilen sich einen Rahmen mit einer
import { AdminSidebar } from '@/components/admin/admin-sidebar'; * gemeinsamen Navigation (siehe ControlCenterLayout).
*/
export default function AdminLayout({ export default function Layout({ children }: { children: React.ReactNode }) {
children, return <ControlCenterLayout>{children}</ControlCenterLayout>;
}: {
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>
);
} }
+235 -226
View File
@@ -3,6 +3,8 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/lib/stores/auth-store'; 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'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -89,6 +91,7 @@ interface SyncResult {
export default function AdminLdapPage() { export default function AdminLdapPage() {
const t = useTranslations('admin.ldap'); const t = useTranslations('admin.ldap');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const tCc = useTranslations('controlCenter');
const currentUser = useAuthStore((s) => s.user); const currentUser = useAuthStore((s) => s.user);
const [config, setConfig] = useState<LdapConfig | null>(null); 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 ( return (
<div className="space-y-8"> <div>
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1> <PageHeader title={t('title')} description={tCc('descriptions.ldap')} />
{/* Section 1: Connection Settings */} {/* Section 1: Connection Settings */}
<section className="rounded-lg border border-border p-6"> <form onSubmit={handleSave}>
<h2 className="text-lg font-semibold text-foreground mb-4"> <SettingsSection
{t('connectionTitle')} id="ldap-connection"
</h2> title={t('connectionTitle')}
<form onSubmit={handleSave} className="space-y-4"> description={tCc('ldap.connectionHelp')}
<div className="grid gap-4 md:grid-cols-2"> footer={
<div className="space-y-2"> <>
<label htmlFor="ldap-server-url" className="text-sm font-medium text-foreground"> <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')} {t('serverUrl')}
</label> </label>
<input <input
@@ -566,12 +590,12 @@ export default function AdminLdapPage() {
value={formData.serverUrl} value={formData.serverUrl}
onChange={(e) => setFormData({ ...formData, serverUrl: e.target.value })} onChange={(e) => setFormData({ ...formData, serverUrl: e.target.value })}
placeholder="ldap://ldap.example.com" 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 required
/> />
</div> </div>
<div className="space-y-2"> <div>
<label htmlFor="ldap-base-dn" className="text-sm font-medium text-foreground"> <label htmlFor="ldap-base-dn" className={labelClass}>
{t('baseDn')} {t('baseDn')}
</label> </label>
<textarea <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" className="flex min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
required 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>
<div className="space-y-2"> <div>
<label htmlFor="ldap-bind-dn" className="text-sm font-medium text-foreground"> <label htmlFor="ldap-bind-dn" className={labelClass}>
{t('bindDn')} {t('bindDn')}
</label> </label>
<input <input
@@ -595,11 +619,11 @@ export default function AdminLdapPage() {
value={formData.bindDn} value={formData.bindDn}
onChange={(e) => setFormData({ ...formData, bindDn: e.target.value })} onChange={(e) => setFormData({ ...formData, bindDn: e.target.value })}
placeholder="cn=admin,dc=example,dc=com (leer = anonymous bind)" 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>
<div className="space-y-2"> <div>
<label htmlFor="ldap-bind-password" className="text-sm font-medium text-foreground"> <label htmlFor="ldap-bind-password" className={labelClass}>
{t('bindPassword')} {t('bindPassword')}
</label> </label>
<input <input
@@ -608,12 +632,14 @@ export default function AdminLdapPage() {
value={formData.bindPassword} value={formData.bindPassword}
onChange={(e) => setFormData({ ...formData, bindPassword: e.target.value })} onChange={(e) => setFormData({ ...formData, bindPassword: e.target.value })}
placeholder={config?.bindPassword ? '********' : ''} 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> </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')} {t('searchFilter')}
</label> </label>
<input <input
@@ -621,11 +647,11 @@ export default function AdminLdapPage() {
type="text" type="text"
value={formData.searchFilter} value={formData.searchFilter}
onChange={(e) => setFormData({ ...formData, searchFilter: e.target.value })} 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>
<div className="space-y-1"> <div>
<label className="flex items-center gap-2 text-sm text-foreground"> <label className="flex items-center gap-2 text-sm text-foreground">
<input <input
type="checkbox" type="checkbox"
@@ -639,30 +665,12 @@ export default function AdminLdapPage() {
/> />
{t('tlsSkip.label')} {t('tlsSkip.label')}
</label> </label>
<p className="text-xs text-muted-foreground">{t('tlsSkip.hint')}</p> <p className="mt-1 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>
</div> </div>
{testResult && ( {testResult && (
<div <div
className={`mt-2 rounded-md px-4 py-2 text-sm ${ className={`rounded-md px-4 py-2 text-sm ${
testResult.success testResult.success
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' ? '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' : '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}`} {testResult.success ? t('testSuccess') : `${t('testFailed')}: ${testResult.error}`}
</div> </div>
)} )}
</div>
</SettingsSection>
</form> </form>
</section>
{/* Section 2: Field Mapping (D-16, D-17) */} {/* Section 2: Field Mapping (D-16, D-17) */}
{config && ( {config && (
<section className="rounded-lg border border-border p-6"> <SettingsSection
<div className="flex items-center justify-between mb-4"> id="ldap-field-mapping"
<h2 className="text-lg font-semibold text-foreground"> flush
{t('fieldMapping.title')} title={t('fieldMapping.title')}
</h2> description={tCc('ldap.fieldMappingHelp')}
actions={
<button <button
type="button" type="button"
onClick={() => setShowMappingForm(true)} 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')} {t('fieldMapping.add')}
</button> </button>
</div> }
>
<div className="overflow-x-auto rounded-md border border-border"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-muted/50"> <thead className="bg-muted/50">
<tr> <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')} {t('fieldMapping.ldapField')}
</th> </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')} {t('fieldMapping.tesseraField')}
</th> </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')} {t('fieldMapping.default')}
</th> </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')} {tCommon('actions')}
</th> </th>
</tr> </tr>
@@ -711,13 +721,13 @@ export default function AdminLdapPage() {
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{config.fieldMappings.map((mapping) => ( {config.fieldMappings.map((mapping) => (
<tr key={mapping.id} className="hover:bg-muted/30 transition-colors"> <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} {mapping.ldapField}
</td> </td>
<td className="px-4 py-3 font-mono text-foreground"> <td className="px-5 py-2.5 font-mono text-foreground">
{mapping.tesseraField} {mapping.tesseraField}
</td> </td>
<td className="px-4 py-3"> <td className="px-5 py-2.5">
{mapping.isDefault && ( {mapping.isDefault && (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -737,7 +747,7 @@ export default function AdminLdapPage() {
</svg> </svg>
)} )}
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-5 py-2.5 text-right">
{!mapping.isDefault && ( {!mapping.isDefault && (
<button <button
type="button" type="button"
@@ -756,9 +766,12 @@ export default function AdminLdapPage() {
{/* Add mapping form */} {/* Add mapping form */}
{showMappingForm && ( {showMappingForm && (
<form onSubmit={handleAddMapping} className="mt-4 flex items-end gap-3"> <form
<div className="space-y-1"> onSubmit={handleAddMapping}
<label htmlFor="ldap-mapping-ldap-field" className="text-xs font-medium text-muted-foreground"> 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')} {t('fieldMapping.ldapField')}
</label> </label>
<input <input
@@ -770,8 +783,8 @@ export default function AdminLdapPage() {
required required
/> />
</div> </div>
<div className="space-y-1"> <div>
<label htmlFor="ldap-mapping-tessera-field" className="text-xs font-medium text-muted-foreground"> <label htmlFor="ldap-mapping-tessera-field" className={smallLabelClass}>
{t('fieldMapping.tesseraField')} {t('fieldMapping.tesseraField')}
</label> </label>
<input <input
@@ -783,35 +796,40 @@ export default function AdminLdapPage() {
required required
/> />
</div> </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 <button
type="button" type="button"
onClick={() => setShowMappingForm(false)} 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')} {tCommon('cancel')}
</button> </button>
<button type="submit" className="btn btn-primary">
{tCommon('save')}
</button>
</form> </form>
)} )}
</section> </SettingsSection>
)} )}
{/* Section 2.5: Group/OU import filter (selective sync) */} {/* Section 2.5: Group/OU import filter (selective sync) */}
{config && ( {config && (
<section className="rounded-lg border border-border p-6"> <SettingsSection
<h2 className="text-lg font-semibold text-foreground mb-2"> id="ldap-group-filter"
{t('groupFilter.title')} title={t('groupFilter.title')}
</h2> description={t('groupFilter.description')}
<p className="text-sm text-muted-foreground mb-4"> footer={
{t('groupFilter.description')} <button
</p> type="button"
onClick={handleSaveGroupFilter}
<div className="flex items-center gap-3 mb-4"> disabled={savingFilter}
className="btn btn-primary"
>
{savingFilter ? tCommon('loading') : t('groupFilter.save')}
</button>
}
>
<div className="space-y-4">
<div>
<button <button
type="button" type="button"
onClick={handleDiscoverGroups} onClick={handleDiscoverGroups}
@@ -823,13 +841,13 @@ export default function AdminLdapPage() {
</div> </div>
{discovered && discovered.length > 0 && ( {discovered && discovered.length > 0 && (
<div className="mb-4 space-y-2"> <div className="space-y-2">
<input <input
type="text" type="text"
value={discoverSearch} value={discoverSearch}
onChange={(e) => setDiscoverSearch(e.target.value)} onChange={(e) => setDiscoverSearch(e.target.value)}
placeholder={t('groupFilter.searchPlaceholder')} 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"> <div className="max-h-64 overflow-y-auto rounded-md border border-border divide-y divide-border">
{filteredDiscovered && filteredDiscovered.length > 0 ? ( {filteredDiscovered && filteredDiscovered.length > 0 ? (
@@ -862,12 +880,12 @@ export default function AdminLdapPage() {
)} )}
{discovered && discovered.length === 0 && ( {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 items-end gap-3">
<div className="flex-1 space-y-1"> <div className="flex-1">
<label htmlFor="ldap-group-manual-dn" className="text-xs font-medium text-muted-foreground"> <label htmlFor="ldap-group-manual-dn" className={smallLabelClass}>
{t('groupFilter.manualDn')} {t('groupFilter.manualDn')}
</label> </label>
<input <input
@@ -876,19 +894,15 @@ export default function AdminLdapPage() {
value={manualDn} value={manualDn}
onChange={(e) => setManualDn(e.target.value)} onChange={(e) => setManualDn(e.target.value)}
placeholder="CN=Beispiel,OU=Gruppen,DC=ctl,DC=local" 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> </div>
<button <button type="button" onClick={handleAddManualDn} className="btn btn-secondary">
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"
>
{t('groupFilter.addDn')} {t('groupFilter.addDn')}
</button> </button>
</div> </div>
<div className="mb-4"> <div>
<p className="text-xs font-medium text-muted-foreground mb-2"> <p className="text-xs font-medium text-muted-foreground mb-2">
{t('groupFilter.selected')} {t('groupFilter.selected')}
</p> </p>
@@ -914,29 +928,33 @@ export default function AdminLdapPage() {
</ul> </ul>
)} )}
</div> </div>
</div>
<button </SettingsSection>
type="button"
onClick={handleSaveGroupFilter}
disabled={savingFilter}
className="btn btn-primary"
>
{savingFilter ? tCommon('loading') : t('groupFilter.save')}
</button>
</section>
)} )}
{/* Section 2.52: AD group import (SC-1/SC-2, D-01/D-02) */} {/* Section 2.52: AD group import (SC-1/SC-2, D-01/D-02) */}
{config && ( {config && (
<section className="rounded-lg border border-border p-6"> <SettingsSection
<h2 className="text-lg font-semibold text-foreground mb-2"> id="ldap-group-import"
{t('groupImport.title')} title={t('groupImport.title')}
</h2> description={t('groupImport.description')}
<p className="text-sm text-muted-foreground mb-4"> footer={
{t('groupImport.description')} groupImportCandidates && groupImportCandidates.length > 0 ? (
</p> <button
type="button"
<div className="flex items-center gap-3 mb-4"> 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 <button
type="button" type="button"
onClick={handleDiscoverGroupsToImport} onClick={handleDiscoverGroupsToImport}
@@ -950,19 +968,19 @@ export default function AdminLdapPage() {
</div> </div>
{groupImportError && ( {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} {groupImportError}
</div> </div>
)} )}
{groupImportCandidates && groupImportCandidates.length > 0 && ( {groupImportCandidates && groupImportCandidates.length > 0 && (
<div className="mb-4 space-y-2"> <div className="space-y-2">
<input <input
type="text" type="text"
value={groupImportSearch} value={groupImportSearch}
onChange={(e) => setGroupImportSearch(e.target.value)} onChange={(e) => setGroupImportSearch(e.target.value)}
placeholder={t('groupImport.searchPlaceholder')} 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"> <div className="max-h-64 overflow-y-auto rounded-md border border-border divide-y divide-border">
{filteredGroupImportCandidates && {filteredGroupImportCandidates &&
@@ -1005,26 +1023,13 @@ export default function AdminLdapPage() {
)} )}
{groupImportCandidates && groupImportCandidates.length === 0 && ( {groupImportCandidates && groupImportCandidates.length === 0 && (
<p className="mb-4 text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t('groupImport.noneFound')} {t('groupImport.noneFound')}
</p> </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 && ( {groupImportResult && (
<div className="mt-3"> <div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t('groupImport.resultSummary', { {t('groupImport.resultSummary', {
imported: groupImportResult.imported, imported: groupImportResult.imported,
@@ -1057,21 +1062,34 @@ export default function AdminLdapPage() {
</p> </p>
</div> </div>
)} )}
</section> </div>
</SettingsSection>
)} )}
{/* Section 2.55: Individual user search & import */} {/* Section 2.55: Individual user search & import */}
{config && ( {config && (
<section className="rounded-lg border border-border p-6"> <SettingsSection
<h2 className="text-lg font-semibold text-foreground mb-2"> id="ldap-user-search"
{t('userSearch.title')} title={t('userSearch.title')}
</h2> description={t('userSearch.description')}
<p className="text-sm text-muted-foreground mb-4"> footer={
{t('userSearch.description')} userSearchResults && userSearchResults.length > 0 ? (
</p> <button
type="button"
<div className="flex items-end gap-3 mb-4"> onClick={() => handleImportUsers(selectedUserDns)}
<div className="flex-1 space-y-1"> 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 <input
type="text" type="text"
value={userSearchQuery} value={userSearchQuery}
@@ -1080,21 +1098,21 @@ export default function AdminLdapPage() {
if (e.key === 'Enter') handleSearchUsers(); if (e.key === 'Enter') handleSearchUsers();
}} }}
placeholder={t('userSearch.placeholder')} 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> </div>
<button <button
type="button" type="button"
onClick={handleSearchUsers} onClick={handleSearchUsers}
disabled={userSearching || !userSearchQuery.trim()} 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')} {userSearching ? tCommon('loading') : t('userSearch.search')}
</button> </button>
</div> </div>
{userSearchResults && userSearchResults.length > 0 && ( {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) => ( {userSearchResults.map((u) => (
<label <label
key={u.dn} key={u.dn}
@@ -1132,26 +1150,13 @@ export default function AdminLdapPage() {
)} )}
{userSearchResults && userSearchResults.length === 0 && ( {userSearchResults && userSearchResults.length === 0 && (
<p className="mb-4 text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t('userSearch.noResults')} {t('userSearch.noResults')}
</p> </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 && ( {userImportResult && (
<p className="mt-3 text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{userImportResult.created} {t('userSearch.created')},{' '} {userImportResult.created} {t('userSearch.created')},{' '}
{userImportResult.skipped} {t('userSearch.skipped')} {userImportResult.skipped} {t('userSearch.skipped')}
{userImportResult.errors.length > 0 && ( {userImportResult.errors.length > 0 && (
@@ -1161,22 +1166,31 @@ export default function AdminLdapPage() {
)} )}
</p> </p>
)} )}
</section> </div>
</SettingsSection>
)} )}
{/* Section 2.6: Per-user exclude/denylist */} {/* Section 2.6: Per-user exclude/denylist */}
{config && ( {config && (
<section className="rounded-lg border border-border p-6"> <SettingsSection
<h2 className="text-lg font-semibold text-foreground mb-2"> id="ldap-user-exclude"
{t('userExclude.title')} title={t('userExclude.title')}
</h2> description={t('userExclude.description')}
<p className="text-sm text-muted-foreground mb-4"> footer={
{t('userExclude.description')} <button
</p> type="button"
onClick={handleSaveExcludeList}
<div className="flex items-end gap-3 mb-4"> disabled={savingExclude}
<div className="flex-1 space-y-1"> className="btn btn-primary"
<label htmlFor="ldap-exclude-username" className="text-xs font-medium text-muted-foreground"> >
{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')} {t('userExclude.username')}
</label> </label>
<input <input
@@ -1191,19 +1205,15 @@ export default function AdminLdapPage() {
} }
}} }}
placeholder="administrator, krbtgt, guest, ldap$ ..." 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> </div>
<button <button type="button" onClick={handleAddExcludeUser} className="btn btn-secondary">
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"
>
{t('userExclude.add')} {t('userExclude.add')}
</button> </button>
</div> </div>
<div className="mb-4"> <div>
<p className="text-xs font-medium text-muted-foreground mb-2"> <p className="text-xs font-medium text-muted-foreground mb-2">
{t('userExclude.excluded')} {t('userExclude.excluded')}
</p> </p>
@@ -1229,30 +1239,31 @@ export default function AdminLdapPage() {
</ul> </ul>
)} )}
</div> </div>
</div>
<button </SettingsSection>
type="button"
onClick={handleSaveExcludeList}
disabled={savingExclude}
className="btn btn-primary"
>
{savingExclude ? tCommon('loading') : t('userExclude.save')}
</button>
</section>
)} )}
{/* Section 3: Sync Settings (D-14) */} {/* Section 3a: Sync schedule (D-14) */}
{config && ( {config && (
<section className="rounded-lg border border-border p-6"> <SettingsSection
<h2 className="text-lg font-semibold text-foreground mb-4"> id="ldap-schedule"
{t('sync.title')} title={tCc('ldap.schedule')}
</h2> description={tCc('ldap.scheduleHelp')}
footer={
<button
type="button"
onClick={handleSave}
disabled={saving}
className="btn btn-primary"
>
{t('save')}
</button>
}
>
<div className="space-y-4"> <div className="space-y-4">
{/* Sync interval */} {/* Sync interval */}
<div className="flex items-center gap-4"> <div>
<div className="space-y-1"> <label htmlFor="ldap-sync-interval" className={labelClass}>
<label htmlFor="ldap-sync-interval" className="text-sm font-medium text-foreground">
{t('sync.interval')} {t('sync.interval')}
</label> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -1264,7 +1275,7 @@ export default function AdminLdapPage() {
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, syncIntervalMin: parseInt(e.target.value, 10) || 0 }) 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> <span className="text-sm text-muted-foreground">min</span>
{formData.syncIntervalMin === 0 && ( {formData.syncIntervalMin === 0 && (
@@ -1274,7 +1285,6 @@ export default function AdminLdapPage() {
)} )}
</div> </div>
</div> </div>
</div>
{/* Enable/Disable toggle */} {/* Enable/Disable toggle */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -1291,30 +1301,17 @@ export default function AdminLdapPage() {
{formData.isActive ? t('enable') : t('disable')} {formData.isActive ? t('enable') : t('disable')}
</span> </span>
</div> </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> </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 <button
type="button" type="button"
onClick={handleSync} onClick={handleSync}
@@ -1323,6 +1320,18 @@ export default function AdminLdapPage() {
> >
{syncing ? t('sync.syncing') : t('sync.trigger')} {syncing ? t('sync.syncing') : t('sync.trigger')}
</button> </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 failure (Owner-Entscheidung 2026-08-06): a failed
sync request must never render as a report full of zeros. */} sync request must never render as a report full of zeros. */}
@@ -1411,7 +1420,7 @@ export default function AdminLdapPage() {
</div> </div>
)} )}
</div> </div>
</section> </SettingsSection>
)} )}
</div> </div>
); );
@@ -151,10 +151,9 @@ describe('Kategorien-Verwaltungsseite', () => {
expect( expect(
within(card('procurement')).getByText('Außerdem 2 persönliche Einträge von Benutzern'), within(card('procurement')).getByText('Außerdem 2 persönliche Einträge von Benutzern'),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Zurück zu Module' })).toHaveAttribute( // Die Navigation links reicht — kein eigener Ruecksprung-Link mehr.
'href', expect(screen.queryByRole('link', { name: 'Zurück zu Module' })).toBeNull();
'/admin/modules', 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 () => { 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'; 'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { PageHeader } from '@/components/control-center/page-header';
import { SettingsSection } from '@/components/control-center/settings-section';
import { import {
assignModuleCategory, assignModuleCategory,
createModuleCategory, createModuleCategory,
@@ -40,8 +41,7 @@ function Arrow({ direction }: { direction: 'up' | 'down' }) {
); );
} }
const arrowButtonClass = const arrowButtonClass = 'btn btn-subtle btn-icon disabled:opacity-40';
'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';
/** Vertauscht zwei Nachbarn einer Liste (Pfeil nach oben/unten). */ /** Vertauscht zwei Nachbarn einer Liste (Pfeil nach oben/unten). */
function swapped<T>(list: T[], index: number, direction: -1 | 1): T[] { function swapped<T>(list: T[], index: number, direction: -1 | 1): T[] {
@@ -100,7 +100,7 @@ function DeleteCategoryDialog({
aria-labelledby="delete-category-title" aria-labelledby="delete-category-title"
className="w-full max-w-md rounded-lg bg-card p-6 shadow-lg dark:border dark:border-border" 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')} {t('deleteDialog.title')}
</h2> </h2>
<p className="mb-4 text-sm text-foreground"> <p className="mb-4 text-sm text-foreground">
@@ -164,6 +164,7 @@ function DeleteCategoryDialog({
export default function AdminModuleCategoriesPage() { export default function AdminModuleCategoriesPage() {
const t = useTranslations('adminModules.categories'); const t = useTranslations('adminModules.categories');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const tCc = useTranslations('controlCenter');
const categoryLabel = useCategoryLabel(); const categoryLabel = useCategoryLabel();
const currentUser = useAuthStore((s) => s.user); const currentUser = useAuthStore((s) => s.user);
const bumpSidebarRefresh = useMarketplaceStore((s) => s.bumpSidebarRefresh); const bumpSidebarRefresh = useMarketplaceStore((s) => s.bumpSidebarRefresh);
@@ -277,26 +278,22 @@ export default function AdminModuleCategoriesPage() {
} }
return ( return (
<div className="space-y-6">
<div> <div>
<Link href="/admin/modules" className="text-sm font-medium link"> <PageHeader title={t('title')} description={tCc('descriptions.categories')} />
{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>
{(loadError || actionError) && ( {(loadError || actionError) && (
<div <div
role="alert" 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')} {actionError ?? t('loadError')}
</div> </div>
)} )}
<div className="space-y-4">
<SettingsSection>
<form onSubmit={handleCreate} className="flex flex-wrap items-end gap-3"> <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"> <label htmlFor="new-category-name" className="text-sm font-medium text-foreground">
{t('createLabel')} {t('createLabel')}
</label> </label>
@@ -307,30 +304,67 @@ export default function AdminModuleCategoriesPage() {
value={newName} value={newName}
placeholder={t('createPlaceholder')} placeholder={t('createPlaceholder')}
onChange={(e) => setNewName(e.target.value)} 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> </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')} {t('createButton')}
</button> </button>
</form> </form>
</SettingsSection>
{loading ? ( {loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p> <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 label = labelOf(category);
const renaming = renamingKey === category.key; const renaming = renamingKey === category.key;
return ( return (
<li // Huelle nur fuer den Testanker; Abstand kommt von space-y-4.
key={category.key} <div key={category.key} data-testid={`category-${category.key}`}>
data-testid={`category-${category.key}`} <SettingsSection
className="rounded-lg border border-border bg-card p-4" 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}> <input
<div className="flex flex-wrap items-center gap-2"> type="text"
<div className="flex gap-1"> 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 <button
type="button" type="button"
aria-label={t('moveUp')} aria-label={t('moveUp')}
@@ -351,47 +385,11 @@ export default function AdminModuleCategoriesPage() {
> >
<Arrow direction="down" /> <Arrow direction="down" />
</button> </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 <button
type="button" type="button"
disabled={busy} disabled={busy}
onClick={() => startRename(category)} onClick={() => startRename(category)}
className="btn btn-secondary" className="btn btn-subtle"
> >
{t('rename')} {t('rename')}
</button> </button>
@@ -400,25 +398,22 @@ export default function AdminModuleCategoriesPage() {
disabled={busy || category.isSystem} disabled={busy || category.isSystem}
title={category.isSystem ? t('deleteBlocked') : undefined} title={category.isSystem ? t('deleteBlocked') : undefined}
onClick={() => setDeleteTarget(category)} onClick={() => setDeleteTarget(category)}
className="btn btn-secondary" className="btn btn-subtle"
> >
{t('delete')} {t('delete')}
</button> </button>
</> </>
)} )
</div> }
{category.isSystem && ( >
<p className="mt-1 text-xs text-muted-foreground">{t('deleteBlocked')}</p>
)}
{category.items.length === 0 ? ( {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) => ( {category.items.map((item, itemIndex) => (
<li <li
key={`${item.type}:${item.id}`} 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"> <div className="flex gap-1">
<button <button
@@ -456,7 +451,7 @@ export default function AdminModuleCategoriesPage() {
value={category.key} value={category.key}
disabled={busy} disabled={busy}
onChange={(e) => assign(item, e.target.value)} 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) => ( {overview.map((c) => (
<option key={c.key} value={c.key}> <option key={c.key} value={c.key}>
@@ -470,16 +465,16 @@ export default function AdminModuleCategoriesPage() {
)} )}
{category.personalCount > 0 && ( {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 })} {t('personalNote', { count: category.personalCount })}
</p> </p>
)} )}
</section> </SettingsSection>
</li> </div>
); );
})} })
</ul>
)} )}
</div>
{deleteTarget && ( {deleteTarget && (
<DeleteCategoryDialog <DeleteCategoryDialog
@@ -3,6 +3,8 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslations } from 'next-intl'; 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 { useAuthStore } from '@/lib/stores/auth-store';
import { useCategoryLabel } from '@/lib/use-category-label'; import { useCategoryLabel } from '@/lib/use-category-label';
@@ -48,6 +50,7 @@ export default function AdminModuleGrantsPage() {
const t = useTranslations('adminModules.grants'); const t = useTranslations('adminModules.grants');
const tGrantLabel = useTranslations('admin.groups.grants'); const tGrantLabel = useTranslations('admin.groups.grants');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const tCc = useTranslations('controlCenter');
const categoryLabel = useCategoryLabel(); const categoryLabel = useCategoryLabel();
const currentUser = useAuthStore((s) => s.user); const currentUser = useAuthStore((s) => s.user);
@@ -231,13 +234,11 @@ export default function AdminModuleGrantsPage() {
} }
return ( return (
<div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1> <PageHeader title={t('title')} description={tCc('descriptions.grants')} />
</div>
{error && ( {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} {error}
</div> </div>
)} )}
@@ -245,7 +246,8 @@ export default function AdminModuleGrantsPage() {
{loading ? ( {loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p> <p className="text-muted-foreground">{tCommon('loading')}</p>
) : modules.length === 0 ? ( ) : 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> <p className="text-sm text-muted-foreground">{t('emptyModules')}</p>
<Link <Link
href="/admin/modules" href="/admin/modules"
@@ -254,22 +256,34 @@ export default function AdminModuleGrantsPage() {
{t('emptyModulesLink')} {t('emptyModulesLink')}
</Link> </Link>
</div> </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 <input
type="text" type="text"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder={t('searchPlaceholder')} 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" className="flex h-9 w-full max-w-sm rounded-md border border-input bg-background px-3 py-1 text-sm"
/> />
</div>
{noMatch ? ( {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> <p className="text-sm text-muted-foreground">{t('noSearchResults', { search })}</p>
</div> </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"> <table className="w-full text-sm border-collapse">
<thead> <thead>
<tr> <tr>
@@ -360,10 +374,7 @@ export default function AdminModuleGrantsPage() {
</table> </table>
</div> </div>
)} )}
</SettingsSection>
<p className="text-xs text-muted-foreground">{t('levelExplanation')}</p>
<p className="text-xs text-muted-foreground">{t('adminNote')}</p>
</>
)} )}
</div> </div>
); );
@@ -1,10 +1,12 @@
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useTranslations } from 'next-intl'; 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 { useAuthStore } from '@/lib/stores/auth-store';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
import { useCategoryLabel } from '@/lib/use-category-label'; import { useCategoryLabel } from '@/lib/use-category-label';
import { ActivateModuleDialog } from './components/ActivateModuleDialog'; import { ActivateModuleDialog } from './components/ActivateModuleDialog';
@@ -30,6 +32,7 @@ interface TenantActivation {
export default function AdminModulesPage() { export default function AdminModulesPage() {
const t = useTranslations('adminModules'); const t = useTranslations('adminModules');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const tCc = useTranslations('controlCenter');
const categoryLabel = useCategoryLabel(); const categoryLabel = useCategoryLabel();
const currentUser = useAuthStore((s) => s.user); const currentUser = useAuthStore((s) => s.user);
// Shared signal that makes the sidebar refetch its active-module list // 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 [activateDialogModule, setActivateDialogModule] = useState<Module | null>(null);
const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN'; 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 () => { const fetchModules = useCallback(async () => {
try { 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 => { const getDescription = (desc: Record<string, string> | string): string => {
if (typeof desc === 'string') return desc; if (typeof desc === 'string') return desc;
return desc.de || desc.en || Object.values(desc)[0] || ''; return desc.de || desc.en || Object.values(desc)[0] || '';
@@ -133,30 +158,11 @@ export default function AdminModulesPage() {
} }
return ( return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1> <PageHeader title={t('title')} description={tCc('descriptions.modules')} />
<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>
{error && ( {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} {error}
</div> </div>
)} )}
@@ -166,20 +172,19 @@ export default function AdminModulesPage() {
) : modules.length === 0 ? ( ) : modules.length === 0 ? (
<p className="text-muted-foreground">{t('noModules')}</p> <p className="text-muted-foreground">{t('noModules')}</p>
) : ( ) : (
<div className="grid gap-4"> groupedModules.map(({ category, mods }) => (
{modules.map((mod) => { <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 isActive = activations.has(mod.id);
const isToggling = toggling === mod.id; const isToggling = toggling === mod.id;
return ( return (
<div <li key={mod.id} className="flex items-center justify-between gap-4 px-5 py-3">
key={mod.id} <div className="min-w-0 flex-1">
className="flex items-center justify-between rounded-lg border border-border bg-card p-4 transition-colors" <div className="flex flex-wrap items-center gap-2">
> <h3 className="text-sm font-medium text-foreground">{mod.name}</h3>
<div className="flex-1 min-w-0"> <span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<h3 className="font-medium text-foreground">{mod.name}</h3>
<span className="text-xs text-muted-foreground">
v{mod.version} v{mod.version}
</span> </span>
{mod.isSystem && ( {mod.isSystem && (
@@ -187,18 +192,15 @@ export default function AdminModulesPage() {
{t('system')} {t('system')}
</span> </span>
)} )}
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{categoryLabel(mod.category)}
</span>
</div> </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)} {getDescription(mod.description)}
</p> </p>
</div> </div>
<div className="ml-4 flex items-center gap-3"> <div className="flex shrink-0 items-center gap-3">
<span <span
className={`text-xs font-medium ${ className={`w-20 text-right text-xs font-medium ${
isActive isActive
? 'text-green-600 dark:text-green-400' ? 'text-green-600 dark:text-green-400'
: 'text-muted-foreground' : 'text-muted-foreground'
@@ -224,6 +226,7 @@ export default function AdminModulesPage() {
}`} }`}
role="switch" role="switch"
aria-checked={isActive} aria-checked={isActive}
aria-label={mod.name}
> >
<span <span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform duration-200 ${ 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> </button>
</div> </div>
</div> </li>
); );
})} })}
</div> </ul>
</SettingsSection>
))
)} )}
{activateDialogModule && ( {activateDialogModule && (
@@ -1,16 +1,16 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { SmtpSettingsForm } from '@/components/settings/smtp-settings-form'; import { SmtpSettingsForm } from '@/components/settings/smtp-settings-form';
export default function AdminSmtpPage() { export default function AdminSmtpPage() {
const t = useTranslations('settings'); const t = useTranslations('settings');
const tCc = useTranslations('controlCenter');
return ( return (
<div> <div>
<h1 className="mb-6 text-lg font-semibold text-foreground"> <PageHeader title={t('smtp.title')} description={tCc('descriptions.smtp')} />
{t('smtp.title')}
</h1>
<SmtpSettingsForm /> <SmtpSettingsForm />
</div> </div>
); );
@@ -2,6 +2,8 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl'; 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 { useAuthStore } from '@/lib/stores/auth-store';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -26,6 +28,7 @@ interface TenantFormData {
export default function AdminTenantsPage() { export default function AdminTenantsPage() {
const t = useTranslations('admin.tenants'); const t = useTranslations('admin.tenants');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const tCc = useTranslations('controlCenter');
const currentUser = useAuthStore((s) => s.user); const currentUser = useAuthStore((s) => s.user);
const [tenants, setTenants] = useState<Tenant[]>([]); const [tenants, setTenants] = useState<Tenant[]>([]);
@@ -143,18 +146,16 @@ export default function AdminTenantsPage() {
} }
return ( return (
<div className="space-y-6"> <div>
{/* Page header */} <PageHeader
<div className="flex items-center justify-between"> title={t('title')}
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1> description={tCc('descriptions.tenants')}
<button actions={
type="button" <button type="button" onClick={openCreate} className="btn btn-primary">
onClick={openCreate}
className="btn btn-primary"
>
{t('create')} {t('create')}
</button> </button>
</div> }
/>
{/* Tenants table */} {/* Tenants table */}
{loading ? ( {loading ? (
@@ -162,23 +163,23 @@ export default function AdminTenantsPage() {
) : tenants.length === 0 ? ( ) : tenants.length === 0 ? (
<p className="text-muted-foreground">{t('noTenants')}</p> <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"> <table className="w-full text-sm">
<thead className="bg-muted/50"> <thead className="border-b border-border">
<tr> <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')} {t('name')}
</th> </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')} {t('slug')}
</th> </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')} {t('status')}
</th> </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')} {t('userCount')}
</th> </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')} {t('actions')}
</th> </th>
</tr> </tr>
@@ -189,46 +190,46 @@ export default function AdminTenantsPage() {
key={tenant.id} key={tenant.id}
className="hover:bg-muted/30 transition-colors" 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} {tenant.name}
</td> </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} {tenant.slug}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-2.5">
<span <span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${ className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
tenant.isActive tenant.isActive
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' ? 'bg-status-ok/12 text-status-ok-fg'
: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500' : 'bg-muted text-muted-foreground'
}`} }`}
> >
{tenant.isActive ? tCommon('active') : tCommon('inactive')} {tenant.isActive ? tCommon('active') : tCommon('inactive')}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-muted-foreground"> <td className="px-4 py-2.5 text-muted-foreground">
{tenant.userCount} {tenant.userCount}
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-2.5 text-right">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-1">
<button <button
type="button" type="button"
onClick={() => openEdit(tenant)} 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')} {tCommon('edit')}
</button> </button>
<button <button
type="button" type="button"
onClick={() => handleToggleActive(tenant)} 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')} {tenant.isActive ? tCommon('inactive') : tCommon('active')}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setDeleteConfirm(tenant.id)} 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')} {tCommon('delete')}
</button> </button>
@@ -238,14 +239,14 @@ export default function AdminTenantsPage() {
))} ))}
</tbody> </tbody>
</table> </table>
</div> </SettingsSection>
)} )}
{/* Create/Edit modal */} {/* Create/Edit modal */}
{showForm && ( {showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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"> <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')} {editingTenant ? t('edit') : t('create')}
</h2> </h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
+55 -39
View File
@@ -2,6 +2,8 @@
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react'; 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 { useAuthStore } from '@/lib/stores/auth-store';
import { UserAccessModal } from './components/UserAccessModal'; import { UserAccessModal } from './components/UserAccessModal';
@@ -62,6 +64,7 @@ export default function AdminUsersPage() {
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const tHeader = useTranslations('header'); const tHeader = useTranslations('header');
const tWelcome = useTranslations('admin.users.welcomeMail'); const tWelcome = useTranslations('admin.users.welcomeMail');
const tCc = useTranslations('controlCenter');
const locale = useLocale(); const locale = useLocale();
const currentUser = useAuthStore((s) => s.user); const currentUser = useAuthStore((s) => s.user);
@@ -304,19 +307,21 @@ export default function AdminUsersPage() {
}; };
return ( return (
<div className="space-y-6"> <div>
{/* Page header */} <PageHeader
<div className="flex items-center justify-between"> title={t('title')}
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1> description={tCc('descriptions.users')}
actions={
<button type="button" onClick={openCreate} className="btn btn-primary"> <button type="button" onClick={openCreate} className="btn btn-primary">
{t('create')} {t('create')}
</button> </button>
</div> }
/>
{loadError && ( {loadError && (
<div <div
role="alert" 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} {loadError}
</div> </div>
@@ -325,7 +330,7 @@ export default function AdminUsersPage() {
{welcomeSuccess && ( {welcomeSuccess && (
<div <div
role="status" 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> <span>{welcomeSuccess}</span>
<button <button
@@ -339,72 +344,83 @@ export default function AdminUsersPage() {
</div> </div>
)} )}
{/* Users table */} {/* Users table: feste Spaltenbreiten, Nebeninfos als zweite Zeile —
passt ohne waagerechtes Scrollen in die Inhaltsbreite (~64rem). */}
{loading ? ( {loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p> <p className="text-muted-foreground">{tCommon('loading')}</p>
) : loadError ? null : users.length === 0 ? ( ) : loadError ? null : users.length === 0 ? (
<p className="text-muted-foreground">{t('noUsers')}</p> <p className="text-muted-foreground">{t('noUsers')}</p>
) : ( ) : (
<div className="overflow-x-auto rounded-md border border-border"> <SettingsSection flush>
<table className="w-full text-sm"> <table className="w-full table-fixed text-sm">
<thead className="bg-muted/50"> <thead className="border-b border-border">
<tr> <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')} {t('username')}
</th> </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('email')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('displayName')} {t('displayName')}
</th> </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')} {t('role')}
</th> </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')} {t('status')}
</th> </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')} {t('lastLogin')}
</th> </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')} {t('actions')}
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{users.map((user) => ( {users.map((user) => (
<tr key={user.id} className="hover:bg-muted/30 transition-colors"> <tr key={user.id} className="align-top hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-medium text-foreground">{user.username}</td> <td className="px-4 py-2.5">
<td className="px-4 py-3 text-muted-foreground">{user.email ?? '–'}</td> <div className="truncate font-medium text-foreground" title={user.username}>
<td className="px-4 py-3 text-muted-foreground">{user.displayName ?? '-'}</td> {user.username}
<td className="px-4 py-3"> </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 <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}`)} {tHeader(`role.${user.role}`)}
</span> </span>
</td> </td>
<td className="px-4 py-3"> <td className="px-3 py-2.5">
<span <span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${ className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
user.isActive user.isActive
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' ? 'bg-status-ok/12 text-status-ok-fg'
: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500' : 'bg-muted text-muted-foreground'
}`} }`}
> >
{user.isActive ? tCommon('active') : tCommon('inactive')} {user.isActive ? tCommon('active') : tCommon('inactive')}
</span> </span>
{user.welcomeMailSentAt && ( {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) })} {tWelcome('sentAt', { date: formatDateTime(user.welcomeMailSentAt) })}
</div> </div>
)} )}
</td> </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')} {user.lastLoginAt ? formatDateTime(user.lastLoginAt) : t('neverLoggedIn')}
</td> </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"> <div className="flex items-center justify-end gap-0.5">
{canSendWelcome(user) && ( {canSendWelcome(user) && (
<button <button
@@ -415,7 +431,7 @@ export default function AdminUsersPage() {
welcomeBlockedReason(user) ?? welcomeBlockedReason(user) ??
(user.welcomeMailSentAt ? tWelcome('resend') : tWelcome('button')) (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" /> <ActionIcon kind="mail" />
<span className="sr-only"> <span className="sr-only">
@@ -427,7 +443,7 @@ export default function AdminUsersPage() {
type="button" type="button"
onClick={() => setDetailsUser(user)} onClick={() => setDetailsUser(user)}
title={t('grants.detailsButton')} 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" /> <ActionIcon kind="details" />
<span className="sr-only">{t('grants.detailsButton')}</span> <span className="sr-only">{t('grants.detailsButton')}</span>
@@ -437,7 +453,7 @@ export default function AdminUsersPage() {
type="button" type="button"
onClick={() => openEdit(user)} onClick={() => openEdit(user)}
title={tCommon('edit')} 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" /> <ActionIcon kind="edit" />
<span className="sr-only">{tCommon('edit')}</span> <span className="sr-only">{tCommon('edit')}</span>
@@ -452,7 +468,7 @@ export default function AdminUsersPage() {
}} }}
disabled={user.id === currentUser?.id} disabled={user.id === currentUser?.id}
title={tCommon('delete')} 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" /> <ActionIcon kind="delete" />
<span className="sr-only">{tCommon('delete')}</span> <span className="sr-only">{tCommon('delete')}</span>
@@ -464,14 +480,14 @@ export default function AdminUsersPage() {
))} ))}
</tbody> </tbody>
</table> </table>
</div> </SettingsSection>
)} )}
{/* Create/Edit modal */} {/* Create/Edit modal */}
{showForm && ( {showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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"> <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')} {editingUser ? t('edit') : t('create')}
</h2> </h2>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
@@ -619,7 +635,7 @@ export default function AdminUsersPage() {
aria-labelledby="welcome-mail-title" aria-labelledby="welcome-mail-title"
className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg" 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')} {tWelcome('dialogTitle')}
</h2> </h2>
<p className="text-sm text-foreground">{tWelcome('dialogText')}</p> <p className="text-sm text-foreground">{tWelcome('dialogText')}</p>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { CalendarSettingsPanel } from '@/components/settings/calendar-settings-panel'; import { CalendarSettingsPanel } from '@/components/settings/calendar-settings-panel';
/** /**
@@ -9,12 +10,11 @@ import { CalendarSettingsPanel } from '@/components/settings/calendar-settings-p
*/ */
export default function CalendarSettingsPage() { export default function CalendarSettingsPage() {
const t = useTranslations('settings'); const t = useTranslations('settings');
const tCc = useTranslations('controlCenter');
return ( return (
<div> <div>
<h1 className="mb-6 text-lg font-semibold text-foreground"> <PageHeader title={t('categoryCalendar')} description={tCc('descriptions.calendar')} />
{t('categoryCalendar')}
</h1>
<CalendarSettingsPanel /> <CalendarSettingsPanel />
</div> </div>
); );
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { WidgetSettingsPanel } from '@/components/settings/widget-settings-panel'; import { WidgetSettingsPanel } from '@/components/settings/widget-settings-panel';
import { fetchDashboards, fetchWidgets } from '@/lib/dashboard-api'; import { fetchDashboards, fetchWidgets } from '@/lib/dashboard-api';
@@ -20,6 +21,7 @@ import { fetchDashboards, fetchWidgets } from '@/lib/dashboard-api';
*/ */
export default function WidgetSettingsPage() { export default function WidgetSettingsPage() {
const t = useTranslations('settings'); const t = useTranslations('settings');
const tCc = useTranslations('controlCenter');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const [widgets, setWidgets] = useState< const [widgets, setWidgets] = useState<
Array<{ id: string; widgetType: string; config: Record<string, unknown> }> Array<{ id: string; widgetType: string; config: Record<string, unknown> }>
@@ -42,9 +44,7 @@ export default function WidgetSettingsPage() {
return ( return (
<div> <div>
<h1 className="mb-6 text-lg font-semibold text-foreground"> <PageHeader title={t('categoryWidgets')} description={tCc('descriptions.widgets')} />
{t('categoryWidgets')}
</h1>
{isLoading ? ( {isLoading ? (
<p className="text-sm text-muted-foreground">{tCommon('loading')}</p> <p className="text-sm text-muted-foreground">{tCommon('loading')}</p>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { AccountSettingsForm } from '@/components/settings/account-settings-form'; import { AccountSettingsForm } from '@/components/settings/account-settings-form';
/** /**
@@ -9,12 +10,11 @@ import { AccountSettingsForm } from '@/components/settings/account-settings-form
*/ */
export default function AccountSettingsPage() { export default function AccountSettingsPage() {
const t = useTranslations('settings'); const t = useTranslations('settings');
const tCc = useTranslations('controlCenter');
return ( return (
<div> <div>
<h1 className="mb-6 text-lg font-semibold text-foreground"> <PageHeader title={t('account.title')} description={tCc('descriptions.account')} />
{t('account.title')}
</h1>
<AccountSettingsForm /> <AccountSettingsForm />
</div> </div>
); );
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { DesktopAppSettings } from '@/components/settings/desktop-app-settings'; import { DesktopAppSettings } from '@/components/settings/desktop-app-settings';
/** /**
@@ -9,12 +10,11 @@ import { DesktopAppSettings } from '@/components/settings/desktop-app-settings';
*/ */
export default function DesktopSettingsPage() { export default function DesktopSettingsPage() {
const t = useTranslations('settings'); const t = useTranslations('settings');
const tCc = useTranslations('controlCenter');
return ( return (
<div> <div>
<h1 className="mb-6 text-lg font-semibold text-foreground"> <PageHeader title={t('desktop.title')} description={tCc('descriptions.desktop')} />
{t('desktop.title')}
</h1>
<DesktopAppSettings /> <DesktopAppSettings />
</div> </div>
); );
+5 -44
View File
@@ -1,48 +1,9 @@
'use client'; import { ControlCenterLayout } from '@/components/control-center/control-center-layout';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { SettingsSidebar } from '@/components/settings/settings-sidebar';
/** /**
* Settings nested layout (D-19, D-20). * Einstellungen und Verwaltung teilen sich einen Rahmen mit einer
* Renders within the portal AppShell, adding its own sub-sidebar on the left. * gemeinsamen Navigation (siehe ControlCenterLayout).
* Back-to-dashboard link at top of content area per UI-SPEC.
*/ */
export default function SettingsLayout({ export default function Layout({ children }: { children: React.ReactNode }) {
children, return <ControlCenterLayout>{children}</ControlCenterLayout>;
}: {
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>
);
} }
+2 -2
View File
@@ -1,8 +1,8 @@
import { redirect } from 'next/navigation'; 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() { export default function SettingsPage() {
redirect('/settings/dashboard'); redirect('/settings/general/account');
} }
+182
View File
@@ -426,6 +426,188 @@ body {
background-color: var(--primary-strong); 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. /* Checkboxen und Optionsfelder in der Link-Farbe statt Browser-Blau bzw.
Gelb (Gelb mit weissem Haken waere kaum lesbar). */ Gelb (Gelb mit weissem Haken waere kaum lesbar). */
input[type="checkbox"], input[type="radio"] { 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, WelcomeMailTemplateRequestError,
type WelcomeMailTemplateState, type WelcomeMailTemplateState,
} from '@/lib/welcome-mail-template-api'; } 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 Field = keyof WelcomeMailTexts;
type PreviewAccount = 'directory' | 'local'; type PreviewAccount = 'directory' | 'local';
@@ -79,6 +81,7 @@ const PREVIEW_DELAY_MS = 400;
*/ */
export function WelcomeMailTemplateEditor() { export function WelcomeMailTemplateEditor() {
const t = useTranslations('admin.welcomeMail'); const t = useTranslations('admin.welcomeMail');
const tCc = useTranslations('controlCenter');
const locale = useLocale(); const locale = useLocale();
const [state, setState] = useState<WelcomeMailTemplateState | null>(null); const [state, setState] = useState<WelcomeMailTemplateState | null>(null);
@@ -255,34 +258,55 @@ export function WelcomeMailTemplateEditor() {
: t('statusDefault'); : t('statusDefault');
return ( return (
<div className="space-y-6">
<div> <div>
<h1 className="mb-2 text-lg font-semibold text-foreground">{t('title')}</h1> <PageHeader title={t('title')} description={t('description')} />
<p className="max-w-3xl text-sm text-muted-foreground">{t('description')}</p>
{/* 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> </div>
{/* Feste Bausteine */} <div className="grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<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)]">
{/* Formular */} {/* Formular */}
<form onSubmit={handleSave} className="min-w-0 space-y-4" noValidate> <form onSubmit={handleSave} className="min-w-0" noValidate>
<p <SettingsSection
className={`text-sm ${state.custom ? 'text-foreground' : 'text-muted-foreground'}`} id="welcome-texts"
title={tCc('welcomeMail.textsTitle')}
description={
<span
className={state.custom ? 'text-foreground' : undefined}
data-testid="welcome-template-status" data-testid="welcome-template-status"
> >
{statusText} {statusText}
{dirty && <span className="ml-2 font-medium">· {t('unsaved')}</span>} {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) => { {FIELDS.map((field) => {
const id = `welcome-${field}`; const id = `welcome-${field}`;
const errors = errorsByField[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"> <div className="mt-1 flex justify-between gap-3 text-xs text-muted-foreground">
<span id={`${id}-help`}>{t(`fields.${field}Help`)}</span> <span id={`${id}-help`}>{t(`fields.${field}Help`)}</span>
<span className="shrink-0 tabular-nums"> <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> </span>
</div> </div>
{errors.length > 0 && ( {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> <p className="text-xs text-muted-foreground">{t('testHelp')}</p>
{confirmReset && ( {confirmReset && (
@@ -373,7 +379,10 @@ export function WelcomeMailTemplateEditor() {
aria-describedby="welcome-reset-body" aria-describedby="welcome-reset-body"
className="max-w-xl rounded-lg border border-border bg-background p-4" 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')} {t('resetConfirm.title')}
</h2> </h2>
<p id="welcome-reset-body" className="mb-3 text-sm text-muted-foreground"> <p id="welcome-reset-body" className="mb-3 text-sm text-muted-foreground">
@@ -384,7 +393,7 @@ export function WelcomeMailTemplateEditor() {
type="button" type="button"
disabled={resetting} disabled={resetting}
onClick={handleReset} onClick={handleReset}
className="btn btn-primary" className="btn btn-secondary"
> >
{t('resetConfirm.confirm')} {t('resetConfirm.confirm')}
</button> </button>
@@ -403,24 +412,70 @@ export function WelcomeMailTemplateEditor() {
<p <p
role={feedback.type === 'error' ? 'alert' : 'status'} role={feedback.type === 'error' ? 'alert' : 'status'}
className={`text-sm ${feedback.type === 'error' ? 'text-destructive' : ''}`} 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} {feedback.message}
</p> </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 */} {/* Platzhalter */}
<section <SettingsSection
aria-labelledby="welcome-placeholders-title" id="welcome-placeholders"
className="rounded-lg border border-border p-4" 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"> <div className="overflow-x-auto">
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead> <thead>
@@ -463,50 +518,7 @@ export function WelcomeMailTemplateEditor() {
</table> </table>
</div> </div>
<p className="mt-3 text-xs text-muted-foreground">{t('placeholders.rules')}</p> <p className="mt-3 text-xs text-muted-foreground">{t('placeholders.rules')}</p>
</section> </SettingsSection>
</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>
</div> </div>
</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 { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store'; import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useCategoryLabel } from '@/lib/use-category-label'; 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 { CustomModuleFormModal } from './custom-module-form-modal';
import { DeleteCustomModuleDialog } from './delete-custom-module-dialog'; import { DeleteCustomModuleDialog } from './delete-custom-module-dialog';
@@ -84,20 +86,19 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
}; };
return ( return (
<div className="space-y-6"> <div>
<div className="flex items-center justify-between"> <PageHeader
<h1 className={`${shared ? 'text-2xl' : 'text-lg'} font-semibold text-foreground`}> title={texts.title}
{texts.title} description={texts.description}
</h1> actions={
<button type="button" onClick={openCreate} className="btn btn-primary"> <button type="button" onClick={openCreate} className="btn btn-primary">
{texts.create} {texts.create}
</button> </button>
</div> }
/>
<p className="text-sm text-muted-foreground">{texts.description}</p>
{loadFailed && ( {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')} {tManage('loadError')}
</div> </div>
)} )}
@@ -105,30 +106,34 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
{/* Bei einem Ladefehler ohne Eintraege steht nur die Fehlermeldung da — {/* Bei einem Ladefehler ohne Eintraege steht nur die Fehlermeldung da —
„keine Eintraege“ waere dann falsch. */} „keine Eintraege“ waere dann falsch. */}
{loading ? ( {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 ? ( ) : modules.length === 0 && loadFailed ? null : modules.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center"> <SettingsSection>
<h2 className="mb-2 text-lg font-semibold text-foreground">{texts.noModules}</h2> <div className="flex flex-col items-start gap-3">
<p className="mb-6 text-sm text-muted-foreground">{texts.noModulesBody}</p> <div>
<button type="button" onClick={openCreate} className="btn btn-primary"> <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} {texts.create}
</button> </button>
</div> </div>
</SettingsSection>
) : ( ) : (
<div className="overflow-x-auto rounded-md border border-border"> <SettingsSection flush>
<table className="w-full text-sm"> <table className="w-full table-fixed text-sm">
<thead className="bg-muted/50"> <thead>
<tr> <tr className="border-b border-border">
<th className="px-4 py-3 text-left font-medium text-muted-foreground"> <th className="w-[28%] px-5 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('name')} {t('name')}
</th> </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')} {t('url')}
</th> </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')} {t('category')}
</th> </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')} {tManage('actions')}
</th> </th>
</tr> </tr>
@@ -136,30 +141,32 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{modules.map((mod) => ( {modules.map((mod) => (
<tr key={mod.id} className="transition-colors hover:bg-muted/30"> <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"> <Link href={`/modules/custom/${mod.id}`} className="link">
{mod.name} {mod.name}
</Link> </Link>
</td> </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}> <span className="block truncate" title={mod.url}>
{mod.url} {mod.url}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-muted-foreground">{categoryLabel(mod.category)}</td> <td className="truncate px-5 py-3 text-muted-foreground">
<td className="px-4 py-3 text-right"> {categoryLabel(mod.category)}
<div className="flex items-center justify-end gap-2"> </td>
<td className="px-5 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button <button
type="button" type="button"
onClick={() => openEdit(mod)} 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')} {tCommon('edit')}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setDeleteTarget(mod)} 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')} {tCommon('delete')}
</button> </button>
@@ -169,7 +176,7 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
))} ))}
</tbody> </tbody>
</table> </table>
</div> </SettingsSection>
)} )}
{showFormModal && ( {showFormModal && (
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { SettingsSection } from '@/components/control-center/settings-section';
import { useState, useTransition, useEffect, useRef } from 'react'; import { useState, useTransition, useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { import {
@@ -149,12 +150,9 @@ export function AccountSettingsForm() {
const userInitial = username.charAt(0).toUpperCase() || '?'; const userInitial = username.charAt(0).toUpperCase() || '?';
return ( return (
<div className="space-y-8 max-w-lg"> <div>
{/* ── Avatar section ─────────────────────────────────────── */} {/* ── Avatar section ─────────────────────────────────────── */}
<section> <SettingsSection id="account-avatar" title={t('account.avatarLabel')}>
<h2 className="text-sm font-semibold text-foreground mb-4">
{t('account.avatarLabel')}
</h2>
<div className="flex items-center gap-4 mb-4"> <div className="flex items-center gap-4 mb-4">
<div className="relative h-16 w-16 shrink-0"> <div className="relative h-16 w-16 shrink-0">
{hasAvatar ? ( {hasAvatar ? (
@@ -197,7 +195,7 @@ export function AccountSettingsForm() {
disabled={isAvatarPending} disabled={isAvatarPending}
onChange={handleAvatarUpload} onChange={handleAvatarUpload}
/> />
<span className="btn btn-primary"> <span className="btn btn-secondary">
{isAvatarPending ? '...' : t('account.uploadCta')} {isAvatarPending ? '...' : t('account.uploadCta')}
</span> </span>
</label> </label>
@@ -206,20 +204,30 @@ export function AccountSettingsForm() {
type="button" type="button"
disabled={isAvatarPending} disabled={isAvatarPending}
onClick={handleDeleteAvatar} 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')} {t('account.deleteAvatarCta')}
</button> </button>
)} )}
</div> </div>
</section> </SettingsSection>
{/* ── Accent color section ────────────────────────────────── */} {/* ── Accent color section ────────────────────────────────── */}
<section> <SettingsSection
<h2 className="text-sm font-semibold text-foreground mb-1"> id="account-accent"
{t('account.accentColorLabel')} title={t('account.accentColorLabel')}
</h2> description={t('account.accentColorHelp')}
<p className="text-xs text-muted-foreground mb-4">{t('account.accentColorHelp')}</p> 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"> <div className="flex items-center gap-3 mb-1">
<input <input
type="color" type="color"
@@ -277,21 +285,32 @@ export function AccountSettingsForm() {
{t('account.accentColorError')} {t('account.accentColorError')}
</div> </div>
)} )}
<button </SettingsSection>
type="button"
onClick={handleSaveAccentColor}
disabled={isAccentPending || !isHexValid}
className="btn btn-primary"
>
{isAccentPending ? '...' : t('account.accentColorSave')}
</button>
</section>
{/* ── Password section ────────────────────────────────────── */} {/* ── Password section ────────────────────────────────────── */}
<section> <SettingsSection
<h2 className="text-sm font-semibold text-foreground mb-4"> id="account-password"
{t('account.passwordSectionTitle')} title={t('account.passwordSectionTitle')}
</h2> 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 && ( {isLocalUser === false && (
<div className="rounded-md bg-muted px-4 py-3 text-sm text-muted-foreground"> <div className="rounded-md bg-muted px-4 py-3 text-sm text-muted-foreground">
@@ -317,7 +336,12 @@ export function AccountSettingsForm() {
</div> </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"> <div className="space-y-2">
<label htmlFor="currentPassword" className="text-sm font-medium text-foreground"> <label htmlFor="currentPassword" className="text-sm font-medium text-foreground">
{tAuth('changePassword.currentPassword')} {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" 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> </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> </form>
</> </>
)} )}
</section> </SettingsSection>
</div> </div>
); );
} }
@@ -10,6 +10,7 @@ import {
testSource, testSource,
} from '@/lib/calendar-api'; } from '@/lib/calendar-api';
import type { CalendarSource, CreateSourcePayload } 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'; import { CalendarSourceForm } from './calendar-source-form';
/** /**
@@ -30,6 +31,7 @@ const TYPE_LABELS: Record<string, string> = {
export function CalendarSettingsPanel() { export function CalendarSettingsPanel() {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const ts = useTranslations('settings'); const ts = useTranslations('settings');
const tCc = useTranslations('controlCenter');
const [sources, setSources] = useState<CalendarSource[]>([]); const [sources, setSources] = useState<CalendarSource[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -40,7 +42,9 @@ export function CalendarSettingsPanel() {
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [editSaveError, setEditSaveError] = useState<string | null>(null); const [editSaveError, setEditSaveError] = useState<string | null>(null);
const [editSaveSuccess, setEditSaveSuccess] = useState(false); 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 // Load sources on mount
useEffect(() => { useEffect(() => {
@@ -53,31 +57,25 @@ export function CalendarSettingsPanel() {
}, []); }, []);
// Toggle visibility (CAL-02) // Toggle visibility (CAL-02)
const handleVisibilityToggle = useCallback( const handleVisibilityToggle = useCallback(async (source: CalendarSource) => {
async (source: CalendarSource) => {
const newIsVisible = !source.isVisible; const newIsVisible = !source.isVisible;
// Optimistic update // Optimistic update
setSources((prev) => setSources((prev) =>
prev.map((s) => prev.map((s) => (s.id === source.id ? { ...s, isVisible: newIsVisible } : s)),
s.id === source.id ? { ...s, isVisible: newIsVisible } : s,
),
); );
try { try {
await updateSource(source.id, { isVisible: newIsVisible }); await updateSource(source.id, { isVisible: newIsVisible });
} catch { } catch {
// Revert on error // Revert on error
setSources((prev) => setSources((prev) =>
prev.map((s) => prev.map((s) => (s.id === source.id ? { ...s, isVisible: source.isVisible } : s)),
s.id === source.id ? { ...s, isVisible: source.isVisible } : s,
),
); );
} }
}, }, []);
[],
);
// Add new source // Add new source
const handleAddSource = useCallback(async (payload: CreateSourcePayload) => { const handleAddSource = useCallback(
async (payload: CreateSourcePayload) => {
setIsSaving(true); setIsSaving(true);
setSaveError(null); setSaveError(null);
try { try {
@@ -101,7 +99,9 @@ export function CalendarSettingsPanel() {
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, [t]); },
[t],
);
// Delete source // Delete source
const handleDeleteSource = useCallback(async (id: string) => { const handleDeleteSource = useCallback(async (id: string) => {
@@ -116,27 +116,32 @@ export function CalendarSettingsPanel() {
// Loading state // Loading state
if (isLoading) { if (isLoading) {
return ( return <div className="text-sm text-muted-foreground">Loading...</div>;
<div className="text-sm text-muted-foreground">Loading...</div>
);
} }
return ( return (
<div className="space-y-6"> <div>
{/* Source list */} {/* Source list */}
{sources.length === 0 && !showAddForm && ( <SettingsSection
<p className="text-sm text-muted-foreground"> flush
{t('calendar.sourceEmpty')} id="calendar-sources"
</p> 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 && ( {sources.length > 0 && (
<div className="space-y-2"> <div className="divide-y divide-border border-t border-border">
{sources.map((source) => ( {sources.map((source) => (
<div <div key={source.id} className="flex items-center gap-3 px-5 py-3">
key={source.id}
className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3"
>
{/* Color dot */} {/* Color dot */}
<span <span
className="h-3 w-3 shrink-0 rounded-full" className="h-3 w-3 shrink-0 rounded-full"
@@ -145,9 +150,7 @@ export function CalendarSettingsPanel() {
/> />
{/* Name */} {/* Name */}
<span className="flex-1 text-sm font-medium text-foreground"> <span className="flex-1 text-sm font-medium text-foreground">{source.name}</span>
{source.name}
</span>
{/* Type badge */} {/* Type badge */}
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground"> <span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
@@ -302,19 +305,17 @@ export function CalendarSettingsPanel() {
))} ))}
</div> </div>
)} )}
</SettingsSection>
{/* Edit form (below the source being edited) */} {/* Edit form (below the source list) */}
{editingId && ( {editingId && (
<div className="rounded-md border border-border bg-card p-4"> <SettingsSection id="calendar-edit-source" title="Edit Source">
{editSaveError && ( {editSaveError && <p className="mb-3 text-sm text-destructive">{editSaveError}</p>}
<p className="mb-3 text-sm text-destructive">{editSaveError}</p>
)}
{editSaveSuccess && ( {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 <CalendarSourceForm
initialValues={(() => { initialValues={(() => {
const s = sources.find((src) => src.id === editingId); const s = sources.find((src) => src.id === editingId);
@@ -337,9 +338,7 @@ export function CalendarSettingsPanel() {
setEditSaveSuccess(false); setEditSaveSuccess(false);
try { try {
const updated = await updateSource(editingId, payload); const updated = await updateSource(editingId, payload);
setSources((prev) => setSources((prev) => prev.map((s) => (s.id === editingId ? updated : s)));
prev.map((s) => (s.id === editingId ? updated : s)),
);
setEditSaveSuccess(true); setEditSaveSuccess(true);
} catch { } catch {
setEditSaveError(t('calendar.saveError') || 'Fehler beim Speichern'); setEditSaveError(t('calendar.saveError') || 'Fehler beim Speichern');
@@ -350,35 +349,19 @@ export function CalendarSettingsPanel() {
onCancel={() => setEditingId(null)} onCancel={() => setEditingId(null)}
isSaving={isSaving} isSaving={isSaving}
/> />
</div> </SettingsSection>
)}
{/* Add source button */}
{!showAddForm && (
<button
type="button"
onClick={() => setShowAddForm(true)}
className="btn btn-primary"
>
{t('calendar.addSource')}
</button>
)} )}
{/* Add source form */} {/* Add source form */}
{showAddForm && ( {showAddForm && (
<div className="rounded-md border border-border bg-card p-4"> <SettingsSection id="calendar-add-source" title={t('calendar.addSource')}>
<h3 className="mb-3 text-sm font-semibold text-foreground"> {saveError && <p className="mb-3 text-sm text-destructive">{saveError}</p>}
{t('calendar.addSource')}
</h3>
{saveError && (
<p className="mb-3 text-sm text-destructive">{saveError}</p>
)}
<CalendarSourceForm <CalendarSourceForm
onSave={handleAddSource} onSave={handleAddSource}
onCancel={() => setShowAddForm(false)} onCancel={() => setShowAddForm(false)}
isSaving={isSaving} isSaving={isSaving}
/> />
</div> </SettingsSection>
)} )}
{/* Delete confirmation dialog (UI-SPEC destructive pattern) */} {/* Delete confirmation dialog (UI-SPEC destructive pattern) */}
@@ -399,12 +382,8 @@ export function CalendarSettingsPanel() {
aria-modal="true" aria-modal="true"
aria-label={t('calendar.deleteDialogLabel')} aria-label={t('calendar.deleteDialogLabel')}
> >
<h3 className="mb-2 text-lg font-semibold text-foreground"> <h3 className="mb-2 text-lg font-semibold text-foreground">{ts('sourceDeleteCta')}</h3>
{ts('sourceDeleteCta')} <p className="mb-4 text-sm text-muted-foreground">{ts('sourceDeleteConfirm')}</p>
</h3>
<p className="mb-4 text-sm text-muted-foreground">
{ts('sourceDeleteConfirm')}
</p>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button <button
type="button" type="button"
@@ -2,11 +2,15 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; 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'; import { useIsDesktopClient } from '@/lib/desktop-client';
import { SettingsSection } from '@/components/control-center/settings-section';
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';
function WindowsIcon() { function WindowsIcon() {
return ( return (
@@ -21,7 +25,15 @@ function WindowsIcon() {
function LinuxIcon() { function LinuxIcon() {
return ( 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" /> <rect x="1" y="2" width="14" height="12" rx="1.5" />
<path d="M4 6l2.5 2L4 10" strokeLinecap="round" strokeLinejoin="round" /> <path d="M4 6l2.5 2L4 10" strokeLinecap="round" strokeLinejoin="round" />
<line x1="8" y1="10" x2="11" y2="10" strokeLinecap="round" /> <line x1="8" y1="10" x2="11" y2="10" strokeLinecap="round" />
@@ -36,6 +48,7 @@ function DownloadButton({
testId, testId,
locale, locale,
fileInfoText, fileInfoText,
primary = false,
}: { }: {
file: DesktopFileInfo; file: DesktopFileInfo;
label: string; label: string;
@@ -43,10 +56,17 @@ function DownloadButton({
testId: string; testId: string;
locale: string; locale: string;
fileInfoText: (values: { name: string; size: string }) => string; fileInfoText: (values: { name: string; size: string }) => string;
/** Hauptknopf der Section (gelb); nur einer je Section. */
primary?: boolean;
}) { }) {
return ( return (
<div> <div className="text-right">
<a href={desktopDownloadUrl(file)} download data-testid={testId} className={BUTTON_CLASS}> <a
href={desktopDownloadUrl(file)}
download
data-testid={testId}
className={`btn ${primary ? 'btn-primary' : 'btn-secondary'}`}
>
{icon} {icon}
{label} {label}
</a> </a>
@@ -85,42 +105,9 @@ export function DesktopAppSettings() {
const fileInfoText = (values: { name: string; size: string }) => t('desktop.fileInfo', values); const fileInfoText = (values: { name: string; size: string }) => t('desktop.fileInfo', values);
return ( const footer =
<div> info && (info.files.windows || info.files.linux) ? (
<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 && (
<> <>
<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 && ( {info.files.linux && (
<DownloadButton <DownloadButton
file={info.files.linux} file={info.files.linux}
@@ -129,11 +116,62 @@ export function DesktopAppSettings() {
testId="desktop-download-linux" testId="desktop-download-linux"
locale={locale} locale={locale}
fileInfoText={fileInfoText} 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> </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'; 'use client';
import { SettingsSection } from '@/components/control-center/settings-section';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { import {
@@ -51,6 +52,7 @@ type TestFeedback =
*/ */
export function SmtpSettingsForm() { export function SmtpSettingsForm() {
const t = useTranslations('settings'); const t = useTranslations('settings');
const tCc = useTranslations('controlCenter');
const [form, setForm] = useState<FormState>(DEFAULT_FORM); const [form, setForm] = useState<FormState>(DEFAULT_FORM);
const [testTo, setTestTo] = useState(''); const [testTo, setTestTo] = useState('');
@@ -137,11 +139,13 @@ export function SmtpSettingsForm() {
}; };
const inputClass = 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'; const labelClass = 'mb-1 block text-sm text-foreground';
return ( 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 */} {/* Host */}
<div> <div>
<label htmlFor="smtp-host" className={labelClass}> <label htmlFor="smtp-host" className={labelClass}>
@@ -159,7 +163,6 @@ export function SmtpSettingsForm() {
} }
/> />
</div> </div>
{/* Port */} {/* Port */}
<div> <div>
<label htmlFor="smtp-port" className={labelClass}> <label htmlFor="smtp-port" className={labelClass}>
@@ -181,7 +184,8 @@ export function SmtpSettingsForm() {
} }
/> />
</div> </div>
</div>
<div className="mt-4 max-w-2xl space-y-4">
{/* Verschlüsselung */} {/* Verschlüsselung */}
<div> <div>
<label htmlFor="smtp-encryption" className={labelClass}> <label htmlFor="smtp-encryption" className={labelClass}>
@@ -206,7 +210,8 @@ export function SmtpSettingsForm() {
))} ))}
</select> </select>
</div> </div>
</div>
<div className="mt-4 grid max-w-2xl gap-4 sm:grid-cols-2">
{/* Benutzername */} {/* Benutzername */}
<div> <div>
<label htmlFor="smtp-username" className={labelClass}> <label htmlFor="smtp-username" className={labelClass}>
@@ -222,13 +227,12 @@ export function SmtpSettingsForm() {
} }
/> />
</div> </div>
{/* Passwort + show/hide toggle */} {/* Passwort + show/hide toggle */}
<div> <div>
<label htmlFor="smtp-password" className={labelClass}> <label htmlFor="smtp-password" className={labelClass}>
{t('smtp.password')} {t('smtp.password')}
</label> </label>
<div className="relative flex max-w-md"> <div className="relative flex">
<input <input
id="smtp-password" id="smtp-password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
@@ -283,7 +287,11 @@ export function SmtpSettingsForm() {
</button> </button>
</div> </div>
</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 */} {/* Absenderadresse */}
<div> <div>
<label htmlFor="smtp-from-address" className={labelClass}> <label htmlFor="smtp-from-address" className={labelClass}>
@@ -304,7 +312,6 @@ export function SmtpSettingsForm() {
{t('smtp.fromAddressHelp')} {t('smtp.fromAddressHelp')}
</p> </p>
</div> </div>
{/* Fehlermeldungen an (quick-260914-m97) */} {/* Fehlermeldungen an (quick-260914-m97) */}
<div> <div>
<label htmlFor="smtp-bug-report-recipient" className={labelClass}> <label htmlFor="smtp-bug-report-recipient" className={labelClass}>
@@ -324,9 +331,26 @@ export function SmtpSettingsForm() {
{t('smtp.bugReportRecipientHelp')} {t('smtp.bugReportRecipientHelp')}
</p> </p>
</div> </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 */} {/* Test-E-Mail Empfänger */}
<div> <div className="max-w-2xl">
<label htmlFor="smtp-test-to" className={labelClass}> <label htmlFor="smtp-test-to" className={labelClass}>
{t('smtp.testToLabel')} {t('smtp.testToLabel')}
</label> </label>
@@ -342,26 +366,6 @@ export function SmtpSettingsForm() {
{t('smtp.testToHelp')} {t('smtp.testToHelp')}
</p> </p>
</div> </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 */} {/* Connection test feedback */}
{testFeedback && ( {testFeedback && (
<p <p
@@ -381,6 +385,7 @@ export function SmtpSettingsForm() {
{testFeedback.message} {testFeedback.message}
</p> </p>
)} )}
</SettingsSection>
</form> </form>
); );
} }
@@ -3,6 +3,7 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import Link from 'next/link'; import Link from 'next/link';
import { SettingsSection } from '@/components/control-center/settings-section';
import { WIDGET_REGISTRY, type WidgetType } from '@/components/dashboard/widget-registry'; import { WIDGET_REGISTRY, type WidgetType } from '@/components/dashboard/widget-registry';
import { PictureFrameConfigForm } from '@/components/settings/picture-frame-config-form'; import { PictureFrameConfigForm } from '@/components/settings/picture-frame-config-form';
import { ProxmoxWidgetConfigForm } from '@/components/settings/proxmox-widget-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. * Lists all placed widget instances grouped by type.
* Each instance is expandable to its type-specific config form. * Each instance is expandable to its type-specific config form.
*/ */
export function WidgetSettingsPanel({ export function WidgetSettingsPanel({ widgets, onWidgetUpdate }: WidgetSettingsPanelProps) {
widgets,
onWidgetUpdate,
}: WidgetSettingsPanelProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const [saving, setSaving] = useState<string | null>(null); const [saving, setSaving] = useState<string | null>(null);
@@ -88,36 +86,35 @@ export function WidgetSettingsPanel({
); );
return ( return (
<div className="space-y-6"> <div>
{Object.entries(groupedByType).map(([type, instances]) => { {Object.entries(groupedByType).map(([type, instances]) => {
const def = WIDGET_REGISTRY[type as WidgetType]; const def = WIDGET_REGISTRY[type as WidgetType];
if (!def) return null; if (!def) return null;
return ( return (
<div key={type}> <SettingsSection
{/* Type group heading */} key={type}
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground"> id={`widget-type-${type}`}
flush
title={
<span className="flex items-center gap-2">
<def.icon className="h-4 w-4 text-muted-foreground" /> <def.icon className="h-4 w-4 text-muted-foreground" />
{t(def.nameKey)} {t(def.nameKey)}
</h2> </span>
}
<div className="space-y-2"> >
<div className="divide-y divide-border overflow-hidden rounded-b-[calc(var(--radius)+2px)] border-t border-border">
{instances.map((widget, idx) => { {instances.map((widget, idx) => {
const isExpanded = expandedId === widget.id; const isExpanded = expandedId === widget.id;
const isSaving = saving === widget.id; const isSaving = saving === widget.id;
return ( return (
<div <div key={widget.id}>
key={widget.id}
className="rounded-md border border-border bg-card"
>
{/* Instance header (click to expand) */} {/* Instance header (click to expand) */}
<button <button
type="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" 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={() => onClick={() => setExpandedId(isExpanded ? null : widget.id)}
setExpandedId(isExpanded ? null : widget.id)
}
> >
<span> <span>
{t(def.nameKey)} #{idx + 1} {t(def.nameKey)} #{idx + 1}
@@ -163,35 +160,27 @@ export function WidgetSettingsPanel({
{/* Expanded config form */} {/* Expanded config form */}
{isExpanded && ( {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 && ( {isSaving && (
<p className="mb-2 text-xs text-muted-foreground"> <p className="mb-2 text-xs text-muted-foreground">Saving...</p>
Saving...
</p>
)} )}
{/* Clock config (D-12, D-13) */} {/* Clock config (D-12, D-13) */}
{widget.widgetType === 'clock' && ( {widget.widgetType === 'clock' && (
<ClockConfig <ClockConfig
config={widget.config} config={widget.config}
onChange={(cfg) => onChange={(cfg) => handleConfigChange(widget.id, cfg)}
handleConfigChange(widget.id, cfg)
}
/> />
)} )}
{/* Search config (D-15) */} {/* Search config (D-15) */}
{widget.widgetType === 'search' && ( {widget.widgetType === 'search' && <SearchProviderForm />}
<SearchProviderForm />
)}
{/* Note config (D-17) */} {/* Note config (D-17) */}
{widget.widgetType === 'note' && ( {widget.widgetType === 'note' && (
<NoteConfig <NoteConfig
config={widget.config} config={widget.config}
onChange={(cfg) => onChange={(cfg) => handleConfigChange(widget.id, cfg)}
handleConfigChange(widget.id, cfg)
}
/> />
)} )}
@@ -199,9 +188,7 @@ export function WidgetSettingsPanel({
{widget.widgetType === 'calendar' && ( {widget.widgetType === 'calendar' && (
<CalendarConfig <CalendarConfig
config={widget.config} config={widget.config}
onChange={(cfg) => onChange={(cfg) => handleConfigChange(widget.id, cfg)}
handleConfigChange(widget.id, cfg)
}
/> />
)} )}
@@ -209,9 +196,7 @@ export function WidgetSettingsPanel({
{widget.widgetType === 'favorites' && ( {widget.widgetType === 'favorites' && (
<FavoritesConfig <FavoritesConfig
config={widget.config} config={widget.config}
onChange={(cfg) => onChange={(cfg) => handleConfigChange(widget.id, cfg)}
handleConfigChange(widget.id, cfg)
}
/> />
)} )}
@@ -219,9 +204,7 @@ export function WidgetSettingsPanel({
{widget.widgetType === 'picture-frame' && ( {widget.widgetType === 'picture-frame' && (
<PictureFrameConfigForm <PictureFrameConfigForm
config={widget.config} config={widget.config}
onChange={(cfg) => onChange={(cfg) => handleConfigChange(widget.id, cfg)}
handleConfigChange(widget.id, cfg)
}
/> />
)} )}
@@ -229,9 +212,7 @@ export function WidgetSettingsPanel({
{widget.widgetType === 'xframe' && ( {widget.widgetType === 'xframe' && (
<XframeConfigForm <XframeConfigForm
config={widget.config} config={widget.config}
onChange={(cfg) => onChange={(cfg) => handleConfigChange(widget.id, cfg)}
handleConfigChange(widget.id, cfg)
}
/> />
)} )}
@@ -239,9 +220,7 @@ export function WidgetSettingsPanel({
{widget.widgetType === 'proxmox' && ( {widget.widgetType === 'proxmox' && (
<ProxmoxWidgetConfigForm <ProxmoxWidgetConfigForm
config={widget.config} config={widget.config}
onChange={(cfg) => onChange={(cfg) => handleConfigChange(widget.id, cfg)}
handleConfigChange(widget.id, cfg)
}
/> />
)} )}
</div> </div>
@@ -250,7 +229,7 @@ export function WidgetSettingsPanel({
); );
})} })}
</div> </div>
</div> </SettingsSection>
); );
})} })}
</div> </div>
@@ -303,10 +282,7 @@ function ClockConfig({
<div className="space-y-4"> <div className="space-y-4">
{/* Timezone select (D-12) */} {/* Timezone select (D-12) */}
<div> <div>
<label <label htmlFor="timezone-select" className="mb-1 block text-sm text-foreground">
htmlFor="timezone-select"
className="mb-1 block text-sm text-foreground"
>
Timezone Timezone
</label> </label>
<select <select
@@ -449,10 +425,7 @@ function CalendarConfig({
{/* Hinweis auf die Kalenderquellenverwaltung */} {/* Hinweis auf die Kalenderquellenverwaltung */}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t('calendar.configSourcesHint')}{' '} {t('calendar.configSourcesHint')}{' '}
<Link <Link href="/settings/dashboard/calendar" className="link">
href="/settings/dashboard/calendar"
className="link"
>
{t('calendar.configSourcesLink')} {t('calendar.configSourcesLink')}
</Link> </Link>
</p> </p>
@@ -502,10 +475,7 @@ function FavoritesConfig({
return ( return (
<div> <div>
<label <label htmlFor="favorites-title" className="mb-1 block text-sm text-foreground">
htmlFor="favorites-title"
className="mb-1 block text-sm text-foreground"
>
{t('favorites.titleLabel')} {t('favorites.titleLabel')}
</label> </label>
<input <input
+71 -9
View File
@@ -81,7 +81,7 @@
"settings": "Einstellungen", "settings": "Einstellungen",
"logout": "Abmelden", "logout": "Abmelden",
"admin": { "admin": {
"label": "Administrator", "label": "Verwaltung",
"users": "Benutzer", "users": "Benutzer",
"tenants": "Mandanten", "tenants": "Mandanten",
"modules": "Module", "modules": "Module",
@@ -210,7 +210,7 @@
"providerDeleteCta": "Anbieter löschen", "providerDeleteCta": "Anbieter löschen",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"smtp": { "smtp": {
"title": "SMTP-Konfiguration", "title": "E-Mail-Versand",
"host": "Host", "host": "Host",
"port": "Port", "port": "Port",
"encryption": "Verschlüsselung", "encryption": "Verschlüsselung",
@@ -531,7 +531,7 @@
}, },
"admin": { "admin": {
"users": { "users": {
"title": "Benutzerverwaltung", "title": "Benutzer",
"create": "Benutzer erstellen", "create": "Benutzer erstellen",
"edit": "Benutzer bearbeiten", "edit": "Benutzer bearbeiten",
"delete": "Benutzer löschen", "delete": "Benutzer löschen",
@@ -574,7 +574,7 @@
"resend": "Erneut senden", "resend": "Erneut senden",
"sentAt": "Willkommensmail gesendet am {date}", "sentAt": "Willkommensmail gesendet am {date}",
"noEmail": "Keine E-Mail-Adresse hinterlegt – bitte zuerst eine Adresse eintragen.", "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.", "inactive": "Das Konto ist deaktiviert.",
"dialogTitle": "Willkommensmail senden", "dialogTitle": "Willkommensmail senden",
"dialogText": "Tessera schickt eine Willkommensmail mit Adresse, Benutzername und Hinweisen zur ersten Anmeldung an:", "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" "cannotDeleteActive": "Mandanten mit aktiven Benutzern können nicht gelöscht werden"
}, },
"ldap": { "ldap": {
"title": "LDAP-Konfiguration", "title": "Verzeichnis (LDAP)",
"connectionTitle": "Verbindungseinstellungen", "connectionTitle": "Verbindungseinstellungen",
"serverUrl": "Server-URL", "serverUrl": "Server-URL",
"baseDn": "Basis-DN", "baseDn": "Basis-DN",
@@ -745,7 +745,7 @@
} }
}, },
"customModules": { "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.", "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", "create": "Gemeinsamen Eintrag anlegen",
"noModules": "Noch keine gemeinsamen Einträge", "noModules": "Noch keine gemeinsamen Einträge",
@@ -853,7 +853,7 @@
} }
}, },
"adminModules": { "adminModules": {
"title": "Modulverwaltung", "title": "Module",
"description": "Module aktivieren oder deaktivieren", "description": "Module aktivieren oder deaktivieren",
"activate": "Aktivieren", "activate": "Aktivieren",
"deactivate": "Deaktivieren", "deactivate": "Deaktivieren",
@@ -868,7 +868,7 @@
"grantsLink": "Freigaben-Matrix", "grantsLink": "Freigaben-Matrix",
"categoriesLink": "Kategorien", "categoriesLink": "Kategorien",
"grants": { "grants": {
"title": "Freigaben-Matrix", "title": "Freigaben",
"searchPlaceholder": "Module oder Gruppen durchsuchen...", "searchPlaceholder": "Module oder Gruppen durchsuchen...",
"emptyModules": "Es sind noch keine Module aktiviert. Aktiviere zuerst ein Modul unter Module.", "emptyModules": "Es sind noch keine Module aktiviert. Aktiviere zuerst ein Modul unter Module.",
"emptyModulesLink": "Zu Module", "emptyModulesLink": "Zu Module",
@@ -940,7 +940,7 @@
"close": "Schließen", "close": "Schließen",
"sent": "Vielen Dank, die Meldung wurde gesendet.", "sent": "Vielen Dank, die Meldung wurde gesendet.",
"errorNotConfigured": "Für Fehlermeldungen ist noch kein Postfach eingerichtet.", "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", "errorNotConfiguredAdminLink": "Zu den SMTP-Einstellungen",
"errorTooMany": "Zu viele Meldungen in kurzer Zeit. Bitte versuchen Sie es in einigen Minuten erneut.", "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.", "errorTooLarge": "Das Bild ist zu groß. Bitte senden Sie die Meldung ohne Bildschirmfoto.",
@@ -1985,5 +1985,67 @@
"countsLabel": "Anzahl der Clouds je Ampelfarbe", "countsLabel": "Anzahl der Clouds je Ampelfarbe",
"open": "Nextcloud-Status öffnen" "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", "providerDeleteCta": "Delete provider",
"cancel": "Cancel", "cancel": "Cancel",
"smtp": { "smtp": {
"title": "SMTP Configuration", "title": "Email delivery",
"host": "Host", "host": "Host",
"port": "Port", "port": "Port",
"encryption": "Encryption", "encryption": "Encryption",
@@ -531,7 +531,7 @@
}, },
"admin": { "admin": {
"users": { "users": {
"title": "User Management", "title": "Users",
"create": "Create User", "create": "Create User",
"edit": "Edit User", "edit": "Edit User",
"delete": "Delete User", "delete": "Delete User",
@@ -598,7 +598,7 @@
"cannotDeleteActive": "Cannot delete tenants with active users" "cannotDeleteActive": "Cannot delete tenants with active users"
}, },
"ldap": { "ldap": {
"title": "LDAP configuration", "title": "Directory (LDAP)",
"connectionTitle": "Connection settings", "connectionTitle": "Connection settings",
"serverUrl": "Server URL", "serverUrl": "Server URL",
"baseDn": "Base DN", "baseDn": "Base DN",
@@ -745,7 +745,7 @@
} }
}, },
"customModules": { "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”.", "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", "create": "Add shared entry",
"noModules": "No shared entries yet", "noModules": "No shared entries yet",
@@ -853,7 +853,7 @@
} }
}, },
"adminModules": { "adminModules": {
"title": "Module Management", "title": "Modules",
"description": "Activate or deactivate modules", "description": "Activate or deactivate modules",
"activate": "Activate", "activate": "Activate",
"deactivate": "Deactivate", "deactivate": "Deactivate",
@@ -868,7 +868,7 @@
"grantsLink": "Grants Matrix", "grantsLink": "Grants Matrix",
"categoriesLink": "Categories", "categoriesLink": "Categories",
"grants": { "grants": {
"title": "Grants Matrix", "title": "Permissions",
"searchPlaceholder": "Search modules or groups...", "searchPlaceholder": "Search modules or groups...",
"emptyModules": "No modules are activated yet. Activate a module under Modules first.", "emptyModules": "No modules are activated yet. Activate a module under Modules first.",
"emptyModulesLink": "Go to Modules", "emptyModulesLink": "Go to Modules",
@@ -1985,5 +1985,67 @@
"countsLabel": "Number of clouds per traffic-light color", "countsLabel": "Number of clouds per traffic-light color",
"open": "Open Nextcloud status" "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"
}
} }
} }