Forgot Passaord
This commit is contained in:
@@ -44,6 +44,10 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
avatar_color TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
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,
|
||||
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 = `
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
@@ -364,6 +445,549 @@ console.log('✅ permissions table ready');
|
||||
db.exec(createUserGroupPermissionsTable);
|
||||
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);
|
||||
console.log('✅ servers table ready');
|
||||
|
||||
|
||||
+510
-46
@@ -18,9 +18,9 @@ import {
|
||||
registerSuperuser,
|
||||
removeSuperuser,
|
||||
findUserByIdentifier,
|
||||
findUserById,
|
||||
markUserLogin,
|
||||
verifyPassword
|
||||
verifyPassword,
|
||||
SUPERUSER_GROUP_NAME
|
||||
} from './auth/superuser.js';
|
||||
import {
|
||||
logEvent,
|
||||
@@ -45,14 +45,46 @@ import {
|
||||
removeServer,
|
||||
setServerApiKey
|
||||
} 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 {
|
||||
getPermissionStructure,
|
||||
getPermissionValuesByGroup,
|
||||
saveGroupPermissionValues,
|
||||
clearGroupPermissionValues,
|
||||
getSuperuserPermissionMap,
|
||||
getEffectivePermissionsForGroups,
|
||||
hasRequiredPermission
|
||||
} from './permissions/index.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
ensureSuperuserFromEnv();
|
||||
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 __dirname = path.dirname(__filename);
|
||||
|
||||
@@ -131,6 +163,8 @@ const AUTH_COOKIE_OPTIONS = {
|
||||
};
|
||||
|
||||
const activeSessions = new Map();
|
||||
const passwordResetTickets = new Map();
|
||||
const PASSWORD_RESET_TTL_MS = 1000 * 60 * 15;
|
||||
|
||||
const PUBLIC_API_ROUTES = [
|
||||
{ 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\/logout$/ },
|
||||
{ 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: 'POST', matcher: /^\/api\/setup\/complete$/ }
|
||||
];
|
||||
@@ -149,6 +185,40 @@ const sanitizeUser = (user) => ({
|
||||
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 now = Date.now();
|
||||
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) => {
|
||||
cleanupExpiredSessions();
|
||||
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) => {
|
||||
if (!data) return;
|
||||
const text = data.toString();
|
||||
@@ -1425,14 +1584,37 @@ app.use((req, res, next) => {
|
||||
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
||||
}
|
||||
|
||||
const user = findUserById(session.userId);
|
||||
if (!user || !user.is_active) {
|
||||
const userRecord = getUserById(session.userId);
|
||||
if (!userRecord || !userRecord.isActive) {
|
||||
activeSessions.delete(token);
|
||||
clearAuthCookie(res);
|
||||
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;
|
||||
touchSession(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 numericId = Number(id);
|
||||
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 numericId = Number(id);
|
||||
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 numericId = Number(id);
|
||||
if (!Number.isFinite(numericId)) {
|
||||
@@ -1732,7 +1914,12 @@ app.post('/api/auth/login', (req, res) => {
|
||||
markUserLogin(user.id);
|
||||
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) => {
|
||||
@@ -1744,6 +1931,74 @@ app.post('/api/auth/logout', (req, res) => {
|
||||
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) => {
|
||||
if (!hasSuperuser()) {
|
||||
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' });
|
||||
}
|
||||
|
||||
const user = findUserById(session.userId);
|
||||
if (!user || !user.is_active) {
|
||||
const payload = buildUserSessionPayload(session.userId);
|
||||
if (!payload) {
|
||||
activeSessions.delete(token);
|
||||
clearAuthCookie(res);
|
||||
return res.status(401).json({ error: 'UNAUTHORIZED' });
|
||||
@@ -1769,10 +2024,10 @@ app.get('/api/auth/session', (req, res) => {
|
||||
|
||||
touchSession(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 {
|
||||
const users = listUsers();
|
||||
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 ?? {};
|
||||
|
||||
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 });
|
||||
} catch (error) {
|
||||
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 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 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 numericId = Number(userId);
|
||||
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 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 numericId = Number(userId);
|
||||
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 {
|
||||
const groups = listGroups();
|
||||
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);
|
||||
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 numericId = Number(groupId);
|
||||
|
||||
@@ -1996,9 +2365,88 @@ app.get('/api/groups/:groupId', (req, res) => {
|
||||
console.error(`⚠️ [Groups] Abruf der Gruppendetails fehlgeschlagen (${groupId}):`, error);
|
||||
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 ?? {};
|
||||
try {
|
||||
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 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 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");
|
||||
try {
|
||||
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 {
|
||||
const { active, message } = req.body ?? {};
|
||||
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 effective = getEffectivePortainerScript();
|
||||
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 {
|
||||
const config = savePortainerSshConfig(req.body || {});
|
||||
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();
|
||||
res.json({
|
||||
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 {
|
||||
const override = req.body && Object.keys(req.body).length ? req.body : null;
|
||||
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) {
|
||||
return res.status(409).json({
|
||||
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) {
|
||||
return res.status(409).json({
|
||||
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({
|
||||
maintenance: getMaintenanceState(),
|
||||
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) {
|
||||
return res.status(409).json({
|
||||
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");
|
||||
try {
|
||||
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 duplicateIdsInput = Array.isArray(req.body?.duplicateIds) ? req.body.duplicateIds : [];
|
||||
const duplicateIds = duplicateIdsInput
|
||||
@@ -2592,7 +3048,7 @@ app.post('/api/maintenance/duplicates/cleanup', maintenanceGuard, async (req, re
|
||||
});
|
||||
|
||||
// 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 perPage = perPageParam === 'all' ? 'all' : Math.min(parseInt(perPageParam, 10) || 50, 500);
|
||||
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);
|
||||
if (Number.isNaN(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 {
|
||||
const deleted = deleteEventLogsByFilters(req.query);
|
||||
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();
|
||||
if (!['txt', 'sql'].includes(format)) {
|
||||
return res.status(400).json({ error: 'Ungültiges Export-Format' });
|
||||
@@ -2723,7 +3179,7 @@ app.get('/api/logs/export', (req, res) => {
|
||||
});
|
||||
|
||||
// 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;
|
||||
console.log(`🔄 PUT /api/stacks/${id}/redeploy: Redeploy gestartet`);
|
||||
|
||||
@@ -2739,7 +3195,11 @@ app.put('/api/stacks/:id/redeploy', async (req, res) => {
|
||||
});
|
||||
|
||||
// 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`);
|
||||
|
||||
let endpointId;
|
||||
@@ -2840,7 +3300,11 @@ app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => {
|
||||
});
|
||||
|
||||
// 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 totalCount = Array.isArray(stackIds) ? stackIds.length : 0;
|
||||
console.log(`🚀 PUT /api/stacks/redeploy-selection: Redeploy Auswahl gestartet (${totalCount} Stacks)`);
|
||||
|
||||
Generated
+32
@@ -8,6 +8,7 @@
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.0.0",
|
||||
"axios": "^1.7.0",
|
||||
"better-sqlite3": "^9.6.0",
|
||||
"dockerode": "^4.0.7",
|
||||
@@ -59,6 +60,17 @@
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dockerode": "^4.0.7",
|
||||
"dotenv": "^16.0.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 type="module" crossorigin src="/assets/index-8d2b4f50.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-11780ccd.css">
|
||||
<script type="module" crossorigin src="/assets/index-f91011a0.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-f6cd1c87.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+385
-6
@@ -1,5 +1,12 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { logEvent } from '../logging/eventLogs.js';
|
||||
import {
|
||||
generateSecurityPhraseWords,
|
||||
encryptSecurityPhrase,
|
||||
decryptSecurityPhrase,
|
||||
canonicalizeWords,
|
||||
canonicalizePhraseInput
|
||||
} from './securityPhrase.js';
|
||||
import {
|
||||
hashPassword,
|
||||
normalizeAvatarColor,
|
||||
@@ -18,6 +25,7 @@ const selectUsersWithGroups = db.prepare(`
|
||||
u.last_login,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.security_phrase_downloaded_at,
|
||||
GROUP_CONCAT(g.id || '::' || g.name, '|||') AS group_pairs
|
||||
FROM users u
|
||||
LEFT JOIN user_group_memberships m ON m.user_id = u.id
|
||||
@@ -36,6 +44,10 @@ const selectUserWithGroupsById = db.prepare(`
|
||||
u.last_login,
|
||||
u.created_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
|
||||
FROM users u
|
||||
LEFT JOIN user_group_memberships m ON m.user_id = u.id
|
||||
@@ -63,6 +75,20 @@ const insertMembershipForUser = db.prepare(`
|
||||
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 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(`
|
||||
INSERT INTO users (username, email, password_hash, password_salt, avatar_color, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
INSERT INTO users (
|
||||
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(`
|
||||
@@ -97,6 +136,14 @@ const updateUserActiveStatement = db.prepare(`
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const updateUserPasswordStatement = db.prepare(`
|
||||
UPDATE users
|
||||
SET password_hash = ?,
|
||||
password_salt = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
const parseGroupPairs = (rawPairs) => {
|
||||
if (!rawPairs) {
|
||||
return [];
|
||||
@@ -127,7 +174,8 @@ const sanitizeUserRecord = (row) => ({
|
||||
lastLogin: row.last_login || null,
|
||||
createdAt: row.created_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() {
|
||||
@@ -147,13 +195,76 @@ const applyUserGroupAssignments = db.transaction((userId, groupIds) => {
|
||||
});
|
||||
});
|
||||
|
||||
const insertUserWithGroups = db.transaction(({ username, email, passwordHash, passwordSalt, avatarColor, groupId }) => {
|
||||
const result = insertUserStatement.run(username, email, passwordHash, passwordSalt, avatarColor);
|
||||
const insertUserWithGroups = db.transaction(({
|
||||
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);
|
||||
applyUserGroupAssignments(userId, [groupId]);
|
||||
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) => {
|
||||
if (!actor || actor.id === undefined || actor.id === null) {
|
||||
return {};
|
||||
@@ -175,6 +286,265 @@ const normalizeEmail = (value) => {
|
||||
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 = {}) {
|
||||
const actor = options.actor || null;
|
||||
const normalizedUsername = typeof username === 'string' ? username.trim() : '';
|
||||
@@ -245,13 +615,22 @@ export function createUser({ username, email, password, groupId, avatarColor },
|
||||
const normalizedAvatarColor = normalizeAvatarColor(avatarColor);
|
||||
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({
|
||||
username: normalizedUsername,
|
||||
email: normalizedEmail,
|
||||
passwordHash,
|
||||
passwordSalt,
|
||||
avatarColor: avatarColorToPersist,
|
||||
groupId: numericGroupId
|
||||
groupId: numericGroupId,
|
||||
securityPhrase: securityPhrasePayload
|
||||
});
|
||||
|
||||
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 { UserDetails } from "@/pages/dashboard/userDetails.jsx";
|
||||
import { UserGroupDetail } from "@/pages/dashboard/userGroupDetail.jsx";
|
||||
import { SecurityPhrase } from "@/pages/dashboard/securityPhrase.jsx";
|
||||
import { useMaterialTailwindController, setOpenConfigurator } from "@/components";
|
||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||
import PermissionGate from "@/components/PermissionGate.jsx";
|
||||
|
||||
export function Dashboard() {
|
||||
const [controller, dispatch] = useMaterialTailwindController();
|
||||
@@ -20,8 +23,13 @@ export function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [setupChecked, setSetupChecked] = useState(false);
|
||||
const [setupIncomplete, setSetupIncomplete] = useState(true);
|
||||
const [authChecked, setAuthChecked] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const {
|
||||
initialized: authInitialized,
|
||||
loading: authLoading,
|
||||
isAuthenticated,
|
||||
refreshSession,
|
||||
user
|
||||
} = useAuth();
|
||||
|
||||
const checkSetupStatus = useCallback(async () => {
|
||||
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(() => {
|
||||
checkSetupStatus();
|
||||
}, [checkSetupStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!setupChecked || setupIncomplete) return;
|
||||
checkSession();
|
||||
}, [setupChecked, setupIncomplete, checkSession]);
|
||||
if (authInitialized || authLoading) return;
|
||||
refreshSession();
|
||||
}, [setupChecked, setupIncomplete, authInitialized, authLoading, refreshSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!setupChecked) return;
|
||||
@@ -82,7 +68,7 @@ export function Dashboard() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authChecked) return;
|
||||
if (!authInitialized) return;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
if (location.pathname !== "/auth/sign-in") {
|
||||
@@ -91,10 +77,53 @@ export function Dashboard() {
|
||||
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 });
|
||||
}
|
||||
}, [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) {
|
||||
return (
|
||||
@@ -112,7 +141,7 @@ export function Dashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!authChecked) {
|
||||
if (!authInitialized || authLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||
<span className="text-blue-gray-500">Pruefe Anmeldung ...</span>
|
||||
@@ -128,26 +157,34 @@ export function Dashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
const isSecurityPhraseRoute = location.pathname === "/dashboard/security-phrase";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-blue-gray-50/50">
|
||||
<Sidenav
|
||||
routes={routes}
|
||||
brandImg={
|
||||
sidenavType === "dark" ? "/img/logo-ct.png" : "/img/logo-ct-dark.png"
|
||||
}
|
||||
/>
|
||||
<div className="p-4 xl:ml-80">
|
||||
<DashboardNavbar />
|
||||
<Configurator />
|
||||
<IconButton
|
||||
size="lg"
|
||||
color="white"
|
||||
className="fixed bottom-8 right-8 z-40 rounded-full shadow-blue-gray-900/10 xl:hidden"
|
||||
ripple={false}
|
||||
onClick={() => setOpenConfigurator(dispatch, true)}
|
||||
>
|
||||
<Cog6ToothIcon className="h-5 w-5" />
|
||||
</IconButton>
|
||||
{!isSecurityPhraseRoute && (
|
||||
<Sidenav
|
||||
routes={routes}
|
||||
brandImg={
|
||||
sidenavType === "dark" ? "/img/logo-ct.png" : "/img/logo-ct-dark.png"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className={isSecurityPhraseRoute ? "" : "p-4 xl:ml-80"}>
|
||||
{!isSecurityPhraseRoute && (
|
||||
<>
|
||||
<DashboardNavbar />
|
||||
<Configurator />
|
||||
<IconButton
|
||||
size="lg"
|
||||
color="white"
|
||||
className="fixed bottom-8 right-8 z-40 rounded-full shadow-blue-gray-900/10 xl:hidden"
|
||||
ripple={false}
|
||||
onClick={() => setOpenConfigurator(dispatch, true)}
|
||||
>
|
||||
<Cog6ToothIcon className="h-5 w-5" />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
<Routes>
|
||||
{routes.map(
|
||||
({ layout, pages }) =>
|
||||
@@ -156,12 +193,29 @@ export function Dashboard() {
|
||||
<Route exact path={path} element={element} />
|
||||
))
|
||||
)}
|
||||
<Route path="users/:userId" element={<UserDetails />} />
|
||||
<Route path="usergroups/:groupId" element={<UserGroupDetail />} />
|
||||
<Route path="security-phrase" element={<SecurityPhrase />} />
|
||||
<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>
|
||||
<div className="text-blue-gray-600">
|
||||
<Footer />
|
||||
</div>
|
||||
{!isSecurityPhraseRoute && (
|
||||
<div className="text-blue-gray-600">
|
||||
<Footer />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+14
-11
@@ -20,23 +20,26 @@ import "./tailwind.css";
|
||||
import ToastProvider from "@/components/ToastProvider.jsx";
|
||||
import MaintenanceProvider from "@/components/MaintenanceProvider.jsx";
|
||||
import PageProvider from "@/components/PageProvider.jsx";
|
||||
import { AuthProvider } from "@/components/AuthProvider.jsx";
|
||||
|
||||
axios.defaults.withCredentials = true;
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<MaintenanceProvider>
|
||||
<ToastProvider>
|
||||
<PageProvider>
|
||||
<ThemeProvider>
|
||||
<MaterialTailwindControllerProvider>
|
||||
<App />
|
||||
</MaterialTailwindControllerProvider>
|
||||
</ThemeProvider>
|
||||
</PageProvider>
|
||||
</ToastProvider>
|
||||
</MaintenanceProvider>
|
||||
<AuthProvider>
|
||||
<MaintenanceProvider>
|
||||
<ToastProvider>
|
||||
<PageProvider>
|
||||
<ThemeProvider>
|
||||
<MaterialTailwindControllerProvider>
|
||||
<App />
|
||||
</MaterialTailwindControllerProvider>
|
||||
</ThemeProvider>
|
||||
</PageProvider>
|
||||
</ToastProvider>
|
||||
</MaintenanceProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</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-up";
|
||||
export * from "@/pages/auth/sign-out";
|
||||
export * from "@/pages/auth/forgot-password";
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
} from "@material-tailwind/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import pattern from "@/assets/images/pattern.png";
|
||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||
|
||||
export function SignIn() {
|
||||
const navigate = useNavigate();
|
||||
const { setSession, refreshSession } = useAuth();
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -42,8 +44,12 @@ export function SignIn() {
|
||||
|
||||
if (sessionResponse.ok) {
|
||||
const sessionData = await sessionResponse.json();
|
||||
if (sessionData?.user) {
|
||||
setSession(sessionData);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -64,7 +70,7 @@ export function SignIn() {
|
||||
isActive = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [navigate]);
|
||||
}, [navigate, setSession]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (event) => {
|
||||
@@ -108,7 +114,20 @@ export function SignIn() {
|
||||
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) {
|
||||
console.error("⚠️ [Auth] Anmeldung fehlgeschlagen:", err);
|
||||
setError("Netzwerkfehler – bitte erneut versuchen.");
|
||||
@@ -116,7 +135,7 @@ export function SignIn() {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[identifier, password, loading, navigate]
|
||||
[identifier, password, loading, navigate, refreshSession, setSession]
|
||||
);
|
||||
|
||||
if (!statusChecked) {
|
||||
@@ -190,6 +209,14 @@ export function SignIn() {
|
||||
<Button type="submit" className="mt-6" fullWidth disabled={loading}>
|
||||
{loading ? "Anmeldung läuft ..." : "Anmelden"}
|
||||
</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>
|
||||
</div>
|
||||
<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/userDetails";
|
||||
export * from "@/pages/dashboard/userGroupDetail";
|
||||
export * from "@/pages/dashboard/securityPhrase";
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@material-tailwind/react";
|
||||
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||
|
||||
const UPDATE_STAGE_LABELS = {
|
||||
initializing: "Vorbereitung",
|
||||
@@ -176,6 +177,9 @@ export function Logs() {
|
||||
const updateRunning = Boolean(updateState?.running);
|
||||
const maintenanceLocked = maintenanceActive || updateRunning;
|
||||
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 {
|
||||
@@ -607,6 +611,10 @@ export function Logs() {
|
||||
]);
|
||||
|
||||
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;
|
||||
setActionLoading(true);
|
||||
setError("");
|
||||
@@ -622,6 +630,10 @@ export function Logs() {
|
||||
};
|
||||
|
||||
const handleDeleteFiltered = async () => {
|
||||
if (!canDeleteLogs) {
|
||||
setError("Dir fehlt die Berechtigung, Logs zu löschen.");
|
||||
return;
|
||||
}
|
||||
if (!logs.length) return;
|
||||
if (!window.confirm("Alle angezeigten Logs (entsprechend Filter) löschen?")) return;
|
||||
setActionLoading(true);
|
||||
@@ -642,6 +654,10 @@ export function Logs() {
|
||||
};
|
||||
|
||||
const handleExport = async (format) => {
|
||||
if (!canExportLogs) {
|
||||
setError("Dir fehlt die Berechtigung, Logs zu exportieren.");
|
||||
return;
|
||||
}
|
||||
setActionLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -716,23 +732,31 @@ export function Logs() {
|
||||
{filtersOpen && (
|
||||
<div className="mb-8">
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() => handleExport('txt')}
|
||||
disabled={actionLoading || loading}
|
||||
className="w-full md:flex-1">
|
||||
Export TXT</Button>
|
||||
<Button
|
||||
onClick={() => handleExport('sql')}
|
||||
disabled={actionLoading || loading}
|
||||
className="w-full md:flex-1">
|
||||
Export SQL</Button>
|
||||
<Button
|
||||
onClick={handleDeleteFiltered}
|
||||
disabled={actionLoading || loading || logs.length === 0}
|
||||
className="w-full md:flex-1 bg-sunsetCoral-500 hover:bg-sunsetCoral-600">
|
||||
Angezeigte löschen</Button>
|
||||
</div>
|
||||
{(canExportLogs || canDeleteLogs) && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canExportLogs && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => handleExport('txt')}
|
||||
disabled={actionLoading || loading}
|
||||
className="w-full md:flex-1">
|
||||
Export TXT</Button>
|
||||
<Button
|
||||
onClick={() => handleExport('sql')}
|
||||
disabled={actionLoading || loading}
|
||||
className="w-full md:flex-1">
|
||||
Export SQL</Button>
|
||||
</>
|
||||
)}
|
||||
{canDeleteLogs && (
|
||||
<Button
|
||||
onClick={handleDeleteFiltered}
|
||||
disabled={actionLoading || loading || logs.length === 0}
|
||||
className="w-full md:flex-1 bg-sunsetCoral-500 hover:bg-sunsetCoral-600">
|
||||
Angezeigte löschen</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 mt-10">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
@@ -1270,15 +1294,17 @@ export function Logs() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 align-top">
|
||||
<Typography variant="small">
|
||||
<button
|
||||
onClick={() => handleDeleteLog(log.id)}
|
||||
disabled={actionLoading}
|
||||
className="rounded-md border border-sunsetCoral-600 px-3 py-1 text-xs text-sunsetCoral-800 transition hover:bg-sunsetCoral-600/20 disabled:opacity-60"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</Typography>
|
||||
{canDeleteLogs && (
|
||||
<Typography variant="small">
|
||||
<button
|
||||
onClick={() => handleDeleteLog(log.id)}
|
||||
disabled={actionLoading}
|
||||
className="rounded-md border border-sunsetCoral-600 px-3 py-1 text-xs text-sunsetCoral-800 transition hover:bg-sunsetCoral-600/20 disabled:opacity-60"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</Typography>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||
|
||||
import {
|
||||
Typography,
|
||||
@@ -73,6 +74,10 @@ export function Stacks() {
|
||||
script: scriptConfig,
|
||||
ssh: sshConfig,
|
||||
} = 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 maintenanceMessage = maintenanceMeta?.message;
|
||||
@@ -519,6 +524,7 @@ export function Stacks() {
|
||||
}, [filteredStacks.length, perPage, page, setPage]);
|
||||
|
||||
const toggleStackSelection = (stackId, disabled) => {
|
||||
if (!canRedeploySelection) return;
|
||||
if (disabled) return;
|
||||
setSelectedStackIds(prev =>
|
||||
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) => {
|
||||
if (!canRedeploySelection) return;
|
||||
const remember = rememberSelectionChoice;
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
@@ -559,6 +577,7 @@ export function Stacks() {
|
||||
};
|
||||
|
||||
const clearStoredSelectionPreference = () => {
|
||||
if (!canRedeploySelection) return;
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.sessionStorage.removeItem(SELECTION_PROMPT_STORAGE_KEY);
|
||||
@@ -577,15 +596,20 @@ export function Stacks() {
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
if (!canRedeploySelection) return;
|
||||
setSelectedStackIds([]);
|
||||
setSelectionPromptVisible(false);
|
||||
};
|
||||
|
||||
const handleChipRemove = (stackId) => {
|
||||
if (!canRedeploySelection) return;
|
||||
setSelectedStackIds((prev) => prev.filter((id) => id !== stackId));
|
||||
};
|
||||
|
||||
const handleRedeploy = async (stackId) => {
|
||||
if (!canRedeploySingle) {
|
||||
return;
|
||||
}
|
||||
const snapshot = stacksByIdRef.current.get(stackId);
|
||||
const phase = snapshot?.redeployPhase;
|
||||
if (phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED) return;
|
||||
@@ -636,6 +660,9 @@ export function Stacks() {
|
||||
};
|
||||
|
||||
const handleRedeployAll = async () => {
|
||||
if (!canRedeployAll) {
|
||||
return;
|
||||
}
|
||||
if (!eligibleFilteredStacks.length) return;
|
||||
|
||||
const targetIdList = eligibleFilteredStacks.map((stack) => stack.Id);
|
||||
@@ -687,6 +714,9 @@ export function Stacks() {
|
||||
};
|
||||
|
||||
const handleRedeploySelection = async () => {
|
||||
if (!canRedeploySelection) {
|
||||
return;
|
||||
}
|
||||
if (!selectedStackIds.length) return;
|
||||
|
||||
const eligibleIds = selectedStackIds.filter((id) => {
|
||||
@@ -747,22 +777,25 @@ export function Stacks() {
|
||||
}
|
||||
};
|
||||
|
||||
const hasSelection = selectedStackIds.length > 0;
|
||||
const hasSelection = canRedeploySelection && selectedStackIds.length > 0;
|
||||
const hasOutdatedStacks = eligibleFilteredStacks.length > 0;
|
||||
const bulkButtonLabel = hasSelection
|
||||
const showBulkButton = (canRedeploySelection && hasSelection) || canRedeployAll;
|
||||
const bulkButtonLabel = canRedeploySelection && hasSelection
|
||||
? `Redeploy Auswahl (${selectedStackIds.length})`
|
||||
: 'Redeploy Alle';
|
||||
|
||||
const bulkActionDisabled = maintenanceLocked || (hasSelection
|
||||
? selectionPromptVisible || selectedStackIds.length === 0 || selectedStackIds.every(id => {
|
||||
const targetStack = stacks.find(stack => stack.Id === id);
|
||||
if (!targetStack) return true;
|
||||
if (targetStack.updateStatus === '✅') return true;
|
||||
if (targetStack.redeployDisabled) return true;
|
||||
const phase = targetStack.redeployPhase;
|
||||
return phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED;
|
||||
})
|
||||
: !hasOutdatedStacks);
|
||||
const bulkActionDisabled = maintenanceLocked || (
|
||||
canRedeploySelection && hasSelection
|
||||
? selectionPromptVisible || selectedStackIds.every(id => {
|
||||
const targetStack = stacks.find(stack => stack.Id === id);
|
||||
if (!targetStack) return true;
|
||||
if (targetStack.updateStatus === '✅') return true;
|
||||
if (targetStack.redeployDisabled) return true;
|
||||
const phase = targetStack.redeployPhase;
|
||||
return phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED;
|
||||
})
|
||||
: (!canRedeployAll || !hasOutdatedStacks)
|
||||
);
|
||||
|
||||
const handleBulkRedeploy = () => {
|
||||
if (maintenanceLocked) {
|
||||
@@ -774,9 +807,9 @@ export function Stacks() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasSelection) {
|
||||
if (canRedeploySelection && hasSelection) {
|
||||
handleRedeploySelection();
|
||||
} else {
|
||||
} else if (canRedeployAll) {
|
||||
handleRedeployAll();
|
||||
}
|
||||
};
|
||||
@@ -868,7 +901,7 @@ export function Stacks() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 mt-5">
|
||||
{selectionPreferenceStored && (
|
||||
{canRedeploySelection && selectionPreferenceStored && (
|
||||
<button
|
||||
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"
|
||||
@@ -881,7 +914,7 @@ export function Stacks() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectionPromptVisible && (
|
||||
{canRedeploySelection && selectionPromptVisible && (
|
||||
<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">
|
||||
@@ -921,8 +954,9 @@ export function Stacks() {
|
||||
|
||||
</div>
|
||||
)}
|
||||
<div id="collect" className="mt-8 flex flex-col gap-4 md:flex-row md:items-start md:gap-6">
|
||||
{selectedStackIds.length > 0 && (
|
||||
{showBulkButton && (
|
||||
<div id="collect" className="mt-8 flex flex-col gap-4 md:flex-row md:items-start md:gap-6">
|
||||
{canRedeploySelection && selectedStackIds.length > 0 && (
|
||||
<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-wrap items-center gap-2">
|
||||
@@ -963,7 +997,8 @@ export function Stacks() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
@@ -978,6 +1013,7 @@ export function Stacks() {
|
||||
const isCurrent = stack.updateStatus === '✅';
|
||||
const isSelfStack = Boolean(stack.redeployDisabled);
|
||||
const isSelectable = !isBusy && !isCurrent && !isSelfStack;
|
||||
const canSelectStack = canRedeploySelection && isSelectable;
|
||||
return (
|
||||
|
||||
<Card key={stack.Id}>
|
||||
@@ -989,13 +1025,15 @@ export function Stacks() {
|
||||
${!isSelectable || isBusy ? 'opacity-75 bg-stormGrey-200/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center space-x-4" id="left">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleStackSelection(stack.Id, !isSelectable)}
|
||||
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' : ''}`}
|
||||
disabled={!isSelectable}
|
||||
/>
|
||||
{canRedeploySelection && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleStackSelection(stack.Id, !canSelectStack)}
|
||||
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={!canSelectStack}
|
||||
/>
|
||||
)}
|
||||
<div className={`w-12 h-12 flex items-center justify-center rounded-full
|
||||
${stack.updateStatus === '✅' ? 'bg-mossGreen-600' :
|
||||
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="text-mossGreen-600">Aktuell</span>
|
||||
</>
|
||||
) : (
|
||||
) : (canRedeploySingle ? (
|
||||
<Button
|
||||
onClick={() => handleRedeploy(stack.Id)}
|
||||
disabled={isBusy}
|
||||
@@ -1039,7 +1077,7 @@ export function Stacks() {
|
||||
>
|
||||
Redeploy
|
||||
</Button>
|
||||
)}
|
||||
) : null)}
|
||||
</div>
|
||||
|
||||
</CardBody>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useToast } from "@/components/ToastProvider.jsx";
|
||||
import { platformSettingsData } from "@/data";
|
||||
import { AVATAR_COLORS } from "@/data/avatarColors.js";
|
||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||
|
||||
const _ = AVATAR_COLORS.join(" ");
|
||||
|
||||
@@ -55,7 +56,8 @@ const mapUser = (item) => ({
|
||||
lastLogin: item?.lastLogin || null,
|
||||
createdAt: item?.createdAt || null,
|
||||
updatedAt: item?.updatedAt || null,
|
||||
groups: normalizeUserGroups(item?.groups)
|
||||
groups: normalizeUserGroups(item?.groups),
|
||||
securityPhraseDownloadedAt: item?.securityPhraseDownloadedAt || null
|
||||
});
|
||||
|
||||
const extractPrimaryGroupId = (user) => {
|
||||
@@ -94,6 +96,7 @@ export function UserDetails() {
|
||||
const { userId } = useParams();
|
||||
const { showToast } = useToast();
|
||||
const { maintenance } = useMaintenance();
|
||||
const { hasPermission, user: authUser } = useAuth();
|
||||
|
||||
const [user, setUser] = useState(null);
|
||||
const [formValues, setFormValues] = useState(buildInitialFormValues(null));
|
||||
@@ -106,8 +109,23 @@ export function UserDetails() {
|
||||
const [groupsError, setGroupsError] = useState("");
|
||||
const [savingUser, setSavingUser] = useState(false);
|
||||
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 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(() => {
|
||||
if (!Array.isArray(user?.groups)) {
|
||||
return false;
|
||||
@@ -140,6 +158,9 @@ export function UserDetails() {
|
||||
throw new Error("USER_NOT_FOUND");
|
||||
}
|
||||
setUser(item);
|
||||
setSecurityPhraseWords([]);
|
||||
setSecurityPhraseDownloadedAt(item.securityPhraseDownloadedAt || null);
|
||||
setSecurityPhraseError("");
|
||||
const initialValues = buildInitialFormValues(item);
|
||||
initialFormValuesRef.current = { ...initialValues };
|
||||
setFormValues(initialValues);
|
||||
@@ -255,30 +276,34 @@ export function UserDetails() {
|
||||
);
|
||||
|
||||
const handleUsernameChange = useCallback((event) => {
|
||||
if (!canEditUsers) return;
|
||||
const { value } = event.target;
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
username: value
|
||||
}));
|
||||
}, []);
|
||||
}, [canEditUsers]);
|
||||
|
||||
const handleEmailChange = useCallback((event) => {
|
||||
if (!canEditUsers) return;
|
||||
const { value } = event.target;
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
email: value
|
||||
}));
|
||||
}, []);
|
||||
}, [canEditUsers]);
|
||||
|
||||
const handlePasswordChange = useCallback((event) => {
|
||||
if (!canEditUsers) return;
|
||||
const { value } = event.target;
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
password: value
|
||||
}));
|
||||
}, []);
|
||||
}, [canEditUsers]);
|
||||
|
||||
const handleGroupChange = useCallback((value) => {
|
||||
if (!canEditUsers) return;
|
||||
if (!value) {
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
@@ -291,17 +316,18 @@ export function UserDetails() {
|
||||
...prev,
|
||||
groupId: Number.isFinite(numeric) && numeric > 0 ? numeric : null
|
||||
}));
|
||||
}, []);
|
||||
}, [canEditUsers]);
|
||||
|
||||
const handleAvatarColorChange = useCallback((value) => {
|
||||
if (!canEditUsers) return;
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
avatarColor: value || ""
|
||||
}));
|
||||
}, []);
|
||||
}, [canEditUsers]);
|
||||
|
||||
const handleSaveUser = useCallback(async () => {
|
||||
if (!user || !hasChanges) {
|
||||
if (!canEditUsers || !user || !hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -364,7 +390,89 @@ export function UserDetails() {
|
||||
} finally {
|
||||
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 source = (formValues.username || formValues.email || "").trim();
|
||||
@@ -381,6 +489,37 @@ export function UserDetails() {
|
||||
return user?.avatarColor || "";
|
||||
}, [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(() => {
|
||||
if (!Array.isArray(availableGroups)) {
|
||||
return [];
|
||||
@@ -402,9 +541,25 @@ export function UserDetails() {
|
||||
}
|
||||
}, [filteredGroups, formValues.groupId, isSuperuserUser]);
|
||||
|
||||
const selectDisabled = maintenanceActive || savingUser || !user || groupsLoading;
|
||||
const avatarSelectDisabled = maintenanceActive || savingUser || !user;
|
||||
const inputDisabled = maintenanceActive || savingUser || !user;
|
||||
useEffect(() => {
|
||||
if (!hasLoaded) {
|
||||
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) : "";
|
||||
|
||||
return (
|
||||
@@ -523,11 +678,6 @@ export function UserDetails() {
|
||||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Globale Rolle
|
||||
</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 ? (
|
||||
<Typography className="block text-xs font-semibold uppercase text-blue-gray-500">
|
||||
Die Rolle für den Superuser kann nicht geändert werden.
|
||||
@@ -615,6 +765,99 @@ export function UserDetails() {
|
||||
</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>
|
||||
</Card></div>
|
||||
</>
|
||||
|
||||
@@ -10,11 +10,16 @@ import {
|
||||
Select,
|
||||
Option,
|
||||
Input,
|
||||
Chip
|
||||
Chip,
|
||||
Radio,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemPrefix
|
||||
} from "@material-tailwind/react";
|
||||
|
||||
import { useToast } from "@/components/ToastProvider.jsx";
|
||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||
import { AVATAR_COLORS } from "@/data/avatarColors.js";
|
||||
|
||||
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() {
|
||||
const { groupId } = useParams();
|
||||
const { showToast } = useToast();
|
||||
const { maintenance } = useMaintenance();
|
||||
const { hasPermission, user: authUser } = useAuth();
|
||||
|
||||
const [group, setGroup] = useState(null);
|
||||
const [formValues, setFormValues] = useState(buildInitialFormValues(null));
|
||||
const initialFormValuesRef = useRef(buildInitialFormValues(null));
|
||||
const [initialPermissionSelection, setInitialPermissionSelection] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [savingGroup, setSavingGroup] = useState(false);
|
||||
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 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 candidate = Number(groupId);
|
||||
return Number.isFinite(candidate) ? candidate : null;
|
||||
}, [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 () => {
|
||||
if (!numericGroupId) {
|
||||
setError("Ungültige Gruppen-ID.");
|
||||
setGroup(null);
|
||||
setFormValues(buildInitialFormValues(null));
|
||||
initialFormValuesRef.current = buildInitialFormValues(null);
|
||||
setPermissionSections([]);
|
||||
setPermissionSelection({});
|
||||
setPermissionDefaults({});
|
||||
setPermissionsError("");
|
||||
setHasLoaded(true);
|
||||
return;
|
||||
}
|
||||
@@ -99,6 +228,25 @@ export function UserGroupDetail() {
|
||||
initialFormValuesRef.current = { ...initialValues };
|
||||
setFormValues(initialValues);
|
||||
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) {
|
||||
const serverError = err.response?.data?.error;
|
||||
let message = "Gruppendetails konnten nicht geladen werden.";
|
||||
@@ -114,6 +262,11 @@ export function UserGroupDetail() {
|
||||
setGroup(null);
|
||||
initialFormValuesRef.current = buildInitialFormValues(null);
|
||||
setFormValues(buildInitialFormValues(null));
|
||||
setPermissionSections([]);
|
||||
setPermissionSelection({});
|
||||
setPermissionDefaults({});
|
||||
setPermissionsError("");
|
||||
setPermissionsLoading(false);
|
||||
setError(message);
|
||||
showToast({
|
||||
variant: "error",
|
||||
@@ -124,13 +277,13 @@ export function UserGroupDetail() {
|
||||
setLoading(false);
|
||||
setHasLoaded(true);
|
||||
}
|
||||
}, [numericGroupId, showToast]);
|
||||
}, [numericGroupId, showToast, fetchGroupPermissions, canViewPermissionSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroupDetails();
|
||||
}, [fetchGroupDetails]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
const detailsChanged = useMemo(() => {
|
||||
if (!hasLoaded || !group) {
|
||||
return false;
|
||||
}
|
||||
@@ -156,6 +309,28 @@ export function UserGroupDetail() {
|
||||
);
|
||||
}, [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 { value } = event.target;
|
||||
setFormValues((prev) => ({
|
||||
@@ -180,7 +355,21 @@ export function UserGroupDetail() {
|
||||
}, []);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -188,29 +377,51 @@ export function UserGroupDetail() {
|
||||
setSaveError("");
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
avatarColor: formValues.avatarColor
|
||||
};
|
||||
if (detailsChanged) {
|
||||
const payload = {
|
||||
avatarColor: formValues.avatarColor
|
||||
};
|
||||
|
||||
if (!isSuperuserGroup) {
|
||||
payload.name = formValues.name;
|
||||
payload.description = formValues.description;
|
||||
if (!isSuperuserGroup) {
|
||||
payload.name = formValues.name;
|
||||
payload.description = formValues.description;
|
||||
}
|
||||
|
||||
const response = await axios.put(`/api/groups/${group.id}`, payload);
|
||||
const updated = mapGroup(response.data?.item || response.data?.group);
|
||||
if (updated?.id) {
|
||||
setGroup(updated);
|
||||
const nextInitial = buildInitialFormValues(updated);
|
||||
initialFormValuesRef.current = { ...nextInitial };
|
||||
setFormValues(nextInitial);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.put(`/api/groups/${group.id}`, payload);
|
||||
const updated = mapGroup(response.data?.item || response.data?.group);
|
||||
setGroup(updated);
|
||||
const nextInitial = buildInitialFormValues(updated);
|
||||
initialFormValuesRef.current = { ...nextInitial };
|
||||
setFormValues(nextInitial);
|
||||
showToast({
|
||||
variant: "success",
|
||||
title: "Gruppe gespeichert",
|
||||
description: "Die Änderungen wurden erfolgreich gespeichert."
|
||||
});
|
||||
if (permissionsChanged && !isSuperuserGroup) {
|
||||
await axios.put(`/api/groups/${group.id}/permissions`, {
|
||||
values: permissionSelection
|
||||
});
|
||||
setInitialPermissionSelection({ ...permissionSelection });
|
||||
}
|
||||
|
||||
if (detailsChanged) {
|
||||
showToast({
|
||||
variant: "success",
|
||||
title: "Gruppe 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) {
|
||||
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") {
|
||||
message = "Bitte einen Gruppennamen angeben.";
|
||||
@@ -221,7 +432,13 @@ export function UserGroupDetail() {
|
||||
} else if (serverError === "GROUP_NOT_FOUND") {
|
||||
message = "Die Benutzergruppe wurde nicht gefunden.";
|
||||
} 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);
|
||||
@@ -233,7 +450,17 @@ export function UserGroupDetail() {
|
||||
} finally {
|
||||
setSavingGroup(false);
|
||||
}
|
||||
}, [group, hasChanges, formValues, showToast, isSuperuserGroup]);
|
||||
}, [
|
||||
group,
|
||||
detailsChanged,
|
||||
permissionsChanged,
|
||||
formValues,
|
||||
showToast,
|
||||
isSuperuserGroup,
|
||||
permissionSelection,
|
||||
canEditGroupDetails,
|
||||
canAdjustPermissions
|
||||
]);
|
||||
|
||||
const avatarLabel = useMemo(() => {
|
||||
const source = (formValues.name || formValues.description || "").trim();
|
||||
@@ -250,10 +477,168 @@ export function UserGroupDetail() {
|
||||
return group?.avatarColor || "";
|
||||
}, [formValues.avatarColor, group]);
|
||||
|
||||
const selectDisabled = maintenanceActive || savingGroup || !group;
|
||||
const inputDisabled = maintenanceActive || savingGroup || !group || isSuperuserGroup;
|
||||
const inputDisabled = maintenanceActive || savingGroup || !group || isSuperuserGroup || !canEditGroupDetails;
|
||||
const selectDisabled = maintenanceActive || savingGroup || !group || !canEditGroupDetails;
|
||||
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 (
|
||||
<>
|
||||
<div className="mt-12 flex flex-col gap-12">
|
||||
@@ -415,11 +800,90 @@ export function UserGroupDetail() {
|
||||
<p>Mitglieder insgesamt: {group?.memberCount ?? 0}</p>
|
||||
</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>
|
||||
</CardBody>
|
||||
</Card></div>
|
||||
</>
|
||||
);
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserGroupDetail;
|
||||
export default UserGroupDetail;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||
import { useToast } from "@/components/ToastProvider.jsx";
|
||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||
|
||||
export function Usergroups() {
|
||||
const [groups, setGroups] = useState([]);
|
||||
@@ -31,6 +32,10 @@ export function Usergroups() {
|
||||
const { maintenance } = useMaintenance();
|
||||
const maintenanceActive = Boolean(maintenance?.active);
|
||||
const navigate = useNavigate();
|
||||
const { hasPermission } = useAuth();
|
||||
|
||||
const canEditGroups = hasPermission("user-groups-edit", "full");
|
||||
const canDeleteGroups = hasPermission("user-groups-delete", "full");
|
||||
|
||||
const {
|
||||
page,
|
||||
@@ -188,6 +193,10 @@ export function Usergroups() {
|
||||
if (maintenanceActive) {
|
||||
return;
|
||||
}
|
||||
if (!canEditGroups) {
|
||||
setCreateGroupError("Du verfügst nicht über die Berechtigung zum Anlegen von Gruppen.");
|
||||
return;
|
||||
}
|
||||
const trimmedName = newGroupName.trim();
|
||||
if (!trimmedName) {
|
||||
setCreateGroupError("Gruppenname ist erforderlich.");
|
||||
@@ -228,6 +237,7 @@ export function Usergroups() {
|
||||
}
|
||||
}, [
|
||||
maintenanceActive,
|
||||
canEditGroups,
|
||||
newGroupName,
|
||||
newGroupDescription,
|
||||
showToast,
|
||||
@@ -242,6 +252,10 @@ export function Usergroups() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canDeleteGroups) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSuperuserGroup = (group?.name || "").toLowerCase() === "superuser";
|
||||
if (isSuperuserGroup) {
|
||||
showToast({
|
||||
@@ -308,7 +322,7 @@ export function Usergroups() {
|
||||
} finally {
|
||||
setDeletingGroupId(null);
|
||||
}
|
||||
}, [maintenanceActive, showToast, fetchGroups]);
|
||||
}, [maintenanceActive, canDeleteGroups, showToast, fetchGroups]);
|
||||
|
||||
return (
|
||||
<div className="mt-12 mb-8 flex flex-col gap-12">
|
||||
@@ -327,43 +341,45 @@ export function Usergroups() {
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardBody className="pt-0">
|
||||
<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">
|
||||
Neue Benutzergruppe anlegen
|
||||
</Typography>
|
||||
<Typography variant="small" className="text-sm text-stormGrey-500 mb-4">
|
||||
Der Gruppenname ist Pflicht, die Beschreibung optional.
|
||||
</Typography>
|
||||
{createGroupError && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{createGroupError}
|
||||
{canEditGroups && (
|
||||
<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">
|
||||
Neue Benutzergruppe anlegen
|
||||
</Typography>
|
||||
<Typography variant="small" className="text-sm text-stormGrey-500 mb-4">
|
||||
Der Gruppenname ist Pflicht, die Beschreibung optional.
|
||||
</Typography>
|
||||
{createGroupError && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{createGroupError}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Input
|
||||
label="Gruppenname"
|
||||
value={newGroupName}
|
||||
onChange={(event) => setNewGroupName(event.target.value)}
|
||||
disabled={createGroupDisabled}
|
||||
crossOrigin=""
|
||||
/>
|
||||
<Input
|
||||
label="Beschreibung (optional)"
|
||||
value={newGroupDescription}
|
||||
onChange={(event) => setNewGroupDescription(event.target.value)}
|
||||
disabled={createGroupDisabled}
|
||||
crossOrigin=""
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Button color="green" onClick={handleCreateGroup} disabled={createGroupDisabled}>
|
||||
{creatingGroup ? "Speichert ..." : "Gruppe anlegen"}
|
||||
</Button>
|
||||
<Button variant="text" color="blue-gray" onClick={resetNewGroupForm} disabled={creatingGroup}>
|
||||
Formular zurücksetzen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Input
|
||||
label="Gruppenname"
|
||||
value={newGroupName}
|
||||
onChange={(event) => setNewGroupName(event.target.value)}
|
||||
disabled={createGroupDisabled}
|
||||
crossOrigin=""
|
||||
/>
|
||||
<Input
|
||||
label="Beschreibung (optional)"
|
||||
value={newGroupDescription}
|
||||
onChange={(event) => setNewGroupDescription(event.target.value)}
|
||||
disabled={createGroupDisabled}
|
||||
crossOrigin=""
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Button color="green" onClick={handleCreateGroup} disabled={createGroupDisabled}>
|
||||
{creatingGroup ? "Speichert ..." : "Gruppe anlegen"}
|
||||
</Button>
|
||||
<Button variant="text" color="blue-gray" onClick={resetNewGroupForm} disabled={creatingGroup}>
|
||||
Formular zurücksetzen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
@@ -475,24 +491,21 @@ export function Usergroups() {
|
||||
>
|
||||
Details
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
color="red"
|
||||
onClick={() => handleDeleteGroup(group)}
|
||||
disabled={
|
||||
maintenanceActive ||
|
||||
deletingGroupId === group.id ||
|
||||
Number(group.memberCount) > 0 ||
|
||||
isSuperuserGroup
|
||||
}
|
||||
>
|
||||
{isSuperuserGroup
|
||||
? ""
|
||||
: deletingGroupId === group.id
|
||||
? "Löscht ..."
|
||||
: "Löschen"}
|
||||
</Button>
|
||||
{canDeleteGroups && !isSuperuserGroup && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
color="red"
|
||||
onClick={() => handleDeleteGroup(group)}
|
||||
disabled={
|
||||
maintenanceActive ||
|
||||
deletingGroupId === group.id ||
|
||||
Number(group.memberCount) > 0
|
||||
}
|
||||
>
|
||||
{deletingGroupId === group.id ? "Löscht ..." : "Löschen"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
|
||||
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
|
||||
import { useToast } from "@/components/ToastProvider.jsx";
|
||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||
|
||||
const normalizeUserGroups = (rawGroups) => {
|
||||
if (!Array.isArray(rawGroups)) {
|
||||
@@ -64,6 +65,10 @@ export function Users() {
|
||||
const maintenanceActive = Boolean(maintenance?.active);
|
||||
const navigate = useNavigate();
|
||||
const noop = useCallback(() => { }, []);
|
||||
const { hasPermission } = useAuth();
|
||||
|
||||
const canManageUsers = hasPermission("users-edit", "full");
|
||||
const canDeleteUsers = hasPermission("users-delete", "full");
|
||||
|
||||
const {
|
||||
page,
|
||||
@@ -251,7 +256,9 @@ export function Users() {
|
||||
if (maintenanceActive || !user?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canDeleteUsers) {
|
||||
return;
|
||||
}
|
||||
const isSuperuserUser = Array.isArray(user?.groups)
|
||||
? user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser")
|
||||
: false;
|
||||
@@ -307,12 +314,15 @@ export function Users() {
|
||||
} finally {
|
||||
setDeletingUserId(null);
|
||||
}
|
||||
}, [maintenanceActive, showToast, fetchUsers]);
|
||||
}, [maintenanceActive, canDeleteUsers, showToast, fetchUsers]);
|
||||
|
||||
const handleToggleUserStatus = useCallback(async (user) => {
|
||||
if (!user?.id) {
|
||||
return;
|
||||
}
|
||||
if (!canManageUsers) {
|
||||
return;
|
||||
}
|
||||
if (maintenanceActive) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
@@ -376,12 +386,16 @@ export function Users() {
|
||||
} finally {
|
||||
setTogglingUserId(null);
|
||||
}
|
||||
}, [maintenanceActive, showToast, fetchUsers]);
|
||||
}, [maintenanceActive, canManageUsers, showToast, fetchUsers]);
|
||||
|
||||
const handleCreateUser = useCallback(async () => {
|
||||
if (maintenanceActive) {
|
||||
return;
|
||||
}
|
||||
if (!canManageUsers) {
|
||||
setCreateError("Du verfügst nicht über die Berechtigung zum Anlegen von Benutzern.");
|
||||
return;
|
||||
}
|
||||
const trimmedUsername = newUsername.trim();
|
||||
if (!trimmedUsername) {
|
||||
setCreateError("Benutzername ist erforderlich.");
|
||||
@@ -454,6 +468,7 @@ export function Users() {
|
||||
}
|
||||
}, [
|
||||
maintenanceActive,
|
||||
canManageUsers,
|
||||
newUsername,
|
||||
newEmail,
|
||||
newPassword,
|
||||
@@ -499,84 +514,86 @@ export function Users() {
|
||||
<CardBody className="pt-0">
|
||||
|
||||
|
||||
<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">
|
||||
Neuen Benutzer anlegen
|
||||
</Typography>
|
||||
{hasSelectableGroups ? (
|
||||
<>
|
||||
<Typography variant="small" className="text-sm text-stormGrey-500 mb-4">
|
||||
Benutzername, Passwort (mindestens 8 Zeichen) und eine globale Rolle sind Pflichtfelder.
|
||||
</Typography>
|
||||
{createError && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{createError}
|
||||
{canManageUsers && (
|
||||
<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">
|
||||
Neuen Benutzer anlegen
|
||||
</Typography>
|
||||
{hasSelectableGroups ? (
|
||||
<>
|
||||
<Typography variant="small" className="text-sm text-stormGrey-500 mb-4">
|
||||
Benutzername, Passwort (mindestens 8 Zeichen) und eine globale Rolle sind Pflichtfelder.
|
||||
</Typography>
|
||||
{createError && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
{groupsError && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{groupsError}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Input
|
||||
label="Benutzername"
|
||||
value={newUsername}
|
||||
onChange={(event) => setNewUsername(event.target.value)}
|
||||
disabled={maintenanceActive || creatingUser}
|
||||
crossOrigin=""
|
||||
/>
|
||||
<Input
|
||||
label="E-Mail (optional)"
|
||||
value={newEmail}
|
||||
onChange={(event) => setNewEmail(event.target.value)}
|
||||
disabled={maintenanceActive || creatingUser}
|
||||
crossOrigin=""
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
label="Passwort"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
disabled={maintenanceActive || creatingUser}
|
||||
crossOrigin=""
|
||||
/>
|
||||
<Select
|
||||
label="Globale Rolle"
|
||||
variant="outlined"
|
||||
value={newGroupId}
|
||||
onChange={setNewGroupId}
|
||||
disabled={groupSelectDisabled}
|
||||
selected={renderSelectedGroup}
|
||||
>
|
||||
<Option value="">Bitte auswählen</Option>
|
||||
{availableGroups.map((group) => (
|
||||
<Option key={group.id} value={String(group.id)}>
|
||||
{group.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{groupsError && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{groupsError}
|
||||
<div className="mt-4 text-sm text-stormGrey-500">
|
||||
{groupsLoading
|
||||
? "Benutzergruppen werden geladen ..."
|
||||
: ""}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Input
|
||||
label="Benutzername"
|
||||
value={newUsername}
|
||||
onChange={(event) => setNewUsername(event.target.value)}
|
||||
disabled={maintenanceActive || creatingUser}
|
||||
crossOrigin=""
|
||||
/>
|
||||
<Input
|
||||
label="E-Mail (optional)"
|
||||
value={newEmail}
|
||||
onChange={(event) => setNewEmail(event.target.value)}
|
||||
disabled={maintenanceActive || creatingUser}
|
||||
crossOrigin=""
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
label="Passwort"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
disabled={maintenanceActive || creatingUser}
|
||||
crossOrigin=""
|
||||
/>
|
||||
<Select
|
||||
label="Globale Rolle"
|
||||
variant="outlined"
|
||||
value={newGroupId}
|
||||
onChange={setNewGroupId}
|
||||
disabled={groupSelectDisabled}
|
||||
selected={renderSelectedGroup}
|
||||
>
|
||||
<Option value="">Bitte auswählen</Option>
|
||||
{availableGroups.map((group) => (
|
||||
<Option key={group.id} value={String(group.id)}>
|
||||
{group.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Button color="green" onClick={handleCreateUser} disabled={createDisabled}>
|
||||
{creatingUser ? "Speichert ..." : "Benutzer anlegen"}
|
||||
</Button>
|
||||
<Button variant="text" color="blue-gray" onClick={resetNewUserForm} disabled={creatingUser}>
|
||||
Formular zurücksetzen
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Es existiert keine verfügbare Benutzergruppe. Bitte lege zuerst eine Gruppe an.
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-stormGrey-500">
|
||||
{groupsLoading
|
||||
? "Benutzergruppen werden geladen ..."
|
||||
: ""}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Button color="green" onClick={handleCreateUser} disabled={createDisabled}>
|
||||
{creatingUser ? "Speichert ..." : "Benutzer anlegen"}
|
||||
</Button>
|
||||
<Button variant="text" color="blue-gray" onClick={resetNewUserForm} disabled={creatingUser}>
|
||||
Formular zurücksetzen
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Es existiert keine verfügbare Benutzergruppe. Bitte lege zuerst eine Gruppe an.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mt-8">
|
||||
@@ -674,20 +691,34 @@ export function Users() {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<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"}`}
|
||||
onClick={() => handleToggleUserStatus(user)}
|
||||
disabled={maintenanceActive || togglingUserId === user.id || (Array.isArray(user.groups) && user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser" && user.isActive))}
|
||||
>
|
||||
{canManageUsers ? (
|
||||
<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"}`}
|
||||
onClick={() => handleToggleUserStatus(user)}
|
||||
disabled={
|
||||
maintenanceActive ||
|
||||
togglingUserId === user.id ||
|
||||
(Array.isArray(user.groups) &&
|
||||
user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser" && user.isActive))
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
value={togglingUserId === user.id ? "Wechselt ..." : user.isActive ? "Aktiv" : "Deaktiviert"}
|
||||
size="sm"
|
||||
color={user.isActive ? "green" : "red"}
|
||||
variant="ghost"
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<Chip
|
||||
value={togglingUserId === user.id ? "Wechselt ..." : user.isActive ? "Aktiv" : "Deaktiviert"}
|
||||
value={user.isActive ? "Aktiv" : "Deaktiviert"}
|
||||
size="sm"
|
||||
color={user.isActive ? "green" : "red"}
|
||||
variant="ghost"
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<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
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
color="red"
|
||||
onClick={() => handleDeleteUser(user)}
|
||||
disabled={maintenanceActive || deletingUserId === user.id || isSuperuserUser}
|
||||
>
|
||||
{isSuperuserUser
|
||||
? ""
|
||||
: deletingUserId === user.id
|
||||
? "Löscht ..."
|
||||
: "Löschen"}
|
||||
</Button>
|
||||
{canDeleteUsers && !isSuperuserUser && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
color="red"
|
||||
onClick={() => handleDeleteUser(user)}
|
||||
disabled={maintenanceActive || deletingUserId === user.id}
|
||||
>
|
||||
{deletingUserId === user.id ? "Löscht ..." : "Löschen"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
+42
-6
@@ -4,11 +4,14 @@ import {
|
||||
ListBulletIcon,
|
||||
ServerStackIcon,
|
||||
RectangleStackIcon,
|
||||
ArrowLeftOnRectangleIcon,
|
||||
UserIcon,
|
||||
UserGroupIcon
|
||||
UserGroupIcon,
|
||||
KeyIcon
|
||||
} from "@heroicons/react/24/solid";
|
||||
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 = {
|
||||
className: "w-5 h-5 text-inherit",
|
||||
@@ -23,30 +26,51 @@ export const routes = [
|
||||
name: "stacks",
|
||||
path: "/stacks",
|
||||
element: <Stacks />,
|
||||
permission: null,
|
||||
},
|
||||
{
|
||||
icon: <WrenchScrewdriverIcon {...icon} />,
|
||||
name: "wartung",
|
||||
path: "/maintenance",
|
||||
element: <Maintenance />,
|
||||
element: (
|
||||
<PermissionGate permission="maintenance-access" requiredLevel="read">
|
||||
<Maintenance />
|
||||
</PermissionGate>
|
||||
),
|
||||
permission: { key: "maintenance-access", requiredLevel: "read" },
|
||||
},
|
||||
{
|
||||
icon: <ListBulletIcon {...icon} />,
|
||||
name: "logs",
|
||||
path: "/logs",
|
||||
element: <Logs />,
|
||||
element: (
|
||||
<PermissionGate permission="logs-access" requiredLevel="full">
|
||||
<Logs />
|
||||
</PermissionGate>
|
||||
),
|
||||
permission: { key: "logs-access", requiredLevel: "full" },
|
||||
},
|
||||
{
|
||||
icon: <UserIcon {...icon} />,
|
||||
name: "benutzer",
|
||||
path: "/users",
|
||||
element: <Users />,
|
||||
element: (
|
||||
<PermissionGate permission="users-access" requiredLevel="read">
|
||||
<Users />
|
||||
</PermissionGate>
|
||||
),
|
||||
permission: { key: "users-access", requiredLevel: "read" },
|
||||
},
|
||||
{
|
||||
icon: <UserGroupIcon {...icon} />,
|
||||
name: "rechtegruppen",
|
||||
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",
|
||||
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} />,
|
||||
name: "sign up",
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { Link, NavLink } from "react-router-dom";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
IconButton,
|
||||
Typography,
|
||||
} from "@material-tailwind/react";
|
||||
import { Avatar, Button, IconButton, Typography } from "@material-tailwind/react";
|
||||
import { useMaterialTailwindController, setOpenSidenav } from "@/components";
|
||||
import { useAuth } from "@/components/AuthProvider.jsx";
|
||||
import logo from "@/assets/images/stackpulse.png";
|
||||
|
||||
export function Sidenav({ brandImg, brandName, routes }) {
|
||||
const [controller, dispatch] = useMaterialTailwindController();
|
||||
const { sidenavColor, sidenavType, openSidenav } = controller;
|
||||
const { initialized: authInitialized, hasPermission } = useAuth();
|
||||
const sidenavTypes = {
|
||||
dark: "bg-gradient-to-br from-gray-800 to-gray-900",
|
||||
white: "bg-white shadow-sm",
|
||||
@@ -38,8 +35,28 @@ export function Sidenav({ brandImg, brandName, routes }) {
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="m-4">
|
||||
{routes.map(({ layout, title, pages }, key) => (
|
||||
<ul key={key} className="mb-4 flex flex-col gap-1">
|
||||
{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">
|
||||
{title && (
|
||||
<li className="mx-3.5 mt-4 mb-2">
|
||||
<Typography
|
||||
@@ -51,7 +68,7 @@ export function Sidenav({ brandImg, brandName, routes }) {
|
||||
</Typography>
|
||||
</li>
|
||||
)}
|
||||
{pages.map(({ icon, name, path }) => (
|
||||
{visiblePages.map(({ icon, name, path }) => (
|
||||
<li key={name}>
|
||||
<NavLink to={`/${layout}${path}`}>
|
||||
{({ isActive }) => (
|
||||
@@ -78,9 +95,10 @@ export function Sidenav({ brandImg, brandName, routes }) {
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user