OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
<?php
namespace Leantime\Domain\Users\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Users\Permissions\UsersPermissions;
use Leantime\Domain\Users\Services\Users;
use Symfony\Component\HttpFoundation\Response;
class DelUser extends Controller
{
private Users $userService;
/**
* Initializes dependencies.
*/
public function init(Users $userService): void
{
$this->userService = $userService;
}
/**
* Displays the delete user confirmation page.
*
* @param array $params Request parameters
*/
#[RequiresPermission(UsersPermissions::DELETE, global: true)]
public function get(array $params): Response
{
if (! isset($params['id'])) {
return $this->tpl->display('errors.error403', responseCode: 403);
}
$id = (int) $params['id'];
$user = $this->userService->getUser($id);
$this->generateFormTokens();
$this->tpl->assign('user', $user);
return $this->tpl->display('users.delUser');
}
/**
* Handles user deletion.
*
* @param array $params Request parameters
*/
#[RequiresPermission(UsersPermissions::DELETE, global: true)]
public function post(array $params): Response
{
if (! isset($params['id'])) {
return $this->tpl->display('errors.error403', responseCode: 403);
}
$id = (int) $params['id'];
$user = $this->userService->getUser($id);
if (isset($_POST['del'])) {
if (isset($_POST[session('formTokenName')]) && $_POST[session('formTokenName')] == session('formTokenValue')) {
$this->userService->deleteUser($id);
$this->tpl->setNotification($this->language->__('notifications.user_deleted'), 'success', 'user_deleted');
return Frontcontroller::redirect(BASE_URL.'/users/showAll');
}
$this->tpl->setNotification($this->language->__('notification.form_token_incorrect'), 'error');
}
$this->generateFormTokens();
$this->tpl->assign('user', $user);
return $this->tpl->display('users.delUser');
}
/**
* Generates CSRF form tokens for the sensitive delete form.
*/
private function generateFormTokens(): void
{
$permitted_chars = '0123456789abcdefghijklmnopqrstuvwxyz';
session(['formTokenName' => substr(str_shuffle($permitted_chars), 0, 32)]);
session(['formTokenValue' => substr(str_shuffle($permitted_chars), 0, 32)]);
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace Leantime\Domain\Users\Controllers;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller as FrontcontrollerCore;
use Leantime\Domain\Users\Services\Users as UserService;
use Symfony\Component\HttpFoundation\Response;
class EditOwn extends Controller
{
private UserService $userService;
private int $userId;
/**
* init - initialize private variables
*/
public function init(UserService $userService): void
{
$this->userService = $userService;
$this->userId = session('userdata.id');
}
/**
* @throws \Exception
*/
public function get(): Response
{
$permitted_chars = '123456789abcdefghijklmnopqrstuvwxyz';
session(['formTokenName' => substr(str_shuffle($permitted_chars), 0, 32)]);
session(['formTokenValue' => substr(str_shuffle($permitted_chars), 0, 32)]);
$profileSettings = $this->userService->getOwnProfileSettings($this->userId);
array_map([$this->tpl, 'assign'], array_keys($profileSettings), array_values($profileSettings));
$notificationPreferences = $this->userService->getNotificationPreferences($this->userId);
array_map([$this->tpl, 'assign'], array_keys($notificationPreferences), array_values($notificationPreferences));
return $this->tpl->display('users.editOwn');
}
/**
* @throws \Exception
*/
public function post(): Response
{
// Save Profile Info
$tab = '';
if (isset($_POST[session('formTokenName')]) && session()->exists('formTokenName') && $_POST[session('formTokenName')] === session('formTokenValue')) {
// profile Info
if (isset($_POST['profileInfo'])) {
$tab = '#myProfile';
$result = $this->userService->saveOwnProfile($this->userId, $_POST);
if ($result === 'success') {
$this->tpl->setNotification($this->language->__('notifications.profile_edited'), 'success', 'profile_edited');
} elseif ($result === 'user_exists') {
$this->tpl->setNotification($this->language->__('notification.user_exists'), 'error');
} elseif ($result === 'no_valid_email') {
$this->tpl->setNotification($this->language->__('notification.no_valid_email'), 'error');
} else {
$this->tpl->setNotification($this->language->__('notification.enter_email'), 'error');
}
}
// Save Password
if (isset($_POST['savepw'])) {
$tab = '#security';
$result = $this->userService->changeOwnPassword(
$this->userId,
$_POST['currentPassword'],
$_POST['newPassword'],
$_POST['confirmPassword']
);
if ($result === 'success') {
$this->tpl->setNotification($this->language->__('notifications.password_changed'), 'success', 'password_edited');
} elseif ($result === 'password_not_strong_enough') {
$this->tpl->setNotification($this->language->__('notification.password_not_strong_enough'), 'error');
} elseif ($result === 'passwords_dont_match') {
$this->tpl->setNotification($this->language->__('notification.passwords_dont_match'), 'error');
} else {
$this->tpl->setNotification($this->language->__('notification.previous_password_incorrect'), 'error');
}
}
if (isset($_POST['saveTheme'])) {
$tab = '#theme';
$this->userService->saveOwnAppearanceSettings($this->userId, $_POST);
$this->tpl->setNotification($this->language->__('notifications.changed_profile_settings_successfully'), 'success', 'themsettings_updated');
}
// Save Look & Feel
if (isset($_POST['saveSettings'])) {
$tab = '#settings';
$this->userService->saveOwnLocaleSettings($this->userId, $_POST);
$this->tpl->setNotification($this->language->__('notifications.changed_profile_settings_successfully'), 'success', 'profilesettings_updated');
}
// Save Profile Image
if (isset($_POST['profileImage'])) {
$tab = '#myProfile';
}
// Save Notifications
if (isset($_POST['savenotifications'])) {
$tab = '#notifications';
$this->userService->saveOwnNotificationPreferences($this->userId, $_POST);
$this->tpl->setNotification($this->language->__('notifications.changed_profile_settings_successfully'), 'success', 'profilesettings_updated');
}
} else {
$this->tpl->setNotification($this->language->__('notification.form_token_incorrect'), 'error');
}
// Redirect
return FrontcontrollerCore::redirect(BASE_URL.'/users/editOwn'.$tab);
}
}

View File

@@ -0,0 +1,250 @@
<?php
namespace Leantime\Domain\Users\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Auth\Models\Roles;
use Leantime\Domain\Clients\Services\Clients as ClientService;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Users\Permissions\UsersPermissions;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
use Leantime\Domain\Users\Services\Users;
use Symfony\Component\HttpFoundation\Response;
class EditUser extends Controller
{
private ProjectService $projectService;
private ClientService $clientService;
private Users $userService;
private UserRepository $userRepo;
/**
* Initializes dependencies.
*/
public function init(
ProjectService $projectService,
ClientService $clientService,
Users $userService,
UserRepository $userRepo
): void {
$this->projectService = $projectService;
$this->clientService = $clientService;
$this->userService = $userService;
$this->userRepo = $userRepo;
}
/**
* Displays the edit user form.
*
* @param array $params Request parameters
*/
#[RequiresPermission(UsersPermissions::EDIT, global: true)]
public function get(array $params): Response
{
if (! isset($params['id'])) {
return $this->tpl->display('errors.error403', responseCode: 403);
}
$id = (int) $params['id'];
// Admin edit form needs the full row (e.g. pwReset for the invite
// link), so read from the repository — the service getUser strips
// secrets for API safety (#3556).
$row = $this->userRepo->getUser($id);
if ($row === false) {
return $this->tpl->display('errors.error404', responseCode: 404);
}
if (array_key_exists('resendInvite', $_GET)) {
return $this->handleResendInvite($id, $row);
}
$values = $this->buildValuesFromUser($row);
$projectrelation = $this->userService->getUserProjectIds($id);
$this->generateFormTokens();
$this->tpl->assign('values', $values);
$this->tpl->assign('relations', $projectrelation);
$this->tpl->assign('id', $id);
$this->assignTemplateVars();
return $this->tpl->display('users.editUser');
}
/**
* Handles user profile updates.
*
* @param array $params Request parameters
*/
#[RequiresPermission(UsersPermissions::EDIT, global: true)]
public function post(array $params): Response
{
if (! isset($params['id'])) {
return $this->tpl->display('errors.error403', responseCode: 403);
}
$id = (int) $params['id'];
// Admin edit form needs the full row (e.g. pwReset for the invite
// link), so read from the repository — the service getUser strips
// secrets for API safety (#3556).
$row = $this->userRepo->getUser($id);
if ($row === false) {
return $this->tpl->display('errors.error404', responseCode: 404);
}
$values = $this->buildValuesFromUser($row);
$edit = false;
if (isset($_POST['save'])) {
if (! isset($_POST[session('formTokenName')]) || $_POST[session('formTokenName')] != session('formTokenValue')) {
$this->tpl->setNotification($this->language->__('notification.form_token_incorrect'), 'error');
} else {
$values = $this->buildValuesFromPost($row);
$edit = $this->handleValidation($values, $row, $id);
}
}
if ($edit) {
$this->userService->updateUser($values, $id, $_POST['projects'] ?? null);
$this->tpl->setNotification($this->language->__('notifications.user_edited'), 'success');
}
$projectrelation = $this->userService->getUserProjectIds($id);
$this->generateFormTokens();
$this->tpl->assign('values', $values);
$this->tpl->assign('relations', $projectrelation);
$this->tpl->assign('id', $id);
$this->assignTemplateVars();
return $this->tpl->display('users.editUser');
}
/**
* Handles the resend invite action.
*/
private function handleResendInvite(int $id, array $row): Response
{
$result = $this->userService->resendUserInvite($id, $row);
if ($result === 'too_soon') {
$this->tpl->setNotification($this->language->__('notification.invite_too_soon'), 'error');
} elseif ($result === 'too_many_invites') {
$this->tpl->setNotification($this->language->__('notification.too_many_invites'), 'error');
} else {
$this->tpl->setNotification($this->language->__('notification.invitation_sent'), 'success', 'userinvitation_sent');
}
return Frontcontroller::redirect(BASE_URL.'/users/editUser/'.$id);
}
/**
* Validates a user update and sets the matching notification on failure.
*
* @return bool True if the update should proceed.
*/
private function handleValidation(array $values, array $row, int $id): bool
{
$result = $this->userService->validateUserUpdate($values, $row, $id, $_POST);
if ($result === 'valid') {
return true;
}
$messages = [
'passwords_dont_match' => 'notification.passwords_dont_match',
'enter_email' => 'notification.enter_email',
'no_valid_email' => 'notification.no_valid_email',
'user_exists' => 'notification.user_exists',
];
$this->tpl->setNotification($this->language->__($messages[$result]), 'error');
return false;
}
/**
* Builds a values array from the user database row.
*/
private function buildValuesFromUser(array $row): array
{
return [
'id' => $row['id'],
'firstname' => $row['firstname'],
'lastname' => $row['lastname'],
'user' => $row['username'],
'phone' => $row['phone'],
'status' => $row['status'],
'role' => $row['role'],
'hours' => $row['hours'],
'wage' => $row['wage'],
'clientId' => $row['clientId'],
'source' => $row['source'],
'pwReset' => $row['pwReset'],
'jobTitle' => $row['jobTitle'],
'jobLevel' => $row['jobLevel'],
'department' => $row['department'],
'weekly_hours' => $row['weekly_hours'] ?? null,
'employment_type' => $row['employment_type'] ?? null,
];
}
/**
* Builds a values array from POST data, falling back to the original user row.
*/
private function buildValuesFromPost(array $row): array
{
return [
'id' => $row['id'],
'firstname' => $_POST['firstname'] ?? $row['firstname'],
'lastname' => $_POST['lastname'] ?? $row['lastname'],
'user' => $_POST['user'] ?? $row['username'],
'phone' => $_POST['phone'] ?? $row['phone'],
'status' => $_POST['status'] ?? $row['status'],
'role' => $_POST['role'] ?? $row['role'],
'hours' => $_POST['hours'] ?? $row['hours'],
'wage' => $_POST['wage'] ?? $row['wage'],
'clientId' => $_POST['client'] ?? $row['clientId'],
'source' => $row['source'],
'pwReset' => $row['pwReset'],
'jobTitle' => $_POST['jobTitle'] ?? $row['jobTitle'],
'jobLevel' => $_POST['jobLevel'] ?? $row['jobLevel'],
'department' => $_POST['department'] ?? $row['department'],
// Capacity fields — only pass when actually posted so the
// repo's array_key_exists guard preserves existing values on
// a form submit that omits them (non-admin path today, but
// also future partial-update callers).
...(array_key_exists('weekly_hours', $_POST) ? ['weekly_hours' => $_POST['weekly_hours']] : []),
...(array_key_exists('employment_type', $_POST) ? ['employment_type' => $_POST['employment_type']] : []),
];
}
/**
* Generates CSRF form tokens.
*/
private function generateFormTokens(): void
{
$permitted_chars = '0123456789abcdefghijklmnopqrstuvwxyz';
session(['formTokenName' => substr(str_shuffle($permitted_chars), 0, 32)]);
session(['formTokenValue' => substr(str_shuffle($permitted_chars), 0, 32)]);
}
/**
* Assigns common template variables.
*/
private function assignTemplateVars(): void
{
$this->tpl->assign('allProjects', $this->projectService->getAll(true));
$this->tpl->assign('roles', Roles::getRoles());
$this->tpl->assign('clients', $this->clientService->getAll());
$this->tpl->assign('status', $this->userService->getUserStatuses());
}
}

View File

@@ -0,0 +1,79 @@
<?php
/* Not production ready yet. Prepping for future version */
namespace Leantime\Domain\Users\Controllers;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Domain\Auth\Models\Roles;
use Leantime\Domain\Ldap\Services\Ldap as LdapService;
use Leantime\Domain\Users\Permissions\UsersPermissions;
use Leantime\Domain\Users\Services\Users as UserService;
use Symfony\Component\HttpFoundation\Response;
class Import extends Controller
{
private UserService $userService;
private LdapService $ldapService;
public function init(UserService $userService, LdapService $ldapService): void
{
$this->userService = $userService;
$this->ldapService = $ldapService;
if (! session()->exists('tmp')) {
session(['tmp' => []]);
}
}
/**
* @throws \Exception
*/
#[RequiresPermission(UsersPermissions::IMPORT, global: true)]
public function get(): Response
{
$this->tpl->assign('allUsers', $this->userService->getAll());
$this->tpl->assign('admin', true);
$this->tpl->assign('roles', Roles::getRoles());
if (session()->exists('tmp.ldapUsers') && count(session('tmp.ldapUsers')) > 0) {
$this->tpl->assign('allLdapUsers', session('tmp.ldapUsers'));
$this->tpl->assign('confirmUsers', true);
}
return $this->tpl->displayPartial('users.importLdapDialog');
}
/**
* @throws BindingResolutionException
*/
#[RequiresPermission(UsersPermissions::IMPORT, global: true)]
public function post($params): Response
{
// Password Submit to connect to ldap and retrieve users. Sets tmp session var
if (isset($params['pwSubmit'])) {
$bindUsername = $this->ldapService->extractLdapFromUsername(session('userdata.mail'));
$members = $this->userService->fetchLdapMembers($bindUsername, $params['password']);
if ($members !== false) {
session(['tmp.ldapUsers' => $members]);
$this->tpl->assign('allLdapUsers', session('tmp.ldapUsers'));
$this->tpl->assign('confirmUsers', true);
} else {
$this->tpl->setNotification($this->language->__('notifications.username_or_password_incorrect'), 'error');
}
}
// Import/Update User Post
if (isset($params['importSubmit'])) {
if (is_array($params['users'])) {
$this->userService->importSelectedLdapUsers(session('tmp.ldapUsers'), $params['users']);
}
}
return $this->tpl->displayPartial('users.importLdapDialog');
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace Leantime\Domain\Users\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Domain\Auth\Models\Roles;
use Leantime\Domain\Auth\Services\Auth;
use Leantime\Domain\Clients\Services\Clients as ClientService;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Users\Permissions\UsersPermissions;
use Leantime\Domain\Users\Services\Users as UserService;
use Symfony\Component\HttpFoundation\Response;
class NewUser extends Controller
{
private UserService $userService;
private ProjectService $projectService;
private ClientService $clientService;
/**
* Initializes dependencies.
*/
public function init(
UserService $userService,
ProjectService $projectService,
ClientService $clientService
): void {
$this->userService = $userService;
$this->projectService = $projectService;
$this->clientService = $clientService;
}
/**
* Displays the new user invitation form.
*
* @param array $params Request parameters
*/
#[RequiresPermission(UsersPermissions::CREATE, global: true)]
public function get(array $params): Response
{
$projectrelation = [];
if (isset($params['preSelectProjectId'])) {
$preSelected = explode(',', $params['preSelectProjectId']);
foreach ($preSelected as $item) {
$projectrelation[] = (int) $item;
}
}
$this->tpl->assign('values', $this->getDefaultValues());
$this->tpl->assign('preSelectedClient', isset($params['preSelectedClient']) ? (int) $params['preSelectedClient'] : '');
$this->tpl->assign('relations', $projectrelation);
$this->assignTemplateVars();
return $this->tpl->displayPartial('users.newUser');
}
/**
* Handles new user creation / invitation.
*
* @param array $params Request parameters
*/
#[RequiresPermission(UsersPermissions::CREATE, global: true)]
public function post(array $params): Response
{
$values = $this->getDefaultValues();
$projectrelation = [];
if (isset($_POST['save'])) {
$isManager = Auth::userHasRole(Roles::$manager);
$values = [
'firstname' => $_POST['firstname'],
'lastname' => $_POST['lastname'],
'user' => $_POST['user'],
'phone' => $_POST['phone'],
'role' => $_POST['role'],
'password' => '',
'pwReset' => '',
'status' => '',
'jobTitle' => $_POST['jobTitle'],
'jobLevel' => $_POST['jobLevel'],
'department' => $_POST['department'],
'clientId' => $isManager ? session('userdata.clientId') : $_POST['client'],
];
if (isset($_POST['projects']) && is_array($_POST['projects'])) {
foreach ($_POST['projects'] as $project) {
$projectrelation[] = $project;
}
}
$result = $this->userService->inviteNewUser($_POST, session('userdata.clientId'), $isManager);
if ($result === 'enter_email') {
$this->tpl->setNotification($this->language->__('notification.enter_email'), 'error');
} elseif ($result === 'no_valid_email') {
$this->tpl->setNotification($this->language->__('notification.no_valid_email'), 'error');
} elseif ($result === 'user_exists') {
$this->tpl->setNotification($this->language->__('notification.user_exists'), 'error');
} elseif ($result === 'invite_failed') {
$this->tpl->setNotification($this->language->__('notification.invite_failed'), 'error');
} else {
$this->tpl->setNotification('notification.user_invited_successfully', 'success', 'user_invited');
}
}
$this->tpl->assign('values', $values);
$this->tpl->assign('preSelectedClient', '');
$this->tpl->assign('relations', $projectrelation);
$this->assignTemplateVars();
return $this->tpl->displayPartial('users.newUser');
}
/**
* Returns default empty values for the new user form.
*/
private function getDefaultValues(): array
{
return [
'firstname' => '',
'lastname' => '',
'user' => '',
'phone' => '',
'role' => '',
'password' => '',
'clientId' => '',
'jobTitle' => '',
'jobLevel' => '',
'department' => '',
];
}
/**
* Assigns common template variables.
*/
private function assignTemplateVars(): void
{
$this->tpl->assign('clients', $this->clientService->getAll());
$this->tpl->assign('allProjects', $this->projectService->getAll());
$this->tpl->assign('roles', Roles::getRoles());
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Leantime\Domain\Users\Controllers;
use Leantime\Core\Controller\Controller;
use Leantime\Domain\Auth\Services\Auth;
use Leantime\Domain\Users\Services\Users as UserService;
use Symfony\Component\HttpFoundation\Response;
class PatchUserSettings extends Controller
{
private Auth $authService;
private UserService $userService;
/**
* Initializes dependencies.
*/
public function init(
Auth $authService,
UserService $userService
): void {
$this->authService = $authService;
$this->userService = $userService;
}
/**
* Handles PATCH requests for user UI settings (e.g. dismissing modals).
*
* @param array $params Request parameters
*/
public function patch(array $params): Response
{
if (! $this->authService->isLoggedIn()) {
return $this->tpl->displayJson(['status' => 'error', 'message' => 'Not authorized'], 401);
}
// Handle modal dismissal updates
if (
isset($params['patchModalSettings'], $params['settings'])
&& $params['patchModalSettings'] == 1
) {
$permanent = isset($params['permanent']) && $params['permanent'] == 1;
$this->userService->saveModalDismissal($params['settings'], $permanent);
return $this->tpl->displayJson(['status' => 'success']);
}
return $this->tpl->displayJson(['status' => 'error', 'message' => 'Invalid request'], 400);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace Leantime\Domain\Users\Controllers;
use Illuminate\Http\Request;
use Leantime\Core\Http\Responses\ImageResponse;
use Leantime\Domain\Users\Services\Users as UserService;
use Symfony\Component\HttpFoundation\Response;
/**
* Serves and updates user profile images.
*
* A native Laravel controller (constructor DI, route-bound actions returning a
* Response/Responsable) — no Frontcontroller/legacy base-controller machinery.
* Relocated from the retired Api\Controllers\Users. Bound in Users/routes.php at the
* canonical /users/profileImage/{id} plus the backward-compatible /api/users alias that
* core templates and the plugin submodule still reference.
*/
class ProfileImage
{
public function __construct(private UserService $userService) {}
/**
* GET — returns the profile image as a binary/SVG response.
*
* The id comes from the canonical path segment ({id}) or the legacy ?profileImage=
* query param. "me" resolves to the session user; "false"/empty yields the placeholder.
*/
public function show(Request $request, ?string $id = null): ImageResponse
{
$id = $request->query('profileImage', $id) ?? 'false';
if ($id === 'me') {
$id = session('userdata.id');
}
return new ImageResponse($this->userService->getProfilePicture($id));
}
/**
* POST — uploads a new profile photo for the current user.
*
* The target is always the session user; a client-supplied id is never trusted.
*/
public function upload(): Response
{
if (! isset($_FILES['file'])) {
return response()->json(['error' => 'File not included'], 400);
}
$_FILES['file']['name'] = 'userPicture.png';
$this->userService->setProfilePicture($_FILES, session('userdata.id'));
session(['msg' => 'PICTURE_CHANGED']);
session(['msgT' => 'success']);
return response()->json(['status' => 'ok']);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Leantime\Domain\Users\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Domain\Auth\Models\Roles;
use Leantime\Domain\Users\Permissions\UsersPermissions;
use Leantime\Domain\Users\Services\Users as UserService;
use Symfony\Component\HttpFoundation\Response;
class ShowAll extends Controller
{
private UserService $userService;
public function init(UserService $userService): void
{
$this->userService = $userService;
}
/**
* @throws \Exception
*/
#[RequiresPermission(UsersPermissions::VIEW, global: true)]
public function get(): Response
{
$this->tpl->assign('allUsers', $this->userService->getAllVisibleToUser());
$this->tpl->assign('admin', true);
$this->tpl->assign('roles', Roles::getRoles());
return $this->tpl->display('users.showAll');
}
public function post($params): Response
{
return $this->tpl->displayJson(['status' => 'Not Implemented']);
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Users\Enums;
/**
* Employment type for a user. Drives how weekly_hours is interpreted
* (target vs ceiling vs bill-to cap vs best-effort) and how downstream
* capacity math (over-allocation warnings, portfolio rollups) treats
* this person.
*
* Stored on zp_user.employment_type (nullable — admin sets it explicitly,
* no assumed default; treat NULL as "not configured").
*
* Value semantics:
* FTE — weekly_hours is the *target*. Over = amber "over target"
* (burnout signal). Common case.
* PT — weekly_hours is a *ceiling* the user set. Over = red
* "over ceiling" (planning violates commitment).
* Contractor — weekly_hours is a *billable cap*. Over = red "over
* billable cap" (cost overrun risk).
* Volunteer — weekly_hours is *best-effort*. Excluded from over-
* allocation math entirely; contributes to project totals
* as separate "volunteer support" throughput.
*/
enum EmploymentType: string
{
case FTE = 'fte';
case PartTime = 'pt';
case Contractor = 'contractor';
case Volunteer = 'volunteer';
/**
* Human-readable label — used in form selects and badges. Not
* translated at the enum level; templates run these through __()
* with the corresponding language key when i18n is needed.
*/
public function label(): string
{
return match ($this) {
self::FTE => 'Full-time',
self::PartTime => 'Part-time',
self::Contractor => 'Contractor',
self::Volunteer => 'Volunteer',
};
}
/**
* i18n key for the label — used by admin templates and any
* downstream UI that wants localised strings.
*/
public function langKey(): string
{
return 'users.employment_type.'.$this->value;
}
/**
* Whether over-allocation warnings apply to this person. False for
* Volunteer only — a volunteer at 3× their nominal hours is not a
* problem, it's a gift. Everyone else gets warned.
*/
public function countsAgainstCapacity(): bool
{
return $this !== self::Volunteer;
}
/**
* The tone of the over-cap warning when it fires. FTE = amber
* (burnout signal, not a violation). PT/Contractor = red
* (violates an explicit ceiling or a billable cap).
*/
public function overCapTone(): string
{
return match ($this) {
self::FTE => 'warn',
self::PartTime, self::Contractor => 'danger',
self::Volunteer => 'none',
};
}
}

View File

@@ -0,0 +1,208 @@
leantime.usersController = (function () {
var readURL = function (input) {
clearCroppie();
if (input.files && input.files[0]) {
var reader = new FileReader();
var profileImg = jQuery('#profileImg');
reader.onload = function (e) {
//profileImg.attr('src', e.currentTarget.result);
_uploadResult = profileImg
.croppie(
{
enableExif: true,
viewport: {
width: 175,
height: 175,
type: 'circle'
},
boundary: {
width: 200,
height: 200
}
}
);
_uploadResult.croppie(
'bind',
{
url: e.currentTarget.result
}
);
jQuery("#previousImage").hide();
};
reader.readAsDataURL(input.files[0]);
}
};
var clearCroppie = function () {
jQuery('#profileImg').croppie('destroy');
jQuery("#previousImage").show();
};
var saveCroppie = function () {
jQuery('#save-picture').addClass('running');
jQuery('#profileImg').attr('src', leantime.appUrl + '/images/loaders/loader28.gif');
_uploadResult.croppie(
'result',
{
type: "blob",
circle: true
}
).then(
function (result) {
leantime.usersService.saveUserPhoto(result);
}
);
};
var initUserTable = function () {
jQuery(document).ready(function () {
var size = 100;
var allUsersTable = jQuery("#allUsersTable").DataTable({
"language": {
"decimal": leantime.i18n.__("datatables.decimal"),
"emptyTable": leantime.i18n.__("datatables.emptyTable"),
"info": leantime.i18n.__("datatables.info"),
"infoEmpty": leantime.i18n.__("datatables.infoEmpty"),
"infoFiltered": leantime.i18n.__("datatables.infoFiltered"),
"infoPostFix": leantime.i18n.__("datatables.infoPostFix"),
"thousands": leantime.i18n.__("datatables.thousands"),
"lengthMenu": leantime.i18n.__("datatables.lengthMenu"),
"loadingRecords": leantime.i18n.__("datatables.loadingRecords"),
"processing": leantime.i18n.__("datatables.processing"),
"search": leantime.i18n.__("datatables.search"),
"zeroRecords": leantime.i18n.__("datatables.zeroRecords"),
"paginate": {
"first": leantime.i18n.__("datatables.first"),
"last": leantime.i18n.__("datatables.last"),
"next": leantime.i18n.__("datatables.next"),
"previous": leantime.i18n.__("datatables.previous"),
},
"aria": {
"sortAscending": leantime.i18n.__("datatables.sortAscending"),
"sortDescending":leantime.i18n.__("datatables.sortDescending"),
}
},
"dom": '<"top">rt<"bottom"ilp><"clear">',
"searching": false,
"displayLength":100
});
});
};
var _initModals = function () {
var userImportModalConfig = {
sizes: {
minW: 400,
minH: 350
},
resizable: true,
autoSizable: true,
callbacks: {
afterShowCont: function () {
jQuery(".showDialogOnLoad").show();
jQuery(".userImportModal").nyroModal(userImportModalConfig);
}
}
};
jQuery(".userImportModal").nyroModal(userImportModalConfig);
}
var initUserEditModal = function () {
var userEditModal = {
sizes: {
minW: 900,
minH: 250
},
resizable: true,
autoSizable: true,
callbacks: {
afterShowCont: function () {
jQuery(".showDialogOnLoad").show();
jQuery(".userEditModal").nyroModal(userEditModal);
},
beforeClose: function () {
location.reload();
},
}
};
jQuery(".userEditModal").nyroModal(userEditModal);
}
var checkPWStrength = function (pwField) {
let timeout;
// traversing the DOM and getting the input and span using their IDs
let password = document.getElementById(pwField)
let strengthBadge = document.getElementById('pwStrength')
// The strong and weak password Regex pattern checker
let strongPassword = new RegExp('(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9])(?=.{8,})')
let mediumPassword = new RegExp('((?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9])(?=.{6,}))|((?=.*[a-z])(?=.*[A-Z])(?=.*[^A-Za-z0-9])(?=.{8,}))')
function StrengthChecker(PasswordParameter)
{
if (strongPassword.test(PasswordParameter)) {
strengthBadge.style.backgroundColor = "#107530";
strengthBadge.textContent = leantime.i18n.__('label.strong');
} else if (mediumPassword.test(PasswordParameter)) {
strengthBadge.style.backgroundColor = '#C5850D';
strengthBadge.textContent = leantime.i18n.__('label.medium');
} else {
strengthBadge.style.backgroundColor = '#CC6B6B';
strengthBadge.textContent = leantime.i18n.__('label.weak');
}
}
password.addEventListener("input", () => {
//The badge is hidden by default, so we show it
strengthBadge.style.display = 'block';
clearTimeout(timeout);
timeout = setTimeout(() => StrengthChecker(password.value), 500);
if (password.value.length !== 0) {
strengthBadge.style.display != 'block'
} else {
strengthBadge.style.display = 'none'
}
});
}
// Make public what you want to have public, everything else is private
return {
readURL: readURL,
clearCroppie: clearCroppie,
saveCroppie: saveCroppie,
initUserTable:initUserTable,
_initModals:_initModals,
checkPWStrength:checkPWStrength,
initUserEditModal:initUserEditModal
};
})();

View File

@@ -0,0 +1,40 @@
leantime.usersRepository = (function () {
//Functions
var saveUserPhoto = function (photo) {
var formData = new FormData();
formData.append('file', photo);
jQuery.ajax(
{
type: 'POST',
url: leantime.appUrl + '/users/profileImage',
data: formData,
processData: false,
contentType: false,
success: function (resp) {
jQuery('#save-picture').removeClass('running');
location.reload();
},
error: function (err) {
console.log(err);
}
}
);
};
var updateUserViewSettings = function (module, value) {
leantime.rpc('Users.Users.updateUserSettings', { category: 'views', setting: module, value: value })
.catch(function (e) { console.error('Could not save view setting', e); });
};
// Make public what you want to have public, everything else is private
return {
saveUserPhoto: saveUserPhoto,
updateUserViewSettings:updateUserViewSettings
};
})();

View File

@@ -0,0 +1,13 @@
leantime.usersService = (function () {
//Functions
var saveUserPhoto = function (photo) {
leantime.usersRepository.saveUserPhoto(photo);
};
// Make public what you want to have public, everything else is private
return {
saveUserPhoto: saveUserPhoto
};
})();

View File

@@ -0,0 +1,57 @@
<?php
namespace Leantime\Domain\Users\Permissions;
use Leantime\Core\Auth\Permissions\Permission;
use Leantime\Core\Auth\Permissions\ProvidesPermissions;
/**
* The Users (account management) permission vocabulary — the verbs only.
*
* Declares *what* can be done with user accounts; it says nothing about *which roles* may do
* it. Role assignment is centrally owned (defaults in
* {@see \Leantime\Core\Auth\Permissions\DefaultRolePermissions}, runtime in `zp_role_permissions`).
*
* Every user capability is COMPANY-WIDE, not project-scoped: managing accounts is authority a
* user holds across the whole installation, evaluated against their GLOBAL role (see
* {@see \Leantime\Core\Auth\RoleResolver}). So every Permission below is constructed with
* `projectScoped = false`, and call sites gate with `#[RequiresPermission(..., global: true)]`.
*
* These verbs cover MANAGING OTHER accounts only. Editing one's OWN profile/settings/avatar is
* self-service that every authenticated user may do and is deliberately NOT gated by any
* `users.*` permission (see {@see \Leantime\Domain\Users\Controllers\EditOwn} and the
* self-service methods on the Users service).
*/
final class UsersPermissions implements ProvidesPermissions
{
/** View the company user roster / read another account. */
public const VIEW = 'users.view';
/** Invite / create new accounts. */
public const CREATE = 'users.create';
/** Edit another account (role, client, status, profile fields). */
public const EDIT = 'users.edit';
/** Delete accounts. */
public const DELETE = 'users.delete';
/** Bulk-import accounts from a directory (LDAP). */
public const IMPORT = 'users.import';
public function domain(): string
{
return 'users';
}
public function permissions(): array
{
return [
new Permission(self::VIEW, 'View users', false),
new Permission(self::CREATE, 'Invite/create users', false),
new Permission(self::EDIT, 'Edit users', false),
new Permission(self::DELETE, 'Delete users', false),
new Permission(self::IMPORT, 'Import users (LDAP)', false),
];
}
}

View File

@@ -0,0 +1,619 @@
<?php
namespace Leantime\Domain\Users\Repositories;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Database\ConnectionInterface;
use Leantime\Core\Configuration\Environment;
use Leantime\Core\Db\DatabaseHelper;
use Leantime\Core\Db\Db as DbCore;
use Leantime\Domain\Files\Repositories\Files;
use Leantime\Domain\Users\Enums\EmploymentType;
class Users
{
private ConnectionInterface $connection;
public string $user;
public string $lastname;
public string $firstname;
public int $role;
public string $jobTitle;
public string $jobLevel;
public string $department;
public int $id;
public array $adminRoles = [40, 50];
public array $status = ['active' => 'label.active', 'inactive' => 'label.inactive', 'invited' => 'label.invited'];
/**
* Request-scoped memo for getUser(), keyed by user id. Cleared on writes.
*
* @var array<int|string, array|bool>
*/
private array $userMemo = [];
/**
* __construct - neu db connection
*/
public function __construct(
protected Environment $config,
protected DbCore $db,
protected DatabaseHelper $dbHelper,
protected Files $files
) {
$this->connection = $db->getConnection();
}
/**
* getUser - get on user from db
*/
public function getUser($id): array|bool
{
// Request-scoped memo: getUser is hit repeatedly per request for
// author/role/avatar lookups. Cleared by editUser/patchUser/deleteUser.
if (array_key_exists($id, $this->userMemo)) {
return $this->userMemo[$id];
}
$result = $this->connection->table('zp_user')
->where('id', $id)
->limit(1)
->first();
return $this->userMemo[$id] = $result ? (array) $result : false;
}
/**
* getUser - get on user from db
*/
public function getUserBySha($hash): array|false
{
$result = $this->connection->table('zp_user')
->whereRaw('SHA1(CONCAT(id, ?)) = ?', [$this->config->sessionPassword, $hash])
->first();
return $result ? (array) $result : false;
}
/**
* getLastLogin - get the date of the last login of any user
*
* @return string|null returns datetime string with last login or null if nothing could be found
*/
public function getLastLogin(): ?string
{
$result = $this->connection->table('zp_user')
->select('lastlogin')
->orderByDesc('lastlogin')
->limit(1)
->first();
return $result->lastlogin ?? null;
}
/**
* getUserByEmail - get on user from db
*/
public function getUserByEmail(string $email, string $status = 'a'): array|false
{
$query = $this->connection->table('zp_user')
->where('username', $email);
if ($status === 'a') {
$query->whereRaw('LOWER(status) = ?', ['a']);
}
if ($status === 'i') {
$query->whereRaw('LOWER(status) = ?', ['i']);
}
$result = $query->limit(1)->first();
return $result ? (array) $result : false;
}
public function getNumberOfUsers($activeOnly = false, $includeApi = true): int
{
$query = $this->connection->table('zp_user')
->selectRaw('COUNT(id) AS '.$this->dbHelper->wrapColumn('userCount'));
if ($activeOnly) {
$query->where('status', 'a');
}
if ($includeApi) {
$query->where(function ($q) {
$q->where('source', '!=', 'api')
->orWhereNull('source');
});
}
$result = $query->first();
return $result->userCount ?? 0;
}
/**
* getEmployees - get all employees
*/
public function getEmployees(): array
{
$results = $this->connection->table('zp_user')
->select([
'zp_user.id',
'zp_user.lastname',
'zp_user.jobTitle',
'zp_user.jobLevel',
'zp_user.department',
'zp_user.modified',
])
->selectRaw('COALESCE(zp_user.firstname, zp_user.username) AS firstname')
->orderBy('lastname')
->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
/**
* getAll - get all user
*
* @param bool $activeOnly
*/
public function getAll($activeOnly = false): array
{
$query = $this->connection->table('zp_user')
->select([
'zp_user.id',
'lastname',
'role',
'profileId',
'status',
'username',
'twoFAEnabled',
'clientId',
'zp_clients.name as clientName',
'jobTitle',
'jobLevel',
'department',
'zp_user.modified',
])
->selectRaw("CASE WHEN firstname <> '' THEN firstname ELSE username END AS firstname")
->leftJoin('zp_clients', 'zp_clients.id', '=', 'zp_user.clientId')
->where(function ($q) {
$q->whereNull('source')
->orWhere('source', '!=', 'api');
});
if ($activeOnly) {
$query->where('status', 'like', 'a');
}
$results = $query->orderBy('lastname')->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
public function getAllBySource($source): false|array
{
$query = $this->connection->table('zp_user')
->select([
'zp_user.id',
'lastname',
'firstname',
'role',
'profileId',
'status',
'username',
'lastlogin',
'createdOn',
'jobTitle',
'jobLevel',
'department',
'modified',
]);
if ($source === null) {
$query->whereNull('source');
} else {
$query->where('source', $source);
}
$results = $query->orderBy('lastname')->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
/**
* getAll - get all user
*/
public function getAllClientUsers($clientId): array
{
$results = $this->connection->table('zp_user')
->select([
'zp_user.id',
'lastname',
'firstname',
'role',
'profileId',
'status',
'username',
'twoFAEnabled',
'zp_clients.name as clientName',
'jobTitle',
'jobLevel',
'department',
'modified',
])
->leftJoin('zp_clients', 'zp_clients.id', '=', 'zp_user.clientId')
->where('clientId', $clientId)
->orderBy('lastname')
->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
public function isAdmin($userId): bool
{
$result = $this->connection->table('zp_user')
->select('role')
->where('id', $userId)
->limit(1)
->first();
if ($result && in_array($result->role, $this->adminRoles)) {
return true;
}
return false;
}
/**
* editUSer - edit user
*/
public function editUser(array $values, $id): bool
{
unset($this->userMemo[$id]);
$updateData = [
'firstname' => $values['firstname'],
'lastname' => $values['lastname'],
'username' => $values['user'],
'phone' => $values['phone'] ?? '',
'status' => $values['status'],
'role' => $values['role'],
'hours' => $values['hours'] ?? 0,
'wage' => $values['wage'] ?? 0,
'clientId' => $values['clientId'],
'jobTitle' => $values['jobTitle'] ?? '',
'jobLevel' => $values['jobLevel'] ?? '',
'department' => $values['department'] ?? '',
'modified' => now(),
];
// Capacity attributes (v3.5.23) — only overwrite when explicitly
// present so an admin form that omits the fields (or a legacy
// caller) doesn't null out a value already set. Validation runs
// at this boundary so every caller (form controller, service,
// JSON-RPC) gets the same guarantees: unrecognised employment
// types normalise to NULL; weekly_hours outside a sane range
// normalises to NULL.
if (array_key_exists('weekly_hours', $values)) {
$updateData['weekly_hours'] = $this->normalizeWeeklyHours($values['weekly_hours']);
}
if (array_key_exists('employment_type', $values)) {
$updateData['employment_type'] = $this->normalizeEmploymentType($values['employment_type']);
}
if (isset($values['password']) && $values['password'] != '' && ! $this->isHashedPassword($values['password'])) {
$updateData['password'] = password_hash($values['password'], PASSWORD_DEFAULT);
}
return $this->connection->table('zp_user')
->where('id', $id)
->update($updateData) > 0;
}
/**
* usernameExist - Check if a username is already in db
*/
public function usernameExist($username, string $userId = ''): bool
{
$query = $this->connection->table('zp_user')
->selectRaw('COUNT(username) AS '.$this->dbHelper->wrapColumn('numUser'))
->where('username', $username);
if ($userId != '') {
$query->where('id', '!=', $userId);
}
$result = $query->limit(1)->first();
return (int) $result->numUser === 1;
}
/**
* removeFromClient - Remove user from client by setting clientId to null
*
* @param int $userId User ID to remove from client
* @return bool Success status
*/
public function removeFromClient(int $userId): bool
{
return $this->connection->table('zp_user')
->where('id', $userId)
->update([
'clientId' => null,
'modified' => now(),
]) > 0;
}
/**
* editOwn - Edit own Userdates
*/
public function editOwn($values, $id): void
{
$updateData = [
'lastname' => $values['lastname'],
'firstname' => $values['firstname'],
'username' => $values['user'],
'phone' => $values['phone'],
'notifications' => $values['notifications'],
'modified' => now(),
];
if (isset($values['password']) && $values['password'] != '' && ! $this->isHashedPassword($values['password'])) {
$updateData['password'] = password_hash($values['password'], PASSWORD_DEFAULT);
}
$this->connection->table('zp_user')
->where('id', $id)
->update($updateData);
}
/**
* isHashedPassword - Detect a value that is already a password hash so callers that pass a
* full user row (e.g. the OIDC login sync) don't re-hash the stored hash and lock the user out.
*/
private function isHashedPassword(string $password): bool
{
return ! empty(password_get_info($password)['algo']);
}
/**
* addUser - add User to db
*/
public function addUser(array $values): false|string
{
$userId = $this->connection->table('zp_user')->insertGetId([
'firstname' => $values['firstname'] ?? '',
'lastname' => $values['lastname'] ?? '',
'phone' => $values['phone'] ?? '',
'username' => $values['user'],
'role' => $values['role'],
'notifications' => 1,
'clientId' => $values['clientId'] ?? '',
'password' => password_hash($values['password'], PASSWORD_DEFAULT),
'source' => $values['source'] ?? '',
'pwReset' => $values['pwReset'] ?? '',
'status' => $values['status'] ?? '',
'createdOn' => now(),
'jobTitle' => $values['jobTitle'] ?? '',
'jobLevel' => $values['jobLevel'] ?? '',
'department' => $values['department'] ?? '',
'weekly_hours' => $this->normalizeWeeklyHours($values['weekly_hours'] ?? null),
'employment_type' => $this->normalizeEmploymentType($values['employment_type'] ?? null),
'modified' => now(),
]);
return (string) $userId;
}
/**
* deleteUser - delete user from db
*/
public function deleteUser($id): void
{
unset($this->userMemo[$id]);
$this->connection->table('zp_user')
->where('zp_user.id', $id)
->delete();
}
/**
* setPicture - set the profile picture for an individual
*
* @throws BindingResolutionException
*/
public function setPicture($fileId, $id): bool
{
return $this->connection->table('zp_user')
->where('id', $id)
->update([
'profileId' => $fileId,
'modified' => dtHelper()->dbNow()->formatDateTimeForDb(),
]) > 0;
}
/**
* @throws BindingResolutionException
*/
public function getProfilePicture($id): array|false
{
if ($id === false) {
return false;
}
$result = $this->connection->table('zp_user')
->select(['profileId', 'firstname', 'lastname'])
->where('id', $id)
->limit(1)
->first();
return $result ? (array) $result : false;
}
public function patchUser($id, $params): bool
{
unset($this->userMemo[$id]);
$updates = [];
foreach ($params as $key => $value) {
$cleanKey = DbCore::sanitizeToColumnString($key);
if ($cleanKey === 'password') {
$updates[$cleanKey] = password_hash($value, PASSWORD_DEFAULT);
} else {
$updates[$cleanKey] = $value;
}
}
$updates['modified'] = now();
return $this->connection->table('zp_user')
->where('id', $id)
->update($updates) > 0;
}
/**
* getUserIdByName - Get Author/User Id by first- and lastname
*
* @param string $lastname Lastname
* @return int|bool Identifier of user or false, if not found
*/
public function getUserIdByName(string $firstname, string $lastname): int|bool
{
$result = $this->connection->table('zp_user')
->select('profileId')
->where('firstname', $firstname)
->where('lastname', $lastname)
->first();
return $result->profileId ?? false;
}
/**
* Get user settings - retrieves and deserializes user settings
*
* @param int $userId The user ID to get settings for
* @param string|null $settingPath Optional dot notation path to retrieve specific setting (e.g. 'onboarding.firstLoginCompleted')
* @return mixed The requested settings or specific setting value, empty array if no settings exist
*/
public function getUserSettings(int $userId, ?string $settingPath = null): mixed
{
$result = $this->connection->table('zp_user')
->select('settings')
->where('id', $userId)
->limit(1)
->first();
// If no settings exist yet, return empty array
if (! $result || empty($result->settings)) {
return [];
}
// Try to unserialize the settings
try {
$settings = safe_unserialize($result->settings, []);
// Ensure settings is an array (unserialize may return stdClass)
if (is_object($settings)) {
$settings = json_decode(json_encode($settings), true);
}
if (! is_array($settings)) {
return [];
}
// If we have a specific path to retrieve
if ($settingPath !== null) {
return $this->getNestedSetting($settings, $settingPath);
}
return $settings;
} catch (\Exception $e) {
// If there's an error unserializing, return empty array
return [];
}
}
/**
* Helper method to get a nested setting using dot notation
*
* @param array $settings The settings array
* @param string $path Dot notation path (e.g. 'onboarding.firstLoginCompleted')
* @return mixed The setting value or null if not found
*/
private function getNestedSetting(array $settings, string $path)
{
$keys = explode('.', $path);
$current = $settings;
foreach ($keys as $key) {
if (! is_array($current) || ! isset($current[$key])) {
return null;
}
$current = $current[$key];
}
return $current;
}
/**
* Normalise a POSTed weekly_hours value into the persisted shape.
* Accepts numeric input; rejects anything outside 0..168 (168 =
* hours in a week, the largest value that has physical meaning).
* Anything that fails validation becomes NULL — treated by the
* capacity math as "not configured" rather than saving garbage.
*
* Fractional input is ROUNDED, not truncated. The column is an int
* and the edit-user field is a step-1 number input, so fractions
* only reach here from the API — where a plain (int) cast silently
* turned 37.5 into 37. Rounding keeps the value closest to intent;
* rejecting it outright would be worse, since NULL removes the
* person from capacity math entirely.
*
* NOTE: the int column cannot represent genuinely fractional
* contracts (37.5 h/wk is common part-time). Storing minutes, or
* widening the column, is the real fix if that case matters.
*/
private function normalizeWeeklyHours(mixed $value): ?int
{
if ($value === null || $value === '') {
return null;
}
if (! is_numeric($value)) {
return null;
}
$hours = (int) round((float) $value);
if ($hours < 0 || $hours > 168) {
return null;
}
return $hours;
}
/**
* Normalise a POSTed employment_type into one of the four enum
* cases or NULL. Any string that isn't a recognised case is
* silently normalised to NULL so a crafted POST can't persist
* garbage that later EmploymentType::from() would throw on.
*/
private function normalizeEmploymentType(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
$case = EmploymentType::tryFrom((string) $value);
return $case?->value;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
@props([
'user' => null
])
<div class="profileBox">
<div class="commentImage">
@if (isset($user['userId']) || isset($user->userId))
<x-users::profile-image :user="$user" />
@else
<i class="fa fa-user"></i>
@endif
</div>
<div class="userName">
{{ $slot }}
</div>
</div>

View File

@@ -0,0 +1,7 @@
@props([
'user' => null
])
<img {{ $attributes->merge([
'src' => BASE_URL . '/api/users?profileImage=' . $user['id'] .'&v='.format($user['modified'])->timestamp(),
]) }} />

View File

@@ -0,0 +1,32 @@
@extends($layout)
@section('content')
<div class="pageheader">
<div class="pageicon"><span class="fa {{ $tpl->getModulePicture() }}"></span></div>
<div class="pagetitle">
<h5>{!! __('label.administration') !!}</h5>
<h1><h1>{!! __('headlines.delete_user') !!}</h1></h1>
</div>
</div><!--pageheader-->
<div class="maincontent">
<div class="maincontentinner">
{!! $tpl->displayNotification() !!}
<h4 class="widget widgettitle">{!! __('subtitles.delete') !!}</h4>
<div class="widgetcontent">
<form method="post">
<input type="hidden" name="{{ session('formTokenName') }}" value="{{ session('formTokenValue') }}" />
<p>{!! __('text.confirm_user_deletion') !!}</p><br />
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.yes_delete')" name="del" />
<x-global::forms.button tag="a" contentRole="tertiary" link="{{ BASE_URL }}/users/showAll">{!! __('buttons.back') !!}</x-global::forms.button>
</form>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,508 @@
@extends($layout)
@section('content')
<x-global::pageheader :icon="'fa fa-user'">
<h5>{{ __('label.overview') }}</h5>
<h1>{!! __('headlines.accountSettings') !!}</h1>
</x-global::pageheader>
<div class="maincontent">
{!! $tpl->displayNotification() !!}
<div class="row">
<div class="col-md-12">
<div class="maincontentinner">
<div class="tabbedwidget tab-primary accountTabs">
<ul>
<li><a href="#myProfile">{!! __('tabs.myProfile') !!}</a></li>
<li><a href="#security">{!! __('tabs.security') !!}</a></li>
<li><a href="#settings">{!! __('tabs.settings') !!}</a></li>
<li><a href="#notifications">{!! __('tabs.notifications') !!}</a></li>
<li><a href="#theme">{!! __('tabs.theme') !!}</a></li>
@dispatchEvent('tabs')
</ul>
<div id="myProfile">
<div class="row">
<div class="col-md-8">
<form action="" method="post">
<h4 class="widgettitle title-light"><?php echo $tpl->__('label.profile_information'); ?></h4>
<input type="hidden" name="{{ session("formTokenName") }}" value="{{ session("formTokenValue") }}" />
<div class="row-fluid">
<div class="form-group">
<label for="firstname" >{{ __('label.firstname') }}</label>
<span>
<input type="text" class="input" name="firstname" id="firstname" {{ session("userdata.isExternalAuth") ? "disabled='disabled'" : '' }}
value="{{ $values['firstname'] }}"/><br/>
</span>
</div>
<div class="form-group">
<label for="lastname" >{{ __('label.lastname') }}</label>
<span>
<input type="text" name="lastname" class="input" id="lastname" {{ session("userdata.isExternalAuth") ? "disabled='disabled'" : '' }}
value="{{ $values['lastname'] }}"/><br/>
</span>
</div>
<div class="form-group">
<label for="user" >{{ __('label.email') }}</label>
<span>
<input type="text" name="user" class="input" id="user" {{ session("userdata.isExternalAuth") ? "disabled='disabled'" : '' }}
value="{{ $values['user'] }}"/><br/>
</span>
</div>
<div class="form-group">
<label for="phone" >{{ __('label.phone') }}</label>
<span>
<input type="text" name="phone" class="input" id="phone" {{ session("userdata.isExternalAuth") ? "disabled='disabled'" : '' }}
value="{{ $values['phone'] }}"/><br/>
</span>
</div>
<p class='stdformbutton'>
<input type="hidden" name="profileInfo" value="1" />
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="save" id="save" />
</p>
<br />
<h4 class="widgettitle title-light">{{ __('label.employee_information') }}</h4>
<em>{{ __('text.only_admins_can_change_user_info') }}</em><br /><br />
<div class="form-group">
<label for="phone" >{{ __('label.jobTitle') }}</label>
<span>
<input type="text" name="jobTitle" readonly class="input" id="jobTitle"}}
value="{{ $values['jobTitle'] }}"/><br/>
</span>
</div>
<div class="form-group">
<label for="phone" >{{ __('label.jobLevel') }}</label>
<span>
<input type="text" name="jobLevel" readonly class="input" id="jobLevel"}}
value="{{ $values['jobLevel'] }}"/><br/>
</span>
</div>
<div class="form-group">
<label for="phone" >{{ __('label.department') }}</label>
<span>
<input type="text" name="department" readonly class="input" id="department"}}
value="{{ $values['department'] }}"/><br/>
</span>
</div>
</div>
</form>
</div>
<div class="col-md-4">
<div class="center">
<img src='{{ BASE_URL }}/api/users?profileImage={{ $user['id'] }}&v={{ format($user['modified'])->timestamp() }}' class='profileImg tw-rounded-full' alt='Profile Picture' id="previousImage"/>
<div id="profileImg">
</div>
<div class="par">
<label>{{ __('label.upload') }}</label>
<div class='fileupload fileupload-new' data-provides='fileupload'>
<input type="hidden"/>
<div class="input-append">
<div class="uneditable-input span3">
<i class="fa-file fileupload-exists"></i>
<span class="fileupload-preview"></span>
</div>
<span class="btn btn-file">
<span class="fileupload-new">{{ __('buttons.select_file') }}</span>
<span class='fileupload-exists'>{{ __('buttons.change') }}</span>
<input type='file' name='file' onchange="leantime.usersController.readURL(this)" accept=".jpg,.png,.gif,.webp"/>
</span>
<a href='#' class='btn fileupload-exists' data-dismiss='fileupload' onclick="leantime.usersController.clearCroppie()">{{ __('buttons.remove') }}</a>
</div>
<p class='stdformbutton'>
<span id="save-picture" class="btn btn-primary fileupload-exists ld-ext-right">
<span onclick="leantime.usersController.saveCroppie()">{{ __('buttons.save') }}</span>
<span class="ld ld-ring ld-spin"></span>
</span>
<input type="hidden" name="profileImage" value="1" />
<input id="picSubmit" type="submit" name="savePic" class="hidden"
value="{{ __('buttons.upload') }}"/>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="security">
<h4 class="widgettitle title-light">
{!! __('headlines.change_password') !!}
</h4>
@if (session("userdata.isExternalAuth") )
<strong> {{ __("text.account_managed_external_auth") }}</strong><br /><br />
@endif
<form method="post">
<input type="hidden" name="{{ session("formTokenName") }}" value="{{ session("formTokenValue") }}" />
<div class="row-fluid">
<div class="form-group">
<label for="currentPassword" >{{ __('label.old_password') }}</label>
<span>
<input type='password' value="" name="currentPassword" class="input" {{ session("userdata.isExternalAuth") ? "disabled='disabled'" : '' }}
id="currentPassword"/><br/>
</span>
</div>
<div class="form-group">
<label for="newPassword" >{{ __('label.new_password') }}</label>
<span>
<input type='password' value="" name="newPassword" class="input" {{ session("userdata.isExternalAuth") ? "disabled='disabled'" : '' }}
id="newPassword"/>
<span id="pwStrength"></span>
</span>
</div>
<div class="form-group">
<label for="confirmPassword" >{{ __('label.password_repeat') }}</label>
<span>
<input type="password" value="" name="confirmPassword" class="input" {{ session("userdata.isExternalAuth") ? "disabled='disabled'" : '' }}
id="confirmPassword"/><br/>
@if (!session("userdata.isExternalAuth") )
<small>{{ __('label.passwordRequirements') }}</small>
@endif
</span>
</div>
</div>
@if (!session("userdata.isExternalAuth") )
<input type="hidden" name="savepw" value="1" />
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="save" id="savePw" />
@endif
</form>
<br /><br />
<h4 class="widgettitle title-light">
<i class="fa-solid fa-shield-halved"></i> {{ __('headlines.twoFA') }}
</h4>
@if ($values['twoFAEnabled'] )
<p>{!! __('text.twoFA_enabled') !!}</p>
@else
<p>{!! __('text.twoFA_disabled') !!}</p>
@endif
<p><a href="{{ BASE_URL }}/twoFA/edit">{!! __('text.twoFA_manage') !!}</a></p>
</div>
<div id="settings">
<form action="" method="post">
<input type="hidden" name="{{ session("formTokenName") }}" value="{{ session("formTokenValue") }}" />
<div class="row-fluid">
<div class="form-group">
<label for="language" >{{ __('label.language') }}</label>
<span class='field'>
<select name="language" id="language" style="width: 220px">
@foreach ($languageList as $languagKey => $languageValue )
<option value="{{ $languagKey }}"
@if ($userLang == $languagKey )
selected='selected'
@endif >{{ $languageValue }}</option>
@endforeach
</select>
</span>
</div>
<div class="form-group">
<label for="date_format" >{{ __('label.date_format') }}</label>
<span>
<select name="date_format" id="date_format" style="width: 220px">
@php
$dateFormats = $dateTimeValues['dates'];
$dateTimeNow = date_create();
@endphp
@foreach ($dateFormats as $format)
<option value="{{ $format }}"
@if ($dateFormat == $format)
selected='selected'
@endif >{{ date_format($dateTimeNow, $format) }}</option>
@endforeach
</select>
</span>
</div>
<div class="form-group">
<label for="time_format" >{{ __('label.time_format') }}</label>
<span>
<select name="time_format" id="time_format" style="width: 220px">
@php
$timeFormats = $dateTimeValues['times'];
$dateTimeNow = date_create();
@endphp
@foreach ($timeFormats as $format)
<option value="{{ $format }}"
@if ($timeFormat == $format)
selected='selected'
@endif>{{ date_format($dateTimeNow, $format) }}</option>
@endforeach
</select>
</span>
</div>
<div class="form-group">
<label for="timezone" >{{ __('label.timezone') }}</label>
<span>
<select name="timezone" id="timezone" style="width: 220px">
@foreach ($timezoneOptions as $tz)
<option value="{{ $tz }}"
@if ($timezone === $tz )
selected='selected'
@endif
>{{ $tz }}</option>
@endforeach
</select>
</span>
</div>
</div>
<input type="hidden" name="saveSettings" value="1" />
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="save" id="saveSettings" />
</form>
</div>
<div id="theme">
<form action="" method="post">
<input type="hidden" name="{{ session("formTokenName") }}" value="{{ session("formTokenValue") }}" />
<div class="row-fluid">
<div class="form-group">
<label for="themeSelect">Optimal Stimulation</label>
<span class='field tw-flex tw-w-80'>
<?php
foreach ($availableThemes as $key => $theme) { ?>
<x-global::selectable selected="{{ ($userTheme == $key ? 'true' : 'false') }}" :id="''" :name="'theme'" :value="$key" :label="''" class="tw-w-1/2" onclick="leantime.snippets.toggleBg('{{ $key }}')">
<img src="{{ BASE_URL }}/dist/images/background-{{$key}}.png" style="margin:0; border-radius:10px;" />
<br /><?= $tpl->__($theme['name']) ?>
</x-global::selectable>
<?php } ?>
</span>
</div>
<div class="row">
<div class="col-md-12">
<hr />
<label for="colormode" >{{ __('label.colormode') }}</label>
<x-global::selectable :selected="($userColorMode == 'light') ? 'true' : ''" :id="'light'" :name="'colormode'" :value="'light'" :label="'Light'" onclick="leantime.snippets.toggleTheme('light')">
<label for="colormode-light" class="tw-w-[100px]">
<i class="fa-solid fa-sun tw-font-xxl"></i>
</label>
</x-global::selectable>
<x-global::selectable :selected="($userColorMode == 'dark') ? 'true' : ''" :id="'dark'" :name="'colormode'" :value="'dark'" :label="'Dark'" onclick="leantime.snippets.toggleTheme('dark')">
<label for="colormode-light" class="tw-w-[100px]">
<i class="fa-solid fa-moon tw-font-xxl"></i>
</label>
</x-global::selectable>
</div>
</div>
<div class="row">
<div class="col-md-12">
<hr />
<label>Font</label>
@foreach($availableFonts as $key => $font)
<x-global::selectable :selected="($themeFont == $font) ? 'true' : ''" :id="$key" :name="'themeFont'" :value="$font" :label="$font" onclick="leantime.snippets.toggleFont('{{ $font }}')">
<label for="selectable-{{ $key }}" class="font tw-w-[200px]"
style="font-family:'{{ $font }}'; font-size:16px;">
The quick brown fox jumps over the lazy dog
</label>
</x-global::selectable>
@endforeach
</div>
</div>
<div class="row">
<div class="col-md-12">
<hr />
<label>Color Scheme</label>
@foreach($availableColorSchemes as $key => $scheme )
<x-global::selectable class="circle" :selected="($userColorScheme == $key) ? 'true' : ''" :id="$key" :name="'colorscheme'" :value="$key" :label="__($scheme['name'])" onclick="leantime.snippets.toggleColors('{{ $scheme['primaryColor'] }}','{{ $scheme['secondaryColor'] }}');">
<label for="color-{{ $key }}" class="colorCircle"
style="background:linear-gradient(135deg, {{ $scheme["primaryColor"] }} 20%, {{ $scheme["secondaryColor"] }} 100%);">
</label>
</x-global::selectable>
@endforeach
</div>
</div>
</div>
<br /><br />
<input type="hidden" name="saveTheme" value="1" />
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="save" id="saveTheme" />
</form>
@dispatchEvent('themecontent')
</div>
<div id="notifications">
<form action="" method="post">
<input type="hidden" name="{{ session("formTokenName") }}" value="{{ session("formTokenValue") }}" />
<div class="row-fluid">
<div class="form-group">
<label for="notifications" style="display: inline-flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" value="on" name="notifications" class="input"
id="notifications"
@if ($values['notifications'] == "1" )
checked='checked'
@endif/>
{{ __('label.receive_notifications') }}
</label>
</div>
<div class="form-group">
<label for="messagesfrequency" >{{ __('label.messages_frequency') }}</label>
<span>
<select name="messagesfrequency" class="input" id="messagesfrequency" style="width: 220px">
<option value="">--{{ __('label.choose_option') }}--</option>
<option value="60"
@if ($values['messagesfrequency'] == "60" )
selected="selected"
@endif>{{ __('label.1min') }}</option>
<option value="300" @if ($values['messagesfrequency'] == "300" )
selected="selected"
@endif>{{ __('label.5min') }}</option>
<option value="900" @if ($values['messagesfrequency'] == "900" )
selected="selected"
@endif>{{ __('label.15min') }}</option>
<option value="1800" @if ($values['messagesfrequency'] == "1800" )
selected="selected"
@endif>{{ __('label.30min') }}</option>
<option value="3600" @if ($values['messagesfrequency'] == "3600" )
selected="selected"
@endif>{{ __('label.1h') }}</option>
<option value="10800" @if ($values['messagesfrequency'] == "10800" )
selected="selected"
@endif>{{ __('label.3h') }}</option>
<option value="36000" @if ($values['messagesfrequency'] == "36000" )
selected="selected"
@endif>{{ __('label.6h') }}</option>
<option value="43200" @if ($values['messagesfrequency'] == "43200" )
selected="selected"
@endif>{{ __('label.12h') }}</option>
<option value="86400" @if ($values['messagesfrequency'] == "86400" )
selected="selected"
@endif>{{ __('label.24h') }}</option>
<option value="172800" @if ($values['messagesfrequency'] == "172800" )
selected="selected"
@endif>{{ __('label.48h') }}</option>
<option value="604800" @if ($values['messagesfrequency'] == "604800" )
selected="selected"
@endif>{{ __('label.1w') }}</option>
</select> <br/>
</span>
</div>
</div>
<hr />
<h4 class="widgettitle title-light">{{ __('label.notification_event_types') }}</h4>
<p><small>{{ __('label.notification_event_types_description') }}</small></p>
<div class="tw-mb-4">
@php
$categoryLabels = [
'tasks' => 'label.notification_category_tasks',
'comments' => 'label.notification_category_comments',
'goals' => 'label.notification_category_goals',
'ideas' => 'label.notification_category_ideas',
'projects' => 'label.notification_category_projects',
'boards' => 'label.notification_category_boards',
];
@endphp
@foreach ($notificationCategories as $categoryKey => $config)
<label class="tw-flex tw-items-start tw-gap-2 tw-cursor-pointer tw-m-0 tw-py-1.5">
<input type="checkbox"
name="enabledEventTypes[]"
value="{{ $categoryKey }}"
class="input tw-mt-0.5"
@if (in_array($categoryKey, $enabledEventTypes))
checked="checked"
@endif
/>
<span>
<strong>{{ __($categoryLabels[$categoryKey] ?? $categoryKey) }}</strong><br />
<small class="tw-text-gray-500">{{ __($config['description'] ?? '') }}</small>
</span>
</label>
@endforeach
</div>
<hr />
<h4 class="widgettitle title-light">{{ __('label.project_notifications') }}</h4>
<p><small>{{ __('label.project_notifications_description') }}</small></p>
<div class="tw-max-w-lg tw-max-h-[350px] tw-overflow-y-auto tw-mb-5">
@if (count($userProjects) > 0)
@foreach ($userProjects as $project)
@php
$currentLevel = $projectNotificationLevels[$project['id']] ?? $companyDefaultRelevance;
@endphp
<div class="tw-flex tw-items-center tw-justify-between tw-py-1.5 tw-border-b tw-border-gray-100">
<span class="tw-truncate tw-mr-3">
{{ $project['name'] }}
@if (!empty($project['clientName']))
<span class="tw-text-gray-400 tw-text-xs">({{ $project['clientName'] }})</span>
@endif
</span>
<select name="projectNotificationLevel[{{ $project['id'] }}]"
class="tw-text-sm tw-border tw-border-gray-300 tw-rounded tw-px-2 tw-py-1 tw-min-w-[140px]">
@foreach ($relevanceLevels as $level => $labelKey)
<option value="{{ $level }}"
@if ($currentLevel === $level) selected @endif
>{{ __($labelKey) }}</option>
@endforeach
</select>
</div>
@endforeach
@else
<p class="tw-text-gray-400 tw-p-2">{{ __('label.no_projects') }}</p>
@endif
</div>
<input type="hidden" name="savenotifications" value="1" />
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="save" />
</form>
</div>
@dispatchEvent('tabsContent')
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
jQuery(document).ready(function(){
leantime.usersController.checkPWStrength('newPassword');
jQuery('.accountTabs').tabs();
jQuery("#messagesfrequency").chosen();
jQuery("#language").chosen();
jQuery("#themeSelect").chosen();
});
</script>
@endsection

View File

@@ -0,0 +1,243 @@
@extends($layout)
@section('content')
@php
$projects = $relations;
@endphp
{!! $tpl->displayNotification() !!}
<div class="pageheader">
<div class="pageicon"><span class="fa {{ $tpl->getModulePicture() }}"></span></div>
<div class="pagetitle">
<h5>{!! __('label.administration') !!}</h5>
<h1>{!! __('headlines.edit_user') !!}</h1>
</div>
</div><!--pageheader-->
<form action="" method="post" class="stdform userEditModal">
<input type="hidden" name="{{ session('formTokenName') }}" value="{{ session('formTokenValue') }}" />
<div class="maincontent">
<div class="row">
<div class="col-md-7">
<div class="maincontentinner">
<h4 class="widgettitle title-light">{!! __('label.profile_information') !!}</h4>
<label for="firstname">{!! __('label.firstname') !!}</label> <x-global::forms.text-input
name="firstname" id="firstname"
value="{{ $values['firstname'] }}" /><br />
<label for="lastname">{!! __('label.lastname') !!}</label> <x-global::forms.text-input
name="lastname" id="lastname"
value="{{ $values['lastname'] }}" /><br />
<label for="role">{!! __('label.role') !!}</label>
<select name="role" id="role">
@foreach ($roles as $key => $role)
{{-- Privilege ceiling (parity with newUser): a manager may never
assign the admin(40)/owner(50) roles. Editing users is admin+
today, so this is defense-in-depth against any future widening
of who can reach this screen a lower role must never be able
to escalate an account above its own authority. --}}
@if ($login::userHasRole(\Leantime\Domain\Auth\Models\Roles::$manager) && $key > 30)
@continue
@endif
<option value="{{ $key }}"
@if ($key == $values['role']) selected="selected" @endif>
{!! __('label.roles.' . $role) !!}
</option>
@endforeach
</select> <br />
<label for="status">{!! __('label.status') !!}</label>
<select name="status" id="status" class="pull-left">
<option value="a"
@if (strtolower($values['status']) == 'a') selected="selected" @endif>
{!! __('label.active') !!}
</option>
<option value="i"
@if (strtolower($values['status']) == 'i') selected="selected" @endif>
{!! __('label.invited') !!}
</option>
<option value="0"
@if (strtolower($values['status']) === '' || $values['status'] === 0 || $values['status'] === '0') selected="selected" @endif>
{!! __('label.deactivated') !!}
</option>
</select>
@if ($values['status'] == 'i')
<div class="pull-left dropdownWrapper" style="padding-left:5px; line-height: 29px;">
<a class="dropdown-toggle btn btn-default" data-toggle="dropdown" href="{{ BASE_URL }}/auth/userInvite/{{ $values['pwReset'] }}"><i class="fa fa-link"></i> {!! __('label.copyinviteLink') !!}</a>
<div class="dropdown-menu padding-md noClickProp">
<x-global::forms.text-input id="inviteURL" value="{{ BASE_URL }}/auth/userInvite/{{ $values['pwReset'] }}" />
<x-global::forms.button contentRole="primary" onclick="leantime.snippets.copyUrl('inviteURL');">{!! __('links.copy_url') !!}</x-global::forms.button>
</div>
<x-global::forms.button tag="a" link="{{ BASE_URL }}/users/editUser/{{ $values['id'] }}?resendInvite=1" contentRole="default" style="margin-left:5px;"><i class="fa fa-envelope"></i> {!! __('buttons.resend_invite') !!}</x-global::forms.button>
</div>
@endif
<div class="clearfix"></div>
<label for="client">{!! __('label.client') !!}</label>
<select name='client' id="client">
@if ($login::userIsAtLeast('manager'))
<option value="0" selected="selected">{!! __('label.no_clients') !!}</option>
@endif
@foreach ($clients as $clientItem)
<option value="{{ $clientItem['id'] }}" @if ($clientItem['id'] == $values['clientId']) selected="selected" @endif>{{ $clientItem['name'] }}</option>
@endforeach
</select><br/>
<br/>
<h4 class="widgettitle title-light">{!! __('label.contact_information') !!}</h4>
<label for="user">{!! __('label.email') !!}</label> <x-global::forms.text-input
name="user" id="user" value="{{ $values['user'] }}" /><br />
<label for="phone">{!! __('label.phone') !!}</label> <x-global::forms.text-input
name="phone" id="phone"
value="{{ $values['phone'] }}" /><br /><br />
<h4 class="widgettitle title-light">{!! __('label.employee_information') !!}</h4>
<label for="jobTitle">{!! __('label.jobTitle') !!}</label> <x-global::forms.text-input
name="jobTitle" id="jobTitle" value="{{ $values['jobTitle'] }}" /><br />
<label for="jobLevel">{!! __('label.jobLevel') !!}</label> <x-global::forms.text-input
name="jobLevel" id="jobLevel" value="{{ $values['jobLevel'] }}" /><br />
<label for="department">{!! __('label.department') !!}</label> <x-global::forms.text-input
name="department" id="department" value="{{ $values['department'] }}" /><br />
@if ($login::userIsAtLeast(\Leantime\Domain\Auth\Models\Roles::$admin))
{{-- Capacity attributes admin-only. Weekly hours is what
downstream resource-planning surfaces (Resource
Allocation, cross-program warnings, portfolio
rollups) use to answer "how much of this person
is available to plan against?". Employment type
sets the semantic (target vs ceiling vs cap vs
best-effort). Both nullable until an admin
sets them, capacity math treats this user as
unconfigured and skips warnings. --}}
<h4 class="widgettitle title-light">{!! __('label.capacity') !!}</h4>
<label for="weekly_hours">{!! __('label.weekly_hours') !!}</label>
<x-global::forms.text-input
name="weekly_hours"
id="weekly_hours"
type="number"
min="0"
max="168"
:value="$values['weekly_hours'] ?? ''"
placeholder="{{ __('label.weekly_hours_placeholder') }}" /><br />
<span class="hint">{!! __('label.weekly_hours_hint') !!}</span><br /><br />
<label for="employment_type">{!! __('label.employment_type') !!}</label>
<select name="employment_type" id="employment_type">
<option value="">{!! __('label.employment_type.unset') !!}</option>
@foreach (\Leantime\Domain\Users\Enums\EmploymentType::cases() as $type)
<option value="{{ $type->value }}"
@if (($values['employment_type'] ?? '') === $type->value) selected="selected" @endif>
{!! __($type->langKey()) !!}
</option>
@endforeach
</select><br />
<span class="hint">{!! __('label.employment_type_hint') !!}</span><br /><br />
@endif
<p class="stdformbutton">
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.save')" name="save" id="save" />
</p>
</div>
</div>
<div class="col-md-5">
<div class="maincontentinner">
<h4 class="widgettitle title-light">{!! __('label.project_assignment') !!}</h4>
<div class="scrollableItemList">
@php
$currentClient = '';
$i = 0;
@endphp
@foreach ($allProjects as $row)
@php
if ($row['clientName'] == null) {
$row['clientName'] = 'Not assigned to client';
}
@endphp
@if ($currentClient != $row['clientName'])
@if ($i > 0)
</div>
@endif
<h3 id='accordion_link_{{ $i }}'>
<a href='#' onclick='accordionToggle({{ $i }});' id='accordion_toggle_{{ $i }}'><i class='fa fa-angle-down'></i> {{ $row['clientName'] }}</a>
</h3>
<div id='accordion_{{ $i }}' class='simpleAccordionContainer'>
@php $currentClient = $row['clientName']; @endphp
@endif
<div class="item" style="padding:10px 0px;">
<input type="checkbox" name="projects[]" id='project_{{ $row['id'] }}' value="{{ $row['id'] }}"
@if (is_array($projects) === true && in_array($row['id'], $projects) === true)
checked='checked'
@endif
/>
<span class="projectAvatar" style="width:30px; float:left; margin-right:10px;">
<img src='{{ BASE_URL }}/api/projects?projectAvatar={{ $row['id'] }}&v={{ format($row['modified'])->timestamp() }}' />
</span>
<label for="project_{{ $row['id'] }}" style="margin-top:-11px">
<small>{{ $row['type'] }}</small><br />
{{ $row['name'] }}</label>
<div class="clearall"></div>
</div>
@php $i++; @endphp
@endforeach
</div>
</div>
</div>
</div>
</div>
</form>
@once
@push('scripts')
<script>
jQuery(".noClickProp.dropdown-menu").on("click", function(e) {
e.stopPropagation();
});
function accordionToggle(id) {
let currentLink = jQuery("#accordion_toggle_"+id).find("i.fa");
if(currentLink.hasClass("fa-angle-right")){
currentLink.removeClass("fa-angle-right");
currentLink.addClass("fa-angle-down");
jQuery('#accordion_'+id).slideDown("fast");
}else{
currentLink.removeClass("fa-angle-down");
currentLink.addClass("fa-angle-right");
jQuery('#accordion_'+id).slideUp("fast");
}
}
</script>
@endpush
@endonce
@endsection

View File

@@ -0,0 +1,35 @@
@extends($layout)
@section('content')
<div class="showDialogOnLoad" style="display:none;">
<h4 class="widgettitle title-light"><i class="fa fa-arrow-circle-o-right"></i>
{!! __('headlines.import_ldap_users') !!}
</h4>
{!! $tpl->displayNotification() !!}
@if ($confirmUsers)
<form class="importModal userImportModal" method="post" action="{{ BASE_URL }}/users/import">
@foreach ($allLdapUsers as $user)
<input type="checkbox" value="{{ $user['user'] }}" id="{{ $user['user'] }}" name="users[]" checked="checked"/>
<label for="{{ $user['user'] }}" style="display:inline;">{{ $user['user'] }} - {{ $user['firstname'] }}, {{ $user['lastname'] }}<br />
@endforeach
<br />
<input type="hidden" name="importSubmit" value="1"/>
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.import')" />
</form>
@else
<form class="importModal userImportModal" method="post" action="{{ BASE_URL }}/users/import">
<label>{!! __('label.please_enter_password') !!} </label>
<x-global::forms.text-input type="password" name="password" />
<input type="hidden" name="pwSubmit" value="1"/>
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.find_users')" />
</form>
@endif
</div>
@endsection

View File

@@ -0,0 +1,219 @@
@extends($layout)
@section('content')
@php
$projects = $relations;
@endphp
<h4 class="widgettitle title-light"><i class="fa fa-people-group"></i> {!! __('headlines.new_user') !!}</h4>
{!! $tpl->displayNotification() !!}
<form action="{{ BASE_URL }}/users/newUser" method="post" class="stdform userEditModal formModal">
<div class="row" style="width:800px;">
<div class="col-md-7">
<h4 class="widgettitle title-light">{!! __('label.profile_information') !!}</h4>
<label for="firstname">{!! __('label.firstname') !!}</label> <x-global::forms.text-input
name="firstname" id="firstname"
value="{{ $values['firstname'] }}" /><br />
<label for="lastname">{!! __('label.lastname') !!}</label> <x-global::forms.text-input
name="lastname" id="lastname"
value="{{ $values['lastname'] }}" /><br />
<label for="role">{!! __('label.role') !!}</label>
<select name="role" id="role">
@foreach ($roles as $key => $role)
@if ($login::userHasRole(\Leantime\Domain\Auth\Models\Roles::$manager) && $key > 30)
@continue
@endif
<option value="{{ $key }}"
@if ($key == $values['role']) selected="selected" @endif>
{!! __('label.roles.' . $role) !!}
</option>
@endforeach
</select> <br />
<label for="client">{!! __('label.client') !!}</label>
<select name='client' id="client">
@if ($login::userIsAtLeast('admin'))
<option value="0" selected="selected">{!! __('label.no_clients') !!}</option>
@endif
@foreach ($clients as $clientItem)
@if ($login::userHasRole(\Leantime\Domain\Auth\Models\Roles::$manager) && $clientItem['id'] !== session('userdata.clientId'))
@continue
@endif
<option value="{{ $clientItem['id'] }}"
@if ($clientItem['id'] == $values['clientId'] || $preSelectedClient == $clientItem['id']) selected="selected" @endif>{{ $clientItem['name'] }}</option>
@endforeach
</select><br/>
<br/>
<h4 class="widgettitle title-light">{!! __('label.contact_information') !!}</h4>
<label for="user">{!! __('label.email') !!}</label> <x-global::forms.text-input
name="user" id="user" value="{{ $values['user'] }}" /><br />
<label for="phone">{!! __('label.phone') !!}</label> <x-global::forms.text-input
name="phone" id="phone"
value="{{ $values['phone'] }}" /><br />
<br/>
<h4 class="widgettitle title-light">{!! __('label.employee_information') !!}</h4>
<label for="jobTitle">{!! __('label.jobTitle') !!}</label> <x-global::forms.text-input
name="jobTitle" id="jobTitle" value="{{ $values['jobTitle'] }}" /><br />
<label for="jobLevel">{!! __('label.jobLevel') !!}</label> <x-global::forms.text-input
name="jobLevel" id="jobLevel" value="{{ $values['jobLevel'] }}" /><br />
<label for="department">{!! __('label.department') !!}</label> <x-global::forms.text-input
name="department" id="department" value="{{ $values['department'] }}" /><br />
<p class="stdformbutton">
<input type="hidden" name="save" value="1" />
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.invite_user')" name="save" id="save" />
</p>
</div>
<div class="col-md-5">
<h4 class="widgettitle title-light">{!! __('label.project_assignment') !!}</h4>
<div class="scrollableItemList">
@php
$currentClient = '';
$i = 0;
@endphp
@foreach ($allProjects as $row)
@if ($login::userHasRole(\Leantime\Domain\Auth\Models\Roles::$manager) && $row['clientId'] !== session('userdata.clientId'))
@continue
@endif
@php
if ($row['clientName'] == '') {
$row['clientName'] = 'Not assigned to client';
}
@endphp
@if ($currentClient != $row['clientName'])
@if ($i > 0)
</div>
@endif
<h3 id='accordion_link_{{ $i }}'>
<a href='#' onclick='accordionToggle({{ $i }});' id='accordion_toggle_{{ $i }}'><i class='fa fa-angle-down'></i> {{ $row['clientName'] }}</a>
</h3>
<div id='accordion_{{ $i }}' class='simpleAccordionContainer'>
@php $currentClient = $row['clientName']; @endphp
@endif
<div class="item" style="padding:10px 0px;">
<input type="checkbox" name="projects[]" id='project_{{ $row['id'] }}' value="{{ $row['id'] }}"
@if (is_array($projects) === true && in_array($row['id'], $projects) === true)
checked='checked'
@endif
/>
<span class="projectAvatar" style="width:30px; float:left; margin-right:10px;">
<img src='{{ BASE_URL }}/api/projects?projectAvatar={{ $row['id'] }}&v={{ format($row['modified'])->timestamp() }}' />
</span>
<label for="project_{{ $row['id'] }}" style="margin-top:-11px">
<small>{{ $row['type'] }}</small><br />
{{ $row['name'] }}</label>
<div class="clearall"></div>
</div>
@php $i++; @endphp
@endforeach
</div>
</div>
</div>
</form>
@once
@push('scripts')
<script type="text/javascript">
jQuery(document).ready(function(){
// Dual Box Select
var db = jQuery('#dualselect').find('.ds_arrow button'); //get arrows of dual select
var sel1 = jQuery('#dualselect select#selectOrigin'); //get first select element
var sel2 = jQuery('#dualselect select#selectDest'); //get second select element
var projects = jQuery('#projects');
//sel2.empty(); //empty it first from dom.
db.click(function(){
var t = (jQuery(this).hasClass('ds_prev'))? 0 : 1; // 0 if arrow prev otherwise arrow next
if(t) {
sel1.find('option').each(function(){
if(jQuery(this).is(':selected')) {
jQuery(this).attr('selected',false);
sel2.append(jQuery(this).clone());
jQuery('#projects').append(jQuery(this));
jQuery('#projects option').attr("selected", "selected");
}
});
} else {
sel2.find('option').each(function(){
if(jQuery(this).is(':selected')) {
jQuery(this).attr('selected',false);
index = jQuery(this).index();
sel1.append(jQuery(this));
jQuery('#projects option:eq('+index+')').remove();
jQuery('#projects option').attr("selected", "selected");
}
});
}
return false;
});
});
</script>
<script>
function accordionToggle(id) {
let currentLink = jQuery("#accordion_toggle_"+id).find("i.fa");
if(currentLink.hasClass("fa-angle-right")){
currentLink.removeClass("fa-angle-right");
currentLink.addClass("fa-angle-down");
jQuery('#accordion_'+id).slideDown("fast");
}else{
currentLink.removeClass("fa-angle-down");
currentLink.addClass("fa-angle-right");
jQuery('#accordion_'+id).slideUp("fast");
}
}
</script>
@endpush
@endonce
@endsection

View File

@@ -0,0 +1,94 @@
@extends($layout)
@section('content')
<div class="pageheader">
<div class="pageicon"><span class="fa {{ $tpl->getModulePicture() }}"></span></div>
<div class="pagetitle">
<h5>{!! __('label.administration') !!}</h5>
<h1>{!! __('headlines.users') !!}</h1>
</div>
</div><!--pageheader-->
<div class="maincontent">
<div class="maincontentinner">
{!! $tpl->displayNotification() !!}
<div class="row">
<div class="col-md-6">
@can('users.create')
<x-global::forms.button tag="a" link="{{ BASE_URL }}/users/newUser" contentRole="primary" class="userEditModal"><i class='fa fa-plus'></i> {!! __('buttons.add_user') !!} </x-global::forms.button>
@endcan
</div>
<div class="col-md-6 align-right">
</div>
</div>
<table class="table table-bordered" id="allUsersTable">
<colgroup>
<col class="con1">
<col class="con0">
<col class="con1">
<col class="con0">
<col class="con1">
<col class="con0">
<col class="con1">
</colgroup>
<thead>
<tr>
<th class='head1'>{!! __('label.name') !!}</th>
<th class='head0'>{!! __('label.email') !!}</th>
<th class='head1'>{!! __('label.client') !!}</th>
<th class='head1'>{!! __('label.role') !!}</th>
<th class='head1'>{!! __('label.status') !!}</th>
<th class='head1'>{!! __('headlines.twoFA') !!}</th>
<th class='head0 no-sort'></th>
</tr>
</thead>
<tbody>
@foreach ($allUsers as $row)
<tr>
<td style="padding:6px 10px;">
<a href="{{ BASE_URL }}/users/editUser/{{ $row['id'] }}">{!! sprintf(__('text.full_name'), e($row['firstname']), e($row['lastname'])) !!}</a>
</td>
<td><a href="{{ BASE_URL }}/users/editUser/{{ $row['id'] }}">{{ $row['username'] }}</a></td>
<td>{{ $row['clientName'] }}</td>
<td>{!! __('label.roles.' . $roles[$row['role']]) !!}</td>
<td>@if (strtolower($row['status']) == 'a')
{!! __('label.active') !!}
@elseif (strtolower($row['status']) == 'i')
{!! __('label.invited') !!}
@else
{!! __('label.deactivated') !!}
@endif</td>
<td>@if ($row['twoFAEnabled'])
{!! __('label.yes') !!}
@else
{!! __('label.no') !!}
@endif</td>
<td>@can('users.delete')<a href="{{ BASE_URL }}/users/delUser/{{ $row['id'] }}" class="delete"><i class="fa fa-trash"></i> {!! __('links.delete') !!}</a>@endcan</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@once
@push('scripts')
<script type="text/javascript">
jQuery(document).ready(function() {
leantime.usersController.initUserTable();
leantime.usersController._initModals();
leantime.usersController.initUserEditModal();
}
);
</script>
@endpush
@endonce
@endsection

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Support\Facades\Route;
use Leantime\Domain\Users\Controllers\ProfileImage;
/*
|--------------------------------------------------------------------------
| Users Domain Routes
|--------------------------------------------------------------------------
|
| Profile images were relocated here from the retired Api\Controllers\Users.
| The canonical route is /users/profileImage/{id}. The /api/users alias is kept
| because ~70 <img src> references across core templates AND the plugin submodule
| (Copilot, StrategyPro, PgmPro, Whiteboardscanvas, Llamadorian) hardcode
| /api/users?profileImage= and cannot be rewritten from this repo. Both paths are
| served directly (no redirect) to avoid an avatar redirect-storm on list views.
|
*/
// Canonical
Route::get('/users/profileImage/{id?}', [ProfileImage::class, 'show'])->name('users.profileImage');
Route::post('/users/profileImage', [ProfileImage::class, 'upload']);
// Backward-compat alias for the retired /api/users endpoint
Route::get('/api/users', [ProfileImage::class, 'show'])->name('users.profileImage.legacy');
Route::post('/api/users', [ProfileImage::class, 'upload']);