Forgot Passaord
This commit is contained in:
@@ -44,6 +44,10 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
avatar_color TEXT,
|
avatar_color TEXT,
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
last_login DATETIME,
|
last_login DATETIME,
|
||||||
|
security_phrase_content TEXT,
|
||||||
|
security_phrase_iv TEXT,
|
||||||
|
security_phrase_tag TEXT,
|
||||||
|
security_phrase_downloaded_at DATETIME,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
@@ -93,6 +97,83 @@ CREATE TABLE IF NOT EXISTS user_group_permissions (
|
|||||||
);
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const createPermissionSectionsTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS permission_sections (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
has_navigation_flag INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const createPermissionGroupsTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS permission_groups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
section_id INTEGER NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(section_id, key),
|
||||||
|
FOREIGN KEY (section_id) REFERENCES permission_sections(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const createPermissionItemsTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS permission_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
section_id INTEGER NOT NULL,
|
||||||
|
group_id INTEGER,
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
default_level TEXT NOT NULL,
|
||||||
|
available_levels TEXT NOT NULL,
|
||||||
|
is_required INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (section_id) REFERENCES permission_sections(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (group_id) REFERENCES permission_groups(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const createPermissionDependenciesTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS permission_dependencies (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
permission_id INTEGER NOT NULL,
|
||||||
|
depends_on_permission_id INTEGER NOT NULL,
|
||||||
|
required_level TEXT DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (permission_id) REFERENCES permission_items(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (depends_on_permission_id) REFERENCES permission_items(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(permission_id, depends_on_permission_id)
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const createPermissionDependenciesIndex = `
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_permission_dependencies_required
|
||||||
|
ON permission_dependencies (permission_id, depends_on_permission_id);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const createGroupPermissionValuesTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS group_permission_values (
|
||||||
|
group_id INTEGER NOT NULL,
|
||||||
|
permission_id INTEGER NOT NULL,
|
||||||
|
level TEXT NOT NULL,
|
||||||
|
effective_level TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (group_id, permission_id),
|
||||||
|
FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (permission_id) REFERENCES permission_items(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
||||||
const createServersTable = `
|
const createServersTable = `
|
||||||
CREATE TABLE IF NOT EXISTS servers (
|
CREATE TABLE IF NOT EXISTS servers (
|
||||||
@@ -364,6 +445,549 @@ console.log('✅ permissions table ready');
|
|||||||
db.exec(createUserGroupPermissionsTable);
|
db.exec(createUserGroupPermissionsTable);
|
||||||
console.log('✅ user_group_permissions table ready');
|
console.log('✅ user_group_permissions table ready');
|
||||||
|
|
||||||
|
db.exec(createPermissionSectionsTable);
|
||||||
|
console.log('✅ permission_sections table ready');
|
||||||
|
|
||||||
|
db.exec(createPermissionGroupsTable);
|
||||||
|
console.log('✅ permission_groups table ready');
|
||||||
|
|
||||||
|
db.exec(createPermissionItemsTable);
|
||||||
|
console.log('✅ permission_items table ready');
|
||||||
|
|
||||||
|
db.exec(createPermissionDependenciesTable);
|
||||||
|
db.exec(createPermissionDependenciesIndex);
|
||||||
|
console.log('✅ permission_dependencies table ready');
|
||||||
|
|
||||||
|
db.exec(createGroupPermissionValuesTable);
|
||||||
|
console.log('✅ group_permission_values table ready');
|
||||||
|
|
||||||
|
const permissionsSeeded = () => {
|
||||||
|
try {
|
||||||
|
const row = db.prepare('SELECT COUNT(*) as count FROM permission_sections').get();
|
||||||
|
return row?.count > 0;
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const seedPermissions = () => {
|
||||||
|
if (permissionsSeeded()) {
|
||||||
|
console.log('ℹ️ permission structure already seeded');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertSection = db.prepare(`
|
||||||
|
INSERT INTO permission_sections (key, title, sort_order, has_navigation_flag)
|
||||||
|
VALUES (@key, @title, @sort_order, @has_navigation_flag)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const insertGroup = db.prepare(`
|
||||||
|
INSERT INTO permission_groups (section_id, key, title, sort_order)
|
||||||
|
VALUES (@section_id, @key, @title, @sort_order)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const insertItem = db.prepare(`
|
||||||
|
INSERT INTO permission_items (
|
||||||
|
section_id,
|
||||||
|
group_id,
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
sort_order,
|
||||||
|
default_level,
|
||||||
|
available_levels,
|
||||||
|
is_required
|
||||||
|
) VALUES (
|
||||||
|
@section_id,
|
||||||
|
@group_id,
|
||||||
|
@key,
|
||||||
|
@label,
|
||||||
|
@sort_order,
|
||||||
|
@default_level,
|
||||||
|
@available_levels,
|
||||||
|
@is_required
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const insertDependency = db.prepare(`
|
||||||
|
INSERT INTO permission_dependencies (permission_id, depends_on_permission_id, required_level)
|
||||||
|
VALUES (@permission_id, @depends_on_permission_id, @required_level)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
key: 'stacks',
|
||||||
|
title: 'Stacks',
|
||||||
|
sortOrder: 0,
|
||||||
|
hasNavigation: 0,
|
||||||
|
groups: [],
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'stacks-redeploy-single',
|
||||||
|
label: 'Redeploy einzeln',
|
||||||
|
sortOrder: 0,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
isRequired: 0,
|
||||||
|
dependencies: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stacks-redeploy-selection',
|
||||||
|
label: 'Redeploy Auswahl',
|
||||||
|
sortOrder: 1,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
isRequired: 0,
|
||||||
|
dependencies: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stacks-redeploy-all',
|
||||||
|
label: 'Redeploy Alle',
|
||||||
|
sortOrder: 2,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
isRequired: 0,
|
||||||
|
dependencies: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'logs',
|
||||||
|
title: 'Logs',
|
||||||
|
sortOrder: 1,
|
||||||
|
hasNavigation: 1,
|
||||||
|
groups: [],
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'logs-access',
|
||||||
|
label: 'Bereich & Navigation',
|
||||||
|
sortOrder: 0,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
isRequired: 1,
|
||||||
|
dependencies: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'logs-export',
|
||||||
|
label: 'Logs Exportieren',
|
||||||
|
sortOrder: 1,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
isRequired: 0,
|
||||||
|
dependencies: [
|
||||||
|
{
|
||||||
|
dependsOnKey: 'logs-access',
|
||||||
|
requiredLevel: '!=none'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'logs-delete',
|
||||||
|
label: 'Logs löschen',
|
||||||
|
sortOrder: 2,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
isRequired: 0,
|
||||||
|
dependencies: [
|
||||||
|
{
|
||||||
|
dependsOnKey: 'logs-access',
|
||||||
|
requiredLevel: '!=none'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'users',
|
||||||
|
title: 'Benutzer',
|
||||||
|
sortOrder: 2,
|
||||||
|
hasNavigation: 1,
|
||||||
|
groups: [],
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'users-access',
|
||||||
|
label: 'Bereich & Navigation',
|
||||||
|
sortOrder: 0,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
isRequired: 1,
|
||||||
|
dependencies: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'users-edit',
|
||||||
|
label: 'Benutzer bearbeiten',
|
||||||
|
sortOrder: 1,
|
||||||
|
defaultLevel: 'read',
|
||||||
|
levels: ['full', 'read'],
|
||||||
|
isRequired: 0,
|
||||||
|
dependencies: [
|
||||||
|
{
|
||||||
|
dependsOnKey: 'users-access',
|
||||||
|
requiredLevel: '!=none'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'users-delete',
|
||||||
|
label: 'Benutzer löschen',
|
||||||
|
sortOrder: 2,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
isRequired: 0,
|
||||||
|
dependencies: [
|
||||||
|
{
|
||||||
|
dependsOnKey: 'users-access',
|
||||||
|
requiredLevel: '!=none'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'users-security-phrase',
|
||||||
|
label: 'Sicherheitsschlüssel',
|
||||||
|
sortOrder: 3,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'read', 'none'],
|
||||||
|
isRequired: 0,
|
||||||
|
dependencies: [
|
||||||
|
{
|
||||||
|
dependsOnKey: 'users-access',
|
||||||
|
requiredLevel: '!=none'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'user-groups',
|
||||||
|
title: 'Benutzergruppen',
|
||||||
|
sortOrder: 3,
|
||||||
|
hasNavigation: 1,
|
||||||
|
groups: [],
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'user-groups-access',
|
||||||
|
label: 'Bereich & Navigation',
|
||||||
|
sortOrder: 0,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
isRequired: 1,
|
||||||
|
dependencies: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'user-groups-edit',
|
||||||
|
label: 'Benutzergruppen bearbeiten',
|
||||||
|
sortOrder: 1,
|
||||||
|
defaultLevel: 'read',
|
||||||
|
levels: ['full', 'read'],
|
||||||
|
isRequired: 0,
|
||||||
|
dependencies: [
|
||||||
|
{
|
||||||
|
dependsOnKey: 'user-groups-access',
|
||||||
|
requiredLevel: '!=none'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'user-groups-delete',
|
||||||
|
label: 'Benutzergruppen löschen',
|
||||||
|
sortOrder: 2,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
isRequired: 0,
|
||||||
|
dependencies: [
|
||||||
|
{
|
||||||
|
dependsOnKey: 'user-groups-access',
|
||||||
|
requiredLevel: '!=none'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maintenance',
|
||||||
|
title: 'Wartung',
|
||||||
|
sortOrder: 4,
|
||||||
|
hasNavigation: 1,
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
key: 'maintenance-server-group',
|
||||||
|
title: 'Server & Endpoints',
|
||||||
|
sortOrder: 0,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'maintenance-server-manage',
|
||||||
|
label: 'Server/Endpoint-Sektion',
|
||||||
|
sortOrder: 0,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'read', 'none'],
|
||||||
|
isRequired: 0,
|
||||||
|
dependencies: [
|
||||||
|
{
|
||||||
|
dependsOnKey: 'maintenance-access',
|
||||||
|
requiredLevel: '!=none'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maintenance-server-delete',
|
||||||
|
label: 'Server/Endpoint löschen',
|
||||||
|
sortOrder: 1,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
isRequired: 0,
|
||||||
|
dependencies: [
|
||||||
|
{
|
||||||
|
dependsOnKey: 'maintenance-access',
|
||||||
|
requiredLevel: '!=none'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dependsOnKey: 'maintenance-server-manage',
|
||||||
|
requiredLevel: '!=none'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maintenance-portainer-group',
|
||||||
|
title: 'Portainer',
|
||||||
|
sortOrder: 1,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'maintenance-portainer',
|
||||||
|
label: 'Portainer-Sektion',
|
||||||
|
sortOrder: 0,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
isRequired: 0,
|
||||||
|
dependencies: [
|
||||||
|
{
|
||||||
|
dependsOnKey: 'maintenance-access',
|
||||||
|
requiredLevel: '!=none'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maintenance-ssh-update',
|
||||||
|
label: 'SSH/Update-Skript',
|
||||||
|
sortOrder: 1,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'read', 'none'],
|
||||||
|
isRequired: 0,
|
||||||
|
dependencies: [
|
||||||
|
{
|
||||||
|
dependsOnKey: 'maintenance-access',
|
||||||
|
requiredLevel: '!=none'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dependsOnKey: 'maintenance-portainer',
|
||||||
|
requiredLevel: '!=none'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maintenance-update',
|
||||||
|
label: 'Update durchführen',
|
||||||
|
sortOrder: 2,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
isRequired: 0,
|
||||||
|
dependencies: [
|
||||||
|
{
|
||||||
|
dependsOnKey: 'maintenance-access',
|
||||||
|
requiredLevel: '!=none'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dependsOnKey: 'maintenance-portainer',
|
||||||
|
requiredLevel: '!=none'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maintenance-duplicates-group',
|
||||||
|
title: 'Doppelte Stacks',
|
||||||
|
sortOrder: 2,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'maintenance-duplicates',
|
||||||
|
label: 'Doppelte Stacks',
|
||||||
|
sortOrder: 0,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'read', 'none'],
|
||||||
|
isRequired: 0,
|
||||||
|
dependencies: [
|
||||||
|
{
|
||||||
|
dependsOnKey: 'maintenance-access',
|
||||||
|
requiredLevel: '!=none'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'maintenance-access',
|
||||||
|
label: 'Bereich & Navigation',
|
||||||
|
sortOrder: 0,
|
||||||
|
defaultLevel: 'none',
|
||||||
|
levels: ['full', 'none'],
|
||||||
|
isRequired: 1,
|
||||||
|
dependencies: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const itemKeyToId = new Map();
|
||||||
|
|
||||||
|
const insertPermissions = db.transaction(() => {
|
||||||
|
sections.forEach((section) => {
|
||||||
|
const sectionResult = insertSection.run({
|
||||||
|
key: section.key,
|
||||||
|
title: section.title,
|
||||||
|
sort_order: section.sortOrder,
|
||||||
|
has_navigation_flag: section.hasNavigation ? 1 : 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const sectionId = sectionResult.lastInsertRowid;
|
||||||
|
|
||||||
|
const sectionItems = section.items ?? [];
|
||||||
|
sectionItems.forEach((item, index) => {
|
||||||
|
const itemResult = insertItem.run({
|
||||||
|
section_id: sectionId,
|
||||||
|
group_id: null,
|
||||||
|
key: item.key,
|
||||||
|
label: item.label,
|
||||||
|
sort_order: item.sortOrder ?? index,
|
||||||
|
default_level: item.defaultLevel,
|
||||||
|
available_levels: JSON.stringify(item.levels),
|
||||||
|
is_required: item.isRequired ? 1 : 0
|
||||||
|
});
|
||||||
|
itemKeyToId.set(item.key, itemResult.lastInsertRowid);
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = section.groups ?? [];
|
||||||
|
groups.forEach((group, groupIdx) => {
|
||||||
|
const groupResult = insertGroup.run({
|
||||||
|
section_id: sectionId,
|
||||||
|
key: group.key,
|
||||||
|
title: group.title,
|
||||||
|
sort_order: group.sortOrder ?? groupIdx
|
||||||
|
});
|
||||||
|
const groupId = groupResult.lastInsertRowid;
|
||||||
|
const groupItems = group.items ?? [];
|
||||||
|
groupItems.forEach((item, itemIdx) => {
|
||||||
|
const itemResult = insertItem.run({
|
||||||
|
section_id: sectionId,
|
||||||
|
group_id: groupId,
|
||||||
|
key: item.key,
|
||||||
|
label: item.label,
|
||||||
|
sort_order: item.sortOrder ?? itemIdx,
|
||||||
|
default_level: item.defaultLevel,
|
||||||
|
available_levels: JSON.stringify(item.levels),
|
||||||
|
is_required: item.isRequired ? 1 : 0
|
||||||
|
});
|
||||||
|
itemKeyToId.set(item.key, itemResult.lastInsertRowid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
sections.forEach((section) => {
|
||||||
|
const collectDependencies = (items = []) => {
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (!item.dependencies || !item.dependencies.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const permissionId = itemKeyToId.get(item.key);
|
||||||
|
item.dependencies.forEach((dependency) => {
|
||||||
|
const dependsId = itemKeyToId.get(dependency.dependsOnKey);
|
||||||
|
if (!dependsId) {
|
||||||
|
console.warn(`⚠️ dependency target ${dependency.dependsOnKey} not found for ${item.key}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
insertDependency.run({
|
||||||
|
permission_id: permissionId,
|
||||||
|
depends_on_permission_id: dependsId,
|
||||||
|
required_level: dependency.requiredLevel ?? null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
collectDependencies(section.items);
|
||||||
|
(section.groups ?? []).forEach((group) => collectDependencies(group.items));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
insertPermissions();
|
||||||
|
console.log('✅ permission structure seeded');
|
||||||
|
};
|
||||||
|
|
||||||
|
seedPermissions();
|
||||||
|
|
||||||
|
const ensureUserSecurityPhraseColumns = () => {
|
||||||
|
const columns = db.prepare('PRAGMA table_info(users)').all();
|
||||||
|
const columnNames = new Set(columns.map((column) => column.name));
|
||||||
|
|
||||||
|
const addColumnIfMissing = (name, sql) => {
|
||||||
|
if (!columnNames.has(name)) {
|
||||||
|
db.exec(sql);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addColumnIfMissing('security_phrase_content', 'ALTER TABLE users ADD COLUMN security_phrase_content TEXT');
|
||||||
|
addColumnIfMissing('security_phrase_iv', 'ALTER TABLE users ADD COLUMN security_phrase_iv TEXT');
|
||||||
|
addColumnIfMissing('security_phrase_tag', 'ALTER TABLE users ADD COLUMN security_phrase_tag TEXT');
|
||||||
|
addColumnIfMissing('security_phrase_downloaded_at', 'ALTER TABLE users ADD COLUMN security_phrase_downloaded_at DATETIME');
|
||||||
|
};
|
||||||
|
|
||||||
|
ensureUserSecurityPhraseColumns();
|
||||||
|
|
||||||
|
const ensurePermissionDefaultLevels = () => {
|
||||||
|
const desiredDefaults = [
|
||||||
|
{ key: 'stacks-redeploy-single', defaultLevel: 'none' },
|
||||||
|
{ key: 'stacks-redeploy-selection', defaultLevel: 'none' },
|
||||||
|
{ key: 'stacks-redeploy-all', defaultLevel: 'none' },
|
||||||
|
{ key: 'logs-access', defaultLevel: 'none' },
|
||||||
|
{ key: 'logs-export', defaultLevel: 'none' },
|
||||||
|
{ key: 'logs-delete', defaultLevel: 'none' },
|
||||||
|
{ key: 'users-access', defaultLevel: 'none' },
|
||||||
|
{ key: 'users-edit', defaultLevel: 'read' },
|
||||||
|
{ key: 'users-delete', defaultLevel: 'none' },
|
||||||
|
{ key: 'users-security-phrase', defaultLevel: 'none' },
|
||||||
|
{ key: 'user-groups-access', defaultLevel: 'none' },
|
||||||
|
{ key: 'user-groups-edit', defaultLevel: 'read' },
|
||||||
|
{ key: 'user-groups-delete', defaultLevel: 'none' },
|
||||||
|
{ key: 'maintenance-access', defaultLevel: 'none' },
|
||||||
|
{ key: 'maintenance-server-manage', defaultLevel: 'none' },
|
||||||
|
{ key: 'maintenance-server-delete', defaultLevel: 'none' },
|
||||||
|
{ key: 'maintenance-portainer', defaultLevel: 'none' },
|
||||||
|
{ key: 'maintenance-ssh-update', defaultLevel: 'none' },
|
||||||
|
{ key: 'maintenance-update', defaultLevel: 'none' },
|
||||||
|
{ key: 'maintenance-duplicates', defaultLevel: 'none' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const updateDefaultLevel = db.prepare(`
|
||||||
|
UPDATE permission_items
|
||||||
|
SET default_level = @default_level
|
||||||
|
WHERE key = @key AND default_level != @default_level
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
desiredDefaults.forEach(({ key, defaultLevel }) => {
|
||||||
|
updateDefaultLevel.run({ key, default_level: defaultLevel });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
ensurePermissionDefaultLevels();
|
||||||
|
|
||||||
|
db.exec(
|
||||||
|
"DELETE FROM group_permission_values WHERE group_id IN (SELECT id FROM user_groups WHERE lower(name) = 'superuser')"
|
||||||
|
);
|
||||||
|
|
||||||
db.exec(createServersTable);
|
db.exec(createServersTable);
|
||||||
console.log('✅ servers table ready');
|
console.log('✅ servers table ready');
|
||||||
|
|
||||||
|
|||||||
+510
-46
@@ -18,9 +18,9 @@ import {
|
|||||||
registerSuperuser,
|
registerSuperuser,
|
||||||
removeSuperuser,
|
removeSuperuser,
|
||||||
findUserByIdentifier,
|
findUserByIdentifier,
|
||||||
findUserById,
|
|
||||||
markUserLogin,
|
markUserLogin,
|
||||||
verifyPassword
|
verifyPassword,
|
||||||
|
SUPERUSER_GROUP_NAME
|
||||||
} from './auth/superuser.js';
|
} from './auth/superuser.js';
|
||||||
import {
|
import {
|
||||||
logEvent,
|
logEvent,
|
||||||
@@ -45,14 +45,46 @@ import {
|
|||||||
removeServer,
|
removeServer,
|
||||||
setServerApiKey
|
setServerApiKey
|
||||||
} from './setup/index.js';
|
} from './setup/index.js';
|
||||||
import { listUsers, getUserById, updateUserGroups, createUser, updateUserDetails, deleteUser, updateUserActiveStatus } from './users/index.js';
|
import {
|
||||||
|
listUsers,
|
||||||
|
getUserById,
|
||||||
|
updateUserGroups,
|
||||||
|
createUser,
|
||||||
|
updateUserDetails,
|
||||||
|
deleteUser,
|
||||||
|
updateUserActiveStatus,
|
||||||
|
getUserSecurityPhrase,
|
||||||
|
renewUserSecurityPhrase,
|
||||||
|
markSecurityPhraseDownloaded,
|
||||||
|
ensureSecurityPhrasesForExistingUsers,
|
||||||
|
verifySecurityPhraseForUsername,
|
||||||
|
setUserPassword
|
||||||
|
} from './users/index.js';
|
||||||
import { listGroups, createGroup, getGroupById, updateGroupDetails, deleteGroup } from './groups/index.js';
|
import { listGroups, createGroup, getGroupById, updateGroupDetails, deleteGroup } from './groups/index.js';
|
||||||
|
import {
|
||||||
|
getPermissionStructure,
|
||||||
|
getPermissionValuesByGroup,
|
||||||
|
saveGroupPermissionValues,
|
||||||
|
clearGroupPermissionValues,
|
||||||
|
getSuperuserPermissionMap,
|
||||||
|
getEffectivePermissionsForGroups,
|
||||||
|
hasRequiredPermission
|
||||||
|
} from './permissions/index.js';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
ensureSuperuserFromEnv();
|
ensureSuperuserFromEnv();
|
||||||
ensureDefaultsFromEnv();
|
ensureDefaultsFromEnv();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const initializedCount = ensureSecurityPhrasesForExistingUsers();
|
||||||
|
if (initializedCount > 0) {
|
||||||
|
console.log(`ℹ️ Sicherheitsschlüssel für ${initializedCount} bestehende Benutzer initialisiert`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('⚠️ Initialisierung der Sicherheitsschlüssel für bestehende Benutzer fehlgeschlagen:', error);
|
||||||
|
}
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
@@ -131,6 +163,8 @@ const AUTH_COOKIE_OPTIONS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const activeSessions = new Map();
|
const activeSessions = new Map();
|
||||||
|
const passwordResetTickets = new Map();
|
||||||
|
const PASSWORD_RESET_TTL_MS = 1000 * 60 * 15;
|
||||||
|
|
||||||
const PUBLIC_API_ROUTES = [
|
const PUBLIC_API_ROUTES = [
|
||||||
{ method: 'GET', matcher: /^\/api\/auth\/superuser\/status$/ },
|
{ method: 'GET', matcher: /^\/api\/auth\/superuser\/status$/ },
|
||||||
@@ -138,6 +172,8 @@ const PUBLIC_API_ROUTES = [
|
|||||||
{ method: 'POST', matcher: /^\/api\/auth\/login$/ },
|
{ method: 'POST', matcher: /^\/api\/auth\/login$/ },
|
||||||
{ method: 'POST', matcher: /^\/api\/auth\/logout$/ },
|
{ method: 'POST', matcher: /^\/api\/auth\/logout$/ },
|
||||||
{ method: 'GET', matcher: /^\/api\/auth\/session$/ },
|
{ method: 'GET', matcher: /^\/api\/auth\/session$/ },
|
||||||
|
{ method: 'POST', matcher: /^\/api\/auth\/recover\/verify$/ },
|
||||||
|
{ method: 'POST', matcher: /^\/api\/auth\/recover\/reset$/ },
|
||||||
{ method: 'GET', matcher: /^\/api\/setup\/status$/ },
|
{ method: 'GET', matcher: /^\/api\/setup\/status$/ },
|
||||||
{ method: 'POST', matcher: /^\/api\/setup\/complete$/ }
|
{ method: 'POST', matcher: /^\/api\/setup\/complete$/ }
|
||||||
];
|
];
|
||||||
@@ -149,6 +185,40 @@ const sanitizeUser = (user) => ({
|
|||||||
avatarColor: user.avatar_color || null
|
avatarColor: user.avatar_color || null
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const buildUserSessionPayload = (userId) => {
|
||||||
|
const record = getUserById(userId);
|
||||||
|
if (!record || !record.isActive) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = Array.isArray(record.groups) ? record.groups : [];
|
||||||
|
const groupIds = groups
|
||||||
|
.map((group) => Number(group.id))
|
||||||
|
.filter((groupId) => Number.isFinite(groupId) && groupId > 0);
|
||||||
|
|
||||||
|
const isSuperuser = groups.some(
|
||||||
|
(group) => typeof group?.name === 'string' && group.name.toLowerCase() === SUPERUSER_GROUP_NAME
|
||||||
|
);
|
||||||
|
|
||||||
|
const permissions = isSuperuser
|
||||||
|
? getSuperuserPermissionMap()
|
||||||
|
: getEffectivePermissionsForGroups(groupIds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: record.id,
|
||||||
|
username: record.username,
|
||||||
|
email: record.email || null,
|
||||||
|
avatarColor: record.avatarColor || null,
|
||||||
|
groups,
|
||||||
|
isSuperuser,
|
||||||
|
securityPhraseDownloadedAt: record.securityPhraseDownloadedAt || null,
|
||||||
|
requiresSecurityPhraseDownload: !record.securityPhraseDownloadedAt
|
||||||
|
},
|
||||||
|
permissions
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const cleanupExpiredSessions = () => {
|
const cleanupExpiredSessions = () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [token, session] of activeSessions.entries()) {
|
for (const [token, session] of activeSessions.entries()) {
|
||||||
@@ -167,6 +237,49 @@ const removeSessionsForUser = (userId) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cleanupExpiredPasswordResetTickets = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [token, ticket] of passwordResetTickets.entries()) {
|
||||||
|
if (!ticket || ticket.expiresAt <= now) {
|
||||||
|
passwordResetTickets.delete(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePasswordResetTicketsForUser = (userId) => {
|
||||||
|
if (!userId) return;
|
||||||
|
for (const [token, ticket] of passwordResetTickets.entries()) {
|
||||||
|
if (ticket?.userId === userId) {
|
||||||
|
passwordResetTickets.delete(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPasswordResetTicketForUser = (userId) => {
|
||||||
|
cleanupExpiredPasswordResetTickets();
|
||||||
|
removePasswordResetTicketsForUser(userId);
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
const expiresAt = Date.now() + PASSWORD_RESET_TTL_MS;
|
||||||
|
passwordResetTickets.set(token, { userId, expiresAt });
|
||||||
|
return { token, expiresAt };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPasswordResetTicket = (token) => {
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
cleanupExpiredPasswordResetTickets();
|
||||||
|
const record = passwordResetTickets.get(token);
|
||||||
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (record.expiresAt <= Date.now()) {
|
||||||
|
passwordResetTickets.delete(token);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
};
|
||||||
|
|
||||||
const createSessionForUser = (user) => {
|
const createSessionForUser = (user) => {
|
||||||
cleanupExpiredSessions();
|
cleanupExpiredSessions();
|
||||||
removeSessionsForUser(user.id);
|
removeSessionsForUser(user.id);
|
||||||
@@ -892,6 +1005,52 @@ const maintenanceGuard = (req, res, next) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requirePermission = (permissionKey, requiredLevel = 'full') => (req, res, next) => {
|
||||||
|
if (!permissionKey) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.user?.isSuperuser) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = req.userPermissions || {};
|
||||||
|
if (hasRequiredPermission(permissions, permissionKey, requiredLevel)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'INSUFFICIENT_PERMISSIONS',
|
||||||
|
permission: permissionKey,
|
||||||
|
requiredLevel
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireAnyPermission = (candidates = []) => (req, res, next) => {
|
||||||
|
if (!Array.isArray(candidates) || candidates.length === 0) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.user?.isSuperuser) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = req.userPermissions || {};
|
||||||
|
const granted = candidates.some(({ key, level }) =>
|
||||||
|
typeof key === 'string' && hasRequiredPermission(permissions, key, level || 'full')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (granted) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'INSUFFICIENT_PERMISSIONS',
|
||||||
|
permission: candidates.map((entry) => entry.key).filter(Boolean),
|
||||||
|
requiredLevel: candidates.map((entry) => entry.level || 'full')
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const logScriptOutput = (data, level) => {
|
const logScriptOutput = (data, level) => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const text = data.toString();
|
const text = data.toString();
|
||||||
@@ -1425,14 +1584,37 @@ app.use((req, res, next) => {
|
|||||||
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = findUserById(session.userId);
|
const userRecord = getUserById(session.userId);
|
||||||
if (!user || !user.is_active) {
|
if (!userRecord || !userRecord.isActive) {
|
||||||
activeSessions.delete(token);
|
activeSessions.delete(token);
|
||||||
clearAuthCookie(res);
|
clearAuthCookie(res);
|
||||||
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
req.user = sanitizeUser(user);
|
const userGroups = Array.isArray(userRecord.groups) ? userRecord.groups : [];
|
||||||
|
const groupIds = userGroups
|
||||||
|
.map((group) => Number(group.id))
|
||||||
|
.filter((groupId) => Number.isFinite(groupId) && groupId > 0);
|
||||||
|
|
||||||
|
const isSuperuser = userGroups.some(
|
||||||
|
(group) => typeof group?.name === 'string' && group.name.toLowerCase() === SUPERUSER_GROUP_NAME
|
||||||
|
);
|
||||||
|
|
||||||
|
const permissionsMap = isSuperuser
|
||||||
|
? getSuperuserPermissionMap()
|
||||||
|
: getEffectivePermissionsForGroups(groupIds);
|
||||||
|
|
||||||
|
req.user = {
|
||||||
|
id: userRecord.id,
|
||||||
|
username: userRecord.username,
|
||||||
|
email: userRecord.email,
|
||||||
|
avatarColor: userRecord.avatarColor || null,
|
||||||
|
groups: userGroups,
|
||||||
|
isSuperuser,
|
||||||
|
securityPhraseDownloadedAt: userRecord.securityPhraseDownloadedAt || null,
|
||||||
|
requiresSecurityPhraseDownload: !userRecord.securityPhraseDownloadedAt
|
||||||
|
};
|
||||||
|
req.userPermissions = permissionsMap;
|
||||||
req.authToken = token;
|
req.authToken = token;
|
||||||
touchSession(token);
|
touchSession(token);
|
||||||
setAuthCookie(res, token);
|
setAuthCookie(res, token);
|
||||||
@@ -1618,7 +1800,7 @@ app.post('/api/setup/complete', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/setup/endpoints/:id', (req, res) => {
|
app.delete('/api/setup/endpoints/:id', requirePermission('maintenance-server-delete', 'full'), (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const numericId = Number(id);
|
const numericId = Number(id);
|
||||||
if (!Number.isFinite(numericId)) {
|
if (!Number.isFinite(numericId)) {
|
||||||
@@ -1643,7 +1825,7 @@ app.delete('/api/setup/endpoints/:id', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/setup/servers/:id', (req, res) => {
|
app.delete('/api/setup/servers/:id', requirePermission('maintenance-server-delete', 'full'), (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const numericId = Number(id);
|
const numericId = Number(id);
|
||||||
if (!Number.isFinite(numericId)) {
|
if (!Number.isFinite(numericId)) {
|
||||||
@@ -1668,7 +1850,7 @@ app.delete('/api/setup/servers/:id', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/setup/servers/:id/api-key', (req, res) => {
|
app.put('/api/setup/servers/:id/api-key', requirePermission('maintenance-server-manage', 'full'), (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const numericId = Number(id);
|
const numericId = Number(id);
|
||||||
if (!Number.isFinite(numericId)) {
|
if (!Number.isFinite(numericId)) {
|
||||||
@@ -1732,7 +1914,12 @@ app.post('/api/auth/login', (req, res) => {
|
|||||||
markUserLogin(user.id);
|
markUserLogin(user.id);
|
||||||
setAuthCookie(res, session.token);
|
setAuthCookie(res, session.token);
|
||||||
|
|
||||||
res.json({ user: sanitizeUser(user) });
|
const payload = buildUserSessionPayload(user.id);
|
||||||
|
if (payload) {
|
||||||
|
res.json(payload);
|
||||||
|
} else {
|
||||||
|
res.json({ user: sanitizeUser(user), permissions: {} });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/auth/logout', (req, res) => {
|
app.post('/api/auth/logout', (req, res) => {
|
||||||
@@ -1744,6 +1931,74 @@ app.post('/api/auth/logout', (req, res) => {
|
|||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/auth/recover/verify', (req, res) => {
|
||||||
|
const { username, phrase, words } = req.body ?? {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const candidate = typeof phrase === 'string' && phrase.trim() ? phrase : words;
|
||||||
|
const verification = verifySecurityPhraseForUsername(username, candidate);
|
||||||
|
const ticket = createPasswordResetTicketForUser(verification.userId);
|
||||||
|
res.json({ token: ticket.token, expiresIn: PASSWORD_RESET_TTL_MS });
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'USERNAME_REQUIRED' || error.code === 'PHRASE_REQUIRED') {
|
||||||
|
return res.status(400).json({ error: error.code });
|
||||||
|
}
|
||||||
|
if (error.code === 'USER_NOT_FOUND') {
|
||||||
|
return res.status(404).json({ error: 'USER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
if (error.code === 'PHRASE_NOT_INITIALIZED') {
|
||||||
|
return res.status(409).json({ error: 'PHRASE_NOT_INITIALIZED' });
|
||||||
|
}
|
||||||
|
if (error.code === 'PHRASE_MISMATCH') {
|
||||||
|
return res.status(401).json({ error: 'PHRASE_MISMATCH' });
|
||||||
|
}
|
||||||
|
if (error.code === 'INVALID_PASSWORD') {
|
||||||
|
return res.status(400).json({ error: 'INVALID_PASSWORD' });
|
||||||
|
}
|
||||||
|
console.error('⚠️ [Auth] Sicherheitsphrase-Überprüfung fehlgeschlagen:', error);
|
||||||
|
res.status(500).json({ error: 'RECOVERY_VERIFY_FAILED' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/auth/recover/reset', (req, res) => {
|
||||||
|
const { token, password, confirmPassword } = req.body ?? {};
|
||||||
|
|
||||||
|
if (!token || typeof token !== 'string' || !token.trim()) {
|
||||||
|
return res.status(400).json({ error: 'TOKEN_REQUIRED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof password !== 'string' || !password.trim()) {
|
||||||
|
return res.status(400).json({ error: 'PASSWORD_REQUIRED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof confirmPassword === 'string' && password !== confirmPassword) {
|
||||||
|
return res.status(400).json({ error: 'PASSWORD_MISMATCH' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedToken = token.trim();
|
||||||
|
const ticket = getPasswordResetTicket(trimmedToken);
|
||||||
|
if (!ticket) {
|
||||||
|
return res.status(410).json({ error: 'TOKEN_INVALID_OR_EXPIRED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUserPassword(ticket.userId, password, { resetMethod: 'security-phrase' });
|
||||||
|
passwordResetTickets.delete(trimmedToken);
|
||||||
|
removeSessionsForUser(ticket.userId);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'INVALID_PASSWORD' || error.code === 'PASSWORD_TOO_SHORT') {
|
||||||
|
return res.status(400).json({ error: error.code });
|
||||||
|
}
|
||||||
|
if (error.code === 'USER_NOT_FOUND') {
|
||||||
|
passwordResetTickets.delete(trimmedToken);
|
||||||
|
return res.status(404).json({ error: 'USER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
console.error('⚠️ [Auth] Passwort-Zurücksetzung fehlgeschlagen:', error);
|
||||||
|
res.status(500).json({ error: 'RECOVERY_RESET_FAILED' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/auth/session', (req, res) => {
|
app.get('/api/auth/session', (req, res) => {
|
||||||
if (!hasSuperuser()) {
|
if (!hasSuperuser()) {
|
||||||
return res.status(403).json({ error: 'SUPERUSER_REQUIRED' });
|
return res.status(403).json({ error: 'SUPERUSER_REQUIRED' });
|
||||||
@@ -1760,8 +2015,8 @@ app.get('/api/auth/session', (req, res) => {
|
|||||||
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = findUserById(session.userId);
|
const payload = buildUserSessionPayload(session.userId);
|
||||||
if (!user || !user.is_active) {
|
if (!payload) {
|
||||||
activeSessions.delete(token);
|
activeSessions.delete(token);
|
||||||
clearAuthCookie(res);
|
clearAuthCookie(res);
|
||||||
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
||||||
@@ -1769,10 +2024,10 @@ app.get('/api/auth/session', (req, res) => {
|
|||||||
|
|
||||||
touchSession(token);
|
touchSession(token);
|
||||||
setAuthCookie(res, token);
|
setAuthCookie(res, token);
|
||||||
res.json({ user: sanitizeUser(user) });
|
res.json(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/users', (req, res) => {
|
app.get('/api/users', requirePermission('users-access', 'read'), (req, res) => {
|
||||||
try {
|
try {
|
||||||
const users = listUsers();
|
const users = listUsers();
|
||||||
res.json({ items: users, total: users.length });
|
res.json({ items: users, total: users.length });
|
||||||
@@ -1782,11 +2037,11 @@ app.get('/api/users', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/users', (req, res) => {
|
app.post('/api/users', requirePermission('users-edit', 'full'), (req, res) => {
|
||||||
const { username, email, password, groupId, avatarColor } = req.body ?? {};
|
const { username, email, password, groupId, avatarColor } = req.body ?? {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = createUser({ username, email, password, groupId, avatarColor });
|
const user = createUser({ username, email, password, groupId, avatarColor }, { actor: req.user });
|
||||||
res.status(201).json({ item: user });
|
res.status(201).json({ item: user });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === 'USERNAME_REQUIRED' || error.code === 'INVALID_GROUP_ID') {
|
if (error.code === 'USERNAME_REQUIRED' || error.code === 'INVALID_GROUP_ID') {
|
||||||
@@ -1809,7 +2064,62 @@ app.post('/api/users', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/users/:userId', (req, res) => {
|
app.get('/api/users/me/security-phrase', (req, res) => {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const phrase = getUserSecurityPhrase(userId, { actor: req.user });
|
||||||
|
if (phrase.downloadedAt) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'SECURITY_PHRASE_ALREADY_DOWNLOADED',
|
||||||
|
downloadedAt: phrase.downloadedAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
item: {
|
||||||
|
userId: phrase.userId,
|
||||||
|
words: phrase.words,
|
||||||
|
downloadedAt: phrase.downloadedAt
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'USER_NOT_FOUND') {
|
||||||
|
return res.status(404).json({ error: 'USER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
console.error(`⚠️ [Users] Sicherheitsschlüssel konnte für Benutzer ${userId} nicht geladen werden:`, error);
|
||||||
|
res.status(500).json({ error: 'USER_SECURITY_PHRASE_FETCH_FAILED' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/users/me/security-phrase/downloaded', (req, res) => {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = markSecurityPhraseDownloaded(userId, { actor: req.user });
|
||||||
|
const sessionPayload = buildUserSessionPayload(userId);
|
||||||
|
res.json({
|
||||||
|
item: {
|
||||||
|
userId: result.userId,
|
||||||
|
downloadedAt: result.downloadedAt
|
||||||
|
},
|
||||||
|
session: sessionPayload
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'USER_NOT_FOUND') {
|
||||||
|
return res.status(404).json({ error: 'USER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
console.error(`⚠️ [Users] Sicherheits-Download konnte nicht vermerkt werden (${userId}):`, error);
|
||||||
|
res.status(500).json({ error: 'USER_SECURITY_PHRASE_DOWNLOAD_FAILED' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/users/:userId', requirePermission('users-access', 'read'), (req, res) => {
|
||||||
const { userId } = req.params;
|
const { userId } = req.params;
|
||||||
const numericId = Number(userId);
|
const numericId = Number(userId);
|
||||||
|
|
||||||
@@ -1829,7 +2139,53 @@ app.get('/api/users/:userId', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/users/:userId', (req, res) => {
|
app.get('/api/users/:userId/security-phrase', requirePermission('users-security-phrase', 'read'), (req, res) => {
|
||||||
|
const { userId } = req.params;
|
||||||
|
const numericId = Number(userId);
|
||||||
|
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||||
|
return res.status(400).json({ error: 'INVALID_USER_ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const phrase = getUserSecurityPhrase(numericId, { actor: req.user });
|
||||||
|
res.json({ item: phrase });
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'INVALID_USER_ID') {
|
||||||
|
return res.status(400).json({ error: 'INVALID_USER_ID' });
|
||||||
|
}
|
||||||
|
if (error.code === 'USER_NOT_FOUND') {
|
||||||
|
return res.status(404).json({ error: 'USER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
console.error(`⚠️ [Users] Sicherheitsschlüssel konnte nicht geladen werden (${userId}):`, error);
|
||||||
|
res.status(500).json({ error: 'USER_SECURITY_PHRASE_FETCH_FAILED' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/users/:userId/security-phrase/renew', requirePermission('users-security-phrase', 'full'), (req, res) => {
|
||||||
|
const { userId } = req.params;
|
||||||
|
const numericId = Number(userId);
|
||||||
|
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||||
|
return res.status(400).json({ error: 'INVALID_USER_ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const renewed = renewUserSecurityPhrase(numericId, { actor: req.user });
|
||||||
|
res.json({ item: renewed });
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'INVALID_USER_ID') {
|
||||||
|
return res.status(400).json({ error: 'INVALID_USER_ID' });
|
||||||
|
}
|
||||||
|
if (error.code === 'USER_NOT_FOUND') {
|
||||||
|
return res.status(404).json({ error: 'USER_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
console.error(`⚠️ [Users] Sicherheitsschlüssel konnte nicht erneuert werden (${userId}):`, error);
|
||||||
|
res.status(500).json({ error: 'USER_SECURITY_PHRASE_RENEW_FAILED' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/users/:userId', requirePermission('users-edit', 'full'), (req, res) => {
|
||||||
const { userId } = req.params;
|
const { userId } = req.params;
|
||||||
const numericId = Number(userId);
|
const numericId = Number(userId);
|
||||||
|
|
||||||
@@ -1886,7 +2242,7 @@ app.put('/api/users/:userId', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/users/:userId/groups', (req, res) => {
|
app.put('/api/users/:userId/groups', requirePermission('users-edit', 'full'), (req, res) => {
|
||||||
const { userId } = req.params;
|
const { userId } = req.params;
|
||||||
const numericId = Number(userId);
|
const numericId = Number(userId);
|
||||||
const { groupIds } = req.body ?? {};
|
const { groupIds } = req.body ?? {};
|
||||||
@@ -1913,7 +2269,7 @@ app.put('/api/users/:userId/groups', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/users/:userId', (req, res) => {
|
app.delete('/api/users/:userId', requirePermission('users-delete', 'full'), (req, res) => {
|
||||||
const { userId } = req.params;
|
const { userId } = req.params;
|
||||||
const numericId = Number(userId);
|
const numericId = Number(userId);
|
||||||
|
|
||||||
@@ -1940,7 +2296,7 @@ app.delete('/api/users/:userId', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/users/:userId/active', (req, res) => {
|
app.put('/api/users/:userId/active', requirePermission('users-edit', 'full'), (req, res) => {
|
||||||
const { userId } = req.params;
|
const { userId } = req.params;
|
||||||
const numericId = Number(userId);
|
const numericId = Number(userId);
|
||||||
const { isActive } = req.body ?? {};
|
const { isActive } = req.body ?? {};
|
||||||
@@ -1968,7 +2324,13 @@ app.put('/api/users/:userId/active', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/groups', (req, res) => {
|
app.get(
|
||||||
|
'/api/groups',
|
||||||
|
requireAnyPermission([
|
||||||
|
{ key: 'user-groups-access', level: 'read' },
|
||||||
|
{ key: 'users-edit', level: 'read' }
|
||||||
|
]),
|
||||||
|
(req, res) => {
|
||||||
try {
|
try {
|
||||||
const groups = listGroups();
|
const groups = listGroups();
|
||||||
res.json({ items: groups, total: groups.length });
|
res.json({ items: groups, total: groups.length });
|
||||||
@@ -1976,9 +2338,16 @@ app.get('/api/groups', (req, res) => {
|
|||||||
console.error('⚠️ [Groups] Abruf der Benutzergruppenliste fehlgeschlagen:', error);
|
console.error('⚠️ [Groups] Abruf der Benutzergruppenliste fehlgeschlagen:', error);
|
||||||
res.status(500).json({ error: 'GROUPS_FETCH_FAILED' });
|
res.status(500).json({ error: 'GROUPS_FETCH_FAILED' });
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
app.get('/api/groups/:groupId', (req, res) => {
|
app.get(
|
||||||
|
'/api/groups/:groupId',
|
||||||
|
requireAnyPermission([
|
||||||
|
{ key: 'user-groups-access', level: 'read' },
|
||||||
|
{ key: 'users-edit', level: 'read' }
|
||||||
|
]),
|
||||||
|
(req, res) => {
|
||||||
const { groupId } = req.params;
|
const { groupId } = req.params;
|
||||||
const numericId = Number(groupId);
|
const numericId = Number(groupId);
|
||||||
|
|
||||||
@@ -1996,9 +2365,88 @@ app.get('/api/groups/:groupId', (req, res) => {
|
|||||||
console.error(`⚠️ [Groups] Abruf der Gruppendetails fehlgeschlagen (${groupId}):`, error);
|
console.error(`⚠️ [Groups] Abruf der Gruppendetails fehlgeschlagen (${groupId}):`, error);
|
||||||
res.status(500).json({ error: 'GROUP_FETCH_FAILED' });
|
res.status(500).json({ error: 'GROUP_FETCH_FAILED' });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get('/api/groups/:groupId/permissions', requirePermission('user-groups-edit', 'read'), (req, res) => {
|
||||||
|
const { groupId } = req.params;
|
||||||
|
const numericId = Number(groupId);
|
||||||
|
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||||
|
return res.status(400).json({ error: 'INVALID_GROUP_ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const group = getGroupById(numericId);
|
||||||
|
if (!group) {
|
||||||
|
return res.status(404).json({ error: 'GROUP_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = getPermissionStructure();
|
||||||
|
const isSuperuser = (group.name || '').toLowerCase() === SUPERUSER_GROUP_NAME;
|
||||||
|
let values = {};
|
||||||
|
|
||||||
|
if (isSuperuser) {
|
||||||
|
clearGroupPermissionValues(numericId);
|
||||||
|
} else {
|
||||||
|
values = getPermissionValuesByGroup(numericId);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ sections, values });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`⚠️ [Groups] Abruf der Gruppenberechtigungen fehlgeschlagen (${groupId}):`, error);
|
||||||
|
res.status(500).json({ error: 'GROUP_PERMISSIONS_FETCH_FAILED' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/groups', (req, res) => {
|
app.put('/api/groups/:groupId/permissions', requirePermission('user-groups-edit', 'full'), (req, res) => {
|
||||||
|
const { groupId } = req.params;
|
||||||
|
const numericId = Number(groupId);
|
||||||
|
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||||
|
return res.status(400).json({ error: 'INVALID_GROUP_ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const group = getGroupById(numericId);
|
||||||
|
if (!group) {
|
||||||
|
return res.status(404).json({ error: 'GROUP_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSuperuser = (group.name || '').toLowerCase() === SUPERUSER_GROUP_NAME;
|
||||||
|
if (isSuperuser) {
|
||||||
|
clearGroupPermissionValues(numericId);
|
||||||
|
return res.status(403).json({ error: 'GROUP_SUPERUSER_PROTECTED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const valuesPayload = req.body?.values ?? {};
|
||||||
|
const savedValues = saveGroupPermissionValues(numericId, valuesPayload);
|
||||||
|
|
||||||
|
res.json({ success: true, values: savedValues });
|
||||||
|
} catch (error) {
|
||||||
|
const serverError = error?.code;
|
||||||
|
if (serverError === 'GROUP_NOT_FOUND') {
|
||||||
|
return res.status(404).json({ error: 'GROUP_NOT_FOUND' });
|
||||||
|
}
|
||||||
|
if (serverError === 'PERMISSION_INVALID_PAYLOAD') {
|
||||||
|
return res.status(400).json({ error: 'PERMISSION_INVALID_PAYLOAD' });
|
||||||
|
}
|
||||||
|
if (serverError === 'PERMISSION_INVALID_LEVEL') {
|
||||||
|
return res.status(400).json({ error: 'PERMISSION_INVALID_LEVEL', permissionKey: error.permissionKey, level: error.level });
|
||||||
|
}
|
||||||
|
if (serverError === 'PERMISSION_UNKNOWN_KEY') {
|
||||||
|
return res.status(400).json({ error: 'PERMISSION_UNKNOWN_KEY', permissionKey: error.permissionKey });
|
||||||
|
}
|
||||||
|
if (serverError === 'INVALID_GROUP_ID') {
|
||||||
|
return res.status(400).json({ error: 'INVALID_GROUP_ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`⚠️ [Groups] Aktualisierung der Berechtigungen fehlgeschlagen (${groupId}):`, error);
|
||||||
|
res.status(500).json({ error: 'GROUP_PERMISSIONS_UPDATE_FAILED' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/groups', requirePermission('user-groups-edit', 'full'), (req, res) => {
|
||||||
const { name, description } = req.body ?? {};
|
const { name, description } = req.body ?? {};
|
||||||
try {
|
try {
|
||||||
const group = createGroup({ name, description });
|
const group = createGroup({ name, description });
|
||||||
@@ -2015,7 +2463,7 @@ app.post('/api/groups', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/groups/:groupId', (req, res) => {
|
app.put('/api/groups/:groupId', requirePermission('user-groups-edit', 'full'), (req, res) => {
|
||||||
const { groupId } = req.params;
|
const { groupId } = req.params;
|
||||||
const numericId = Number(groupId);
|
const numericId = Number(groupId);
|
||||||
|
|
||||||
@@ -2053,7 +2501,7 @@ app.put('/api/groups/:groupId', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/groups/:groupId', (req, res) => {
|
app.delete('/api/groups/:groupId', requirePermission('user-groups-delete', 'full'), (req, res) => {
|
||||||
const { groupId } = req.params;
|
const { groupId } = req.params;
|
||||||
const numericId = Number(groupId);
|
const numericId = Number(groupId);
|
||||||
|
|
||||||
@@ -2197,7 +2645,7 @@ app.get('/api/stacks', maintenanceGuard, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/maintenance/portainer-status', async (req, res) => {
|
app.get('/api/maintenance/portainer-status', requirePermission('maintenance-portainer', 'read'), async (req, res) => {
|
||||||
console.log("🧭 [Maintenance] GET /api/maintenance/portainer-status: Prüfung gestartet");
|
console.log("🧭 [Maintenance] GET /api/maintenance/portainer-status: Prüfung gestartet");
|
||||||
try {
|
try {
|
||||||
const payload = await fetchPortainerStatusSummary();
|
const payload = await fetchPortainerStatusSummary();
|
||||||
@@ -2212,7 +2660,7 @@ app.get('/api/maintenance/portainer-status', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/maintenance/mode', (req, res) => {
|
app.post('/api/maintenance/mode', requirePermission('maintenance-access', 'full'), (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { active, message } = req.body ?? {};
|
const { active, message } = req.body ?? {};
|
||||||
const normalizedMessage = typeof message === 'string' && message.trim() ? message.trim() : null;
|
const normalizedMessage = typeof message === 'string' && message.trim() ? message.trim() : null;
|
||||||
@@ -2231,7 +2679,7 @@ app.post('/api/maintenance/mode', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/maintenance/config', (req, res) => {
|
app.get('/api/maintenance/config', requirePermission('maintenance-server-manage', 'read'), (req, res) => {
|
||||||
const custom = getCustomPortainerScript();
|
const custom = getCustomPortainerScript();
|
||||||
const effective = getEffectivePortainerScript();
|
const effective = getEffectivePortainerScript();
|
||||||
const ssh = getPortainerSshConfig();
|
const ssh = getPortainerSshConfig();
|
||||||
@@ -2257,7 +2705,7 @@ app.get('/api/maintenance/config', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/maintenance/ssh-config', (req, res) => {
|
app.put('/api/maintenance/ssh-config', requirePermission('maintenance-ssh-update', 'full'), (req, res) => {
|
||||||
try {
|
try {
|
||||||
const config = savePortainerSshConfig(req.body || {});
|
const config = savePortainerSshConfig(req.body || {});
|
||||||
res.json({
|
res.json({
|
||||||
@@ -2276,7 +2724,7 @@ app.put('/api/maintenance/ssh-config', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/maintenance/ssh-config', (req, res) => {
|
app.delete('/api/maintenance/ssh-config', requirePermission('maintenance-ssh-update', 'full'), (req, res) => {
|
||||||
const config = deletePortainerSshConfig();
|
const config = deletePortainerSshConfig();
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -2290,7 +2738,7 @@ app.delete('/api/maintenance/ssh-config', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/maintenance/test-ssh', async (req, res) => {
|
app.post('/api/maintenance/test-ssh', requirePermission('maintenance-ssh-update', 'full'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const override = req.body && Object.keys(req.body).length ? req.body : null;
|
const override = req.body && Object.keys(req.body).length ? req.body : null;
|
||||||
const result = await testSshConnection(override);
|
const result = await testSshConnection(override);
|
||||||
@@ -2301,7 +2749,7 @@ app.post('/api/maintenance/test-ssh', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/maintenance/update-script', (req, res) => {
|
app.put('/api/maintenance/update-script', requirePermission('maintenance-ssh-update', 'full'), (req, res) => {
|
||||||
if (portainerUpdateState.running) {
|
if (portainerUpdateState.running) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
error: 'Aktualisierung läuft. Skript kann derzeit nicht geändert werden.',
|
error: 'Aktualisierung läuft. Skript kann derzeit nicht geändert werden.',
|
||||||
@@ -2332,7 +2780,7 @@ app.put('/api/maintenance/update-script', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/maintenance/update-script', (req, res) => {
|
app.delete('/api/maintenance/update-script', requirePermission('maintenance-ssh-update', 'full'), (req, res) => {
|
||||||
if (portainerUpdateState.running) {
|
if (portainerUpdateState.running) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
error: 'Aktualisierung läuft. Skript kann derzeit nicht geändert werden.',
|
error: 'Aktualisierung läuft. Skript kann derzeit nicht geändert werden.',
|
||||||
@@ -2356,14 +2804,14 @@ app.delete('/api/maintenance/update-script', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/maintenance/update-status', (req, res) => {
|
app.get('/api/maintenance/update-status', requirePermission('maintenance-update', 'read'), (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
maintenance: getMaintenanceState(),
|
maintenance: getMaintenanceState(),
|
||||||
update: getPortainerUpdateStatus()
|
update: getPortainerUpdateStatus()
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/maintenance/portainer-update', async (req, res) => {
|
app.post('/api/maintenance/portainer-update', requirePermission('maintenance-update', 'full'), async (req, res) => {
|
||||||
if (portainerUpdateState.running) {
|
if (portainerUpdateState.running) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
error: 'Ein Portainer-Update läuft bereits.',
|
error: 'Ein Portainer-Update läuft bereits.',
|
||||||
@@ -2429,7 +2877,11 @@ app.post('/api/maintenance/portainer-update', async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/maintenance/duplicates', maintenanceGuard, async (req, res) => {
|
app.get(
|
||||||
|
'/api/maintenance/duplicates',
|
||||||
|
maintenanceGuard,
|
||||||
|
requirePermission('maintenance-duplicates', 'read'),
|
||||||
|
async (req, res) => {
|
||||||
console.log("🧹 [Maintenance] GET /api/maintenance/duplicates: Abruf gestartet");
|
console.log("🧹 [Maintenance] GET /api/maintenance/duplicates: Abruf gestartet");
|
||||||
try {
|
try {
|
||||||
const { duplicates } = await loadStackCollections();
|
const { duplicates } = await loadStackCollections();
|
||||||
@@ -2464,7 +2916,11 @@ app.get('/api/maintenance/duplicates', maintenanceGuard, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/maintenance/duplicates/cleanup', maintenanceGuard, async (req, res) => {
|
app.post(
|
||||||
|
'/api/maintenance/duplicates/cleanup',
|
||||||
|
maintenanceGuard,
|
||||||
|
requirePermission('maintenance-duplicates', 'full'),
|
||||||
|
async (req, res) => {
|
||||||
const canonicalId = req.body?.canonicalId;
|
const canonicalId = req.body?.canonicalId;
|
||||||
const duplicateIdsInput = Array.isArray(req.body?.duplicateIds) ? req.body.duplicateIds : [];
|
const duplicateIdsInput = Array.isArray(req.body?.duplicateIds) ? req.body.duplicateIds : [];
|
||||||
const duplicateIds = duplicateIdsInput
|
const duplicateIds = duplicateIdsInput
|
||||||
@@ -2592,7 +3048,7 @@ app.post('/api/maintenance/duplicates/cleanup', maintenanceGuard, async (req, re
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Event-Logs abrufen
|
// Event-Logs abrufen
|
||||||
app.get('/api/logs', (req, res) => {
|
app.get('/api/logs', requirePermission('logs-access', 'read'), (req, res) => {
|
||||||
const perPageParam = req.query.perPage ?? req.query.limit;
|
const perPageParam = req.query.perPage ?? req.query.limit;
|
||||||
const perPage = perPageParam === 'all' ? 'all' : Math.min(parseInt(perPageParam, 10) || 50, 500);
|
const perPage = perPageParam === 'all' ? 'all' : Math.min(parseInt(perPageParam, 10) || 50, 500);
|
||||||
const page = Math.max(parseInt(req.query.page, 10) || 1, 1);
|
const page = Math.max(parseInt(req.query.page, 10) || 1, 1);
|
||||||
@@ -2677,7 +3133,7 @@ app.get('/api/logs', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/logs/:id', (req, res) => {
|
app.delete('/api/logs/:id', requirePermission('logs-delete', 'full'), (req, res) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
if (Number.isNaN(id)) {
|
if (Number.isNaN(id)) {
|
||||||
return res.status(400).json({ error: 'Ungültige ID' });
|
return res.status(400).json({ error: 'Ungültige ID' });
|
||||||
@@ -2695,7 +3151,7 @@ app.delete('/api/logs/:id', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/logs', (req, res) => {
|
app.delete('/api/logs', requirePermission('logs-delete', 'full'), (req, res) => {
|
||||||
try {
|
try {
|
||||||
const deleted = deleteEventLogsByFilters(req.query);
|
const deleted = deleteEventLogsByFilters(req.query);
|
||||||
res.json({ success: true, deleted });
|
res.json({ success: true, deleted });
|
||||||
@@ -2705,7 +3161,7 @@ app.delete('/api/logs', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/logs/export', (req, res) => {
|
app.get('/api/logs/export', requirePermission('logs-export', 'full'), (req, res) => {
|
||||||
const format = (req.query.format || 'txt').toLowerCase();
|
const format = (req.query.format || 'txt').toLowerCase();
|
||||||
if (!['txt', 'sql'].includes(format)) {
|
if (!['txt', 'sql'].includes(format)) {
|
||||||
return res.status(400).json({ error: 'Ungültiges Export-Format' });
|
return res.status(400).json({ error: 'Ungültiges Export-Format' });
|
||||||
@@ -2723,7 +3179,7 @@ app.get('/api/logs/export', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Einzel-Redeploy
|
// Einzel-Redeploy
|
||||||
app.put('/api/stacks/:id/redeploy', async (req, res) => {
|
app.put('/api/stacks/:id/redeploy', requirePermission('stacks-redeploy-single', 'full'), async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
console.log(`🔄 PUT /api/stacks/${id}/redeploy: Redeploy gestartet`);
|
console.log(`🔄 PUT /api/stacks/${id}/redeploy: Redeploy gestartet`);
|
||||||
|
|
||||||
@@ -2739,7 +3195,11 @@ app.put('/api/stacks/:id/redeploy', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Redeploy ALL
|
// Redeploy ALL
|
||||||
app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => {
|
app.put(
|
||||||
|
'/api/stacks/redeploy-all',
|
||||||
|
maintenanceGuard,
|
||||||
|
requirePermission('stacks-redeploy-all', 'full'),
|
||||||
|
async (req, res) => {
|
||||||
console.log(`🚀 PUT /api/stacks/redeploy-all: Redeploy ALL gestartet`);
|
console.log(`🚀 PUT /api/stacks/redeploy-all: Redeploy ALL gestartet`);
|
||||||
|
|
||||||
let endpointId;
|
let endpointId;
|
||||||
@@ -2840,7 +3300,11 @@ app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Redeploy selection
|
// Redeploy selection
|
||||||
app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) => {
|
app.put(
|
||||||
|
'/api/stacks/redeploy-selection',
|
||||||
|
maintenanceGuard,
|
||||||
|
requirePermission('stacks-redeploy-selection', 'full'),
|
||||||
|
async (req, res) => {
|
||||||
const { stackIds } = req.body || {};
|
const { stackIds } = req.body || {};
|
||||||
const totalCount = Array.isArray(stackIds) ? stackIds.length : 0;
|
const totalCount = Array.isArray(stackIds) ? stackIds.length : 0;
|
||||||
console.log(`🚀 PUT /api/stacks/redeploy-selection: Redeploy Auswahl gestartet (${totalCount} Stacks)`);
|
console.log(`🚀 PUT /api/stacks/redeploy-selection: Redeploy Auswahl gestartet (${totalCount} Stacks)`);
|
||||||
|
|||||||
Generated
+32
@@ -8,6 +8,7 @@
|
|||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@scure/bip39": "^2.0.0",
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
"better-sqlite3": "^9.6.0",
|
"better-sqlite3": "^9.6.0",
|
||||||
"dockerode": "^4.0.7",
|
"dockerode": "^4.0.7",
|
||||||
@@ -59,6 +60,17 @@
|
|||||||
"url": "https://opencollective.com/js-sdsl"
|
"url": "https://opencollective.com/js-sdsl"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/hashes": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@protobufjs/aspromise": {
|
"node_modules/@protobufjs/aspromise": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||||
@@ -113,6 +125,26 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
|
||||||
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="
|
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@scure/base": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@scure/bip39": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": "2.0.1",
|
||||||
|
"@scure/base": "2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@socket.io/component-emitter": {
|
"node_modules/@socket.io/component-emitter": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"dockerode": "^4.0.7",
|
"dockerode": "^4.0.7",
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0",
|
||||||
"express": "^4.18.0",
|
"express": "^4.18.0",
|
||||||
"socket.io": "^4.8.1"
|
"socket.io": "^4.8.1",
|
||||||
|
"@scure/bip39": "^2.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import { db } from '../db/index.js';
|
||||||
|
|
||||||
|
const LEVEL_PRIORITY = {
|
||||||
|
none: 0,
|
||||||
|
read: 1,
|
||||||
|
full: 2
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectSections = db.prepare(`
|
||||||
|
SELECT id, key, title, sort_order, has_navigation_flag
|
||||||
|
FROM permission_sections
|
||||||
|
ORDER BY sort_order ASC, id ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectGroups = db.prepare(`
|
||||||
|
SELECT id, section_id, key, title, sort_order
|
||||||
|
FROM permission_groups
|
||||||
|
ORDER BY section_id ASC, sort_order ASC, id ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectItems = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
section_id,
|
||||||
|
group_id,
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
sort_order,
|
||||||
|
default_level,
|
||||||
|
available_levels,
|
||||||
|
is_required
|
||||||
|
FROM permission_items
|
||||||
|
ORDER BY section_id ASC, COALESCE(group_id, 0) ASC, sort_order ASC, id ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectDependencies = db.prepare(`
|
||||||
|
SELECT permission_id, depends_on_permission_id, required_level
|
||||||
|
FROM permission_dependencies
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectPermissionItemsForMap = db.prepare(`
|
||||||
|
SELECT id, key, available_levels, default_level
|
||||||
|
FROM permission_items
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectGroupPermissionValues = db.prepare(`
|
||||||
|
SELECT gp.permission_id, gp.level, gp.effective_level, pi.key
|
||||||
|
FROM group_permission_values gp
|
||||||
|
JOIN permission_items pi ON pi.id = gp.permission_id
|
||||||
|
WHERE gp.group_id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const deleteGroupPermissionValuesStmt = db.prepare(`
|
||||||
|
DELETE FROM group_permission_values
|
||||||
|
WHERE group_id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const insertGroupPermissionValueStmt = db.prepare(`
|
||||||
|
INSERT INTO group_permission_values (group_id, permission_id, level, effective_level, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const parseAvailableLevels = (raw) => {
|
||||||
|
if (typeof raw !== 'string' || !raw.trim()) {
|
||||||
|
return ['full', 'read', 'none'];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === 'string')) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Konnte available_levels nicht parsen:', error.message);
|
||||||
|
}
|
||||||
|
return ['full', 'read', 'none'];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLevelPriority = (level) => {
|
||||||
|
if (!level) return 0;
|
||||||
|
return LEVEL_PRIORITY[level] ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
let cachedPermissionItems = null;
|
||||||
|
|
||||||
|
const getPermissionItems = () => {
|
||||||
|
if (!cachedPermissionItems) {
|
||||||
|
cachedPermissionItems = selectPermissionItemsForMap.all();
|
||||||
|
}
|
||||||
|
return cachedPermissionItems;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetPermissionItemsCache = () => {
|
||||||
|
cachedPermissionItems = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getPermissionStructure() {
|
||||||
|
const sections = selectSections.all();
|
||||||
|
const groups = selectGroups.all();
|
||||||
|
const items = selectItems.all();
|
||||||
|
const dependencies = selectDependencies.all();
|
||||||
|
|
||||||
|
const itemIdToKey = new Map(items.map((item) => [item.id, item.key]));
|
||||||
|
|
||||||
|
const dependenciesByItemId = new Map();
|
||||||
|
dependencies.forEach((dependency) => {
|
||||||
|
const list = dependenciesByItemId.get(dependency.permission_id) || [];
|
||||||
|
list.push(dependency);
|
||||||
|
dependenciesByItemId.set(dependency.permission_id, list);
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupsBySectionId = new Map();
|
||||||
|
groups.forEach((group) => {
|
||||||
|
const list = groupsBySectionId.get(group.section_id) || [];
|
||||||
|
list.push(group);
|
||||||
|
groupsBySectionId.set(group.section_id, list);
|
||||||
|
});
|
||||||
|
|
||||||
|
const itemsBySectionId = new Map();
|
||||||
|
const itemsByGroupId = new Map();
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item.group_id) {
|
||||||
|
const list = itemsByGroupId.get(item.group_id) || [];
|
||||||
|
list.push(item);
|
||||||
|
itemsByGroupId.set(item.group_id, list);
|
||||||
|
} else {
|
||||||
|
const list = itemsBySectionId.get(item.section_id) || [];
|
||||||
|
list.push(item);
|
||||||
|
itemsBySectionId.set(item.section_id, list);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatItem = (item) => {
|
||||||
|
const availableLevels = parseAvailableLevels(item.available_levels);
|
||||||
|
|
||||||
|
const dependencyEntries = dependenciesByItemId.get(item.id) || [];
|
||||||
|
const formattedDependencies = dependencyEntries
|
||||||
|
.map((dependency) => {
|
||||||
|
const dependsOnKey = itemIdToKey.get(dependency.depends_on_permission_id);
|
||||||
|
if (!dependsOnKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: dependsOnKey,
|
||||||
|
requiredLevel: dependency.required_level || null
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: item.key,
|
||||||
|
label: item.label,
|
||||||
|
sortOrder: item.sort_order ?? 0,
|
||||||
|
defaultLevel: item.default_level || 'none',
|
||||||
|
availableLevels,
|
||||||
|
isRequired: Boolean(item.is_required),
|
||||||
|
dependencies: formattedDependencies
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const formattedSections = sections.map((section) => {
|
||||||
|
const sectionItems = (itemsBySectionId.get(section.id) || []).map(formatItem);
|
||||||
|
const sectionGroups = (groupsBySectionId.get(section.id) || []).map((group) => ({
|
||||||
|
key: group.key,
|
||||||
|
title: group.title,
|
||||||
|
sortOrder: group.sort_order ?? 0,
|
||||||
|
items: (itemsByGroupId.get(group.id) || []).map(formatItem)
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: section.key,
|
||||||
|
title: section.title,
|
||||||
|
sortOrder: section.sort_order ?? 0,
|
||||||
|
hasNavigation: Boolean(section.has_navigation_flag),
|
||||||
|
items: sectionItems,
|
||||||
|
groups: sectionGroups
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return formattedSections;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPermissionValuesByGroup(groupId) {
|
||||||
|
const numericId = Number(groupId);
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = selectGroupPermissionValues.all(numericId);
|
||||||
|
const values = {};
|
||||||
|
rows.forEach((row) => {
|
||||||
|
if (row?.key && typeof row.level === 'string') {
|
||||||
|
values[row.key] = row.level;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearGroupPermissionValues(groupId) {
|
||||||
|
const numericId = Number(groupId);
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteGroupPermissionValuesStmt.run(numericId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveGroupPermissionValues(groupId, values = {}) {
|
||||||
|
const numericId = Number(groupId);
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||||
|
const error = new Error('INVALID_GROUP_ID');
|
||||||
|
error.code = 'INVALID_GROUP_ID';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values === null || typeof values !== 'object' || Array.isArray(values)) {
|
||||||
|
const error = new Error('PERMISSION_INVALID_PAYLOAD');
|
||||||
|
error.code = 'PERMISSION_INVALID_PAYLOAD';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = getPermissionItems();
|
||||||
|
const itemMap = new Map();
|
||||||
|
items.forEach((item) => {
|
||||||
|
itemMap.set(item.key, {
|
||||||
|
id: item.id,
|
||||||
|
key: item.key,
|
||||||
|
defaultLevel: item.default_level || 'none',
|
||||||
|
availableLevels: parseAvailableLevels(item.available_levels)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const entries = Object.entries(values);
|
||||||
|
const normalizedEntries = [];
|
||||||
|
|
||||||
|
entries.forEach(([key, rawValue]) => {
|
||||||
|
const item = itemMap.get(key);
|
||||||
|
if (!item) {
|
||||||
|
const error = new Error('PERMISSION_UNKNOWN_KEY');
|
||||||
|
error.code = 'PERMISSION_UNKNOWN_KEY';
|
||||||
|
error.permissionKey = key;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let level = typeof rawValue === 'string' ? rawValue.trim() : String(rawValue ?? '').trim();
|
||||||
|
if (!level) {
|
||||||
|
level = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.availableLevels.includes(level)) {
|
||||||
|
const error = new Error('PERMISSION_INVALID_LEVEL');
|
||||||
|
error.code = 'PERMISSION_INVALID_LEVEL';
|
||||||
|
error.permissionKey = key;
|
||||||
|
error.level = level;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedEntries.push({ item, level });
|
||||||
|
});
|
||||||
|
|
||||||
|
const runSave = db.transaction(() => {
|
||||||
|
deleteGroupPermissionValuesStmt.run(numericId);
|
||||||
|
normalizedEntries.forEach(({ item, level }) => {
|
||||||
|
if (level === (item.defaultLevel || 'none')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
insertGroupPermissionValueStmt.run(numericId, item.id, level, level);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
runSave();
|
||||||
|
resetPermissionItemsCache();
|
||||||
|
|
||||||
|
return getPermissionValuesByGroup(numericId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultPermissionMap() {
|
||||||
|
const items = getPermissionItems();
|
||||||
|
const defaults = {};
|
||||||
|
items.forEach((item) => {
|
||||||
|
defaults[item.key] = item.default_level || 'none';
|
||||||
|
});
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSuperuserPermissionMap() {
|
||||||
|
const items = getPermissionItems();
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
const available = parseAvailableLevels(item.available_levels);
|
||||||
|
let chosen = 'full';
|
||||||
|
if (!available.includes('full')) {
|
||||||
|
chosen = available.reduce(
|
||||||
|
(best, candidate) =>
|
||||||
|
getLevelPriority(candidate) > getLevelPriority(best) ? candidate : best,
|
||||||
|
'none'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result[item.key] = chosen;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEffectivePermissionsForGroup(groupId) {
|
||||||
|
const defaults = getDefaultPermissionMap();
|
||||||
|
const numericId = Number(groupId);
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrides = getPermissionValuesByGroup(numericId);
|
||||||
|
const map = { ...defaults };
|
||||||
|
|
||||||
|
Object.keys(overrides).forEach((key) => {
|
||||||
|
const value = overrides[key];
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
map[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEffectivePermissionsForGroups(groupIds = []) {
|
||||||
|
const defaults = getDefaultPermissionMap();
|
||||||
|
const finalMap = { ...defaults };
|
||||||
|
|
||||||
|
const iterableIds = Array.isArray(groupIds) ? groupIds : [];
|
||||||
|
iterableIds
|
||||||
|
.map((value) => Number(value))
|
||||||
|
.filter((value) => Number.isFinite(value) && value > 0)
|
||||||
|
.forEach((groupId) => {
|
||||||
|
const map = getEffectivePermissionsForGroup(groupId);
|
||||||
|
Object.entries(map).forEach(([key, level]) => {
|
||||||
|
if (getLevelPriority(level) > getLevelPriority(finalMap[key] ?? 'none')) {
|
||||||
|
finalMap[key] = level;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return finalMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasRequiredPermission(permissions = {}, permissionKey, requiredLevel = 'full') {
|
||||||
|
if (!permissionKey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (requiredLevel === 'none' || requiredLevel === null || requiredLevel === undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const current = typeof permissions[permissionKey] === 'string' ? permissions[permissionKey] : 'none';
|
||||||
|
const required = typeof requiredLevel === 'string' ? requiredLevel : 'full';
|
||||||
|
return getLevelPriority(current) >= getLevelPriority(required);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -34,8 +34,8 @@
|
|||||||
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
|
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
|
||||||
|
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/index-8d2b4f50.js"></script>
|
<script type="module" crossorigin src="/assets/index-f91011a0.js"></script>
|
||||||
<link rel="stylesheet" href="/assets/index-11780ccd.css">
|
<link rel="stylesheet" href="/assets/index-f6cd1c87.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+385
-6
@@ -1,5 +1,12 @@
|
|||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { logEvent } from '../logging/eventLogs.js';
|
import { logEvent } from '../logging/eventLogs.js';
|
||||||
|
import {
|
||||||
|
generateSecurityPhraseWords,
|
||||||
|
encryptSecurityPhrase,
|
||||||
|
decryptSecurityPhrase,
|
||||||
|
canonicalizeWords,
|
||||||
|
canonicalizePhraseInput
|
||||||
|
} from './securityPhrase.js';
|
||||||
import {
|
import {
|
||||||
hashPassword,
|
hashPassword,
|
||||||
normalizeAvatarColor,
|
normalizeAvatarColor,
|
||||||
@@ -18,6 +25,7 @@ const selectUsersWithGroups = db.prepare(`
|
|||||||
u.last_login,
|
u.last_login,
|
||||||
u.created_at,
|
u.created_at,
|
||||||
u.updated_at,
|
u.updated_at,
|
||||||
|
u.security_phrase_downloaded_at,
|
||||||
GROUP_CONCAT(g.id || '::' || g.name, '|||') AS group_pairs
|
GROUP_CONCAT(g.id || '::' || g.name, '|||') AS group_pairs
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN user_group_memberships m ON m.user_id = u.id
|
LEFT JOIN user_group_memberships m ON m.user_id = u.id
|
||||||
@@ -36,6 +44,10 @@ const selectUserWithGroupsById = db.prepare(`
|
|||||||
u.last_login,
|
u.last_login,
|
||||||
u.created_at,
|
u.created_at,
|
||||||
u.updated_at,
|
u.updated_at,
|
||||||
|
u.security_phrase_content,
|
||||||
|
u.security_phrase_iv,
|
||||||
|
u.security_phrase_tag,
|
||||||
|
u.security_phrase_downloaded_at,
|
||||||
GROUP_CONCAT(g.id || '::' || g.name, '|||') AS group_pairs
|
GROUP_CONCAT(g.id || '::' || g.name, '|||') AS group_pairs
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN user_group_memberships m ON m.user_id = u.id
|
LEFT JOIN user_group_memberships m ON m.user_id = u.id
|
||||||
@@ -63,6 +75,20 @@ const insertMembershipForUser = db.prepare(`
|
|||||||
VALUES (?, ?)
|
VALUES (?, ?)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const selectSecurityPhraseByUsername = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
is_active,
|
||||||
|
security_phrase_content AS content,
|
||||||
|
security_phrase_iv AS iv,
|
||||||
|
security_phrase_tag AS tag,
|
||||||
|
security_phrase_downloaded_at AS downloaded_at
|
||||||
|
FROM users
|
||||||
|
WHERE lower(username) = lower(?)
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
|
||||||
const selectGroupById = db.prepare('SELECT id, name FROM user_groups WHERE id = ?');
|
const selectGroupById = db.prepare('SELECT id, name FROM user_groups WHERE id = ?');
|
||||||
const selectGroupIdByName = db.prepare('SELECT id FROM user_groups WHERE lower(name) = lower(?) LIMIT 1');
|
const selectGroupIdByName = db.prepare('SELECT id FROM user_groups WHERE lower(name) = lower(?) LIMIT 1');
|
||||||
|
|
||||||
@@ -75,8 +101,21 @@ const isUserInGroupStatement = db.prepare(`
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
const insertUserStatement = db.prepare(`
|
const insertUserStatement = db.prepare(`
|
||||||
INSERT INTO users (username, email, password_hash, password_salt, avatar_color, is_active, created_at, updated_at)
|
INSERT INTO users (
|
||||||
VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
username,
|
||||||
|
email,
|
||||||
|
password_hash,
|
||||||
|
password_salt,
|
||||||
|
avatar_color,
|
||||||
|
is_active,
|
||||||
|
security_phrase_content,
|
||||||
|
security_phrase_iv,
|
||||||
|
security_phrase_tag,
|
||||||
|
security_phrase_downloaded_at,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const updateUserCoreStatement = db.prepare(`
|
const updateUserCoreStatement = db.prepare(`
|
||||||
@@ -97,6 +136,14 @@ const updateUserActiveStatement = db.prepare(`
|
|||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const updateUserPasswordStatement = db.prepare(`
|
||||||
|
UPDATE users
|
||||||
|
SET password_hash = ?,
|
||||||
|
password_salt = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
const parseGroupPairs = (rawPairs) => {
|
const parseGroupPairs = (rawPairs) => {
|
||||||
if (!rawPairs) {
|
if (!rawPairs) {
|
||||||
return [];
|
return [];
|
||||||
@@ -127,7 +174,8 @@ const sanitizeUserRecord = (row) => ({
|
|||||||
lastLogin: row.last_login || null,
|
lastLogin: row.last_login || null,
|
||||||
createdAt: row.created_at || null,
|
createdAt: row.created_at || null,
|
||||||
updatedAt: row.updated_at || null,
|
updatedAt: row.updated_at || null,
|
||||||
groups: parseGroupPairs(row.group_pairs)
|
groups: parseGroupPairs(row.group_pairs),
|
||||||
|
securityPhraseDownloadedAt: row.security_phrase_downloaded_at || null
|
||||||
});
|
});
|
||||||
|
|
||||||
export function listUsers() {
|
export function listUsers() {
|
||||||
@@ -147,13 +195,76 @@ const applyUserGroupAssignments = db.transaction((userId, groupIds) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const insertUserWithGroups = db.transaction(({ username, email, passwordHash, passwordSalt, avatarColor, groupId }) => {
|
const insertUserWithGroups = db.transaction(({
|
||||||
const result = insertUserStatement.run(username, email, passwordHash, passwordSalt, avatarColor);
|
username,
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
passwordSalt,
|
||||||
|
avatarColor,
|
||||||
|
groupId,
|
||||||
|
securityPhrase
|
||||||
|
}) => {
|
||||||
|
const phraseToPersist = securityPhrase && typeof securityPhrase === 'object'
|
||||||
|
? {
|
||||||
|
content: securityPhrase.content ?? null,
|
||||||
|
iv: securityPhrase.iv ?? null,
|
||||||
|
tag: securityPhrase.tag ?? null
|
||||||
|
}
|
||||||
|
: { content: null, iv: null, tag: null };
|
||||||
|
|
||||||
|
const result = insertUserStatement.run(
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
passwordSalt,
|
||||||
|
avatarColor,
|
||||||
|
phraseToPersist.content,
|
||||||
|
phraseToPersist.iv,
|
||||||
|
phraseToPersist.tag
|
||||||
|
);
|
||||||
const userId = Number(result.lastInsertRowid);
|
const userId = Number(result.lastInsertRowid);
|
||||||
applyUserGroupAssignments(userId, [groupId]);
|
applyUserGroupAssignments(userId, [groupId]);
|
||||||
return userId;
|
return userId;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectSecurityPhraseByUserId = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
security_phrase_content AS content,
|
||||||
|
security_phrase_iv AS iv,
|
||||||
|
security_phrase_tag AS tag,
|
||||||
|
security_phrase_downloaded_at AS downloaded_at
|
||||||
|
FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const updateSecurityPhraseStatement = db.prepare(`
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
security_phrase_content = ?,
|
||||||
|
security_phrase_iv = ?,
|
||||||
|
security_phrase_tag = ?,
|
||||||
|
security_phrase_downloaded_at = NULL,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const markSecurityPhraseDownloadedStatement = db.prepare(`
|
||||||
|
UPDATE users
|
||||||
|
SET security_phrase_downloaded_at = COALESCE(security_phrase_downloaded_at, CURRENT_TIMESTAMP),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectUsersMissingSecurityPhraseStatement = db.prepare(`
|
||||||
|
SELECT id
|
||||||
|
FROM users
|
||||||
|
WHERE security_phrase_content IS NULL
|
||||||
|
OR security_phrase_iv IS NULL
|
||||||
|
OR security_phrase_tag IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
const resolveActorFields = (actor) => {
|
const resolveActorFields = (actor) => {
|
||||||
if (!actor || actor.id === undefined || actor.id === null) {
|
if (!actor || actor.id === undefined || actor.id === null) {
|
||||||
return {};
|
return {};
|
||||||
@@ -175,6 +286,265 @@ const normalizeEmail = (value) => {
|
|||||||
return trimmed.toLowerCase();
|
return trimmed.toLowerCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getUserSecurityPhrase(userId, options = {}) {
|
||||||
|
const numericUserId = Number(userId);
|
||||||
|
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||||
|
const error = new Error('INVALID_USER_ID');
|
||||||
|
error.code = 'INVALID_USER_ID';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { actor = null, allowAutoGenerate = true } = options;
|
||||||
|
|
||||||
|
const record = selectSecurityPhraseByUserId.get(numericUserId);
|
||||||
|
if (!record) {
|
||||||
|
const error = new Error('USER_NOT_FOUND');
|
||||||
|
error.code = 'USER_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = decryptSecurityPhrase(record);
|
||||||
|
|
||||||
|
if (allowAutoGenerate && (!Array.isArray(words) || words.length === 0)) {
|
||||||
|
return renewUserSecurityPhrase(numericUserId, { actor, suppressLog: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: numericUserId,
|
||||||
|
username: record.username,
|
||||||
|
words,
|
||||||
|
downloadedAt: record.downloaded_at || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renewUserSecurityPhrase(userId, options = {}) {
|
||||||
|
const actor = options.actor || null;
|
||||||
|
const suppressLog = Boolean(options.suppressLog);
|
||||||
|
const numericUserId = Number(userId);
|
||||||
|
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||||
|
const error = new Error('INVALID_USER_ID');
|
||||||
|
error.code = 'INVALID_USER_ID';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = selectUserCredentialsById.get(numericUserId);
|
||||||
|
if (!existingUser) {
|
||||||
|
const error = new Error('USER_NOT_FOUND');
|
||||||
|
error.code = 'USER_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousPhraseRecord = selectSecurityPhraseByUserId.get(numericUserId);
|
||||||
|
|
||||||
|
const words = generateSecurityPhraseWords(8);
|
||||||
|
const { encrypted: encryptedPhrase } = encryptSecurityPhrase(words);
|
||||||
|
|
||||||
|
updateSecurityPhraseStatement.run(
|
||||||
|
encryptedPhrase.content,
|
||||||
|
encryptedPhrase.iv,
|
||||||
|
encryptedPhrase.tag,
|
||||||
|
numericUserId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!suppressLog) {
|
||||||
|
logEvent({
|
||||||
|
category: 'benutzer',
|
||||||
|
eventType: 'benutzer-sicherheitsschluessel-erneuert',
|
||||||
|
action: 'sicherheitsschluessel-erneuern',
|
||||||
|
status: 'erfolgreich',
|
||||||
|
entityType: 'benutzer',
|
||||||
|
entityId: String(numericUserId),
|
||||||
|
entityName: existingUser.username ?? `ID ${numericUserId}`,
|
||||||
|
message: `Sicherheitsschlüssel für Benutzer "${existingUser.username ?? numericUserId}" erneuert`,
|
||||||
|
metadata: {
|
||||||
|
previousDownloadedAt: previousPhraseRecord?.downloaded_at ?? null
|
||||||
|
},
|
||||||
|
...resolveActorFields(actor)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: numericUserId,
|
||||||
|
username: existingUser.username,
|
||||||
|
words,
|
||||||
|
downloadedAt: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markSecurityPhraseDownloaded(userId, options = {}) {
|
||||||
|
const actor = options.actor || null;
|
||||||
|
const numericUserId = Number(userId);
|
||||||
|
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||||
|
const error = new Error('INVALID_USER_ID');
|
||||||
|
error.code = 'INVALID_USER_ID';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = selectSecurityPhraseByUserId.get(numericUserId);
|
||||||
|
if (!record) {
|
||||||
|
const error = new Error('USER_NOT_FOUND');
|
||||||
|
error.code = 'USER_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
markSecurityPhraseDownloadedStatement.run(numericUserId);
|
||||||
|
|
||||||
|
const updatedRecord = selectSecurityPhraseByUserId.get(numericUserId) || record;
|
||||||
|
const downloadedAt = updatedRecord?.downloaded_at || null;
|
||||||
|
|
||||||
|
logEvent({
|
||||||
|
category: 'benutzer',
|
||||||
|
eventType: 'benutzer-sicherheitsschluessel-heruntergeladen',
|
||||||
|
action: 'sicherheitsschluessel-herunterladen',
|
||||||
|
status: 'erfolgreich',
|
||||||
|
entityType: 'benutzer',
|
||||||
|
entityId: String(numericUserId),
|
||||||
|
entityName: record.username ?? `ID ${numericUserId}`,
|
||||||
|
message: `Sicherheitsschlüssel für Benutzer "${record.username ?? numericUserId}" heruntergeladen`,
|
||||||
|
metadata: {
|
||||||
|
downloadedAt
|
||||||
|
},
|
||||||
|
...resolveActorFields(actor)
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: numericUserId,
|
||||||
|
username: record.username,
|
||||||
|
downloadedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifySecurityPhraseForUsername(username, phraseInput) {
|
||||||
|
const normalizedUsername = typeof username === 'string' ? username.trim() : '';
|
||||||
|
if (!normalizedUsername) {
|
||||||
|
const error = new Error('USERNAME_REQUIRED');
|
||||||
|
error.code = 'USERNAME_REQUIRED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = selectSecurityPhraseByUsername.get(normalizedUsername);
|
||||||
|
if (!record) {
|
||||||
|
const error = new Error('USER_NOT_FOUND');
|
||||||
|
error.code = 'USER_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateCanonical = canonicalizePhraseInput(phraseInput);
|
||||||
|
if (!candidateCanonical) {
|
||||||
|
const error = new Error('PHRASE_REQUIRED');
|
||||||
|
error.code = 'PHRASE_REQUIRED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = decryptSecurityPhrase(record);
|
||||||
|
if (!Array.isArray(words) || words.length === 0) {
|
||||||
|
const error = new Error('PHRASE_NOT_INITIALIZED');
|
||||||
|
error.code = 'PHRASE_NOT_INITIALIZED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedCanonical = canonicalizeWords(words);
|
||||||
|
if (!storedCanonical) {
|
||||||
|
const error = new Error('PHRASE_NOT_INITIALIZED');
|
||||||
|
error.code = 'PHRASE_NOT_INITIALIZED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedCanonical !== candidateCanonical) {
|
||||||
|
const error = new Error('PHRASE_MISMATCH');
|
||||||
|
error.code = 'PHRASE_MISMATCH';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: record.id,
|
||||||
|
username: record.username,
|
||||||
|
isActive: Boolean(record.is_active),
|
||||||
|
words
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUserPassword(userId, newPassword, options = {}) {
|
||||||
|
const actor = options.actor || null;
|
||||||
|
const resetMethod = options.resetMethod || 'security-phrase';
|
||||||
|
const numericUserId = Number(userId);
|
||||||
|
if (!Number.isFinite(numericUserId) || numericUserId <= 0) {
|
||||||
|
const error = new Error('INVALID_USER_ID');
|
||||||
|
error.code = 'INVALID_USER_ID';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = selectUserCredentialsById.get(numericUserId);
|
||||||
|
if (!existingUser) {
|
||||||
|
const error = new Error('USER_NOT_FOUND');
|
||||||
|
error.code = 'USER_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let passwordHash;
|
||||||
|
let passwordSalt;
|
||||||
|
try {
|
||||||
|
const hashed = hashPassword(newPassword);
|
||||||
|
passwordHash = hashed.hash;
|
||||||
|
passwordSalt = hashed.salt;
|
||||||
|
} catch (err) {
|
||||||
|
if (err && err.code) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const error = new Error('INVALID_PASSWORD');
|
||||||
|
error.code = 'INVALID_PASSWORD';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUserPasswordStatement.run(passwordHash, passwordSalt, numericUserId);
|
||||||
|
const updated = getUserById(numericUserId);
|
||||||
|
|
||||||
|
logEvent({
|
||||||
|
category: 'benutzer',
|
||||||
|
eventType: 'benutzer-passwort-zurueckgesetzt',
|
||||||
|
action: 'passwort-zuruecksetzen',
|
||||||
|
status: 'erfolgreich',
|
||||||
|
entityType: 'benutzer',
|
||||||
|
entityId: String(numericUserId),
|
||||||
|
entityName: updated?.username ?? existingUser.username ?? `ID ${numericUserId}`,
|
||||||
|
message: `Passwort für Benutzer "${updated?.username ?? existingUser.username ?? numericUserId}" zurückgesetzt`,
|
||||||
|
metadata: {
|
||||||
|
resetMethod
|
||||||
|
},
|
||||||
|
...resolveActorFields(actor)
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureSecurityPhrasesForExistingUsers() {
|
||||||
|
const rows = selectUsersMissingSecurityPhraseStatement.all();
|
||||||
|
if (!Array.isArray(rows) || rows.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
const initialize = db.transaction((users) => {
|
||||||
|
users.forEach((entry) => {
|
||||||
|
if (!entry || entry.id === undefined || entry.id === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const words = generateSecurityPhraseWords(8);
|
||||||
|
const { encrypted: encryptedPhrase } = encryptSecurityPhrase(words);
|
||||||
|
updateSecurityPhraseStatement.run(
|
||||||
|
encryptedPhrase.content,
|
||||||
|
encryptedPhrase.iv,
|
||||||
|
encryptedPhrase.tag,
|
||||||
|
entry.id
|
||||||
|
);
|
||||||
|
processed += 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
initialize(rows);
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
export function createUser({ username, email, password, groupId, avatarColor }, options = {}) {
|
export function createUser({ username, email, password, groupId, avatarColor }, options = {}) {
|
||||||
const actor = options.actor || null;
|
const actor = options.actor || null;
|
||||||
const normalizedUsername = typeof username === 'string' ? username.trim() : '';
|
const normalizedUsername = typeof username === 'string' ? username.trim() : '';
|
||||||
@@ -245,13 +615,22 @@ export function createUser({ username, email, password, groupId, avatarColor },
|
|||||||
const normalizedAvatarColor = normalizeAvatarColor(avatarColor);
|
const normalizedAvatarColor = normalizeAvatarColor(avatarColor);
|
||||||
const avatarColorToPersist = normalizedAvatarColor || pickRandomAvatarColor() || DEFAULT_AVATAR_COLOR;
|
const avatarColorToPersist = normalizedAvatarColor || pickRandomAvatarColor() || DEFAULT_AVATAR_COLOR;
|
||||||
|
|
||||||
|
const securityPhraseWords = generateSecurityPhraseWords(8);
|
||||||
|
const { encrypted: encryptedPhrase } = encryptSecurityPhrase(securityPhraseWords);
|
||||||
|
const securityPhrasePayload = {
|
||||||
|
content: encryptedPhrase.content,
|
||||||
|
iv: encryptedPhrase.iv,
|
||||||
|
tag: encryptedPhrase.tag
|
||||||
|
};
|
||||||
|
|
||||||
const userId = insertUserWithGroups({
|
const userId = insertUserWithGroups({
|
||||||
username: normalizedUsername,
|
username: normalizedUsername,
|
||||||
email: normalizedEmail,
|
email: normalizedEmail,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
passwordSalt,
|
passwordSalt,
|
||||||
avatarColor: avatarColorToPersist,
|
avatarColor: avatarColorToPersist,
|
||||||
groupId: numericGroupId
|
groupId: numericGroupId,
|
||||||
|
securityPhrase: securityPhrasePayload
|
||||||
});
|
});
|
||||||
|
|
||||||
const userRecord = getUserById(userId);
|
const userRecord = getUserById(userId);
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { wordlist as englishWordlist } from '@scure/bip39/wordlists/english.js';
|
||||||
|
|
||||||
|
const FALLBACK_SECRET = 'stackpulse-security-phrase-secret';
|
||||||
|
|
||||||
|
const WORD_LIST = englishWordlist;
|
||||||
|
|
||||||
|
|
||||||
|
const SECURITY_PHRASE_KEY = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(String(process.env.SECURITY_PHRASE_SECRET || FALLBACK_SECRET))
|
||||||
|
.digest();
|
||||||
|
|
||||||
|
const encryptValue = (value) => {
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', SECURITY_PHRASE_KEY, iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return {
|
||||||
|
iv: iv.toString('base64'),
|
||||||
|
content: encrypted.toString('base64'),
|
||||||
|
tag: tag.toString('base64')
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const decryptValue = ({ content, iv, tag }) => {
|
||||||
|
if (!content || !iv || !tag) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', SECURITY_PHRASE_KEY, Buffer.from(iv, 'base64'));
|
||||||
|
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
||||||
|
const decrypted = Buffer.concat([
|
||||||
|
decipher.update(Buffer.from(content, 'base64')),
|
||||||
|
decipher.final()
|
||||||
|
]);
|
||||||
|
return decrypted.toString('utf8');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateSecurityPhraseWords = (count = 8) => {
|
||||||
|
const words = [];
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
const index = crypto.randomInt(0, WORD_LIST.length);
|
||||||
|
words.push(WORD_LIST[index]);
|
||||||
|
}
|
||||||
|
return words;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const encodeWords = (words = []) => {
|
||||||
|
return words
|
||||||
|
.map((word) => {
|
||||||
|
const index = WORD_LIST.indexOf(word);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error(`SECURITY_PHRASE_WORD_UNKNOWN:${word}`);
|
||||||
|
}
|
||||||
|
return String(index);
|
||||||
|
})
|
||||||
|
.join('-');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decodeWords = (encoded) => {
|
||||||
|
if (!encoded) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return encoded.split('-').map((entry) => {
|
||||||
|
const index = Number(entry);
|
||||||
|
if (!Number.isFinite(index) || index < 0 || index >= WORD_LIST.length) {
|
||||||
|
throw new Error('SECURITY_PHRASE_DECODE_FAILED');
|
||||||
|
}
|
||||||
|
return WORD_LIST[index];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const encryptSecurityPhrase = (words) => {
|
||||||
|
const encoded = encodeWords(words);
|
||||||
|
const encrypted = encryptValue(encoded);
|
||||||
|
return {
|
||||||
|
encrypted,
|
||||||
|
encoded
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decryptSecurityPhrase = (record) => {
|
||||||
|
if (!record) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const encoded = decryptValue(record);
|
||||||
|
if (!encoded) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return decodeWords(encoded);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatPhraseForDisplay = (words) => {
|
||||||
|
const rows = [];
|
||||||
|
for (let i = 0; i < words.length; i += 4) {
|
||||||
|
rows.push(words.slice(i, i + 4));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeWord = (value) => {
|
||||||
|
if (!value && value !== 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return String(value).trim().toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const canonicalizeWords = (words = []) => {
|
||||||
|
return words
|
||||||
|
.map(normalizeWord)
|
||||||
|
.filter((word) => word.length > 0)
|
||||||
|
.join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractWordsFromString = (input) => {
|
||||||
|
if (!input && input !== 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const normalized = String(input)
|
||||||
|
.replace(/[\r\n\t]+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return normalized.split(/\s+/).filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const canonicalizePhraseInput = (input) => {
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
return canonicalizeWords(input);
|
||||||
|
}
|
||||||
|
return canonicalizeWords(extractWordsFromString(input));
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WORD_LIST;
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
const LEVEL_PRIORITY = {
|
||||||
|
none: 0,
|
||||||
|
read: 1,
|
||||||
|
full: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
AuthContext.displayName = "AuthContext";
|
||||||
|
|
||||||
|
const normalizePermissions = (raw) => {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return Object.entries(raw).reduce((acc, [key, value]) => {
|
||||||
|
if (typeof key !== "string" || !key) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
const normalizedValue = typeof value === "string" ? value : String(value ?? "").trim();
|
||||||
|
acc[key] = normalizedValue || "none";
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [state, setState] = useState({
|
||||||
|
user: null,
|
||||||
|
permissions: {},
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
lastErrorCode: null,
|
||||||
|
initialized: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setSession = useCallback((payload) => {
|
||||||
|
const user = payload?.user ?? null;
|
||||||
|
const permissions = normalizePermissions(payload?.permissions);
|
||||||
|
setState({
|
||||||
|
user,
|
||||||
|
permissions,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
lastErrorCode: null,
|
||||||
|
initialized: true,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSession = useCallback(() => {
|
||||||
|
setState((prev) => ({
|
||||||
|
user: null,
|
||||||
|
permissions: {},
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
lastErrorCode: null,
|
||||||
|
initialized: prev.initialized || true,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshSession = useCallback(async () => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
lastErrorCode: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/session", {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setState({
|
||||||
|
user: null,
|
||||||
|
permissions: {},
|
||||||
|
loading: false,
|
||||||
|
error: payload?.error ?? null,
|
||||||
|
lastErrorCode: payload?.error ?? null,
|
||||||
|
initialized: true,
|
||||||
|
});
|
||||||
|
return { ok: false, status: response.status, error: payload?.error ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
setSession(payload);
|
||||||
|
return { ok: true, data: payload };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err?.message || "NETWORK_ERROR";
|
||||||
|
setState({
|
||||||
|
user: null,
|
||||||
|
permissions: {},
|
||||||
|
loading: false,
|
||||||
|
error: message,
|
||||||
|
lastErrorCode: "NETWORK_ERROR",
|
||||||
|
initialized: true,
|
||||||
|
});
|
||||||
|
return { ok: false, error: message };
|
||||||
|
}
|
||||||
|
}, [setSession]);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
let capturedError = null;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/logout", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
const logoutError = new Error(payload?.error || "LOGOUT_FAILED");
|
||||||
|
logoutError.code = payload?.error || `STATUS_${response.status}`;
|
||||||
|
throw logoutError;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
capturedError = err;
|
||||||
|
} finally {
|
||||||
|
clearSession();
|
||||||
|
}
|
||||||
|
if (capturedError) {
|
||||||
|
throw capturedError;
|
||||||
|
}
|
||||||
|
}, [clearSession]);
|
||||||
|
|
||||||
|
const hasPermission = useCallback(
|
||||||
|
(permissionKey, requiredLevel = "full") => {
|
||||||
|
if (!permissionKey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (state.user?.isSuperuser) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const current = state.permissions?.[permissionKey] ?? "none";
|
||||||
|
const currentPriority = LEVEL_PRIORITY[current] ?? 0;
|
||||||
|
const requiredPriority = LEVEL_PRIORITY[requiredLevel] ?? LEVEL_PRIORITY.full;
|
||||||
|
return currentPriority >= requiredPriority;
|
||||||
|
},
|
||||||
|
[state.permissions, state.user]
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
user: state.user,
|
||||||
|
permissions: state.permissions,
|
||||||
|
loading: state.loading,
|
||||||
|
error: state.error,
|
||||||
|
lastErrorCode: state.lastErrorCode,
|
||||||
|
initialized: state.initialized,
|
||||||
|
isAuthenticated: Boolean(state.user),
|
||||||
|
refreshSession,
|
||||||
|
setSession,
|
||||||
|
clearSession,
|
||||||
|
logout,
|
||||||
|
hasPermission,
|
||||||
|
}),
|
||||||
|
[state, refreshSession, setSession, clearSession, logout, hasPermission]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useAuth must be used within an AuthProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContextDisplayName = "/src/components/AuthProvider.jsx";
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import React from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { Typography, Card, CardBody } from "@material-tailwind/react";
|
||||||
|
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||||
|
|
||||||
|
export function PermissionGate({
|
||||||
|
permission,
|
||||||
|
requiredLevel = "full",
|
||||||
|
loadingFallback = null,
|
||||||
|
children,
|
||||||
|
}) {
|
||||||
|
const { initialized, loading, hasPermission } = useAuth();
|
||||||
|
|
||||||
|
if (!permission) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!initialized || loading) {
|
||||||
|
return (
|
||||||
|
loadingFallback ?? (
|
||||||
|
<div className="flex min-h-[200px] items-center justify-center">
|
||||||
|
<Typography variant="small" color="blue-gray">
|
||||||
|
Berechtigungen werden geladen …
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPermission(permission, requiredLevel)) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-12 flex items-center justify-center px-4">
|
||||||
|
<Card className="max-w-xl border border-blue-gray-100 shadow-sm">
|
||||||
|
<CardBody className="space-y-3 text-center">
|
||||||
|
<Typography variant="h5" color="blue-gray">
|
||||||
|
Kein Zugriff auf diesen Bereich
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="small" className="text-blue-gray-500">
|
||||||
|
Für diese Funktion wird die Berechtigung <code>{permission}</code> benötigt.
|
||||||
|
</Typography>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PermissionGate.propTypes = {
|
||||||
|
permission: PropTypes.string,
|
||||||
|
requiredLevel: PropTypes.string,
|
||||||
|
loadingFallback: PropTypes.node,
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PermissionGate;
|
||||||
|
|
||||||
@@ -11,7 +11,10 @@ import {
|
|||||||
import routes from "@/routes";
|
import routes from "@/routes";
|
||||||
import { UserDetails } from "@/pages/dashboard/userDetails.jsx";
|
import { UserDetails } from "@/pages/dashboard/userDetails.jsx";
|
||||||
import { UserGroupDetail } from "@/pages/dashboard/userGroupDetail.jsx";
|
import { UserGroupDetail } from "@/pages/dashboard/userGroupDetail.jsx";
|
||||||
|
import { SecurityPhrase } from "@/pages/dashboard/securityPhrase.jsx";
|
||||||
import { useMaterialTailwindController, setOpenConfigurator } from "@/components";
|
import { useMaterialTailwindController, setOpenConfigurator } from "@/components";
|
||||||
|
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||||
|
import PermissionGate from "@/components/PermissionGate.jsx";
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const [controller, dispatch] = useMaterialTailwindController();
|
const [controller, dispatch] = useMaterialTailwindController();
|
||||||
@@ -20,8 +23,13 @@ export function Dashboard() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [setupChecked, setSetupChecked] = useState(false);
|
const [setupChecked, setSetupChecked] = useState(false);
|
||||||
const [setupIncomplete, setSetupIncomplete] = useState(true);
|
const [setupIncomplete, setSetupIncomplete] = useState(true);
|
||||||
const [authChecked, setAuthChecked] = useState(false);
|
const {
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
initialized: authInitialized,
|
||||||
|
loading: authLoading,
|
||||||
|
isAuthenticated,
|
||||||
|
refreshSession,
|
||||||
|
user
|
||||||
|
} = useAuth();
|
||||||
|
|
||||||
const checkSetupStatus = useCallback(async () => {
|
const checkSetupStatus = useCallback(async () => {
|
||||||
setSetupChecked(false);
|
setSetupChecked(false);
|
||||||
@@ -40,37 +48,15 @@ export function Dashboard() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const checkSession = useCallback(async () => {
|
|
||||||
setAuthChecked(false);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/auth/session", { credentials: "include" });
|
|
||||||
if (response.status === 403) {
|
|
||||||
setSetupIncomplete(true);
|
|
||||||
setIsAuthenticated(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
setIsAuthenticated(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
setIsAuthenticated(Boolean(data?.user));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("⚠️ [Auth] Sessionprüfung fehlgeschlagen:", error);
|
|
||||||
setIsAuthenticated(false);
|
|
||||||
} finally {
|
|
||||||
setAuthChecked(true);
|
|
||||||
}
|
|
||||||
}, [setSetupIncomplete, setIsAuthenticated, setAuthChecked]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkSetupStatus();
|
checkSetupStatus();
|
||||||
}, [checkSetupStatus]);
|
}, [checkSetupStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!setupChecked || setupIncomplete) return;
|
if (!setupChecked || setupIncomplete) return;
|
||||||
checkSession();
|
if (authInitialized || authLoading) return;
|
||||||
}, [setupChecked, setupIncomplete, checkSession]);
|
refreshSession();
|
||||||
|
}, [setupChecked, setupIncomplete, authInitialized, authLoading, refreshSession]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!setupChecked) return;
|
if (!setupChecked) return;
|
||||||
@@ -82,7 +68,7 @@ export function Dashboard() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authChecked) return;
|
if (!authInitialized) return;
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
if (location.pathname !== "/auth/sign-in") {
|
if (location.pathname !== "/auth/sign-in") {
|
||||||
@@ -91,10 +77,53 @@ export function Dashboard() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (location.pathname === "/setup" || location.pathname.startsWith("/auth/")) {
|
const requiresSecurityPhrase = Boolean(user?.requiresSecurityPhraseDownload);
|
||||||
|
const isSecurityPhrasePath = location.pathname === "/dashboard/security-phrase";
|
||||||
|
|
||||||
|
if (requiresSecurityPhrase && !isSecurityPhrasePath) {
|
||||||
|
navigate("/dashboard/security-phrase", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requiresSecurityPhrase && isSecurityPhrasePath) {
|
||||||
navigate("/dashboard/stacks", { replace: true });
|
navigate("/dashboard/stacks", { replace: true });
|
||||||
}
|
}
|
||||||
}, [setupChecked, setupIncomplete, authChecked, isAuthenticated, location.pathname, navigate]);
|
}, [
|
||||||
|
setupChecked,
|
||||||
|
setupIncomplete,
|
||||||
|
authInitialized,
|
||||||
|
isAuthenticated,
|
||||||
|
location.pathname,
|
||||||
|
navigate,
|
||||||
|
user?.requiresSecurityPhraseDownload
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!setupChecked || setupIncomplete) return;
|
||||||
|
if (!authInitialized || authLoading) return;
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
if (location.pathname !== "/auth/sign-in") {
|
||||||
|
navigate("/auth/sign-in", { replace: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthPath = location.pathname.startsWith("/auth/");
|
||||||
|
const isLogoutPath = location.pathname === "/auth/logout";
|
||||||
|
|
||||||
|
if (location.pathname === "/setup" || (isAuthPath && !isLogoutPath)) {
|
||||||
|
navigate("/dashboard/stacks", { replace: true });
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
setupChecked,
|
||||||
|
setupIncomplete,
|
||||||
|
authInitialized,
|
||||||
|
authLoading,
|
||||||
|
isAuthenticated,
|
||||||
|
location.pathname,
|
||||||
|
navigate
|
||||||
|
]);
|
||||||
|
|
||||||
if (!setupChecked) {
|
if (!setupChecked) {
|
||||||
return (
|
return (
|
||||||
@@ -112,7 +141,7 @@ export function Dashboard() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authChecked) {
|
if (!authInitialized || authLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||||
<span className="text-blue-gray-500">Pruefe Anmeldung ...</span>
|
<span className="text-blue-gray-500">Pruefe Anmeldung ...</span>
|
||||||
@@ -128,15 +157,21 @@ export function Dashboard() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSecurityPhraseRoute = location.pathname === "/dashboard/security-phrase";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-blue-gray-50/50">
|
<div className="min-h-screen bg-blue-gray-50/50">
|
||||||
|
{!isSecurityPhraseRoute && (
|
||||||
<Sidenav
|
<Sidenav
|
||||||
routes={routes}
|
routes={routes}
|
||||||
brandImg={
|
brandImg={
|
||||||
sidenavType === "dark" ? "/img/logo-ct.png" : "/img/logo-ct-dark.png"
|
sidenavType === "dark" ? "/img/logo-ct.png" : "/img/logo-ct-dark.png"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="p-4 xl:ml-80">
|
)}
|
||||||
|
<div className={isSecurityPhraseRoute ? "" : "p-4 xl:ml-80"}>
|
||||||
|
{!isSecurityPhraseRoute && (
|
||||||
|
<>
|
||||||
<DashboardNavbar />
|
<DashboardNavbar />
|
||||||
<Configurator />
|
<Configurator />
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -148,6 +183,8 @@ export function Dashboard() {
|
|||||||
>
|
>
|
||||||
<Cog6ToothIcon className="h-5 w-5" />
|
<Cog6ToothIcon className="h-5 w-5" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Routes>
|
<Routes>
|
||||||
{routes.map(
|
{routes.map(
|
||||||
({ layout, pages }) =>
|
({ layout, pages }) =>
|
||||||
@@ -156,12 +193,29 @@ export function Dashboard() {
|
|||||||
<Route exact path={path} element={element} />
|
<Route exact path={path} element={element} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
<Route path="users/:userId" element={<UserDetails />} />
|
<Route path="security-phrase" element={<SecurityPhrase />} />
|
||||||
<Route path="usergroups/:groupId" element={<UserGroupDetail />} />
|
<Route
|
||||||
|
path="users/:userId"
|
||||||
|
element={
|
||||||
|
<PermissionGate permission="users-access" requiredLevel="read">
|
||||||
|
<UserDetails />
|
||||||
|
</PermissionGate>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="usergroups/:groupId"
|
||||||
|
element={
|
||||||
|
<PermissionGate permission="user-groups-access" requiredLevel="read">
|
||||||
|
<UserGroupDetail />
|
||||||
|
</PermissionGate>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
{!isSecurityPhraseRoute && (
|
||||||
<div className="text-blue-gray-600">
|
<div className="text-blue-gray-600">
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ import "./tailwind.css";
|
|||||||
import ToastProvider from "@/components/ToastProvider.jsx";
|
import ToastProvider from "@/components/ToastProvider.jsx";
|
||||||
import MaintenanceProvider from "@/components/MaintenanceProvider.jsx";
|
import MaintenanceProvider from "@/components/MaintenanceProvider.jsx";
|
||||||
import PageProvider from "@/components/PageProvider.jsx";
|
import PageProvider from "@/components/PageProvider.jsx";
|
||||||
|
import { AuthProvider } from "@/components/AuthProvider.jsx";
|
||||||
|
|
||||||
axios.defaults.withCredentials = true;
|
axios.defaults.withCredentials = true;
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
<MaintenanceProvider>
|
<MaintenanceProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<PageProvider>
|
<PageProvider>
|
||||||
@@ -37,6 +39,7 @@ ReactDOM.createRoot(document.getElementById("root")).render(
|
|||||||
</PageProvider>
|
</PageProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</MaintenanceProvider>
|
</MaintenanceProvider>
|
||||||
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
Typography,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
Spinner,
|
||||||
|
Alert
|
||||||
|
} from "@material-tailwind/react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useToast } from "@/components/ToastProvider.jsx";
|
||||||
|
|
||||||
|
const PHRASE_WORD_COUNT = 8;
|
||||||
|
|
||||||
|
const normalizeWord = (value) => String(value || "").trim().toLowerCase();
|
||||||
|
|
||||||
|
const extractWordsFromText = (text = "") => {
|
||||||
|
const normalized = String(text || "").replace(/[\r\n\t]+/g, " ").trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return normalized.split(/\s+/).filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPhrasePayload = (words = []) => {
|
||||||
|
const normalized = words.map(normalizeWord).filter(Boolean);
|
||||||
|
return {
|
||||||
|
words: normalized,
|
||||||
|
canonical: normalized.join("")
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ForgotPassword() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [phraseInputs, setPhraseInputs] = useState(Array(PHRASE_WORD_COUNT).fill(""));
|
||||||
|
const [fileName, setFileName] = useState("");
|
||||||
|
const [fileContent, setFileContent] = useState("");
|
||||||
|
const [verificationError, setVerificationError] = useState("");
|
||||||
|
const [verifying, setVerifying] = useState(false);
|
||||||
|
const [verificationToken, setVerificationToken] = useState("");
|
||||||
|
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [resetting, setResetting] = useState(false);
|
||||||
|
const [resetError, setResetError] = useState("");
|
||||||
|
|
||||||
|
const phraseWords = useMemo(() => {
|
||||||
|
if (fileContent.trim()) {
|
||||||
|
return extractWordsFromText(fileContent);
|
||||||
|
}
|
||||||
|
return phraseInputs.map(normalizeWord).filter(Boolean);
|
||||||
|
}, [fileContent, phraseInputs]);
|
||||||
|
|
||||||
|
const handlePhraseInputChange = useCallback((index, value) => {
|
||||||
|
setPhraseInputs((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[index] = value;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileUpload = useCallback((event) => {
|
||||||
|
const [file] = event.target.files || [];
|
||||||
|
if (!file) {
|
||||||
|
setFileName("");
|
||||||
|
setFileContent("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFileName(file.name);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (loadEvent) => {
|
||||||
|
const text = String(loadEvent.target?.result || "");
|
||||||
|
setFileContent(text);
|
||||||
|
setPhraseInputs(Array(PHRASE_WORD_COUNT).fill(""));
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
setFileName("");
|
||||||
|
setFileContent("");
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Datei konnte nicht gelesen werden",
|
||||||
|
description: "Bitte lade die Datei erneut hoch."
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
const handleVerifyPhrase = useCallback(async () => {
|
||||||
|
setVerificationError("");
|
||||||
|
setVerificationToken("");
|
||||||
|
|
||||||
|
const trimmedUsername = username.trim();
|
||||||
|
if (!trimmedUsername) {
|
||||||
|
setVerificationError("Bitte gib deinen Benutzernamen ein.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = phraseWords;
|
||||||
|
const hasFile = Boolean(fileContent.trim());
|
||||||
|
|
||||||
|
if (!hasFile && words.length !== PHRASE_WORD_COUNT) {
|
||||||
|
setVerificationError("Bitte gib alle acht Wörter des Sicherheitsschlüssels ein.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasFile && words.length !== PHRASE_WORD_COUNT) {
|
||||||
|
setVerificationError("Die hochgeladene Datei enthält nicht genau acht Wörter.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildPhrasePayload(words);
|
||||||
|
if (!payload.canonical) {
|
||||||
|
setVerificationError("Der Sicherheitsschlüssel ist ungültig oder leer.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVerifying(true);
|
||||||
|
try {
|
||||||
|
const response = await axios.post("/api/auth/recover/verify", {
|
||||||
|
username: trimmedUsername,
|
||||||
|
phrase: payload.canonical,
|
||||||
|
words: payload.words
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseToken = response.data?.token;
|
||||||
|
if (!responseToken) {
|
||||||
|
throw new Error("TOKEN_MISSING");
|
||||||
|
}
|
||||||
|
setVerificationToken(responseToken);
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: "Sicherheitsschlüssel bestätigt",
|
||||||
|
description: "Du kannst jetzt ein neues Passwort festlegen."
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const serverError = error.response?.data?.error;
|
||||||
|
if (serverError === "USER_NOT_FOUND") {
|
||||||
|
setVerificationError("Der angegebene Benutzer wurde nicht gefunden.");
|
||||||
|
} else if (serverError === "PHRASE_MISMATCH") {
|
||||||
|
setVerificationError("Der Sicherheitsschlüssel passt nicht zum Benutzer.");
|
||||||
|
} else if (serverError === "PHRASE_NOT_INITIALIZED") {
|
||||||
|
setVerificationError("Für diesen Benutzer ist aktuell kein Sicherheitsschlüssel hinterlegt.");
|
||||||
|
} else if (serverError === "PHRASE_REQUIRED") {
|
||||||
|
setVerificationError("Bitte gib den Sicherheitsschlüssel ein.");
|
||||||
|
} else if (serverError === "USERNAME_REQUIRED") {
|
||||||
|
setVerificationError("Der Benutzername darf nicht leer sein.");
|
||||||
|
} else if (serverError === "INVALID_PASSWORD") {
|
||||||
|
setVerificationError("Der Sicherheitsschlüssel ist ungültig.");
|
||||||
|
} else {
|
||||||
|
setVerificationError("Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setVerifying(false);
|
||||||
|
}
|
||||||
|
}, [fileContent, phraseWords, showToast, username]);
|
||||||
|
|
||||||
|
const handleResetPassword = useCallback(async () => {
|
||||||
|
setResetError("");
|
||||||
|
|
||||||
|
if (!verificationToken) {
|
||||||
|
setResetError("Bitte bestätige zuerst deinen Sicherheitsschlüssel.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedPassword = password.trim();
|
||||||
|
const trimmedConfirm = confirmPassword.trim();
|
||||||
|
|
||||||
|
if (!trimmedPassword || !trimmedConfirm) {
|
||||||
|
setResetError("Bitte gib das neue Passwort zweimal ein.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedPassword !== trimmedConfirm) {
|
||||||
|
setResetError("Die Passwörter stimmen nicht überein.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setResetting(true);
|
||||||
|
try {
|
||||||
|
await axios.post("/api/auth/recover/reset", {
|
||||||
|
token: verificationToken,
|
||||||
|
password: trimmedPassword,
|
||||||
|
confirmPassword: trimmedConfirm
|
||||||
|
});
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: "Passwort aktualisiert",
|
||||||
|
description: "Du kannst dich jetzt mit deinem neuen Passwort anmelden."
|
||||||
|
});
|
||||||
|
navigate("/auth/sign-in", { replace: true });
|
||||||
|
} catch (error) {
|
||||||
|
const serverError = error.response?.data?.error;
|
||||||
|
if (serverError === "TOKEN_INVALID_OR_EXPIRED") {
|
||||||
|
setResetError("Die Wiederherstellungssitzung ist abgelaufen. Bitte starte den Vorgang erneut.");
|
||||||
|
setVerificationToken("");
|
||||||
|
} else if (serverError === "PASSWORD_TOO_SHORT") {
|
||||||
|
setResetError("Das Passwort muss mindestens 8 Zeichen enthalten.");
|
||||||
|
} else if (serverError === "PASSWORD_REQUIRED") {
|
||||||
|
setResetError("Bitte gib ein neues Passwort ein.");
|
||||||
|
} else if (serverError === "PASSWORD_MISMATCH") {
|
||||||
|
setResetError("Die Passwörter stimmen nicht überein.");
|
||||||
|
} else {
|
||||||
|
setResetError("Das Passwort konnte nicht zurückgesetzt werden. Bitte versuche es erneut.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setResetting(false);
|
||||||
|
}
|
||||||
|
}, [confirmPassword, navigate, password, showToast, verificationToken]);
|
||||||
|
|
||||||
|
const verificationCompleted = Boolean(verificationToken);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="m-8 flex justify-center">
|
||||||
|
<Card className="w-full max-w-3xl">
|
||||||
|
<CardHeader floated={false} shadow={false} className="rounded-none border-b border-blue-gray-50 bg-white px-6 py-5">
|
||||||
|
<Typography variant="h4" color="blue-gray" className="font-semibold">
|
||||||
|
Passwort zurücksetzen
|
||||||
|
</Typography>
|
||||||
|
<Typography color="blue-gray" className="mt-1 text-sm">
|
||||||
|
Gib deinen Benutzernamen und den Sicherheitsschlüssel (8 Wörter) ein oder lade die gespeicherte Schlüsseldatei.
|
||||||
|
</Typography>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Typography variant="h6" color="blue-gray">
|
||||||
|
Schritt 1: Sicherheitsschlüssel prüfen
|
||||||
|
</Typography>
|
||||||
|
<div>
|
||||||
|
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||||
|
Benutzername
|
||||||
|
</Typography>
|
||||||
|
<Input
|
||||||
|
size="lg"
|
||||||
|
placeholder="Benutzername"
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
disabled={verificationCompleted || verifying}
|
||||||
|
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||||
|
labelProps={{ className: "before:content-none after:content-none" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||||
|
Sicherheitsschlüssel eingeben
|
||||||
|
</Typography>
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-4 min-w-[20px] [&>div]:!min-w-0">
|
||||||
|
{phraseInputs.map((value, index) => (
|
||||||
|
<Input
|
||||||
|
key={`phrase-input-${index}`}
|
||||||
|
placeholder={`Wort ${index + 1}`}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => handlePhraseInputChange(index, event.target.value)}
|
||||||
|
disabled={verificationCompleted || verifying || Boolean(fileContent.trim())}
|
||||||
|
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||||
|
labelProps={{ className: "before:content-none after:content-none" }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||||
|
Alternativ: Schlüsseldatei (.txt) hochladen
|
||||||
|
</Typography>
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".txt"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
disabled={verificationCompleted || verifying}
|
||||||
|
className="block w-full max-w-xs text-sm text-blue-gray-500 file:mr-4 file:rounded-md file:border-0 file:bg-blue-gray-50 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-blue-gray-700 hover:file:bg-blue-gray-100"
|
||||||
|
/>
|
||||||
|
{fileName && (
|
||||||
|
<Typography className="text-xs text-blue-gray-500">
|
||||||
|
Ausgewählte Datei: <span className="font-medium text-blue-gray-700">{fileName}</span>
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{verificationError && (
|
||||||
|
<Alert color="red" className="border border-red-200 bg-red-50 text-red-700">
|
||||||
|
{verificationError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<Button
|
||||||
|
color="blue"
|
||||||
|
className="normal-case"
|
||||||
|
onClick={handleVerifyPhrase}
|
||||||
|
disabled={verifying || verificationCompleted}
|
||||||
|
>
|
||||||
|
{verifying ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Spinner className="h-4 w-4" /> Prüfe Sicherheitsschlüssel ...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Sicherheitsschlüssel prüfen"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{verificationCompleted && (
|
||||||
|
<Typography className="text-sm text-blue-gray-500">
|
||||||
|
Die Wiederherstellung bleibt für kurze Zeit aktiv. Bitte setze dein Passwort zeitnah zurück.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Schritt 2 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Typography variant="h6" color={verificationCompleted ? "blue-gray" : "gray"}>
|
||||||
|
Schritt 2: Neues Passwort festlegen
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{!verificationCompleted && (
|
||||||
|
<Typography className="text-sm text-blue-gray-500">
|
||||||
|
Nach erfolgreicher Prüfung des Sicherheitsschlüssels kannst du hier ein neues Passwort vergeben.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{verificationCompleted && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||||
|
Neues Passwort
|
||||||
|
</Typography>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
size="lg"
|
||||||
|
placeholder="Neues Passwort"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
disabled={resetting}
|
||||||
|
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||||
|
labelProps={{ className: "before:content-none after:content-none" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||||
|
Passwort bestätigen
|
||||||
|
</Typography>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
size="lg"
|
||||||
|
placeholder="Passwort bestätigen"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||||
|
disabled={resetting}
|
||||||
|
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||||||
|
labelProps={{ className: "before:content-none after:content-none" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{resetError && (
|
||||||
|
<Alert color="red" className="border border-red-200 bg-red-50 text-red-700">
|
||||||
|
{resetError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
color="green"
|
||||||
|
className="normal-case"
|
||||||
|
onClick={handleResetPassword}
|
||||||
|
disabled={resetting}
|
||||||
|
>
|
||||||
|
{resetting ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Spinner className="h-4 w-4" /> Speichere Passwort ...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Neues Passwort speichern"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-blue-gray-50 pt-4">
|
||||||
|
<Typography
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/auth/sign-in")}
|
||||||
|
className="text-sm font-medium text-blue-600 transition hover:text-blue-800"
|
||||||
|
>
|
||||||
|
Zurück zur Anmeldung
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ForgotPassword;
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
export * from "@/pages/auth/sign-in";
|
export * from "@/pages/auth/sign-in";
|
||||||
export * from "@/pages/auth/sign-up";
|
export * from "@/pages/auth/sign-up";
|
||||||
|
export * from "@/pages/auth/sign-out";
|
||||||
|
export * from "@/pages/auth/forgot-password";
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import {
|
|||||||
} from "@material-tailwind/react";
|
} from "@material-tailwind/react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import pattern from "@/assets/images/pattern.png";
|
import pattern from "@/assets/images/pattern.png";
|
||||||
|
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||||
|
|
||||||
export function SignIn() {
|
export function SignIn() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { setSession, refreshSession } = useAuth();
|
||||||
const [identifier, setIdentifier] = useState("");
|
const [identifier, setIdentifier] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -42,8 +44,12 @@ export function SignIn() {
|
|||||||
|
|
||||||
if (sessionResponse.ok) {
|
if (sessionResponse.ok) {
|
||||||
const sessionData = await sessionResponse.json();
|
const sessionData = await sessionResponse.json();
|
||||||
|
if (sessionData?.user) {
|
||||||
|
setSession(sessionData);
|
||||||
|
}
|
||||||
if (sessionData?.user && isActive) {
|
if (sessionData?.user && isActive) {
|
||||||
navigate("/dashboard/stacks", { replace: true });
|
const requiresSecurityPhrase = Boolean(sessionData.user.requiresSecurityPhraseDownload);
|
||||||
|
navigate(requiresSecurityPhrase ? "/dashboard/security-phrase" : "/dashboard/stacks", { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,7 +70,7 @@ export function SignIn() {
|
|||||||
isActive = false;
|
isActive = false;
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [navigate]);
|
}, [navigate, setSession]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (event) => {
|
async (event) => {
|
||||||
@@ -108,7 +114,20 @@ export function SignIn() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
navigate("/dashboard/stacks", { replace: true });
|
let sessionPayload = null;
|
||||||
|
|
||||||
|
if (payload?.user) {
|
||||||
|
setSession(payload);
|
||||||
|
sessionPayload = payload;
|
||||||
|
} else {
|
||||||
|
const refreshed = await refreshSession();
|
||||||
|
if (refreshed?.ok && refreshed?.data?.user) {
|
||||||
|
sessionPayload = refreshed.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiresSecurityPhrase = Boolean(sessionPayload?.user?.requiresSecurityPhraseDownload);
|
||||||
|
navigate(requiresSecurityPhrase ? "/dashboard/security-phrase" : "/dashboard/stacks", { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("⚠️ [Auth] Anmeldung fehlgeschlagen:", err);
|
console.error("⚠️ [Auth] Anmeldung fehlgeschlagen:", err);
|
||||||
setError("Netzwerkfehler – bitte erneut versuchen.");
|
setError("Netzwerkfehler – bitte erneut versuchen.");
|
||||||
@@ -116,7 +135,7 @@ export function SignIn() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[identifier, password, loading, navigate]
|
[identifier, password, loading, navigate, refreshSession, setSession]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!statusChecked) {
|
if (!statusChecked) {
|
||||||
@@ -190,6 +209,14 @@ export function SignIn() {
|
|||||||
<Button type="submit" className="mt-6" fullWidth disabled={loading}>
|
<Button type="submit" className="mt-6" fullWidth disabled={loading}>
|
||||||
{loading ? "Anmeldung läuft ..." : "Anmelden"}
|
{loading ? "Anmeldung läuft ..." : "Anmelden"}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Typography
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/auth/forgot-password")}
|
||||||
|
className="mt-4 text-sm font-medium text-blue-600 transition hover:text-blue-800"
|
||||||
|
>
|
||||||
|
Passwort vergessen?
|
||||||
|
</Typography>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-2/5 h-full hidden lg:block">
|
<div className="w-2/5 h-full hidden lg:block">
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Card, CardBody, Typography, Spinner } from "@material-tailwind/react";
|
||||||
|
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||||
|
|
||||||
|
export function SignOut() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { logout } = useAuth();
|
||||||
|
const [status, setStatus] = useState("abmelden...");
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const performLogout = async () => {
|
||||||
|
try {
|
||||||
|
setStatus("Abmeldung läuft …");
|
||||||
|
setError(null);
|
||||||
|
await logout();
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.error("⚠️ [Auth] Logout fehlgeschlagen:", err);
|
||||||
|
setError("Abmeldung fehlgeschlagen. Bitte versuche es erneut.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
navigate("/auth/sign-in", { replace: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
performLogout();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [logout, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="m-8 flex min-h-[60vh] items-center justify-center">
|
||||||
|
<Card className="w-full max-w-md border border-blue-gray-100 shadow-sm">
|
||||||
|
<CardBody className="space-y-4 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<Spinner className="h-5 w-5 text-blue-gray-500" />
|
||||||
|
<Typography color="blue-gray" className="text-sm font-medium">
|
||||||
|
{status}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<Typography color="red" className="text-sm">
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Typography className="text-xs text-blue-gray-400">
|
||||||
|
Du wirst automatisch weitergeleitet …
|
||||||
|
</Typography>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SignOut.displayName = "/src/pages/auth/sign-out.jsx";
|
||||||
@@ -5,3 +5,4 @@ export * from "@/pages/dashboard/users";
|
|||||||
export * from "@/pages/dashboard/usergroups";
|
export * from "@/pages/dashboard/usergroups";
|
||||||
export * from "@/pages/dashboard/userDetails";
|
export * from "@/pages/dashboard/userDetails";
|
||||||
export * from "@/pages/dashboard/userGroupDetail";
|
export * from "@/pages/dashboard/userGroupDetail";
|
||||||
|
export * from "@/pages/dashboard/securityPhrase";
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "@material-tailwind/react";
|
} from "@material-tailwind/react";
|
||||||
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
||||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||||
|
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||||
|
|
||||||
const UPDATE_STAGE_LABELS = {
|
const UPDATE_STAGE_LABELS = {
|
||||||
initializing: "Vorbereitung",
|
initializing: "Vorbereitung",
|
||||||
@@ -176,6 +177,9 @@ export function Logs() {
|
|||||||
const updateRunning = Boolean(updateState?.running);
|
const updateRunning = Boolean(updateState?.running);
|
||||||
const maintenanceLocked = maintenanceActive || updateRunning;
|
const maintenanceLocked = maintenanceActive || updateRunning;
|
||||||
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "–";
|
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "–";
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
const canDeleteLogs = hasPermission("logs-delete", "full");
|
||||||
|
const canExportLogs = hasPermission("logs-export", "full");
|
||||||
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -607,6 +611,10 @@ export function Logs() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const handleDeleteLog = async (id) => {
|
const handleDeleteLog = async (id) => {
|
||||||
|
if (!canDeleteLogs) {
|
||||||
|
setError("Dir fehlt die Berechtigung, einzelne Logeinträge zu löschen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!window.confirm("Diesen Log-Eintrag dauerhaft löschen?")) return;
|
if (!window.confirm("Diesen Log-Eintrag dauerhaft löschen?")) return;
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
@@ -622,6 +630,10 @@ export function Logs() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteFiltered = async () => {
|
const handleDeleteFiltered = async () => {
|
||||||
|
if (!canDeleteLogs) {
|
||||||
|
setError("Dir fehlt die Berechtigung, Logs zu löschen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!logs.length) return;
|
if (!logs.length) return;
|
||||||
if (!window.confirm("Alle angezeigten Logs (entsprechend Filter) löschen?")) return;
|
if (!window.confirm("Alle angezeigten Logs (entsprechend Filter) löschen?")) return;
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
@@ -642,6 +654,10 @@ export function Logs() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleExport = async (format) => {
|
const handleExport = async (format) => {
|
||||||
|
if (!canExportLogs) {
|
||||||
|
setError("Dir fehlt die Berechtigung, Logs zu exportieren.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
@@ -716,7 +732,10 @@ export function Logs() {
|
|||||||
{filtersOpen && (
|
{filtersOpen && (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
|
|
||||||
|
{(canExportLogs || canDeleteLogs) && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{canExportLogs && (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleExport('txt')}
|
onClick={() => handleExport('txt')}
|
||||||
disabled={actionLoading || loading}
|
disabled={actionLoading || loading}
|
||||||
@@ -727,12 +746,17 @@ export function Logs() {
|
|||||||
disabled={actionLoading || loading}
|
disabled={actionLoading || loading}
|
||||||
className="w-full md:flex-1">
|
className="w-full md:flex-1">
|
||||||
Export SQL</Button>
|
Export SQL</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{canDeleteLogs && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleDeleteFiltered}
|
onClick={handleDeleteFiltered}
|
||||||
disabled={actionLoading || loading || logs.length === 0}
|
disabled={actionLoading || loading || logs.length === 0}
|
||||||
className="w-full md:flex-1 bg-sunsetCoral-500 hover:bg-sunsetCoral-600">
|
className="w-full md:flex-1 bg-sunsetCoral-500 hover:bg-sunsetCoral-600">
|
||||||
Angezeigte löschen</Button>
|
Angezeigte löschen</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid gap-6 mt-10">
|
<div className="grid gap-6 mt-10">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
@@ -1270,6 +1294,7 @@ export function Logs() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 align-top">
|
<td className="px-6 py-4 align-top">
|
||||||
|
{canDeleteLogs && (
|
||||||
<Typography variant="small">
|
<Typography variant="small">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteLog(log.id)}
|
onClick={() => handleDeleteLog(log.id)}
|
||||||
@@ -1279,6 +1304,7 @@ export function Logs() {
|
|||||||
Löschen
|
Löschen
|
||||||
</button>
|
</button>
|
||||||
</Typography>
|
</Typography>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useToast } from "@/components/ToastProvider.jsx";
|
|
||||||
import { useMaintenance } from "@/components/MaintenanceProvider";
|
import { useMaintenance } from "@/components/MaintenanceProvider";
|
||||||
|
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Typography,
|
Typography,
|
||||||
@@ -110,7 +110,8 @@ const createEmptySshDraft = () => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function Maintenance() {
|
export function Maintenance() {
|
||||||
const { showToast } = useToast();
|
const { hasPermission, user: authUser } = useAuth();
|
||||||
|
const showToast = useCallback(() => { }, []);
|
||||||
const {
|
const {
|
||||||
maintenance: maintenanceMeta,
|
maintenance: maintenanceMeta,
|
||||||
update: updateState,
|
update: updateState,
|
||||||
@@ -133,6 +134,19 @@ export function Maintenance() {
|
|||||||
removeSuperuserAccount
|
removeSuperuserAccount
|
||||||
} = useMaintenance();
|
} = useMaintenance();
|
||||||
|
|
||||||
|
const isSuperuserAccount = Boolean(authUser?.isSuperuser);
|
||||||
|
const canViewMaintenance = Boolean(isSuperuserAccount || hasPermission("maintenance-access", "read"));
|
||||||
|
const canControlMaintenance = Boolean(isSuperuserAccount || hasPermission("maintenance-access", "full"));
|
||||||
|
const canViewServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-manage", "read"));
|
||||||
|
const canEditServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-manage", "full"));
|
||||||
|
const canDeleteServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-delete", "full"));
|
||||||
|
const canViewPortainer = Boolean(isSuperuserAccount || hasPermission("maintenance-portainer", "read"));
|
||||||
|
const canManagePortainerUpdate = Boolean(isSuperuserAccount || hasPermission("maintenance-update", "full"));
|
||||||
|
const canViewSsh = Boolean(isSuperuserAccount || hasPermission("maintenance-ssh-update", "read"));
|
||||||
|
const canManageSsh = Boolean(isSuperuserAccount || hasPermission("maintenance-ssh-update", "full"));
|
||||||
|
const canViewDuplicates = Boolean(isSuperuserAccount || hasPermission("maintenance-duplicates", "read"));
|
||||||
|
const canManageDuplicates = Boolean(isSuperuserAccount || hasPermission("maintenance-duplicates", "full"));
|
||||||
|
|
||||||
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
||||||
const maintenanceMessage = maintenanceMeta?.message;
|
const maintenanceMessage = maintenanceMeta?.message;
|
||||||
const maintenanceExtraType = maintenanceMeta?.extra?.type;
|
const maintenanceExtraType = maintenanceMeta?.extra?.type;
|
||||||
@@ -383,11 +397,12 @@ export function Maintenance() {
|
|||||||
}, [duplicates]);
|
}, [duplicates]);
|
||||||
|
|
||||||
const handleSshDraftChange = useCallback((field, value) => {
|
const handleSshDraftChange = useCallback((field, value) => {
|
||||||
|
if (!canManageSsh) return;
|
||||||
setSshDraft((prev) => ({ ...prev, [field]: value }));
|
setSshDraft((prev) => ({ ...prev, [field]: value }));
|
||||||
if (field === 'password') {
|
if (field === 'password') {
|
||||||
setSshPasswordStored(false);
|
setSshPasswordStored(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [canManageSsh]);
|
||||||
|
|
||||||
const normalizedSshDraft = useMemo(() => {
|
const normalizedSshDraft = useMemo(() => {
|
||||||
const normalized = {
|
const normalized = {
|
||||||
@@ -411,6 +426,7 @@ export function Maintenance() {
|
|||||||
}, [sshDraft, sshPasswordStored]);
|
}, [sshDraft, sshPasswordStored]);
|
||||||
|
|
||||||
const handleScriptSave = useCallback(async () => {
|
const handleScriptSave = useCallback(async () => {
|
||||||
|
if (!canManageSsh) return;
|
||||||
if (!scriptConfig) return;
|
if (!scriptConfig) return;
|
||||||
try {
|
try {
|
||||||
setScriptSaving(true);
|
setScriptSaving(true);
|
||||||
@@ -426,9 +442,10 @@ export function Maintenance() {
|
|||||||
} finally {
|
} finally {
|
||||||
setScriptSaving(false);
|
setScriptSaving(false);
|
||||||
}
|
}
|
||||||
}, [saveScript, scriptDraft, scriptConfig, showToast]);
|
}, [canManageSsh, saveScript, scriptDraft, scriptConfig, showToast]);
|
||||||
|
|
||||||
const handleScriptReset = useCallback(async () => {
|
const handleScriptReset = useCallback(async () => {
|
||||||
|
if (!canManageSsh) return;
|
||||||
try {
|
try {
|
||||||
setScriptSaving(true);
|
setScriptSaving(true);
|
||||||
await resetScript();
|
await resetScript();
|
||||||
@@ -443,9 +460,10 @@ export function Maintenance() {
|
|||||||
} finally {
|
} finally {
|
||||||
setScriptSaving(false);
|
setScriptSaving(false);
|
||||||
}
|
}
|
||||||
}, [resetScript, showToast]);
|
}, [canManageSsh, resetScript, showToast]);
|
||||||
|
|
||||||
const handleSshSaveConfig = useCallback(async () => {
|
const handleSshSaveConfig = useCallback(async () => {
|
||||||
|
if (!canManageSsh) return;
|
||||||
try {
|
try {
|
||||||
setSshSaving(true);
|
setSshSaving(true);
|
||||||
await saveSshConfig(normalizedSshDraft);
|
await saveSshConfig(normalizedSshDraft);
|
||||||
@@ -462,9 +480,10 @@ export function Maintenance() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSshSaving(false);
|
setSshSaving(false);
|
||||||
}
|
}
|
||||||
}, [normalizedSshDraft, saveSshConfig, showToast]);
|
}, [canManageSsh, normalizedSshDraft, saveSshConfig, showToast]);
|
||||||
|
|
||||||
const handleSshTestConnection = useCallback(async () => {
|
const handleSshTestConnection = useCallback(async () => {
|
||||||
|
if (!canManageSsh) return;
|
||||||
try {
|
try {
|
||||||
setSshTesting(true);
|
setSshTesting(true);
|
||||||
const result = await testSshConnection(normalizedSshDraft);
|
const result = await testSshConnection(normalizedSshDraft);
|
||||||
@@ -481,9 +500,10 @@ export function Maintenance() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSshTesting(false);
|
setSshTesting(false);
|
||||||
}
|
}
|
||||||
}, [normalizedSshDraft, testSshConnection, showToast]);
|
}, [canManageSsh, normalizedSshDraft, testSshConnection, showToast]);
|
||||||
|
|
||||||
const handleSshDeleteConfig = useCallback(async () => {
|
const handleSshDeleteConfig = useCallback(async () => {
|
||||||
|
if (!canManageSsh) return;
|
||||||
try {
|
try {
|
||||||
setSshDeleting(true);
|
setSshDeleting(true);
|
||||||
await deleteSshConfig();
|
await deleteSshConfig();
|
||||||
@@ -502,7 +522,7 @@ export function Maintenance() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSshDeleting(false);
|
setSshDeleting(false);
|
||||||
}
|
}
|
||||||
}, [deleteSshConfig, showToast]);
|
}, [canManageSsh, deleteSshConfig, showToast]);
|
||||||
|
|
||||||
const handleSuperuserDelete = useCallback(async () => {
|
const handleSuperuserDelete = useCallback(async () => {
|
||||||
if (superuserDeleteLoading || superuserStatusLoading || !superuserExists) {
|
if (superuserDeleteLoading || superuserStatusLoading || !superuserExists) {
|
||||||
@@ -549,6 +569,9 @@ export function Maintenance() {
|
|||||||
}, [superuserDeleteLoading, superuserStatusLoading, superuserExists, removeSuperuserAccount, loadSuperuserStatus, showToast]);
|
}, [superuserDeleteLoading, superuserStatusLoading, superuserExists, removeSuperuserAccount, loadSuperuserStatus, showToast]);
|
||||||
|
|
||||||
const handleDeleteEndpoint = useCallback(async (endpointId) => {
|
const handleDeleteEndpoint = useCallback(async (endpointId) => {
|
||||||
|
if (!canDeleteServers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (endpointDeleteId === endpointId) {
|
if (endpointDeleteId === endpointId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -584,9 +607,12 @@ export function Maintenance() {
|
|||||||
} finally {
|
} finally {
|
||||||
setEndpointDeleteId(null);
|
setEndpointDeleteId(null);
|
||||||
}
|
}
|
||||||
}, [deleteSetupEndpoint, endpointDeleteId, loadSetupResources, setupEndpoints, showToast]);
|
}, [canDeleteServers, deleteSetupEndpoint, endpointDeleteId, loadSetupResources, setupEndpoints, showToast]);
|
||||||
|
|
||||||
const handleDeleteServer = useCallback(async (serverId) => {
|
const handleDeleteServer = useCallback(async (serverId) => {
|
||||||
|
if (!canDeleteServers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (serverDeleteId === serverId) {
|
if (serverDeleteId === serverId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -628,16 +654,20 @@ export function Maintenance() {
|
|||||||
} finally {
|
} finally {
|
||||||
setServerDeleteId(null);
|
setServerDeleteId(null);
|
||||||
}
|
}
|
||||||
}, [deleteSetupServer, loadSetupResources, serverDeleteId, setupEndpoints, setupServers, showToast]);
|
}, [canDeleteServers, deleteSetupServer, loadSetupResources, serverDeleteId, setupEndpoints, setupServers, showToast]);
|
||||||
|
|
||||||
const handleApiKeyDraftChange = useCallback((serverId, value) => {
|
const handleApiKeyDraftChange = useCallback((serverId, value) => {
|
||||||
|
if (!canEditServers) return;
|
||||||
setApiKeyDrafts((prev) => ({
|
setApiKeyDrafts((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[serverId]: value
|
[serverId]: value
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, [canEditServers]);
|
||||||
|
|
||||||
const handleApiKeyUpdate = useCallback(async (serverId) => {
|
const handleApiKeyUpdate = useCallback(async (serverId) => {
|
||||||
|
if (!canEditServers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const draft = (apiKeyDrafts[serverId] ?? "").trim();
|
const draft = (apiKeyDrafts[serverId] ?? "").trim();
|
||||||
if (!draft) {
|
if (!draft) {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -674,9 +704,10 @@ export function Maintenance() {
|
|||||||
} finally {
|
} finally {
|
||||||
setApiKeyUpdatingId(null);
|
setApiKeyUpdatingId(null);
|
||||||
}
|
}
|
||||||
}, [apiKeyDrafts, apiKeyUpdatingId, loadSetupResources, showToast, updateSetupApiKey]);
|
}, [apiKeyDrafts, apiKeyUpdatingId, canEditServers, loadSetupResources, showToast, updateSetupApiKey]);
|
||||||
|
|
||||||
const handleMaintenanceToggle = useCallback(async (nextActive) => {
|
const handleMaintenanceToggle = useCallback(async (nextActive) => {
|
||||||
|
if (!canControlMaintenance) return;
|
||||||
if (maintenanceLoading || maintenanceToggleLoading) return;
|
if (maintenanceLoading || maintenanceToggleLoading) return;
|
||||||
if (nextActive === maintenanceActive) return;
|
if (nextActive === maintenanceActive) return;
|
||||||
if (updateRunning) {
|
if (updateRunning) {
|
||||||
@@ -713,9 +744,12 @@ export function Maintenance() {
|
|||||||
} finally {
|
} finally {
|
||||||
setMaintenanceToggleLoading(false);
|
setMaintenanceToggleLoading(false);
|
||||||
}
|
}
|
||||||
}, [maintenanceLoading, maintenanceToggleLoading, maintenanceActive, updateRunning, setMaintenanceMode, maintenanceMessage, maintenanceExtraType, showToast]);
|
}, [canControlMaintenance, maintenanceLoading, maintenanceToggleLoading, maintenanceActive, updateRunning, setMaintenanceMode, maintenanceMessage, maintenanceExtraType, showToast]);
|
||||||
|
|
||||||
const handleTriggerUpdate = useCallback(async () => {
|
const handleTriggerUpdate = useCallback(async () => {
|
||||||
|
if (!canManagePortainerUpdate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (maintenanceActive || updateRunning) {
|
if (maintenanceActive || updateRunning) {
|
||||||
showToast({
|
showToast({
|
||||||
variant: "warning",
|
variant: "warning",
|
||||||
@@ -751,9 +785,10 @@ export function Maintenance() {
|
|||||||
} finally {
|
} finally {
|
||||||
setUpdateActionLoading(false);
|
setUpdateActionLoading(false);
|
||||||
}
|
}
|
||||||
}, [maintenanceActive, updateRunning, portainerStatus, triggerUpdate, showToast]);
|
}, [canManagePortainerUpdate, maintenanceActive, updateRunning, portainerStatus, triggerUpdate, showToast]);
|
||||||
|
|
||||||
const handleCleanup = useCallback(async (entry) => {
|
const handleCleanup = useCallback(async (entry) => {
|
||||||
|
if (!canManageDuplicates) return;
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
if (maintenanceActive || updateRunning) {
|
if (maintenanceActive || updateRunning) {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -815,7 +850,7 @@ export function Maintenance() {
|
|||||||
} finally {
|
} finally {
|
||||||
setActiveCleanupId(null);
|
setActiveCleanupId(null);
|
||||||
}
|
}
|
||||||
}, [fetchDuplicates, maintenanceActive, showToast, updateRunning]);
|
}, [canManageDuplicates, fetchDuplicates, maintenanceActive, showToast, updateRunning]);
|
||||||
|
|
||||||
const maintenanceActivatedAt = maintenanceMeta?.activatedAt ? formatCreatedAt(maintenanceMeta.activatedAt) : null;
|
const maintenanceActivatedAt = maintenanceMeta?.activatedAt ? formatCreatedAt(maintenanceMeta.activatedAt) : null;
|
||||||
const maintenanceUpdatedAt = maintenanceMeta?.updatedAt ? formatCreatedAt(maintenanceMeta.updatedAt) : null;
|
const maintenanceUpdatedAt = maintenanceMeta?.updatedAt ? formatCreatedAt(maintenanceMeta.updatedAt) : null;
|
||||||
@@ -836,6 +871,10 @@ export function Maintenance() {
|
|||||||
|
|
||||||
const disableUpdateButton = updateRunning || scriptSaving || maintenanceLoading;
|
const disableUpdateButton = updateRunning || scriptSaving || maintenanceLoading;
|
||||||
|
|
||||||
|
if (!canViewMaintenance) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div className="mt-12 mb-8 flex flex-col gap-12">
|
<div className="mt-12 mb-8 flex flex-col gap-12">
|
||||||
@@ -854,6 +893,7 @@ export function Maintenance() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canViewMaintenance && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
||||||
<Typography
|
<Typography
|
||||||
@@ -874,7 +914,7 @@ export function Maintenance() {
|
|||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={maintenanceActive}
|
checked={maintenanceActive}
|
||||||
disabled={maintenanceToggleLoading || maintenanceLoading || updateRunning}
|
disabled={!canControlMaintenance || maintenanceToggleLoading || maintenanceLoading || updateRunning}
|
||||||
onChange={(event) => handleMaintenanceToggle(event.target.checked)}
|
onChange={(event) => handleMaintenanceToggle(event.target.checked)}
|
||||||
ripple={false}
|
ripple={false}
|
||||||
color="amber"
|
color="amber"
|
||||||
@@ -885,7 +925,9 @@ export function Maintenance() {
|
|||||||
)}
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canViewServers && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
||||||
<Typography
|
<Typography
|
||||||
@@ -958,7 +1000,7 @@ export function Maintenance() {
|
|||||||
label="Neuer API-Key"
|
label="Neuer API-Key"
|
||||||
value={apiKeyDrafts[server.id] ?? ""}
|
value={apiKeyDrafts[server.id] ?? ""}
|
||||||
onChange={(event) => handleApiKeyDraftChange(server.id, event.target.value)}
|
onChange={(event) => handleApiKeyDraftChange(server.id, event.target.value)}
|
||||||
disabled={setupResourcesLoading || apiKeyUpdatingId === server.id}
|
disabled={!canEditServers || setupResourcesLoading || apiKeyUpdatingId === server.id}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
<Button
|
<Button
|
||||||
@@ -966,7 +1008,7 @@ export function Maintenance() {
|
|||||||
color="red"
|
color="red"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDeleteServer(server.id)}
|
onClick={() => handleDeleteServer(server.id)}
|
||||||
disabled={setupResourcesLoading || serverDeleteId === server.id || endpointDeleteId !== null || apiKeyUpdatingId === server.id}
|
disabled={!canDeleteServers || setupResourcesLoading || serverDeleteId === server.id || endpointDeleteId !== null || apiKeyUpdatingId === server.id}
|
||||||
>
|
>
|
||||||
{serverDeleteId === server.id ? "Lösche…" : "Löschen"}
|
{serverDeleteId === server.id ? "Lösche…" : "Löschen"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -974,7 +1016,7 @@ export function Maintenance() {
|
|||||||
color="blue"
|
color="blue"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleApiKeyUpdate(server.id)}
|
onClick={() => handleApiKeyUpdate(server.id)}
|
||||||
disabled={setupResourcesLoading || apiKeyUpdatingId === server.id}
|
disabled={!canEditServers || setupResourcesLoading || apiKeyUpdatingId === server.id}
|
||||||
>
|
>
|
||||||
{apiKeyUpdatingId === server.id ? "Speichere…" : "API-Key speichern"}
|
{apiKeyUpdatingId === server.id ? "Speichere…" : "API-Key speichern"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1028,7 +1070,7 @@ export function Maintenance() {
|
|||||||
color="red"
|
color="red"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDeleteEndpoint(endpoint.id)}
|
onClick={() => handleDeleteEndpoint(endpoint.id)}
|
||||||
disabled={setupResourcesLoading || endpointDeleteId === endpoint.id || serverDeleteId !== null}
|
disabled={!canDeleteServers || setupResourcesLoading || endpointDeleteId === endpoint.id || serverDeleteId !== null}
|
||||||
>
|
>
|
||||||
{endpointDeleteId === endpoint.id ? "Lösche…" : "Löschen"}
|
{endpointDeleteId === endpoint.id ? "Lösche…" : "Löschen"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1060,7 +1102,9 @@ export function Maintenance() {
|
|||||||
)}
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canViewServers && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
||||||
<Typography
|
<Typography
|
||||||
@@ -1117,7 +1161,9 @@ export function Maintenance() {
|
|||||||
</p>
|
</p>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canViewPortainer && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
||||||
<Typography
|
<Typography
|
||||||
@@ -1294,6 +1340,7 @@ export function Maintenance() {
|
|||||||
|
|
||||||
|
|
||||||
<div className="mt-6 grid gap-6 lg:grid-cols-2 xl:grid-cols-3">
|
<div className="mt-6 grid gap-6 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{canViewSsh && (
|
||||||
<div className="rounded-md border border-gray-700/20 p-4">
|
<div className="rounded-md border border-gray-700/20 p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold">SSH-Verbindung</h3>
|
<h3 className="text-sm font-semibold">SSH-Verbindung</h3>
|
||||||
@@ -1305,7 +1352,7 @@ export function Maintenance() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={sshDraft.host}
|
value={sshDraft.host}
|
||||||
onChange={(event) => handleSshDraftChange('host', event.target.value)}
|
onChange={(event) => handleSshDraftChange('host', event.target.value)}
|
||||||
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
|
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
|
||||||
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 text-sm focus:border-purple-500 focus:outline-none"
|
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 text-sm focus:border-purple-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -1317,7 +1364,7 @@ export function Maintenance() {
|
|||||||
min="1"
|
min="1"
|
||||||
value={sshDraft.port}
|
value={sshDraft.port}
|
||||||
onChange={(event) => handleSshDraftChange('port', event.target.value)}
|
onChange={(event) => handleSshDraftChange('port', event.target.value)}
|
||||||
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
|
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
|
||||||
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 text-sm focus:border-purple-500 focus:outline-none"
|
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 text-sm focus:border-purple-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -1327,7 +1374,7 @@ export function Maintenance() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={sshDraft.username}
|
value={sshDraft.username}
|
||||||
onChange={(event) => handleSshDraftChange('username', event.target.value)}
|
onChange={(event) => handleSshDraftChange('username', event.target.value)}
|
||||||
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
|
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
|
||||||
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 text-sm focus:border-purple-500 focus:outline-none"
|
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 text-sm focus:border-purple-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -1338,7 +1385,7 @@ export function Maintenance() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword((prev) => !prev)}
|
onClick={() => setShowPassword((prev) => !prev)}
|
||||||
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
|
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
|
||||||
className="text-[11px] font-medium text-blue-500 transition hover:text-blue-800 disabled:opacity-50"
|
className="text-[11px] font-medium text-blue-500 transition hover:text-blue-800 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{showPassword ? 'Verbergen' : 'Anzeigen'}
|
{showPassword ? 'Verbergen' : 'Anzeigen'}
|
||||||
@@ -1349,7 +1396,7 @@ export function Maintenance() {
|
|||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={sshDraft.password}
|
value={sshDraft.password}
|
||||||
onChange={(event) => handleSshDraftChange('password', event.target.value)}
|
onChange={(event) => handleSshDraftChange('password', event.target.value)}
|
||||||
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
|
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
|
||||||
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 text-sm focus:border-purple-500 focus:outline-none"
|
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 text-sm focus:border-purple-500 focus:outline-none"
|
||||||
placeholder="Passwort für den SSH-Benutzer"
|
placeholder="Passwort für den SSH-Benutzer"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
@@ -1367,7 +1414,7 @@ export function Maintenance() {
|
|||||||
rows={3}
|
rows={3}
|
||||||
value={sshDraft.extraSshArgs}
|
value={sshDraft.extraSshArgs}
|
||||||
onChange={(event) => handleSshDraftChange('extraSshArgs', event.target.value)}
|
onChange={(event) => handleSshDraftChange('extraSshArgs', event.target.value)}
|
||||||
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
|
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
|
||||||
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 font-mono text-[11px] focus:border-purple-500 focus:outline-none"
|
className="w-full rounded-md border border-gray-700 bg-gray-950/70 px-3 py-2 font-mono text-[11px] focus:border-purple-500 focus:outline-none"
|
||||||
placeholder="je Zeile ein Argument (optional)"
|
placeholder="je Zeile ein Argument (optional)"
|
||||||
/>
|
/>
|
||||||
@@ -1377,14 +1424,14 @@ export function Maintenance() {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSshSaveConfig}
|
onClick={handleSshSaveConfig}
|
||||||
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
|
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
|
||||||
className="mt-3 w-full">
|
className="mt-3 w-full">
|
||||||
{sshSaving ? 'Speichern…' : 'SSH-Konfiguration speichern'}
|
{sshSaving ? 'Speichern…' : 'SSH-Konfiguration speichern'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSshTestConnection}
|
onClick={handleSshTestConnection}
|
||||||
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
|
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
|
||||||
className="w-full bg-arcticBlue-500 hover:bg-arcticBlue-600">
|
className="w-full bg-arcticBlue-500 hover:bg-arcticBlue-600">
|
||||||
{sshTesting ? 'Test läuft…' : 'Verbindung testen'}
|
{sshTesting ? 'Test läuft…' : 'Verbindung testen'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1392,7 +1439,7 @@ export function Maintenance() {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSshDeleteConfig}
|
onClick={handleSshDeleteConfig}
|
||||||
disabled={sshSaving || sshTesting || sshDeleting || updateRunning}
|
disabled={!canManageSsh || sshSaving || sshTesting || sshDeleting || updateRunning}
|
||||||
className="w-full hover:bg-sunsetCoral-600 bg-sunsetCoral-500"
|
className="w-full hover:bg-sunsetCoral-600 bg-sunsetCoral-500"
|
||||||
>
|
>
|
||||||
{sshDeleting ? 'Löschen…' : 'SSH-Einstellungen löschen'}
|
{sshDeleting ? 'Löschen…' : 'SSH-Einstellungen löschen'}
|
||||||
@@ -1404,7 +1451,9 @@ export function Maintenance() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canViewSsh && (
|
||||||
<div className="rounded-md border border-gray-700/20 p-4">
|
<div className="rounded-md border border-gray-700/20 p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold">Update-Skript</h3>
|
<h3 className="text-sm font-semibold">Update-Skript</h3>
|
||||||
@@ -1414,20 +1463,20 @@ export function Maintenance() {
|
|||||||
value={scriptDraft}
|
value={scriptDraft}
|
||||||
onChange={(event) => setScriptDraft(event.target.value)}
|
onChange={(event) => setScriptDraft(event.target.value)}
|
||||||
rows={10}
|
rows={10}
|
||||||
disabled={scriptSaving || updateRunning}
|
disabled={!canManageSsh || scriptSaving || updateRunning}
|
||||||
className="mt-3 w-full rounded-md border border-gray-700/20 px-3 py-2 font-mono text-xs focus:border-purple-500 focus:outline-none"
|
className="mt-3 w-full rounded-md border border-gray-700/20 px-3 py-2 font-mono text-xs focus:border-purple-500 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<div className="mt-3 grid gap-2">
|
<div className="mt-3 grid gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleScriptSave}
|
onClick={handleScriptSave}
|
||||||
disabled={!scriptIsDirty || scriptSaving || updateRunning}
|
disabled={!canManageSsh || !scriptIsDirty || scriptSaving || updateRunning}
|
||||||
className="mt-3 w-full">
|
className="mt-3 w-full">
|
||||||
Speichern
|
Speichern
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={handleScriptReset}
|
onClick={handleScriptReset}
|
||||||
disabled={!scriptConfig || scriptConfig.source !== "custom" || scriptSaving || updateRunning}
|
disabled={!canManageSsh || !scriptConfig || scriptConfig.source !== "custom" || scriptSaving || updateRunning}
|
||||||
className="w-full hover:bg-sunsetCoral-600 bg-sunsetCoral-500">
|
className="w-full hover:bg-sunsetCoral-600 bg-sunsetCoral-500">
|
||||||
Standard wiederherstellen
|
Standard wiederherstellen
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1439,7 +1488,9 @@ export function Maintenance() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canViewSsh && (
|
||||||
<div className="rounded-md border border-gray-700/20 p-4">
|
<div className="rounded-md border border-gray-700/20 p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold">Update-Status</h3>
|
<h3 className="text-sm font-semibold">Update-Status</h3>
|
||||||
@@ -1501,19 +1552,22 @@ export function Maintenance() {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleTriggerUpdate}
|
onClick={handleTriggerUpdate}
|
||||||
disabled={disableUpdateButton || updateActionLoading}
|
disabled={!canManagePortainerUpdate || disableUpdateButton || updateActionLoading}
|
||||||
className="mt-3 w-full">
|
className="mt-3 w-full">
|
||||||
{updateActionLoading ? "Update wird gestartet…" : "Portainer aktualisieren"}
|
{updateActionLoading ? "Update wird gestartet…" : "Portainer aktualisieren"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canViewDuplicates && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
|
||||||
<Typography
|
<Typography
|
||||||
@@ -1527,7 +1581,7 @@ export function Maintenance() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody className="flex flex-col gap-4 p-4">
|
<CardBody className="flex flex-col gap-4 p-4">
|
||||||
<div className="rounded-lg border border-warmAmberGlow-600 bg-warmAmberGlow-700 px-4 py-3 text-sm text-white">
|
<div className="rounded-lg border border-warmAmberGlow-600 bg-warmAmberGlow-700 px-4 py-3 text-sm text-white">
|
||||||
Das Entfernen von deppelten Stacks ist noch nicht getestet. Bitte nicht in Produktivumgebungen einsetzen.
|
Das Entfernen von doppelten Stacks ist noch nicht getestet. Bitte nicht in Produktivumgebungen einsetzen.
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -1605,7 +1659,7 @@ export function Maintenance() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleCleanup(entry)}
|
onClick={() => handleCleanup(entry)}
|
||||||
disabled={isDuplicatesDisabled || isProcessing || duplicatesRefreshing || duplicatesLoading}
|
disabled={!canManageDuplicates || isDuplicatesDisabled || isProcessing || duplicatesRefreshing || duplicatesLoading}
|
||||||
className="self-start rounded-lg bg-sunsetCoral-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-sunsetCoral-600 disabled:opacity-50"
|
className="self-start rounded-lg bg-sunsetCoral-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-sunsetCoral-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isProcessing ? "Bereinigung läuft…" : `Bereinigen (${duplicatesForEntry.length})`}
|
{isProcessing ? "Bereinigung läuft…" : `Bereinigen (${duplicatesForEntry.length})`}
|
||||||
@@ -1634,6 +1688,7 @@ export function Maintenance() {
|
|||||||
)}
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Spinner,
|
||||||
|
Alert
|
||||||
|
} from "@material-tailwind/react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { ArrowDownTrayIcon, ArrowPathIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||||
|
|
||||||
|
const formatWordsAsText = (words = []) => {
|
||||||
|
if (!Array.isArray(words) || words.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const rows = [];
|
||||||
|
for (let index = 0; index < words.length; index += 4) {
|
||||||
|
rows.push(words.slice(index, index + 4).join(" "));
|
||||||
|
}
|
||||||
|
return rows.join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupWords = (words = []) => {
|
||||||
|
if (!Array.isArray(words)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const rows = [];
|
||||||
|
for (let index = 0; index < words.length; index += 4) {
|
||||||
|
rows.push(words.slice(index, index + 4));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SecurityPhrase() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, setSession, refreshSession } = useAuth();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [words, setWords] = useState([]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [downloaded, setDownloaded] = useState(false);
|
||||||
|
|
||||||
|
const requiresDownload = Boolean(user?.requiresSecurityPhraseDownload);
|
||||||
|
|
||||||
|
const groupedWords = useMemo(() => groupWords(words), [words]);
|
||||||
|
|
||||||
|
const triggerDownload = useCallback((content) => {
|
||||||
|
if (!content) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
|
||||||
|
const baseName = user?.username ? `${user.username}-sicherheitsschluessel` : "security-phrase";
|
||||||
|
link.download = `${baseName}.txt`;
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, [user?.username]);
|
||||||
|
|
||||||
|
const fetchPhrase = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await axios.get("/api/users/me/security-phrase");
|
||||||
|
const phraseWords = Array.isArray(response.data?.item?.words) ? response.data.item.words : [];
|
||||||
|
if (!phraseWords.length) {
|
||||||
|
throw new Error("EMPTY_SECURITY_PHRASE");
|
||||||
|
}
|
||||||
|
setWords(phraseWords);
|
||||||
|
setDownloaded(false);
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.response?.status === 409 || err?.response?.data?.error === "SECURITY_PHRASE_ALREADY_DOWNLOADED") {
|
||||||
|
await refreshSession();
|
||||||
|
navigate("/dashboard/stacks", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = err?.response?.data?.error || err?.message || "Sicherheitsschlüssel konnte nicht geladen werden.";
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [navigate, refreshSession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
navigate("/auth/sign-in", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requiresDownload) {
|
||||||
|
navigate("/dashboard/stacks", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchPhrase();
|
||||||
|
}, [user, requiresDownload, fetchPhrase, navigate]);
|
||||||
|
|
||||||
|
const handleRetry = useCallback(() => {
|
||||||
|
fetchPhrase();
|
||||||
|
}, [fetchPhrase]);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(async () => {
|
||||||
|
if (saving || !Array.isArray(words) || words.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = formatWordsAsText(words);
|
||||||
|
triggerDownload(text);
|
||||||
|
|
||||||
|
const response = await axios.post("/api/users/me/security-phrase/downloaded");
|
||||||
|
const downloadedAt = response.data?.item?.downloadedAt || null;
|
||||||
|
const sessionPayload = response.data?.session;
|
||||||
|
|
||||||
|
if (sessionPayload?.user) {
|
||||||
|
setSession(sessionPayload);
|
||||||
|
} else {
|
||||||
|
await refreshSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
setDownloaded(Boolean(downloadedAt));
|
||||||
|
navigate("/dashboard/stacks", { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err?.response?.data?.error || err?.message || "Download konnte nicht bestätigt werden.";
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [words, saving, triggerDownload, refreshSession, navigate, setSession]);
|
||||||
|
|
||||||
|
if (!requiresDownload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center bg-blue-gray-50/50 px-4 py-10">
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<Typography variant="h2" className="font-semibold text-blue-gray-800">
|
||||||
|
Sicherheitsschlüssel sichern
|
||||||
|
</Typography>
|
||||||
|
<Typography color="blue-gray" className="mt-2 max-w-3xl text-base">
|
||||||
|
Bitte lade den einmaligen Sicherheitsschlüssel herunter und bewahre ihn sicher auf. Er wird benötigt, um dein Passwort zurückzusetzen.
|
||||||
|
Ohne den Download kannst du die Anwendung nicht weiter nutzen.
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="w-full max-w-3xl shadow-lg">
|
||||||
|
<CardBody className="space-y-6">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-10">
|
||||||
|
<Spinner className="h-10 w-10 text-blue-gray-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && error && (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
className="border border-red-200 bg-red-50 text-red-700"
|
||||||
|
icon={<ArrowPathIcon className="h-5 w-5" />}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<Typography variant="h6" color="red">
|
||||||
|
Fehler
|
||||||
|
</Typography>
|
||||||
|
<Typography color="red" className="text-sm">
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="red"
|
||||||
|
variant="text"
|
||||||
|
onClick={handleRetry}
|
||||||
|
className="normal-case"
|
||||||
|
>
|
||||||
|
Erneut versuchen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
<div className="rounded-lg border border-blue-gray-100 bg-blue-gray-50/60 p-6">
|
||||||
|
<Typography variant="h5" className="mb-4 text-blue-gray-800">
|
||||||
|
Deine Sicherheitsphrase
|
||||||
|
</Typography>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{groupedWords.map((row, rowIndex) => (
|
||||||
|
<div
|
||||||
|
key={`phrase-row-${rowIndex}`}
|
||||||
|
className="flex flex-wrap items-center justify-center gap-4 text-lg font-mono tracking-wide text-blue-gray-900 md:text-xl"
|
||||||
|
>
|
||||||
|
{row.map((word, wordIndex) => (
|
||||||
|
<span key={`phrase-${rowIndex}-${wordIndex}`} className="uppercase">
|
||||||
|
{word}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
|
||||||
|
<div className="text-sm text-blue-gray-600">
|
||||||
|
Der Download startet im TXT-Format. Bewahre die Datei sicher auf – sie kann später nicht erneut angezeigt werden.
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
color="blue"
|
||||||
|
size="md"
|
||||||
|
className="flex items-center gap-2 normal-case"
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={saving || downloaded || words.length === 0}
|
||||||
|
>
|
||||||
|
<ArrowDownTrayIcon className="h-5 w-5" />
|
||||||
|
Sicherheitsschlüssel herunterladen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SecurityPhrase;
|
||||||
@@ -4,6 +4,7 @@ import { io } from "socket.io-client";
|
|||||||
import { useToast } from "@/components/ToastProvider.jsx";
|
import { useToast } from "@/components/ToastProvider.jsx";
|
||||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||||
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
||||||
|
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Typography,
|
Typography,
|
||||||
@@ -73,6 +74,10 @@ export function Stacks() {
|
|||||||
script: scriptConfig,
|
script: scriptConfig,
|
||||||
ssh: sshConfig,
|
ssh: sshConfig,
|
||||||
} = useMaintenance();
|
} = useMaintenance();
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
const canRedeploySingle = hasPermission("stacks-redeploy-single", "full");
|
||||||
|
const canRedeploySelection = hasPermission("stacks-redeploy-selection", "full");
|
||||||
|
const canRedeployAll = hasPermission("stacks-redeploy-all", "full");
|
||||||
|
|
||||||
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
const maintenanceActive = Boolean(maintenanceMeta?.active);
|
||||||
const maintenanceMessage = maintenanceMeta?.message;
|
const maintenanceMessage = maintenanceMeta?.message;
|
||||||
@@ -519,6 +524,7 @@ export function Stacks() {
|
|||||||
}, [filteredStacks.length, perPage, page, setPage]);
|
}, [filteredStacks.length, perPage, page, setPage]);
|
||||||
|
|
||||||
const toggleStackSelection = (stackId, disabled) => {
|
const toggleStackSelection = (stackId, disabled) => {
|
||||||
|
if (!canRedeploySelection) return;
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
setSelectedStackIds(prev =>
|
setSelectedStackIds(prev =>
|
||||||
prev.includes(stackId)
|
prev.includes(stackId)
|
||||||
@@ -527,8 +533,20 @@ export function Stacks() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canRedeploySelection) {
|
||||||
|
if (selectedStackIds.length) {
|
||||||
|
setSelectedStackIds([]);
|
||||||
|
}
|
||||||
|
if (selectionPromptVisible) {
|
||||||
|
setSelectionPromptVisible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [canRedeploySelection, selectedStackIds.length, selectionPromptVisible]);
|
||||||
|
|
||||||
|
|
||||||
const applySelectionPreference = (action) => {
|
const applySelectionPreference = (action) => {
|
||||||
|
if (!canRedeploySelection) return;
|
||||||
const remember = rememberSelectionChoice;
|
const remember = rememberSelectionChoice;
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
try {
|
try {
|
||||||
@@ -559,6 +577,7 @@ export function Stacks() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearStoredSelectionPreference = () => {
|
const clearStoredSelectionPreference = () => {
|
||||||
|
if (!canRedeploySelection) return;
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
try {
|
try {
|
||||||
window.sessionStorage.removeItem(SELECTION_PROMPT_STORAGE_KEY);
|
window.sessionStorage.removeItem(SELECTION_PROMPT_STORAGE_KEY);
|
||||||
@@ -577,15 +596,20 @@ export function Stacks() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearSelection = () => {
|
const clearSelection = () => {
|
||||||
|
if (!canRedeploySelection) return;
|
||||||
setSelectedStackIds([]);
|
setSelectedStackIds([]);
|
||||||
setSelectionPromptVisible(false);
|
setSelectionPromptVisible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChipRemove = (stackId) => {
|
const handleChipRemove = (stackId) => {
|
||||||
|
if (!canRedeploySelection) return;
|
||||||
setSelectedStackIds((prev) => prev.filter((id) => id !== stackId));
|
setSelectedStackIds((prev) => prev.filter((id) => id !== stackId));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRedeploy = async (stackId) => {
|
const handleRedeploy = async (stackId) => {
|
||||||
|
if (!canRedeploySingle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const snapshot = stacksByIdRef.current.get(stackId);
|
const snapshot = stacksByIdRef.current.get(stackId);
|
||||||
const phase = snapshot?.redeployPhase;
|
const phase = snapshot?.redeployPhase;
|
||||||
if (phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED) return;
|
if (phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED) return;
|
||||||
@@ -636,6 +660,9 @@ export function Stacks() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRedeployAll = async () => {
|
const handleRedeployAll = async () => {
|
||||||
|
if (!canRedeployAll) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!eligibleFilteredStacks.length) return;
|
if (!eligibleFilteredStacks.length) return;
|
||||||
|
|
||||||
const targetIdList = eligibleFilteredStacks.map((stack) => stack.Id);
|
const targetIdList = eligibleFilteredStacks.map((stack) => stack.Id);
|
||||||
@@ -687,6 +714,9 @@ export function Stacks() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRedeploySelection = async () => {
|
const handleRedeploySelection = async () => {
|
||||||
|
if (!canRedeploySelection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!selectedStackIds.length) return;
|
if (!selectedStackIds.length) return;
|
||||||
|
|
||||||
const eligibleIds = selectedStackIds.filter((id) => {
|
const eligibleIds = selectedStackIds.filter((id) => {
|
||||||
@@ -747,14 +777,16 @@ export function Stacks() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasSelection = selectedStackIds.length > 0;
|
const hasSelection = canRedeploySelection && selectedStackIds.length > 0;
|
||||||
const hasOutdatedStacks = eligibleFilteredStacks.length > 0;
|
const hasOutdatedStacks = eligibleFilteredStacks.length > 0;
|
||||||
const bulkButtonLabel = hasSelection
|
const showBulkButton = (canRedeploySelection && hasSelection) || canRedeployAll;
|
||||||
|
const bulkButtonLabel = canRedeploySelection && hasSelection
|
||||||
? `Redeploy Auswahl (${selectedStackIds.length})`
|
? `Redeploy Auswahl (${selectedStackIds.length})`
|
||||||
: 'Redeploy Alle';
|
: 'Redeploy Alle';
|
||||||
|
|
||||||
const bulkActionDisabled = maintenanceLocked || (hasSelection
|
const bulkActionDisabled = maintenanceLocked || (
|
||||||
? selectionPromptVisible || selectedStackIds.length === 0 || selectedStackIds.every(id => {
|
canRedeploySelection && hasSelection
|
||||||
|
? selectionPromptVisible || selectedStackIds.every(id => {
|
||||||
const targetStack = stacks.find(stack => stack.Id === id);
|
const targetStack = stacks.find(stack => stack.Id === id);
|
||||||
if (!targetStack) return true;
|
if (!targetStack) return true;
|
||||||
if (targetStack.updateStatus === '✅') return true;
|
if (targetStack.updateStatus === '✅') return true;
|
||||||
@@ -762,7 +794,8 @@ export function Stacks() {
|
|||||||
const phase = targetStack.redeployPhase;
|
const phase = targetStack.redeployPhase;
|
||||||
return phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED;
|
return phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED;
|
||||||
})
|
})
|
||||||
: !hasOutdatedStacks);
|
: (!canRedeployAll || !hasOutdatedStacks)
|
||||||
|
);
|
||||||
|
|
||||||
const handleBulkRedeploy = () => {
|
const handleBulkRedeploy = () => {
|
||||||
if (maintenanceLocked) {
|
if (maintenanceLocked) {
|
||||||
@@ -774,9 +807,9 @@ export function Stacks() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasSelection) {
|
if (canRedeploySelection && hasSelection) {
|
||||||
handleRedeploySelection();
|
handleRedeploySelection();
|
||||||
} else {
|
} else if (canRedeployAll) {
|
||||||
handleRedeployAll();
|
handleRedeployAll();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -868,7 +901,7 @@ export function Stacks() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-3 mt-5">
|
<div className="flex items-center justify-end gap-3 mt-5">
|
||||||
{selectionPreferenceStored && (
|
{canRedeploySelection && selectionPreferenceStored && (
|
||||||
<button
|
<button
|
||||||
onClick={clearStoredSelectionPreference}
|
onClick={clearStoredSelectionPreference}
|
||||||
className="block antialiased font-sans text-sm leading-normal text-inherit text-xs font-medium text-warmAmberGlow-500 underline underline-offset-2 transition hover:text-warmAmberGlow-600"
|
className="block antialiased font-sans text-sm leading-normal text-inherit text-xs font-medium text-warmAmberGlow-500 underline underline-offset-2 transition hover:text-warmAmberGlow-600"
|
||||||
@@ -881,7 +914,7 @@ export function Stacks() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectionPromptVisible && (
|
{canRedeploySelection && selectionPromptVisible && (
|
||||||
<div className="flex flex-col mt-5">
|
<div className="flex flex-col mt-5">
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 rounded-lg border border-arcticBlue-800 bg-arcticBlue-900/90 px-4 py-3 text-arcticBlue-100 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 rounded-lg border border-arcticBlue-800 bg-arcticBlue-900/90 px-4 py-3 text-arcticBlue-100 md:flex-row md:items-center md:justify-between">
|
||||||
@@ -921,8 +954,9 @@ export function Stacks() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{showBulkButton && (
|
||||||
<div id="collect" className="mt-8 flex flex-col gap-4 md:flex-row md:items-start md:gap-6">
|
<div id="collect" className="mt-8 flex flex-col gap-4 md:flex-row md:items-start md:gap-6">
|
||||||
{selectedStackIds.length > 0 && (
|
{canRedeploySelection && selectedStackIds.length > 0 && (
|
||||||
<div className="w-full md:order-first order-last md:w-3/4">
|
<div className="w-full md:order-first order-last md:w-3/4">
|
||||||
<div className="flex flex-col gap-3 text-sm text-gray-300">
|
<div className="flex flex-col gap-3 text-sm text-gray-300">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -964,6 +998,7 @@ export function Stacks() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -978,6 +1013,7 @@ export function Stacks() {
|
|||||||
const isCurrent = stack.updateStatus === '✅';
|
const isCurrent = stack.updateStatus === '✅';
|
||||||
const isSelfStack = Boolean(stack.redeployDisabled);
|
const isSelfStack = Boolean(stack.redeployDisabled);
|
||||||
const isSelectable = !isBusy && !isCurrent && !isSelfStack;
|
const isSelectable = !isBusy && !isCurrent && !isSelfStack;
|
||||||
|
const canSelectStack = canRedeploySelection && isSelectable;
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<Card key={stack.Id}>
|
<Card key={stack.Id}>
|
||||||
@@ -989,13 +1025,15 @@ export function Stacks() {
|
|||||||
${!isSelectable || isBusy ? 'opacity-75 bg-stormGrey-200/20' : ''}`}
|
${!isSelectable || isBusy ? 'opacity-75 bg-stormGrey-200/20' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-4" id="left">
|
<div className="flex items-center space-x-4" id="left">
|
||||||
|
{canRedeploySelection && (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={() => toggleStackSelection(stack.Id, !isSelectable)}
|
onChange={() => toggleStackSelection(stack.Id, !canSelectStack)}
|
||||||
className={`h-5 w-5 text-purple-500 focus:ring-purple-400 border-gray-600 bg-gray-900 rounded ${!isSelectable ? 'opacity-40 cursor-not-allowed' : ''}`}
|
className={`h-5 w-5 text-purple-500 focus:ring-purple-400 border-gray-600 bg-gray-900 rounded ${!canSelectStack ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||||
disabled={!isSelectable}
|
disabled={!canSelectStack}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<div className={`w-12 h-12 flex items-center justify-center rounded-full
|
<div className={`w-12 h-12 flex items-center justify-center rounded-full
|
||||||
${stack.updateStatus === '✅' ? 'bg-mossGreen-600' :
|
${stack.updateStatus === '✅' ? 'bg-mossGreen-600' :
|
||||||
stack.updateStatus === '⚠️' ? 'bg-warmAmberGlow-400' :
|
stack.updateStatus === '⚠️' ? 'bg-warmAmberGlow-400' :
|
||||||
@@ -1031,7 +1069,7 @@ export function Stacks() {
|
|||||||
<span className="antialiased font-sans font-light leading-normal text-xs uppercase tracking-wide text-stormGrey-500">Status</span>
|
<span className="antialiased font-sans font-light leading-normal text-xs uppercase tracking-wide text-stormGrey-500">Status</span>
|
||||||
<span className="text-mossGreen-600">Aktuell</span>
|
<span className="text-mossGreen-600">Aktuell</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (canRedeploySingle ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleRedeploy(stack.Id)}
|
onClick={() => handleRedeploy(stack.Id)}
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
@@ -1039,7 +1077,7 @@ export function Stacks() {
|
|||||||
>
|
>
|
||||||
Redeploy
|
Redeploy
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : null)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</CardBody>
|
</CardBody>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useToast } from "@/components/ToastProvider.jsx";
|
|||||||
import { platformSettingsData } from "@/data";
|
import { platformSettingsData } from "@/data";
|
||||||
import { AVATAR_COLORS } from "@/data/avatarColors.js";
|
import { AVATAR_COLORS } from "@/data/avatarColors.js";
|
||||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||||
|
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||||
|
|
||||||
const _ = AVATAR_COLORS.join(" ");
|
const _ = AVATAR_COLORS.join(" ");
|
||||||
|
|
||||||
@@ -55,7 +56,8 @@ const mapUser = (item) => ({
|
|||||||
lastLogin: item?.lastLogin || null,
|
lastLogin: item?.lastLogin || null,
|
||||||
createdAt: item?.createdAt || null,
|
createdAt: item?.createdAt || null,
|
||||||
updatedAt: item?.updatedAt || null,
|
updatedAt: item?.updatedAt || null,
|
||||||
groups: normalizeUserGroups(item?.groups)
|
groups: normalizeUserGroups(item?.groups),
|
||||||
|
securityPhraseDownloadedAt: item?.securityPhraseDownloadedAt || null
|
||||||
});
|
});
|
||||||
|
|
||||||
const extractPrimaryGroupId = (user) => {
|
const extractPrimaryGroupId = (user) => {
|
||||||
@@ -94,6 +96,7 @@ export function UserDetails() {
|
|||||||
const { userId } = useParams();
|
const { userId } = useParams();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const { maintenance } = useMaintenance();
|
const { maintenance } = useMaintenance();
|
||||||
|
const { hasPermission, user: authUser } = useAuth();
|
||||||
|
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [formValues, setFormValues] = useState(buildInitialFormValues(null));
|
const [formValues, setFormValues] = useState(buildInitialFormValues(null));
|
||||||
@@ -106,8 +109,23 @@ export function UserDetails() {
|
|||||||
const [groupsError, setGroupsError] = useState("");
|
const [groupsError, setGroupsError] = useState("");
|
||||||
const [savingUser, setSavingUser] = useState(false);
|
const [savingUser, setSavingUser] = useState(false);
|
||||||
const [saveError, setSaveError] = useState("");
|
const [saveError, setSaveError] = useState("");
|
||||||
|
const [securityPhraseWords, setSecurityPhraseWords] = useState([]);
|
||||||
|
const [securityPhraseDownloadedAt, setSecurityPhraseDownloadedAt] = useState(null);
|
||||||
|
const [securityPhraseLoading, setSecurityPhraseLoading] = useState(false);
|
||||||
|
const [securityPhraseError, setSecurityPhraseError] = useState("");
|
||||||
|
const [renewingSecurityPhrase, setRenewingSecurityPhrase] = useState(false);
|
||||||
|
|
||||||
const maintenanceActive = Boolean(maintenance?.active);
|
const maintenanceActive = Boolean(maintenance?.active);
|
||||||
|
|
||||||
|
const canEditUsers = Boolean(authUser?.isSuperuser || hasPermission("users-edit", "full"));
|
||||||
|
const canReadUsers = Boolean(authUser?.isSuperuser || hasPermission("users-edit", "read"));
|
||||||
|
const canViewSecurityPhrase = Boolean(authUser?.isSuperuser || hasPermission("users-security-phrase", "read"));
|
||||||
|
const canRenewSecurityPhrase = Boolean(authUser?.isSuperuser || hasPermission("users-security-phrase", "full"));
|
||||||
|
|
||||||
|
if (!canReadUsers) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const isSuperuserUser = useMemo(() => {
|
const isSuperuserUser = useMemo(() => {
|
||||||
if (!Array.isArray(user?.groups)) {
|
if (!Array.isArray(user?.groups)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -140,6 +158,9 @@ export function UserDetails() {
|
|||||||
throw new Error("USER_NOT_FOUND");
|
throw new Error("USER_NOT_FOUND");
|
||||||
}
|
}
|
||||||
setUser(item);
|
setUser(item);
|
||||||
|
setSecurityPhraseWords([]);
|
||||||
|
setSecurityPhraseDownloadedAt(item.securityPhraseDownloadedAt || null);
|
||||||
|
setSecurityPhraseError("");
|
||||||
const initialValues = buildInitialFormValues(item);
|
const initialValues = buildInitialFormValues(item);
|
||||||
initialFormValuesRef.current = { ...initialValues };
|
initialFormValuesRef.current = { ...initialValues };
|
||||||
setFormValues(initialValues);
|
setFormValues(initialValues);
|
||||||
@@ -255,30 +276,34 @@ export function UserDetails() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleUsernameChange = useCallback((event) => {
|
const handleUsernameChange = useCallback((event) => {
|
||||||
|
if (!canEditUsers) return;
|
||||||
const { value } = event.target;
|
const { value } = event.target;
|
||||||
setFormValues((prev) => ({
|
setFormValues((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
username: value
|
username: value
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, [canEditUsers]);
|
||||||
|
|
||||||
const handleEmailChange = useCallback((event) => {
|
const handleEmailChange = useCallback((event) => {
|
||||||
|
if (!canEditUsers) return;
|
||||||
const { value } = event.target;
|
const { value } = event.target;
|
||||||
setFormValues((prev) => ({
|
setFormValues((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
email: value
|
email: value
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, [canEditUsers]);
|
||||||
|
|
||||||
const handlePasswordChange = useCallback((event) => {
|
const handlePasswordChange = useCallback((event) => {
|
||||||
|
if (!canEditUsers) return;
|
||||||
const { value } = event.target;
|
const { value } = event.target;
|
||||||
setFormValues((prev) => ({
|
setFormValues((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
password: value
|
password: value
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, [canEditUsers]);
|
||||||
|
|
||||||
const handleGroupChange = useCallback((value) => {
|
const handleGroupChange = useCallback((value) => {
|
||||||
|
if (!canEditUsers) return;
|
||||||
if (!value) {
|
if (!value) {
|
||||||
setFormValues((prev) => ({
|
setFormValues((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -291,17 +316,18 @@ export function UserDetails() {
|
|||||||
...prev,
|
...prev,
|
||||||
groupId: Number.isFinite(numeric) && numeric > 0 ? numeric : null
|
groupId: Number.isFinite(numeric) && numeric > 0 ? numeric : null
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, [canEditUsers]);
|
||||||
|
|
||||||
const handleAvatarColorChange = useCallback((value) => {
|
const handleAvatarColorChange = useCallback((value) => {
|
||||||
|
if (!canEditUsers) return;
|
||||||
setFormValues((prev) => ({
|
setFormValues((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
avatarColor: value || ""
|
avatarColor: value || ""
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, [canEditUsers]);
|
||||||
|
|
||||||
const handleSaveUser = useCallback(async () => {
|
const handleSaveUser = useCallback(async () => {
|
||||||
if (!user || !hasChanges) {
|
if (!canEditUsers || !user || !hasChanges) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,7 +390,89 @@ export function UserDetails() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSavingUser(false);
|
setSavingUser(false);
|
||||||
}
|
}
|
||||||
}, [user, hasChanges, formValues, showToast, isSuperuserUser]);
|
}, [canEditUsers, user, hasChanges, formValues, showToast, isSuperuserUser]);
|
||||||
|
|
||||||
|
const fetchSecurityPhrase = useCallback(async () => {
|
||||||
|
if (!canViewSecurityPhrase || !numericUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSecurityPhraseLoading(true);
|
||||||
|
setSecurityPhraseError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/users/${numericUserId}/security-phrase`);
|
||||||
|
const words = Array.isArray(response.data?.item?.words) ? response.data.item.words : [];
|
||||||
|
setSecurityPhraseWords(words);
|
||||||
|
setSecurityPhraseDownloadedAt(response.data?.item?.downloadedAt || null);
|
||||||
|
} catch (err) {
|
||||||
|
const serverError = err.response?.data?.error;
|
||||||
|
let message = "Sicherheitsschlüssel konnte nicht geladen werden.";
|
||||||
|
|
||||||
|
if (serverError === "USER_NOT_FOUND") {
|
||||||
|
message = "Der Benutzer wurde nicht gefunden.";
|
||||||
|
} else if (serverError === "INVALID_USER_ID") {
|
||||||
|
message = "Die Benutzer-ID ist ungültig.";
|
||||||
|
} else if (serverError === "INSUFFICIENT_PERMISSIONS") {
|
||||||
|
message = "Keine Berechtigung zum Anzeigen des Sicherheitsschlüssels.";
|
||||||
|
}
|
||||||
|
|
||||||
|
setSecurityPhraseError(message);
|
||||||
|
setSecurityPhraseWords([]);
|
||||||
|
setSecurityPhraseDownloadedAt(null);
|
||||||
|
} finally {
|
||||||
|
setSecurityPhraseLoading(false);
|
||||||
|
}
|
||||||
|
}, [canViewSecurityPhrase, numericUserId]);
|
||||||
|
|
||||||
|
const handleReloadSecurityPhrase = useCallback(() => {
|
||||||
|
if (securityPhraseLoading || renewingSecurityPhrase) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchSecurityPhrase();
|
||||||
|
}, [fetchSecurityPhrase, renewingSecurityPhrase, securityPhraseLoading]);
|
||||||
|
|
||||||
|
const handleRenewSecurityPhrase = useCallback(async () => {
|
||||||
|
if (!canRenewSecurityPhrase || !numericUserId || maintenanceActive || renewingSecurityPhrase) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRenewingSecurityPhrase(true);
|
||||||
|
setSecurityPhraseError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`/api/users/${numericUserId}/security-phrase/renew`);
|
||||||
|
const words = Array.isArray(response.data?.item?.words) ? response.data.item.words : [];
|
||||||
|
setSecurityPhraseWords(words);
|
||||||
|
setSecurityPhraseDownloadedAt(response.data?.item?.downloadedAt || null);
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: "Sicherheitsschlüssel erneuert",
|
||||||
|
description: "Der Benutzer muss den neuen Schlüssel erneut herunterladen."
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const serverError = err.response?.data?.error;
|
||||||
|
let message = "Der Sicherheitsschlüssel konnte nicht erneuert werden.";
|
||||||
|
|
||||||
|
if (serverError === "USER_NOT_FOUND") {
|
||||||
|
message = "Der Benutzer wurde nicht gefunden.";
|
||||||
|
} else if (serverError === "INVALID_USER_ID") {
|
||||||
|
message = "Die Benutzer-ID ist ungültig.";
|
||||||
|
} else if (serverError === "INSUFFICIENT_PERMISSIONS") {
|
||||||
|
message = "Keine Berechtigung zum Erneuern des Sicherheitsschlüssels.";
|
||||||
|
}
|
||||||
|
|
||||||
|
setSecurityPhraseError(message);
|
||||||
|
} finally {
|
||||||
|
setRenewingSecurityPhrase(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
canRenewSecurityPhrase,
|
||||||
|
numericUserId,
|
||||||
|
maintenanceActive,
|
||||||
|
renewingSecurityPhrase,
|
||||||
|
showToast
|
||||||
|
]);
|
||||||
|
|
||||||
const avatarLabel = useMemo(() => {
|
const avatarLabel = useMemo(() => {
|
||||||
const source = (formValues.username || formValues.email || "").trim();
|
const source = (formValues.username || formValues.email || "").trim();
|
||||||
@@ -381,6 +489,37 @@ export function UserDetails() {
|
|||||||
return user?.avatarColor || "";
|
return user?.avatarColor || "";
|
||||||
}, [formValues.avatarColor, user]);
|
}, [formValues.avatarColor, user]);
|
||||||
|
|
||||||
|
const securityPhraseRows = useMemo(() => {
|
||||||
|
if (!Array.isArray(securityPhraseWords) || securityPhraseWords.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const rows = [];
|
||||||
|
for (let index = 0; index < securityPhraseWords.length; index += 4) {
|
||||||
|
rows.push(securityPhraseWords.slice(index, index + 4));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}, [securityPhraseWords]);
|
||||||
|
|
||||||
|
const securityPhraseStatus = useMemo(() => {
|
||||||
|
if (!securityPhraseDownloadedAt) {
|
||||||
|
return {
|
||||||
|
text: "Noch nicht heruntergeladen",
|
||||||
|
tone: "text-red-500"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const date = new Date(securityPhraseDownloadedAt);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return {
|
||||||
|
text: "Zuletzt heruntergeladen: unbekannt",
|
||||||
|
tone: "text-blue-gray-500"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text: `Zuletzt heruntergeladen: ${date.toLocaleString("de-DE")}`,
|
||||||
|
tone: "text-blue-gray-500"
|
||||||
|
};
|
||||||
|
}, [securityPhraseDownloadedAt]);
|
||||||
|
|
||||||
const filteredGroups = useMemo(() => {
|
const filteredGroups = useMemo(() => {
|
||||||
if (!Array.isArray(availableGroups)) {
|
if (!Array.isArray(availableGroups)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -402,9 +541,25 @@ export function UserDetails() {
|
|||||||
}
|
}
|
||||||
}, [filteredGroups, formValues.groupId, isSuperuserUser]);
|
}, [filteredGroups, formValues.groupId, isSuperuserUser]);
|
||||||
|
|
||||||
const selectDisabled = maintenanceActive || savingUser || !user || groupsLoading;
|
useEffect(() => {
|
||||||
const avatarSelectDisabled = maintenanceActive || savingUser || !user;
|
if (!hasLoaded) {
|
||||||
const inputDisabled = maintenanceActive || savingUser || !user;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canViewSecurityPhrase || !numericUserId) {
|
||||||
|
setSecurityPhraseWords([]);
|
||||||
|
setSecurityPhraseDownloadedAt(null);
|
||||||
|
setSecurityPhraseError("");
|
||||||
|
setSecurityPhraseLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchSecurityPhrase();
|
||||||
|
}, [hasLoaded, canViewSecurityPhrase, fetchSecurityPhrase, numericUserId]);
|
||||||
|
|
||||||
|
const inputDisabled = maintenanceActive || savingUser || !user || !canEditUsers;
|
||||||
|
const selectDisabled = maintenanceActive || savingUser || !user || groupsLoading || !canEditUsers || isSuperuserUser;
|
||||||
|
const avatarSelectDisabled = maintenanceActive || savingUser || !user || !canEditUsers;
|
||||||
const groupSelectValue = formValues.groupId ? String(formValues.groupId) : "";
|
const groupSelectValue = formValues.groupId ? String(formValues.groupId) : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -523,11 +678,6 @@ export function UserDetails() {
|
|||||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||||
Globale Rolle
|
Globale Rolle
|
||||||
</Typography>
|
</Typography>
|
||||||
{groupsError && !isSuperuserUser && (
|
|
||||||
<div className="mb-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
|
||||||
{groupsError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isSuperuserUser ? (
|
{isSuperuserUser ? (
|
||||||
<Typography className="block text-xs font-semibold uppercase text-blue-gray-500">
|
<Typography className="block text-xs font-semibold uppercase text-blue-gray-500">
|
||||||
Die Rolle für den Superuser kann nicht geändert werden.
|
Die Rolle für den Superuser kann nicht geändert werden.
|
||||||
@@ -615,6 +765,99 @@ export function UserDetails() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canViewSecurityPhrase && (
|
||||||
|
<div className="mt-10 border-t border-blue-gray-50 pt-8">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<Typography variant="h6" color="blue-gray">
|
||||||
|
Sicherheitsschlüssel
|
||||||
|
</Typography>
|
||||||
|
<Typography className={`text-sm ${securityPhraseStatus.tone}`}>
|
||||||
|
{securityPhraseStatus.text}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="blue"
|
||||||
|
className="normal-case"
|
||||||
|
onClick={handleReloadSecurityPhrase}
|
||||||
|
disabled={
|
||||||
|
!canViewSecurityPhrase ||
|
||||||
|
securityPhraseLoading ||
|
||||||
|
renewingSecurityPhrase
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Aktualisieren
|
||||||
|
</Button>
|
||||||
|
{canRenewSecurityPhrase && (
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
className="normal-case"
|
||||||
|
onClick={handleRenewSecurityPhrase}
|
||||||
|
disabled={
|
||||||
|
maintenanceActive ||
|
||||||
|
securityPhraseLoading ||
|
||||||
|
renewingSecurityPhrase
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{renewingSecurityPhrase ? "Erneuert ..." : "Schlüssel erneuern"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{securityPhraseLoading ? (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-blue-gray-50 bg-blue-gray-50/50 px-4 py-3 text-sm text-blue-gray-500">
|
||||||
|
<Spinner className="h-4 w-4" />
|
||||||
|
<span>Sicherheitsschlüssel wird geladen ...</span>
|
||||||
|
</div>
|
||||||
|
) : securityPhraseError ? (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
<span>{securityPhraseError}</span>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="sm"
|
||||||
|
color="red"
|
||||||
|
className="normal-case"
|
||||||
|
onClick={handleReloadSecurityPhrase}
|
||||||
|
disabled={securityPhraseLoading || renewingSecurityPhrase}
|
||||||
|
>
|
||||||
|
Erneut laden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : securityPhraseRows.length > 0 ? (
|
||||||
|
<div className="rounded-lg border border-blue-gray-100 bg-blue-gray-50/60 p-4">
|
||||||
|
<div className="space-y-2 text-center font-mono text-base tracking-wide text-blue-gray-900 md:text-lg">
|
||||||
|
{securityPhraseRows.map((row, rowIndex) => (
|
||||||
|
<div
|
||||||
|
key={`security-phrase-row-${rowIndex}`}
|
||||||
|
className="flex flex-wrap items-center justify-center gap-4"
|
||||||
|
>
|
||||||
|
{row.map((word, wordIndex) => (
|
||||||
|
<span
|
||||||
|
key={`security-phrase-${rowIndex}-${wordIndex}`}
|
||||||
|
className="uppercase"
|
||||||
|
>
|
||||||
|
{word}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Typography className="text-sm text-blue-gray-500">
|
||||||
|
Kein Sicherheitsschlüssel verfügbar.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{canRenewSecurityPhrase && (
|
||||||
|
<Typography className="mt-3 text-sm text-blue-gray-500">
|
||||||
|
Nach dem Erneuern muss der Benutzer den neuen Sicherheitsschlüssel erneut herunterladen.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card></div>
|
</Card></div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -10,11 +10,16 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Option,
|
Option,
|
||||||
Input,
|
Input,
|
||||||
Chip
|
Chip,
|
||||||
|
Radio,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemPrefix
|
||||||
} from "@material-tailwind/react";
|
} from "@material-tailwind/react";
|
||||||
|
|
||||||
import { useToast } from "@/components/ToastProvider.jsx";
|
import { useToast } from "@/components/ToastProvider.jsx";
|
||||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||||
|
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||||
import { AVATAR_COLORS } from "@/data/avatarColors.js";
|
import { AVATAR_COLORS } from "@/data/avatarColors.js";
|
||||||
|
|
||||||
const _ = AVATAR_COLORS.join(" ");
|
const _ = AVATAR_COLORS.join(" ");
|
||||||
@@ -53,34 +58,158 @@ const buildInitialFormValues = (group) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LEVEL_OPTIONS = [
|
||||||
|
{ value: "full", label: "Vollzugriff" },
|
||||||
|
{ value: "read", label: "Nur lesen" },
|
||||||
|
{ value: "none", label: "Kein Zugriff" }
|
||||||
|
];
|
||||||
|
|
||||||
export function UserGroupDetail() {
|
export function UserGroupDetail() {
|
||||||
const { groupId } = useParams();
|
const { groupId } = useParams();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const { maintenance } = useMaintenance();
|
const { maintenance } = useMaintenance();
|
||||||
|
const { hasPermission, user: authUser } = useAuth();
|
||||||
|
|
||||||
const [group, setGroup] = useState(null);
|
const [group, setGroup] = useState(null);
|
||||||
const [formValues, setFormValues] = useState(buildInitialFormValues(null));
|
const [formValues, setFormValues] = useState(buildInitialFormValues(null));
|
||||||
const initialFormValuesRef = useRef(buildInitialFormValues(null));
|
const initialFormValuesRef = useRef(buildInitialFormValues(null));
|
||||||
|
const [initialPermissionSelection, setInitialPermissionSelection] = useState({});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [hasLoaded, setHasLoaded] = useState(false);
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
const [savingGroup, setSavingGroup] = useState(false);
|
const [savingGroup, setSavingGroup] = useState(false);
|
||||||
const [saveError, setSaveError] = useState("");
|
const [saveError, setSaveError] = useState("");
|
||||||
|
const [permissionSections, setPermissionSections] = useState([]);
|
||||||
|
const [permissionSelection, setPermissionSelection] = useState({});
|
||||||
|
const [permissionDefaults, setPermissionDefaults] = useState({});
|
||||||
|
const [permissionsLoading, setPermissionsLoading] = useState(false);
|
||||||
|
const [permissionsError, setPermissionsError] = useState("");
|
||||||
|
|
||||||
const maintenanceActive = Boolean(maintenance?.active);
|
const maintenanceActive = Boolean(maintenance?.active);
|
||||||
const isSuperuserGroup = useMemo(() => (group?.name || "").toLowerCase() === "superuser", [group]);
|
const isSuperuserGroup = useMemo(() => (group?.name || "").toLowerCase() === "superuser", [group]);
|
||||||
|
const isSuperuserAccount = Boolean(authUser?.isSuperuser);
|
||||||
|
const canEditGroupDetails = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "full"));
|
||||||
|
const canViewPermissionSettings = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "read"));
|
||||||
|
const canAdjustPermissions = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "full"));
|
||||||
|
|
||||||
const numericGroupId = useMemo(() => {
|
const numericGroupId = useMemo(() => {
|
||||||
const candidate = Number(groupId);
|
const candidate = Number(groupId);
|
||||||
return Number.isFinite(candidate) ? candidate : null;
|
return Number.isFinite(candidate) ? candidate : null;
|
||||||
}, [groupId]);
|
}, [groupId]);
|
||||||
|
|
||||||
|
const fetchGroupPermissions = useCallback(
|
||||||
|
async (targetGroupId) => {
|
||||||
|
const numericTargetId = Number(targetGroupId);
|
||||||
|
if (!Number.isFinite(numericTargetId) || numericTargetId <= 0) {
|
||||||
|
setPermissionSections([]);
|
||||||
|
setPermissionSelection({});
|
||||||
|
setPermissionDefaults({});
|
||||||
|
setPermissionsError("");
|
||||||
|
setInitialPermissionSelection({});
|
||||||
|
setPermissionsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPermissionsLoading(true);
|
||||||
|
setPermissionsError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/groups/${numericTargetId}/permissions`);
|
||||||
|
const rawSections = Array.isArray(response.data?.sections) ? response.data.sections : [];
|
||||||
|
const rawValues =
|
||||||
|
response.data?.values && typeof response.data.values === "object" ? response.data.values : {};
|
||||||
|
|
||||||
|
const defaults = {};
|
||||||
|
const initialSelection = {};
|
||||||
|
|
||||||
|
const normalizeItem = (item, index) => {
|
||||||
|
if (!item || typeof item !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const defaultLevel =
|
||||||
|
typeof item.defaultLevel === "string" && item.defaultLevel ? item.defaultLevel : "none";
|
||||||
|
defaults[item.key] = defaultLevel;
|
||||||
|
const assigned = rawValues[item.key];
|
||||||
|
initialSelection[item.key] = typeof assigned === "string" && assigned ? assigned : defaultLevel;
|
||||||
|
|
||||||
|
const availableLevels =
|
||||||
|
Array.isArray(item.availableLevels) && item.availableLevels.length
|
||||||
|
? item.availableLevels
|
||||||
|
: LEVEL_OPTIONS.map((option) => option.value);
|
||||||
|
|
||||||
|
const dependencies = Array.isArray(item.dependencies) ? item.dependencies : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
availableLevels,
|
||||||
|
dependencies,
|
||||||
|
sortOrder: typeof item.sortOrder === "number" ? item.sortOrder : index
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedSections = rawSections.map((section, sectionIndex) => {
|
||||||
|
const sectionItems = Array.isArray(section.items)
|
||||||
|
? section.items.map((item, itemIdx) => normalizeItem(item, itemIdx)).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const sectionGroups = Array.isArray(section.groups)
|
||||||
|
? section.groups.map((group, groupIdx) => {
|
||||||
|
const groupItems = Array.isArray(group.items)
|
||||||
|
? group.items.map((item, itemIdx) => normalizeItem(item, itemIdx)).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
sortOrder: typeof group.sortOrder === "number" ? group.sortOrder : groupIdx,
|
||||||
|
items: groupItems
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...section,
|
||||||
|
sortOrder: typeof section.sortOrder === "number" ? section.sortOrder : sectionIndex,
|
||||||
|
hasNavigation: Boolean(section.hasNavigation),
|
||||||
|
items: sectionItems,
|
||||||
|
groups: sectionGroups
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setPermissionSections(normalizedSections);
|
||||||
|
setPermissionDefaults(defaults);
|
||||||
|
setPermissionSelection({ ...initialSelection });
|
||||||
|
setInitialPermissionSelection({ ...initialSelection });
|
||||||
|
} catch (err) {
|
||||||
|
let message = "Die Berechtigungen konnten nicht geladen werden.";
|
||||||
|
if (err.response?.data?.error === "GROUP_NOT_FOUND") {
|
||||||
|
message = "Die angeforderte Benutzergruppe wurde nicht gefunden.";
|
||||||
|
}
|
||||||
|
setPermissionsError(message);
|
||||||
|
setPermissionSections([]);
|
||||||
|
setPermissionSelection({});
|
||||||
|
setPermissionDefaults({});
|
||||||
|
setInitialPermissionSelection({});
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: "Fehler beim Laden",
|
||||||
|
description: message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPermissionsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showToast]
|
||||||
|
);
|
||||||
|
|
||||||
const fetchGroupDetails = useCallback(async () => {
|
const fetchGroupDetails = useCallback(async () => {
|
||||||
if (!numericGroupId) {
|
if (!numericGroupId) {
|
||||||
setError("Ungültige Gruppen-ID.");
|
setError("Ungültige Gruppen-ID.");
|
||||||
setGroup(null);
|
setGroup(null);
|
||||||
setFormValues(buildInitialFormValues(null));
|
setFormValues(buildInitialFormValues(null));
|
||||||
initialFormValuesRef.current = buildInitialFormValues(null);
|
initialFormValuesRef.current = buildInitialFormValues(null);
|
||||||
|
setPermissionSections([]);
|
||||||
|
setPermissionSelection({});
|
||||||
|
setPermissionDefaults({});
|
||||||
|
setPermissionsError("");
|
||||||
setHasLoaded(true);
|
setHasLoaded(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -99,6 +228,25 @@ export function UserGroupDetail() {
|
|||||||
initialFormValuesRef.current = { ...initialValues };
|
initialFormValuesRef.current = { ...initialValues };
|
||||||
setFormValues(initialValues);
|
setFormValues(initialValues);
|
||||||
setSaveError("");
|
setSaveError("");
|
||||||
|
|
||||||
|
const superuserDetected = (item.name || "").toLowerCase() === "superuser";
|
||||||
|
if (superuserDetected) {
|
||||||
|
setPermissionSections([]);
|
||||||
|
setPermissionSelection({});
|
||||||
|
setPermissionDefaults({});
|
||||||
|
setPermissionsError("");
|
||||||
|
setInitialPermissionSelection({});
|
||||||
|
setPermissionsLoading(false);
|
||||||
|
} else if (canViewPermissionSettings) {
|
||||||
|
fetchGroupPermissions(item.id);
|
||||||
|
} else {
|
||||||
|
setPermissionSections([]);
|
||||||
|
setPermissionSelection({});
|
||||||
|
setPermissionDefaults({});
|
||||||
|
setPermissionsError("");
|
||||||
|
setInitialPermissionSelection({});
|
||||||
|
setPermissionsLoading(false);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const serverError = err.response?.data?.error;
|
const serverError = err.response?.data?.error;
|
||||||
let message = "Gruppendetails konnten nicht geladen werden.";
|
let message = "Gruppendetails konnten nicht geladen werden.";
|
||||||
@@ -114,6 +262,11 @@ export function UserGroupDetail() {
|
|||||||
setGroup(null);
|
setGroup(null);
|
||||||
initialFormValuesRef.current = buildInitialFormValues(null);
|
initialFormValuesRef.current = buildInitialFormValues(null);
|
||||||
setFormValues(buildInitialFormValues(null));
|
setFormValues(buildInitialFormValues(null));
|
||||||
|
setPermissionSections([]);
|
||||||
|
setPermissionSelection({});
|
||||||
|
setPermissionDefaults({});
|
||||||
|
setPermissionsError("");
|
||||||
|
setPermissionsLoading(false);
|
||||||
setError(message);
|
setError(message);
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
@@ -124,13 +277,13 @@ export function UserGroupDetail() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
setHasLoaded(true);
|
setHasLoaded(true);
|
||||||
}
|
}
|
||||||
}, [numericGroupId, showToast]);
|
}, [numericGroupId, showToast, fetchGroupPermissions, canViewPermissionSettings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchGroupDetails();
|
fetchGroupDetails();
|
||||||
}, [fetchGroupDetails]);
|
}, [fetchGroupDetails]);
|
||||||
|
|
||||||
const hasChanges = useMemo(() => {
|
const detailsChanged = useMemo(() => {
|
||||||
if (!hasLoaded || !group) {
|
if (!hasLoaded || !group) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -156,6 +309,28 @@ export function UserGroupDetail() {
|
|||||||
);
|
);
|
||||||
}, [formValues, hasLoaded, group]);
|
}, [formValues, hasLoaded, group]);
|
||||||
|
|
||||||
|
const permissionsChanged = useMemo(() => {
|
||||||
|
if (isSuperuserGroup) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const initial = initialPermissionSelection || {};
|
||||||
|
const current = permissionSelection || {};
|
||||||
|
const allKeys = new Set([...Object.keys(initial), ...Object.keys(current)]);
|
||||||
|
for (const key of allKeys) {
|
||||||
|
const initialValue = initial[key] ?? null;
|
||||||
|
const currentValue = current[key] ?? null;
|
||||||
|
if (initialValue !== currentValue) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [permissionSelection, initialPermissionSelection, isSuperuserGroup]);
|
||||||
|
|
||||||
|
const hasChanges = useMemo(
|
||||||
|
() => detailsChanged || permissionsChanged,
|
||||||
|
[detailsChanged, permissionsChanged]
|
||||||
|
);
|
||||||
|
|
||||||
const handleNameChange = useCallback((event) => {
|
const handleNameChange = useCallback((event) => {
|
||||||
const { value } = event.target;
|
const { value } = event.target;
|
||||||
setFormValues((prev) => ({
|
setFormValues((prev) => ({
|
||||||
@@ -180,7 +355,21 @@ export function UserGroupDetail() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSaveGroup = useCallback(async () => {
|
const handleSaveGroup = useCallback(async () => {
|
||||||
if (!group || !hasChanges) {
|
if (!group || (!detailsChanged && !permissionsChanged)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissionsChanged && isSuperuserGroup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailsChanged && !canEditGroupDetails) {
|
||||||
|
setSaveError("Dir fehlt die Berechtigung, diese Gruppe zu bearbeiten.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissionsChanged && !canAdjustPermissions) {
|
||||||
|
setSaveError("Dir fehlt die Berechtigung, die Gruppenrechte anzupassen.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +377,7 @@ export function UserGroupDetail() {
|
|||||||
setSaveError("");
|
setSaveError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (detailsChanged) {
|
||||||
const payload = {
|
const payload = {
|
||||||
avatarColor: formValues.avatarColor
|
avatarColor: formValues.avatarColor
|
||||||
};
|
};
|
||||||
@@ -199,18 +389,39 @@ export function UserGroupDetail() {
|
|||||||
|
|
||||||
const response = await axios.put(`/api/groups/${group.id}`, payload);
|
const response = await axios.put(`/api/groups/${group.id}`, payload);
|
||||||
const updated = mapGroup(response.data?.item || response.data?.group);
|
const updated = mapGroup(response.data?.item || response.data?.group);
|
||||||
|
if (updated?.id) {
|
||||||
setGroup(updated);
|
setGroup(updated);
|
||||||
const nextInitial = buildInitialFormValues(updated);
|
const nextInitial = buildInitialFormValues(updated);
|
||||||
initialFormValuesRef.current = { ...nextInitial };
|
initialFormValuesRef.current = { ...nextInitial };
|
||||||
setFormValues(nextInitial);
|
setFormValues(nextInitial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissionsChanged && !isSuperuserGroup) {
|
||||||
|
await axios.put(`/api/groups/${group.id}/permissions`, {
|
||||||
|
values: permissionSelection
|
||||||
|
});
|
||||||
|
setInitialPermissionSelection({ ...permissionSelection });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailsChanged) {
|
||||||
showToast({
|
showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
title: "Gruppe gespeichert",
|
title: "Gruppe gespeichert",
|
||||||
description: "Die Änderungen wurden erfolgreich gespeichert."
|
description: "Die Änderungen wurden erfolgreich gespeichert."
|
||||||
});
|
});
|
||||||
|
} else if (permissionsChanged) {
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
title: "Berechtigungen gespeichert",
|
||||||
|
description: "Die Berechtigungen wurden erfolgreich gespeichert."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaveError("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const serverError = err.response?.data?.error;
|
const serverError = err.response?.data?.error;
|
||||||
let message = "Die Gruppe konnte nicht gespeichert werden.";
|
let message = "Die Änderungen konnten nicht gespeichert werden.";
|
||||||
|
|
||||||
if (serverError === "GROUP_NAME_REQUIRED") {
|
if (serverError === "GROUP_NAME_REQUIRED") {
|
||||||
message = "Bitte einen Gruppennamen angeben.";
|
message = "Bitte einen Gruppennamen angeben.";
|
||||||
@@ -221,7 +432,13 @@ export function UserGroupDetail() {
|
|||||||
} else if (serverError === "GROUP_NOT_FOUND") {
|
} else if (serverError === "GROUP_NOT_FOUND") {
|
||||||
message = "Die Benutzergruppe wurde nicht gefunden.";
|
message = "Die Benutzergruppe wurde nicht gefunden.";
|
||||||
} else if (serverError === "GROUP_SUPERUSER_PROTECTED") {
|
} else if (serverError === "GROUP_SUPERUSER_PROTECTED") {
|
||||||
message = "Für die Superuser-Gruppe kann nur die Avatar-Farbe angepasst werden.";
|
message = "Für die Superuser-Gruppe können Berechtigungen nicht angepasst werden.";
|
||||||
|
} else if (serverError === "PERMISSION_INVALID_PAYLOAD") {
|
||||||
|
message = "Ungültige Berechtigungsdaten übermittelt.";
|
||||||
|
} else if (serverError === "PERMISSION_INVALID_LEVEL") {
|
||||||
|
message = "Für mindestens eine Berechtigung wurde ein nicht erlaubter Wert übermittelt.";
|
||||||
|
} else if (serverError === "PERMISSION_UNKNOWN_KEY") {
|
||||||
|
message = "Es wurde eine unbekannte Berechtigung übermittelt.";
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaveError(message);
|
setSaveError(message);
|
||||||
@@ -233,7 +450,17 @@ export function UserGroupDetail() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSavingGroup(false);
|
setSavingGroup(false);
|
||||||
}
|
}
|
||||||
}, [group, hasChanges, formValues, showToast, isSuperuserGroup]);
|
}, [
|
||||||
|
group,
|
||||||
|
detailsChanged,
|
||||||
|
permissionsChanged,
|
||||||
|
formValues,
|
||||||
|
showToast,
|
||||||
|
isSuperuserGroup,
|
||||||
|
permissionSelection,
|
||||||
|
canEditGroupDetails,
|
||||||
|
canAdjustPermissions
|
||||||
|
]);
|
||||||
|
|
||||||
const avatarLabel = useMemo(() => {
|
const avatarLabel = useMemo(() => {
|
||||||
const source = (formValues.name || formValues.description || "").trim();
|
const source = (formValues.name || formValues.description || "").trim();
|
||||||
@@ -250,10 +477,168 @@ export function UserGroupDetail() {
|
|||||||
return group?.avatarColor || "";
|
return group?.avatarColor || "";
|
||||||
}, [formValues.avatarColor, group]);
|
}, [formValues.avatarColor, group]);
|
||||||
|
|
||||||
const selectDisabled = maintenanceActive || savingGroup || !group;
|
const inputDisabled = maintenanceActive || savingGroup || !group || isSuperuserGroup || !canEditGroupDetails;
|
||||||
const inputDisabled = maintenanceActive || savingGroup || !group || isSuperuserGroup;
|
const selectDisabled = maintenanceActive || savingGroup || !group || !canEditGroupDetails;
|
||||||
const selectValue = formValues.avatarColor || "";
|
const selectValue = formValues.avatarColor || "";
|
||||||
|
|
||||||
|
const handlePermissionChange = useCallback((permissionKey, value) => {
|
||||||
|
if (!permissionKey || !canAdjustPermissions || isSuperuserGroup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPermissionSelection((prev) => {
|
||||||
|
if (prev[permissionKey] === value) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[permissionKey]: value
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [canAdjustPermissions, isSuperuserGroup]);
|
||||||
|
|
||||||
|
const getPermissionValue = useCallback(
|
||||||
|
(permissionKey) => {
|
||||||
|
if (!permissionKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(permissionSelection, permissionKey)) {
|
||||||
|
return permissionSelection[permissionKey];
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(permissionDefaults, permissionKey)) {
|
||||||
|
return permissionDefaults[permissionKey];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[permissionSelection, permissionDefaults]
|
||||||
|
);
|
||||||
|
|
||||||
|
const radioIcon = useMemo(
|
||||||
|
() => (
|
||||||
|
<span className="mx-auto block h-2.5 w-2.5 rounded-full border border-blue-gray-400 bg-black" />
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const radioCheckedIcon = useMemo(
|
||||||
|
() => (
|
||||||
|
<span className="mx-auto block h-2.5 w-2.5 rounded-full border border-blue-gray-700 bg-gray-900" />
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isPermissionRowVisible = useCallback(
|
||||||
|
(row) => {
|
||||||
|
if (!row || !row.key) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const dependencies = Array.isArray(row.dependencies) ? row.dependencies : [];
|
||||||
|
return dependencies.every((dependency) => {
|
||||||
|
if (!dependency || !dependency.key) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const value = getPermissionValue(dependency.key) ?? "none";
|
||||||
|
const requirement = dependency.requiredLevel || "!=none";
|
||||||
|
|
||||||
|
if (requirement === "!=none") {
|
||||||
|
return value !== "none";
|
||||||
|
}
|
||||||
|
if (requirement.startsWith("!=")) {
|
||||||
|
return value !== requirement.substring(2);
|
||||||
|
}
|
||||||
|
if (requirement.startsWith("=")) {
|
||||||
|
return value === requirement.substring(1);
|
||||||
|
}
|
||||||
|
if (!requirement) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return value === requirement;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[getPermissionValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderPermissionRow = (row, ownerKey, options = {}) => {
|
||||||
|
if (!isPermissionRowVisible(row)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowName = `permission-${ownerKey}-${row.key}`;
|
||||||
|
const currentValue = getPermissionValue(row.key) ?? "none";
|
||||||
|
const { addTopBorder = true } = options;
|
||||||
|
|
||||||
|
const rowClasses = [
|
||||||
|
"flex flex-col gap-2.5 px-4 py-4 md:flex-row md:items-center md:gap-6",
|
||||||
|
addTopBorder ? "border-t border-blue-gray-100" : ""
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const availableLevels = new Set(
|
||||||
|
Array.isArray(row.availableLevels) && row.availableLevels.length
|
||||||
|
? row.availableLevels
|
||||||
|
: LEVEL_OPTIONS.map((option) => option.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${ownerKey}-${row.key}`} className={rowClasses}>
|
||||||
|
<div className="text-sm font-medium leading-5 text-blue-gray-600 md:w-1/2">
|
||||||
|
{row.label}
|
||||||
|
</div>
|
||||||
|
<List className="flex-col gap-1.5 md:ml-auto md:flex-row md:flex-nowrap md:w-1/2 md:items-center md:justify-end md:gap-3">
|
||||||
|
{LEVEL_OPTIONS.map((option) => {
|
||||||
|
const optionId = `${rowName}-${option.value}`;
|
||||||
|
const checked = currentValue === option.value;
|
||||||
|
const disabled =
|
||||||
|
!availableLevels.has(option.value) ||
|
||||||
|
permissionsLoading ||
|
||||||
|
savingGroup ||
|
||||||
|
!canAdjustPermissions ||
|
||||||
|
isSuperuserGroup;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
key={`${ownerKey}-${row.key}-${option.value}`}
|
||||||
|
className="w-full min-w-[140px] p-0 md:w-auto md:flex-1"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
htmlFor={optionId}
|
||||||
|
className={`inline-flex w-full items-center gap-2 rounded-lg px-3 py-2 min-h-[38px] ${
|
||||||
|
disabled ? "cursor-not-allowed bg-blue-gray-50/40" : "cursor-pointer hover:bg-blue-gray-50/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ListItemPrefix className="flex-shrink-0">
|
||||||
|
<Radio
|
||||||
|
id={optionId}
|
||||||
|
name={rowName}
|
||||||
|
value={option.value}
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => handlePermissionChange(row.key, option.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
ripple={false}
|
||||||
|
className="h-4 w-4 hover:before:opacity-0"
|
||||||
|
containerProps={{
|
||||||
|
className: "p-0"
|
||||||
|
}}
|
||||||
|
icon={radioIcon}
|
||||||
|
checkedIcon={radioCheckedIcon}
|
||||||
|
/>
|
||||||
|
</ListItemPrefix>
|
||||||
|
<Typography
|
||||||
|
color="blue-gray"
|
||||||
|
className={`text-sm font-medium leading-5 whitespace-nowrap ${
|
||||||
|
disabled ? "text-blue-gray-300" : "text-blue-gray-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Typography>
|
||||||
|
</label>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mt-12 flex flex-col gap-12">
|
<div className="mt-12 flex flex-col gap-12">
|
||||||
@@ -415,9 +800,88 @@ export function UserGroupDetail() {
|
|||||||
<p>Mitglieder insgesamt: {group?.memberCount ?? 0}</p>
|
<p>Mitglieder insgesamt: {group?.memberCount ?? 0}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="lg:col-span-2 xl:col-span-3">
|
||||||
|
<Typography variant="h6" color="blue-gray" className="mb-4">
|
||||||
|
Berechtigungen (Ansicht)
|
||||||
|
</Typography>
|
||||||
|
{isSuperuserGroup ? (
|
||||||
|
<div className="rounded-xl border border-blue-gray-100 bg-white px-4 py-4 text-sm text-blue-gray-600 shadow-sm">
|
||||||
|
Die Superuser-Gruppe besitzt automatisch Vollzugriff auf alle Bereiche. Berechtigungen können hier nicht angepasst werden.
|
||||||
|
</div>
|
||||||
|
) : !canViewPermissionSettings ? null : (
|
||||||
|
<div className="rounded-xl border border-blue-gray-100 bg-white shadow-sm">
|
||||||
|
{permissionsLoading && (
|
||||||
|
<div className="border-b border-blue-gray-100 px-4 py-4">
|
||||||
|
<div className="flex items-center gap-3 text-sm text-blue-gray-500">
|
||||||
|
<Spinner className="h-4 w-4" />
|
||||||
|
<span>Berechtigungen werden geladen ...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!permissionsLoading && permissionsError && (
|
||||||
|
<div className="border-b border-blue-gray-100 px-4 py-4 text-sm text-red-700">
|
||||||
|
{permissionsError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!permissionsLoading && !permissionsError &&
|
||||||
|
(permissionSections.length === 0 ? (
|
||||||
|
<div className="border-b border-blue-gray-100 px-4 py-4 text-sm text-blue-gray-500">
|
||||||
|
Für diese Gruppe sind keine Berechtigungen definiert.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
permissionSections.map((section, sectionIndex) => {
|
||||||
|
const sectionItems = Array.isArray(section.items) ? section.items : [];
|
||||||
|
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={section.key || sectionIndex}
|
||||||
|
className={sectionIndex === 0 ? "" : "border-t border-blue-gray-100"}
|
||||||
|
>
|
||||||
|
<div className="border-b border-blue-gray-100 px-4 py-4">
|
||||||
|
<Typography variant="h6" className="text-lg font-semibold text-blue-gray-700">
|
||||||
|
{section.title}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
{sectionItems.map((row, rowIndex) =>
|
||||||
|
renderPermissionRow(row, section.key || sectionIndex, {
|
||||||
|
addTopBorder: rowIndex !== 0
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
{sectionGroups.map((group, groupIdx) => {
|
||||||
|
const groupItems = Array.isArray(group.items) ? group.items : [];
|
||||||
|
const hasVisibleRows = groupItems.some(isPermissionRowVisible);
|
||||||
|
|
||||||
|
if (!hasVisibleRows) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.key || `${section.key || sectionIndex}-${groupIdx}`} className="border-t border-blue-gray-100">
|
||||||
|
<div className="border-b border-blue-gray-100 px-4 py-3">
|
||||||
|
<Typography className="text-sm font-semibold uppercase tracking-wide text-blue-gray-600">
|
||||||
|
{group.title}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
{groupItems.map((row, rowIndex) =>
|
||||||
|
renderPermissionRow(row, group.key || `${section.key || sectionIndex}-${groupIdx}`, {
|
||||||
|
addTopBorder: rowIndex !== 0
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card></div>
|
</Card>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
||||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||||
import { useToast } from "@/components/ToastProvider.jsx";
|
import { useToast } from "@/components/ToastProvider.jsx";
|
||||||
|
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||||
|
|
||||||
export function Usergroups() {
|
export function Usergroups() {
|
||||||
const [groups, setGroups] = useState([]);
|
const [groups, setGroups] = useState([]);
|
||||||
@@ -31,6 +32,10 @@ export function Usergroups() {
|
|||||||
const { maintenance } = useMaintenance();
|
const { maintenance } = useMaintenance();
|
||||||
const maintenanceActive = Boolean(maintenance?.active);
|
const maintenanceActive = Boolean(maintenance?.active);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
|
||||||
|
const canEditGroups = hasPermission("user-groups-edit", "full");
|
||||||
|
const canDeleteGroups = hasPermission("user-groups-delete", "full");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
page,
|
page,
|
||||||
@@ -188,6 +193,10 @@ export function Usergroups() {
|
|||||||
if (maintenanceActive) {
|
if (maintenanceActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canEditGroups) {
|
||||||
|
setCreateGroupError("Du verfügst nicht über die Berechtigung zum Anlegen von Gruppen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const trimmedName = newGroupName.trim();
|
const trimmedName = newGroupName.trim();
|
||||||
if (!trimmedName) {
|
if (!trimmedName) {
|
||||||
setCreateGroupError("Gruppenname ist erforderlich.");
|
setCreateGroupError("Gruppenname ist erforderlich.");
|
||||||
@@ -228,6 +237,7 @@ export function Usergroups() {
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
maintenanceActive,
|
maintenanceActive,
|
||||||
|
canEditGroups,
|
||||||
newGroupName,
|
newGroupName,
|
||||||
newGroupDescription,
|
newGroupDescription,
|
||||||
showToast,
|
showToast,
|
||||||
@@ -242,6 +252,10 @@ export function Usergroups() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!canDeleteGroups) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isSuperuserGroup = (group?.name || "").toLowerCase() === "superuser";
|
const isSuperuserGroup = (group?.name || "").toLowerCase() === "superuser";
|
||||||
if (isSuperuserGroup) {
|
if (isSuperuserGroup) {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -308,7 +322,7 @@ export function Usergroups() {
|
|||||||
} finally {
|
} finally {
|
||||||
setDeletingGroupId(null);
|
setDeletingGroupId(null);
|
||||||
}
|
}
|
||||||
}, [maintenanceActive, showToast, fetchGroups]);
|
}, [maintenanceActive, canDeleteGroups, showToast, fetchGroups]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-12 mb-8 flex flex-col gap-12">
|
<div className="mt-12 mb-8 flex flex-col gap-12">
|
||||||
@@ -327,6 +341,7 @@ export function Usergroups() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody className="pt-0">
|
<CardBody className="pt-0">
|
||||||
|
{canEditGroups && (
|
||||||
<div className="mb-8 rounded-lg border border-blue-gray-100 bg-white p-4 shadow-sm">
|
<div className="mb-8 rounded-lg border border-blue-gray-100 bg-white p-4 shadow-sm">
|
||||||
<Typography variant="h6" color="blue-gray" className="mb-2">
|
<Typography variant="h6" color="blue-gray" className="mb-2">
|
||||||
Neue Benutzergruppe anlegen
|
Neue Benutzergruppe anlegen
|
||||||
@@ -364,6 +379,7 @@ export function Usergroups() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
@@ -475,6 +491,7 @@ export function Usergroups() {
|
|||||||
>
|
>
|
||||||
Details
|
Details
|
||||||
</Button>
|
</Button>
|
||||||
|
{canDeleteGroups && !isSuperuserGroup && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="text"
|
variant="text"
|
||||||
@@ -483,16 +500,12 @@ export function Usergroups() {
|
|||||||
disabled={
|
disabled={
|
||||||
maintenanceActive ||
|
maintenanceActive ||
|
||||||
deletingGroupId === group.id ||
|
deletingGroupId === group.id ||
|
||||||
Number(group.memberCount) > 0 ||
|
Number(group.memberCount) > 0
|
||||||
isSuperuserGroup
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isSuperuserGroup
|
{deletingGroupId === group.id ? "Löscht ..." : "Löschen"}
|
||||||
? ""
|
|
||||||
: deletingGroupId === group.id
|
|
||||||
? "Löscht ..."
|
|
||||||
: "Löschen"}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
||||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||||
import { useToast } from "@/components/ToastProvider.jsx";
|
import { useToast } from "@/components/ToastProvider.jsx";
|
||||||
|
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||||
|
|
||||||
const normalizeUserGroups = (rawGroups) => {
|
const normalizeUserGroups = (rawGroups) => {
|
||||||
if (!Array.isArray(rawGroups)) {
|
if (!Array.isArray(rawGroups)) {
|
||||||
@@ -64,6 +65,10 @@ export function Users() {
|
|||||||
const maintenanceActive = Boolean(maintenance?.active);
|
const maintenanceActive = Boolean(maintenance?.active);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const noop = useCallback(() => { }, []);
|
const noop = useCallback(() => { }, []);
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
|
||||||
|
const canManageUsers = hasPermission("users-edit", "full");
|
||||||
|
const canDeleteUsers = hasPermission("users-delete", "full");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
page,
|
page,
|
||||||
@@ -251,7 +256,9 @@ export function Users() {
|
|||||||
if (maintenanceActive || !user?.id) {
|
if (maintenanceActive || !user?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canDeleteUsers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const isSuperuserUser = Array.isArray(user?.groups)
|
const isSuperuserUser = Array.isArray(user?.groups)
|
||||||
? user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser")
|
? user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser")
|
||||||
: false;
|
: false;
|
||||||
@@ -307,12 +314,15 @@ export function Users() {
|
|||||||
} finally {
|
} finally {
|
||||||
setDeletingUserId(null);
|
setDeletingUserId(null);
|
||||||
}
|
}
|
||||||
}, [maintenanceActive, showToast, fetchUsers]);
|
}, [maintenanceActive, canDeleteUsers, showToast, fetchUsers]);
|
||||||
|
|
||||||
const handleToggleUserStatus = useCallback(async (user) => {
|
const handleToggleUserStatus = useCallback(async (user) => {
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canManageUsers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (maintenanceActive) {
|
if (maintenanceActive) {
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
@@ -376,12 +386,16 @@ export function Users() {
|
|||||||
} finally {
|
} finally {
|
||||||
setTogglingUserId(null);
|
setTogglingUserId(null);
|
||||||
}
|
}
|
||||||
}, [maintenanceActive, showToast, fetchUsers]);
|
}, [maintenanceActive, canManageUsers, showToast, fetchUsers]);
|
||||||
|
|
||||||
const handleCreateUser = useCallback(async () => {
|
const handleCreateUser = useCallback(async () => {
|
||||||
if (maintenanceActive) {
|
if (maintenanceActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canManageUsers) {
|
||||||
|
setCreateError("Du verfügst nicht über die Berechtigung zum Anlegen von Benutzern.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const trimmedUsername = newUsername.trim();
|
const trimmedUsername = newUsername.trim();
|
||||||
if (!trimmedUsername) {
|
if (!trimmedUsername) {
|
||||||
setCreateError("Benutzername ist erforderlich.");
|
setCreateError("Benutzername ist erforderlich.");
|
||||||
@@ -454,6 +468,7 @@ export function Users() {
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
maintenanceActive,
|
maintenanceActive,
|
||||||
|
canManageUsers,
|
||||||
newUsername,
|
newUsername,
|
||||||
newEmail,
|
newEmail,
|
||||||
newPassword,
|
newPassword,
|
||||||
@@ -499,6 +514,7 @@ export function Users() {
|
|||||||
<CardBody className="pt-0">
|
<CardBody className="pt-0">
|
||||||
|
|
||||||
|
|
||||||
|
{canManageUsers && (
|
||||||
<div className="mb-8 rounded-lg border border-blue-gray-100 bg-white p-4 shadow-sm">
|
<div className="mb-8 rounded-lg border border-blue-gray-100 bg-white p-4 shadow-sm">
|
||||||
<Typography variant="h6" color="blue-gray" className="mb-2">
|
<Typography variant="h6" color="blue-gray" className="mb-2">
|
||||||
Neuen Benutzer anlegen
|
Neuen Benutzer anlegen
|
||||||
@@ -577,6 +593,7 @@ export function Users() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mt-8">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mt-8">
|
||||||
@@ -674,11 +691,17 @@ export function Users() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
|
{canManageUsers ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`inline-flex items-center rounded-full px-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${maintenanceActive ? "cursor-not-allowed" : "cursor-pointer"}`}
|
className={`inline-flex items-center rounded-full px-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${maintenanceActive ? "cursor-not-allowed" : "cursor-pointer"}`}
|
||||||
onClick={() => handleToggleUserStatus(user)}
|
onClick={() => handleToggleUserStatus(user)}
|
||||||
disabled={maintenanceActive || togglingUserId === user.id || (Array.isArray(user.groups) && user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser" && user.isActive))}
|
disabled={
|
||||||
|
maintenanceActive ||
|
||||||
|
togglingUserId === user.id ||
|
||||||
|
(Array.isArray(user.groups) &&
|
||||||
|
user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser" && user.isActive))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Chip
|
<Chip
|
||||||
value={togglingUserId === user.id ? "Wechselt ..." : user.isActive ? "Aktiv" : "Deaktiviert"}
|
value={togglingUserId === user.id ? "Wechselt ..." : user.isActive ? "Aktiv" : "Deaktiviert"}
|
||||||
@@ -688,6 +711,14 @@ export function Users() {
|
|||||||
className="pointer-events-none"
|
className="pointer-events-none"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<Chip
|
||||||
|
value={user.isActive ? "Aktiv" : "Deaktiviert"}
|
||||||
|
size="sm"
|
||||||
|
color={user.isActive ? "green" : "red"}
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
|
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
|
||||||
@@ -709,19 +740,17 @@ export function Users() {
|
|||||||
>
|
>
|
||||||
Details
|
Details
|
||||||
</Button>
|
</Button>
|
||||||
|
{canDeleteUsers && !isSuperuserUser && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="text"
|
variant="text"
|
||||||
color="red"
|
color="red"
|
||||||
onClick={() => handleDeleteUser(user)}
|
onClick={() => handleDeleteUser(user)}
|
||||||
disabled={maintenanceActive || deletingUserId === user.id || isSuperuserUser}
|
disabled={maintenanceActive || deletingUserId === user.id}
|
||||||
>
|
>
|
||||||
{isSuperuserUser
|
{deletingUserId === user.id ? "Löscht ..." : "Löschen"}
|
||||||
? ""
|
|
||||||
: deletingUserId === user.id
|
|
||||||
? "Löscht ..."
|
|
||||||
: "Löschen"}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
+42
-6
@@ -4,11 +4,14 @@ import {
|
|||||||
ListBulletIcon,
|
ListBulletIcon,
|
||||||
ServerStackIcon,
|
ServerStackIcon,
|
||||||
RectangleStackIcon,
|
RectangleStackIcon,
|
||||||
|
ArrowLeftOnRectangleIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
UserGroupIcon
|
UserGroupIcon,
|
||||||
|
KeyIcon
|
||||||
} from "@heroicons/react/24/solid";
|
} from "@heroicons/react/24/solid";
|
||||||
import { Stacks, Maintenance, Logs, Users, Usergroups } from "@/pages/dashboard";
|
import { Stacks, Maintenance, Logs, Users, Usergroups } from "@/pages/dashboard";
|
||||||
import { SignIn, SignUp } from "@/pages/auth";
|
import { SignIn, SignUp, SignOut, ForgotPassword } from "@/pages/auth";
|
||||||
|
import PermissionGate from "@/components/PermissionGate.jsx";
|
||||||
|
|
||||||
const icon = {
|
const icon = {
|
||||||
className: "w-5 h-5 text-inherit",
|
className: "w-5 h-5 text-inherit",
|
||||||
@@ -23,30 +26,51 @@ export const routes = [
|
|||||||
name: "stacks",
|
name: "stacks",
|
||||||
path: "/stacks",
|
path: "/stacks",
|
||||||
element: <Stacks />,
|
element: <Stacks />,
|
||||||
|
permission: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <WrenchScrewdriverIcon {...icon} />,
|
icon: <WrenchScrewdriverIcon {...icon} />,
|
||||||
name: "wartung",
|
name: "wartung",
|
||||||
path: "/maintenance",
|
path: "/maintenance",
|
||||||
element: <Maintenance />,
|
element: (
|
||||||
|
<PermissionGate permission="maintenance-access" requiredLevel="read">
|
||||||
|
<Maintenance />
|
||||||
|
</PermissionGate>
|
||||||
|
),
|
||||||
|
permission: { key: "maintenance-access", requiredLevel: "read" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <ListBulletIcon {...icon} />,
|
icon: <ListBulletIcon {...icon} />,
|
||||||
name: "logs",
|
name: "logs",
|
||||||
path: "/logs",
|
path: "/logs",
|
||||||
element: <Logs />,
|
element: (
|
||||||
|
<PermissionGate permission="logs-access" requiredLevel="full">
|
||||||
|
<Logs />
|
||||||
|
</PermissionGate>
|
||||||
|
),
|
||||||
|
permission: { key: "logs-access", requiredLevel: "full" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <UserIcon {...icon} />,
|
icon: <UserIcon {...icon} />,
|
||||||
name: "benutzer",
|
name: "benutzer",
|
||||||
path: "/users",
|
path: "/users",
|
||||||
element: <Users />,
|
element: (
|
||||||
|
<PermissionGate permission="users-access" requiredLevel="read">
|
||||||
|
<Users />
|
||||||
|
</PermissionGate>
|
||||||
|
),
|
||||||
|
permission: { key: "users-access", requiredLevel: "read" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <UserGroupIcon {...icon} />,
|
icon: <UserGroupIcon {...icon} />,
|
||||||
name: "rechtegruppen",
|
name: "rechtegruppen",
|
||||||
path: "/usergroups",
|
path: "/usergroups",
|
||||||
element: <Usergroups />,
|
element: (
|
||||||
|
<PermissionGate permission="user-groups-access" requiredLevel="read">
|
||||||
|
<Usergroups />
|
||||||
|
</PermissionGate>
|
||||||
|
),
|
||||||
|
permission: { key: "user-groups-access", requiredLevel: "read" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -60,6 +84,18 @@ export const routes = [
|
|||||||
path: "/sign-in",
|
path: "/sign-in",
|
||||||
element: <SignIn />,
|
element: <SignIn />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: <KeyIcon {...icon} />,
|
||||||
|
name: "passwort vergessen",
|
||||||
|
path: "/forgot-password",
|
||||||
|
element: <ForgotPassword />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <ArrowLeftOnRectangleIcon {...icon} />,
|
||||||
|
name: "log out",
|
||||||
|
path: "/logout",
|
||||||
|
element: <SignOut />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: <RectangleStackIcon {...icon} />,
|
icon: <RectangleStackIcon {...icon} />,
|
||||||
name: "sign up",
|
name: "sign up",
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { Link, NavLink } from "react-router-dom";
|
import { Link, NavLink } from "react-router-dom";
|
||||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
import {
|
import { Avatar, Button, IconButton, Typography } from "@material-tailwind/react";
|
||||||
Avatar,
|
|
||||||
Button,
|
|
||||||
IconButton,
|
|
||||||
Typography,
|
|
||||||
} from "@material-tailwind/react";
|
|
||||||
import { useMaterialTailwindController, setOpenSidenav } from "@/components";
|
import { useMaterialTailwindController, setOpenSidenav } from "@/components";
|
||||||
|
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||||
import logo from "@/assets/images/stackpulse.png";
|
import logo from "@/assets/images/stackpulse.png";
|
||||||
|
|
||||||
export function Sidenav({ brandImg, brandName, routes }) {
|
export function Sidenav({ brandImg, brandName, routes }) {
|
||||||
const [controller, dispatch] = useMaterialTailwindController();
|
const [controller, dispatch] = useMaterialTailwindController();
|
||||||
const { sidenavColor, sidenavType, openSidenav } = controller;
|
const { sidenavColor, sidenavType, openSidenav } = controller;
|
||||||
|
const { initialized: authInitialized, hasPermission } = useAuth();
|
||||||
const sidenavTypes = {
|
const sidenavTypes = {
|
||||||
dark: "bg-gradient-to-br from-gray-800 to-gray-900",
|
dark: "bg-gradient-to-br from-gray-800 to-gray-900",
|
||||||
white: "bg-white shadow-sm",
|
white: "bg-white shadow-sm",
|
||||||
@@ -38,7 +35,27 @@ export function Sidenav({ brandImg, brandName, routes }) {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
<div className="m-4">
|
<div className="m-4">
|
||||||
{routes.map(({ layout, title, pages }, key) => (
|
{routes.map(({ layout, title, pages }, key) => {
|
||||||
|
const visiblePages = pages.filter(({ permission }) => {
|
||||||
|
if (!permission || !permission.key) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!authInitialized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const requiredLevel = permission.requiredLevel || permission.level || "full";
|
||||||
|
return hasPermission(permission.key, requiredLevel);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (layout !== "dashboard" && layout !== "auth") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visiblePages.length === 0 && !title) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<ul key={key} className="mb-4 flex flex-col gap-1">
|
<ul key={key} className="mb-4 flex flex-col gap-1">
|
||||||
{title && (
|
{title && (
|
||||||
<li className="mx-3.5 mt-4 mb-2">
|
<li className="mx-3.5 mt-4 mb-2">
|
||||||
@@ -51,7 +68,7 @@ export function Sidenav({ brandImg, brandName, routes }) {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
{pages.map(({ icon, name, path }) => (
|
{visiblePages.map(({ icon, name, path }) => (
|
||||||
<li key={name}>
|
<li key={name}>
|
||||||
<NavLink to={`/${layout}${path}`}>
|
<NavLink to={`/${layout}${path}`}>
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
@@ -80,7 +97,8 @@ export function Sidenav({ brandImg, brandName, routes }) {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user