Track a user's last activity
A user will have an accessedAt timestamp that will update at most once per day if they make some API call. This timestamp can then be used find active users and calculate daily, weekly, and monthly active users. To ensure consistent updates to the user the $user from the resource is always updated to the user making the request, including requests like Create Account, Update Team Membership Status, and Create Phone Session (confirmation). This ensures the shutdown can update the accessedAt timestamp if there was a $user set.
This commit is contained in:
parent
5c3f96289d
commit
2befa60350
|
@ -1463,7 +1463,18 @@ $collections = [
|
|||
'default' => null,
|
||||
'array' => false,
|
||||
'filters' => ['userSearch'],
|
||||
]
|
||||
],
|
||||
[
|
||||
'$id' => ID::custom('accessedAt'),
|
||||
'type' => Database::VAR_DATETIME,
|
||||
'format' => '',
|
||||
'size' => 0,
|
||||
'signed' => false,
|
||||
'required' => false,
|
||||
'default' => null,
|
||||
'array' => false,
|
||||
'filters' => ['datetime'],
|
||||
],
|
||||
],
|
||||
'indexes' => [
|
||||
[
|
||||
|
@ -1528,7 +1539,14 @@ $collections = [
|
|||
'attributes' => ['search'],
|
||||
'lengths' => [],
|
||||
'orders' => [],
|
||||
]
|
||||
],
|
||||
[
|
||||
'$id' => '_key_accessedAt',
|
||||
'type' => Database::INDEX_KEY,
|
||||
'attributes' => ['accessedAt'],
|
||||
'lengths' => [],
|
||||
'orders' => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
@ -70,10 +70,11 @@ App::post('/v1/account')
|
|||
->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
|
||||
->inject('request')
|
||||
->inject('response')
|
||||
->inject('user')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('events')
|
||||
->action(function (string $userId, string $email, string $password, string $name, Request $request, Response $response, Document $project, Database $dbForProject, Event $events) {
|
||||
->action(function (string $userId, string $email, string $password, string $name, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Event $events) {
|
||||
$email = \strtolower($email);
|
||||
if ('console' === $project->getId()) {
|
||||
$whitelistEmails = $project->getAttribute('authWhitelistEmails');
|
||||
|
@ -102,7 +103,7 @@ App::post('/v1/account')
|
|||
$password = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS);
|
||||
try {
|
||||
$userId = $userId == 'unique()' ? ID::unique() : $userId;
|
||||
$user = Authorization::skip(fn() => $dbForProject->createDocument('users', new Document([
|
||||
$user->setAttributes([
|
||||
'$id' => $userId,
|
||||
'$permissions' => [
|
||||
Permission::read(Role::any()),
|
||||
|
@ -124,8 +125,10 @@ App::post('/v1/account')
|
|||
'sessions' => null,
|
||||
'tokens' => null,
|
||||
'memberships' => null,
|
||||
'search' => implode(' ', [$userId, $email, $name])
|
||||
])));
|
||||
'search' => implode(' ', [$userId, $email, $name]),
|
||||
'accessedAt' => DateTime::now(), // Add this here to make sure it's returned in the response
|
||||
]);
|
||||
Authorization::skip(fn() => $dbForProject->createDocument('users', $user));
|
||||
} catch (Duplicate $th) {
|
||||
throw new Exception(Exception::USER_ALREADY_EXISTS);
|
||||
}
|
||||
|
@ -166,12 +169,13 @@ App::post('/v1/account/sessions/email')
|
|||
->param('password', '', new Password(), 'User password. Must be at least 8 chars.')
|
||||
->inject('request')
|
||||
->inject('response')
|
||||
->inject('user')
|
||||
->inject('dbForProject')
|
||||
->inject('project')
|
||||
->inject('locale')
|
||||
->inject('geodb')
|
||||
->inject('events')
|
||||
->action(function (string $email, string $password, Request $request, Response $response, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $events) {
|
||||
->action(function (string $email, string $password, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $events) {
|
||||
|
||||
$email = \strtolower($email);
|
||||
$protocol = $request->getProtocol();
|
||||
|
@ -188,6 +192,8 @@ App::post('/v1/account/sessions/email')
|
|||
throw new Exception(Exception::USER_BLOCKED); // User is in status blocked
|
||||
}
|
||||
|
||||
$user->setAttributes($profile->getArrayCopy());
|
||||
|
||||
$duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
|
||||
|
||||
$detector = new Detector($request->getUserAgent('UNKNOWN'));
|
||||
|
@ -197,8 +203,8 @@ App::post('/v1/account/sessions/email')
|
|||
$session = new Document(array_merge(
|
||||
[
|
||||
'$id' => ID::unique(),
|
||||
'userId' => $profile->getId(),
|
||||
'userInternalId' => $profile->getInternalId(),
|
||||
'userId' => $user->getId(),
|
||||
'userInternalId' => $user->getInternalId(),
|
||||
'provider' => Auth::SESSION_PROVIDER_EMAIL,
|
||||
'providerUid' => $email,
|
||||
'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak
|
||||
|
@ -211,35 +217,35 @@ App::post('/v1/account/sessions/email')
|
|||
$detector->getDevice()
|
||||
));
|
||||
|
||||
Authorization::setRole(Role::user($profile->getId())->toString());
|
||||
Authorization::setRole(Role::user($user->getId())->toString());
|
||||
|
||||
// Re-hash if not using recommended algo
|
||||
if ($profile->getAttribute('hash') !== Auth::DEFAULT_ALGO) {
|
||||
$profile
|
||||
if ($user->getAttribute('hash') !== Auth::DEFAULT_ALGO) {
|
||||
$user
|
||||
->setAttribute('password', Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS))
|
||||
->setAttribute('hash', Auth::DEFAULT_ALGO)
|
||||
->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS);
|
||||
$dbForProject->updateDocument('users', $profile->getId(), $profile);
|
||||
$dbForProject->updateDocument('users', $user->getId(), $user);
|
||||
}
|
||||
|
||||
$dbForProject->deleteCachedDocument('users', $profile->getId());
|
||||
$dbForProject->deleteCachedDocument('users', $user->getId());
|
||||
|
||||
$session = $dbForProject->createDocument('sessions', $session->setAttribute('$permissions', [
|
||||
Permission::read(Role::user($profile->getId())),
|
||||
Permission::update(Role::user($profile->getId())),
|
||||
Permission::delete(Role::user($profile->getId())),
|
||||
Permission::read(Role::user($user->getId())),
|
||||
Permission::update(Role::user($user->getId())),
|
||||
Permission::delete(Role::user($user->getId())),
|
||||
]));
|
||||
|
||||
|
||||
if (!Config::getParam('domainVerification')) {
|
||||
$response
|
||||
->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($profile->getId(), $secret)]))
|
||||
->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)]))
|
||||
;
|
||||
}
|
||||
|
||||
$response
|
||||
->addCookie(Auth::$cookieName . '_legacy', Auth::encodeSession($profile->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
|
||||
->addCookie(Auth::$cookieName, Auth::encodeSession($profile->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
|
||||
->addCookie(Auth::$cookieName . '_legacy', Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
|
||||
->addCookie(Auth::$cookieName, Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
|
||||
->setStatusCode(Response::STATUS_CODE_CREATED)
|
||||
;
|
||||
|
||||
|
@ -252,7 +258,7 @@ App::post('/v1/account/sessions/email')
|
|||
;
|
||||
|
||||
$events
|
||||
->setParam('userId', $profile->getId())
|
||||
->setParam('userId', $user->getId())
|
||||
->setParam('sessionId', $session->getId())
|
||||
;
|
||||
|
||||
|
@ -476,10 +482,15 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
|
|||
}
|
||||
}
|
||||
|
||||
$user = ($user->isEmpty()) ? $dbForProject->findOne('sessions', [ // Get user by provider id
|
||||
Query::equal('provider', [$provider]),
|
||||
Query::equal('providerUid', [$oauth2ID]),
|
||||
]) : $user;
|
||||
if ($user->isEmpty()) {
|
||||
$session = $dbForProject->findOne('sessions', [ // Get user by provider id
|
||||
Query::equal('provider', [$provider]),
|
||||
Query::equal('providerUid', [$oauth2ID]),
|
||||
]);
|
||||
if ($session !== false && !$session->isEmpty()) {
|
||||
$user->setAttributes($dbForProject->getDocument('users', $session->getAttribute('userId'))->getArrayCopy());
|
||||
}
|
||||
}
|
||||
|
||||
if ($user === false || $user->isEmpty()) { // No user logged in or with OAuth2 provider ID, create new one or connect with account with same email
|
||||
$name = $oauth2->getUserName($accessToken);
|
||||
|
@ -490,9 +501,12 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
|
|||
*/
|
||||
$isVerified = $oauth2->isEmailVerified($accessToken);
|
||||
|
||||
$user = $dbForProject->findOne('users', [
|
||||
$userWithEmail = $dbForProject->findOne('users', [
|
||||
Query::equal('email', [$email]),
|
||||
]);
|
||||
if ($userWithEmail !== false && !$userWithEmail->isEmpty()) {
|
||||
$user->setAttributes($userWithEmail->getArrayCopy());
|
||||
}
|
||||
|
||||
if ($user === false || $user->isEmpty()) { // Last option -> create the user, generate random password
|
||||
$limit = $project->getAttribute('auths', [])['limit'] ?? 0;
|
||||
|
@ -510,7 +524,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
|
|||
try {
|
||||
$userId = ID::unique();
|
||||
$password = Auth::passwordHash(Auth::passwordGenerator(), Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS);
|
||||
$user = Authorization::skip(fn() => $dbForProject->createDocument('users', new Document([
|
||||
$user->setAttributes([
|
||||
'$id' => $userId,
|
||||
'$permissions' => [
|
||||
Permission::read(Role::any()),
|
||||
|
@ -533,7 +547,8 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
|
|||
'tokens' => null,
|
||||
'memberships' => null,
|
||||
'search' => implode(' ', [$userId, $email, $name])
|
||||
])));
|
||||
]);
|
||||
Authorization::skip(fn() => $dbForProject->createDocument('users', $user));
|
||||
} catch (Duplicate $th) {
|
||||
throw new Exception(Exception::USER_ALREADY_EXISTS);
|
||||
}
|
||||
|
@ -645,12 +660,13 @@ App::post('/v1/account/sessions/magic-url')
|
|||
->param('url', '', fn($clients) => new Host($clients), 'URL to redirect the user back to your app from the magic URL login. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['clients'])
|
||||
->inject('request')
|
||||
->inject('response')
|
||||
->inject('user')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('locale')
|
||||
->inject('events')
|
||||
->inject('mails')
|
||||
->action(function (string $userId, string $email, string $url, Request $request, Response $response, Document $project, Database $dbForProject, Locale $locale, Event $events, Mail $mails) {
|
||||
->action(function (string $userId, string $email, string $url, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Locale $locale, Event $events, Mail $mails) {
|
||||
|
||||
if (empty(App::getEnv('_APP_SMTP_HOST'))) {
|
||||
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP disabled');
|
||||
|
@ -660,9 +676,10 @@ App::post('/v1/account/sessions/magic-url')
|
|||
$isPrivilegedUser = Auth::isPrivilegedUser($roles);
|
||||
$isAppUser = Auth::isAppUser($roles);
|
||||
|
||||
$user = $dbForProject->findOne('users', [Query::equal('email', [$email])]);
|
||||
|
||||
if (!$user) {
|
||||
$result = $dbForProject->findOne('users', [Query::equal('email', [$email])]);
|
||||
if ($result !== false && !$result->isEmpty()) {
|
||||
$user->setAttributes($result->getArrayCopy());
|
||||
} else {
|
||||
$limit = $project->getAttribute('auths', [])['limit'] ?? 0;
|
||||
|
||||
if ($limit !== 0) {
|
||||
|
@ -675,7 +692,7 @@ App::post('/v1/account/sessions/magic-url')
|
|||
|
||||
$userId = $userId == 'unique()' ? ID::unique() : $userId;
|
||||
|
||||
$user = Authorization::skip(fn () => $dbForProject->createDocument('users', new Document([
|
||||
$user->setAttributes([
|
||||
'$id' => $userId,
|
||||
'$permissions' => [
|
||||
Permission::read(Role::any()),
|
||||
|
@ -696,7 +713,9 @@ App::post('/v1/account/sessions/magic-url')
|
|||
'tokens' => null,
|
||||
'memberships' => null,
|
||||
'search' => implode(' ', [$userId, $email])
|
||||
])));
|
||||
]);
|
||||
|
||||
Authorization::skip(fn () => $dbForProject->createDocument('users', $user));
|
||||
}
|
||||
|
||||
$loginSecret = Auth::tokenGenerator();
|
||||
|
@ -808,27 +827,30 @@ App::put('/v1/account/sessions/magic-url')
|
|||
->param('secret', '', new Text(256), 'Valid verification token.')
|
||||
->inject('request')
|
||||
->inject('response')
|
||||
->inject('user')
|
||||
->inject('dbForProject')
|
||||
->inject('project')
|
||||
->inject('locale')
|
||||
->inject('geodb')
|
||||
->inject('events')
|
||||
->action(function (string $userId, string $secret, Request $request, Response $response, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $events) {
|
||||
->action(function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $events) {
|
||||
|
||||
/** @var Utopia\Database\Document $user */
|
||||
|
||||
$user = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId));
|
||||
$userFromRequest = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId));
|
||||
|
||||
if ($user->isEmpty()) {
|
||||
if ($userFromRequest->isEmpty()) {
|
||||
throw new Exception(Exception::USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
$token = Auth::tokenVerify($user->getAttribute('tokens', []), Auth::TOKEN_TYPE_MAGIC_URL, $secret);
|
||||
$token = Auth::tokenVerify($userFromRequest->getAttribute('tokens', []), Auth::TOKEN_TYPE_MAGIC_URL, $secret);
|
||||
|
||||
if (!$token) {
|
||||
throw new Exception(Exception::USER_INVALID_TOKEN);
|
||||
}
|
||||
|
||||
$user->setAttributes($userFromRequest->getArrayCopy());
|
||||
|
||||
$duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
|
||||
$detector = new Detector($request->getUserAgent('UNKNOWN'));
|
||||
$record = $geodb->get($request->getIP());
|
||||
|
@ -873,9 +895,9 @@ App::put('/v1/account/sessions/magic-url')
|
|||
|
||||
$user->setAttribute('emailVerification', true);
|
||||
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
|
||||
|
||||
if (false === $user) {
|
||||
try {
|
||||
$dbForProject->updateDocument('users', $user->getId(), $user);
|
||||
} catch (\Throwable $th) {
|
||||
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed saving user to DB');
|
||||
}
|
||||
|
||||
|
@ -928,12 +950,13 @@ App::post('/v1/account/sessions/phone')
|
|||
->param('phone', '', new Phone(), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.')
|
||||
->inject('request')
|
||||
->inject('response')
|
||||
->inject('user')
|
||||
->inject('project')
|
||||
->inject('dbForProject')
|
||||
->inject('events')
|
||||
->inject('messaging')
|
||||
->inject('locale')
|
||||
->action(function (string $userId, string $phone, Request $request, Response $response, Document $project, Database $dbForProject, Event $events, EventPhone $messaging, Locale $locale) {
|
||||
->action(function (string $userId, string $phone, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Event $events, EventPhone $messaging, Locale $locale) {
|
||||
|
||||
if (empty(App::getEnv('_APP_SMS_PROVIDER'))) {
|
||||
throw new Exception(Exception::GENERAL_PHONE_DISABLED, 'Phone provider not configured');
|
||||
|
@ -943,9 +966,10 @@ App::post('/v1/account/sessions/phone')
|
|||
$isPrivilegedUser = Auth::isPrivilegedUser($roles);
|
||||
$isAppUser = Auth::isAppUser($roles);
|
||||
|
||||
$user = $dbForProject->findOne('users', [Query::equal('phone', [$phone])]);
|
||||
|
||||
if (!$user) {
|
||||
$result = $dbForProject->findOne('users', [Query::equal('phone', [$phone])]);
|
||||
if ($result !== false && !$result->isEmpty()) {
|
||||
$user->setAttributes($result->getArrayCopy());
|
||||
} else {
|
||||
$limit = $project->getAttribute('auths', [])['limit'] ?? 0;
|
||||
|
||||
if ($limit !== 0) {
|
||||
|
@ -957,8 +981,7 @@ App::post('/v1/account/sessions/phone')
|
|||
}
|
||||
|
||||
$userId = $userId == 'unique()' ? ID::unique() : $userId;
|
||||
|
||||
$user = Authorization::skip(fn () => $dbForProject->createDocument('users', new Document([
|
||||
$user->setAttributes([
|
||||
'$id' => $userId,
|
||||
'$permissions' => [
|
||||
Permission::read(Role::any()),
|
||||
|
@ -979,7 +1002,9 @@ App::post('/v1/account/sessions/phone')
|
|||
'tokens' => null,
|
||||
'memberships' => null,
|
||||
'search' => implode(' ', [$userId, $phone])
|
||||
])));
|
||||
]);
|
||||
|
||||
Authorization::skip(fn () => $dbForProject->createDocument('users', $user));
|
||||
}
|
||||
|
||||
$secret = Auth::codeGenerator();
|
||||
|
@ -1058,25 +1083,28 @@ App::put('/v1/account/sessions/phone')
|
|||
->param('secret', '', new Text(256), 'Valid verification token.')
|
||||
->inject('request')
|
||||
->inject('response')
|
||||
->inject('user')
|
||||
->inject('dbForProject')
|
||||
->inject('project')
|
||||
->inject('locale')
|
||||
->inject('geodb')
|
||||
->inject('events')
|
||||
->action(function (string $userId, string $secret, Request $request, Response $response, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $events) {
|
||||
->action(function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $events) {
|
||||
|
||||
$user = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId));
|
||||
$userFromRequest = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId));
|
||||
|
||||
if ($user->isEmpty()) {
|
||||
if ($userFromRequest->isEmpty()) {
|
||||
throw new Exception(Exception::USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
$token = Auth::phoneTokenVerify($user->getAttribute('tokens', []), $secret);
|
||||
$token = Auth::phoneTokenVerify($userFromRequest->getAttribute('tokens', []), $secret);
|
||||
|
||||
if (!$token) {
|
||||
throw new Exception(Exception::USER_INVALID_TOKEN);
|
||||
}
|
||||
|
||||
$user->setAttributes($userFromRequest->getArrayCopy());
|
||||
|
||||
$duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
|
||||
$detector = new Detector($request->getUserAgent('UNKNOWN'));
|
||||
$record = $geodb->get($request->getIP());
|
||||
|
@ -1119,7 +1147,7 @@ App::put('/v1/account/sessions/phone')
|
|||
|
||||
$user->setAttribute('phoneVerification', true);
|
||||
|
||||
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
|
||||
$dbForProject->updateDocument('users', $user->getId(), $user);
|
||||
|
||||
if (false === $user) {
|
||||
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed saving user to DB');
|
||||
|
@ -1204,7 +1232,7 @@ App::post('/v1/account/sessions/anonymous')
|
|||
}
|
||||
|
||||
$userId = ID::unique();
|
||||
$user = Authorization::skip(fn() => $dbForProject->createDocument('users', new Document([
|
||||
$user->setAttributes([
|
||||
'$id' => $userId,
|
||||
'$permissions' => [
|
||||
Permission::read(Role::any()),
|
||||
|
@ -1225,8 +1253,9 @@ App::post('/v1/account/sessions/anonymous')
|
|||
'sessions' => null,
|
||||
'tokens' => null,
|
||||
'memberships' => null,
|
||||
'search' => $userId
|
||||
])));
|
||||
'search' => $userId,
|
||||
]);
|
||||
Authorization::skip(fn() => $dbForProject->createDocument('users', $user));
|
||||
|
||||
// Create session token
|
||||
$duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
|
||||
|
@ -2068,12 +2097,13 @@ App::post('/v1/account/recovery')
|
|||
->param('url', '', fn ($clients) => new Host($clients), 'URL to redirect the user back to your app from the recovery email. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', false, ['clients'])
|
||||
->inject('request')
|
||||
->inject('response')
|
||||
->inject('user')
|
||||
->inject('dbForProject')
|
||||
->inject('project')
|
||||
->inject('locale')
|
||||
->inject('mails')
|
||||
->inject('events')
|
||||
->action(function (string $email, string $url, Request $request, Response $response, Database $dbForProject, Document $project, Locale $locale, Mail $mails, Event $events) {
|
||||
->action(function (string $email, string $url, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Mail $mails, Event $events) {
|
||||
|
||||
if (empty(App::getEnv('_APP_SMTP_HOST'))) {
|
||||
throw new Exception(Exception::GENERAL_SMTP_DISABLED, 'SMTP Disabled');
|
||||
|
@ -2093,6 +2123,8 @@ App::post('/v1/account/recovery')
|
|||
throw new Exception(Exception::USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
$user->setAttributes($profile->getArrayCopy());
|
||||
|
||||
if (false === $profile->getAttribute('status')) { // Account is blocked
|
||||
throw new Exception(Exception::USER_BLOCKED);
|
||||
}
|
||||
|
@ -2207,9 +2239,10 @@ App::put('/v1/account/recovery')
|
|||
->param('password', '', new Password(), 'New user password. Must be at least 8 chars.')
|
||||
->param('passwordAgain', '', new Password(), 'Repeat new user password. Must be at least 8 chars.')
|
||||
->inject('response')
|
||||
->inject('user')
|
||||
->inject('dbForProject')
|
||||
->inject('events')
|
||||
->action(function (string $userId, string $secret, string $password, string $passwordAgain, Response $response, Database $dbForProject, Event $events) {
|
||||
->action(function (string $userId, string $secret, string $password, string $passwordAgain, Response $response, Document $user, Database $dbForProject, Event $events) {
|
||||
if ($password !== $passwordAgain) {
|
||||
throw new Exception(Exception::USER_PASSWORD_MISMATCH);
|
||||
}
|
||||
|
@ -2236,6 +2269,8 @@ App::put('/v1/account/recovery')
|
|||
->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS)
|
||||
->setAttribute('emailVerification', true));
|
||||
|
||||
$user->setAttributes($profile->getArrayCopy());
|
||||
|
||||
$recoveryDocument = $dbForProject->getDocument('tokens', $recovery);
|
||||
|
||||
/**
|
||||
|
@ -2417,6 +2452,8 @@ App::put('/v1/account/verification')
|
|||
|
||||
$profile = $dbForProject->updateDocument('users', $profile->getId(), $profile->setAttribute('emailVerification', true));
|
||||
|
||||
$user->setAttributes($profile->getArrayCopy());
|
||||
|
||||
$verificationDocument = $dbForProject->getDocument('tokens', $verification);
|
||||
|
||||
/**
|
||||
|
@ -2573,6 +2610,8 @@ App::put('/v1/account/verification/phone')
|
|||
|
||||
$profile = $dbForProject->updateDocument('users', $profile->getId(), $profile->setAttribute('phoneVerification', true));
|
||||
|
||||
$user->setAttributes($profile->getArrayCopy());
|
||||
|
||||
$verificationDocument = $dbForProject->getDocument('tokens', $verification);
|
||||
|
||||
/**
|
||||
|
|
|
@ -867,7 +867,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status')
|
|||
}
|
||||
|
||||
if ($user->isEmpty()) {
|
||||
$user = $dbForProject->getDocument('users', $userId); // Get user
|
||||
$user->setAttributes($dbForProject->getDocument('users', $userId)->getArrayCopy()); // Get user
|
||||
}
|
||||
|
||||
if ($membership->getAttribute('userId') !== $user->getId()) {
|
||||
|
@ -883,7 +883,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status')
|
|||
->setAttribute('confirm', true)
|
||||
;
|
||||
|
||||
$user = Authorization::skip(fn() => $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('emailVerification', true)));
|
||||
Authorization::skip(fn() => $dbForProject->updateDocument('users', $user->getId(), $user->setAttribute('emailVerification', true)));
|
||||
|
||||
// Log user in
|
||||
|
||||
|
|
|
@ -169,9 +169,9 @@ App::init()
|
|||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Background Jobs
|
||||
*/
|
||||
/*
|
||||
* Background Jobs
|
||||
*/
|
||||
$events
|
||||
->setEvent($route->getLabel('event', ''))
|
||||
->setProject($project)
|
||||
|
@ -369,6 +369,7 @@ App::shutdown()
|
|||
->inject('request')
|
||||
->inject('response')
|
||||
->inject('project')
|
||||
->inject('user')
|
||||
->inject('events')
|
||||
->inject('audits')
|
||||
->inject('usage')
|
||||
|
@ -376,8 +377,8 @@ App::shutdown()
|
|||
->inject('database')
|
||||
->inject('mode')
|
||||
->inject('dbForProject')
|
||||
->action(function (App $utopia, Request $request, Response $response, Document $project, Event $events, Audit $audits, Stats $usage, Delete $deletes, EventDatabase $database, string $mode, Database $dbForProject) use ($parseLabel) {
|
||||
|
||||
->inject('dbForConsole')
|
||||
->action(function (App $utopia, Request $request, Response $response, Document $project, Document $user, Event $events, Audit $audits, Stats $usage, Delete $deletes, EventDatabase $database, string $mode, Database $dbForProject, Database $dbForConsole) use ($parseLabel) {
|
||||
$responsePayload = $response->getPayload();
|
||||
|
||||
if (!empty($events->getEvent())) {
|
||||
|
@ -437,7 +438,6 @@ App::shutdown()
|
|||
|
||||
$route = $utopia->match($request);
|
||||
$requestParams = $route->getParamsValues();
|
||||
$user = $audits->getUser();
|
||||
|
||||
/**
|
||||
* Audit labels
|
||||
|
@ -450,10 +450,7 @@ App::shutdown()
|
|||
}
|
||||
}
|
||||
|
||||
$pattern = $route->getLabel('audits.userId', null);
|
||||
if (!empty($pattern)) {
|
||||
$userId = $parseLabel($pattern, $responsePayload, $requestParams, $user);
|
||||
$user = $dbForProject->getDocument('users', $userId);
|
||||
if (!$user->isEmpty()) {
|
||||
$audits->setUser($user);
|
||||
}
|
||||
|
||||
|
@ -564,6 +561,22 @@ App::shutdown()
|
|||
->setParam('project.{scope}.network.outbound', $response->getSize())
|
||||
->submit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user last activity
|
||||
*/
|
||||
if (!$user->isEmpty()) {
|
||||
$accessedAt = $user->getAttribute('accessedAt', '');
|
||||
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_USER_ACCCESS)) > $accessedAt) {
|
||||
$user->setAttribute('accessedAt', DateTime::now());
|
||||
|
||||
if (APP_MODE_ADMIN !== $mode) {
|
||||
$dbForProject->updateDocument('users', $user->getId(), $user);
|
||||
} else {
|
||||
$dbForConsole->updateDocument('users', $user->getId(), $user);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
App::init()
|
||||
|
|
|
@ -100,6 +100,7 @@ const APP_LIMIT_WRITE_RATE_DEFAULT = 60; // Default maximum write rate per rate
|
|||
const APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT = 60; // Default maximum write rate period in seconds
|
||||
const APP_LIMIT_LIST_DEFAULT = 25; // Default maximum number of items to return in list API calls
|
||||
const APP_KEY_ACCCESS = 24 * 60 * 60; // 24 hours
|
||||
const APP_USER_ACCCESS = 24 * 60 * 60; // 24 hours
|
||||
const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours
|
||||
const APP_CACHE_BUSTER = 506;
|
||||
const APP_VERSION_STABLE = '1.3.7';
|
||||
|
|
|
@ -43,13 +43,13 @@
|
|||
"ext-sockets": "*",
|
||||
"appwrite/php-clamav": "1.1.*",
|
||||
"appwrite/php-runtimes": "0.11.*",
|
||||
"utopia-php/abuse": "0.26.*",
|
||||
"utopia-php/abuse": "0.27.*",
|
||||
"utopia-php/analytics": "0.2.*",
|
||||
"utopia-php/audit": "0.28.*",
|
||||
"utopia-php/audit": "0.29.*",
|
||||
"utopia-php/cache": "0.8.*",
|
||||
"utopia-php/cli": "0.13.*",
|
||||
"utopia-php/config": "0.2.*",
|
||||
"utopia-php/database": "0.37.*",
|
||||
"utopia-php/database": "0.38.*",
|
||||
"utopia-php/domains": "1.1.*",
|
||||
"utopia-php/framework": "0.28.*",
|
||||
"utopia-php/image": "0.5.*",
|
||||
|
|
56
composer.lock
generated
56
composer.lock
generated
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "0f20fb41e9b250b6763af1b734bb8d2d",
|
||||
"content-hash": "0299a46dacaa5ae6607931f91b459db4",
|
||||
"packages": [
|
||||
{
|
||||
"name": "adhocore/jwt",
|
||||
|
@ -1802,23 +1802,23 @@
|
|||
},
|
||||
{
|
||||
"name": "utopia-php/abuse",
|
||||
"version": "0.26.0",
|
||||
"version": "0.27.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/abuse.git",
|
||||
"reference": "fb73180f0588bc8826b85d433393b983bdc37cfa"
|
||||
"reference": "d1115f5843e903ffaba9c23e450b33c0fe265ae0"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/utopia-php/abuse/zipball/fb73180f0588bc8826b85d433393b983bdc37cfa",
|
||||
"reference": "fb73180f0588bc8826b85d433393b983bdc37cfa",
|
||||
"url": "https://api.github.com/repos/utopia-php/abuse/zipball/d1115f5843e903ffaba9c23e450b33c0fe265ae0",
|
||||
"reference": "d1115f5843e903ffaba9c23e450b33c0fe265ae0",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-curl": "*",
|
||||
"ext-pdo": "*",
|
||||
"php": ">=8.0",
|
||||
"utopia-php/database": "0.37.*"
|
||||
"utopia-php/database": "0.38.*"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "1.5.*",
|
||||
|
@ -1845,9 +1845,9 @@
|
|||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/utopia-php/abuse/issues",
|
||||
"source": "https://github.com/utopia-php/abuse/tree/0.26.0"
|
||||
"source": "https://github.com/utopia-php/abuse/tree/0.27.0"
|
||||
},
|
||||
"time": "2023-06-15T00:53:36+00:00"
|
||||
"time": "2023-07-15T00:53:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/analytics",
|
||||
|
@ -1906,21 +1906,21 @@
|
|||
},
|
||||
{
|
||||
"name": "utopia-php/audit",
|
||||
"version": "0.28.0",
|
||||
"version": "0.29.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/audit.git",
|
||||
"reference": "abf4124bec20b6ab3555869b64afe5b274e37165"
|
||||
"reference": "5318538f457bf73623629345c98ea06371ca5dd4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/utopia-php/audit/zipball/abf4124bec20b6ab3555869b64afe5b274e37165",
|
||||
"reference": "abf4124bec20b6ab3555869b64afe5b274e37165",
|
||||
"url": "https://api.github.com/repos/utopia-php/audit/zipball/5318538f457bf73623629345c98ea06371ca5dd4",
|
||||
"reference": "5318538f457bf73623629345c98ea06371ca5dd4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.0",
|
||||
"utopia-php/database": "0.37.*"
|
||||
"utopia-php/database": "0.38.*"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "1.5.*",
|
||||
|
@ -1947,9 +1947,9 @@
|
|||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/utopia-php/audit/issues",
|
||||
"source": "https://github.com/utopia-php/audit/tree/0.28.0"
|
||||
"source": "https://github.com/utopia-php/audit/tree/0.29.0"
|
||||
},
|
||||
"time": "2023-06-15T00:52:49+00:00"
|
||||
"time": "2023-07-15T00:51:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/cache",
|
||||
|
@ -2106,16 +2106,16 @@
|
|||
},
|
||||
{
|
||||
"name": "utopia-php/database",
|
||||
"version": "0.37.1",
|
||||
"version": "0.38.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/utopia-php/database.git",
|
||||
"reference": "4035d3f7e3393385eabc7816055047659c6fb4d3"
|
||||
"reference": "59e4684cf87e03c12dab9240158c1dfc6888e534"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/utopia-php/database/zipball/4035d3f7e3393385eabc7816055047659c6fb4d3",
|
||||
"reference": "4035d3f7e3393385eabc7816055047659c6fb4d3",
|
||||
"url": "https://api.github.com/repos/utopia-php/database/zipball/59e4684cf87e03c12dab9240158c1dfc6888e534",
|
||||
"reference": "59e4684cf87e03c12dab9240158c1dfc6888e534",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
@ -2156,9 +2156,9 @@
|
|||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/utopia-php/database/issues",
|
||||
"source": "https://github.com/utopia-php/database/tree/0.37.1"
|
||||
"source": "https://github.com/utopia-php/database/tree/0.38.0"
|
||||
},
|
||||
"time": "2023-06-15T06:36:27+00:00"
|
||||
"time": "2023-07-14T07:49:38+00:00"
|
||||
},
|
||||
{
|
||||
"name": "utopia-php/domains",
|
||||
|
@ -3029,16 +3029,16 @@
|
|||
"packages-dev": [
|
||||
{
|
||||
"name": "appwrite/sdk-generator",
|
||||
"version": "0.33.6",
|
||||
"version": "0.33.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/appwrite/sdk-generator.git",
|
||||
"reference": "237fe97b68090a244382c36f96482c352880a38c"
|
||||
"reference": "9f5db4a637b23879ceacea9ed2d33b0486771ffc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/237fe97b68090a244382c36f96482c352880a38c",
|
||||
"reference": "237fe97b68090a244382c36f96482c352880a38c",
|
||||
"url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/9f5db4a637b23879ceacea9ed2d33b0486771ffc",
|
||||
"reference": "9f5db4a637b23879ceacea9ed2d33b0486771ffc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
@ -3074,9 +3074,9 @@
|
|||
"description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms",
|
||||
"support": {
|
||||
"issues": "https://github.com/appwrite/sdk-generator/issues",
|
||||
"source": "https://github.com/appwrite/sdk-generator/tree/0.33.6"
|
||||
"source": "https://github.com/appwrite/sdk-generator/tree/0.33.7"
|
||||
},
|
||||
"time": "2023-07-10T16:27:53+00:00"
|
||||
"time": "2023-07-12T12:15:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/deprecations",
|
||||
|
@ -5674,5 +5674,5 @@
|
|||
"platform-overrides": {
|
||||
"php": "8.0"
|
||||
},
|
||||
"plugin-api-version": "2.2.0"
|
||||
"plugin-api-version": "2.3.0"
|
||||
}
|
||||
|
|
|
@ -120,6 +120,12 @@ class User extends Model
|
|||
'default' => new \stdClass(),
|
||||
'example' => ['theme' => 'pink', 'timezone' => 'UTC'],
|
||||
])
|
||||
->addRule('accessedAt', [
|
||||
'type' => self::TYPE_DATETIME,
|
||||
'description' => 'Most recent access date in ISO 8601 format.',
|
||||
'default' => '',
|
||||
'example' => self::TYPE_DATETIME_EXAMPLE,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
|
|
|
@ -40,6 +40,8 @@ trait AccountBase
|
|||
$this->assertEquals($response['body']['email'], $email);
|
||||
$this->assertEquals($response['body']['name'], $name);
|
||||
$this->assertEquals($response['body']['labels'], []);
|
||||
$this->assertArrayHasKey('accessedAt', $response['body']);
|
||||
$this->assertNotEmpty($response['body']['accessedAt']);
|
||||
|
||||
/**
|
||||
* Test for FAILURE
|
||||
|
@ -127,6 +129,21 @@ trait AccountBase
|
|||
$sessionId = $response['body']['$id'];
|
||||
$session = $this->client->parseCookie((string)$response['headers']['set-cookie'])['a_session_' . $this->getProject()['$id']];
|
||||
|
||||
// apiKey is only available in custom client test
|
||||
$apiKey = $this->getProject()['apiKey'];
|
||||
if (!empty($apiKey)) {
|
||||
$userId = $response['body']['userId'];
|
||||
$response = $this->client->call(Client::METHOD_GET, '/users/' . $userId, array_merge([
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $apiKey,
|
||||
]));
|
||||
$this->assertEquals($response['headers']['status-code'], 200);
|
||||
$this->assertArrayHasKey('accessedAt', $response['body']);
|
||||
$this->assertNotEmpty($response['body']['accessedAt']);
|
||||
}
|
||||
|
||||
$response = $this->client->call(Client::METHOD_POST, '/account/sessions', array_merge([
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
|
@ -207,6 +224,8 @@ trait AccountBase
|
|||
$this->assertEquals(true, $dateValidator->isValid($response['body']['registration']));
|
||||
$this->assertEquals($response['body']['email'], $email);
|
||||
$this->assertEquals($response['body']['name'], $name);
|
||||
$this->assertArrayHasKey('accessedAt', $response['body']);
|
||||
$this->assertNotEmpty($response['body']['accessedAt']);
|
||||
|
||||
/**
|
||||
* Test for FAILURE
|
||||
|
|
|
@ -369,6 +369,20 @@ class AccountCustomClientTest extends Scope
|
|||
|
||||
$session = $this->client->parseCookie((string)$response['headers']['set-cookie'])['a_session_' . $this->getProject()['$id']];
|
||||
|
||||
\usleep(1000 * 30); // wait for 30ms to let the shutdown update accessedAt
|
||||
|
||||
$apiKey = $this->getProject()['apiKey'];
|
||||
$userId = $response['body']['userId'];
|
||||
$response = $this->client->call(Client::METHOD_GET, '/users/' . $userId, array_merge([
|
||||
'origin' => 'http://localhost',
|
||||
'content-type' => 'application/json',
|
||||
'x-appwrite-project' => $this->getProject()['$id'],
|
||||
'x-appwrite-key' => $apiKey,
|
||||
]));
|
||||
$this->assertEquals($response['headers']['status-code'], 200);
|
||||
$this->assertArrayHasKey('accessedAt', $response['body']);
|
||||
$this->assertNotEmpty($response['body']['accessedAt']);
|
||||
|
||||
/**
|
||||
* Test for FAILURE
|
||||
*/
|
||||
|
|
|
@ -57,7 +57,7 @@ class WebhooksCustomClientTest extends Scope
|
|||
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected);
|
||||
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']);
|
||||
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']);
|
||||
$this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id']), true);
|
||||
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-User-Id'], $id);
|
||||
$this->assertNotEmpty($webhook['data']['$id']);
|
||||
$this->assertEquals($webhook['data']['name'], $name);
|
||||
$dateValidator = new DatetimeValidator();
|
||||
|
@ -195,7 +195,7 @@ class WebhooksCustomClientTest extends Scope
|
|||
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected);
|
||||
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']);
|
||||
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']);
|
||||
$this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id']), true);
|
||||
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-User-Id'], $id);
|
||||
$this->assertNotEmpty($webhook['data']['$id']);
|
||||
$this->assertNotEmpty($webhook['data']['userId']);
|
||||
$dateValidator = new DatetimeValidator();
|
||||
|
@ -744,7 +744,7 @@ class WebhooksCustomClientTest extends Scope
|
|||
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected);
|
||||
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']);
|
||||
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']);
|
||||
$this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id']), true);
|
||||
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-User-Id'], $id);
|
||||
$this->assertNotEmpty($webhook['data']['$id']);
|
||||
$this->assertNotEmpty($webhook['data']['userId']);
|
||||
$this->assertNotEmpty($webhook['data']['secret']);
|
||||
|
@ -923,7 +923,7 @@ class WebhooksCustomClientTest extends Scope
|
|||
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Signature'], $signatureExpected);
|
||||
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $this->getProject()['webhookId']);
|
||||
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Project-Id'] ?? '', $this->getProject()['$id']);
|
||||
$this->assertEquals(empty($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? ''), true);
|
||||
$this->assertEquals($webhook['headers']['X-Appwrite-Webhook-User-Id'] ?? '', $userUid);
|
||||
$this->assertNotEmpty($webhook['data']['$id']);
|
||||
$this->assertNotEmpty($webhook['data']['userId']);
|
||||
$this->assertNotEmpty($webhook['data']['teamId']);
|
||||
|
|
Loading…
Reference in a new issue