Merge branch 'dev' of https://framagit.org/hubzilla/core into xdev_merge
This commit is contained in:
commit
2d29095348
@ -4,7 +4,7 @@ namespace Zotlabs\Identity;
|
|||||||
|
|
||||||
class OAuth2Server extends \OAuth2\Server {
|
class OAuth2Server extends \OAuth2\Server {
|
||||||
|
|
||||||
public function __construct(OAuth2Storage $storage, $config = []) {
|
public function __construct(OAuth2Storage $storage, $config = null) {
|
||||||
|
|
||||||
if(! is_array($config)) {
|
if(! is_array($config)) {
|
||||||
$config = [
|
$config = [
|
||||||
@ -19,7 +19,8 @@ class OAuth2Server extends \OAuth2\Server {
|
|||||||
$this->addGrantType(new \OAuth2\GrantType\ClientCredentials($storage));
|
$this->addGrantType(new \OAuth2\GrantType\ClientCredentials($storage));
|
||||||
|
|
||||||
// Add the "Authorization Code" grant type (this is where the oauth magic happens)
|
// Add the "Authorization Code" grant type (this is where the oauth magic happens)
|
||||||
$this->addGrantType(new \OAuth2\GrantType\AuthorizationCode($storage));
|
// Need to use OpenID\GrantType to return id_token (see:https://github.com/bshaffer/oauth2-server-php/issues/443)
|
||||||
|
$this->addGrantType(new \OAuth2\OpenID\GrantType\AuthorizationCode($storage));
|
||||||
|
|
||||||
$keyStorage = new \OAuth2\Storage\Memory( [
|
$keyStorage = new \OAuth2\Storage\Memory( [
|
||||||
'keys' => [
|
'keys' => [
|
||||||
|
@ -50,20 +50,67 @@ class OAuth2Storage extends \OAuth2\Storage\Pdo {
|
|||||||
public function getUser($username)
|
public function getUser($username)
|
||||||
{
|
{
|
||||||
|
|
||||||
$x = channelx_by_nick($username);
|
$x = channelx_by_n($username);
|
||||||
if(! $x) {
|
if(! $x) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return( [
|
return( [
|
||||||
|
'webbie' => $x['channel_address'].'@'.\App::get_hostname(),
|
||||||
|
'zothash' => $x['channel_hash'],
|
||||||
'username' => $x['channel_address'],
|
'username' => $x['channel_address'],
|
||||||
'user_id' => $x['channel_id'],
|
'user_id' => $x['channel_id'],
|
||||||
|
'name' => $x['channel_name'],
|
||||||
'firstName' => $x['channel_name'],
|
'firstName' => $x['channel_name'],
|
||||||
'lastName' => '',
|
'lastName' => '',
|
||||||
'password' => 'NotARealPassword'
|
'password' => 'NotARealPassword'
|
||||||
] );
|
] );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function scopeExists($scope) {
|
||||||
|
// Report that the scope is valid even if it's not.
|
||||||
|
// We will only return a very small subset no matter what.
|
||||||
|
// @TODO: Truly validate the scope
|
||||||
|
// see vendor/bshaffer/oauth2-server-php/src/OAuth2/Storage/ScopeInterface.php and
|
||||||
|
// vendor/bshaffer/oauth2-server-php/src/OAuth2/Storage/Pdo.php
|
||||||
|
// for more info.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDefaultScope($client_id=null) {
|
||||||
|
// Do not REQUIRE a scope
|
||||||
|
// see vendor/bshaffer/oauth2-server-php/src/OAuth2/Storage/ScopeInterface.php and
|
||||||
|
// for more info.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserClaims ($user_id, $claims) {
|
||||||
|
// Populate the CLAIMS requested (if any).
|
||||||
|
// @TODO: create a more reasonable/comprehensive list.
|
||||||
|
// @TODO: present claims on the AUTHORIZATION screen
|
||||||
|
|
||||||
|
$userClaims = Array();
|
||||||
|
$claims = explode (' ', trim($claims));
|
||||||
|
$validclaims = Array ("name","preferred_username","zothash");
|
||||||
|
$claimsmap = Array (
|
||||||
|
"zotwebbie" => 'webbie',
|
||||||
|
"zothash" => 'zothash',
|
||||||
|
"name" => 'name',
|
||||||
|
"preferred_username" => "username"
|
||||||
|
);
|
||||||
|
$userinfo = $this->getUser($user_id);
|
||||||
|
foreach ($validclaims as $validclaim) {
|
||||||
|
if (in_array($validclaim,$claims)) {
|
||||||
|
$claimkey = $claimsmap[$validclaim];
|
||||||
|
$userClaims[$validclaim] = $userinfo[$claimkey];
|
||||||
|
} else {
|
||||||
|
$userClaims[$validclaim] = $validclaim;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$userClaims["sub"]=$user_id;
|
||||||
|
return $userClaims;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* plaintext passwords are bad! Override this for your application
|
* plaintext passwords are bad! Override this for your application
|
||||||
*
|
*
|
||||||
@ -78,4 +125,4 @@ class OAuth2Storage extends \OAuth2\Storage\Pdo {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -332,7 +332,7 @@ class Site {
|
|||||||
'$register_policy' => array('register_policy', t("Does this site allow new member registration?"), get_config('system','register_policy'), "", $register_choices),
|
'$register_policy' => array('register_policy', t("Does this site allow new member registration?"), get_config('system','register_policy'), "", $register_choices),
|
||||||
'$invite_only' => array('invite_only', t("Invitation only"), get_config('system','invitation_only'), t("Only allow new member registrations with an invitation code. Above register policy must be set to Yes.")),
|
'$invite_only' => array('invite_only', t("Invitation only"), get_config('system','invitation_only'), t("Only allow new member registrations with an invitation code. Above register policy must be set to Yes.")),
|
||||||
'$minimum_age' => array('minimum_age', t("Minimum age"), (x(get_config('system','minimum_age'))?get_config('system','minimum_age'):13), t("Minimum age (in years) for who may register on this site.")),
|
'$minimum_age' => array('minimum_age', t("Minimum age"), (x(get_config('system','minimum_age'))?get_config('system','minimum_age'):13), t("Minimum age (in years) for who may register on this site.")),
|
||||||
'$access_policy' => array('access_policy', t("Which best describes the types of account offered by this hub?"), get_config('system','access_policy'), "This is displayed on the public server site list.", $access_choices),
|
'$access_policy' => array('access_policy', t("Which best describes the types of account offered by this hub?"), get_config('system','access_policy'), t("This is displayed on the public server site list."), $access_choices),
|
||||||
'$register_text' => array('register_text', t("Register text"), htmlspecialchars(get_config('system','register_text'), ENT_QUOTES, 'UTF-8'), t("Will be displayed prominently on the registration page.")),
|
'$register_text' => array('register_text', t("Register text"), htmlspecialchars(get_config('system','register_text'), ENT_QUOTES, 'UTF-8'), t("Will be displayed prominently on the registration page.")),
|
||||||
'$role' => $role,
|
'$role' => $role,
|
||||||
'$frontpage' => array('frontpage', t("Site homepage to show visitors (default: login box)"), get_config('system','frontpage'), t("example: 'public' to show public stream, 'page/sys/home' to show a system webpage called 'home' or 'include:home.html' to include a file.")),
|
'$frontpage' => array('frontpage', t("Site homepage to show visitors (default: login box)"), get_config('system','frontpage'), t("example: 'public' to show public stream, 'page/sys/home' to show a system webpage called 'home' or 'include:home.html' to include a file.")),
|
||||||
|
@ -60,12 +60,16 @@ class Authorize extends \Zotlabs\Web\Controller {
|
|||||||
$request = \OAuth2\Request::createFromGlobals();
|
$request = \OAuth2\Request::createFromGlobals();
|
||||||
$response = new \OAuth2\Response();
|
$response = new \OAuth2\Response();
|
||||||
|
|
||||||
|
// Note, "sub" field must match type and content. $user_id is used to populate - make sure it's a string.
|
||||||
|
$channel = channelx_by_n(local_channel());
|
||||||
|
$user_id = $channel["channel_id"];
|
||||||
|
|
||||||
// If the client is not registered, add to the database
|
// If the client is not registered, add to the database
|
||||||
if (!$client = $storage->getClientDetails($client_id)) {
|
if (!$client = $storage->getClientDetails($client_id)) {
|
||||||
$client_secret = random_string(16);
|
// Until "Dynamic Client Registration" is pursued - allow new clients to assign their own secret in the REQUEST
|
||||||
|
$client_secret = (isset($_REQUEST["client_secret"])) ? $_REQUEST["client_secret"] : random_string(16);
|
||||||
// Client apps are registered per channel
|
// Client apps are registered per channel
|
||||||
$user_id = local_channel();
|
$storage->setClientDetails($client_id, $client_secret, $redirect_uri, 'authorization_code', urldecode($_REQUEST["scope"]), $user_id);
|
||||||
$storage->setClientDetails($client_id, $client_secret, $redirect_uri, 'authorization_code', null, $user_id);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
if (!$client = $storage->getClientDetails($client_id)) {
|
if (!$client = $storage->getClientDetails($client_id)) {
|
||||||
@ -83,7 +87,7 @@ class Authorize extends \Zotlabs\Web\Controller {
|
|||||||
|
|
||||||
// print the authorization code if the user has authorized your client
|
// print the authorization code if the user has authorized your client
|
||||||
$is_authorized = ($_POST['authorize'] === 'allow');
|
$is_authorized = ($_POST['authorize'] === 'allow');
|
||||||
$s->handleAuthorizeRequest($request, $response, $is_authorized, local_channel());
|
$s->handleAuthorizeRequest($request, $response, $is_authorized, $user_id);
|
||||||
if ($is_authorized) {
|
if ($is_authorized) {
|
||||||
$code = substr($response->getHttpHeader('Location'), strpos($response->getHttpHeader('Location'), 'code=') + 5, 40);
|
$code = substr($response->getHttpHeader('Location'), strpos($response->getHttpHeader('Location'), 'code=') + 5, 40);
|
||||||
logger('Authorization Code: ' . $code);
|
logger('Authorization Code: ' . $code);
|
||||||
|
@ -10,10 +10,19 @@ class Oauth2 {
|
|||||||
|
|
||||||
if(x($_POST,'remove')){
|
if(x($_POST,'remove')){
|
||||||
check_form_security_token_redirectOnErr('/settings/oauth2', 'settings_oauth2');
|
check_form_security_token_redirectOnErr('/settings/oauth2', 'settings_oauth2');
|
||||||
|
$name = ((x($_POST,'name')) ? escape_tags(trim($_POST['name'])) : '');
|
||||||
|
logger("REMOVE! ".$name." uid: ".local_channel());
|
||||||
$key = $_POST['remove'];
|
$key = $_POST['remove'];
|
||||||
q("DELETE FROM tokens WHERE id='%s' AND uid=%d",
|
q("DELETE FROM oauth_authorization_codes WHERE client_id='%s' AND user_id=%d",
|
||||||
dbesc($key),
|
dbesc($name),
|
||||||
|
intval(local_channel())
|
||||||
|
);
|
||||||
|
q("DELETE FROM oauth_access_tokens WHERE client_id='%s' AND user_id=%d",
|
||||||
|
dbesc($name),
|
||||||
|
intval(local_channel())
|
||||||
|
);
|
||||||
|
q("DELETE FROM oauth_refresh_tokens WHERE client_id='%s' AND user_id=%d",
|
||||||
|
dbesc($name),
|
||||||
intval(local_channel())
|
intval(local_channel())
|
||||||
);
|
);
|
||||||
goaway(z_root()."/settings/oauth2/");
|
goaway(z_root()."/settings/oauth2/");
|
||||||
@ -45,14 +54,15 @@ class Oauth2 {
|
|||||||
grant_types = '%s',
|
grant_types = '%s',
|
||||||
scope = '%s',
|
scope = '%s',
|
||||||
user_id = %d
|
user_id = %d
|
||||||
WHERE client_id='%s'",
|
WHERE client_id='%s' and user_id = %s",
|
||||||
dbesc($name),
|
dbesc($name),
|
||||||
dbesc($secret),
|
dbesc($secret),
|
||||||
dbesc($redirect),
|
dbesc($redirect),
|
||||||
dbesc($grant),
|
dbesc($grant),
|
||||||
dbesc($scope),
|
dbesc($scope),
|
||||||
intval(local_channel()),
|
intval(local_channel()),
|
||||||
dbesc($name));
|
dbesc($name),
|
||||||
|
intval(local_channel()));
|
||||||
} else {
|
} else {
|
||||||
$r = q("INSERT INTO oauth_clients (client_id, client_secret, redirect_uri, grant_types, scope, user_id)
|
$r = q("INSERT INTO oauth_clients (client_id, client_secret, redirect_uri, grant_types, scope, user_id)
|
||||||
VALUES ('%s','%s','%s','%s','%s',%d)",
|
VALUES ('%s','%s','%s','%s','%s',%d)",
|
||||||
@ -128,6 +138,18 @@ class Oauth2 {
|
|||||||
dbesc(argv(3)),
|
dbesc(argv(3)),
|
||||||
intval(local_channel())
|
intval(local_channel())
|
||||||
);
|
);
|
||||||
|
$r = q("DELETE FROM oauth_access_tokens WHERE client_id = '%s' AND user_id = %d",
|
||||||
|
dbesc(argv(3)),
|
||||||
|
intval(local_channel())
|
||||||
|
);
|
||||||
|
$r = q("DELETE FROM oauth_authorization_codes WHERE client_id = '%s' AND user_id = %d",
|
||||||
|
dbesc(argv(3)),
|
||||||
|
intval(local_channel())
|
||||||
|
);
|
||||||
|
$r = q("DELETE FROM oauth_refresh_tokens WHERE client_id = '%s' AND user_id = %d",
|
||||||
|
dbesc(argv(3)),
|
||||||
|
intval(local_channel())
|
||||||
|
);
|
||||||
goaway(z_root()."/settings/oauth2/");
|
goaway(z_root()."/settings/oauth2/");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -135,7 +157,8 @@ class Oauth2 {
|
|||||||
|
|
||||||
$r = q("SELECT oauth_clients.*, oauth_access_tokens.access_token as oauth_token, (oauth_clients.user_id = %d) AS my
|
$r = q("SELECT oauth_clients.*, oauth_access_tokens.access_token as oauth_token, (oauth_clients.user_id = %d) AS my
|
||||||
FROM oauth_clients
|
FROM oauth_clients
|
||||||
LEFT JOIN oauth_access_tokens ON oauth_clients.client_id=oauth_access_tokens.client_id
|
LEFT JOIN oauth_access_tokens ON oauth_clients.client_id=oauth_access_tokens.client_id AND
|
||||||
|
oauth_clients.user_id=oauth_access_tokens.user_id
|
||||||
WHERE oauth_clients.user_id IN (%d,0)",
|
WHERE oauth_clients.user_id IN (%d,0)",
|
||||||
intval(local_channel()),
|
intval(local_channel()),
|
||||||
intval(local_channel())
|
intval(local_channel())
|
||||||
|
@ -27,11 +27,11 @@ class Token extends \Zotlabs\Web\Controller {
|
|||||||
$_SERVER['PHP_AUTH_PW'] = $password;
|
$_SERVER['PHP_AUTH_PW'] = $password;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$storage = new OAuth2Storage(\DBA::$dba->db);
|
||||||
$s = new \Zotlabs\Identity\OAuth2Server(new OAuth2Storage(\DBA::$dba->db));
|
$s = new \Zotlabs\Identity\OAuth2Server($storage);
|
||||||
$request = \OAuth2\Request::createFromGlobals();
|
$request = \OAuth2\Request::createFromGlobals();
|
||||||
$s->handleTokenRequest($request)->send();
|
$response = $s->handleTokenRequest($request);
|
||||||
|
$response->send();
|
||||||
killme();
|
killme();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
17
Zotlabs/Module/Userinfo.php
Normal file
17
Zotlabs/Module/Userinfo.php
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Zotlabs\Module;
|
||||||
|
|
||||||
|
use Zotlabs\Identity\OAuth2Storage;
|
||||||
|
|
||||||
|
|
||||||
|
class Userinfo extends \Zotlabs\Web\Controller {
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
$s = new \Zotlabs\Identity\OAuth2Server(new OAuth2Storage(\DBA::$dba->db));
|
||||||
|
$request = \OAuth2\Request::createFromGlobals();
|
||||||
|
$s->handleUserInfoRequest($request)->send();
|
||||||
|
killme();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -74,8 +74,11 @@ function markdown_to_bb($s, $use_zrl = false, $options = []) {
|
|||||||
|
|
||||||
// Convert everything that looks like a link to a link
|
// Convert everything that looks like a link to a link
|
||||||
if($use_zrl) {
|
if($use_zrl) {
|
||||||
$s = str_replace(['[img', '/img]'], ['[zmg', '/zmg]'], $s);
|
if (strpos($s,'[/img]') !== false) {
|
||||||
$s = preg_replace("/([^\]\=\{]|^)(https?\:\/\/)([a-zA-Z0-9\pL\:\/\-\?\&\;\.\=\_\~\#\%\$\!\+\,\@\(\)]+)/ismu", '$1[zrl=$2$3]$2$3[/zrl]',$s);
|
$s = preg_replace_callback("/\[img\](.*?)\[\/img\]/ism", 'use_zrl_cb_img', $s);
|
||||||
|
$s = preg_replace_callback("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", 'use_zrl_cb_img_x', $s);
|
||||||
|
}
|
||||||
|
$s = preg_replace_callback("/([^\]\=\{]|^)(https?\:\/\/)([a-zA-Z0-9\pL\:\/\-\?\&\;\.\=\_\~\#\%\$\!\+\,\@\(\)]+)/ismu", 'use_zrl_cb_link',$s);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
$s = preg_replace("/([^\]\=\{]|^)(https?\:\/\/)([a-zA-Z0-9\pL\:\/\-\?\&\;\.\=\_\~\#\%\$\!\+\,\@\(\)]+)/ismu", '$1[url=$2$3]$2$3[/url]',$s);
|
$s = preg_replace("/([^\]\=\{]|^)(https?\:\/\/)([a-zA-Z0-9\pL\:\/\-\?\&\;\.\=\_\~\#\%\$\!\+\,\@\(\)]+)/ismu", '$1[url=$2$3]$2$3[/url]',$s);
|
||||||
@ -96,6 +99,41 @@ function markdown_to_bb($s, $use_zrl = false, $options = []) {
|
|||||||
return $s;
|
return $s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function use_zrl_cb_link($match) {
|
||||||
|
$res = '';
|
||||||
|
$is_zid = is_matrix_url(trim($match[0]));
|
||||||
|
|
||||||
|
if($is_zid)
|
||||||
|
$res = $match[1] . '[zrl=' . $match[2] . $match[3] . ']' . $match[2] . $match[3] . '[/zrl]';
|
||||||
|
else
|
||||||
|
$res = $match[1] . '[url=' . $match[2] . $match[3] . ']' . $match[2] . $match[3] . '[/url]';
|
||||||
|
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function use_zrl_cb_img($match) {
|
||||||
|
$res = '';
|
||||||
|
$is_zid = is_matrix_url(trim($match[1]));
|
||||||
|
|
||||||
|
if($is_zid)
|
||||||
|
$res = '[zmg]' . $match[1] . '[/zmg]';
|
||||||
|
else
|
||||||
|
$res = $match[0];
|
||||||
|
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function use_zrl_cb_img_x($match) {
|
||||||
|
$res = '';
|
||||||
|
$is_zid = is_matrix_url(trim($match[3]));
|
||||||
|
|
||||||
|
if($is_zid)
|
||||||
|
$res = '[zmg=' . $match[1] . 'x' . $match[2] . ']' . $match[3] . '[/zmg]';
|
||||||
|
else
|
||||||
|
$res = $match[0];
|
||||||
|
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief
|
* @brief
|
||||||
|
@ -175,7 +175,7 @@ function nav($template = 'default') {
|
|||||||
$search_form_action = 'network';
|
$search_form_action = 'network';
|
||||||
break;
|
break;
|
||||||
case 'channel':
|
case 'channel':
|
||||||
$search_form_action = 'channel';
|
$search_form_action = 'channel/' . App::$profile['channel_address'];
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
$search_form_action = 'search';
|
$search_form_action = 'search';
|
||||||
|
@ -89,9 +89,10 @@ web server platforms.
|
|||||||
php.ini file - and with no hosting provider restrictions on the use of
|
php.ini file - and with no hosting provider restrictions on the use of
|
||||||
exec() and proc_open().
|
exec() and proc_open().
|
||||||
|
|
||||||
- curl, gd (with at least jpeg and png support), mysqli, mbstring, xml, zip
|
- curl, gd (with at least jpeg and png support), mysqli, mbstring, xml,
|
||||||
and openssl extensions. The imagick extension MAY be used instead of gd,
|
xmlreader (FreeBSD), zip and openssl extensions. The imagick extension MAY be used
|
||||||
but is not required and MAY also be disabled via configuration option.
|
instead of gd, but is not required and MAY also be disabled via
|
||||||
|
configuration option.
|
||||||
|
|
||||||
- some form of email server or email gateway such that PHP mail() works.
|
- some form of email server or email gateway such that PHP mail() works.
|
||||||
|
|
||||||
|
@ -4,8 +4,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section-content-tools-wrapper">
|
<div class="section-content-tools-wrapper">
|
||||||
<form action="settings/oauth2" method="post" autocomplete="off">
|
|
||||||
<input type='hidden' name='form_security_token' value='{{$form_security_token}}'>
|
|
||||||
|
|
||||||
<div id="profile-edit-links">
|
<div id="profile-edit-links">
|
||||||
<ul>
|
<ul>
|
||||||
@ -16,6 +14,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{foreach $apps as $app}}
|
{{foreach $apps as $app}}
|
||||||
|
<form action="settings/oauth2" method="post" autocomplete="off">
|
||||||
|
<input type='hidden' name='form_security_token' value='{{$form_security_token}}'>
|
||||||
|
<input type='hidden' name='name' value='{{$app.client_id}}'>
|
||||||
<div class='oauthapp'>
|
<div class='oauthapp'>
|
||||||
{{if $app.client_id}}<h4>{{$app.client_id}}</h4>{{else}}<h4>{{$noname}}</h4>{{/if}}
|
{{if $app.client_id}}<h4>{{$app.client_id}}</h4>{{else}}<h4>{{$noname}}</h4>{{/if}}
|
||||||
{{if $app.my}}
|
{{if $app.my}}
|
||||||
@ -28,8 +29,8 @@
|
|||||||
<a href="{{$baseurl}}/settings/oauth2/delete/{{$app.client_id}}?t={{$form_security_token}}" title="{{$delete}}"><i class="fa fa-trash-o btn btn-outline-secondary"></i></a>
|
<a href="{{$baseurl}}/settings/oauth2/delete/{{$app.client_id}}?t={{$form_security_token}}" title="{{$delete}}"><i class="fa fa-trash-o btn btn-outline-secondary"></i></a>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
{{/foreach}}
|
{{/foreach}}
|
||||||
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
Reference in New Issue
Block a user