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,47 @@
<?php
namespace Leantime\Domain\Auth\Controllers;
use Leantime\Core\Controller\Controller;
use Leantime\Domain\Auth\Services\Auth as AuthService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* Keeping the session alive when not active
*
* @Deprecated With laravels new session management we should not need this anymore
*/
class KeepAlive extends Controller
{
private AuthService $authService;
/**
* init - initialize private variables
*/
public function init(AuthService $authService): void
{
$this->authService = $authService;
}
/**
* get - handle get requests
*/
public function get(array $params): Response
{
$userId = session('userdata.id');
$sessionId = session()->getId();
// @TODO: Once we have a session table, check the session is valid in there as well as
// added security layer. If not we can log the user out.
$return = $this->authService->updateUserSessionDB($userId, $sessionId);
$response = ['status' => 'ok'];
if (! $return) {
$response['status'] = 'logout';
}
return new JsonResponse($response);
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace Leantime\Domain\Auth\Controllers;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Configuration\Environment;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller as FrontcontrollerCore;
use Leantime\Domain\Auth\Services\Auth as AuthService;
use Symfony\Component\HttpFoundation\Response;
class Login extends Controller
{
private AuthService $authService;
private Environment $config;
/**
* init - initialize private variables
*/
public function init(
AuthService $authService,
Environment $config
): void {
$this->authService = $authService;
$this->config = $config;
}
/**
* get - handle get requests
*
*
*
*
* @throws BindingResolutionException
*/
public function get(array $params): Response
{
self::dispatchEvent('beforeAuth', $params);
$return = self::dispatchFilter('beforeAuthHandling', $params);
if ($return instanceof Response) {
return $return;
}
// Guard the type: redirect[]=x arrives as an array, which would TypeError against
// resolveSafeRedirect(?string) and 500 the login page on malformed input.
$rawRedirect = $_GET['redirect'] ?? null;
$redirectUrl = $this->authService->resolveSafeRedirect(is_string($rawRedirect) ? $rawRedirect : null);
$this->tpl->assign('inputPlaceholder', $this->authService->getLoginInputPlaceholder());
$this->tpl->assign('redirectUrl', urlencode($redirectUrl));
$this->tpl->assign('oidcEnabled', $this->config->oidcEnable);
$this->tpl->assign('noLoginForm', $this->authService->shouldHideLoginForm());
return $this->tpl->display('auth.login', 'entry');
}
/**
* post - handle post requests
*
*
*
*
* @throws BindingResolutionException
*/
public function post(array $params): Response
{
if (isset($_POST['username']) === true && isset($_POST['password']) === true) {
// Same array guard as the GET path above — redirectUrl[]=x must not 500 the login POST.
$rawRedirect = $_POST['redirectUrl'] ?? null;
$redirectUrl = $this->authService->resolveSafeRedirect(is_string($rawRedirect) ? $rawRedirect : null);
$username = trim($_POST['username']);
$password = $_POST['password'];
try {
// Allow login interruptions through events
self::dispatch_event('beforeAuthServiceCall', ['post' => $_POST]);
} catch (\Exception $e) {
$this->tpl->setNotification($e->getMessage(), 'error');
return FrontcontrollerCore::redirect(BASE_URL.'/auth/login');
}
// If login successful redirect to the correct url to avoid post on reload
if ($this->authService->login($username, $password) === true) {
self::dispatch_event('successfulLogin', ['post' => $_POST]);
if ($this->authService->use2FA()) {
return FrontcontrollerCore::redirect(BASE_URL.'/auth/twoFA');
}
return FrontcontrollerCore::redirect($redirectUrl);
} else {
$this->tpl->setNotification('notifications.username_or_password_incorrect', 'error');
return FrontcontrollerCore::redirect(BASE_URL.'/auth/login');
}
} else {
$this->tpl->setNotification('notifications.username_or_password_missing', 'error');
return FrontcontrollerCore::redirect(BASE_URL.'/auth/login');
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Leantime\Domain\Auth\Controllers;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller as FrontcontrollerCore;
use Leantime\Domain\Auth\Services\Auth as AuthService;
use Symfony\Component\HttpFoundation\Response;
class Logout extends Controller
{
private AuthService $authService;
/**
* init - initialize private variables
*/
public function init(AuthService $authService): void
{
$this->authService = $authService;
}
/**
* get - handle get requests
*/
public function get(array $params): Response
{
$this->authService->logout();
return FrontcontrollerCore::redirect(BASE_URL.'/');
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Leantime\Domain\Auth\Controllers;
use Laravel\Socialite\Facades\Socialite;
use Leantime\Core\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
/**
* Redirects to the OAuth provider for authentication.
*/
class Redirect extends Controller
{
/**
* Redirects to the GitHub OAuth login page.
*
* @param array $params Request parameters
*/
public function get(array $params): Response
{
return Socialite::driver('github')->setScopes(['user:email'])->redirect();
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace Leantime\Domain\Auth\Controllers;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller as FrontcontrollerCore;
use Leantime\Domain\Auth\Services\Auth as AuthService;
use Symfony\Component\HttpFoundation\Response;
class ResetPw extends Controller
{
private AuthService $authService;
/**
* init - initialize private variables
*/
public function init(
AuthService $authService
): void {
$this->authService = $authService;
}
/**
* get - handle get requests
*
*
*
*
* @throws \Exception
*/
public function get(array $params): Response
{
if ((isset($params['id']) === true && $this->authService->validateResetLink($params['id']))) {
return $this->tpl->display('auth.resetPw', 'entry');
} else {
return $this->tpl->display('auth.requestPwLink', 'entry');
}
}
/**
* post - handle post requests
*
*
*
*
* @throws BindingResolutionException
*/
public function post(array $params): Response
{
if (! isset($_POST['resetPassword'])) {
return FrontcontrollerCore::redirect(BASE_URL.'/auth/resetPw/');
}
if (isset($_POST['username']) === true) {
// Always return success to prevent db attacks checking which email address are in there
$this->authService->generateLinkAndSendEmail($_POST['username']);
$this->tpl->setNotification($this->language->__('notifications.email_was_sent_to_reset'), 'success');
return FrontcontrollerCore::redirect(BASE_URL.'/auth/resetPw/');
}
if (isset($_POST['password']) === true && isset($_POST['password2']) === true) {
$result = $this->authService->resetPassword($_POST['password'], $_POST['password2'], $params['id']);
if ($result === 'success') {
$this->tpl->setNotification(
$this->language->__('notifications.passwords_changed_successfully'),
'success',
'password_changed'
);
return FrontcontrollerCore::redirect(BASE_URL.'/auth/login');
}
if ($result === 'mismatch') {
$this->tpl->setNotification($this->language->__('notification.passwords_dont_match'), 'error');
return FrontcontrollerCore::redirect(BASE_URL.'/auth/resetPw/'.$params['id']);
}
if ($result === 'weak') {
$this->tpl->setNotification(
$this->language->__('notification.password_not_strong_enough'),
'error'
);
return FrontcontrollerCore::redirect(BASE_URL.'/auth/resetPw/'.$params['id']);
}
$this->tpl->setNotification(
$this->language->__('notifications.problem_resetting_password'),
'error'
);
return FrontcontrollerCore::redirect(BASE_URL.'/auth/resetPw/'.$params['id']);
}
$this->tpl->setNotification(
$this->language->__('notifications.problem_resetting_password'),
'error'
);
return FrontcontrollerCore::redirect(BASE_URL.'/auth/resetPw/'.$params['id']);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Leantime\Domain\Auth\Controllers;
use Leantime\Core\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
/**
* Renders the "create personal access token" modal.
*/
class TokenNew extends Controller
{
/**
* Displays the new token form (loaded into a modal via #/auth/tokenNew).
*
* @param array $params Request parameters
*/
public function get(array $params): Response
{
return $this->tpl->displayPartial('auth.tokenNew');
}
}

View File

@@ -0,0 +1,174 @@
<?php
namespace Leantime\Domain\Auth\Controllers;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller as FrontcontrollerCore;
use Leantime\Core\UI\Theme;
use Leantime\Domain\Auth\Services\Auth as AuthService;
use Leantime\Domain\Auth\Services\Onboarding as OnboardingService;
use Symfony\Component\HttpFoundation\Response;
class UserInvite extends Controller
{
private AuthService $authService;
private OnboardingService $onboardingService;
private Theme $themeCore;
/**
* init - initializes the objects for the class
*
*
* @param AuthService $authService The AuthService object
* @param OnboardingService $onboardingService The Onboarding service object
* @param Theme $theme The Theme object
*
* @throws \Exception
*/
public function init(
AuthService $authService,
OnboardingService $onboardingService,
Theme $theme
): void {
$this->authService = $authService;
$this->onboardingService = $onboardingService;
$this->themeCore = $theme;
}
/**
* get - handle get requests
*
*
*
*
* @throws \Exception
*/
public function get(array $params): Response
{
if (isset($params['id']) === true) {
$inviteId = htmlspecialchars($params['id']);
$user = $this->authService->getUserByInviteLink($params['id']);
if (! $user) {
return FrontcontrollerCore::redirect(BASE_URL.'/auth/login');
}
$inviteSettings = $this->onboardingService->getInviteSettings($user);
array_map([$this->tpl, 'assign'], array_keys($inviteSettings), array_values($inviteSettings));
$this->tpl->assign('user', $user);
$this->tpl->assign('themeCore', $this->themeCore);
$this->tpl->assign('inviteId', $inviteId);
if (isset($_GET['step']) && is_numeric($_GET['step'])) {
return $this->tpl->display('auth.userInvite'.$_GET['step'], 'entry');
}
return $this->tpl->display('auth.userInvite', 'entry');
}
return FrontcontrollerCore::redirect(BASE_URL.'/errors/error404');
}
/**
* post - handle post requests
*
*
*
*
* @throws BindingResolutionException
*/
public function post(array $params): Response
{
$invitationId = $params['id'] ?? '';
$userInvite = $this->authService->getUserByInviteLink($invitationId);
if (! $userInvite) {
return FrontcontrollerCore::redirect(BASE_URL.'/auth/login');
}
// Step 1
if (isset($_POST['saveAccount']) && isset($_POST['step'])) {
$result = $this->onboardingService->saveAccount(
$userInvite,
$_POST['name'] ?? '',
$_POST['jobTitle'] ?? '',
$_POST['password'] ?? ''
);
if ($result === 'weak') {
$this->tpl->setNotification(
$this->language->__('notification.password_not_strong_enough'),
'error'
);
return FrontcontrollerCore::redirect(BASE_URL.'/auth/userInvite/'.$invitationId);
}
if ($result === 'saved') {
return FrontcontrollerCore::redirect(BASE_URL.'/auth/userInvite/'.$invitationId.'?step=2');
} else {
$this->tpl->setNotification(
$this->language->__('notifications.problem_updating_user'),
'error'
);
}
}
if (isset($_POST['step']) && $_POST['step'] == 2) {
$this->onboardingService->saveThemeChoice($userInvite, $_POST['theme'], $_POST['themeFont']);
return FrontcontrollerCore::redirect(BASE_URL.'/auth/userInvite/'.$invitationId.'?step=3');
}
if (isset($_POST['step']) && $_POST['step'] == 3) {
$this->onboardingService->saveColorChoice(
$userInvite,
$_POST['colormode'],
$_POST['colorscheme'] ?? 'themeDefault'
);
return FrontcontrollerCore::redirect(BASE_URL.'/auth/userInvite/'.$invitationId.'?step=4');
}
if (isset($_POST['step']) && $_POST['step'] == 4) {
$this->onboardingService->saveSchedule(
$userInvite,
$_POST['daySchedule-workStart'] ?? '',
$_POST['daySchedule-lunch'] ?? '',
$_POST['daySchedule-workEnd'] ?? ''
);
return FrontcontrollerCore::redirect(BASE_URL.'/auth/userInvite/'.$invitationId.'?step=5');
}
if (isset($_POST['step']) && $_POST['step'] == 5) {
$this->tpl->setNotification(
$this->language->__('notifications.you_are_active'),
'success',
'user_activated'
);
$loggedIn = $this->onboardingService->completeOnboarding($userInvite);
if ($loggedIn) {
return FrontcontrollerCore::redirect(BASE_URL.'/dashboard/home');
} else {
return FrontcontrollerCore::redirect(BASE_URL.'/auth/login');
}
}
return FrontcontrollerCore::redirect(BASE_URL.'/auth/userInvite/'.$invitationId);
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace Leantime\Domain\Auth\Guards;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Contracts\Auth\UserProvider;
use Leantime\Core\Http\ApiRequest;
use Leantime\Core\Http\IncomingRequest;
use Leantime\Domain\Api\Services\Api;
use Leantime\Domain\Auth\Models\AuthenticatableUser;
class ApiGuard implements Guard
{
protected $user;
private string $apiKey = '';
public function __construct(
protected UserProvider $provider,
protected Api $apiService,
protected IncomingRequest $request)
{
if ($this->request instanceof ApiRequest && $this->request->isApiRequest()) {
$this->apiKey = $this->request->getAPIKey();
}
}
public function check()
{
if (empty($this->apiKey)) {
return false;
}
$apiUser = $this->apiService->getAPIKeyUser($this->apiKey);
if (! $apiUser) {
return false;
}
return true;
}
public function guest()
{
return ! $this->check();
}
public function user()
{
if ($this->user !== null) {
return $this->user;
}
if (empty($this->apiKey)) {
return $this->user;
}
$apiUser = $this->apiService->getAPIKeyUser($this->apiKey);
if (! $apiUser) {
$this->user = null;
return $this->user;
}
$this->user = new AuthenticatableUser((array) $apiUser);
return $this->user;
}
public function id()
{
return $this->user()?->getAuthIdentifier();
}
public function validate(array $credentials = [])
{
if (empty($this->apiKey)) {
return false;
}
$apiUser = $this->apiService->getAPIKeyUser($this->apiKey);
if (! $apiUser) {
return false;
}
return true;
}
public function hasUser()
{
return $this->user ? true : false;
}
public function setUser(Authenticatable $user)
{
$this->user = $user;
return $this;
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace Leantime\Domain\Auth\Guards;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Contracts\Auth\UserProvider;
use Leantime\Domain\Auth\Services\Auth as AuthService;
class WebGuard implements Guard
{
protected $provider;
protected $user;
protected AuthService $authService;
public function __construct(UserProvider $provider, AuthService $authService)
{
$this->provider = $provider;
$this->authService = $authService;
}
public function check()
{
return $this->authService->loggedIn();
}
public function guest()
{
return ! $this->check();
}
public function user()
{
if ($this->user !== null) {
return $this->user;
}
if ($this->authService->loggedIn()) {
$this->user = $this->provider->retrieveById($this->authService::getUserId());
}
return $this->user;
}
public function hasUser()
{
return $this->user ? true : false;
}
public function id()
{
return $this->user()?->getAuthIdentifier();
}
public function validate(array $credentials = [])
{
return $this->authService->login(
$credentials['username'],
$credentials['password']
);
}
public function setUser(Authenticatable $user)
{
$this->user = $user;
return $this;
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace Leantime\Domain\Auth\Hxcontrollers;
use Leantime\Core\Controller\HtmxController;
use Leantime\Domain\Auth\Services\AccessToken;
/**
* HxController for Personal Access Token management.
*
* Provides HTMX endpoints for creating, listing, and revoking
* personal access tokens from the user settings page.
*/
class PersonalTokens extends HtmxController
{
protected static string $view = 'auth::partials.tokens';
private AccessToken $tokenService;
/**
* Initialize the controller with dependencies.
*/
public function init(AccessToken $tokenService): void
{
$this->tokenService = $tokenService;
}
/**
* List all tokens for the current user.
*/
public function get(): void
{
$tokens = $this->tokenService->getUserTokens(session('userdata.id'));
$this->tpl->assign('tokens', $tokens);
}
/**
* Create a new personal access token.
*
* @return mixed
*/
public function create()
{
$name = $this->incomingRequest->request->get('name');
if (empty($name)) {
$this->tpl->setNotification(__('notifications.token_name_required'), 'error');
return $this->tpl->emptyResponse(400);
}
$token = $this->tokenService->createToken(
session('userdata.id'),
$name
);
$this->tpl->setNotification(__('notifications.token_created'), 'success');
// Return the token value in a modal for one-time display
$this->tpl->assign('newToken', $token->token);
return $this->tpl->displayPartial('auth.token-created');
}
/**
* Delete a personal access token.
*/
public function delete(): void
{
$id = $this->incomingRequest->get('id');
if (! $this->tokenService->deleteToken((int) $id)) {
$this->tpl->setNotification(__('notifications.token_not_found'), 'error');
return;
}
$this->tpl->setNotification(__('notifications.token_deleted'), 'success');
$this->get();
}
}

View File

@@ -0,0 +1,47 @@
leantime.authController = (function () {
var makeInputReadonly = function (container) {
if (typeof container === undefined) {
container = "body";
}
jQuery(container).find("input").not(".filterBar input").prop("readonly", true);
jQuery(container).find("input").not(".filterBar input").prop("disabled", true);
jQuery(container).find("select").not(".filterBar select, .mainSprintSelector").prop("readonly", true);
jQuery(container).find("select").not(".filterBar select, .mainSprintSelector").prop("disabled", true);
jQuery(container).find("textarea").not(".filterBar textarea").prop("disabled", true);
jQuery(container).find("a.delete").remove();
jQuery(container).find(".quickAddLink").hide();
// Make Tiptap editors readonly
if (jQuery(container).find(".tiptap-editor").length && window.leantime && window.leantime.tiptapController) {
jQuery(container).find(".tiptap-editor").each(function () {
var editor = leantime.tiptapController.registry.get(this);
if (editor) {
editor.setEditable(false);
}
});
}
// Hide Tiptap toolbar
jQuery(container).find(".tiptap-toolbar").hide();
jQuery(container).find(".ticketDropdown a").removeAttr("data-toggle");
jQuery("#mainToggler").hide();
jQuery(".commentBox").hide();
jQuery(".deleteComment, .replyButton").hide();
jQuery(container).find(".dropdown i").removeClass('fa-caret-down');
};
// Make public what you want to have public, everything else is private
return {
makeInputReadonly:makeInputReadonly,
};
})();

View File

@@ -0,0 +1,24 @@
<?php
namespace Leantime\Domain\Auth\Listeners;
/**
* Renders the Personal Access Tokens tab content in the user account settings page.
*
* Loads via HTMX: the tab panel contains an hx-get that fetches the token list
* from the Auth HxController on first reveal.
*/
class ShowPersonalTokenContent
{
/**
* Render the tab content panel with HTMX lazy-loading.
*/
public function handle(mixed $payload): void
{
echo '<div id="personalTokens"
hx-get="'.BASE_URL.'/hx/auth/personalTokens"
hx-trigger="load"
hx-swap="innerHTML">
</div>';
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace Leantime\Domain\Auth\Listeners;
/**
* Injects the Personal Access Tokens tab into the user account settings page.
*/
class ShowPersonalTokenTab
{
/**
* Render the tab navigation item.
*/
public function handle(mixed $payload): void
{
echo '<li><a href="#personalTokens"><i class="fa-solid fa-key"></i> '.__('tabs.personal_access_tokens').'</a></li>';
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Leantime\Domain\Auth\Models;
use Illuminate\Contracts\Auth\Authenticatable;
/**
* Lightweight Authenticatable wrapper around a user-data row.
*
* Replaces the `(object) $userRow` stdClass casts in AuthUser/ApiGuard so the provider/guard
* methods satisfy their `?Authenticatable` contracts. It uses dynamic properties on purpose so it
* stays a behavioural drop-in for the old stdClass cast — same property reads, same json/array
* serialization, truthy even when empty — and merely ADDS the Authenticatable accessor methods.
*/
#[\AllowDynamicProperties]
class AuthenticatableUser implements Authenticatable
{
/**
* @param array<string, mixed> $attributes A user row (column => value).
*/
public function __construct(array $attributes = [])
{
foreach ($attributes as $key => $value) {
$this->{$key} = $value;
}
}
public function getAuthIdentifierName(): string
{
return 'id';
}
public function getAuthIdentifier(): mixed
{
return $this->id ?? null;
}
public function getAuthPasswordName(): string
{
return 'password';
}
public function getAuthPassword(): string
{
return $this->password ?? '';
}
public function getRememberToken(): string
{
return $this->remember_token ?? '';
}
public function setRememberToken($value): void
{
// No-op: Leantime does not persist remember tokens (mirrors Auth::setRememberToken and
// AuthUser::updateRememberToken, which are likewise not implemented).
}
public function getRememberTokenName(): string
{
return 'remember_token';
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Leantime\Domain\Auth\Models;
use Carbon\CarbonImmutable;
class CurrentUser
{
public function __construct(
public int $id,
public string $name,
public string $profileId,
public string $mail,
public int $clientId,
public string $role,
public mixed $settings,
public bool $twoFAEnabled,
public bool $twoFAVerified,
public string $twoFASecret,
public bool $isExternalAuth,
public CarbonImmutable $createdOn,
public CarbonImmutable $modified,
) {}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Leantime\Domain\Auth\Models;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Events\DispatchesEvents;
/**
* @TODO: Role names should be converted into an enum.
*/
class Roles
{
use DispatchesEvents;
public static string $readonly = 'readonly';
public static string $commenter = 'commenter';
public static string $editor = 'editor';
public static string $manager = 'manager';
public static string $admin = 'admin';
public static string $owner = 'owner';
private static array $roleKeys = [
5 => 'readonly', // prev: none
10 => 'commenter', // prev: client
20 => 'editor', // prev: developer
30 => 'manager', // prev: clientmanager
40 => 'admin', // prev: manager
50 => 'owner', // prev: admin
];
/**
* @throws BindingResolutionException
*/
private static function getFilteredRoles(): mixed
{
return self::dispatch_filter('available_roles', self::$roleKeys);
}
/**
* @return false|mixed
*
* @throws BindingResolutionException
*/
public static function getRoleString(mixed $key): mixed
{
return self::getFilteredRoles()[$key] ?? false;
}
/**
* @throws BindingResolutionException
*/
public static function getRoles(): mixed
{
return self::getFilteredRoles();
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace Leantime\Domain\Auth\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Str;
use Leantime\Core\Db\Db;
class AccessTokenRepository
{
private ConnectionInterface $db;
public function __construct(Db $db)
{
$this->db = $db->getConnection();
}
/**
* @param \DateTimeInterface|null $expiresAt Optional absolute expiry. Null
* keeps the historical non-expiring
* behavior; getTokenByUserId() already
* honors expires_at when set.
*/
public function createToken(int $userId, string $name, array $abilities = ['*'], ?\DateTimeInterface $expiresAt = null): array
{
$token = Str::random(40);
$hashedToken = hash('sha256', $token);
$id = $this->db->table('zp_access_tokens')->insertGetId([
'tokenable_type' => 'Leantime\\Domain\\Auth\\Services\\Auth',
'tokenable_id' => $userId,
'name' => $name,
'token' => $hashedToken,
'abilities' => json_encode($abilities),
'expires_at' => $expiresAt,
'created_at' => now(),
]);
return [
'id' => $id,
'token' => $token,
];
}
public function findToken(string $token): ?array
{
$hashedToken = hash('sha256', $token);
$result = $this->db->table('zp_access_tokens')
->where('token', $hashedToken)
->first();
return $result ? (array) $result : null;
}
public function findTokenById(int $tokenId): ?array
{
$result = $this->db->table('zp_access_tokens')
->where('id', $tokenId)
->first();
return $result ? (array) $result : null;
}
public function deleteToken(int $id): bool
{
return $this->db->table('zp_access_tokens')
->where('id', $id)
->delete() > 0;
}
public function updateLastUsedAt(int $id): bool
{
return $this->db->table('zp_access_tokens')
->where('id', $id)
->update(['last_used_at' => now()]) > 0;
}
public function getTokenByUserId(int|string $userId, ?string $name = null): ?array
{
$query = $this->db->table('zp_access_tokens')
->where('tokenable_id', $userId)
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
if ($name !== null) {
$query->where('name', $name);
}
$result = $query->first();
return $result ? (array) $result : null;
}
public function getAllTokensByUserId(int|string $userId, ?string $name = null): ?array
{
$query = $this->db->table('zp_access_tokens')
->where('tokenable_id', $userId);
if ($name !== null) {
$query->where('name', $name);
}
$results = $query->get();
if ($results->isEmpty()) {
return null;
}
return array_map(fn ($item) => (array) $item, $results->toArray());
}
}

View File

@@ -0,0 +1,167 @@
<?php
namespace Leantime\Domain\Auth\Repositories;
use Illuminate\Database\ConnectionInterface;
use Leantime\Core\Db\DatabaseHelper;
use Leantime\Core\Db\Db as DbCore;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
class Auth
{
private ConnectionInterface $db;
/**
* @var string userrole (admin, client, employee)
*/
public string $role = '';
public string $settings = '';
/**
* @var int time for cookie
*/
public int $cookieTime;
public string $error = '';
public string $success = '';
public string|bool $resetInProgress = false;
public object $hasher;
/**
* How often can a user reset a password before it has to be changed
*/
public int $pwResetLimit = 5;
private UserRepository $userRepo;
private DatabaseHelper $dbHelper;
public function __construct(
DbCore $db,
UserRepository $userRepo,
DatabaseHelper $dbHelper
) {
$this->db = $db->getConnection();
$this->userRepo = $userRepo;
$this->dbHelper = $dbHelper;
}
/**
* logout - destroy sessions and cookies
*/
public function invalidateSession(string $sessionId): bool
{
return $this->db->table('zp_user')
->where('session', $sessionId)
->update(['session' => '']) >= 0;
}
/**
* getUserByLogin - Check login data and returns user if correct
*/
public function getUserByLogin(string $username, string $password): array|false
{
$user = $this->userRepo->getUserByEmail($username);
if ($user !== false && password_verify($password, $user['password'])) {
return $user;
}
return false;
}
public function getUserByEmail(string $username): array|false
{
return $this->userRepo->getUserByEmail($username);
}
/**
* updateSession - Update the session time by sessionId
*/
public function updateUserSession(int $userId, string $sessionid, string $time): bool
{
return $this->db->table('zp_user')
->where('id', $userId)
->update([
'lastlogin' => now(),
'session' => $sessionid,
'sessiontime' => $time,
'pwReset' => null,
'pwResetExpiration' => null,
]) >= 0;
}
/**
* validateResetLink - validates that the password reset link belongs to a user account in the database
*/
public function validateResetLink(string $hash): bool
{
return $this->db->table('zp_user')
->where('pwReset', $hash)
->where('status', 'like', 'a')
->where('pwResetExpiration', '>=', now())
->exists();
}
/**
* getUserByInviteLink - gets an invited user by invite code
*/
public function getUserByInviteLink(string $hash): bool|array
{
$result = $this->db->table('zp_user')
->where('pwReset', $hash)
->whereRaw('LOWER(status) = ?', ['i'])
->limit(1)
->first();
return $result ? (array) $result : false;
}
public function setPWResetLink(string $username, string $resetLink): bool
{
return $this->db->table('zp_user')
->where('username', $username)
->update([
'pwReset' => $resetLink,
// Store the EXPIRY moment (not creation): the reset link is valid for 1 hour.
'pwResetExpiration' => now()->addHours(1),
'pwResetCount' => $this->db->raw('COALESCE('.$this->dbHelper->wrapColumn('pwResetCount').', 0) + 1'),
]) >= 0;
}
public function changePW(string $password, string $hash): bool
{
// Never match on an empty reset token: many accounts carry an empty
// pwReset (it's cleared after every successful change), so an empty hash
// would match a pile of users. Resolve to a single user id first, then
// update by primary key. This also avoids the MySQL-only DELETE/UPDATE
// ... LIMIT 1 that breaks on Postgres (#3384) — we can't drop limit(1)
// here because pwReset isn't unique.
if ($hash === '') {
return false;
}
$userId = $this->db->table('zp_user')
->where('pwReset', $hash)
->where('pwResetExpiration', '>=', now())
->value('id');
if (empty($userId)) {
return false;
}
return $this->db->table('zp_user')
->where('id', $userId)
->update([
'password' => password_hash($password, PASSWORD_DEFAULT),
'pwReset' => '',
'pwResetExpiration' => '',
'lastpwd_change' => now(),
'pwResetCount' => 0,
]) >= 0;
}
}

View File

@@ -0,0 +1,184 @@
<?php
namespace Leantime\Domain\Auth\Services;
use DateTimeInterface;
use Illuminate\Validation\UnauthorizedException;
use Laravel\Sanctum\Contracts\HasAbilities;
use Laravel\Sanctum\HasApiTokens;
use Leantime\Domain\Auth\Models\Roles;
use Leantime\Domain\Auth\Repositories\AccessTokenRepository;
class AccessToken implements HasAbilities
{
use HasApiTokens, \Illuminate\Auth\Authenticatable;
public ?int $id = null;
public string $tokenableType;
public int $tokenableId;
public string $name;
public string $token;
public array $abilities;
public ?DateTimeInterface $lastUsedAt;
public ?DateTimeInterface $expires_at;
public ?DateTimeInterface $created_at;
public ?DateTimeInterface $updatedAt;
public AuthUser $tokenable;
protected AccessTokenRepository $tokenRepo;
public function __construct(
array $attributes = [],
) {
foreach ($attributes as $key => $value) {
if (property_exists($this, $key)) {
$this->$key = $value;
}
}
$this->tokenRepo = app()->make(AccessTokenRepository::class);
$this->tokenable = app()->make(AuthUser::class);
$this->abilities = $this->abilities ?? ['*'];
}
public function can($ability): bool
{
return in_array('*', $this->abilities) ||
array_key_exists($ability, array_flip($this->abilities));
}
public function cant($ability): bool
{
return ! $this->can($ability);
}
public function createToken($userId, $name = null)
{
if ($userId == session('userdata.id') || Auth::userIsAtLeast(Roles::$admin)) {
$token = $this->tokenRepo->createToken($userId, $name ?? 'personal-token');
return (object) $token;
} else {
return false;
}
}
public static function findToken($token)
{
$tokenObject = new self;
$tokenData = $tokenObject->tokenRepo->findToken($token);
if (empty($tokenData)) {
return false;
}
$tokenObject->id = $tokenData['id'];
$tokenObject->expires_at = ! empty($tokenData['expires_at']) ? dtHelper()->parseDbDateTime($tokenData['expires_at']) : null;
$tokenObject->created_at = ! empty($tokenData['created_at']) ? dtHelper()->parseDbDateTime($tokenData['created_at']) : null;
$tokenObject->tokenable->setUser($tokenData['tokenable_id']);
return $tokenObject;
}
public function getConnection()
{
return '';
}
public function forceFill()
{
$this->tokenRepo->updateLastUsedAt($this->id);
return $this;
}
public function save()
{
return true;
}
public function getUserTokens(int $userId)
{
if (Auth::userIsAtLeast(Roles::$admin) || $userId == session('userdata.id')) {
return $this->tokenRepo->getAllTokensByUserId($userId) ?? [];
} else {
throw new UnauthorizedException('You are not authorized to access this resource.');
}
}
public function getTokenById($tokenId)
{
return $this->tokenRepo->findTokenById($tokenId);
}
public function deleteToken(int $tokenId)
{
$token = $this->getTokenById($tokenId);
if (Auth::userIsAtLeast(Roles::$admin) || $token['tokenable_id'] == session('userdata.id')) {
return $this->tokenRepo->deleteToken($tokenId);
} else {
throw new UnauthorizedException('You are not authorized to access this resource.');
}
}
/**
* Revoke the bearer token used to authenticate the current request.
* Designed for client-side sign-out flows (mobile, third-party
* integrations) that need a server-side invalidation rather than
* just clearing local credentials.
*
* Uses the request's Authorization header to identify the token —
* caller doesn't need to track its own token id. Defense-in-depth
* check confirms the matched token belongs to the session user
* (the middleware should already guarantee this since the bearer
* is what populated the session, but the explicit check guards
* against any odd state).
*
* @api
*/
public function revokeCurrentToken(): bool
{
$request = app(\Leantime\Core\Http\ApiRequest::class);
$bearer = $request->getBearerToken();
if (! $bearer) {
return false;
}
$token = $this->tokenRepo->findToken($bearer);
if (! $token) {
return false;
}
$sessionUserId = (int) session('userdata.id');
if ($sessionUserId === 0 || (int) $token['tokenable_id'] !== $sessionUserId) {
// Bearer matched a row that isn't this session's user —
// refuse rather than risk deleting someone else's token.
return false;
}
return $this->tokenRepo->deleteToken((int) $token['id']);
}
}

View File

@@ -0,0 +1,772 @@
<?php
namespace Leantime\Domain\Auth\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Session\SessionManager;
use Illuminate\Support\Facades\Log;
use Laravel\Sanctum\HasApiTokens;
use Leantime\Core\Configuration\Environment as EnvironmentCore;
use Leantime\Core\Controller\Frontcontroller as FrontcontrollerCore;
use Leantime\Core\Events\DispatchesEvents;
use Leantime\Core\Language as LanguageCore;
use Leantime\Core\Mailer as MailerCore;
use Leantime\Core\UI\Theme;
use Leantime\Domain\Auth\Models\Roles;
use Leantime\Domain\Auth\Repositories\AccessTokenRepository;
use Leantime\Domain\Auth\Repositories\Auth as AuthRepository;
use Leantime\Domain\Ldap\Services\Ldap;
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
use Ramsey\Uuid\Uuid;
use RobThree\Auth\TwoFactorAuth;
class Auth implements Authenticatable
{
use DispatchesEvents, HasApiTokens, \Illuminate\Auth\Authenticatable;
/**
* @var int|null user id from DB
*/
private ?int $userId = null;
private ?string $password = null;
private ?SessionManager $session = null;
/**
* @var string userrole (admin, client, employee)
*/
public string $role = '';
public array $settings = [];
/**
* @var int time for cookie
*/
public mixed $cookieTime;
public string $error = '';
public string $success = '';
public string|bool $resetInProgress = false;
/**
* How often can a user reset a password before it has to be changed
*/
public int $pwResetLimit = 5;
private EnvironmentCore $config;
public LanguageCore $language;
public SettingRepository $settingsRepo;
public AuthRepository $authRepo;
public UserRepository $userRepo;
private AccessTokenRepository $tokenRepo;
/**
* __construct - getInstance of session and get sessionId and refers to login if post is set
*
* @throws BindingResolutionException
*/
public function __construct(
EnvironmentCore $config,
?SessionManager $session,
LanguageCore $language,
SettingRepository $settingsRepo,
AuthRepository $authRepo,
UserRepository $userRepo,
AccessTokenRepository $tokenRepo
) {
$this->config = $config;
$this->session = $session;
$this->language = $language;
$this->settingsRepo = $settingsRepo;
$this->authRepo = $authRepo;
$this->userRepo = $userRepo;
$this->tokenRepo = $tokenRepo;
$this->cookieTime = $this->config->sessionExpiration;
}
/**
* @return string|bool returns role as string or false on failure
*
* @throws BindingResolutionException
*/
public static function getRoleToCheck(bool $forceGlobalRoleCheck): string|bool
{
if (session()->exists('userdata') === false) {
return false;
}
if ($forceGlobalRoleCheck) {
$roleToCheck = session('userdata.role');
// If projectRole is not defined or if it is set to inherited
} elseif (! session()->exists('userdata.projectRole') || session('userdata.projectRole') == 'inherited' || session('userdata.projectRole') == '') {
$roleToCheck = session('userdata.role');
// Do not overwrite admin or owner roles
} elseif (session('userdata.role') == Roles::$owner || session('userdata.role') == Roles::$admin || session('userdata.role') == Roles::$manager) {
$roleToCheck = session('userdata.role');
// In all other cases check the project role
} else {
$roleToCheck = session('userdata.projectRole');
}
// Ensure the role is a valid role. An unresolvable role here makes the permission engine
// deny EVERYTHING (every #[RequiresPermission] check fails) — so log it loudly with
// context. This exact breadcrumb ("invalid role detected: 50") is what surfaced the 3.9.x
// Bearer regression where a session stored the raw role int instead of its name string.
if (in_array($roleToCheck, Roles::getRoles()) === false) {
Log::warning('Invalid role in session — authorization will deny everything. Resolved role: '.var_export($roleToCheck, true).' (user '.(session('userdata.id') ?? 'guest').'). Expected one of: '.implode(', ', Roles::getRoles()));
return false;
}
return $roleToCheck;
}
/**
* login - Validate POST-data with DB
*
*
*
* @throws BindingResolutionException
*/
public function login(string $username, string $password): bool
{
self::dispatch_event('beforeLoginCheck', ['username' => $username, 'password' => $password]);
// different identity providers can live here
// they all need to
// // A: ensure the user is in leantime (with a valid role) and if not create the user
// // B: set the session variables
// // C: update users from the identity provider,
// Try Ldap
if ($this->config->useLdap === true && extension_loaded('ldap')) {
$ldap = app()->make(Ldap::class);
if ($ldap->connect() && $ldap->bind($username, $password)) {
// Update username to include domain
$usernameWDomain = $ldap->getEmail($username);
// Get user
$user = $this->userRepo->getUserByEmail($usernameWDomain);
$ldapUser = $ldap->getSingleUser($username);
if ($ldapUser === false) {
return false;
}
// If user does not exist create user
if (! $user) {
$userArray = [
'firstname' => $ldapUser['firstname'],
'lastname' => $ldapUser['lastname'],
'phone' => $ldapUser['phone'],
'user' => $ldapUser['user'],
'role' => $ldapUser['role'],
'department' => $ldapUser['department'],
'jobTitle' => $ldapUser['jobTitle'],
'jobLevel' => $ldapUser['jobLevel'],
'password' => '',
'clientId' => '',
'source' => 'ldap',
'status' => 'a',
];
$userId = $this->userRepo->addUser($userArray);
if ($userId !== false) {
$user = $this->userRepo->getUserByEmail($usernameWDomain);
} else {
Log::error('Ldap user creation failed.');
return false;
}
// @TODO: create a better login response. This will return that the username or password was not correct
} else {
$user['firstname'] = $ldapUser['firstname'];
$user['lastname'] = $ldapUser['lastname'];
$user['phone'] = $ldapUser['phone'];
$user['user'] = $user['username'];
$user['department'] = $ldapUser['department'];
$user['jobTitle'] = $ldapUser['jobTitle'];
$user['jobLevel'] = $ldapUser['jobLevel'];
$this->userRepo->editUser($user, $user['id']);
}
if ($user !== false && is_array($user)) {
$this->setUserSession($user, true);
return true;
} else {
Log::info('Could not retrieve user by email');
return false;
}
}
// Don't return false, to allow the standard login provider to check the db for contractors or clients not
// in ldap
} elseif ($this->config->useLdap === true && ! extension_loaded('ldap')) {
Log::error("Can't use ldap. Extension not installed");
}
// TODO: Single Sign On?
// Standard login
// Check if the user is in our db
// Check even if ldap is turned on to allow contractors and clients to have an account
$user = $this->authRepo->getUserByLogin($username, $password);
if ($user !== false && is_array($user)) {
$this->setUserSession($user);
self::dispatch_event('afterLoginCheck', ['username' => $username, 'password' => $password, 'authService' => app()->make(self::class)]);
return true;
} else {
$this->logFailedLogin($username);
self::dispatch_event('afterLoginCheck', ['username' => $username, 'password' => $password, 'authService' => app()->make(self::class)]);
return false;
}
}
/**
* Create a new personal access token
*/
public function createToken(string $name, array $abilities = ['*']): array
{
if (! $this->loggedIn()) {
throw new \Exception('User must be authenticated to create token');
}
return $this->tokenRepo->createToken($this->getUserId(), $name, $abilities);
}
/**
* @return false|void
*
* @throws BindingResolutionException
*/
public function setUserSession(mixed $user, bool $isExternalAuth = false)
{
if (! $user || ! is_array($user)) {
return false;
}
// Web-login session. twoFAVerified: false — the web flow enforces interactive 2FA via the
// AuthCheck gate. Built via the shared factory (role NAME string + consistent fields), with
// the web-only globalUserId added on top.
$currentUser = UserSessionBuilder::build($user, isExternalAuth: $isExternalAuth, twoFAVerified: false);
$currentUser['globalUserId'] = Uuid::uuid5(Uuid::NAMESPACE_DNS, strtolower($user['username']));
$currentUser = self::dispatch_filter('user_session_vars', $currentUser);
session(['userdata' => $currentUser]);
session(['usersettings' => $currentUser['settings']]);
$this->updateUserSessionDB($currentUser['id'], session()->getId());
// Clear user theme cache on login
Theme::clearCache();
}
public function updateUserSessionDB(int $userId, string $sessionID): bool
{
return $this->authRepo->updateUserSession($userId, $sessionID, (string) time());
}
/**
* logged_in - Check if logged in and Update sessions
*/
public function loggedIn(): bool
{
// Check if we actually have a php session available
if (session()->exists('userdata')) {
return true;
// If the session doesn't have any session data we are out of sync. Start again
} else {
return false;
}
}
/**
* Checks if a user is logged in.
*
* @return bool Returns true if the user is logged in, false otherwise.
*/
public static function isLoggedIn(): bool
{
// Check if we actually have a php session available
if (session()->exists('userdata')) {
return true;
} else {
return false;
}
}
/**
* logout - destroy sessions and cookies
*
*
* @throws BindingResolutionException
*/
public function logout(): void
{
$this->authRepo->invalidateSession($this->session->getId());
$sessionsToDestroy = self::dispatch_filter('sessions_vars_to_destroy', [
'userdata',
'template',
'subdomainData',
'currentProject',
'currentSprint',
'projectsettings',
'currentSubscriptions',
'lastTicketView',
'lastFilteredTicketTableView',
]);
foreach ($sessionsToDestroy as $key) {
session()->forget($key);
}
self::dispatch_event('afterSessionDestroy', ['authService' => app()->make(self::class)]);
}
/**
* validateResetLink - validates that the password reset link belongs to a user account in the database
*
* @param string $hash invite link hash
*/
public function validateResetLink(string $hash): bool
{
return $this->authRepo->validateResetLink($hash);
}
/**
* getUserByInviteLink - gets the user by invite link
*
* @param string $hash invite link hash
*/
public function getUserByInviteLink(string $hash): bool|array
{
return $this->authRepo->getUserByInviteLink($hash);
}
/**
* generateLinkAndSendEmail - generates an invitation link (hash) and sends email to user
*
* @param string $username new user to be invited (email)
* @return bool returns true on success, false on failure
*
* @throws BindingResolutionException
*/
public function generateLinkAndSendEmail(string $username): bool
{
$userFromDB = $this->userRepo->getUserByEmail($username);
if ($userFromDB !== false && count($userFromDB) > 0) {
if ($userFromDB['pwResetCount'] < $this->pwResetLimit) {
$permitted_chars = '0123456789abcdefghijklmnopqrstuvwxyz';
$resetLink = substr(str_shuffle($permitted_chars), 0, 32);
$result = $this->authRepo->setPWResetLink($username, $resetLink);
if ($result) {
// Don't queue, send right away
$mailer = app()->make(MailerCore::class);
$mailer->setContext('password_reset');
$mailer->setSubject($this->language->__('email_notifications.password_reset_subject'));
$actual_link = ''.BASE_URL.'/auth/resetPw/'.$resetLink;
$mailer->setHtml(sprintf($this->language->__('email_notifications.password_reset_message'), $actual_link));
$to = [$username];
$mailer->sendMail($to, 'Leantime System');
return true;
}
} elseif ($this->config->debug) {
Log::warning('PW reset failed: maximum request count has been reached for user '.$userFromDB['id']);
}
}
return false;
}
public function changePw(string $password, string $hash): bool
{
return $this->authRepo->changePW($password, $hash);
}
/**
* checkPasswordStrength - validates that a password meets the minimum strength requirements.
*
* Password must be at least 8 characters and include an upper case letter,
* a lower case letter, a number and a special character.
*
* @param string $password the password to validate
* @return bool returns true if the password is strong enough, false otherwise
*/
public function checkPasswordStrength(string $password): bool
{
$uppercase = preg_match('@[A-Z]@', $password);
$lowercase = preg_match('@[a-z]@', $password);
$number = preg_match('@[0-9]@', $password);
$specialChars = preg_match('@[^\w]@', $password);
if (! $uppercase || ! $lowercase || ! $number || ! $specialChars || strlen($password) < 8) {
return false;
}
return true;
}
/**
* resetPassword - validates and applies a password reset for a given reset link.
*
* Performs the password match check, strength check and persists the new
* password. Returns a status string the caller can map to a notification:
* 'success', 'mismatch', 'weak' or 'error'.
*
* @param string $password the new password
* @param string $passwordConfirm the password confirmation
* @param string $hash the password reset link hash
* @return string one of 'success', 'mismatch', 'weak', 'error'
*
* @api
*/
public function resetPassword(string $password, string $passwordConfirm, string $hash): string
{
if (strlen($password) === 0 || $password !== $passwordConfirm) {
return 'mismatch';
}
if (! $this->checkPasswordStrength($password)) {
return 'weak';
}
if ($this->changePw($password, $hash)) {
return 'success';
}
return 'error';
}
/**
* resolveSafeRedirect - resolves a user supplied redirect target into a safe,
* application-internal absolute URL, guarding against open redirects.
*
* @param string|null $redirect the raw redirect target (typically from the request)
* @return string an absolute URL that is safe to redirect to
*
* @api
*/
public function resolveSafeRedirect(?string $redirect): string
{
$redirectUrl = BASE_URL.'/dashboard/home';
if ($redirect !== null && trim($redirect) !== '' && trim($redirect) !== '/') {
// Normalize backslash-based protocol tricks (e.g. \/\/attacker.com)
// to forward slashes before any checks.
$url = str_replace('\\', '/', rawurldecode($redirect));
// Drop control characters and surrounding whitespace before any guard, so a
// padded variant (" //evil.com", "%09//evil.com") can't slip past the checks
// below and can't reach the Location header.
$url = trim(preg_replace('/[\x00-\x1F\x7F]/', '', $url));
// Strip the application base URL when present so that same-origin
// absolute URLs (e.g. https://my-leantime.com/dashboard/home) are
// treated the same as their relative counterparts.
//
// Match only on a real boundary: a bare str_starts_with() would also fire on
// https://hostile.com/pwn when BASE_URL is https://host, rewriting an external
// URL into the bogus internal path /ile.com/pwn instead of rejecting it. The
// same applies to subdirectory installs (BASE_URL /app vs a /application path).
$base = rtrim(BASE_URL, '/');
if ($base !== '' && (
$url === $base
|| str_starts_with($url, $base.'/')
|| str_starts_with($url, $base.'?')
|| str_starts_with($url, $base.'#')
)) {
$url = substr($url, strlen($base));
}
// Guard: protocol-relative URL (//attacker.com) — explicitly reject.
// FILTER_VALIDATE_URL treats these as valid without a scheme, but
// browsers resolve them to the current scheme, making them an open
// redirect vector.
if (str_starts_with($url, '//')) {
return $redirectUrl;
}
// Guard: external absolute URL — reject.
// filter_var returns the URL (truthy) for well-formed absolute URLs
// with a scheme; relative paths return false.
if (filter_var($url, FILTER_VALIDATE_URL) !== false) {
return $redirectUrl;
}
// At this point $url is a relative path. Guard against an empty
// path that could result from stripping a BASE_URL-only input.
$url = ltrim($url, '/');
// Block redirect to logout — allowing a POST-login redirect to
// /auth/logout would create a forced-logout loop. Compare the normalized
// path so the query string, a trailing slash and casing can't be used to
// walk around the block (/auth/logout/, /auth/logout?next=/x, /AUTH/logout).
$path = rtrim(strtolower(strtok($url, '?#')), '/');
if ($url !== '' && $path !== 'auth/logout') {
$redirectUrl = BASE_URL.'/'.$url;
}
}
return $redirectUrl;
}
/**
* shouldHideLoginForm - determines whether the default login form should be hidden,
* combining the admin setting with the configured disableLoginForm flag.
*
* @return bool returns true if the default login form should be hidden
*
* @api
*/
public function shouldHideLoginForm(): bool
{
$hideLogin = $this->settingsRepo->getSetting('auth.hideDefaultLogin');
if (! empty($hideLogin) && $hideLogin == 'on') {
return true;
}
return (bool) $this->config->disableLoginForm;
}
/**
* getLoginInputPlaceholder - returns the translation key for the login input placeholder
* depending on whether LDAP authentication is enabled.
*
* @return string the placeholder translation key
*
* @api
*/
public function getLoginInputPlaceholder(): string
{
if ($this->config->useLdap) {
return 'input.placeholders.enter_email_or_username';
}
return 'input.placeholders.enter_email';
}
/**
* @throws BindingResolutionException
*/
public static function userIsAtLeast(string $role, bool $forceGlobalRoleCheck = false): bool
{
// Force Global Role check to circumvent projectRole checks for global controllers (users, projects, clients etc)
$roleToCheck = self::getRoleToCheck($forceGlobalRoleCheck);
if ($roleToCheck === false) {
return false;
}
$testKey = array_search($role, Roles::getRoles());
if ($role == '' || $testKey === false) {
Log::warning('Check for invalid role detected: '.$role);
return false;
}
$currentUserKey = array_search($roleToCheck, Roles::getRoles());
if ($testKey <= $currentUserKey) {
return true;
} else {
return false;
}
}
/**
* @throws HttpResponseException
*/
public static function authOrRedirect(array|string $role, bool $forceGlobalRoleCheck = false): bool
{
if (self::userHasRole($role, $forceGlobalRoleCheck)) {
return true;
}
throw new HttpResponseException(FrontcontrollerCore::redirect(BASE_URL.'/errors/error403'));
}
/**
* @throws BindingResolutionException
*/
public static function userHasRole(string|array $role, bool $forceGlobalRoleCheck = false): bool
{
// Force Global Role check to circumvent projectRole checks for global controllers (users, projects, clients etc)
$roleToCheck = self::getRoleToCheck($forceGlobalRoleCheck);
if (is_array($role) && in_array($roleToCheck, $role)) {
return true;
} elseif ($role == $roleToCheck) {
return true;
}
return false;
}
public static function getRole(): void {}
public static function getUserClientId(): mixed
{
return session('userdata.clientId');
}
public static function getUserId(): mixed
{
return session('userdata.id');
}
public function use2FA(): mixed
{
return session('userdata.twoFAEnabled');
}
public function verify2FA(string $code): bool
{
$twoFactorAuthentication = new TwoFactorAuth('Leantime');
return $twoFactorAuthentication->verifyCode(session('userdata.twoFASecret'), $code);
}
public function get2FAVerified(): mixed
{
return session('userdata.twoFAVerified');
}
public function set2FAVerified(): void
{
session(['userdata.twoFAVerified' => true]);
}
private function logFailedLogin(string $user): void
{
$user = $user == '' ? 'unknown' : $user;
$date = new \DateTime;
$date = $date->format('y:m:d h:i:s');
$ip = $_SERVER['REMOTE_ADDR'];
$msg = '['.$date.']['.$ip.'] Login failed for user: '.$user;
Log::info($msg);
}
public function getAuthIdentifierName()
{
return 'id';
}
public function getAuthIdentifier()
{
return $this->userId;
}
public function getAuthPassword()
{
return $this->password;
}
public function getAuthPasswordName()
{
return 'password';
}
public function getRememberToken()
{
return ''; // Not implemented yet (Authenticatable::getRememberToken is contractually a string)
}
public function setRememberToken($value)
{
// Not implemented yet
}
public function getRememberTokenName()
{
return 'remember_token';
}
public function getUserById($id)
{
return (object) $this->userRepo->getUser($id);
}
public function validateToken(string $token): bool
{
$user = $this->getUserByToken($token);
if ($user) {
$this->setUserSession($user);
// Turn off 2FA for token verification
$this->set2FAVerified();
return true;
}
return false;
}
public function getUserByToken(string $token): array|bool
{
$tokenModel = $this->tokenRepo->findToken($token);
if (! $tokenModel) {
return false;
}
if ($tokenModel['expires_at'] && strtotime($tokenModel['expires_at']) < time()) {
return false;
}
// Load the user associated with this token
$user = $this->userRepo->getUser($tokenModel['tokenable_id']);
if (! $user) {
return false;
}
$this->tokenRepo->updateLastUsedAt($tokenModel['id']);
return $user;
}
}

View File

@@ -0,0 +1,124 @@
<?php
namespace Leantime\Domain\Auth\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;
use Laravel\Sanctum\HasApiTokens;
use Leantime\Domain\Auth\Models\AuthenticatableUser;
use Leantime\Domain\Auth\Services\Auth as AuthService;
class AuthUser implements UserProvider
{
use HasApiTokens;
protected $authRepo;
protected $userRepo;
protected $userdata;
public function __construct(
protected AuthService $authService)
{
$this->authRepo = $this->authService->authRepo;
$this->userRepo = $this->authService->userRepo;
}
public function retrieveById($identifier)
{
$userData = $this->userRepo->getUser($identifier);
// Not found → null, per the UserProvider contract. Returning a (non-null) empty user
// object would let the guard treat the request as authenticated.
if (empty($userData)) {
return null;
}
return new AuthenticatableUser((array) $userData);
}
public function retrieveByToken($identifier, $token)
{
$userData = $this->authService->getUserByToken($token);
if (empty($userData)) {
return null;
}
return new AuthenticatableUser((array) $userData);
}
public function updateRememberToken(Authenticatable $user, $token)
{
// Not implemented for now
}
public function retrieveByCredentials(array $credentials)
{
if (! isset($credentials['username'])) {
return null;
}
return $this->authRepo->getUserByLogin(
$credentials['username'],
$credentials['password'] ?? null
);
}
public function validateCredentials(Authenticatable $user, array $credentials)
{
return $this->authService->login(
$credentials['username'],
$credentials['password']
);
}
public function rehashPasswordIfRequired(Authenticatable $user, array $credentials, bool $force = false) {}
public function getOrCreateUser($user, $source)
{
// Look up the existing account in a separate variable — the $user param holds the
// external/OAuth profile data we need to create the account from, so it must not be
// overwritten by the lookup result (doing so previously built new users with empty fields).
$existingUser = $this->authRepo->getUserByEmail($user['email']);
if (empty($existingUser) && config()->get('auth.create_user')) {
$userArray = [
'firstname' => $user['firstname'],
'lastname' => $user['lastname'],
'phone' => $user['phone'] ?? '',
'user' => $user['email'] ?? '',
'role' => $user['role'] ?? '30',
'department' => $user['department'] ?? '',
'jobTitle' => $user['jobTitle'] ?? '',
'jobLevel' => $user['jobLevel'] ?? '',
'password' => '',
'clientId' => '',
'source' => $source,
'status' => 'a',
];
$this->userRepo->addUser($userArray);
$existingUser = $this->authRepo->getUserByEmail($user['email']);
}
return $existingUser;
}
public function setUser($userId)
{
$this->userdata = $this->userRepo->getUser($userId);
$this->setUserSession($this->userdata);
}
protected function setUserSession($user)
{
// Sanctum/Bearer-token session. twoFAVerified: true — the token is the strong credential
// and no interactive 2FA is possible. Built via the shared factory so role (NAME string,
// not raw int) and every other field stay identical to the web + x-api-key paths.
session(['userdata' => UserSessionBuilder::build($user, isExternalAuth: false, twoFAVerified: true)]);
}
}

View File

@@ -0,0 +1,368 @@
<?php
namespace Leantime\Domain\Auth\Services;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Events\DispatchesEvents;
use Leantime\Core\Language as LanguageCore;
use Leantime\Core\UI\Theme;
use Leantime\Domain\Auth\Services\Auth as AuthService;
use Leantime\Domain\Setting\Services\Setting as SettingService;
use Leantime\Domain\Users\Services\Users as UserService;
/**
* Onboarding service - encapsulates the multi-step user invite / onboarding
* flow that was previously orchestrated inside the UserInvite controller.
*/
class Onboarding
{
use DispatchesEvents;
/**
* The event context the onboarding controller used to dispatch events under.
*
* Onboarding events are dispatched from this service, but the original
* (controller-based) event names must be preserved so registered listeners
* (including plugins) keep matching. Passing this fully-qualified context to
* the dispatch helpers keeps the emitted event names byte-identical.
*/
private const EVENT_CONTEXT = 'leantime.domain.auth.controllers.userinvite.post';
/**
* init - initializes the service dependencies.
*/
public function __construct(
private AuthService $authService,
private UserService $userService,
private SettingService $settingService,
private Theme $themeCore,
private LanguageCore $language
) {}
/**
* getInviteSettings - builds the defaulted settings payload used to render the
* onboarding screens for a given invited user.
*
* @param array $user the invited user record
* @return array the resolved settings (theme, colorMode, colorScheme, themeFont,
* date/time formats, timezone, workdays and daySchedule plus the
* available option catalogs)
*
* @api
*/
public function getInviteSettings(array $user): array
{
$userId = $user['id'];
$userTheme = $this->settingService->getSetting('usersettings.'.$userId.'.theme');
if (! $userTheme) {
$userTheme = 'default';
}
$userColorMode = $this->settingService->getSetting('usersettings.'.$userId.'.colorMode');
if (! $userColorMode) {
$userColorMode = 'light';
}
$userColorScheme = $this->settingService->getSetting('usersettings.'.$userId.'.colorScheme');
if (! $userColorScheme) {
$userColorScheme = 'companyColors';
}
$themeFont = $this->settingService->getSetting('usersettings.'.$userId.'.themeFont');
if (! $themeFont) {
$themeFont = 'Roboto';
}
$userDateFormat = $this->settingService->getSetting('usersettings.'.$userId.'.date_format');
$userTimeFormat = $this->settingService->getSetting('usersettings.'.$userId.'.time_format');
$timezone = $this->settingService->getSetting('usersettings.'.$userId.'.timezone');
if (! $timezone) {
$timezone = date_default_timezone_get();
}
$workdays = $this->settingService->getSetting('usersettings.'.$userId.'.workdays');
if (! $workdays) {
$workdays = $this->getDefaultWorkdays();
} else {
$workdays = safe_unserialize($workdays, []);
}
$daySchedule = $this->settingService->getSetting('usersettings.'.$userId.'.daySchedule');
if ($daySchedule) {
$daySchedule = safe_unserialize($daySchedule, []);
} else {
$daySchedule = $this->getDefaultDaySchedule();
}
return [
'userTheme' => $userTheme,
'userColorMode' => $userColorMode,
'userColorScheme' => $userColorScheme,
'themeFont' => $themeFont,
'dateFormat' => $userDateFormat,
'timeFormat' => $userTimeFormat,
'dateTimeValues' => $this->getSupportedDateTimeFormats(),
'timezone' => $timezone,
'timezoneOptions' => timezone_identifiers_list(),
'availableColorSchemes' => $this->themeCore->getAvailableColorSchemes(),
'availableFonts' => $this->themeCore->getAvailableFonts(),
'fontTooltips' => $this->themeCore->fontTooltips,
'availableThemes' => $this->themeCore->getAll(),
'languageList' => $this->language->getLanguageList(),
'workdays' => $workdays,
'daySchedule' => $daySchedule,
'dayHourOptions' => $this->getDayHourOptions(),
];
}
/**
* getDefaultWorkdays - returns the default weekly working hours used when a
* user has not yet configured their schedule.
*
* @return array<int, array{start: string, end: string}>
*/
public function getDefaultWorkdays(): array
{
return [
1 => ['start' => '09:00', 'end' => '17:00'],
2 => ['start' => '09:00', 'end' => '17:00'],
3 => ['start' => '09:00', 'end' => '17:00'],
4 => ['start' => '09:00', 'end' => '17:00'],
5 => ['start' => '09:00', 'end' => '17:00'],
];
}
/**
* getDefaultDaySchedule - returns the default daily schedule used when a user
* has not yet configured one.
*
* @return array<string, int>
*/
public function getDefaultDaySchedule(): array
{
return [
'wakeup' => 6,
'workStart' => 8,
'lunch' => 12,
'workEnd' => 16,
'bed' => 22,
];
}
/**
* getDayHourOptions - returns the catalog of selectable two-hour blocks used
* when configuring the daily schedule.
*
* @return array<int, array{start: string, end: string}>
*/
public function getDayHourOptions(): array
{
return [
0 => ['start' => '0:00', 'end' => '2:00'],
2 => ['start' => '2:00', 'end' => '4:00'],
4 => ['start' => '4:00', 'end' => '6:00'],
6 => ['start' => '6:00', 'end' => '8:00'],
8 => ['start' => '8:00', 'end' => '10:00'],
10 => ['start' => '10:00', 'end' => '12:00'],
12 => ['start' => '12:00', 'end' => '14:00'],
14 => ['start' => '14:00', 'end' => '16:00'],
16 => ['start' => '16:00', 'end' => '18:00'],
18 => ['start' => '18:00', 'end' => '20:00'],
20 => ['start' => '20:00', 'end' => '22:00'],
22 => ['start' => '22:00', 'end' => '0:00'],
];
}
/**
* getSupportedDateTimeFormats - returns the catalog of supported date and time
* format options shown during onboarding.
*
* @return array{dates: array<int, string>, times: array<int, string>}
*/
public function getSupportedDateTimeFormats(): array
{
return [
'dates' => [
$this->language->__('language.dateformat'),
'Y-m-d',
'D, d M y',
'l, d-M-y',
'd.m.Y',
'd/m/Y',
'd. F Y',
'm-d-Y',
'dmY',
'F d, Y',
'd F Y',
],
'times' => [
$this->language->__('language.timeformat'),
'H:i P',
'H:i O',
'H:i T',
'H:i:s',
'H:i',
],
];
}
/**
* saveAccount - first onboarding step: validates the chosen password, assembles
* the user record from the submitted profile fields and persists it.
*
* The plaintext password is stored in the session (tempPassword) so the user can
* be logged in automatically once onboarding completes, mirroring the original flow.
*
* @param array $userInvite the invited user record (resolved from the invite link)
* @param string $name the full name as submitted (split into first/last name)
* @param string $jobTitle the submitted job title
* @param string $password the chosen password
* @return string 'weak' if the password is not strong enough, 'saved' if the user was
* persisted, 'error' if persistence failed
*
* @api
*/
public function saveAccount(array $userInvite, string $name, string $jobTitle, string $password): string
{
if (! $this->userService->checkPasswordStrength($password)) {
return 'weak';
}
$nameParts = explode(' ', $name);
$userInvite['firstname'] = $nameParts[0];
$userInvite['lastname'] = $nameParts[1] ?? '';
$userInvite['jobTitle'] = $jobTitle;
$userInvite['status'] = 'i';
$userInvite['user'] = $userInvite['username'];
$userInvite['password'] = $password;
session(['tempPassword' => $password]);
if ($this->userService->editUser($userInvite, $userInvite['id'])) {
return 'saved';
}
return 'error';
}
/**
* saveThemeChoice - second onboarding step: persists the chosen theme and font,
* activates them and dispatches the related onboarding events.
*
* @param array $userInvite the invited user record
* @param string $theme the chosen theme
* @param string $themeFont the chosen font
*
* @throws BindingResolutionException
*
* @api
*/
public function saveThemeChoice(array $userInvite, string $theme, string $themeFont): void
{
$postTheme = htmlentities($theme);
$font = htmlentities($themeFont);
$this->settingService->saveSetting('usersettings.'.$userInvite['id'].'.theme', $postTheme);
$this->settingService->saveSetting('usersettings.'.$userInvite['id'].'.themeFont', $font);
$this->themeCore->clearCache();
$this->themeCore->setActive($postTheme);
$this->themeCore->setFont($font);
$this->themeCore->clearCache();
self::dispatchEvent('onboarding_themechoice_'.$postTheme, [], self::EVENT_CONTEXT);
self::dispatchEvent('onboarding_themechoice_'.$font, [], self::EVENT_CONTEXT);
}
/**
* saveColorChoice - third onboarding step: persists the chosen color mode and
* scheme, activates them and dispatches the related onboarding events.
*
* @param array $userInvite the invited user record
* @param string $colorMode the chosen color mode
* @param string $colorScheme the chosen color scheme
*
* @throws BindingResolutionException
*
* @api
*/
public function saveColorChoice(array $userInvite, string $colorMode, string $colorScheme): void
{
$postColorMode = htmlentities($colorMode);
$postColorScheme = htmlentities($colorScheme);
$this->settingService->saveSetting('usersettings.'.$userInvite['id'].'.colorMode', $postColorMode);
$this->settingService->saveSetting('usersettings.'.$userInvite['id'].'.colorScheme', $postColorScheme);
self::dispatchEvent('onboarding_colorchoice_'.$postColorMode, [], self::EVENT_CONTEXT);
self::dispatchEvent('onboarding_colorchoice_'.$postColorScheme, [], self::EVENT_CONTEXT);
$this->themeCore->clearCache();
$this->themeCore->setColorMode($postColorMode);
$this->themeCore->setColorScheme($postColorScheme);
$this->themeCore->clearCache();
}
/**
* saveSchedule - fourth onboarding step: assembles the day schedule from the
* submitted values, dispatches the related onboarding events and persists it.
*
* @param array $userInvite the invited user record
* @param string $workStart the submitted work start block
* @param string $lunch the submitted lunch block
* @param string $workEnd the submitted work end block
*
* @throws BindingResolutionException
*
* @api
*/
public function saveSchedule(array $userInvite, string $workStart, string $lunch, string $workEnd): void
{
$daySchedule = [
'wakeup' => '',
'workStart' => $workStart,
'lunch' => $lunch,
'workEnd' => $workEnd,
'bed' => '',
];
self::dispatchEvent('onboarding_schedule_start_'.$daySchedule['workStart'], [], self::EVENT_CONTEXT);
self::dispatchEvent('onboarding_schedule_lunch_'.$daySchedule['lunch'], [], self::EVENT_CONTEXT);
self::dispatchEvent('onboarding_schedule_end_'.$daySchedule['workEnd'], [], self::EVENT_CONTEXT);
$this->settingService->saveSetting('usersettings.'.$userInvite['id'].'.daySchedule', serialize($daySchedule));
}
/**
* completeOnboarding - final onboarding step: activates the user, dispatches the
* onboarding-finished and signup-success events, then logs the user in using the
* temporary password captured during account setup.
*
* @param array $userInvite the invited user record
* @return bool true if the user was successfully logged in, false otherwise
*
* @throws BindingResolutionException
*
* @api
*/
public function completeOnboarding(array $userInvite): bool
{
$userInvite['status'] = 'A';
$userInvite['password'] = '';
$userInvite['user'] = $userInvite['username'];
$this->userService->editUser($userInvite, $userInvite['id']);
self::dispatchEvent('onboarding_finished', [], self::EVENT_CONTEXT);
$loggedIn = $this->authService->login($userInvite['username'], session('tempPassword'));
session()->forget('tempPassword');
self::dispatch_event('userSignUpSuccess', ['user' => $userInvite], self::EVENT_CONTEXT);
return $loggedIn;
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Leantime\Domain\Auth\Services;
use Leantime\Core\Support\NameSanitizer;
use Leantime\Domain\Auth\Models\Roles;
/**
* Single source of truth for the `session('userdata')` array.
*
* Every authentication path — web login ({@see Auth::setUserSession}), x-api-key
* ({@see \Leantime\Domain\Api\Services\Api::setApiUserSession}) and Sanctum/Bearer tokens
* ({@see AuthUser::setUserSession}) — builds this same structure. They used to each build it
* inline, which let fields drift silently between paths:
* - `role` was stored as the raw DB int on the Bearer path but as the role-NAME string on the
* others; the permission engine validates against the name list, so Bearer auth denied every
* gated method with -32001 (the 3.9.x regression).
* - `twoFAVerified` likewise diverged between the two token paths.
*
* Routing all three through this factory makes those bugs structurally impossible: `role` is
* ALWAYS converted via {@see Roles::getRoleString()}, and every field is produced identically.
* The two genuinely per-path values — whether the session is external-auth and whether 2FA is
* already satisfied — are explicit parameters.
*/
class UserSessionBuilder
{
/**
* Build the canonical userdata array from a `zp_user` row.
*
* @param array $user A zp_user row (id, firstname, username, profileId, clientId, role, …).
* @param bool $isExternalAuth True when the user authenticated via an external provider.
* @param bool $twoFAVerified True when 2FA is considered satisfied (token auth — the token
* is the strong credential and no interactive 2FA is possible).
* @return array<string, mixed> The userdata array to store in `session('userdata')`.
*/
public static function build(array $user, bool $isExternalAuth = false, bool $twoFAVerified = false): array
{
return [
'id' => (int) $user['id'],
'name' => NameSanitizer::clean($user['firstname'] ?? ''),
'profileId' => $user['profileId'] ?? '',
'mail' => filter_var($user['username'] ?? '', FILTER_SANITIZE_EMAIL),
'clientId' => $user['clientId'] ?? '',
// ALWAYS the role-NAME string the permission engine validates against — never the raw
// DB int. This is the field whose drift caused the Bearer -32001 regression.
'role' => Roles::getRoleString($user['role']),
'settings' => ! empty($user['settings']) ? safe_unserialize($user['settings'], []) : [],
'twoFAEnabled' => $user['twoFAEnabled'] ?? false,
'twoFAVerified' => $twoFAVerified,
'twoFASecret' => $user['twoFASecret'] ?? '',
'isExternalAuth' => $isExternalAuth,
'createdOn' => ! empty($user['createdOn']) ? dtHelper()->parseDbDateTime($user['createdOn']) : dtHelper()->userNow(),
'modified' => ! empty($user['modified']) ? dtHelper()->parseDbDateTime($user['modified']) : dtHelper()->userNow(),
];
}
}

View File

@@ -0,0 +1,74 @@
@props([
'percentComplete' => 0,
'current' => '',
'completed' => [],
])
<div class="projectSteps">
<div class="progressWrapper">
<div class="progress">
<div
id="progressChecklistBar"
class="progress-bar progress-bar-success tx-transition"
role="progressbar"
aria-valuenow="0"
aria-valuemin="0"
aria-valuemax="100"
style="width: {{ $percentComplete }}%"
><span class="sr-only">{{ $percentComplete }}%</span></div>
</div>
<div class="step @if($current=='account') current @endif @if(in_array("account", $completed)) complete @endif" style="left: 12%;">
<a href="javascript:void(0)" data-toggle="dropdown" class="dropdown-toggle">
<span class="innerCircle">
@if(in_array("account", $completed))
<i class="fa-solid fa-check" style="color:var(--main-action-color); padding-left:3px;"></i>
@endif
</span>
<span class="title">
Account
</span>
</a>
</div>
<div class="step @if($current=='theme') current @endif @if(in_array("theme", $completed)) complete @endif" style="left: 37%;">
<a href="javascript:void(0)" data-toggle="dropdown" class="dropdown-toggle">
<span class="innerCircle">
@if(in_array("theme", $completed))
<i class="fa-solid fa-check" style="color:var(--main-action-color); padding-left:3px;"></i>
@endif
</span>
<span class="title">
Theme
</span>
</a>
</div>
<div class="step @if($current=='personalization') current @endif @if(in_array("personalization", $completed)) complete @endif" style="left: 62%;">
<a href="javascript:void(0)" data-toggle="dropdown" class="dropdown-toggle">
<span class="innerCircle">
@if(in_array("personalization", $completed))
<i class="fa-solid fa-check" style="color:var(--main-action-color); padding-left:3px;"></i>
@endif
</span>
<span class="title">
Personalization
</span>
</a>
</div>
<div class="step @if($current=='time') current @endif @if(in_array("time", $completed)) complete @endif" style="left: 88%;">
<a href="javascript:void(0)" data-toggle="dropdown" class="dropdown-toggle">
<span class="innerCircle">
@if(in_array("time", $completed))
<i class="fa-solid fa-check" style="color:var(--main-action-color); padding-left:3px;"></i>
@endif
</span>
<span class="title">
Routine
</span>
</a>
</div>
</div>
</div>
<br /><br /><br />

View File

@@ -0,0 +1,63 @@
@extends($layout)
@section('content')
@dispatchEvent('beforePageHeaderOpen')
<div class="pageheader">
@dispatchEvent('afterPageHeaderOpen')
<div class="pagetitle">
<h1>{!! __('headlines.login') !!}</h1>
</div>
@dispatchEvent('beforePageHeaderClose')
</div>
@dispatchEvent('afterPageHeaderClose')
<div class="regcontent">
@dispatchEvent('afterRegcontentOpen')
{!! $tpl->displayInlineNotification() !!}
@if ($noLoginForm === false)
<form id="login" action="{{ BASE_URL }}/auth/login" method="post">
@csrf
@dispatchEvent('afterFormOpen')
<input type="hidden" name="redirectUrl" value="{{ $redirectUrl }}" />
<div class="">
<label for="username">Email</label>
<x-global::forms.text-input name="username" id="username" placeholder="{{ __($inputPlaceholder) }}" value="" />
</div>
<div class="">
<label for="password">Password</label>
<x-global::forms.text-input type="password" name="password" id="password" autocomplete="off" placeholder="{{ __('input.placeholders.enter_password') }}" value="" />
<div class="forgotPwContainer">
<a href="{{ BASE_URL }}/auth/resetPw" class="forgotPw">{!! __('links.forgot_password') !!}</a>
</div>
</div>
@dispatchEvent('beforeSubmitButton')
<div class="">
<x-global::forms.button tag="input" inputType="submit" name="login" contentRole="primary" :labelText="__('buttons.login')" />
</div>
<div>
</div>
@dispatchEvent('beforeFormClose')
</form>
@else
{!! __('text.no_login_form') !!}<br /><br />
@endif
@if ($oidcEnabled)
@dispatchEvent('beforeOidcButton')
<div class="">
<div style="margin-top:20px; border-bottom:1px solid #ccc; with:100%; height:10px; overflow:show; text-align:center; margin-bottom:40px;">
<p style="text-align:center; display:inline-block; background:var(--secondary-background); padding:0px 5px;">{!! __('label.or_login_with') !!}</p>
</div>
<x-global::forms.button tag="a" :link="BASE_URL . '/oidc/login'" contentRole="primary" style="width:100%;">{!! __('buttons.oidclogin') !!}</x-global::forms.button>
</div>
@endif
@dispatchEvent('beforeRegcontentClose')
</div>
@endsection

View File

@@ -0,0 +1,44 @@
@dispatchEvent('beforeUserinfoMenuOpen')
<div class="userinfo">
@dispatchEvent('afterUserinfoMenuOpen')
@if(session()->exists("companysettings.logoPath") && session("companysettings.logoPath") !== false && session("companysettings.logoPath") !== '')
<a href='{{ BASE_URL }}/users/editOwn/' preload="mouseover" class="dropdown-toggle profileHandler includeLogo" data-toggle="dropdown">
<img src="{{ BASE_URL }}/api/users?profileImage={{ $user['id'] ?? -1 }}&v={{ format($user['modified'] ?? -1)->timestamp() }}" class="profilePicture"/>
<img src="{{ session("companysettings.logoPath") }}" class="logo tw-pl-1" />
</a>
@else
<a href='{{ BASE_URL }}/users/editOwn/' preload="mouseover" class="dropdown-toggle profileHandler" data-toggle="dropdown">
<img src="{{ BASE_URL }}/api/users?profileImage={{ $user['id'] ?? -1 }}&v={{ format($user['modified'] ?? -1)->timestamp() }}" class="profilePicture"/>
</a>
@endif
<ul class="dropdown-menu">
@dispatchEvent('afterUserinfoDropdownMenuOpen')
<li>
<a href='{{ BASE_URL }}/users/editOwn/' preload="mouseover">
{!! __("menu.my_profile") !!}
</a>
</li>
@dispatchEvent('afterMyProfile')
<li>
<a href='{{ BASE_URL }}/users/editOwn#theme' preload="mouseover">
{!! __("menu.theme") !!}
</a>
</li>
@dispatchEvent('afterTheme')
<li>
<a href='{{ BASE_URL }}/users/editOwn#settings' preload="mouseover">
{!! __("menu.settings") !!}
</a>
</li>
@dispatchEvent('afterSettings')
<li class="border">
<a href='{{ BASE_URL }}/auth/logout'>
{!! __("menu.sign_out") !!}
</a>
</li>
@dispatchEvent('beforeUserinfoDropdownMenuClose')
</ul>
@dispatchEvent('beforeUserinfoMenuClose')
</div>
@dispatchEvent('afterUserinfoMenuClose')

View File

@@ -0,0 +1,38 @@
@fragment('tokens-table')
<div class="row">
<div class="col-md-12">
<div>
<h5 class="subtitle">{{ __('headlines.personal_access_tokens') }}</h5>
<p>{{ __('text.create_tokens_to_authenticate') }}</p>
<br />
<x-global::forms.button tag="a" contentRole="primary" link="#/auth/tokenNew">{{ __('buttons.create_token') }}</x-global::forms.button> <br />
<div class="clearfix"></div>
<table class="table table-bordered" id="tokens-table">
<thead>
<tr>
<th>{{ __('label.name') }}</th>
<th>{{ __('label.last_used') }}</th>
<th>{{ __('label.created_on') }}</th>
<th>{{ __('label.actions') }}</th>
</tr>
</thead>
<tbody>
@foreach($tokens as $token)
<tr>
<td>{{ $token['name'] }}</td>
<td>{{ $token['last_used_at'] ? format($token['last_used_at'])->date() . ' ' . format($token['last_used_at'])->time(): 'Never' }}</td>
<td>{{ format($token['created_at'])->date(). ' ' . format($token['created_at'])->time() }}</td>
<td>
<x-global::forms.button state="danger" class="btn-sm" hx-delete="{{ BASE_URL }}/hx/auth/personalTokens/delete/{{ $token['id'] }}" hx-confirm="{{ __('notifications.confirm_token_delete') }}" hx-target="#personalTokens"><i class="fa fa-trash"></i></x-global::forms.button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
@endfragment

View File

@@ -0,0 +1,32 @@
@extends($layout)
@section('content')
@dispatchEvent('beforePageHeaderOpen')
<div class="pageheader">
<div class="pagetitle">
<h1>{!! __('headlines.reset_password') !!}</h1>
</div>
</div>
@dispatchEvent('afterPageHeaderClose')
<div class="regcontent">
@dispatchEvent('afterRegcontentOpen')
<form id="resetPassword" action="" method="post">
@dispatchEvent('afterFormOpen')
{!! $tpl->displayInlineNotification() !!}
<p>{!! __('text.enter_email_address_to_reset') !!}<br /><br /></p>
<div class="">
<x-global::forms.text-input name="username" id="username" placeholder="{{ __('input.placeholders.enter_email') }}" />
</div>
<div class="">
<div class="forgotPwContainer">
<a href="{{ BASE_URL }}/" class="forgotPw">{!! __('links.back_to_login') !!}</a>
</div>
@dispatchEvent('beforeSubmitButton')
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.reset_password')" name="resetPassword" />
</div>
@dispatchEvent('beforeFormClose')
</form>
@dispatchEvent('beforeRegcontentClose')
</div>
@endsection

View File

@@ -0,0 +1,51 @@
@extends($layout)
@section('content')
@dispatchEvent('beforePageHeaderOpen')
<div class="pageheader">
@dispatchEvent('afterPageHeaderOpen')
<div class="pagetitle">
<h1>{!! __('headlines.reset_password') !!}</h1>
</div>
@dispatchEvent('beforePageHeaderClose')
</div>
@dispatchEvent('afterPageHeaderClose')
<div class="regcontent">
@dispatchEvent('afterRegcontentOpen')
<form id="resetPassword" action="" method="post">
@dispatchEvent('afterFormOpen')
{!! $tpl->displayInlineNotification() !!}
<p>{!! __('text.enter_new_password') !!}<br /><br /></p>
<div class="">
<x-global::forms.text-input type="password" autocomplete="off" name="password" id="password" placeholder="{{ __('input.placeholders.enter_new_password') }}" />
<span id="pwStrength" style="width:100%;"></span>
</div>
<div class=" ">
<x-global::forms.text-input type="password" autocomplete="off" name="password2" id="password2" placeholder="{{ __('input.placeholders.confirm_password') }}" />
</div>
<small>{!! __('label.passwordRequirements') !!}</small><br /><br />
<div class="">
@dispatchEvent('beforeSubmitButton')
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('buttons.reset_password')" name="resetPassword" />
<div class="forgotPwContainer">
<a href="{{ BASE_URL }}/" class="forgotPw">{!! __('links.back_to_login') !!}</a>
</div>
</div>
@dispatchEvent('beforeFormClose')
</form>
@dispatchEvent('beforeRegcontentClose')
</div>
@once
@push('scripts')
<script>
leantime.usersController.checkPWStrength('password');
</script>
@endpush
@endonce
@endsection

View File

@@ -0,0 +1,10 @@
<h4 class="widgettitle title-light">{{ __('headlines.token_created') }}</h4>
<p>{{ __('text.copy_token_now') }}</p>
<div class="form-group">
<x-global::forms.text-input value="{{ $newToken }}" onclick="this.select();" />
</div>
<div class="align-right">
<x-global::forms.button inputType="button" contentRole="default" onclick="leantime.modals.closeModal();">{{ __('buttons.close') }}</x-global::forms.button>
<x-global::forms.button inputType="button" contentRole="primary" onclick="leantime.snippets.copyToClipboard('{{ $newToken }}')">{{ __('labels.copy_to_clipboard') }}</x-global::forms.button>
</div>

View File

@@ -0,0 +1,20 @@
<div id="tokenModal">
<h4 class="widgettitle title-light">{{ __('headlines.create_access_token') }}</h4>
<form hx-post="{{ BASE_URL }}/hx/auth/personalTokens/create"
hx-target="#tokenModal" id="newToken">
<div class="form-group">
<label for="tokenName">{{ __('label.token_name') }}</label>
<x-global::forms.text-input id="tokenName" name="name" required />
<small class="form-text text-muted">
<br/>{{ __('text.token_name_description') }}
</small>
</div>
<br />
<div class="align-right">
<x-global::forms.button inputType="button" contentRole="default" onclick="jQuery('#modal').modal('hide');">{{ __('buttons.close') }}</x-global::forms.button>
<x-global::forms.button inputType="submit" contentRole="primary">{{ __('buttons.create_token') }}</x-global::forms.button>
</div>
</form>
</div>

View File

@@ -0,0 +1,52 @@
@extends($layout)
@section('content')
<x-auth::onboardingProgress :percentComplete="12" current="account" :completed="[]" />
<h2>{{ __('titles.account_details') }}</h2>
<?php $tpl->dispatchTplEvent('afterPageHeaderClose'); ?>
<div class="regcontent">
<?php $tpl->dispatchTplEvent('afterRegcontentOpen'); ?>
<form id="resetPassword" action="" method="post">
<?php $tpl->dispatchTplEvent('afterFormOpen'); ?>
<?php echo $tpl->displayInlineNotification(); ?>
<input type="hidden" name="step" value="1"/>
<div class="">
<label for="name"><?php echo $tpl->language->__("label.name"); ?></label>
<input type="text" name="name" style="margin-bottom:15px" id="name" placeholder="<?php echo $tpl->language->__("input.placeholders.name"); ?>" value="<?=$tpl->escape($user['firstname']); ?>" />
</div>
<div class="">
<label for="jobTitle"><?php echo $tpl->language->__("label.role_or_title"); ?></label>
<input type="text" name="jobTitle" id="jobTitle" style="margin-bottom:15px" placeholder="<?php echo $tpl->language->__("input.placeholders.jobtitle"); ?>" value="<?=$tpl->escape($user['jobTitle']); ?>" />
</div>
<div class="">
<label for="password"><?php echo $tpl->language->__("label.password"); ?></label>
<input type="password" name="password" autocomplete="off" id="password" style="margin-bottom:15px" placeholder="<?php echo $tpl->language->__("input.placeholders.enter_new_password"); ?>" />
<span id="pwStrength" style="width:100%;"></span>
</div>
<small><?=$tpl->__('label.passwordRequirements') ?></small><br /><br />
<div class="">
<input type="hidden" name="saveAccount" value="1" />
<?php $tpl->dispatchTplEvent('beforeSubmitButton'); ?>
<div class="tw-text-right">
<input type="submit" name="createAccount" class="tw-w-auto" style="width:auto" value="<?php echo $tpl->language->__("buttons.next"); ?>" />
</div>
</div>
<?php $tpl->dispatchTplEvent('beforeFormClose'); ?>
</form>
<?php $tpl->dispatchTplEvent('beforeRegcontentClose'); ?>
</div>
<script>
leantime.usersController.checkPWStrength('password');
</script>
@endsection

View File

@@ -0,0 +1,63 @@
@extends($layout)
@section('content')
<x-auth::onboardingProgress :percentComplete="37" current="theme" :completed="['account']" />
<h2>{{ __('titles.determine_visual_experience') }}</h2>
<p>{{ __('text.choose_a_theme_and_font_easy_to_read') }}</p>
<div class="regcontent">
<form id="resetPassword" action="" method="post">
<input type="hidden" name="step" value="2" />
{{ $tpl->displayInlineNotification() }}
<div class="row-fluid">
<div class="form-group">
<label for="themeSelect">Optimal Stimulation</label>
<span class='field tw-flex'>
<?php
$themeAll = $themeCore->getAll();
foreach ($themeAll 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>
<br />
<div class="form-group">
<label>Readability</label>
<div class="tw-flex">
@foreach($availableFonts as $key => $font)
<x-global::selectable data-tippy-content="{{ $fontTooltips[$key] }}" :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-[150px]"
style="font-family:'{{ $font }}'; font-size:16px;">
The quick brown fox jumps over the lazy dog
</label>
</x-global::selectable>
@endforeach
</div>
</div>
</div>
<br />
<div class="tw-text-right">
<x-global::forms.button tag="a" link="{{BASE_URL}}/auth/userInvite/{{$inviteId}}" contentRole="tertiary" style="width:auto; margin-right:10px">Back</x-global::forms.button>
<input type="submit" name="createAccount" class="tw-w-auto" style="width:auto" value="<?php echo $tpl->language->__("buttons.next"); ?>" />
</div>
</form>
</div>
@endsection

View File

@@ -0,0 +1,61 @@
@extends($layout)
@section('content')
<x-auth::onboardingProgress :percentComplete="64" current="personalization" :completed="['account', 'theme']" />
<h2>🎨 Creating A Comfortable View</h2>
<p>Your favorite color mode and scheme.<br /></p>
<div class="regcontent">
<form id="resetPassword" action="" method="post">
<input type="hidden" name="step" value="3" />
{{ $tpl->displayInlineNotification() }}
<div class="row">
<div class="col-md-12">
<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-[200px]">
<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-[200px]">
<i class="fa-solid fa-moon tw-font-xxl"></i>
</label>
</x-global::selectable>
</div>
</div>
<br />
<div class="row">
<div class="col-md-12">
<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>
<br /> <br />
<div class="tw-text-right">
<x-global::forms.button tag="a" link="{{BASE_URL}}/auth/userInvite/{{$inviteId}}?step=2" contentRole="tertiary" style="width:auto; margin-right:10px">Back</x-global::forms.button>
<input type="submit" name="createAccount" class="tw-w-auto" style="width:auto" value="<?php echo $tpl->language->__("buttons.next"); ?>" />
</div>
</form>
</div>
@endsection

View File

@@ -0,0 +1,200 @@
@extends($layout)
@section('content')
<x-auth::onboardingProgress :percentComplete="88" current="time" :completed="['account', 'theme', 'personalization']" />
<h2>🗓️ Shaping A Daily Flow</h2>
<p>We'll use these times to help prioritize your tasks</p>
<div class="regcontent">
<form id="resetPassword" action="" method="post">
<input type="hidden" name="step" value="4"/>
{{ $tpl->displayInlineNotification() }}
<label>What time do you usually start working?</label>
<div class="">
<x-global::selectable selected="{{ $daySchedule['workStart'] == '8' ? 'true' : 'false' }}" :id="'daySchedule-workStart-1'" :name="'daySchedule-workStart-button'" :value="'8'" :label="''" onclick="jQuery('#daySchedule-workStart').val('8').hide(); jQuery('#daySchedule-workStart-3').show();" class="compact">
<label for="" class="">
{{ format($dayHourOptions[8]['start'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time() }} - {{ format($dayHourOptions[8]['end'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time()}}
</label>
</x-global::selectable>
<x-global::selectable selected="{{ $daySchedule['workStart'] == '10' ? 'true' : 'false' }}" :id="'daySchedule-workStart-2'" :name="'daySchedule-workStart-button'" :value="'10'" :label="''" onclick="jQuery('#daySchedule-workStart').val('10').hide(); jQuery('#daySchedule-workStart-3').show(); " class="compact">
<label for="" class="">
{{ format($dayHourOptions[10]['start'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time() }} - {{ format($dayHourOptions[10]['end'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time()}}
</label>
</x-global::selectable>
<x-global::selectable selected="" :id="'daySchedule-workStart-3'" :name="'daySchedule-workStart-button'" :value="''" :label="''" class="compact" onclick="jQuery(this).hide(); jQuery('#daySchedule-workStart').show()">
<label for="" class="">
<i class="fa fa-clock"></i> Select my own
</label>
</x-global::selectable>
<select name="daySchedule-workStart" id="daySchedule-workStart" style="display:none; vertical-align: top;">
@foreach($dayHourOptions as $key => $value)
<option value="{{ $key }}">
{{ format($value['start'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time() }} - {{ format($value['end'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time()}}
</option>
@endforeach
</select>
</div>
<br />
<label>When do you normally take a lunch break from work?</label>
<div class="">
<x-global::selectable selected="{{ $daySchedule['lunch'] == '12' ? 'true' : 'false' }}" :id="'daySchedule-lunch-1'" :name="'daySchedule-lunch-button'" :value="'12'" :label="''" onclick="jQuery('#daySchedule-lunch').val('12').hide(); jQuery('#daySchedule-lunch-3').show();" class="compact">
<label for="" class="">
{{ format($dayHourOptions[12]['start'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time() }} - {{ format($dayHourOptions[12]['end'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time()}}
</label>
</x-global::selectable>
<x-global::selectable selected="{{ $daySchedule['lunch'] == '14' ? 'true' : 'false' }}" :id="'daySchedule-lunch-2'" :name="'daySchedule-lunch-button'" :value="'14'" :label="''" onclick="jQuery('#daySchedule-lunch').val('14').hide(); jQuery('#daySchedule-lunch-3').show();" class="compact">
<label for="" class="">
{{ format($dayHourOptions[14]['start'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time() }} - {{ format($dayHourOptions[14]['end'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time()}}
</label>
</x-global::selectable>
<x-global::selectable selected="" :id="'daySchedule-lunch-3'" :name="'daySchedule-lunch-button'" :value="''" :label="''" class="compact" onclick="jQuery(this).hide(); jQuery('#daySchedule-lunch').show()">
<label for="" class="">
<i class="fa fa-clock"></i> Select my own
</label>
</x-global::selectable>
<select name="daySchedule-lunch" id="daySchedule-lunch" style="display:none; vertical-align: top;">
@foreach($dayHourOptions as $key => $value)
<option value="{{ $key }}">
{{ format($value['start'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time() }} - {{ format($value['end'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time()}}
</option>
@endforeach
</select>
</div>
<br />
<label>When do you normally end your work day? 🥳</label>
<div class="">
<x-global::selectable selected="{{ $daySchedule['workEnd'] == '16' ? 'true' : 'false' }}" :id="'daySchedule-workEnd-1'" :name="'daySchedule-workEnd-button'" :value="'16'" :label="''" onclick="jQuery('#daySchedule-workEnd').val('16').hide(); jQuery('#daySchedule-workEnd-3').show();" class="compact">
<label for="" class="">
{{ format($dayHourOptions[16]['start'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time() }} - {{ format($dayHourOptions[16]['end'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time()}}
</label>
</x-global::selectable>
<x-global::selectable selected="{{ $daySchedule['workEnd'] == '18' ? 'true' : 'false' }}" :id="'daySchedule-workEnd-2'" :name="'daySchedule-workEnd-button'" :value="'18'" :label="''" onclick="jQuery('#daySchedule-workEnd').val('18').hide(); jQuery('#daySchedule-workEnd-3').show();" class="compact">
<label for="" class="">
{{ format($dayHourOptions[18]['start'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time() }} - {{ format($dayHourOptions[18]['end'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time()}}
</label>
</x-global::selectable>
<x-global::selectable selected="" :id="'daySchedule-workEnd-3'" :name="'daySchedule-workEnd-button'" :value="''" :label="''" class="compact" onclick="jQuery(this).hide(); jQuery('#daySchedule-workEnd').show()">
<label for="" class="">
<i class="fa fa-clock"></i> Select my own
</label>
</x-global::selectable>
<select name="daySchedule-workEnd" id="daySchedule-workEnd" style="display:none; vertical-align: top;">
@foreach($dayHourOptions as $key => $value)
<option value="{{ $key }}">
{{ format($value['start'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time() }} - {{ format($value['end'], null, \Leantime\Core\Support\FromFormat::User24hTime)->time()}}
</option>
@endforeach
</select>
</div>
{{-- <div class="tw-flex">--}}
{{-- @foreach([1,2,3,4,5,6,7] as $dayOfWeekIso)--}}
{{-- <x-global::selectable type="checkbox" class="circle" selected="{{ isset($workdays[$dayOfWeekIso]) ? 'true' : '' }}" :id="'dayOfWeek-'.$dayOfWeekIso" :name="'dayOfWeek-'.$dayOfWeekIso" :value="$dayOfWeekIso" :label="''" onclick="showTimeForm({{$dayOfWeekIso}})">--}}
{{-- <label for="dayOfWeek-{{ $dayOfWeekIso }}" class="">--}}
{{-- {{ substr(__('dates.day_of_week_iso-'.$dayOfWeekIso), 0, 2) }}--}}
{{-- </label>--}}
{{-- </x-global::selectable>--}}
{{-- @endforeach--}}
{{-- </div>--}}
{{-- <div>--}}
{{-- @foreach([1,2,3,4,5,6,7] as $dayOfWeekIso)--}}
{{-- <div class="dayOfWeekInputs dayOfWeekInput-{{$dayOfWeekIso}} {{ isset($workdays[$dayOfWeekIso]) ? 'tw-flex' : 'tw-hidden' }}">--}}
{{-- <div class="tw-w-1/4 tw-leading-[32px]">--}}
{{-- {{ __('dates.day_of_week_iso-'.$dayOfWeekIso) }}--}}
{{-- </div>--}}
{{-- <div class="tw-w-1/4">--}}
{{-- <input type="time" class="dayStart" name="dayOfWeek-{{$dayOfWeekIso}}-start" value='{{ isset($workdays[$dayOfWeekIso]) ? $workdays[$dayOfWeekIso]['start'] : '09:00'}}' step="1800"/>--}}
{{-- </div>--}}
{{-- <div class="tw-px-2 tw-leading-[32px]">to</div>--}}
{{-- <div class="tw-w-1/4">--}}
{{-- <input type="time" class="dayEnd" name="dayOfWeek-{{$dayOfWeekIso}}-end" value='{{ isset($workdays[$dayOfWeekIso]) ? $workdays[$dayOfWeekIso]['end'] : '17:00'}}' step="1800"/>--}}
{{-- </div>--}}
{{-- <div class="tw-w tw-leading-[32px] tw-pl-2 applyBox">--}}
{{-- @if($loop->index == 0)--}}
{{-- <a href="javascript:void(0)">Apply to all</a>--}}
{{-- @endif--}}
{{-- </div>--}}
{{-- </div>--}}
{{-- @endforeach--}}
{{-- </div>--}}
<br /> <br />
<div class="tw-text-right">
<x-global::forms.button tag="a" link="{{BASE_URL}}/auth/userInvite/{{$inviteId}}?step=3" contentRole="tertiary" style="width:auto; margin-right:10px">Back</x-global::forms.button>
<input type="submit" name="createAccount" class="tw-w-auto" style="width:auto" value="<?php echo $tpl->language->__("buttons.next"); ?>" />
</div>
</form>
</div>
<script>
function applyToAllClick() {
jQuery('.dayOfWeekInputs').each(function() {
let linkParentContainer = jQuery(this);
jQuery(this).find('.applyBox a').click(function() {
let startInput = jQuery(linkParentContainer).find("input.dayStart").val();
let endInput = jQuery(linkParentContainer).find("input.dayEnd").val();
jQuery('.dayOfWeekInputs input.dayStart').val(startInput);
jQuery('.dayOfWeekInputs input.dayEnd').val(endInput);
});
})
}
jQuery(document).ready(function() {
applyToAllClick();
var timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
jQuery("#timezone").val(timezone);
var now=new Date(2010,11,31);
var str=now.toLocaleDateString();
str=str.replace("31","dd");
str=str.replace("12","mm");
str=str.replace("2010","yyyy");
})
function showTimeForm($id) {
let isVisible = jQuery('.dayOfWeekInput-'+$id).hasClass("tw-flex");
if(isVisible) {
jQuery('.dayOfWeekInput-'+$id).removeClass("tw-flex");
jQuery('.dayOfWeekInput-'+$id).addClass("tw-hidden");
}else{
jQuery('.dayOfWeekInput-'+$id).addClass("tw-flex");
jQuery('.dayOfWeekInput-'+$id).removeClass("tw-hidden");
}
jQuery('.dayOfWeekInputs').find('.applyBox').html("");
jQuery('.dayOfWeekInputs.tw-flex').each(function(index){
if(index == 0) {
jQuery(this).find('.applyBox').html("<a href='javascript:void(0);'>Apply to all")
}else{
jQuery(this).find('.applyBox').html();
}
});
applyToAllClick();
}
</script>
@endsection

View File

@@ -0,0 +1,42 @@
@extends($layout)
@section('content')
<x-auth::onboardingProgress :percentComplete="100" current="" :completed="['account', 'theme', 'personalization', 'time']" />
<h2>🎉 Your Leantime journey is about to begin</h2>
<div class="regcontent">
<form id="resetPassword" action="" method="post">
<input type="hidden" name="step" value="5"/>
<input type="hidden" name="complete" value="1"/>
{{ $tpl->displayInlineNotification() }}
<div class="row">
<div class="col-md-6">
<div class="ticketBox tw-p-[20px]">
<span class="fancyLink">Did you know?</span><br />
<span style="font-size:16px;">Setting Intentions has been shown to <strong>more than double the success rate</strong> of completing a task.</span>
</div>
</div>
<div class="col-md-6">
<x-global::undrawSvg image="undraw_adventure_map_hnin.svg" maxWidth="60%" maxHeight="300px"></x-global::undrawSvg>
</div>
</div>
<p><br />From here, we'll help you turn your task list into a project and goals.
Then we'll work<br /> together to identify your most important tasks so you can create some
intentions<br />to get the work done.</p> <br />
<br />
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" labelText="Complete Sign up" name="createAccount" />
</form>
</div>
@endsection

View File

@@ -0,0 +1,62 @@
<?php
use Leantime\Core\Events\EventDispatcher;
use Leantime\Domain\Auth\Listeners\ShowPersonalTokenContent;
use Leantime\Domain\Auth\Listeners\ShowPersonalTokenTab;
// Register Personal Access Tokens tab in user account settings
EventDispatcher::add_event_listener(
'leantime.domain.users.templates.editOwn.tabs',
ShowPersonalTokenTab::class
);
EventDispatcher::add_event_listener(
'leantime.domain.users.templates.editOwn.tabsContent',
ShowPersonalTokenContent::class
);
EventDispatcher::add_filter_listener('leantime.domain.auth.template.userInvite.welcomeText', function ($content, $params) {
$language = app()->make(\Leantime\Core\Language::class);
return $language->__('text.welcome_to_leantime_content');
});
EventDispatcher::add_filter_listener('leantime.domain.auth.template.userInvite2.welcomeText', function ($content, $params) {
$language = app()->make(\Leantime\Core\Language::class);
return $language->__('text.welcome_to_leantime_content');
});
EventDispatcher::add_filter_listener('leantime.domain.auth.template.userInvite3.welcomeText', function ($content, $params) {
$language = app()->make(\Leantime\Core\Language::class);
return $language->__('text.welcome_to_leantime_content');
});
EventDispatcher::add_filter_listener('leantime.domain.auth.template.userInvite4.welcomeText', function ($content, $params) {
$language = app()->make(\Leantime\Core\Language::class);
return $language->__('text.welcome_to_leantime_content');
});
EventDispatcher::add_filter_listener('leantime.domain.auth.template.userInvite5.welcomeText', function ($content, $params) {
$language = app()->make(\Leantime\Core\Language::class);
return $language->__('text.welcome_to_leantime_content');
});
EventDispatcher::add_filter_listener('leantime.domain.auth.*.belowWelcomeText', function ($content, $params) {
$quotes = [];
$quotes[] = "\"It's the first project management app I've used for more than a week, and it makes sense too.\"<br /><br />- Interior Designer";
$quotes[] = '"For me, Leantime is very cool, because it is lean. Not 3 million options to think about. The more you put in, the more it could be overloaded."<br /><br />- Open Source User';
$quotes[] = '"We are a small digital marketing agency and have been using Leantime for a couple of months after switching from ClickUp. Getting great feedback from our clients."<br /><br />- CEO';
$random = rand(0, 2);
return '
<div class="socialProofContent">
<i>'.$quotes[$random].'</i>
</div>
';
});