OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
47
app/Domain/Auth/Controllers/KeepAlive.php
Normal file
47
app/Domain/Auth/Controllers/KeepAlive.php
Normal 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);
|
||||
}
|
||||
}
|
||||
110
app/Domain/Auth/Controllers/Login.php
Normal file
110
app/Domain/Auth/Controllers/Login.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
31
app/Domain/Auth/Controllers/Logout.php
Normal file
31
app/Domain/Auth/Controllers/Logout.php
Normal 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.'/');
|
||||
}
|
||||
}
|
||||
23
app/Domain/Auth/Controllers/Redirect.php
Normal file
23
app/Domain/Auth/Controllers/Redirect.php
Normal 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();
|
||||
}
|
||||
}
|
||||
106
app/Domain/Auth/Controllers/ResetPw.php
Normal file
106
app/Domain/Auth/Controllers/ResetPw.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
22
app/Domain/Auth/Controllers/TokenNew.php
Normal file
22
app/Domain/Auth/Controllers/TokenNew.php
Normal 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');
|
||||
}
|
||||
}
|
||||
174
app/Domain/Auth/Controllers/UserInvite.php
Normal file
174
app/Domain/Auth/Controllers/UserInvite.php
Normal 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);
|
||||
}
|
||||
}
|
||||
106
app/Domain/Auth/Guards/ApiGuard.php
Normal file
106
app/Domain/Auth/Guards/ApiGuard.php
Normal 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;
|
||||
}
|
||||
}
|
||||
71
app/Domain/Auth/Guards/WebGuard.php
Normal file
71
app/Domain/Auth/Guards/WebGuard.php
Normal 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;
|
||||
}
|
||||
}
|
||||
82
app/Domain/Auth/Hxcontrollers/PersonalTokens.php
Normal file
82
app/Domain/Auth/Hxcontrollers/PersonalTokens.php
Normal 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();
|
||||
}
|
||||
}
|
||||
47
app/Domain/Auth/Js/authController.js
Normal file
47
app/Domain/Auth/Js/authController.js
Normal 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,
|
||||
};
|
||||
|
||||
})();
|
||||
24
app/Domain/Auth/Listeners/ShowPersonalTokenContent.php
Normal file
24
app/Domain/Auth/Listeners/ShowPersonalTokenContent.php
Normal 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>';
|
||||
}
|
||||
}
|
||||
17
app/Domain/Auth/Listeners/ShowPersonalTokenTab.php
Normal file
17
app/Domain/Auth/Listeners/ShowPersonalTokenTab.php
Normal 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>';
|
||||
}
|
||||
}
|
||||
63
app/Domain/Auth/Models/AuthenticatableUser.php
Normal file
63
app/Domain/Auth/Models/AuthenticatableUser.php
Normal 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';
|
||||
}
|
||||
}
|
||||
24
app/Domain/Auth/Models/CurrentUser.php
Normal file
24
app/Domain/Auth/Models/CurrentUser.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
61
app/Domain/Auth/Models/Roles.php
Normal file
61
app/Domain/Auth/Models/Roles.php
Normal 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();
|
||||
}
|
||||
}
|
||||
114
app/Domain/Auth/Repositories/AccessTokenRepository.php
Normal file
114
app/Domain/Auth/Repositories/AccessTokenRepository.php
Normal 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());
|
||||
}
|
||||
}
|
||||
167
app/Domain/Auth/Repositories/Auth.php
Normal file
167
app/Domain/Auth/Repositories/Auth.php
Normal 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;
|
||||
}
|
||||
}
|
||||
184
app/Domain/Auth/Services/AccessToken.php
Normal file
184
app/Domain/Auth/Services/AccessToken.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
772
app/Domain/Auth/Services/Auth.php
Normal file
772
app/Domain/Auth/Services/Auth.php
Normal 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;
|
||||
|
||||
}
|
||||
}
|
||||
124
app/Domain/Auth/Services/AuthUser.php
Normal file
124
app/Domain/Auth/Services/AuthUser.php
Normal 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)]);
|
||||
}
|
||||
}
|
||||
368
app/Domain/Auth/Services/Onboarding.php
Normal file
368
app/Domain/Auth/Services/Onboarding.php
Normal 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;
|
||||
}
|
||||
}
|
||||
56
app/Domain/Auth/Services/UserSessionBuilder.php
Normal file
56
app/Domain/Auth/Services/UserSessionBuilder.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 />
|
||||
63
app/Domain/Auth/Templates/login.blade.php
Normal file
63
app/Domain/Auth/Templates/login.blade.php
Normal 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
|
||||
44
app/Domain/Auth/Templates/partials/loginInfo.blade.php
Normal file
44
app/Domain/Auth/Templates/partials/loginInfo.blade.php
Normal 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')
|
||||
38
app/Domain/Auth/Templates/partials/tokens.blade.php
Normal file
38
app/Domain/Auth/Templates/partials/tokens.blade.php
Normal 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
|
||||
32
app/Domain/Auth/Templates/requestPwLink.blade.php
Normal file
32
app/Domain/Auth/Templates/requestPwLink.blade.php
Normal 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
|
||||
51
app/Domain/Auth/Templates/resetPw.blade.php
Normal file
51
app/Domain/Auth/Templates/resetPw.blade.php
Normal 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
|
||||
10
app/Domain/Auth/Templates/token-created.blade.php
Normal file
10
app/Domain/Auth/Templates/token-created.blade.php
Normal 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>
|
||||
20
app/Domain/Auth/Templates/tokenNew.blade.php
Normal file
20
app/Domain/Auth/Templates/tokenNew.blade.php
Normal 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>
|
||||
52
app/Domain/Auth/Templates/userInvite.blade.php
Normal file
52
app/Domain/Auth/Templates/userInvite.blade.php
Normal 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
|
||||
63
app/Domain/Auth/Templates/userInvite2.blade.php
Normal file
63
app/Domain/Auth/Templates/userInvite2.blade.php
Normal 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
|
||||
61
app/Domain/Auth/Templates/userInvite3.blade.php
Normal file
61
app/Domain/Auth/Templates/userInvite3.blade.php
Normal 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
|
||||
200
app/Domain/Auth/Templates/userInvite4.blade.php
Normal file
200
app/Domain/Auth/Templates/userInvite4.blade.php
Normal 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
|
||||
42
app/Domain/Auth/Templates/userInvite5.blade.php
Normal file
42
app/Domain/Auth/Templates/userInvite5.blade.php
Normal 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
|
||||
62
app/Domain/Auth/register.php
Normal file
62
app/Domain/Auth/register.php
Normal 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>
|
||||
';
|
||||
});
|
||||
Reference in New Issue
Block a user