Initial commit

This commit is contained in:
Jarek Krochmalski
2025-12-28 21:16:03 +01:00
commit 62e3c6439e
552 changed files with 104858 additions and 0 deletions

View File

@@ -0,0 +1,299 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import {
getUser,
updateUser as dbUpdateUser,
deleteUser as dbDeleteUser,
deleteUserSessions,
countAdminUsers,
updateAuthSettings,
userHasAdminRole,
getRoleByName,
assignUserRole,
removeUserRole
} from '$lib/server/db';
import { hashPassword } from '$lib/server/auth';
import { authorize } from '$lib/server/authorize';
// GET /api/users/[id] - Get a specific user
// Free for all - local users are needed for basic auth
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
// When auth is enabled, require authentication (any authenticated user can view)
if (auth.authEnabled && !auth.isAuthenticated) {
return json({ error: 'Authentication required' }, { status: 401 });
}
if (!params.id) {
return json({ error: 'User ID is required' }, { status: 400 });
}
try {
const id = parseInt(params.id);
const user = await getUser(id);
if (!user) {
return json({ error: 'User not found' }, { status: 404 });
}
// Derive isAdmin from role assignment
const isAdmin = await userHasAdminRole(id);
return json({
id: user.id,
username: user.username,
email: user.email,
displayName: user.displayName,
mfaEnabled: user.mfaEnabled,
isAdmin,
isActive: user.isActive,
lastLogin: user.lastLogin,
createdAt: user.createdAt,
updatedAt: user.updatedAt
});
} catch (error) {
console.error('Failed to get user:', error);
return json({ error: 'Failed to get user' }, { status: 500 });
}
};
// PUT /api/users/[id] - Update a user
// Free for all - local users are needed for basic auth
export const PUT: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (!params.id) {
return json({ error: 'User ID is required' }, { status: 400 });
}
const userId = parseInt(params.id);
// Allow users to edit their own profile, otherwise check permission
// (free edition allows all, enterprise checks RBAC)
if (auth.user && auth.user.id !== userId && !await auth.can('users', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const data = await request.json();
const existingUser = await getUser(userId);
if (!existingUser) {
return json({ error: 'User not found' }, { status: 404 });
}
// Check if user is currently an admin (via role)
const existingUserIsAdmin = await userHasAdminRole(userId);
// Build update object
const updateData: any = {};
if (data.username !== undefined) updateData.username = data.username;
if (data.email !== undefined) updateData.email = data.email;
if (data.displayName !== undefined) updateData.displayName = data.displayName;
// Track if we need to change admin role
let shouldPromote = false;
let shouldDemote = false;
// Only admins can change admin status or active status
if (auth.isAdmin) {
// Check if trying to demote or deactivate the last admin
if (existingUserIsAdmin) {
const adminCount = await countAdminUsers();
const isDemoting = data.isAdmin === false;
const isDeactivating = data.isActive === false;
if (adminCount <= 1 && (isDemoting || isDeactivating)) {
const confirmDisableAuth = data.confirmDisableAuth === true;
if (!confirmDisableAuth) {
return json({
error: 'This is the last admin user',
isLastAdmin: true,
message: isDemoting
? 'Removing admin privileges from this user will disable authentication.'
: 'Deactivating this user will disable authentication.'
}, { status: 409 });
}
// User confirmed - proceed and disable auth
if (isDemoting) shouldDemote = true;
if (isDeactivating) {
updateData.isActive = false;
await deleteUserSessions(userId);
}
// Disable authentication
await updateAuthSettings({ authEnabled: false });
// Update user first
const user = await dbUpdateUser(userId, updateData);
if (!user) {
return json({ error: 'Failed to update user' }, { status: 500 });
}
// Remove Admin role if demoting
if (shouldDemote) {
const adminRole = await getRoleByName('Admin');
if (adminRole) {
await removeUserRole(userId, adminRole.id, null);
}
}
return json({
id: user.id,
username: user.username,
email: user.email,
displayName: user.displayName,
mfaEnabled: user.mfaEnabled,
isAdmin: !shouldDemote && existingUserIsAdmin,
isActive: user.isActive,
lastLogin: user.lastLogin,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
authDisabled: true
});
}
}
// Handle isAdmin change via Admin role assignment
if (data.isAdmin !== undefined) {
if (data.isAdmin && !existingUserIsAdmin) {
shouldPromote = true;
} else if (!data.isAdmin && existingUserIsAdmin) {
shouldDemote = true;
}
}
if (data.isActive !== undefined) {
updateData.isActive = data.isActive;
// If deactivating, invalidate all sessions
if (!data.isActive) {
await deleteUserSessions(userId);
}
}
}
// Handle password change
if (data.password) {
if (data.password.length < 8) {
return json({ error: 'Password must be at least 8 characters' }, { status: 400 });
}
updateData.passwordHash = await hashPassword(data.password);
// Invalidate all sessions on password change (except current)
await deleteUserSessions(userId);
}
const user = await dbUpdateUser(userId, updateData);
if (!user) {
return json({ error: 'Failed to update user' }, { status: 500 });
}
// Handle Admin role assignment/removal
const adminRole = await getRoleByName('Admin');
if (adminRole) {
if (shouldPromote) {
await assignUserRole(userId, adminRole.id, null);
} else if (shouldDemote) {
await removeUserRole(userId, adminRole.id, null);
}
}
// Compute final isAdmin status
const finalIsAdmin = shouldPromote || (existingUserIsAdmin && !shouldDemote);
return json({
id: user.id,
username: user.username,
email: user.email,
displayName: user.displayName,
mfaEnabled: user.mfaEnabled,
isAdmin: finalIsAdmin,
isActive: user.isActive,
lastLogin: user.lastLogin,
createdAt: user.createdAt,
updatedAt: user.updatedAt
});
} catch (error: any) {
console.error('Failed to update user:', error);
if (error.message?.includes('UNIQUE constraint failed')) {
return json({ error: 'Username already exists' }, { status: 409 });
}
return json({ error: 'Failed to update user' }, { status: 500 });
}
};
// DELETE /api/users/[id] - Delete a user
// Free for all - local users are needed for basic auth
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
// When auth is enabled, check permission (free edition allows all, enterprise checks RBAC)
if (auth.authEnabled && auth.isAuthenticated && !await auth.can('users', 'remove')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
if (!params.id) {
return json({ error: 'User ID is required' }, { status: 400 });
}
const confirmDisableAuth = url.searchParams.get('confirmDisableAuth') === 'true';
try {
const id = parseInt(params.id);
const isSelfDeletion = auth.user && auth.user.id === id;
const user = await getUser(id);
if (!user) {
return json({ error: 'User not found' }, { status: 404 });
}
// Check if user is admin via role
const userIsAdmin = await userHasAdminRole(id);
// Check if this is the last admin user AND auth is enabled
// Only warn if auth is currently ON (deleting last admin will turn it off)
if (auth.authEnabled && userIsAdmin) {
const adminCount = await countAdminUsers();
if (adminCount <= 1) {
// This is the last admin - require confirmation (whether self or other)
if (!confirmDisableAuth) {
return json({
error: 'This is the last admin user',
isLastAdmin: true,
isSelf: isSelfDeletion,
message: isSelfDeletion
? 'This is the only admin account. Deleting it will disable authentication and allow anyone to access Dockhand.'
: 'This is the only admin account. Deleting it will disable authentication and allow anyone to access Dockhand.'
}, { status: 409 });
}
// User confirmed - proceed with deletion and disable auth
await deleteUserSessions(id);
const deleted = await dbDeleteUser(id);
if (!deleted) {
return json({ error: 'Failed to delete user' }, { status: 500 });
}
// Disable authentication
await updateAuthSettings({ authEnabled: false });
return json({ success: true, authDisabled: true });
}
}
// Delete all sessions first
await deleteUserSessions(id);
const deleted = await dbDeleteUser(id);
if (!deleted) {
return json({ error: 'Failed to delete user' }, { status: 500 });
}
return json({ success: true });
} catch (error) {
console.error('Failed to delete user:', error);
return json({ error: 'Failed to delete user' }, { status: 500 });
}
};

View File

@@ -0,0 +1,96 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { isEnterprise } from '$lib/server/license';
import {
validateSession,
checkPermission,
generateMfaSetup,
verifyAndEnableMfa,
disableMfa
} from '$lib/server/auth';
// POST /api/users/[id]/mfa - Setup MFA (generate QR code)
export const POST: RequestHandler = async ({ params, request, cookies }) => {
// Check enterprise license
if (!(await isEnterprise())) {
return json({ error: 'Enterprise license required' }, { status: 403 });
}
const currentUser = await validateSession(cookies);
if (!params.id) {
return json({ error: 'User ID is required' }, { status: 400 });
}
const userId = parseInt(params.id);
// Users can only setup MFA for themselves, or admins can do it for others
if (!currentUser || (currentUser.id !== userId && !currentUser.isAdmin)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json().catch(() => ({}));
// Check if this is a verification request
if (body.action === 'verify') {
if (!body.token) {
return json({ error: 'MFA token is required' }, { status: 400 });
}
const success = await verifyAndEnableMfa(userId, body.token);
if (!success) {
return json({ error: 'Invalid MFA code' }, { status: 400 });
}
return json({ success: true, message: 'MFA enabled successfully' });
}
// Generate new MFA setup
const setup = await generateMfaSetup(userId);
if (!setup) {
return json({ error: 'User not found' }, { status: 404 });
}
return json({
secret: setup.secret,
qrDataUrl: setup.qrDataUrl
});
} catch (error) {
console.error('MFA setup error:', error);
return json({ error: 'Failed to setup MFA' }, { status: 500 });
}
};
// DELETE /api/users/[id]/mfa - Disable MFA
export const DELETE: RequestHandler = async ({ params, cookies }) => {
// Check enterprise license
if (!(await isEnterprise())) {
return json({ error: 'Enterprise license required' }, { status: 403 });
}
const currentUser = await validateSession(cookies);
if (!params.id) {
return json({ error: 'User ID is required' }, { status: 400 });
}
const userId = parseInt(params.id);
// Users can only disable their own MFA, or admins can do it for others
if (!currentUser || (currentUser.id !== userId && !currentUser.isAdmin)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const success = await disableMfa(userId);
if (!success) {
return json({ error: 'User not found' }, { status: 404 });
}
return json({ success: true, message: 'MFA disabled successfully' });
} catch (error) {
console.error('MFA disable error:', error);
return json({ error: 'Failed to disable MFA' }, { status: 500 });
}
};

View File

@@ -0,0 +1,110 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { isEnterprise } from '$lib/server/license';
import { validateSession } from '$lib/server/auth';
import {
getUserRoles,
assignUserRole,
removeUserRole,
getUser
} from '$lib/server/db';
// GET /api/users/[id]/roles - Get roles assigned to a user
export const GET: RequestHandler = async ({ params, cookies }) => {
// Check enterprise license
if (!(await isEnterprise())) {
return json({ error: 'Enterprise license required' }, { status: 403 });
}
if (!params.id) {
return json({ error: 'User ID is required' }, { status: 400 });
}
try {
const userId = parseInt(params.id);
const user = await getUser(userId);
if (!user) {
return json({ error: 'User not found' }, { status: 404 });
}
const userRoles = await getUserRoles(userId);
return json(userRoles);
} catch (error) {
console.error('Failed to get user roles:', error);
return json({ error: 'Failed to get user roles' }, { status: 500 });
}
};
// POST /api/users/[id]/roles - Assign a role to a user
export const POST: RequestHandler = async ({ params, request, cookies }) => {
// Check enterprise license
if (!(await isEnterprise())) {
return json({ error: 'Enterprise license required' }, { status: 403 });
}
const currentUser = await validateSession(cookies);
if (!currentUser || !currentUser.isAdmin) {
return json({ error: 'Admin access required' }, { status: 403 });
}
if (!params.id) {
return json({ error: 'User ID is required' }, { status: 400 });
}
try {
const userId = parseInt(params.id);
const { roleId, environmentId } = await request.json();
if (!roleId) {
return json({ error: 'Role ID is required' }, { status: 400 });
}
const user = await getUser(userId);
if (!user) {
return json({ error: 'User not found' }, { status: 404 });
}
const userRole = await assignUserRole(userId, roleId, environmentId);
return json(userRole, { status: 201 });
} catch (error) {
console.error('Failed to assign role:', error);
return json({ error: 'Failed to assign role' }, { status: 500 });
}
};
// DELETE /api/users/[id]/roles - Remove a role from a user
export const DELETE: RequestHandler = async ({ params, request, cookies }) => {
// Check enterprise license
if (!(await isEnterprise())) {
return json({ error: 'Enterprise license required' }, { status: 403 });
}
const currentUser = await validateSession(cookies);
if (!currentUser || !currentUser.isAdmin) {
return json({ error: 'Admin access required' }, { status: 403 });
}
if (!params.id) {
return json({ error: 'User ID is required' }, { status: 400 });
}
try {
const userId = parseInt(params.id);
const { roleId, environmentId } = await request.json();
if (!roleId) {
return json({ error: 'Role ID is required' }, { status: 400 });
}
const deleted = await removeUserRole(userId, roleId, environmentId);
if (!deleted) {
return json({ error: 'Role assignment not found' }, { status: 404 });
}
return json({ success: true });
} catch (error) {
console.error('Failed to remove role:', error);
return json({ error: 'Failed to remove role' }, { status: 500 });
}
};