mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 02:24:11 -05:00
Merge branch 'next' into main
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
APP_ENV=testing
|
||||
APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k=
|
||||
APP_DEBUG=true
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
|
||||
DB_CONNECTION=testing
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ class CreateNewUser implements CreatesNewUsers
|
||||
public function create(array $input): User
|
||||
{
|
||||
$settings = instanceSettings();
|
||||
if (! $settings->is_registration_enabled) {
|
||||
if (! $settings->isPasswordRegistrationAllowed()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class CheckUpdates
|
||||
@@ -106,6 +107,15 @@ class CheckUpdates
|
||||
$out['osId'] = $osId;
|
||||
$out['package_manager'] = $packageManager;
|
||||
|
||||
return $out;
|
||||
case 'apk':
|
||||
instant_remote_process(['apk update -q'], $server);
|
||||
$output = instant_remote_process(['LANG=C apk list --upgradable 2>/dev/null'], $server);
|
||||
|
||||
$out = $this->parseApkOutput($output);
|
||||
$out['osId'] = $osId;
|
||||
$out['package_manager'] = $packageManager;
|
||||
|
||||
return $out;
|
||||
default:
|
||||
return [
|
||||
@@ -266,11 +276,39 @@ class CheckUpdates
|
||||
// Include unparsed lines in the result for debugging if any exist
|
||||
if (! empty($unparsedLines)) {
|
||||
$result['unparsed_lines'] = $unparsedLines;
|
||||
\Illuminate\Support\Facades\Log::debug('Pacman output contained unparsed lines', [
|
||||
Log::debug('Pacman output contained unparsed lines', [
|
||||
'unparsed_lines' => $unparsedLines,
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function parseApkOutput(string $output): array
|
||||
{
|
||||
$updates = [];
|
||||
$lines = explode("\n", $output);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
// Skip empty lines
|
||||
if (empty($line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Example line: docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4]
|
||||
if (preg_match('/^(.+)-([0-9]\S*) (\S+) \{\S+\} \([^)]+\) \[upgradable from: .+?-([0-9][^\]]+)\]$/', $line, $matches)) {
|
||||
$updates[] = [
|
||||
'package' => $matches[1],
|
||||
'new_version' => $matches[2],
|
||||
'architecture' => $matches[3],
|
||||
'current_version' => $matches[4],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total_updates' => count($updates),
|
||||
'updates' => $updates,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,8 @@ class InstallDocker
|
||||
$command = $command->merge([$this->getSuseDockerInstallCommand()]);
|
||||
} elseif ($supported_os_type->contains('arch')) {
|
||||
$command = $command->merge([$this->getArchDockerInstallCommand()]);
|
||||
} elseif ($supported_os_type->contains('alpine')) {
|
||||
$command = $command->merge([$this->getAlpineDockerInstallCommand()]);
|
||||
} else {
|
||||
$command = $command->merge([$this->getGenericDockerInstallCommand()]);
|
||||
}
|
||||
@@ -93,9 +95,8 @@ class InstallDocker
|
||||
"jq -s '.[0] * .[1]' /etc/docker/daemon.json.coolify /etc/docker/daemon.json | tee /etc/docker/daemon.json.appended > /dev/null",
|
||||
'mv /etc/docker/daemon.json.appended /etc/docker/daemon.json',
|
||||
"echo 'Restarting Docker Engine...'",
|
||||
'systemctl enable docker >/dev/null 2>&1 || true',
|
||||
'systemctl restart docker',
|
||||
]);
|
||||
$command = $command->merge($this->getDockerServiceCommands($supported_os_type->contains('alpine')));
|
||||
if ($server->isSwarm()) {
|
||||
$command = $command->merge([
|
||||
'docker network create --attachable --driver overlay coolify-overlay >/dev/null 2>&1 || true',
|
||||
@@ -154,6 +155,28 @@ class InstallDocker
|
||||
'systemctl start docker.service';
|
||||
}
|
||||
|
||||
private function getAlpineDockerInstallCommand(): string
|
||||
{
|
||||
return 'apk update && '.
|
||||
'apk add docker docker-cli-buildx docker-cli-compose && '.
|
||||
'mkdir -p /etc/docker';
|
||||
}
|
||||
|
||||
private function getDockerServiceCommands(bool $usesOpenRc): array
|
||||
{
|
||||
if ($usesOpenRc) {
|
||||
return [
|
||||
'rc-update add docker default',
|
||||
'rc-service docker restart',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'systemctl enable docker >/dev/null 2>&1 || true',
|
||||
'systemctl restart docker',
|
||||
];
|
||||
}
|
||||
|
||||
private function getGenericDockerInstallCommand(): string
|
||||
{
|
||||
return 'curl -fsSL https://get.docker.com | sh';
|
||||
|
||||
@@ -53,6 +53,8 @@ class InstallPrerequisites
|
||||
"echo 'Installing Prerequisites for Arch Linux...'",
|
||||
'pacman -Syu --noconfirm --needed curl wget git jq',
|
||||
]);
|
||||
} elseif ($supported_os_type->contains('alpine')) {
|
||||
$command = $command->merge($this->getAlpinePrerequisiteCommands());
|
||||
} else {
|
||||
throw new \Exception('Unsupported OS type for prerequisites installation');
|
||||
}
|
||||
@@ -61,4 +63,18 @@ class InstallPrerequisites
|
||||
|
||||
return remote_process($command, $server);
|
||||
}
|
||||
|
||||
private function getAlpinePrerequisiteCommands(): array
|
||||
{
|
||||
return [
|
||||
"echo 'Installing Prerequisites for Alpine Linux...'",
|
||||
"sed -i '/^#.*\\/community/s/^#//' /etc/apk/repositories 2>/dev/null || true",
|
||||
'apk update',
|
||||
'command -v bash >/dev/null || apk add bash',
|
||||
'command -v curl >/dev/null || apk add curl',
|
||||
'command -v wget >/dev/null || apk add wget',
|
||||
'command -v git >/dev/null || apk add git',
|
||||
'command -v jq >/dev/null || apk add jq',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ class UpdatePackage
|
||||
$commandAll = 'pacman -Syu --noconfirm';
|
||||
$commandInstall = 'pacman -S --noconfirm '.$sanitizedPackage;
|
||||
break;
|
||||
case 'apk':
|
||||
$commandAll = 'apk update && apk upgrade';
|
||||
$commandInstall = 'apk upgrade '.$sanitizedPackage;
|
||||
break;
|
||||
default:
|
||||
return [
|
||||
'error' => 'OS not supported',
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc\Exceptions;
|
||||
|
||||
class OidcDiscoveryException extends OidcException {}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class OidcException extends RuntimeException {}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc\Exceptions;
|
||||
|
||||
class OidcJwksException extends OidcException {}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc\Exceptions;
|
||||
|
||||
class OidcSigningKeyNotFoundException extends OidcTokenException {}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc\Exceptions;
|
||||
|
||||
class OidcTokenException extends OidcException {}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc;
|
||||
|
||||
use App\Models\OauthSetting;
|
||||
|
||||
final readonly class OidcConfig
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $scopes
|
||||
*/
|
||||
public function __construct(
|
||||
public string $issuerUrl,
|
||||
public string $clientId,
|
||||
public string $clientSecret,
|
||||
public string $redirectUri,
|
||||
public array $scopes = ['openid', 'email', 'profile'],
|
||||
public bool $usePkce = true,
|
||||
public int $clockSkewSeconds = 60,
|
||||
) {}
|
||||
|
||||
public static function fromOauthSetting(OauthSetting $setting): self
|
||||
{
|
||||
return new self(
|
||||
issuerUrl: rtrim((string) $setting->base_url, '/'),
|
||||
clientId: (string) $setting->client_id,
|
||||
clientSecret: (string) $setting->client_secret,
|
||||
redirectUri: filled($setting->redirect_uri) ? $setting->redirect_uri : route('auth.callback', 'oidc'),
|
||||
scopes: $setting->scopeList(),
|
||||
usePkce: $setting->use_pkce ?? true,
|
||||
clockSkewSeconds: $setting->clock_skew_seconds ?? 60,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc;
|
||||
|
||||
use App\Auth\Oidc\Exceptions\OidcDiscoveryException;
|
||||
|
||||
final readonly class OidcDiscoveryDocument
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $supportedScopes
|
||||
* @param array<int, string> $supportedClaims
|
||||
* @param array<int, string> $idTokenSigningAlgValuesSupported
|
||||
*/
|
||||
public function __construct(
|
||||
public string $issuer,
|
||||
public string $authorizationEndpoint,
|
||||
public string $tokenEndpoint,
|
||||
public string $userinfoEndpoint,
|
||||
public string $jwksUri,
|
||||
public ?string $endSessionEndpoint = null,
|
||||
public array $supportedScopes = [],
|
||||
public array $supportedClaims = [],
|
||||
public array $idTokenSigningAlgValuesSupported = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public static function fromArray(array $payload): self
|
||||
{
|
||||
foreach (['issuer', 'authorization_endpoint', 'token_endpoint', 'userinfo_endpoint', 'jwks_uri'] as $field) {
|
||||
if (! is_string($payload[$field] ?? null) || trim($payload[$field]) === '') {
|
||||
throw new OidcDiscoveryException("Discovery document is missing required field: {$field}");
|
||||
}
|
||||
}
|
||||
|
||||
return new self(
|
||||
issuer: $payload['issuer'],
|
||||
authorizationEndpoint: $payload['authorization_endpoint'],
|
||||
tokenEndpoint: $payload['token_endpoint'],
|
||||
userinfoEndpoint: $payload['userinfo_endpoint'],
|
||||
jwksUri: $payload['jwks_uri'],
|
||||
endSessionEndpoint: is_string($payload['end_session_endpoint'] ?? null) ? $payload['end_session_endpoint'] : null,
|
||||
supportedScopes: self::stringList($payload['scopes_supported'] ?? []),
|
||||
supportedClaims: self::stringList($payload['claims_supported'] ?? []),
|
||||
idTokenSigningAlgValuesSupported: self::stringList($payload['id_token_signing_alg_values_supported'] ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function stringList(mixed $value): array
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_map('strval', $value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc;
|
||||
|
||||
use App\Auth\Oidc\Exceptions\OidcDiscoveryException;
|
||||
use App\Auth\Oidc\Exceptions\OidcJwksException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Throwable;
|
||||
|
||||
class OidcDiscoveryService
|
||||
{
|
||||
public function discover(string $issuerUrl): OidcDiscoveryDocument
|
||||
{
|
||||
$this->assertHttpsUrl($issuerUrl, new OidcDiscoveryException('Issuer URL must be an absolute HTTPS URL.'));
|
||||
|
||||
$issuerUrl = rtrim($issuerUrl, '/');
|
||||
$cacheKey = 'oidc:discovery:'.hash('sha256', $issuerUrl);
|
||||
|
||||
return Cache::remember($cacheKey, 3600, function () use ($issuerUrl): OidcDiscoveryDocument {
|
||||
$url = $issuerUrl.'/.well-known/openid-configuration';
|
||||
|
||||
try {
|
||||
$response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($url);
|
||||
} catch (Throwable $e) {
|
||||
throw new OidcDiscoveryException("Failed to fetch discovery document: {$e->getMessage()}", previous: $e);
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new OidcDiscoveryException("Discovery endpoint returned HTTP {$response->status()}");
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
if (! is_array($json) || $json === []) {
|
||||
throw new OidcDiscoveryException('Discovery endpoint returned invalid JSON.');
|
||||
}
|
||||
|
||||
$discovery = OidcDiscoveryDocument::fromArray($json);
|
||||
if (rtrim($discovery->issuer, '/') !== $issuerUrl) {
|
||||
throw new OidcDiscoveryException('Discovery issuer does not match the configured issuer URL.');
|
||||
}
|
||||
|
||||
return $discovery;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the JWKS for the given URI.
|
||||
*
|
||||
* When $forceRefresh is true the cached document is bypassed so freshly
|
||||
* rotated signing keys become visible immediately. A short cooldown still
|
||||
* prevents a flood of upstream requests if many logins miss the same kid.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function jwks(string $jwksUri, bool $forceRefresh = false): array
|
||||
{
|
||||
$this->assertHttpsUrl($jwksUri, new OidcJwksException('JWKS URI must be an absolute HTTPS URL.'));
|
||||
|
||||
$cacheKey = 'oidc:jwks:'.hash('sha256', $jwksUri);
|
||||
|
||||
if ($forceRefresh) {
|
||||
$cooldownKey = $cacheKey.':refresh';
|
||||
if (Cache::add($cooldownKey, true, 60)) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
return Cache::remember($cacheKey, 21600, function () use ($jwksUri): array {
|
||||
try {
|
||||
$response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($jwksUri);
|
||||
} catch (Throwable $e) {
|
||||
throw new OidcJwksException("Failed to fetch JWKS: {$e->getMessage()}", previous: $e);
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new OidcJwksException("JWKS endpoint returned HTTP {$response->status()}");
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
if (! is_array($json) || ! is_array($json['keys'] ?? null)) {
|
||||
throw new OidcJwksException("JWKS endpoint returned an invalid payload without 'keys'.");
|
||||
}
|
||||
|
||||
return $json;
|
||||
});
|
||||
}
|
||||
|
||||
private function assertHttpsUrl(string $url, Throwable $exception): void
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
|
||||
if (($parts['scheme'] ?? null) !== 'https' || ! is_string($parts['host'] ?? null) || $parts['host'] === '') {
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc;
|
||||
|
||||
use App\Auth\Oidc\Exceptions\OidcSigningKeyNotFoundException;
|
||||
use App\Auth\Oidc\Exceptions\OidcTokenException;
|
||||
use Firebase\JWT\JWK;
|
||||
use Firebase\JWT\JWT;
|
||||
use Throwable;
|
||||
|
||||
class OidcTokenValidator
|
||||
{
|
||||
/**
|
||||
* Algorithms we accept for id_token signatures. RS256 only — this is the
|
||||
* OIDC baseline and a strict allowlist prevents algorithm-confusion and
|
||||
* "none" attacks.
|
||||
*/
|
||||
private const ALLOWED_ALGORITHM = 'RS256';
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $jwks
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function validate(
|
||||
string $idToken,
|
||||
OidcDiscoveryDocument $discovery,
|
||||
array $jwks,
|
||||
string $clientId,
|
||||
?string $expectedNonce = null,
|
||||
int $clockSkewSeconds = 60,
|
||||
): array {
|
||||
$kid = $this->extractKid($idToken);
|
||||
|
||||
try {
|
||||
$keys = JWK::parseKeySet($this->signingKeysOnly($jwks), self::ALLOWED_ALGORITHM);
|
||||
} catch (Throwable $e) {
|
||||
throw new OidcTokenException("Unable to parse JWKS: {$e->getMessage()}", previous: $e);
|
||||
}
|
||||
|
||||
// Surface an unknown signing key distinctly so the caller can refresh
|
||||
// the JWKS once (key rotation) before giving up.
|
||||
if (! array_key_exists($kid, $keys)) {
|
||||
throw new OidcSigningKeyNotFoundException('No matching JWKS key found for id_token kid.');
|
||||
}
|
||||
|
||||
$previousLeeway = JWT::$leeway;
|
||||
JWT::$leeway = $clockSkewSeconds;
|
||||
|
||||
try {
|
||||
// Validates signature, header alg against the key alg (RS256),
|
||||
// exp, nbf and iat. Throws on any failure.
|
||||
$claims = (array) JWT::decode($idToken, $keys);
|
||||
} catch (OidcTokenException $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
throw new OidcTokenException("id_token validation failed: {$e->getMessage()}", previous: $e);
|
||||
} finally {
|
||||
JWT::$leeway = $previousLeeway;
|
||||
}
|
||||
|
||||
$this->assertExpiry($claims);
|
||||
$this->assertIssuer($claims, $discovery->issuer);
|
||||
$this->assertAudience($claims, $clientId);
|
||||
$this->assertNonce($claims, $expectedNonce);
|
||||
$this->assertSubject($claims);
|
||||
|
||||
return $claims;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop JWKS entries explicitly marked for anything other than signing
|
||||
* (e.g. "use":"enc") so they can never verify an id_token signature.
|
||||
* firebase/php-jwt does not honour the "use" parameter on its own.
|
||||
*
|
||||
* @param array<string, mixed> $jwks
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function signingKeysOnly(array $jwks): array
|
||||
{
|
||||
$keys = array_values(array_filter(
|
||||
$jwks['keys'] ?? [],
|
||||
fn ($jwk): bool => is_array($jwk) && (! isset($jwk['use']) || $jwk['use'] === 'sig'),
|
||||
));
|
||||
|
||||
return ['keys' => $keys];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode just the JWT header to read the kid before signature
|
||||
* verification, so an unknown key can be reported as a rotation miss.
|
||||
*/
|
||||
private function extractKid(string $idToken): string
|
||||
{
|
||||
$segments = explode('.', $idToken);
|
||||
if (count($segments) !== 3) {
|
||||
throw new OidcTokenException('Malformed id_token.');
|
||||
}
|
||||
|
||||
$header = json_decode($this->base64UrlDecode($segments[0]), true);
|
||||
if (! is_array($header)) {
|
||||
throw new OidcTokenException('id_token header contains invalid JSON.');
|
||||
}
|
||||
|
||||
if (($header['alg'] ?? null) !== self::ALLOWED_ALGORITHM) {
|
||||
throw new OidcTokenException('id_token uses a disallowed algorithm.');
|
||||
}
|
||||
|
||||
$kid = $header['kid'] ?? null;
|
||||
if (! is_string($kid) || $kid === '') {
|
||||
throw new OidcTokenException('id_token header is missing kid.');
|
||||
}
|
||||
|
||||
return $kid;
|
||||
}
|
||||
|
||||
private function base64UrlDecode(string $value): string
|
||||
{
|
||||
$remainder = strlen($value) % 4;
|
||||
if ($remainder !== 0) {
|
||||
$value .= str_repeat('=', 4 - $remainder);
|
||||
}
|
||||
|
||||
$decoded = base64_decode(strtr($value, '-_', '+/'), true);
|
||||
if ($decoded === false) {
|
||||
throw new OidcTokenException('Invalid base64url value in id_token header.');
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*/
|
||||
private function assertExpiry(array $claims): void
|
||||
{
|
||||
// Firebase enforces the exp window when present; OIDC requires it to exist.
|
||||
if (! is_numeric($claims['exp'] ?? null)) {
|
||||
throw new OidcTokenException('id_token is missing the exp claim.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*/
|
||||
private function assertSubject(array $claims): void
|
||||
{
|
||||
$subject = $claims['sub'] ?? null;
|
||||
if (! is_string($subject) || $subject === '') {
|
||||
throw new OidcTokenException('id_token subject is missing or invalid.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*/
|
||||
private function assertIssuer(array $claims, string $expectedIssuer): void
|
||||
{
|
||||
if (($claims['iss'] ?? null) !== $expectedIssuer) {
|
||||
throw new OidcTokenException('id_token issuer does not match discovery issuer.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*/
|
||||
private function assertAudience(array $claims, string $clientId): void
|
||||
{
|
||||
$audience = $claims['aud'] ?? null;
|
||||
if (is_string($audience)) {
|
||||
$audience = [$audience];
|
||||
}
|
||||
|
||||
if (! is_array($audience) || ! in_array($clientId, $audience, true)) {
|
||||
throw new OidcTokenException('id_token audience does not include configured client id.');
|
||||
}
|
||||
|
||||
if (count($audience) > 1 && (! isset($claims['azp']) || $claims['azp'] !== $clientId)) {
|
||||
throw new OidcTokenException('id_token azp is required when aud contains multiple values and must match configured client id.');
|
||||
}
|
||||
|
||||
if (isset($claims['azp']) && $claims['azp'] !== $clientId) {
|
||||
throw new OidcTokenException('id_token azp does not match configured client id.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*/
|
||||
private function assertNonce(array $claims, ?string $expectedNonce): void
|
||||
{
|
||||
if ($expectedNonce === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (($claims['nonce'] ?? null) !== $expectedNonce) {
|
||||
throw new OidcTokenException('id_token nonce does not match.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc;
|
||||
|
||||
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||
|
||||
class OidcUser extends SocialiteUser
|
||||
{
|
||||
public ?string $issuer = null;
|
||||
|
||||
public ?string $subject = null;
|
||||
|
||||
public bool $emailVerified = false;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
public array $idTokenClaims = [];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*/
|
||||
public function setIdTokenClaims(array $claims): self
|
||||
{
|
||||
$this->idTokenClaims = $claims;
|
||||
$this->issuer = is_string($claims['iss'] ?? null) ? $claims['iss'] : null;
|
||||
$this->subject = is_string($claims['sub'] ?? null) ? $claims['sub'] : null;
|
||||
$this->emailVerified = ($claims['email_verified'] ?? false) === true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc\Socialite;
|
||||
|
||||
use App\Auth\Oidc\Exceptions\OidcException;
|
||||
use App\Auth\Oidc\Exceptions\OidcSigningKeyNotFoundException;
|
||||
use App\Auth\Oidc\OidcConfig;
|
||||
use App\Auth\Oidc\OidcDiscoveryDocument;
|
||||
use App\Auth\Oidc\OidcDiscoveryService;
|
||||
use App\Auth\Oidc\OidcTokenValidator;
|
||||
use App\Auth\Oidc\OidcUser;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Two\AbstractProvider;
|
||||
use Laravel\Socialite\Two\InvalidStateException;
|
||||
use Laravel\Socialite\Two\ProviderInterface;
|
||||
|
||||
class OidcProvider extends AbstractProvider implements ProviderInterface
|
||||
{
|
||||
private const int OIDC_FLOW_TTL_MINUTES = 10;
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $scopes = ['openid', 'email', 'profile'];
|
||||
|
||||
protected $scopeSeparator = ' ';
|
||||
|
||||
protected ?OidcConfig $oidcConfig = null;
|
||||
|
||||
protected ?OidcDiscoveryDocument $discovery = null;
|
||||
|
||||
public function __construct(
|
||||
Request $request,
|
||||
protected OidcDiscoveryService $discoveryService,
|
||||
protected OidcTokenValidator $tokenValidator,
|
||||
string $clientId,
|
||||
string $clientSecret,
|
||||
string $redirectUrl,
|
||||
) {
|
||||
parent::__construct($request, $clientId, $clientSecret, $redirectUrl);
|
||||
}
|
||||
|
||||
public function setConfig(OidcConfig $config): self
|
||||
{
|
||||
$this->oidcConfig = $config;
|
||||
$this->clientId = $config->clientId;
|
||||
$this->clientSecret = $config->clientSecret;
|
||||
$this->redirectUrl = $config->redirectUri;
|
||||
$this->scopes = $config->scopes;
|
||||
$this->discovery = null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getConfig(): OidcConfig
|
||||
{
|
||||
if ($this->oidcConfig === null) {
|
||||
throw new OidcException('OIDC provider config is not set.');
|
||||
}
|
||||
|
||||
return $this->oidcConfig;
|
||||
}
|
||||
|
||||
protected function getAuthUrl($state): string
|
||||
{
|
||||
$config = $this->getConfig();
|
||||
$nonce = Str::random(40);
|
||||
$this->putOidcFlowValue($this->nonceSessionKey($state), $nonce);
|
||||
|
||||
$extra = ['nonce' => $nonce];
|
||||
if ($config->usePkce) {
|
||||
$verifier = $this->generateCodeVerifier();
|
||||
$this->putOidcFlowValue($this->verifierSessionKey($state), $verifier);
|
||||
$extra['code_challenge'] = $this->codeChallenge($verifier);
|
||||
$extra['code_challenge_method'] = 'S256';
|
||||
}
|
||||
|
||||
return $this->buildAuthUrlFromBase($this->resolveDiscovery()->authorizationEndpoint, $state)
|
||||
.'&'.http_build_query($extra, '', '&', $this->encodingType);
|
||||
}
|
||||
|
||||
protected function getTokenUrl(): string
|
||||
{
|
||||
return $this->resolveDiscovery()->tokenEndpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getUserByToken($token): array
|
||||
{
|
||||
$response = $this->getHttpClient()->get($this->resolveDiscovery()->userinfoEndpoint, [
|
||||
RequestOptions::HEADERS => [
|
||||
'Accept' => 'application/json',
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
],
|
||||
RequestOptions::CONNECT_TIMEOUT => 5,
|
||||
RequestOptions::TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
$decoded = json_decode((string) $response->getBody(), true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $user
|
||||
*/
|
||||
protected function mapUserToObject(array $user)
|
||||
{
|
||||
return (new OidcUser)->setRaw($user)->map([
|
||||
'id' => $user['sub'] ?? null,
|
||||
'nickname' => $user['preferred_username'] ?? null,
|
||||
'name' => $this->resolveName($user),
|
||||
'email' => $user['email'] ?? null,
|
||||
'avatar' => $user['picture'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
if ($this->user) {
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
if ($this->hasInvalidState()) {
|
||||
throw new InvalidStateException;
|
||||
}
|
||||
|
||||
$tokenResponse = $this->getAccessTokenResponse($this->getCode());
|
||||
$accessToken = Arr::get($tokenResponse, 'access_token');
|
||||
$idToken = Arr::get($tokenResponse, 'id_token');
|
||||
|
||||
if (! is_string($accessToken) || $accessToken === '' || ! is_string($idToken) || $idToken === '') {
|
||||
throw new OidcException('OIDC token endpoint did not return required tokens.');
|
||||
}
|
||||
|
||||
$discovery = $this->resolveDiscovery();
|
||||
$config = $this->getConfig();
|
||||
$expectedNonce = $this->pullOidcFlowValue($this->nonceSessionKey((string) $this->request->input('state')));
|
||||
if ($expectedNonce === null) {
|
||||
throw new OidcException('OIDC login session expired. Please try again.');
|
||||
}
|
||||
|
||||
$claims = $this->validateIdToken($idToken, $discovery, $config, $expectedNonce);
|
||||
|
||||
$userinfo = $this->getUserByToken($accessToken);
|
||||
|
||||
// OIDC core §5.3.2: the userinfo sub MUST match the id_token sub.
|
||||
// Reject the response rather than trust unsigned userinfo claims.
|
||||
$userinfoSub = $userinfo['sub'] ?? null;
|
||||
if (is_string($userinfoSub) && $userinfoSub !== '' && $userinfoSub !== ($claims['sub'] ?? null)) {
|
||||
throw new OidcException('OIDC userinfo subject does not match the id_token subject.');
|
||||
}
|
||||
|
||||
$merged = array_merge($userinfo, $claims);
|
||||
|
||||
/** @var OidcUser $user */
|
||||
$user = $this->mapUserToObject($merged);
|
||||
$user->setIdTokenClaims($claims)
|
||||
->setToken($accessToken)
|
||||
->setRefreshToken(Arr::get($tokenResponse, 'refresh_token'))
|
||||
->setExpiresIn(Arr::get($tokenResponse, 'expires_in'));
|
||||
|
||||
return $this->user = $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the id_token, retrying once against a freshly fetched JWKS when
|
||||
* the signing key is unknown. This keeps logins working immediately after
|
||||
* the IdP rotates keys instead of failing until the JWKS cache expires.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function validateIdToken(
|
||||
string $idToken,
|
||||
OidcDiscoveryDocument $discovery,
|
||||
OidcConfig $config,
|
||||
?string $expectedNonce,
|
||||
): array {
|
||||
foreach ([false, true] as $forceRefresh) {
|
||||
try {
|
||||
return $this->tokenValidator->validate(
|
||||
idToken: $idToken,
|
||||
discovery: $discovery,
|
||||
jwks: $this->discoveryService->jwks($discovery->jwksUri, $forceRefresh),
|
||||
clientId: $config->clientId,
|
||||
expectedNonce: $expectedNonce,
|
||||
clockSkewSeconds: $config->clockSkewSeconds,
|
||||
);
|
||||
} catch (OidcSigningKeyNotFoundException $e) {
|
||||
if ($forceRefresh) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new OidcSigningKeyNotFoundException('No matching JWKS key found for id_token kid.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getAccessTokenResponse($code)
|
||||
{
|
||||
$fields = $this->getTokenFields($code);
|
||||
if ($this->getConfig()->usePkce) {
|
||||
$verifier = $this->pullOidcFlowValue($this->verifierSessionKey((string) $this->request->input('state')));
|
||||
if ($verifier === null) {
|
||||
throw new OidcException('OIDC login session expired. Please try again.');
|
||||
}
|
||||
|
||||
$fields['code_verifier'] = $verifier;
|
||||
}
|
||||
|
||||
$response = $this->getHttpClient()->post($this->getTokenUrl(), [
|
||||
RequestOptions::HEADERS => ['Accept' => 'application/json'],
|
||||
RequestOptions::FORM_PARAMS => $fields,
|
||||
RequestOptions::CONNECT_TIMEOUT => 5,
|
||||
RequestOptions::TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
$decoded = json_decode((string) $response->getBody(), true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
protected function resolveDiscovery(): OidcDiscoveryDocument
|
||||
{
|
||||
return $this->discovery ??= $this->discoveryService->discover($this->getConfig()->issuerUrl);
|
||||
}
|
||||
|
||||
protected function generateCodeVerifier(): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
protected function codeChallenge(string $verifier): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $user
|
||||
*/
|
||||
protected function resolveName(array $user): ?string
|
||||
{
|
||||
if (is_string($user['name'] ?? null) && $user['name'] !== '') {
|
||||
return $user['name'];
|
||||
}
|
||||
|
||||
$name = trim(((string) ($user['given_name'] ?? '')).' '.((string) ($user['family_name'] ?? '')));
|
||||
|
||||
return $name === '' ? null : $name;
|
||||
}
|
||||
|
||||
protected function putOidcFlowValue(string $key, string $value): void
|
||||
{
|
||||
$this->request->session()->put($key, [
|
||||
'value' => $value,
|
||||
'expires_at' => now()->addMinutes(self::OIDC_FLOW_TTL_MINUTES)->timestamp,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function pullOidcFlowValue(string $key): ?string
|
||||
{
|
||||
$entry = $this->request->session()->pull($key);
|
||||
|
||||
if (! is_array($entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = $entry['value'] ?? null;
|
||||
$expiresAt = $entry['expires_at'] ?? null;
|
||||
|
||||
if (! is_string($value) || $value === '' || ! is_int($expiresAt)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($expiresAt < now()->timestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
protected function nonceSessionKey(string $state): string
|
||||
{
|
||||
return "oidc.nonce.{$state}";
|
||||
}
|
||||
|
||||
protected function verifierSessionKey(string $state): string
|
||||
{
|
||||
return "oidc.code_verifier.{$state}";
|
||||
}
|
||||
}
|
||||
@@ -243,12 +243,18 @@ class SshMultiplexingHelper
|
||||
|
||||
$delimiter = base64_encode(Hash::make($command));
|
||||
$command = str_replace($delimiter, '', $command);
|
||||
$remoteShellCommand = self::remoteShellCommand();
|
||||
|
||||
return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL
|
||||
return $sshCommand.self::escapedUserAtHost($server)." '{$remoteShellCommand}' << \\$delimiter".PHP_EOL
|
||||
.$command.PHP_EOL
|
||||
.$delimiter;
|
||||
}
|
||||
|
||||
private static function remoteShellCommand(): string
|
||||
{
|
||||
return 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi';
|
||||
}
|
||||
|
||||
public static function getConnectionTimeout(Server $server): int
|
||||
{
|
||||
$timeout = data_get($server, 'settings.connection_timeout');
|
||||
|
||||
@@ -2,47 +2,60 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use App\Models\OauthSetting;
|
||||
use App\Services\Auth\OauthLoginService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class OauthController extends Controller
|
||||
{
|
||||
public function redirect(string $provider)
|
||||
{
|
||||
$socialite_provider = get_socialite_provider($provider);
|
||||
$oauthSetting = $this->enabledProvider($provider);
|
||||
$socialiteProvider = get_socialite_provider($oauthSetting->provider);
|
||||
|
||||
return $socialite_provider->redirect();
|
||||
return $socialiteProvider->redirect();
|
||||
}
|
||||
|
||||
public function callback(string $provider)
|
||||
public function callback(string $provider, OauthLoginService $oauthLoginService)
|
||||
{
|
||||
try {
|
||||
$oauthUser = get_socialite_provider($provider)->user();
|
||||
$email = trim((string) $oauthUser->email);
|
||||
if ($email === '') {
|
||||
abort(403, 'OAuth provider did not return an email address');
|
||||
}
|
||||
$email = strtolower($email);
|
||||
$user = User::whereEmail($email)->first();
|
||||
if (! $user) {
|
||||
$settings = instanceSettings();
|
||||
if (! $settings->is_registration_enabled) {
|
||||
abort(403, 'Registration is disabled');
|
||||
}
|
||||
|
||||
$user = User::create([
|
||||
'name' => $oauthUser->name,
|
||||
'email' => $email,
|
||||
]);
|
||||
}
|
||||
Auth::login($user);
|
||||
$oauthSetting = $this->enabledProvider($provider);
|
||||
$oauthUser = get_socialite_provider($oauthSetting->provider)->user();
|
||||
$oauthLoginService->login($oauthSetting->provider, $oauthUser, $oauthSetting);
|
||||
|
||||
return redirect('/');
|
||||
} catch (\Exception $e) {
|
||||
$this->logCallbackFailure($provider, $e);
|
||||
|
||||
$errorCode = $e instanceof HttpException ? 'auth.failed' : 'auth.failed.callback';
|
||||
|
||||
return redirect()->route('login')->withErrors([__($errorCode)]);
|
||||
}
|
||||
}
|
||||
|
||||
private function logCallbackFailure(string $provider, \Throwable $exception): void
|
||||
{
|
||||
Log::error('OAuth callback failed.', [
|
||||
'provider' => $provider,
|
||||
'exception_class' => $exception::class,
|
||||
'exception_message' => $exception->getMessage(),
|
||||
'request_error' => request()->query('error'),
|
||||
'request_error_description' => request()->query('error_description'),
|
||||
'has_code' => request()->query->has('code'),
|
||||
'has_state' => request()->query->has('state'),
|
||||
'ip' => request()->ip(),
|
||||
'exception' => $exception,
|
||||
]);
|
||||
}
|
||||
|
||||
private function enabledProvider(string $provider): OauthSetting
|
||||
{
|
||||
$oauthSetting = OauthSetting::where('provider', $provider)->first();
|
||||
if (! $oauthSetting || ! $oauthSetting->enabled || ! $oauthSetting->couldBeEnabled()) {
|
||||
throw new HttpException(403, 'OAuth provider is not enabled');
|
||||
}
|
||||
|
||||
return $oauthSetting;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,30 @@ class Discord extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleDiscordEnabled(): void
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
|
||||
if ($this->discordEnabled) {
|
||||
$this->discordEnabled = false;
|
||||
} else {
|
||||
$this->validate([
|
||||
'discordWebhookUrl' => 'required',
|
||||
], [
|
||||
'discordWebhookUrl.required' => 'Discord Webhook URL is required.',
|
||||
]);
|
||||
$this->discordEnabled = true;
|
||||
}
|
||||
|
||||
$this->saveModel();
|
||||
} catch (\Throwable $e) {
|
||||
$this->syncData();
|
||||
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Livewire\Notifications;
|
||||
|
||||
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
|
||||
use App\Models\EmailNotificationSettings;
|
||||
use App\Models\Team;
|
||||
use App\Notifications\Test;
|
||||
@@ -15,7 +14,7 @@ use Livewire\Component;
|
||||
|
||||
class Email extends Component
|
||||
{
|
||||
use AuthorizesRequests, TogglesNotificationEvents;
|
||||
use AuthorizesRequests;
|
||||
|
||||
protected $listeners = ['refresh' => '$refresh'];
|
||||
|
||||
@@ -252,32 +251,59 @@ class Email extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleSmtp()
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
|
||||
if ($this->smtpEnabled) {
|
||||
$this->smtpEnabled = false;
|
||||
$this->saveModel();
|
||||
} else {
|
||||
$this->validateSmtpSettings();
|
||||
$this->smtpEnabled = true;
|
||||
$this->resendEnabled = false;
|
||||
$this->submitSmtp();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->syncData();
|
||||
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
$this->dispatch('refresh');
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleResend()
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
|
||||
if ($this->resendEnabled) {
|
||||
$this->resendEnabled = false;
|
||||
$this->saveModel();
|
||||
} else {
|
||||
$this->validateResendSettings();
|
||||
$this->resendEnabled = true;
|
||||
$this->smtpEnabled = false;
|
||||
$this->submitResend();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->syncData();
|
||||
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
$this->dispatch('refresh');
|
||||
}
|
||||
}
|
||||
|
||||
public function submitSmtp()
|
||||
{
|
||||
$this->authorize('update', $this->settings);
|
||||
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
$this->validate([
|
||||
'smtpEnabled' => 'boolean',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
'smtpHost' => 'required|string',
|
||||
'smtpPort' => 'required|numeric',
|
||||
'smtpEncryption' => 'required|string|in:starttls,tls,none',
|
||||
'smtpUsername' => 'nullable|string',
|
||||
'smtpPassword' => 'nullable|string',
|
||||
'smtpTimeout' => 'nullable|numeric',
|
||||
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
|
||||
], [
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromName.required' => 'From Name is required.',
|
||||
'smtpHost.required' => 'SMTP Host is required.',
|
||||
'smtpPort.required' => 'SMTP Port is required.',
|
||||
'smtpPort.numeric' => 'SMTP Port must be a number.',
|
||||
'smtpEncryption.required' => 'Encryption type is required.',
|
||||
]);
|
||||
$this->validateSmtpSettings();
|
||||
|
||||
if ($this->smtpEnabled) {
|
||||
$this->settings->resend_enabled = $this->resendEnabled = false;
|
||||
@@ -309,17 +335,7 @@ class Email extends Component
|
||||
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
$this->validate([
|
||||
'resendEnabled' => 'boolean',
|
||||
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
], [
|
||||
'resendApiKey.required' => 'Resend API Key is required.',
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromName.required' => 'From Name is required.',
|
||||
]);
|
||||
$this->validateResendSettings();
|
||||
if ($this->resendEnabled) {
|
||||
$this->settings->smtp_enabled = $this->smtpEnabled = false;
|
||||
}
|
||||
@@ -336,6 +352,45 @@ class Email extends Component
|
||||
}
|
||||
}
|
||||
|
||||
private function validateSmtpSettings(): void
|
||||
{
|
||||
$this->validate([
|
||||
'smtpEnabled' => 'boolean',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
'smtpHost' => 'required|string',
|
||||
'smtpPort' => 'required|numeric',
|
||||
'smtpEncryption' => 'required|string|in:starttls,tls,none',
|
||||
'smtpUsername' => 'nullable|string',
|
||||
'smtpPassword' => 'nullable|string',
|
||||
'smtpTimeout' => 'nullable|numeric',
|
||||
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
|
||||
], [
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromName.required' => 'From Name is required.',
|
||||
'smtpHost.required' => 'SMTP Host is required.',
|
||||
'smtpPort.required' => 'SMTP Port is required.',
|
||||
'smtpPort.numeric' => 'SMTP Port must be a number.',
|
||||
'smtpEncryption.required' => 'Encryption type is required.',
|
||||
]);
|
||||
}
|
||||
|
||||
private function validateResendSettings(): void
|
||||
{
|
||||
$this->validate([
|
||||
'resendEnabled' => 'boolean',
|
||||
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
], [
|
||||
'resendApiKey.required' => 'Resend API Key is required.',
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromName.required' => 'From Name is required.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendTestEmail()
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -159,6 +159,34 @@ class Pushover extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function togglePushoverEnabled()
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
|
||||
if ($this->pushoverEnabled) {
|
||||
$this->pushoverEnabled = false;
|
||||
} else {
|
||||
$this->validate([
|
||||
'pushoverUserKey' => 'required',
|
||||
'pushoverApiToken' => 'required',
|
||||
], [
|
||||
'pushoverUserKey.required' => 'Pushover User Key is required.',
|
||||
'pushoverApiToken.required' => 'Pushover API Token is required.',
|
||||
]);
|
||||
$this->pushoverEnabled = true;
|
||||
}
|
||||
|
||||
$this->saveModel();
|
||||
} catch (\Throwable $e) {
|
||||
$this->syncData();
|
||||
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
$this->dispatch('refresh');
|
||||
}
|
||||
}
|
||||
|
||||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -150,6 +150,32 @@ class Slack extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleSlackEnabled()
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
|
||||
if ($this->slackEnabled) {
|
||||
$this->slackEnabled = false;
|
||||
} else {
|
||||
$this->validate([
|
||||
'slackWebhookUrl' => 'required',
|
||||
], [
|
||||
'slackWebhookUrl.required' => 'Slack Webhook URL is required.',
|
||||
]);
|
||||
$this->slackEnabled = true;
|
||||
}
|
||||
|
||||
$this->saveModel();
|
||||
} catch (\Throwable $e) {
|
||||
$this->syncData();
|
||||
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
$this->dispatch('refresh');
|
||||
}
|
||||
}
|
||||
|
||||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -252,6 +252,34 @@ class Telegram extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleTelegramEnabled(): void
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
|
||||
if ($this->telegramEnabled) {
|
||||
$this->telegramEnabled = false;
|
||||
} else {
|
||||
$this->validate([
|
||||
'telegramToken' => 'required',
|
||||
'telegramChatId' => 'required',
|
||||
], [
|
||||
'telegramToken.required' => 'Telegram Token is required.',
|
||||
'telegramChatId.required' => 'Telegram Chat ID is required.',
|
||||
]);
|
||||
$this->telegramEnabled = true;
|
||||
}
|
||||
|
||||
$this->saveModel();
|
||||
} catch (\Throwable $e) {
|
||||
$this->syncData();
|
||||
|
||||
handleError($e, $this);
|
||||
} finally {
|
||||
$this->dispatch('refresh');
|
||||
}
|
||||
}
|
||||
|
||||
public function saveModel()
|
||||
{
|
||||
$this->syncData(true);
|
||||
|
||||
@@ -144,6 +144,30 @@ class Webhook extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleWebhookEnabled()
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
|
||||
if ($this->webhookEnabled) {
|
||||
$this->webhookEnabled = false;
|
||||
} else {
|
||||
$this->validate([
|
||||
'webhookUrl' => 'required',
|
||||
], [
|
||||
'webhookUrl.required' => 'Webhook URL is required.',
|
||||
]);
|
||||
$this->webhookEnabled = true;
|
||||
}
|
||||
|
||||
$this->saveModel();
|
||||
} catch (\Throwable $e) {
|
||||
$this->syncData();
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -2,19 +2,15 @@
|
||||
|
||||
namespace App\Livewire\Profile;
|
||||
|
||||
use App\Services\AvatarStorageService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class Index extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public int $userId;
|
||||
|
||||
public string $email;
|
||||
@@ -36,6 +32,10 @@ class Index extends Component
|
||||
|
||||
public bool $show_verification = false;
|
||||
|
||||
public bool $uses_sso = false;
|
||||
|
||||
public ?string $sso_provider_label = null;
|
||||
|
||||
public $avatar;
|
||||
|
||||
public function uploadAvatar(AvatarStorageService $avatarStorage): bool
|
||||
@@ -75,8 +75,12 @@ class Index extends Component
|
||||
$this->name = Auth::user()->name;
|
||||
$this->email = Auth::user()->email;
|
||||
|
||||
$oauthIdentity = Auth::user()->oauthIdentities()->latest('id')->first();
|
||||
$this->uses_sso = $oauthIdentity !== null;
|
||||
$this->sso_provider_label = $oauthIdentity ? $this->providerLabel($oauthIdentity->provider) : null;
|
||||
|
||||
// Check if there's a pending email change
|
||||
if (Auth::user()->hasEmailChangeRequest()) {
|
||||
if (! $this->uses_sso && Auth::user()->hasEmailChangeRequest()) {
|
||||
$this->new_email = Auth::user()->pending_email;
|
||||
$this->show_verification = true;
|
||||
}
|
||||
@@ -101,6 +105,10 @@ class Index extends Component
|
||||
public function requestEmailChange()
|
||||
{
|
||||
try {
|
||||
if ($this->rejectSsoEmailChange()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For self-hosted, check if email is enabled
|
||||
if (! isCloud()) {
|
||||
$settings = instanceSettings();
|
||||
@@ -159,6 +167,10 @@ class Index extends Component
|
||||
public function verifyEmailChange()
|
||||
{
|
||||
try {
|
||||
if ($this->rejectSsoEmailChange()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate([
|
||||
'email_verification_code' => ['required', 'string', 'size:6'],
|
||||
]);
|
||||
@@ -204,7 +216,6 @@ class Index extends Component
|
||||
$this->show_verification = false;
|
||||
|
||||
$this->dispatch('success', 'Email address updated successfully.');
|
||||
$this->dispatch('close-email-change-modal');
|
||||
} else {
|
||||
$this->dispatch('error', 'Failed to update email address.');
|
||||
}
|
||||
@@ -216,6 +227,10 @@ class Index extends Component
|
||||
public function resendVerificationCode()
|
||||
{
|
||||
try {
|
||||
if ($this->rejectSsoEmailChange()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there's a pending request
|
||||
if (! Auth::user()->hasEmailChangeRequest()) {
|
||||
$this->dispatch('error', 'No pending email change request.');
|
||||
@@ -269,6 +284,30 @@ class Index extends Component
|
||||
$this->dispatch('success', 'Email change request cancelled.');
|
||||
}
|
||||
|
||||
public function showEmailChangeForm()
|
||||
{
|
||||
if ($this->rejectSsoEmailChange()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->show_email_change = true;
|
||||
$this->new_email = '';
|
||||
}
|
||||
|
||||
private function rejectSsoEmailChange(): bool
|
||||
{
|
||||
if (! Auth::user()->hasSsoIdentity()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->uses_sso = true;
|
||||
$this->show_email_change = false;
|
||||
$this->show_verification = false;
|
||||
$this->dispatch('error', 'Email addresses managed by SSO cannot be changed in Coolify.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function resetPassword()
|
||||
{
|
||||
try {
|
||||
@@ -299,6 +338,14 @@ class Index extends Component
|
||||
}
|
||||
}
|
||||
|
||||
private function providerLabel(string $provider): string
|
||||
{
|
||||
return match ($provider) {
|
||||
'oidc' => 'OIDC',
|
||||
default => str($provider)->headline()->toString(),
|
||||
};
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.profile.index');
|
||||
|
||||
@@ -77,6 +77,7 @@ class Storage extends Component
|
||||
$this->activeTab = $this->resolveDefaultTab();
|
||||
$this->fileStorage = collect();
|
||||
$this->loadFileStorageForActiveTab();
|
||||
$this->name = $this->generateDefaultVolumeName();
|
||||
}
|
||||
|
||||
public function refreshStoragesFromEvent()
|
||||
@@ -201,9 +202,7 @@ class Storage extends Component
|
||||
$this->validate([
|
||||
'name' => ValidationPatterns::volumeNameRules(),
|
||||
'mount_path' => 'required|string',
|
||||
'host_path' => $this->isSwarm
|
||||
? ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN]
|
||||
: ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||
'host_path' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||
], array_merge(ValidationPatterns::volumeNameMessages(), [
|
||||
'host_path.regex' => 'Host path must start with / and only contain safe path characters.',
|
||||
]));
|
||||
@@ -340,7 +339,7 @@ class Storage extends Component
|
||||
|
||||
public function clearForm()
|
||||
{
|
||||
$this->name = '';
|
||||
$this->name = $this->generateDefaultVolumeName();
|
||||
$this->mount_path = '';
|
||||
$this->host_path = null;
|
||||
$this->file_storage_path = '';
|
||||
@@ -373,6 +372,13 @@ class Storage extends Component
|
||||
throw new \Exception('No valid resource type for file mount storage type!');
|
||||
}
|
||||
|
||||
private function generateDefaultVolumeName(): string
|
||||
{
|
||||
$name = str($this->resource->name)->slug()->value();
|
||||
|
||||
return ($name ?: 'volume').'-data';
|
||||
}
|
||||
|
||||
public function fileStoragePreviewPath(): string
|
||||
{
|
||||
$path = str($this->file_storage_path)->trim();
|
||||
|
||||
@@ -161,6 +161,22 @@ class Show extends Component
|
||||
$this->valuesLoaded = true;
|
||||
}
|
||||
|
||||
public function copyValue(): ?string
|
||||
{
|
||||
if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->env instanceof ModelsEnvironmentVariable) {
|
||||
return $this->env->value;
|
||||
}
|
||||
|
||||
return $this->env->get_real_environment_variables_with_server(
|
||||
$this->env->resolveReferencedValue(),
|
||||
$this->env->resourceable,
|
||||
);
|
||||
}
|
||||
|
||||
public function syncData(bool $toModel = false)
|
||||
{
|
||||
if ($toModel) {
|
||||
@@ -204,7 +220,7 @@ class Show extends Component
|
||||
$this->is_required = (bool) ($this->env->is_required ?? false);
|
||||
// Use the stored column, not the value-based accessor (that decrypts).
|
||||
$this->is_shared = (bool) ($this->env->getAttributes()['is_shared'] ?? false);
|
||||
$this->isValueHidden = auth()->user()?->isMember() ?? false;
|
||||
$this->isValueHidden = auth()->user()?->isMember() ?? true;
|
||||
|
||||
if ($this->valuesLoaded) {
|
||||
$this->hydrateValueFields();
|
||||
@@ -231,12 +247,12 @@ class Show extends Component
|
||||
$this->is_really_required = $this->is_required && blank($this->value);
|
||||
}
|
||||
|
||||
if ($this->env->is_shown_once || auth()->user()?->isMember()) {
|
||||
if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) {
|
||||
$this->value = null;
|
||||
$this->real_value = null;
|
||||
}
|
||||
|
||||
$this->isValueHidden = auth()->user()?->isMember() ?? false;
|
||||
$this->isValueHidden = auth()->user()?->isMember() ?? true;
|
||||
}
|
||||
|
||||
public function checkEnvs()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||
|
||||
use App\Models\EnvironmentVariable;
|
||||
use Livewire\Component;
|
||||
|
||||
class ShowHardcoded extends Component
|
||||
@@ -20,6 +21,10 @@ class ShowHardcoded extends Component
|
||||
|
||||
public bool $isPreview = false;
|
||||
|
||||
public ?string $resourceableType = null;
|
||||
|
||||
public ?int $resourceableId = null;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->key = $this->env['key'];
|
||||
@@ -28,6 +33,20 @@ class ShowHardcoded extends Component
|
||||
$this->serviceName = $this->env['service_name'] ?? null;
|
||||
}
|
||||
|
||||
public function copyValue(): ?string
|
||||
{
|
||||
if (auth()->user()?->isMember() ?? true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return EnvironmentVariable::make([
|
||||
'value' => $this->value,
|
||||
'is_preview' => $this->isPreview,
|
||||
'resourceable_type' => $this->resourceableType,
|
||||
'resourceable_id' => $this->resourceableId,
|
||||
])->resolveReferencedValue();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.shared.environment-variable.show-hardcoded');
|
||||
|
||||
@@ -107,6 +107,25 @@ class All extends Component
|
||||
$this->submit($storageId);
|
||||
}
|
||||
|
||||
public function clearHostPath(int $storageId): void
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$storage = $this->findStorageOrFail($storageId);
|
||||
if ($storage->shouldBeReadOnlyInUI()) {
|
||||
$this->dispatch('error', 'This volume is read-only.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$storage->host_path = null;
|
||||
$storage->save();
|
||||
$this->forms[$storageId]['hostPath'] = null;
|
||||
|
||||
$this->dispatch('configurationChanged');
|
||||
$this->dispatch('success', 'Source path removed. Use a directory mount for host directory bindings.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Livewire listbox onChange cannot pass args; PR suffix fields call this via updatedForms.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Security;
|
||||
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Services\CloudflareTokenValidator;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class IntegrationTokenEditor extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public IntegrationToken $integrationToken;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $newToken = '';
|
||||
|
||||
public array $capabilities = [];
|
||||
|
||||
public function mount(string $integration_token_uuid): void
|
||||
{
|
||||
$this->integrationToken = IntegrationToken::ownedByCurrentTeam()
|
||||
->whereUuid($integration_token_uuid)
|
||||
->firstOrFail();
|
||||
|
||||
$this->authorize('view', $this->integrationToken);
|
||||
|
||||
$this->name = $this->integrationToken->name;
|
||||
$this->capabilities = $this->integrationToken->capabilities;
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'newToken' => ['nullable', 'string'],
|
||||
'capabilities' => ['required', 'array', 'min:1'],
|
||||
'capabilities.*' => ['required', 'in:dns'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function messages(): array
|
||||
{
|
||||
return [
|
||||
'capabilities.required' => 'Select at least one capability.',
|
||||
'capabilities.min' => 'Select at least one capability.',
|
||||
];
|
||||
}
|
||||
|
||||
public function save(CloudflareTokenValidator $validator): void
|
||||
{
|
||||
$this->authorize('update', $this->integrationToken);
|
||||
$validated = $this->validate();
|
||||
$token = filled($validated['newToken']) ? $validated['newToken'] : $this->integrationToken->token;
|
||||
$capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all()
|
||||
!== collect($this->integrationToken->capabilities)->sort()->values()->all();
|
||||
|
||||
try {
|
||||
if ((filled($validated['newToken']) || $capabilitiesChanged)
|
||||
&& ! $validator->validate($token, $validated['capabilities'])) {
|
||||
$this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$updates = [
|
||||
'name' => $validated['name'],
|
||||
'capabilities' => $validated['capabilities'],
|
||||
];
|
||||
|
||||
if (filled($validated['newToken'])) {
|
||||
$updates['token'] = $validated['newToken'];
|
||||
}
|
||||
|
||||
$this->integrationToken->update($updates);
|
||||
$this->newToken = '';
|
||||
|
||||
auditLog('ui.integration_token.updated', [
|
||||
'team_id' => currentTeam()->id,
|
||||
'integration_token_uuid' => $this->integrationToken->uuid,
|
||||
'integration_token_name' => $this->integrationToken->name,
|
||||
'provider' => $this->integrationToken->provider,
|
||||
'rotated' => array_key_exists('token', $updates),
|
||||
]);
|
||||
|
||||
$this->dispatch(
|
||||
'integration-token-updated',
|
||||
uuid: $this->integrationToken->uuid,
|
||||
name: $this->integrationToken->name,
|
||||
capabilities: $this->integrationToken->capabilities,
|
||||
);
|
||||
$this->dispatch('success', 'Integration token updated successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(string $password = ''): void
|
||||
{
|
||||
$this->authorize('delete', $this->integrationToken);
|
||||
$this->integrationToken->delete();
|
||||
|
||||
$this->dispatch('integration-token-deleted', uuid: $this->integrationToken->uuid);
|
||||
$this->dispatch('close-modal');
|
||||
$this->dispatch('success', 'Integration token deleted successfully.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.security.integration-token-editor');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Security;
|
||||
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Services\CloudflareTokenValidator;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class IntegrationTokenForm extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public bool $modal_mode = false;
|
||||
|
||||
public string $provider = 'cloudflare';
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $token = '';
|
||||
|
||||
public array $capabilities = ['dns'];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('create', IntegrationToken::class);
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'provider' => ['required', 'in:cloudflare'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'token' => ['required', 'string'],
|
||||
'capabilities' => ['required', 'array', 'min:1'],
|
||||
'capabilities.*' => ['required', 'in:dns'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function messages(): array
|
||||
{
|
||||
return [
|
||||
'capabilities.required' => 'Select at least one capability.',
|
||||
'capabilities.min' => 'Select at least one capability.',
|
||||
];
|
||||
}
|
||||
|
||||
public function addToken(CloudflareTokenValidator $validator): void
|
||||
{
|
||||
$validated = $this->validate();
|
||||
|
||||
try {
|
||||
if (! $validator->validate($validated['token'], $validated['capabilities'])) {
|
||||
$this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
IntegrationToken::query()->create([
|
||||
...$validated,
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
|
||||
$this->reset(['name', 'token']);
|
||||
$this->dispatch('integrationTokenAdded')->to(IntegrationTokens::class);
|
||||
|
||||
if ($this->modal_mode) {
|
||||
$this->dispatch('close-modal');
|
||||
}
|
||||
|
||||
$this->dispatch('success', 'Integration token added successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.security.integration-token-form');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Security;
|
||||
|
||||
use App\Models\IntegrationToken;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class IntegrationTokens extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $tokens;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('viewAny', IntegrationToken::class);
|
||||
$this->loadTokens();
|
||||
}
|
||||
|
||||
#[On('integrationTokenAdded')]
|
||||
public function loadTokens(): void
|
||||
{
|
||||
$this->tokens = IntegrationToken::ownedByCurrentTeam()->latest()->get();
|
||||
}
|
||||
|
||||
public function deleteToken(int $tokenId, string $password = ''): void
|
||||
{
|
||||
$token = IntegrationToken::ownedByCurrentTeam()->findOrFail($tokenId);
|
||||
$this->authorize('delete', $token);
|
||||
$token->delete();
|
||||
$this->loadTokens();
|
||||
$this->dispatch('success', 'Integration token deleted successfully.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.security.integration-tokens');
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,49 @@ class LogDrains extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleLogDrain(string $type): void
|
||||
{
|
||||
$previousNewRelicEnabled = $this->server->settings->is_logdrain_newrelic_enabled;
|
||||
$previousAxiomEnabled = $this->server->settings->is_logdrain_axiom_enabled;
|
||||
$previousCustomEnabled = $this->server->settings->is_logdrain_custom_enabled;
|
||||
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->resetErrorBag();
|
||||
|
||||
$enabledProperty = $this->enabledProperty($type);
|
||||
|
||||
if ($this->{$enabledProperty}) {
|
||||
$this->{$enabledProperty} = false;
|
||||
} else {
|
||||
$this->validateLogDrainSettings($type);
|
||||
$this->isLogDrainNewRelicEnabled = $type === 'newrelic';
|
||||
$this->isLogDrainAxiomEnabled = $type === 'axiom';
|
||||
$this->isLogDrainCustomEnabled = $type === 'custom';
|
||||
}
|
||||
|
||||
$this->syncData(true);
|
||||
|
||||
if ($this->server->isLogDrainEnabled()) {
|
||||
StartLogDrain::run($this->server);
|
||||
$this->dispatch('success', 'Log drain service started.');
|
||||
} else {
|
||||
StopLogDrain::run($this->server);
|
||||
$this->dispatch('success', 'Log drain service stopped.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Restore the previously persisted enabled flags so the UI/DB never
|
||||
// claim a runtime state that the Start/StopLogDrain action failed to apply.
|
||||
$this->server->settings->is_logdrain_newrelic_enabled = $previousNewRelicEnabled;
|
||||
$this->server->settings->is_logdrain_axiom_enabled = $previousAxiomEnabled;
|
||||
$this->server->settings->is_logdrain_custom_enabled = $previousCustomEnabled;
|
||||
$this->server->settings->save();
|
||||
$this->syncData();
|
||||
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
@@ -192,4 +235,33 @@ class LogDrains extends Component
|
||||
{
|
||||
return view('livewire.server.log-drains');
|
||||
}
|
||||
|
||||
private function enabledProperty(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'newrelic' => 'isLogDrainNewRelicEnabled',
|
||||
'axiom' => 'isLogDrainAxiomEnabled',
|
||||
'custom' => 'isLogDrainCustomEnabled',
|
||||
default => throw new \InvalidArgumentException('Unknown log drain type.'),
|
||||
};
|
||||
}
|
||||
|
||||
private function validateLogDrainSettings(string $type): void
|
||||
{
|
||||
match ($type) {
|
||||
'newrelic' => $this->validate([
|
||||
'logDrainNewRelicLicenseKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
|
||||
'logDrainNewRelicBaseUri' => ['required', 'url'],
|
||||
]),
|
||||
'axiom' => $this->validate([
|
||||
'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
|
||||
'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
|
||||
]),
|
||||
'custom' => $this->validate([
|
||||
'logDrainCustomConfig' => ['required'],
|
||||
'logDrainCustomConfigParser' => ['string', 'nullable'],
|
||||
]),
|
||||
default => throw new \InvalidArgumentException('Unknown log drain type.'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ class Advanced extends Component
|
||||
#[Validate('boolean')]
|
||||
public bool $is_registration_enabled;
|
||||
|
||||
#[Validate('boolean')]
|
||||
public bool $disable_registration_when_oauth_enabled;
|
||||
|
||||
#[Validate('boolean')]
|
||||
public bool $do_not_track;
|
||||
|
||||
@@ -59,6 +62,7 @@ class Advanced extends Component
|
||||
{
|
||||
return [
|
||||
'is_registration_enabled' => 'boolean',
|
||||
'disable_registration_when_oauth_enabled' => 'boolean',
|
||||
'do_not_track' => 'boolean',
|
||||
'is_dns_validation_enabled' => 'boolean',
|
||||
'custom_dns_servers' => ['nullable', 'string', new ValidDnsServers],
|
||||
@@ -84,6 +88,7 @@ class Advanced extends Component
|
||||
$this->allowed_ips = $this->settings->allowed_ips;
|
||||
$this->do_not_track = $this->settings->do_not_track;
|
||||
$this->is_registration_enabled = $this->settings->is_registration_enabled;
|
||||
$this->disable_registration_when_oauth_enabled = $this->settings->disable_registration_when_oauth_enabled;
|
||||
$this->is_dns_validation_enabled = $this->settings->is_dns_validation_enabled;
|
||||
$this->is_api_enabled = $this->settings->is_api_enabled;
|
||||
$this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation;
|
||||
@@ -199,6 +204,7 @@ class Advanced extends Component
|
||||
try {
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->settings->is_registration_enabled = $this->is_registration_enabled;
|
||||
$this->settings->disable_registration_when_oauth_enabled = $this->disable_registration_when_oauth_enabled;
|
||||
$this->settings->do_not_track = $this->do_not_track;
|
||||
$this->settings->is_dns_validation_enabled = $this->is_dns_validation_enabled;
|
||||
$this->settings->custom_dns_servers = $this->custom_dns_servers;
|
||||
|
||||
@@ -160,30 +160,59 @@ class SettingsEmail extends Component
|
||||
$this->instantSave('Resend');
|
||||
}
|
||||
|
||||
public function toggleSmtp()
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
|
||||
if ($this->smtpEnabled) {
|
||||
$this->smtpEnabled = false;
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'SMTP settings updated.');
|
||||
} else {
|
||||
$this->validateSmtpSettings();
|
||||
$this->smtpEnabled = true;
|
||||
$this->resendEnabled = false;
|
||||
$this->submitSmtp();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->syncData();
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleResend()
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
|
||||
if ($this->resendEnabled) {
|
||||
$this->resendEnabled = false;
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Resend settings updated.');
|
||||
} else {
|
||||
$this->validateResendSettings();
|
||||
$this->resendEnabled = true;
|
||||
$this->smtpEnabled = false;
|
||||
$this->submitResend();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->syncData();
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submitSmtp()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->validate([
|
||||
'smtpEnabled' => 'boolean',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
'smtpHost' => 'required|string',
|
||||
'smtpPort' => 'required|numeric',
|
||||
'smtpEncryption' => 'required|string|in:starttls,tls,none',
|
||||
'smtpUsername' => 'nullable|string',
|
||||
'smtpPassword' => 'nullable|string',
|
||||
'smtpTimeout' => 'nullable|numeric',
|
||||
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
|
||||
], [
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromName.required' => 'From Name is required.',
|
||||
'smtpHost.required' => 'SMTP Host is required.',
|
||||
'smtpPort.required' => 'SMTP Port is required.',
|
||||
'smtpPort.numeric' => 'SMTP Port must be a number.',
|
||||
'smtpEncryption.required' => 'Encryption type is required.',
|
||||
]);
|
||||
$this->validateSmtpSettings();
|
||||
|
||||
if ($this->smtpEnabled) {
|
||||
$this->settings->resend_enabled = $this->resendEnabled = false;
|
||||
}
|
||||
|
||||
$this->settings->smtp_enabled = $this->smtpEnabled;
|
||||
$this->settings->smtp_host = $this->smtpHost;
|
||||
@@ -210,17 +239,11 @@ class SettingsEmail extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->validate([
|
||||
'resendEnabled' => 'boolean',
|
||||
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
], [
|
||||
'resendApiKey.required' => 'Resend API Key is required.',
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromName.required' => 'From Name is required.',
|
||||
]);
|
||||
$this->validateResendSettings();
|
||||
|
||||
if ($this->resendEnabled) {
|
||||
$this->settings->smtp_enabled = $this->smtpEnabled = false;
|
||||
}
|
||||
|
||||
$this->settings->resend_enabled = $this->resendEnabled;
|
||||
$this->settings->resend_api_key = $this->resendApiKey;
|
||||
@@ -237,6 +260,45 @@ class SettingsEmail extends Component
|
||||
}
|
||||
}
|
||||
|
||||
private function validateSmtpSettings(): void
|
||||
{
|
||||
$this->validate([
|
||||
'smtpEnabled' => 'boolean',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
'smtpHost' => 'required|string',
|
||||
'smtpPort' => 'required|numeric',
|
||||
'smtpEncryption' => 'required|string|in:starttls,tls,none',
|
||||
'smtpUsername' => 'nullable|string',
|
||||
'smtpPassword' => 'nullable|string',
|
||||
'smtpTimeout' => 'nullable|numeric',
|
||||
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
|
||||
], [
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromName.required' => 'From Name is required.',
|
||||
'smtpHost.required' => 'SMTP Host is required.',
|
||||
'smtpPort.required' => 'SMTP Port is required.',
|
||||
'smtpPort.numeric' => 'SMTP Port must be a number.',
|
||||
'smtpEncryption.required' => 'Encryption type is required.',
|
||||
]);
|
||||
}
|
||||
|
||||
private function validateResendSettings(): void
|
||||
{
|
||||
$this->validate([
|
||||
'resendEnabled' => 'boolean',
|
||||
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
], [
|
||||
'resendApiKey.required' => 'Resend API Key is required.',
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromName.required' => 'From Name is required.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendTestEmail()
|
||||
{
|
||||
try {
|
||||
|
||||
+232
-114
@@ -2,53 +2,89 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\OauthSetting;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Component;
|
||||
|
||||
class SettingsOauth extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public InstanceSettings $settings;
|
||||
|
||||
public $oauth_settings_map;
|
||||
|
||||
protected function rules()
|
||||
public ?string $selectedProvider = null;
|
||||
|
||||
public bool $disable_registration_when_oauth_enabled = false;
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return OauthSetting::all()->reduce(function ($carry, $setting) {
|
||||
$carry["oauth_settings_map.$setting->provider.enabled"] = 'required';
|
||||
$carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable';
|
||||
$carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable';
|
||||
$carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable';
|
||||
$carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable';
|
||||
$carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable';
|
||||
return $this->validationRules();
|
||||
}
|
||||
|
||||
private function validationRules(?string $provider = null): array
|
||||
{
|
||||
$rules = OauthSetting::all()->reduce(function ($carry, $setting) use ($provider) {
|
||||
if ($provider !== null && $setting->provider !== $provider) {
|
||||
return $carry;
|
||||
}
|
||||
|
||||
$carry["oauth_settings_map.$setting->provider.enabled"] = 'required|boolean';
|
||||
$carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable|string';
|
||||
$carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable|string';
|
||||
$carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable|string|max:2048|url:http,https';
|
||||
$carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable|string';
|
||||
$carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable|string|max:2048|url:http,https';
|
||||
$carry["oauth_settings_map.$setting->provider.custom_label"] = 'nullable|string|max:255';
|
||||
$carry["oauth_settings_map.$setting->provider.scopes"] = 'nullable|string|max:1000';
|
||||
$carry["oauth_settings_map.$setting->provider.allow_registration"] = 'boolean';
|
||||
$carry["oauth_settings_map.$setting->provider.auto_join_root_team"] = 'boolean';
|
||||
$carry["oauth_settings_map.$setting->provider.require_email_verified"] = 'boolean';
|
||||
$carry["oauth_settings_map.$setting->provider.use_pkce"] = 'boolean';
|
||||
$carry["oauth_settings_map.$setting->provider.clock_skew_seconds"] = 'nullable|integer|min:0|max:600';
|
||||
|
||||
return $carry;
|
||||
}, []);
|
||||
|
||||
if ($provider === null) {
|
||||
$rules['disable_registration_when_oauth_enabled'] = 'boolean';
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public function mount()
|
||||
public function mount(?string $provider = null): ?RedirectResponse
|
||||
{
|
||||
if (! isInstanceAdmin()) {
|
||||
return redirect()->route('home');
|
||||
}
|
||||
$this->oauth_settings_map = OauthSetting::all()->sortBy('provider')->reduce(function ($carry, $setting) {
|
||||
$carry[$setting->provider] = [
|
||||
'id' => $setting->id,
|
||||
'provider' => $setting->provider,
|
||||
'enabled' => $setting->enabled,
|
||||
'client_id' => $setting->client_id,
|
||||
'client_secret' => $setting->client_secret,
|
||||
'redirect_uri' => $setting->redirect_uri,
|
||||
'tenant' => $setting->tenant,
|
||||
'base_url' => $setting->base_url,
|
||||
];
|
||||
|
||||
return $carry;
|
||||
}, []);
|
||||
$this->settings = instanceSettings();
|
||||
$this->selectedProvider = $provider;
|
||||
$this->disable_registration_when_oauth_enabled = (bool) $this->settings->disable_registration_when_oauth_enabled;
|
||||
$this->oauth_settings_map = OauthSetting::all()
|
||||
->sortBy(fn (OauthSetting $setting): string => $setting->isOidc() ? '' : $setting->provider)
|
||||
->reduce(function ($carry, $setting) {
|
||||
$carry[$setting->provider] = $this->oauthSettingToArray($setting);
|
||||
|
||||
return $carry;
|
||||
}, []);
|
||||
|
||||
if ($this->selectedProvider !== null && ! array_key_exists($this->selectedProvider, $this->oauth_settings_map)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function updateOauthSettings(?string $provider = null)
|
||||
private function updateOauthSettings(?string $provider = null): void
|
||||
{
|
||||
$this->validate($this->validationRules($provider));
|
||||
|
||||
if ($provider) {
|
||||
$oauthData = $this->oauth_settings_map[$provider];
|
||||
$oauth = OauthSetting::find($oauthData['id']);
|
||||
@@ -57,78 +93,128 @@ class SettingsOauth extends Component
|
||||
throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.');
|
||||
}
|
||||
|
||||
$oauth->fill([
|
||||
'enabled' => $oauthData['enabled'],
|
||||
'client_id' => $oauthData['client_id'],
|
||||
'client_secret' => $oauthData['client_secret'],
|
||||
'redirect_uri' => $oauthData['redirect_uri'],
|
||||
'tenant' => $oauthData['tenant'],
|
||||
'base_url' => $oauthData['base_url'],
|
||||
]);
|
||||
|
||||
if ($oauthData['enabled'] && ! $oauth->couldBeEnabled()) {
|
||||
$oauth->update(['enabled' => false]);
|
||||
throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.<br/>Please fill in all required fields.');
|
||||
}
|
||||
$this->fillOauthSetting($oauth, $oauthData);
|
||||
$this->ensureProviderCanBeEnabled($oauth);
|
||||
$oauth->save();
|
||||
|
||||
// Update the array with fresh data
|
||||
$this->oauth_settings_map[$provider] = [
|
||||
'id' => $oauth->id,
|
||||
'provider' => $oauth->provider,
|
||||
'enabled' => $oauth->enabled,
|
||||
'client_id' => $oauth->client_id,
|
||||
'client_secret' => $oauth->client_secret,
|
||||
'redirect_uri' => $oauth->redirect_uri,
|
||||
'tenant' => $oauth->tenant,
|
||||
'base_url' => $oauth->base_url,
|
||||
];
|
||||
$this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth);
|
||||
|
||||
$this->dispatch('success', 'OAuth settings for '.$oauth->provider.' updated successfully!');
|
||||
} else {
|
||||
$errors = [];
|
||||
foreach (array_values($this->oauth_settings_map) as $settingData) {
|
||||
$oauth = OauthSetting::find($settingData['id']);
|
||||
|
||||
if (! $oauth) {
|
||||
$errors[] = "OAuth setting for provider '{$settingData['provider']}' not found. It may have been deleted.";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$oauth->fill([
|
||||
'enabled' => $settingData['enabled'],
|
||||
'client_id' => $settingData['client_id'],
|
||||
'client_secret' => $settingData['client_secret'],
|
||||
'redirect_uri' => $settingData['redirect_uri'],
|
||||
'tenant' => $settingData['tenant'],
|
||||
'base_url' => $settingData['base_url'],
|
||||
]);
|
||||
|
||||
if ($settingData['enabled'] && ! $oauth->couldBeEnabled()) {
|
||||
$oauth->enabled = false;
|
||||
$errors[] = "OAuth settings are incomplete for '{$oauth->provider}'. Required fields are missing. The provider has been disabled.";
|
||||
}
|
||||
|
||||
$oauth->save();
|
||||
|
||||
// Update the array with fresh data
|
||||
$this->oauth_settings_map[$oauth->provider] = [
|
||||
'id' => $oauth->id,
|
||||
'provider' => $oauth->provider,
|
||||
'enabled' => $oauth->enabled,
|
||||
'client_id' => $oauth->client_id,
|
||||
'client_secret' => $oauth->client_secret,
|
||||
'redirect_uri' => $oauth->redirect_uri,
|
||||
'tenant' => $oauth->tenant,
|
||||
'base_url' => $oauth->base_url,
|
||||
];
|
||||
}
|
||||
|
||||
if (! empty($errors)) {
|
||||
$this->dispatch('error', implode('<br/>', $errors));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
foreach (array_values($this->oauth_settings_map) as $settingData) {
|
||||
$oauth = OauthSetting::find($settingData['id']);
|
||||
|
||||
if (! $oauth) {
|
||||
$errors[] = "OAuth setting for provider '{$settingData['provider']}' not found. It may have been deleted.";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->fillOauthSetting($oauth, $settingData);
|
||||
|
||||
if ($oauth->enabled && ! $oauth->couldBeEnabled()) {
|
||||
$oauth->enabled = false;
|
||||
$errors[] = "OAuth settings are incomplete for '{$oauth->provider}'. Required fields are missing. The provider has been disabled.";
|
||||
}
|
||||
|
||||
if ($oauth->enabled && $oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) {
|
||||
$oauth->enabled = false;
|
||||
$errors[] = "OIDC scopes must include 'openid'. The provider has been disabled.";
|
||||
}
|
||||
|
||||
$oauth->save();
|
||||
$this->oauth_settings_map[$oauth->provider] = $this->oauthSettingToArray($oauth);
|
||||
}
|
||||
|
||||
instanceSettings()->update([
|
||||
'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled,
|
||||
]);
|
||||
|
||||
if (! empty($errors)) {
|
||||
$this->dispatch('error', implode('<br/>', $errors));
|
||||
}
|
||||
}
|
||||
|
||||
private function fillOauthSetting(OauthSetting $oauth, array $data): void
|
||||
{
|
||||
$oauth->fill([
|
||||
'enabled' => (bool) ($data['enabled'] ?? false),
|
||||
'client_id' => $data['client_id'] ?? null,
|
||||
'client_secret' => $data['client_secret'] ?? null,
|
||||
'redirect_uri' => $this->nullableString($data['redirect_uri'] ?? null),
|
||||
'tenant' => $data['tenant'] ?? null,
|
||||
'base_url' => $this->nullableString($data['base_url'] ?? null),
|
||||
'custom_label' => $data['custom_label'] ?? null,
|
||||
'scopes' => $data['scopes'] ?? null,
|
||||
'allow_registration' => (bool) ($data['allow_registration'] ?? false),
|
||||
'auto_join_root_team' => (bool) ($data['auto_join_root_team'] ?? false),
|
||||
'require_email_verified' => (bool) ($data['require_email_verified'] ?? true),
|
||||
'use_pkce' => (bool) ($data['use_pkce'] ?? true),
|
||||
'clock_skew_seconds' => (int) ($data['clock_skew_seconds'] ?? 60),
|
||||
]);
|
||||
}
|
||||
|
||||
private function nullableString(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim((string) $value);
|
||||
|
||||
return $value === '' ? null : $value;
|
||||
}
|
||||
|
||||
private function ensureProviderCanBeEnabled(OauthSetting $oauth): void
|
||||
{
|
||||
if (! $oauth->enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $oauth->couldBeEnabled()) {
|
||||
$oauth->update(['enabled' => false]);
|
||||
throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.<br/>Please fill in all required fields.');
|
||||
}
|
||||
|
||||
if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) {
|
||||
$oauth->update(['enabled' => false]);
|
||||
throw new \Exception("OIDC scopes must include 'openid'.");
|
||||
}
|
||||
}
|
||||
|
||||
private function oauthSettingToArray(OauthSetting $setting): array
|
||||
{
|
||||
return [
|
||||
'id' => $setting->id,
|
||||
'provider' => $setting->provider,
|
||||
'enabled' => $setting->enabled,
|
||||
'client_id' => $setting->client_id,
|
||||
'client_secret' => $setting->client_secret,
|
||||
'redirect_uri' => $setting->redirect_uri,
|
||||
'tenant' => $setting->tenant,
|
||||
'base_url' => $setting->base_url,
|
||||
'custom_label' => $setting->custom_label,
|
||||
'scopes' => $setting->scopes ?: 'openid email profile',
|
||||
'allow_registration' => $setting->allow_registration,
|
||||
'auto_join_root_team' => $setting->auto_join_root_team,
|
||||
'require_email_verified' => $setting->require_email_verified ?? true,
|
||||
'use_pkce' => $setting->use_pkce ?? true,
|
||||
'clock_skew_seconds' => $setting->clock_skew_seconds ?? 60,
|
||||
'label' => $this->providerLabel($setting->provider),
|
||||
];
|
||||
}
|
||||
|
||||
public function providerLabel(string $provider): string
|
||||
{
|
||||
return match ($provider) {
|
||||
'oidc' => 'OpenID Connect',
|
||||
'gitlab' => 'GitLab',
|
||||
default => str($provider)->headline()->toString(),
|
||||
};
|
||||
}
|
||||
|
||||
public function instantSave(string $provider)
|
||||
@@ -141,56 +227,88 @@ class SettingsOauth extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleProvider(string $provider): mixed
|
||||
public function toggleProvider(string $provider)
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', instanceSettings());
|
||||
|
||||
if (! array_key_exists($provider, $this->oauth_settings_map)) {
|
||||
throw new \Exception('OAuth provider not found.');
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$enabling = ! $this->oauth_settings_map[$provider]['enabled'];
|
||||
if ($enabling) {
|
||||
$this->validate($this->providerRules($provider));
|
||||
if (! (bool) $this->oauth_settings_map[$provider]['enabled']) {
|
||||
$this->validateProviderCanBeEnabled($provider);
|
||||
}
|
||||
|
||||
$this->oauth_settings_map[$provider]['enabled'] = $enabling;
|
||||
$this->oauth_settings_map[$provider]['enabled'] = ! (bool) $this->oauth_settings_map[$provider]['enabled'];
|
||||
$this->updateOauthSettings($provider);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (\Exception $e) {
|
||||
$oauth = OauthSetting::where('provider', $provider)->first();
|
||||
if ($oauth) {
|
||||
$this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth);
|
||||
}
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function providerRules(string $provider): array
|
||||
private function validateProviderCanBeEnabled(string $provider): void
|
||||
{
|
||||
$prefix = "oauth_settings_map.$provider";
|
||||
$rules = [
|
||||
"$prefix.client_id" => 'required',
|
||||
"$prefix.client_secret" => 'required',
|
||||
];
|
||||
$this->validate($this->validationRules($provider));
|
||||
|
||||
if ($provider === 'azure') {
|
||||
$rules["$prefix.tenant"] = 'required';
|
||||
$oauth = OauthSetting::find($this->oauth_settings_map[$provider]['id']);
|
||||
if (! $oauth) {
|
||||
throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.');
|
||||
}
|
||||
|
||||
if (in_array($provider, ['authentik', 'clerk'], true)) {
|
||||
$rules["$prefix.base_url"] = 'required';
|
||||
$this->fillOauthSetting($oauth, [
|
||||
...$this->oauth_settings_map[$provider],
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
if (! $oauth->couldBeEnabled()) {
|
||||
throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.<br/>Please fill in all required fields.');
|
||||
}
|
||||
|
||||
return $rules;
|
||||
if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) {
|
||||
throw new \Exception("OIDC scopes must include 'openid'.");
|
||||
}
|
||||
}
|
||||
|
||||
public function submit()
|
||||
public function saveRegistrationPolicy(): void
|
||||
{
|
||||
$this->authorize('update', instanceSettings());
|
||||
$this->validate([
|
||||
'disable_registration_when_oauth_enabled' => 'boolean',
|
||||
]);
|
||||
|
||||
instanceSettings()->update([
|
||||
'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled,
|
||||
]);
|
||||
|
||||
$this->dispatch('success', 'Authentication settings updated successfully!');
|
||||
}
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', instanceSettings());
|
||||
$this->updateOauthSettings();
|
||||
$this->dispatch('success', 'Instance settings updated successfully!');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
$this->updateOauthSettings($this->selectedProvider);
|
||||
|
||||
if ($this->selectedProvider === null) {
|
||||
$this->dispatch('success', 'Instance settings updated successfully!');
|
||||
}
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Exception $e) {
|
||||
if ($this->selectedProvider !== null) {
|
||||
$oauth = OauthSetting::where('provider', $this->selectedProvider)->first();
|
||||
if ($oauth) {
|
||||
$this->oauth_settings_map[$this->selectedProvider] = $this->oauthSettingToArray($oauth);
|
||||
}
|
||||
}
|
||||
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +302,23 @@ class EnvironmentVariable extends BaseModel
|
||||
return $real_value;
|
||||
}
|
||||
|
||||
public function resolveReferencedValue(): ?string
|
||||
{
|
||||
$value = $this->value;
|
||||
|
||||
if ($this->is_literal || blank($value) || ! str($value)->startsWith('$')) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$referencedKey = str($value)->after('$')->trim('{}')->value();
|
||||
|
||||
return static::where('resourceable_type', $this->resourceable_type)
|
||||
->where('resourceable_id', $this->resourceable_id)
|
||||
->where('is_preview', (bool) $this->is_preview)
|
||||
->where('key', $referencedKey)
|
||||
->first()?->value ?? $value;
|
||||
}
|
||||
|
||||
private function get_real_environment_variables(?string $environment_variable = null, $resource = null)
|
||||
{
|
||||
return $this->get_real_environment_variables_internal($environment_variable, $resource);
|
||||
|
||||
@@ -22,6 +22,7 @@ class InstanceSettings extends Model
|
||||
'do_not_track',
|
||||
'is_auto_update_enabled',
|
||||
'is_registration_enabled',
|
||||
'disable_registration_when_oauth_enabled',
|
||||
'next_channel',
|
||||
'smtp_enabled',
|
||||
'smtp_from_address',
|
||||
@@ -88,6 +89,8 @@ class InstanceSettings extends Model
|
||||
|
||||
'allowed_ip_ranges' => 'array',
|
||||
'is_auto_update_enabled' => 'boolean',
|
||||
'is_registration_enabled' => 'boolean',
|
||||
'disable_registration_when_oauth_enabled' => 'boolean',
|
||||
'auto_update_frequency' => 'string',
|
||||
'update_check_frequency' => 'string',
|
||||
'sentinel_token' => 'encrypted',
|
||||
@@ -115,6 +118,19 @@ class InstanceSettings extends Model
|
||||
});
|
||||
}
|
||||
|
||||
public function isPasswordRegistrationAllowed(): bool
|
||||
{
|
||||
if (! $this->is_registration_enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->disable_registration_when_oauth_enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! OauthSetting::where('enabled', true)->exists();
|
||||
}
|
||||
|
||||
public function fqdn(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class IntegrationToken extends BaseModel
|
||||
{
|
||||
protected $fillable = [
|
||||
'team_id',
|
||||
'provider',
|
||||
'name',
|
||||
'token',
|
||||
'capabilities',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'token',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'token' => 'encrypted',
|
||||
'capabilities' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function team(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return self::query()->where('team_id', currentTeam()->id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class OauthIdentity extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'provider',
|
||||
'issuer',
|
||||
'provider_user_id',
|
||||
'email',
|
||||
'raw_claims',
|
||||
'last_login_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'raw_claims' => 'array',
|
||||
'last_login_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,19 @@ class OauthSetting extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled'];
|
||||
protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled', 'custom_label', 'scopes', 'allow_registration', 'auto_join_root_team', 'require_email_verified', 'use_pkce', 'clock_skew_seconds'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => 'boolean',
|
||||
'allow_registration' => 'boolean',
|
||||
'auto_join_root_team' => 'boolean',
|
||||
'require_email_verified' => 'boolean',
|
||||
'use_pkce' => 'boolean',
|
||||
'clock_skew_seconds' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
protected $hidden = [
|
||||
'client_secret',
|
||||
@@ -32,9 +44,46 @@ class OauthSetting extends Model
|
||||
return filled($this->client_id) && filled($this->client_secret) && filled($this->tenant);
|
||||
case 'authentik':
|
||||
case 'clerk':
|
||||
case 'oidc':
|
||||
return filled($this->client_id) && filled($this->client_secret) && filled($this->base_url);
|
||||
default:
|
||||
return filled($this->client_id) && filled($this->client_secret);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function scopeList(): array
|
||||
{
|
||||
$scopes = str($this->scopes ?: 'openid email profile')
|
||||
->replace(',', ' ')
|
||||
->explode(' ')
|
||||
->map(fn (string $scope) => trim($scope))
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $scopes === [] ? ['openid', 'email', 'profile'] : $scopes;
|
||||
}
|
||||
|
||||
public function loginLabel(): string
|
||||
{
|
||||
if (filled($this->custom_label)) {
|
||||
return $this->custom_label;
|
||||
}
|
||||
|
||||
$envLabel = config("services.{$this->provider}.custom_label");
|
||||
if (filled($envLabel)) {
|
||||
return $envLabel;
|
||||
}
|
||||
|
||||
return __("auth.login.{$this->provider}");
|
||||
}
|
||||
|
||||
public function isOidc(): bool
|
||||
{
|
||||
return $this->provider === 'oidc';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +304,11 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
|
||||
return $this->hasMany(CloudProviderToken::class);
|
||||
}
|
||||
|
||||
public function integrationTokens()
|
||||
{
|
||||
return $this->hasMany(IntegrationToken::class);
|
||||
}
|
||||
|
||||
public function sources()
|
||||
{
|
||||
$sources = collect([]);
|
||||
|
||||
+16
-1
@@ -11,6 +11,7 @@ use App\Services\ChangelogService;
|
||||
use App\Traits\DeletesUserSessions;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
@@ -507,12 +508,26 @@ class User extends Authenticatable implements SendsEmail
|
||||
&& Carbon::now()->lessThan($this->email_change_code_expires_at);
|
||||
}
|
||||
|
||||
public function oauthIdentities(): HasMany
|
||||
{
|
||||
return $this->hasMany(OauthIdentity::class);
|
||||
}
|
||||
|
||||
public function hasSsoIdentity(): bool
|
||||
{
|
||||
return $this->oauthIdentities()->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user has a password set.
|
||||
* OAuth users are created without passwords.
|
||||
*/
|
||||
public function hasPassword(): bool
|
||||
{
|
||||
return ! empty($this->password);
|
||||
}
|
||||
|
||||
public function requiresPasswordConfirmation(): bool
|
||||
{
|
||||
return $this->hasPassword() && ! $this->hasSsoIdentity();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Models\User;
|
||||
|
||||
class IntegrationTokenPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
public function view(User $user, IntegrationToken $integrationToken): bool
|
||||
{
|
||||
return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id;
|
||||
}
|
||||
|
||||
public function update(User $user, IntegrationToken $integrationToken): bool
|
||||
{
|
||||
return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id;
|
||||
}
|
||||
|
||||
public function delete(User $user, IntegrationToken $integrationToken): bool
|
||||
{
|
||||
return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Auth\Oidc\OidcDiscoveryService;
|
||||
use App\Auth\Oidc\OidcTokenValidator;
|
||||
use App\Auth\Oidc\Socialite\OidcProvider;
|
||||
use App\Models\PersonalAccessToken;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\App;
|
||||
@@ -10,6 +13,7 @@ use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Laravel\Socialite\Contracts\Factory as SocialiteFactory;
|
||||
use Stripe\StripeClient;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -22,12 +26,11 @@ class AppServiceProvider extends ServiceProvider
|
||||
public function boot(): void
|
||||
{
|
||||
$this->configureCommands();
|
||||
|
||||
$this->configureModels();
|
||||
$this->configurePasswords();
|
||||
$this->configureSanctumModel();
|
||||
$this->configureGitHubHttp();
|
||||
|
||||
$this->configureOidcSocialite();
|
||||
}
|
||||
|
||||
private function configureCommands(): void
|
||||
@@ -62,6 +65,24 @@ class AppServiceProvider extends ServiceProvider
|
||||
Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);
|
||||
}
|
||||
|
||||
private function configureOidcSocialite(): void
|
||||
{
|
||||
if (! $this->app->bound(SocialiteFactory::class)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->app->make(SocialiteFactory::class)->extend('oidc', function ($app) {
|
||||
return new OidcProvider(
|
||||
$app['request'],
|
||||
$app->make(OidcDiscoveryService::class),
|
||||
$app->make(OidcTokenValidator::class),
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private function configureGitHubHttp(): void
|
||||
{
|
||||
Http::macro('GitHub', function (string $api_url, ?string $github_access_token = null) {
|
||||
@@ -77,16 +98,5 @@ class AppServiceProvider extends ServiceProvider
|
||||
])->baseUrl($api_url);
|
||||
}
|
||||
});
|
||||
|
||||
Http::macro('GitLab', function (string $api_url, ?string $access_token = null) {
|
||||
$client = Http::withHeaders([
|
||||
'Accept' => 'application/json',
|
||||
])->baseUrl($api_url);
|
||||
if ($access_token) {
|
||||
$client = $client->withToken($access_token);
|
||||
}
|
||||
|
||||
return $client;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Models\EnvironmentVariable;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\PushoverNotificationSettings;
|
||||
@@ -52,6 +53,7 @@ use App\Policies\EnvironmentVariablePolicy;
|
||||
use App\Policies\GithubAppPolicy;
|
||||
use App\Policies\GitlabAppPolicy;
|
||||
use App\Policies\InstanceSettingsPolicy;
|
||||
use App\Policies\IntegrationTokenPolicy;
|
||||
use App\Policies\NotificationPolicy;
|
||||
use App\Policies\PrivateKeyPolicy;
|
||||
use App\Policies\ProjectPolicy;
|
||||
@@ -132,6 +134,7 @@ class AuthServiceProvider extends ServiceProvider
|
||||
|
||||
// Cloud provider policies
|
||||
CloudProviderToken::class => CloudProviderTokenPolicy::class,
|
||||
IntegrationToken::class => IntegrationTokenPolicy::class,
|
||||
CloudInitScript::class => CloudInitScriptPolicy::class,
|
||||
Tag::class => TagPolicy::class,
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class DuskServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register Dusk's browser macros.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
\Laravel\Dusk\Browser::macro('loginWithRootUser', function () {
|
||||
return $this->visit('/login')
|
||||
->type('email', 'test@example.com')
|
||||
->type('password', 'password')
|
||||
->press('Login');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
$isFirstUser = User::count() === 0;
|
||||
|
||||
$settings = instanceSettings();
|
||||
if (! $settings->is_registration_enabled) {
|
||||
if (! $settings->isPasswordRegistrationAllowed()) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
@@ -61,13 +61,13 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
$settings = instanceSettings();
|
||||
$enabled_oauth_providers = OauthSetting::where('enabled', true)->get();
|
||||
$users = User::count();
|
||||
if ($users == 0) {
|
||||
// If there are no users, redirect to registration
|
||||
if ($users == 0 && $settings->isPasswordRegistrationAllowed()) {
|
||||
// If there are no users and password registration is allowed, redirect to registration.
|
||||
return redirect()->route('register');
|
||||
}
|
||||
|
||||
return view('auth.login', [
|
||||
'is_registration_enabled' => $settings->is_registration_enabled,
|
||||
'is_registration_enabled' => $settings->isPasswordRegistrationAllowed(),
|
||||
'enabled_oauth_providers' => $enabled_oauth_providers,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Auth\Oidc\OidcUser;
|
||||
use App\Models\OauthIdentity;
|
||||
use App\Models\OauthSetting;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class OauthLoginService
|
||||
{
|
||||
public function login(string $provider, object $oauthUser, OauthSetting $oauthSetting): User
|
||||
{
|
||||
$email = strtolower(trim((string) $oauthUser->email));
|
||||
if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new HttpException(403, 'OAuth provider did not return a valid email address');
|
||||
}
|
||||
|
||||
$user = $provider === 'oidc'
|
||||
? $this->resolveOidcUser($oauthUser, $oauthSetting, $email)
|
||||
: $this->resolveOauthUser($oauthUser, $oauthSetting, $email);
|
||||
|
||||
Auth::login($user);
|
||||
$team = $user->currentTeam() ?? $user->teams()->first() ?? $user->recreate_personal_team();
|
||||
session(['currentTeam' => $user->currentTeam = $team]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function resolveOauthUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User
|
||||
{
|
||||
$provider = $oauthSetting->provider;
|
||||
$providerUserId = $oauthUser->id ?? null;
|
||||
if (
|
||||
(! is_string($providerUserId) && ! is_int($providerUserId))
|
||||
|| (is_string($providerUserId) && trim($providerUserId) === '')
|
||||
) {
|
||||
throw new HttpException(403, 'OAuth provider did not return a valid user ID');
|
||||
}
|
||||
$providerUserId = (string) $providerUserId;
|
||||
$rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : [];
|
||||
|
||||
$identityKey = [
|
||||
'provider' => $provider,
|
||||
'issuer' => $provider,
|
||||
'provider_user_id' => $providerUserId,
|
||||
];
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $provider, $providerUserId, $rawClaims, $identityKey): User {
|
||||
$identity = OauthIdentity::where($identityKey)->first();
|
||||
|
||||
if ($identity) {
|
||||
$identity->update([
|
||||
'email' => $email,
|
||||
'raw_claims' => $rawClaims,
|
||||
'last_login_at' => now(),
|
||||
]);
|
||||
|
||||
return $identity->user;
|
||||
}
|
||||
|
||||
$user = User::whereEmail($email)->first();
|
||||
if (! $user) {
|
||||
if (! $this->canCreateUser($oauthSetting)) {
|
||||
throw new HttpException(403, 'Registration is disabled');
|
||||
}
|
||||
|
||||
$user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting);
|
||||
}
|
||||
|
||||
OauthIdentity::create([
|
||||
'user_id' => $user->id,
|
||||
'provider' => $provider,
|
||||
'issuer' => $provider,
|
||||
'provider_user_id' => $providerUserId,
|
||||
'email' => $email,
|
||||
'raw_claims' => $rawClaims,
|
||||
'last_login_at' => now(),
|
||||
]);
|
||||
|
||||
return $user;
|
||||
});
|
||||
} catch (UniqueConstraintViolationException $exception) {
|
||||
return OauthIdentity::where($identityKey)->first()?->user ?? throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User
|
||||
{
|
||||
$issuer = $oauthUser instanceof OidcUser && filled($oauthUser->issuer)
|
||||
? $oauthUser->issuer
|
||||
: data_get($oauthUser->user, 'iss');
|
||||
$subject = $oauthUser instanceof OidcUser && filled($oauthUser->subject)
|
||||
? $oauthUser->subject
|
||||
: data_get($oauthUser->user, 'sub', $oauthUser->id);
|
||||
$emailVerified = ($oauthUser instanceof OidcUser && $oauthUser->emailVerified)
|
||||
|| data_get($oauthUser->user, 'email_verified') === true;
|
||||
|
||||
if (! is_string($issuer) || $issuer === '' || ! is_string($subject) || $subject === '') {
|
||||
throw new HttpException(403, 'OIDC provider did not return issuer and subject claims');
|
||||
}
|
||||
|
||||
if ($oauthSetting->require_email_verified && ! $emailVerified) {
|
||||
throw new HttpException(403, 'OIDC provider did not verify the email address');
|
||||
}
|
||||
|
||||
$rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : [];
|
||||
|
||||
$identityKey = [
|
||||
'provider' => 'oidc',
|
||||
'issuer' => $issuer,
|
||||
'provider_user_id' => $subject,
|
||||
];
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $issuer, $subject, $emailVerified, $rawClaims, $identityKey): User {
|
||||
$identity = OauthIdentity::where($identityKey)->first();
|
||||
|
||||
if ($identity) {
|
||||
$identity->update([
|
||||
'email' => $email,
|
||||
'raw_claims' => $rawClaims,
|
||||
'last_login_at' => now(),
|
||||
]);
|
||||
|
||||
return $identity->user;
|
||||
}
|
||||
|
||||
$user = User::whereEmail($email)->first();
|
||||
|
||||
// Linking a new OIDC identity to an existing local account by email
|
||||
// is account takeover unless the provider attests the email. This
|
||||
// guard is independent of the require_email_verified toggle, which
|
||||
// only governs the broader login flow.
|
||||
if ($user && ! $emailVerified) {
|
||||
throw new HttpException(403, 'OIDC provider must verify the email address before linking to an existing account');
|
||||
}
|
||||
|
||||
if (! $user) {
|
||||
if (! $this->canCreateUser($oauthSetting)) {
|
||||
throw new HttpException(403, 'Registration is disabled');
|
||||
}
|
||||
|
||||
$user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting);
|
||||
}
|
||||
|
||||
OauthIdentity::create([
|
||||
'user_id' => $user->id,
|
||||
'provider' => 'oidc',
|
||||
'issuer' => $issuer,
|
||||
'provider_user_id' => $subject,
|
||||
'email' => $email,
|
||||
'raw_claims' => $rawClaims,
|
||||
'last_login_at' => now(),
|
||||
]);
|
||||
|
||||
return $user;
|
||||
});
|
||||
} catch (UniqueConstraintViolationException $exception) {
|
||||
return OauthIdentity::where($identityKey)->first()?->user ?? throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function canCreateUser(OauthSetting $oauthSetting): bool
|
||||
{
|
||||
return instanceSettings()->is_registration_enabled || $oauthSetting->allow_registration;
|
||||
}
|
||||
|
||||
private function createUser(string $name, string $email, OauthSetting $oauthSetting): User
|
||||
{
|
||||
if (User::count() === 0) {
|
||||
$user = (new User)->forceFill([
|
||||
'id' => 0,
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
]);
|
||||
$user->save();
|
||||
|
||||
$team = $user->teams()->first() ?? Team::find(0);
|
||||
if ($team !== null && ! $user->teams()->where('team_id', $team->id)->exists()) {
|
||||
$user->teams()->attach($team, ['role' => 'owner']);
|
||||
}
|
||||
|
||||
instanceSettings()->update(['is_registration_enabled' => false]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
if ($oauthSetting->auto_join_root_team) {
|
||||
return $this->createRootTeamOnlyUser($name, $email);
|
||||
}
|
||||
|
||||
return User::create([
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
]);
|
||||
}
|
||||
|
||||
private function createRootTeamOnlyUser(string $name, string $email): User
|
||||
{
|
||||
return DB::transaction(function () use ($name, $email) {
|
||||
$rootTeam = Team::find(0);
|
||||
if ($rootTeam === null) {
|
||||
throw new HttpException(403, 'Root team is not available for OAuth user provisioning');
|
||||
}
|
||||
|
||||
$user = User::withoutEvents(fn () => User::create([
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
]));
|
||||
|
||||
$user->teams()->attach($rootTeam, ['role' => 'member']);
|
||||
|
||||
return $user;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class CloudflareTokenValidator
|
||||
{
|
||||
public function validate(string $token, array $capabilities): bool
|
||||
{
|
||||
$client = $this->client($token);
|
||||
$verification = $client->get('https://api.cloudflare.com/client/v4/user/tokens/verify');
|
||||
|
||||
if (! $verification->successful() || $verification->json('result.status') !== 'active') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (in_array('dns', $capabilities, true)) {
|
||||
$zones = $client->get('https://api.cloudflare.com/client/v4/zones', ['per_page' => 1]);
|
||||
$zoneId = $zones->json('result.0.id');
|
||||
|
||||
if (! $zones->successful() || ! is_string($zoneId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $client->get("https://api.cloudflare.com/client/v4/zones/{$zoneId}/dns_records", [
|
||||
'per_page' => 1,
|
||||
])->successful();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function client(string $token): PendingRequest
|
||||
{
|
||||
return Http::withToken($token)
|
||||
->acceptJson()
|
||||
->connectTimeout(5)
|
||||
->timeout(10);
|
||||
}
|
||||
}
|
||||
@@ -4553,7 +4553,7 @@ function formatContainerStatus(string $status): string
|
||||
* Check if password confirmation should be skipped.
|
||||
* Returns true if:
|
||||
* - Two-step confirmation is globally disabled
|
||||
* - User has no password (OAuth users)
|
||||
* - User has no usable local password confirmation (including SSO users)
|
||||
*
|
||||
* Used by modal-confirmation.blade.php to determine if password step should be shown.
|
||||
*
|
||||
@@ -4566,8 +4566,9 @@ function shouldSkipPasswordConfirmation(): bool
|
||||
return true;
|
||||
}
|
||||
|
||||
// Skip if user has no password (OAuth users)
|
||||
if (! Auth::user()?->hasPassword()) {
|
||||
// OAuth users may have an unusable generated password, so the linked
|
||||
// identity is the source of truth for whether confirmation is possible.
|
||||
if (! Auth::user()?->requiresPasswordConfirmation()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -4578,7 +4579,7 @@ function shouldSkipPasswordConfirmation(): bool
|
||||
* Verify password for two-step confirmation.
|
||||
* Skips verification if:
|
||||
* - Two-step confirmation is globally disabled
|
||||
* - User has no password (OAuth users)
|
||||
* - User has no usable local password confirmation (including SSO users)
|
||||
*
|
||||
* @param mixed $password The password to verify (may be array if skipped by frontend)
|
||||
* @param Component|null $component Optional Livewire component to add errors to
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<?php
|
||||
|
||||
use App\Auth\Oidc\OidcConfig;
|
||||
use App\Models\OauthSetting;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Laravel\Socialite\Two\BitbucketProvider;
|
||||
use Laravel\Socialite\Two\GithubProvider;
|
||||
use Laravel\Socialite\Two\GitlabProvider;
|
||||
use SocialiteProviders\Discord\Provider;
|
||||
use SocialiteProviders\Manager\Config;
|
||||
|
||||
function get_socialite_provider(string $provider)
|
||||
{
|
||||
@@ -12,7 +18,7 @@ function get_socialite_provider(string $provider)
|
||||
}
|
||||
|
||||
if ($provider === 'azure') {
|
||||
$azure_config = new \SocialiteProviders\Manager\Config(
|
||||
$azure_config = new Config(
|
||||
$oauth_setting->client_id,
|
||||
$oauth_setting->client_secret,
|
||||
$oauth_setting->redirect_uri,
|
||||
@@ -23,7 +29,7 @@ function get_socialite_provider(string $provider)
|
||||
}
|
||||
|
||||
if ($provider == 'authentik' || $provider == 'clerk') {
|
||||
$authentik_clerk_config = new \SocialiteProviders\Manager\Config(
|
||||
$authentik_clerk_config = new Config(
|
||||
$oauth_setting->client_id,
|
||||
$oauth_setting->client_secret,
|
||||
$oauth_setting->redirect_uri,
|
||||
@@ -34,7 +40,7 @@ function get_socialite_provider(string $provider)
|
||||
}
|
||||
|
||||
if ($provider == 'zitadel') {
|
||||
$zitadel_config = new \SocialiteProviders\Manager\Config(
|
||||
$zitadel_config = new Config(
|
||||
$oauth_setting->client_id,
|
||||
$oauth_setting->client_secret,
|
||||
$oauth_setting->redirect_uri,
|
||||
@@ -44,8 +50,12 @@ function get_socialite_provider(string $provider)
|
||||
return Socialite::driver('zitadel')->setConfig($zitadel_config);
|
||||
}
|
||||
|
||||
if ($provider === 'oidc') {
|
||||
return Socialite::driver('oidc')->setConfig(OidcConfig::fromOauthSetting($oauth_setting));
|
||||
}
|
||||
|
||||
if ($provider == 'google') {
|
||||
$google_config = new \SocialiteProviders\Manager\Config(
|
||||
$google_config = new Config(
|
||||
$oauth_setting->client_id,
|
||||
$oauth_setting->client_secret,
|
||||
$oauth_setting->redirect_uri
|
||||
@@ -63,11 +73,11 @@ function get_socialite_provider(string $provider)
|
||||
];
|
||||
|
||||
$provider_class_map = [
|
||||
'bitbucket' => \Laravel\Socialite\Two\BitbucketProvider::class,
|
||||
'discord' => \SocialiteProviders\Discord\Provider::class,
|
||||
'github' => \Laravel\Socialite\Two\GithubProvider::class,
|
||||
'gitlab' => \Laravel\Socialite\Two\GitlabProvider::class,
|
||||
'infomaniak' => \SocialiteProviders\Infomaniak\Provider::class,
|
||||
'bitbucket' => BitbucketProvider::class,
|
||||
'discord' => Provider::class,
|
||||
'github' => GithubProvider::class,
|
||||
'gitlab' => GitlabProvider::class,
|
||||
'infomaniak' => SocialiteProviders\Infomaniak\Provider::class,
|
||||
];
|
||||
|
||||
$socialite = Socialite::buildProvider(
|
||||
|
||||
+1
-1
@@ -14,6 +14,7 @@
|
||||
"php": "^8.4",
|
||||
"danharrin/livewire-rate-limiting": "^2.2.1",
|
||||
"doctrine/dbal": "^4.4.4",
|
||||
"firebase/php-jwt": "7.1.0",
|
||||
"guzzlehttp/guzzle": "^7.15.3",
|
||||
"laravel/fortify": "^1.37.3",
|
||||
"laravel/framework": "^12.65.0",
|
||||
@@ -63,7 +64,6 @@
|
||||
"driftingly/rector-laravel": "^2.5.0",
|
||||
"fakerphp/faker": "^1.24.1",
|
||||
"laravel/boost": "^2.4.8",
|
||||
"laravel/dusk": "^8.6.0",
|
||||
"laravel/pint": "^1.30.4",
|
||||
"mockery/mockery": "^1.6.12",
|
||||
"nunomaduro/collision": "^8.9.5",
|
||||
|
||||
Generated
+1
-141
@@ -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": "971daeb1b3078a36428c0fb56bb895b7",
|
||||
"content-hash": "13e5d201c34a64cdf53e80a21304c9d5",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
@@ -13698,80 +13698,6 @@
|
||||
},
|
||||
"time": "2026-05-19T20:09:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/dusk",
|
||||
"version": "v8.6.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/dusk.git",
|
||||
"reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/dusk/zipball/e7fd48762c6a82ad2cd311db07587aa2a97ce143",
|
||||
"reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"ext-zip": "*",
|
||||
"guzzlehttp/guzzle": "^7.5",
|
||||
"illuminate/console": "^10.0|^11.0|^12.0|^13.0",
|
||||
"illuminate/support": "^10.0|^11.0|^12.0|^13.0",
|
||||
"php": "^8.1",
|
||||
"php-webdriver/webdriver": "^1.15.2",
|
||||
"symfony/console": "^6.2|^7.0|^8.0",
|
||||
"symfony/finder": "^6.2|^7.0|^8.0",
|
||||
"symfony/process": "^6.2|^7.0|^8.0",
|
||||
"vlucas/phpdotenv": "^5.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/framework": "^10.0|^11.0|^12.0|^13.0",
|
||||
"mockery/mockery": "^1.6",
|
||||
"orchestra/testbench-core": "^8.19|^9.17|^10.8|^11.0",
|
||||
"phpstan/phpstan": "^1.10",
|
||||
"phpunit/phpunit": "^10.1|^11.0|^12.0.1",
|
||||
"psy/psysh": "^0.11.12|^0.12",
|
||||
"symfony/yaml": "^6.2|^7.0|^8.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-pcntl": "Used to gracefully terminate Dusk when tests are running."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Dusk\\DuskServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Dusk\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "Laravel Dusk provides simple end-to-end testing and browser automation.",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"testing",
|
||||
"webdriver"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/dusk/issues",
|
||||
"source": "https://github.com/laravel/dusk/tree/v8.6.0"
|
||||
},
|
||||
"time": "2026-04-15T14:50:40+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/pint",
|
||||
"version": "v1.30.4",
|
||||
@@ -14817,72 +14743,6 @@
|
||||
},
|
||||
"time": "2022-02-21T01:04:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "php-webdriver/webdriver",
|
||||
"version": "1.16.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-webdriver/php-webdriver.git",
|
||||
"reference": "ac0662863aa120b4f645869f584013e4c4dba46a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/ac0662863aa120b4f645869f584013e4c4dba46a",
|
||||
"reference": "ac0662863aa120b4f645869f584013e4c4dba46a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"ext-zip": "*",
|
||||
"php": "^7.3 || ^8.0",
|
||||
"symfony/polyfill-mbstring": "^1.12",
|
||||
"symfony/process": "^5.0 || ^6.0 || ^7.0 || ^8.0"
|
||||
},
|
||||
"replace": {
|
||||
"facebook/webdriver": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"ergebnis/composer-normalize": "^2.20.0",
|
||||
"ondram/ci-detector": "^4.0",
|
||||
"php-coveralls/php-coveralls": "^2.4",
|
||||
"php-mock/php-mock-phpunit": "^2.0",
|
||||
"php-parallel-lint/php-parallel-lint": "^1.2",
|
||||
"phpunit/phpunit": "^9.3",
|
||||
"squizlabs/php_codesniffer": "^3.5",
|
||||
"symfony/var-dumper": "^5.0 || ^6.0 || ^7.0 || ^8.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-simplexml": "For Firefox profile creation"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"lib/Exception/TimeoutException.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Facebook\\WebDriver\\": "lib/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.",
|
||||
"homepage": "https://github.com/php-webdriver/php-webdriver",
|
||||
"keywords": [
|
||||
"Chromedriver",
|
||||
"geckodriver",
|
||||
"php",
|
||||
"selenium",
|
||||
"webdriver"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/php-webdriver/php-webdriver/issues",
|
||||
"source": "https://github.com/php-webdriver/php-webdriver/tree/1.16.0"
|
||||
},
|
||||
"time": "2025-12-28T23:57:40+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan",
|
||||
"version": "2.2.8",
|
||||
|
||||
+2
-2
@@ -193,8 +193,8 @@ return [
|
||||
*/
|
||||
|
||||
'maintenance' => [
|
||||
'driver' => 'cache',
|
||||
'store' => 'redis',
|
||||
'driver' => env('APP_MAINTENANCE_DRIVER', 'cache'),
|
||||
'store' => env('APP_MAINTENANCE_STORE', 'redis'),
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@@ -60,6 +60,14 @@ return [
|
||||
'tenant' => env('GOOGLE_TENANT'),
|
||||
],
|
||||
|
||||
'oidc' => [
|
||||
'client_id' => env('OIDC_CLIENT_ID'),
|
||||
'client_secret' => env('OIDC_CLIENT_SECRET'),
|
||||
'redirect' => env('OIDC_REDIRECT_URI'),
|
||||
'base_url' => env('OIDC_BASE_URL'),
|
||||
'custom_label' => env('OIDC_LOGIN_LABEL'),
|
||||
],
|
||||
|
||||
'zitadel' => [
|
||||
'client_id' => env('ZITADEL_CLIENT_ID'),
|
||||
'client_secret' => env('ZITADEL_CLIENT_SECRET'),
|
||||
|
||||
+6
@@ -8,6 +8,12 @@ return new class extends Migration
|
||||
/**
|
||||
* The configuration snapshot/diff now store an encrypted blob (not valid
|
||||
* JSON), so the columns must hold arbitrary text instead of json.
|
||||
*
|
||||
* Coolify's own backend runs exclusively on PostgreSQL in production and
|
||||
* SQLite in testing (see config/database.php — the only configured
|
||||
* connections are `pgsql` and `testing`). MySQL/MariaDB are user-managed
|
||||
* resources, never Coolify's application database, so no driver path is
|
||||
* needed for them here.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('oauth_settings', function (Blueprint $table) {
|
||||
$table->string('custom_label')->nullable();
|
||||
$table->string('scopes')->nullable();
|
||||
$table->boolean('allow_registration')->default(true);
|
||||
$table->boolean('require_email_verified')->default(true);
|
||||
$table->boolean('use_pkce')->default(true);
|
||||
$table->unsignedSmallInteger('clock_skew_seconds')->default(60);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('oauth_settings', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'custom_label',
|
||||
'scopes',
|
||||
'allow_registration',
|
||||
'require_email_verified',
|
||||
'use_pkce',
|
||||
'clock_skew_seconds',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_identities', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('provider');
|
||||
$table->string('issuer');
|
||||
$table->string('provider_user_id');
|
||||
$table->string('email')->nullable()->index();
|
||||
$table->json('raw_claims')->nullable();
|
||||
$table->timestamp('last_login_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['provider', 'issuer', 'provider_user_id'], 'oauth_identity_provider_issuer_user_unique');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('oauth_identities');
|
||||
}
|
||||
};
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('instance_settings', function (Blueprint $table) {
|
||||
$table->boolean('disable_registration_when_oauth_enabled')->default(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('instance_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('disable_registration_when_oauth_enabled');
|
||||
});
|
||||
}
|
||||
};
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('oauth_settings', function (Blueprint $table) {
|
||||
$table->boolean('auto_join_root_team')->default(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('oauth_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('auto_join_root_team');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('integration_tokens', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('provider');
|
||||
$table->string('name');
|
||||
$table->text('token');
|
||||
$table->json('capabilities');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['team_id', 'provider']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('integration_tokens');
|
||||
}
|
||||
};
|
||||
@@ -23,6 +23,7 @@ class OauthSettingSeeder extends Seeder
|
||||
'github',
|
||||
'gitlab',
|
||||
'google',
|
||||
'oidc',
|
||||
'authentik',
|
||||
'infomaniak',
|
||||
'zitadel',
|
||||
|
||||
@@ -15,12 +15,10 @@ class UserSeeder extends Seeder
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
User::factory()->create([
|
||||
'id' => 1,
|
||||
'name' => 'Normal User (but in root team)',
|
||||
'email' => 'test2@example.com',
|
||||
]);
|
||||
User::factory()->create([
|
||||
'id' => 2,
|
||||
'name' => 'Normal User (not in root team)',
|
||||
'email' => 'test3@example.com',
|
||||
]);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"auth.login.github": "Mit GitHub anmelden",
|
||||
"auth.login.gitlab": "Mit GitLab anmelden",
|
||||
"auth.login.google": "Mit Google anmelden",
|
||||
"auth.login.oidc": "Mit SSO anmelden",
|
||||
"auth.login.infomaniak": "Mit Infomaniak anmelden",
|
||||
"auth.login.zitadel": "Mit Zitadel anmelden",
|
||||
"auth.already_registered": "Bereits registriert?",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"auth.login.github": "Login with GitHub",
|
||||
"auth.login.gitlab": "Login with Gitlab",
|
||||
"auth.login.google": "Login with Google",
|
||||
"auth.login.oidc": "Login with SSO",
|
||||
"auth.login.infomaniak": "Login with Infomaniak",
|
||||
"auth.login.zitadel": "Login with Zitadel",
|
||||
"auth.already_registered": "Already registered?",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"auth.login.github": "Zaloguj się przez GitHub",
|
||||
"auth.login.gitlab": "Zaloguj się przez Gitlab",
|
||||
"auth.login.google": "Zaloguj się przez Google",
|
||||
"auth.login.oidc": "Zaloguj się przez SSO",
|
||||
"auth.login.infomaniak": "Zaloguj się przez Infomaniak",
|
||||
"auth.login.zitadel": "Zaloguj się przez Zitadel",
|
||||
"auth.already_registered": "Już zarejestrowany?",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>OpenID Connect</title>
|
||||
<path fill-rule="evenodd" d="M10 5.5a7.5 7.5 0 1 0 7.5 7.5c0-.9-.16-1.77-.45-2.57l-2.78 1.04c.15.48.23.99.23 1.53a4.5 4.5 0 1 1-4.5-4.5c.54 0 1.05.09 1.53.26l1.05-2.8A7.48 7.48 0 0 0 10 5.5Z" clip-rule="evenodd"/>
|
||||
<path d="M16.5 1 12 5.5h3V10h3V5.5h3L16.5 1Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 383 B |
@@ -1,3 +1,4 @@
|
||||
import { initializeCopyButtonComponent } from './copy-button.js';
|
||||
import { initializeTerminalComponent } from './terminal.js';
|
||||
|
||||
// Livewire 3.5.19+ re-applies `x-cloak` to morphed elements during wire:navigate
|
||||
@@ -12,6 +13,7 @@ document.addEventListener('livewire:navigated', () => {
|
||||
// Keeping this registration independent from the current route also makes it
|
||||
// available before Alpine processes terminal markup after wire:navigate.
|
||||
document.addEventListener('alpine:init', initializeTerminalComponent);
|
||||
document.addEventListener('alpine:init', initializeCopyButtonComponent);
|
||||
|
||||
/**
|
||||
* Smooth-scroll a settings section into view, then flash its border for 500ms
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Alpine data provider for the <x-copy-button> component (x-data="copyButton").
|
||||
export function initializeCopyButtonComponent() {
|
||||
window.Alpine.data('copyButton', () => ({
|
||||
copied: false,
|
||||
async copy(value) {
|
||||
if (value === null || value === undefined) {
|
||||
window.toast('Value is not available.', { type: 'warning' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (navigator.clipboard?.writeText && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} else {
|
||||
// Deprecated, but the only copy path on plain http (non-secure contexts).
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = value;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
if (!ok) {
|
||||
throw new Error('Copy command was rejected.');
|
||||
}
|
||||
}
|
||||
this.copied = true;
|
||||
setTimeout(() => (this.copied = false), 1200);
|
||||
} catch (e) {
|
||||
window.toast('Could not copy to clipboard.', { type: 'warning' });
|
||||
}
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -80,11 +80,15 @@
|
||||
|
||||
@if ($enabled_oauth_providers->isNotEmpty())
|
||||
<div class="auth-divider"><span>Or continue with</span></div>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
@foreach ($enabled_oauth_providers as $provider_setting)
|
||||
<x-forms.button class="w-full justify-center" type="button"
|
||||
onclick="document.location.href='/auth/{{ $provider_setting->provider }}/redirect'">
|
||||
{{ __("auth.login.$provider_setting->provider") }}
|
||||
@if ($provider_setting->provider !== 'oidc')
|
||||
<img class="size-5 shrink-0 dark:invert"
|
||||
src="{{ asset('svgs/'.$provider_setting->provider.'.svg') }}" alt="" aria-hidden="true">
|
||||
@endif
|
||||
{{ $provider_setting->loginLabel() }}
|
||||
</x-forms.button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
@props([
|
||||
'value',
|
||||
'value' => null,
|
||||
'resolve' => null,
|
||||
'label' => 'Copy to clipboard',
|
||||
])
|
||||
|
||||
<button type="button"
|
||||
x-data="{ copied: false }"
|
||||
x-on:click.prevent.stop="await window.copyToClipboard({{ Js::from($value) }}); copied = true; setTimeout(() => copied = false, 1000)"
|
||||
{{ $attributes->class('inline-flex size-6 shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-40 dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-white') }}
|
||||
title="{{ $label }}" aria-label="{{ $label }}" @disabled(blank($value))>
|
||||
<svg x-show="!copied" class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
aria-hidden="true">
|
||||
<path d="M8 8.75H6.5A2.25 2.25 0 0 0 4.25 11v6.5a2.25 2.25 0 0 0 2.25 2.25H13a2.25 2.25 0 0 0 2.25-2.25V16"
|
||||
stroke-width="1.5" stroke-linecap="round" />
|
||||
<rect x="8.75" y="4.25" width="11" height="11" rx="2.25" stroke-width="1.5" />
|
||||
</svg>
|
||||
<svg x-show="copied" x-cloak class="size-3.5 text-green-500" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" aria-hidden="true">
|
||||
<path d="m6.75 12.25 3.5 3.5 7-7" stroke-width="1.5" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
@php
|
||||
$valueExpression = $resolve ?? \Illuminate\Support\Js::from($value);
|
||||
@endphp
|
||||
|
||||
<button type="button" title="{{ $label }}" aria-label="{{ $label }}"
|
||||
{{ $attributes->class(['icon-button group shrink-0']) }} @disabled($resolve === null && blank($value))
|
||||
x-data="copyButton" @click="copy(await ({{ $valueExpression }}))">
|
||||
<span class="inline-flex transition-transform duration-150 ease-out group-active:scale-75">
|
||||
<x-reicon name="copy" x-show="!copied" class="size-3.5" />
|
||||
<x-reicon name="check" x-cloak x-show="copied" class="size-3.5 text-success"
|
||||
x-transition:enter="transition-transform duration-200 ease-out"
|
||||
x-transition:enter-start="scale-50" x-transition:enter-end="scale-100" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
@props(['text', 'label' => null])
|
||||
|
||||
<div class="w-full" x-data="{ copied: false }">
|
||||
@if ($label)
|
||||
<label class="flex gap-1 items-center mb-1 text-sm font-medium text-black dark:text-white">{{ $label }}</label>
|
||||
@endif
|
||||
<div class="relative">
|
||||
<input type="text" value="{{ $text }}"
|
||||
class="input input-with-copy-button bg-white dark:bg-coolgray-100 dark:read-only:bg-coolgray-100 dark:read-only:text-white"
|
||||
readonly
|
||||
@keydown.prevent @paste.prevent @cut.prevent @drop.prevent
|
||||
@focus="$event.target.select()">
|
||||
<button
|
||||
type="button"
|
||||
@click.prevent="await window.copyToClipboard({{ Js::from($text) }}); copied = true; setTimeout(() => copied = false, 1000)"
|
||||
class="copy-button flex absolute inset-y-0 right-0 z-10 items-center pr-2 cursor-pointer text-neutral-500 transition-colors hover:text-black focus-visible:ring-2 focus-visible:ring-coollabs focus-visible:ring-offset-2 dark:text-neutral-400 dark:hover:text-white dark:focus-visible:ring-warning dark:focus-visible:ring-offset-base"
|
||||
title="Copy to clipboard"
|
||||
aria-label="Copy to clipboard">
|
||||
<svg x-show="!copied" class="size-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<svg x-show="copied" class="size-[18px] text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
@props(['text', 'label' => null])
|
||||
|
||||
<div class="w-full">
|
||||
@if ($label)
|
||||
<label class="flex gap-1 items-center mb-1 text-sm font-medium text-black dark:text-white">{{ $label }}</label>
|
||||
@endif
|
||||
<div class="relative">
|
||||
<input type="text" value="{{ $text }}"
|
||||
class="input input-with-copy-button bg-white dark:bg-coolgray-100 dark:read-only:bg-coolgray-100 dark:read-only:text-white"
|
||||
readonly
|
||||
@keydown.prevent @paste.prevent @cut.prevent @drop.prevent
|
||||
@focus="$event.target.select()">
|
||||
<x-copy-button :value="$text" class="absolute top-1/2 right-2 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -287,17 +287,8 @@
|
||||
<div class="relative mb-2" x-data="{ decodedText: confirmationText }">
|
||||
<div class="relative">
|
||||
<input type="text" x-model="decodedText" readonly class="input">
|
||||
<button x-show="window.isSecureContext"
|
||||
@click.prevent="navigator.clipboard.writeText(decodedText); $el.innerHTML = '<svg class=\'w-5 h-5 text-green-500\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M5 13l4 4L19 7\' /></svg>'; setTimeout(() => $el.innerHTML = '<svg class=\'w-5 h-5\' fill=\'none\' stroke=\'currentColor\' viewBox=\'0 0 24 24\'><path stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z\' /></svg>', 1000)"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 text-gray-400 hover:text-gray-300 transition-colors"
|
||||
title="Copy to clipboard">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<x-copy-button resolve="decodedText"
|
||||
class="absolute top-1/2 right-2 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
'upload' => '<path d="M11.4697 3.46967C11.7626 3.17678 12.2374 3.17678 12.5303 3.46967L16.5303 7.46967C16.8232 7.76256 16.8232 8.23744 16.5303 8.53033C16.2374 8.82322 15.7626 8.82322 15.4697 8.53033L12.75 5.81066V14C12.75 14.4142 12.4142 14.75 12 14.75C11.5858 14.75 11.25 14.4142 11.25 14V5.81066L8.53033 8.53033C8.23744 8.82322 7.76256 8.82322 7.46967 8.53033C7.17678 8.23744 7.17678 7.76256 7.46967 7.46967L11.4697 3.46967Z" fill="currentColor"/><path d="M4 14.25C4.41421 14.25 4.75 14.5858 4.75 15V17C4.75 18.5188 5.98122 19.75 7.5 19.75H16.5C18.0188 19.75 19.25 18.5188 19.25 17V15C19.25 14.5858 19.5858 14.25 20 14.25C20.4142 14.25 20.75 14.5858 20.75 15V17C20.75 19.3472 18.8472 21.25 16.5 21.25H7.5C5.15279 21.25 3.25 19.3472 3.25 17V15C3.25 14.5858 3.58579 14.25 4 14.25Z" fill="currentColor"/>',
|
||||
'x' => '<path d="M18.4697 19.5303C18.7626 19.8232 19.2374 19.8232 19.5303 19.5303C19.8232 19.2374 19.8232 18.7626 19.5303 18.4697L13.0607 12L19.5303 5.53033C19.8232 5.23744 19.8232 4.76256 19.5303 4.46967C19.2374 4.17678 18.7626 4.17678 18.4697 4.46967L12 10.9393L5.53033 4.46967C5.23744 4.17678 4.76256 4.17678 4.46967 4.46967C4.17678 4.76256 4.17678 5.23744 4.46967 5.53033L10.9393 12L4.46967 18.4697C4.17678 18.7626 4.17678 19.2374 4.46967 19.5303C4.76256 19.8232 5.23744 19.8232 5.53033 19.5303L12 13.0607L18.4697 19.5303Z" fill="currentColor"/>',
|
||||
'check' => '<path d="M21.5303 5.46967C21.8232 5.76256 21.8232 6.23744 21.5303 6.53033L9.53033 18.5303C9.23744 18.8232 8.76256 18.8232 8.46967 18.5303L2.46967 12.5303C2.17678 12.2374 2.17678 11.7626 2.46967 11.4697C2.76256 11.1768 3.23744 11.1768 3.53033 11.4697L9 16.9393L20.4697 5.46967C20.7626 5.17678 21.2374 5.17678 21.5303 5.46967Z" fill="currentColor"/>',
|
||||
'copy' => '<path d="M8 8.75H6.5A2.25 2.25 0 0 0 4.25 11v6.5a2.25 2.25 0 0 0 2.25 2.25H13a2.25 2.25 0 0 0 2.25-2.25V16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><rect x="8.75" y="4.25" width="11" height="11" rx="2.25" stroke="currentColor" stroke-width="1.5"/>',
|
||||
'chevron-down' => '<g transform="scale(1.33333)"><polyline points="15.25 6.5 9 12.75 2.75 6.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></polyline></g>',
|
||||
'trash' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M15.0924 1.25H8.90788C7.33861 1.24998 6.08032 1.24996 5.10577 1.38767C4.09802 1.53007 3.25979 1.83575 2.64218 2.55292C2.02457 3.27008 1.84661 4.14438 1.85528 5.1621C1.86366 6.1463 2.05033 7.39066 2.28314 8.94256L3.49937 17.0508C3.67587 18.2275 3.81878 19.1804 4.02849 19.9262C4.24683 20.7027 4.56045 21.3453 5.13662 21.8415C5.71279 22.3377 6.39485 22.5525 7.19513 22.6533C7.96377 22.75 8.92732 22.75 10.1173 22.75H13.883C15.073 22.75 16.0365 22.75 16.8052 22.6533C17.6054 22.5525 18.2875 22.3377 18.8637 21.8415C19.4398 21.3453 19.7535 20.7027 19.9718 19.9262C20.1815 19.1805 20.3244 18.2276 20.5009 17.0509L21.7172 8.94253C21.95 7.39065 22.1366 6.14629 22.145 5.1621C22.1537 4.14438 21.9757 3.27008 21.3581 2.55292C20.7405 1.83575 19.9023 1.53007 18.8945 1.38767C17.92 1.24996 16.6617 1.24998 15.0924 1.25ZM3.77879 3.53175C4.05882 3.20658 4.47927 2.9911 5.31565 2.87292C6.17295 2.75177 7.32479 2.75 8.96727 2.75H15.033C16.6755 2.75 17.8273 2.75177 18.6846 2.87292C19.521 2.9911 19.9415 3.20658 20.2215 3.53175C20.5015 3.85692 20.6523 4.30468 20.6451 5.14933C20.6448 5.18248 20.6443 5.21604 20.6435 5.25H20.5005C20.5003 5.25 20.5007 5.25 20.5005 5.25H7.00045C7.00025 5.25 7.00065 5.25 7.00045 5.25H3.35678C3.35603 5.21603 3.35551 5.18248 3.35522 5.14933C3.34803 4.30468 3.49876 3.85692 3.77879 3.53175ZM5.18949 6.75H3.48546C3.53687 7.15852 3.60161 7.61096 3.67631 8.1155L3.75015 8.18934L5.18949 6.75ZM4.05013 10.6106L4.6686 14.7338L6.37599 12.9365L4.05013 10.6106ZM5.15659 17.9593C5.17275 18.0594 5.18872 18.1563 5.20463 18.25H5.39887L5.15659 17.9593ZM6.99527 19.75C6.99879 19.75 7.00232 19.75 7.00584 19.75H13.9972C13.9991 19.75 14.0009 19.75 14.0027 19.75H18.4577C18.299 20.2287 18.1176 20.5044 17.8848 20.7049C17.6171 20.9355 17.261 21.0841 16.6178 21.165C15.9538 21.2486 15.0849 21.25 13.833 21.25H10.1673C8.91538 21.25 8.04651 21.2486 7.38247 21.165C6.73934 21.0841 6.38321 20.9355 6.11546 20.7049C5.88266 20.5044 5.70127 20.2287 5.54256 19.75H6.99527ZM15.7131 18.25H18.1895L16.9018 16.9623L15.7131 18.25ZM19.0007 16.9399C19.0087 16.8869 19.0168 16.8332 19.0249 16.7788L19.404 14.2515L17.92 15.8592L19.0007 16.9399ZM19.856 11.2381L20.2249 8.77879C20.3197 8.14673 20.4033 7.5881 20.4704 7.09045L18.16 9.40079L19.856 11.2381ZM18.6895 6.75H15.7131L17.1418 8.2977L18.6895 6.75ZM12.2532 6.75H8.81081L10.5761 8.51531L12.2532 6.75ZM11.6895 18.25H9.31081L10.5002 17.0607L11.6895 18.25ZM7.40946 11.8486L4.81081 9.25L7.00015 7.06066L9.54266 9.60317L7.40946 11.8486ZM8.47047 12.9097L10.6037 10.6642L12.6895 12.75L10.5002 14.9393L8.47047 12.9097ZM11.5608 16L13.7502 13.8107L15.8403 15.9008L13.7385 18.1777L11.5608 16ZM14.8108 12.75L16.8585 14.7977L18.9795 12.5L17.0985 10.4623L14.8108 12.75ZM13.7502 11.6893L16.0803 9.35921L13.9923 7.09721L11.6371 9.57632L13.7502 11.6893ZM7.437 13.9975L9.43949 16L7.27782 18.1617L5.50363 16.0326L7.437 13.9975Z" fill="currentColor"/>',
|
||||
'external-link' => '<path d="M13 11L21.2 2.80005" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M22 6.8V2H17.2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M11 2H9C4 2 2 4 2 9V15C2 20 4 22 9 22H15C20 22 22 20 22 15V13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
'active' => request()->routeIs('security.cloud-tokens*'),
|
||||
'icon' => 'cloud',
|
||||
] : null,
|
||||
auth()->user()?->can('viewAny', App\Models\IntegrationToken::class) ? [
|
||||
'label' => 'Integration Tokens',
|
||||
'route' => 'security.integration-tokens',
|
||||
'active' => request()->routeIs('security.integration-tokens'),
|
||||
'icon' => 'network',
|
||||
] : null,
|
||||
auth()->user()?->can('viewAny', App\Models\CloudInitScript::class) ? [
|
||||
'label' => 'Cloud-Init Scripts',
|
||||
'route' => 'security.cloud-init-scripts',
|
||||
|
||||
@@ -12,6 +12,24 @@
|
||||
'active' => $activeMenu === 'advanced',
|
||||
'icon' => 'grid',
|
||||
],
|
||||
[
|
||||
'label' => 'Authentication',
|
||||
'route' => 'settings.oauth',
|
||||
'active' => $activeMenu === 'oauth',
|
||||
'icon' => 'keys',
|
||||
],
|
||||
[
|
||||
'label' => 'Transactional Email',
|
||||
'route' => 'settings.email',
|
||||
'active' => $activeMenu === 'email',
|
||||
'icon' => 'notifications',
|
||||
],
|
||||
[
|
||||
'label' => 'Instance Backup',
|
||||
'route' => 'settings.backup',
|
||||
'active' => $activeMenu === 'backup',
|
||||
'icon' => 'database',
|
||||
],
|
||||
[
|
||||
'label' => 'Updates',
|
||||
'route' => 'settings.updates',
|
||||
|
||||
@@ -225,30 +225,6 @@
|
||||
let checkHealthInterval = null;
|
||||
let checkIfIamDeadInterval = null;
|
||||
|
||||
async function copyToClipboard(text) {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const copied = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
if (!copied) {
|
||||
throw new Error('Copy command was rejected.');
|
||||
}
|
||||
}
|
||||
window.Livewire.dispatch('success', 'Copied to clipboard.');
|
||||
} catch (error) {
|
||||
window.Livewire.dispatch('error', 'Failed to copy to clipboard.');
|
||||
}
|
||||
}
|
||||
window.copyToClipboard = copyToClipboard;
|
||||
document.addEventListener('livewire:init', () => {
|
||||
window.Livewire.on('reloadWindow', (timeout) => {
|
||||
if (timeout) {
|
||||
|
||||
@@ -134,15 +134,22 @@
|
||||
<div class="flex items-end gap-2">
|
||||
<x-forms.input id="email" label="Email" readonly />
|
||||
<x-forms.button @click="openEmailModal()" type="button"
|
||||
x-bind:disabled="emailModalOpen">
|
||||
:disabled="$uses_sso" x-bind:disabled="emailModalOpen || @js($uses_sso)">
|
||||
Change
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<template x-teleport="body">
|
||||
@if ($uses_sso)
|
||||
<x-callout type="info" title="Email managed by SSO">
|
||||
Signed in with SSO @if ($sso_provider_label) ({{ $sso_provider_label }}) @endif. Email is managed by your SSO provider.
|
||||
</x-callout>
|
||||
@endif
|
||||
|
||||
@if (! $uses_sso)
|
||||
<template x-teleport="body">
|
||||
<div x-show="emailModalOpen" x-cloak
|
||||
class="fixed inset-0 z-99 flex h-screen w-screen items-center justify-center p-4">
|
||||
<div class="absolute inset-0 h-full w-full bg-black/55 backdrop-blur-[3px]"></div>
|
||||
@@ -191,7 +198,8 @@
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@endif
|
||||
|
||||
<form wire:submit="resetPassword">
|
||||
<section class="application-settings-section">
|
||||
@@ -249,9 +257,9 @@
|
||||
</form>
|
||||
<div x-data="{ showCode: false }">
|
||||
<div x-cloak x-show="showCode" class="space-y-2 pb-3">
|
||||
<x-forms.copy-button
|
||||
<x-forms.copy-input
|
||||
text="{{ decrypt(request()->user()->two_factor_secret) }}" />
|
||||
<x-forms.copy-button text="{{ request()->user()->twoFactorQrCodeUrl() }}" />
|
||||
<x-forms.copy-input text="{{ request()->user()->twoFactorQrCodeUrl() }}" />
|
||||
</div>
|
||||
<x-forms.button type="button" x-on:click="showCode = !showCode">
|
||||
<span x-text="showCode ? 'Hide manual setup' : 'Show manual setup'"></span>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<h3 class="mb-4 text-sm font-semibold text-black dark:text-fg">Internal access</h3>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@if ($currentInternalHostname)
|
||||
<x-forms.copy-button label="Internal hostname" :text="$currentInternalHostname" />
|
||||
<x-forms.copy-input label="Internal hostname" :text="$currentInternalHostname" />
|
||||
@else
|
||||
<div class="w-full">
|
||||
<label class="mb-1 flex items-center gap-1 text-sm font-medium text-black dark:text-white">Internal hostname</label>
|
||||
@@ -25,9 +25,9 @@
|
||||
readonly aria-live="polite">
|
||||
</div>
|
||||
@endif
|
||||
<x-forms.copy-button label="Docker network" :text="$application->destination->network" />
|
||||
<x-forms.copy-button label="Exposed ports" :text="$exposedPorts ?: 'None'" />
|
||||
<x-forms.copy-button label="Network aliases" :text="$networkAliases->implode(', ') ?: 'None'" />
|
||||
<x-forms.copy-input label="Docker network" :text="$application->destination->network" />
|
||||
<x-forms.copy-input label="Exposed ports" :text="$exposedPorts ?: 'None'" />
|
||||
<x-forms.copy-input label="Network aliases" :text="$networkAliases->implode(', ') ?: 'None'" />
|
||||
</div>
|
||||
<div class="mt-4 flex flex-col gap-3 border-t border-neutral-200 pt-4 sm:flex-row sm:items-center sm:justify-between dark:border-white/[0.07]">
|
||||
<p class="text-sm text-neutral-500 dark:text-fg-dim">
|
||||
|
||||
@@ -116,25 +116,9 @@
|
||||
<p class="text-[13px] leading-5 text-neutral-500 dark:text-fg-dim">
|
||||
Mount a Docker volume inside the container.
|
||||
</p>
|
||||
@if ($isSwarm)
|
||||
<div class="text-warning">Swarm Mode detected: You need to set a shared
|
||||
volume
|
||||
(EFS/NFS/etc) on all the worker nodes if you would like to use a
|
||||
persistent
|
||||
volumes.</div>
|
||||
@endif
|
||||
<div class="flex flex-col gap-4">
|
||||
<x-forms.input canGate="update" :canResource="$resource" placeholder="pv-name"
|
||||
id="name" label="Name" required helper="Volume name." />
|
||||
@if ($isSwarm)
|
||||
<x-forms.input canGate="update" :canResource="$resource"
|
||||
placeholder="/root" id="host_path" label="Source Path" required
|
||||
helper="Directory on the host system." />
|
||||
@else
|
||||
<x-forms.input canGate="update" :canResource="$resource"
|
||||
placeholder="/root" id="host_path" label="Source Path"
|
||||
helper="Directory on the host system." />
|
||||
@endif
|
||||
<x-forms.input canGate="update" :canResource="$resource"
|
||||
placeholder="/tmp/root" id="mount_path" label="Destination Path"
|
||||
required helper="Directory inside the container." />
|
||||
|
||||
@@ -219,7 +219,8 @@
|
||||
@else
|
||||
<livewire:project.shared.environment-variable.show-hardcoded
|
||||
wire:key="{{ $row['id'] }}" :env="$row['environmentVariable']"
|
||||
:isPreview="$row['scope'] === 'preview'" :showEnvironmentType="$showEnvironmentType" />
|
||||
:isPreview="$row['scope'] === 'preview'" :showEnvironmentType="$showEnvironmentType"
|
||||
:resourceableType="get_class($resource)" :resourceableId="$resource->id" />
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
+4
-1
@@ -28,7 +28,10 @@
|
||||
<span class="data-table-cell-dash">-</span>
|
||||
<span class="data-table-cell-dash">-</span>
|
||||
<span class="data-table-cell-dash">-</span>
|
||||
<div class="justify-self-end">
|
||||
<div class="flex items-center gap-0.5 justify-self-end">
|
||||
@unless (auth()->user()?->isMember() ?? true)
|
||||
<x-copy-button resolve="$wire.copyValue()" label="Copy value" />
|
||||
@endunless
|
||||
<x-modal-input title="Environment variable details" :closeOutside="false">
|
||||
<x-slot:content>
|
||||
<button type="button" data-env-settings-trigger class="icon-button shrink-0"
|
||||
|
||||
@@ -83,7 +83,10 @@
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
<div class="justify-self-end">
|
||||
<div class="flex items-center gap-0.5 justify-self-end">
|
||||
@if (! $isLocked && ! $isValueHidden)
|
||||
<x-copy-button resolve="$wire.copyValue()" label="Copy value" />
|
||||
@endif
|
||||
{{-- Open modal immediately (Alpine); decrypt value in a follow-up Livewire request. --}}
|
||||
<x-modal-input title="Edit environment variable" :closeOutside="false" :wireIgnore="false"
|
||||
wireOpen="editorOpen">
|
||||
|
||||
@@ -2,44 +2,7 @@
|
||||
$break = $break ?? false;
|
||||
$label = $label ?? 'Copy';
|
||||
@endphp
|
||||
<div class="flex min-w-0 items-center gap-1.5"
|
||||
x-data="{
|
||||
copied: false,
|
||||
async copy(text) {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const el = document.createElement('textarea');
|
||||
el.value = text;
|
||||
el.setAttribute('readonly', '');
|
||||
el.style.position = 'fixed';
|
||||
el.style.left = '-9999px';
|
||||
document.body.appendChild(el);
|
||||
el.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(el);
|
||||
}
|
||||
this.copied = true;
|
||||
setTimeout(() => this.copied = false, 1000);
|
||||
} catch (e) {
|
||||
console.error('Copy failed', e);
|
||||
}
|
||||
}
|
||||
}">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
<span @class(['min-w-0', 'break-all' => $break])>{{ $text }}</span>
|
||||
<button type="button"
|
||||
@click.prevent.stop="copy(@js($text))"
|
||||
class="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-coolgray-200 dark:hover:text-white"
|
||||
title="{{ $label }}"
|
||||
aria-label="{{ $label }}">
|
||||
<svg x-show="!copied" class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<svg x-show="copied" x-cloak class="size-3.5 text-green-500" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<x-copy-button :value="$text" :label="$label" />
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<div>
|
||||
<h3>Resource</h3>
|
||||
<div class="pt-2 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<x-forms.copy-button label="Name" :text="$resource->name ?? ''" />
|
||||
<x-forms.copy-button label="UUID" :text="$resource->uuid ?? ''" />
|
||||
<x-forms.copy-input label="Name" :text="$resource->name ?? ''" />
|
||||
<x-forms.copy-input label="UUID" :text="$resource->uuid ?? ''" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
<div>
|
||||
<h3>Environment</h3>
|
||||
<div class="pt-2 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<x-forms.copy-button label="Name" :text="$environment_name ?? ''" />
|
||||
<x-forms.copy-button label="UUID" :text="$environment_uuid" />
|
||||
<x-forms.copy-input label="Name" :text="$environment_name ?? ''" />
|
||||
<x-forms.copy-input label="UUID" :text="$environment_uuid" />
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@@ -22,8 +22,8 @@
|
||||
<div>
|
||||
<h3>Project</h3>
|
||||
<div class="pt-2 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<x-forms.copy-button label="Name" :text="$project_name ?? ''" />
|
||||
<x-forms.copy-button label="UUID" :text="$project_uuid" />
|
||||
<x-forms.copy-input label="Name" :text="$project_name ?? ''" />
|
||||
<x-forms.copy-input label="UUID" :text="$project_uuid" />
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@@ -32,8 +32,8 @@
|
||||
<div>
|
||||
<h3>Server</h3>
|
||||
<div class="pt-2 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<x-forms.copy-button label="Name" :text="$server_name ?? ''" />
|
||||
<x-forms.copy-button label="UUID" :text="$server_uuid" />
|
||||
<x-forms.copy-input label="Name" :text="$server_name ?? ''" />
|
||||
<x-forms.copy-input label="UUID" :text="$server_uuid" />
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@@ -43,10 +43,10 @@
|
||||
<h3>Stack Sub-Resources</h3>
|
||||
<div class="pt-2 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
@foreach ($stack_applications as $item)
|
||||
<x-forms.copy-button :label="'Application: ' . $item['name']" :text="$item['uuid']" />
|
||||
<x-forms.copy-input :label="'Application: ' . $item['name']" :text="$item['uuid']" />
|
||||
@endforeach
|
||||
@foreach ($stack_databases as $item)
|
||||
<x-forms.copy-button :label="'Database: ' . $item['name']" :text="$item['uuid']" />
|
||||
<x-forms.copy-input :label="'Database: ' . $item['name']" :text="$item['uuid']" />
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -154,7 +154,24 @@
|
||||
|
||||
<div class="volumes-col-source min-w-0">
|
||||
<span class="volumes-mobile-label volumes-field-label">Source Path</span>
|
||||
<x-forms.input id="forms.{{ $id }}.hostPath" placeholder="Host path (optional)" />
|
||||
@if (filled($form['hostPath']))
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="min-w-0 flex-1">
|
||||
<x-forms.input id="forms.{{ $id }}.hostPath" />
|
||||
</div>
|
||||
<x-modal-confirmation title="Remove Source Path?" isErrorButton
|
||||
canGate="update" :canResource="$resource"
|
||||
buttonTitle="Remove" submitAction="clearHostPath({{ $id }})"
|
||||
:actions="[
|
||||
'Are you sure you want to remove the source path?',
|
||||
'The next deployment will use a named Docker volume instead.',
|
||||
'Data from the existing host directory will not be copied to the named volume.',
|
||||
'Use a Directory Mount when you need to mount a host directory.',
|
||||
]" />
|
||||
</div>
|
||||
@else
|
||||
<span class="data-table-cell-dash">-</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="volumes-cell-dest min-w-0">
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@
|
||||
</span>
|
||||
|
||||
<span class="min-w-0">
|
||||
<x-forms.copy-button :text="$execution->filename ?? 'No archive name'" />
|
||||
<x-forms.copy-input :text="$execution->filename ?? 'No archive name'" />
|
||||
</span>
|
||||
|
||||
<span class="text-[11px] text-neutral-500 dark:text-fg-faint">
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<x-external-link />
|
||||
</a>
|
||||
</x-slot:actions>
|
||||
<x-forms.copy-button label="Deploy webhook URL" :text="$deploywebhook ?? ''" />
|
||||
<x-forms.copy-input label="Deploy webhook URL" :text="$deploywebhook ?? ''" />
|
||||
</x-application.settings-section>
|
||||
|
||||
@if ($githubManualWebhook && $gitlabManualWebhook)
|
||||
@@ -70,7 +70,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<x-forms.copy-button label="Webhook URL" :text="$provider['url'] ?? ''" />
|
||||
<x-forms.copy-input label="Webhook URL" :text="$provider['url'] ?? ''" />
|
||||
@can('update', $resource)
|
||||
<x-forms.input type="password" :id="$provider['secret']"
|
||||
label="Webhook secret"
|
||||
@@ -106,7 +106,7 @@
|
||||
<x-external-link />
|
||||
</a>
|
||||
</x-slot:actions>
|
||||
<x-forms.copy-button label="Deploy webhook URL" :text="$deploywebhook ?? ''" />
|
||||
<x-forms.copy-input label="Deploy webhook URL" :text="$deploywebhook ?? ''" />
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -109,7 +109,12 @@
|
||||
@if (session()->has('token'))
|
||||
<x-application.settings-section title="Copy your token"
|
||||
description="This value will not be shown again after you leave this page.">
|
||||
<x-forms.copy-button :text="session('token')" />
|
||||
<div class="relative">
|
||||
<input type="text" value="{{ session('token') }}" readonly
|
||||
class="input w-full pr-12! font-mono text-[12px] text-black dark:text-fg">
|
||||
<x-copy-button :value="session('token')" label="Copy token"
|
||||
class="absolute top-1/2 right-2 -translate-y-1/2" />
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
@endif
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<div class="w-full">
|
||||
<form class="application-settings-form flex w-full flex-col gap-4" wire:submit="save">
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input required id="name" label="Token name" />
|
||||
<x-forms.input readonly label="Provider" value="Cloudflare" />
|
||||
<div class="lg:col-span-2">
|
||||
<x-forms.input type="password" id="newToken" label="New API token"
|
||||
placeholder="Leave blank to keep the current token"
|
||||
helper="Paste a replacement token to rotate this credential." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend class="text-sm font-medium text-black dark:text-fg">Capabilities</legend>
|
||||
<div class="mt-3 rounded-lg border border-neutral-200 p-1 dark:border-white/[0.08]">
|
||||
<x-forms.checkbox id="edit-dns-capability" label="DNS" domValue="dns" fullWidth
|
||||
wire:model.live="capabilities" />
|
||||
<p class="px-2.5 pb-2 text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
Manage Cloudflare DNS records.
|
||||
</p>
|
||||
</div>
|
||||
@error('capabilities')
|
||||
<span class="text-xs text-red-500">{{ $message }}</span>
|
||||
@enderror
|
||||
</fieldset>
|
||||
|
||||
@if (in_array('dns', $capabilities, true))
|
||||
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
|
||||
<div class="font-medium text-black dark:text-fg">Required Cloudflare permissions</div>
|
||||
<ul class="list-inside list-disc">
|
||||
<li>Zone - DNS - Edit</li>
|
||||
<li>Zone - Zone - Read</li>
|
||||
</ul>
|
||||
<a href="https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D&accountId=%2A&zoneId=all&name=Coolify%20DNS%20Management"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
class="font-medium text-coollabs hover:underline dark:text-warning">
|
||||
Create a replacement token in Cloudflare
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex items-center justify-between gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
|
||||
<x-modal-confirmation title="Delete integration token?" isErrorButton buttonTitle="Delete"
|
||||
submitAction="delete" :actions="['This integration token will be permanently deleted.']"
|
||||
confirmationText="{{ $integrationToken->name }}" :confirmWithPassword="false"
|
||||
step2ButtonText="Delete token" />
|
||||
<x-forms.button type="submit" wire:target="save" isHighlighted>
|
||||
Validate and save
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
<div class="w-full">
|
||||
<form class="application-settings-form flex w-full flex-col gap-4" wire:submit="addToken">
|
||||
<x-forms.listbox required id="provider" label="Provider" :options="[
|
||||
['value' => 'cloudflare', 'label' => 'Cloudflare'],
|
||||
]" />
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input required id="name" label="Token name" placeholder="Production DNS" />
|
||||
<x-forms.input required type="password" id="token" label="API token"
|
||||
placeholder="Paste the provider token" />
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend class="text-sm font-medium text-black dark:text-fg">Capabilities</legend>
|
||||
<div class="mt-3 rounded-lg border border-neutral-200 p-1 dark:border-white/[0.08]">
|
||||
<x-forms.checkbox id="dns-capability" label="DNS" domValue="dns" fullWidth
|
||||
wire:model.live="capabilities" />
|
||||
<p class="px-2.5 pb-2 text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
Manage Cloudflare DNS records.
|
||||
</p>
|
||||
</div>
|
||||
@error('capabilities')
|
||||
<span class="text-xs text-red-500">{{ $message }}</span>
|
||||
@enderror
|
||||
</fieldset>
|
||||
|
||||
@if (in_array('dns', $capabilities, true))
|
||||
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
|
||||
<div class="font-medium text-black dark:text-fg">Required Cloudflare permissions</div>
|
||||
<ul class="list-inside list-disc">
|
||||
<li>Zone - DNS - Edit</li>
|
||||
<li>Zone - Zone - Read</li>
|
||||
</ul>
|
||||
<p>Limit zone resources to the zones Coolify should manage.</p>
|
||||
<a href="https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D&accountId=%2A&zoneId=all&name=Coolify%20DNS%20Management"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
class="font-medium text-coollabs hover:underline dark:text-warning">
|
||||
Create this token in Cloudflare
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex justify-end border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
|
||||
<x-forms.button type="submit" wire:target="addToken" isHighlighted>
|
||||
Validate and add
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,84 @@
|
||||
<div>
|
||||
<x-slot:title>
|
||||
Integration Tokens | Coolify
|
||||
</x-slot>
|
||||
|
||||
<x-security.settings-layout>
|
||||
<div class="application-settings-form">
|
||||
<x-application.settings-section title="Integration tokens"
|
||||
description="Credentials used by third-party integrations such as DNS providers." flush>
|
||||
<x-slot:actions>
|
||||
@can('create', App\Models\IntegrationToken::class)
|
||||
<x-modal-input title="New Integration Token">
|
||||
<x-slot:content>
|
||||
<button type="button" class="button button-highlighted">
|
||||
<x-reicon name="plus" class="size-3.5" />
|
||||
New token
|
||||
</button>
|
||||
</x-slot:content>
|
||||
<livewire:security.integration-token-form :modal_mode="true"
|
||||
wire:key="new-integration-token" />
|
||||
</x-modal-input>
|
||||
@endcan
|
||||
</x-slot:actions>
|
||||
|
||||
@if ($tokens->isEmpty())
|
||||
<x-empty title="No integration tokens"
|
||||
description="Add a provider token to connect a third-party integration."
|
||||
icon-name="keys" size="sm" />
|
||||
@else
|
||||
<div class="divide-y divide-neutral-200 dark:divide-white/[0.07]">
|
||||
@foreach ($tokens as $savedToken)
|
||||
<div wire:key="integration-token-{{ $savedToken->id }}"
|
||||
x-data="{
|
||||
visible: true,
|
||||
tokenName: @js($savedToken->name),
|
||||
tokenCapabilities: @js($savedToken->capabilities),
|
||||
}"
|
||||
x-show="visible"
|
||||
x-on:integration-token-updated.window="
|
||||
if ($event.detail.uuid === @js($savedToken->uuid)) {
|
||||
tokenName = $event.detail.name;
|
||||
tokenCapabilities = $event.detail.capabilities;
|
||||
}
|
||||
"
|
||||
x-on:integration-token-deleted.window="
|
||||
if ($event.detail.uuid === @js($savedToken->uuid)) visible = false
|
||||
">
|
||||
<x-modal-input title="Edit Integration Token" isFullWidth :wireIgnore="false"
|
||||
:contentClicks="false"
|
||||
class="border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
|
||||
<x-slot:content>
|
||||
<div class="grid min-h-14 w-full grid-cols-[minmax(0,1fr)_8rem_minmax(0,1fr)_2rem] items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-neutral-50 dark:hover:bg-white/[0.025]">
|
||||
<div class="min-w-0">
|
||||
<h3 class="truncate text-[13px]! font-semibold! text-black dark:text-fg">
|
||||
<span x-text="tokenName"></span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="text-center text-[12px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ ucfirst($savedToken->provider) }}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<template x-for="capability in tokenCapabilities" :key="capability">
|
||||
<span x-text="capability"
|
||||
class="rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-medium uppercase text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim"></span>
|
||||
</template>
|
||||
</div>
|
||||
<button type="button" class="icon-button" title="Edit integration token"
|
||||
:aria-label="`Edit ${tokenName}`" @click="modalOpen=true">
|
||||
<x-reicon name="settings" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</x-slot:content>
|
||||
<livewire:security.integration-token-editor
|
||||
:integration_token_uuid="$savedToken->uuid"
|
||||
:key="'integration-token-editor-'.$savedToken->uuid" />
|
||||
</x-modal-input>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
</x-security.settings-layout>
|
||||
</div>
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
<div class="mt-4">
|
||||
<p class="mb-1.5 text-xs font-medium text-neutral-500 dark:text-fg-dim">Read-only bind mount</p>
|
||||
<x-forms.copy-button
|
||||
<x-forms.copy-input
|
||||
text="- /data/coolify/ssl/coolify-ca.crt:/etc/ssl/certs/coolify-ca.crt:ro" />
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
</x-slot:actions>
|
||||
|
||||
<x-callout type="info" title="Supported package managers">
|
||||
Automated package discovery currently supports apt, dnf, and zypper. Weekly status notifications
|
||||
can be managed from
|
||||
Automated package discovery currently supports apk, apt, dnf, pacman, and zypper. Weekly status
|
||||
notifications can be managed from
|
||||
<a class="font-medium underline" href="{{ route('notifications.email') }}"
|
||||
{{ wireNavigate() }}>notification settings</a>.
|
||||
</x-callout>
|
||||
|
||||
@@ -5,76 +5,126 @@
|
||||
|
||||
<x-settings.layout>
|
||||
<x-slot:submenu>
|
||||
<div
|
||||
x-data="{ activeProvider: location.hash.slice(1).replace('-oauth-section', '') || '{{ $oauth_settings_map[0]['provider'] ?? '' }}' }"
|
||||
@hashchange.window="activeProvider = location.hash.slice(1).replace('-oauth-section', '')">
|
||||
<nav aria-label="OAuth providers"
|
||||
class="grid gap-0.5 py-1">
|
||||
@foreach ($oauth_settings_map as $oauth_setting)
|
||||
@php
|
||||
$provider = $oauth_setting['provider'];
|
||||
$providerLabel = str($provider)->headline();
|
||||
@endphp
|
||||
<a href="#{{ $provider }}-oauth-section" class="menu-item min-h-8! py-1! text-[12px]!"
|
||||
:class="{ 'menu-item-active': activeProvider === '{{ $provider }}' }"
|
||||
@click.prevent="activeProvider = '{{ $provider }}'; history.replaceState(null, '', '#{{ $provider }}-oauth-section'); window.scrollToSettingsSection?.('{{ $provider }}-oauth-section')">
|
||||
<span class="menu-item-icon bg-current"
|
||||
style="mask: url('{{ asset('svgs/' . $provider . '.svg') }}') center / contain no-repeat; -webkit-mask: url('{{ asset('svgs/' . $provider . '.svg') }}') center / contain no-repeat;"></span>
|
||||
<span class="menu-item-label">{{ $providerLabel }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
</div>
|
||||
<div
|
||||
x-data="{ activeProvider: location.hash.slice(1).replace('-oauth-section', '') || @js($selectedProvider ?? array_key_first($oauth_settings_map)) }"
|
||||
@hashchange.window="activeProvider = location.hash.slice(1).replace('-oauth-section', '')">
|
||||
<nav aria-label="OAuth providers" class="grid gap-0.5 py-1">
|
||||
@foreach ($oauth_settings_map as $provider => $oauth_setting)
|
||||
<a href="#{{ $provider }}-oauth-section" class="menu-item min-h-8! py-1! text-[12px]!"
|
||||
:class="{ 'menu-item-active': activeProvider === '{{ $provider }}' }"
|
||||
@click.prevent="activeProvider = '{{ $provider }}'; history.replaceState(null, '', '#{{ $provider }}-oauth-section'); window.scrollToSettingsSection?.('{{ $provider }}-oauth-section')">
|
||||
<span class="menu-item-icon bg-current"
|
||||
style="mask: url('{{ asset('svgs/' . $provider . '.svg') }}') center / contain no-repeat; -webkit-mask: url('{{ asset('svgs/' . $provider . '.svg') }}') center / contain no-repeat;"></span>
|
||||
<span class="menu-item-label">{{ $oauth_setting['label'] }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
</div>
|
||||
</x-slot:submenu>
|
||||
|
||||
<form wire:submit="submit" class="application-settings-form flex w-full min-w-0 flex-col gap-6">
|
||||
<x-unsaved-bar action="submit" />
|
||||
@foreach ($oauth_settings_map as $oauth_setting)
|
||||
@php
|
||||
$provider = $oauth_setting['provider'];
|
||||
$providerLabel = str($provider)->headline();
|
||||
@endphp
|
||||
|
||||
<x-application.settings-section title="Registration"
|
||||
description="Control password registration when an OAuth provider is available.">
|
||||
<x-forms.checkbox canGate="update" :canResource="$settings"
|
||||
id="disable_registration_when_oauth_enabled"
|
||||
label="Disable password registration when OAuth is enabled"
|
||||
helper="OAuth providers can still create users when registration is enabled for that provider."
|
||||
instantSave="saveRegistrationPolicy" />
|
||||
</x-application.settings-section>
|
||||
|
||||
@foreach ($oauth_settings_map as $provider => $oauth_setting)
|
||||
<x-application.settings-section id="{{ $provider }}-oauth-section" class="scroll-mt-28"
|
||||
title="{{ $providerLabel }}">
|
||||
title="{{ $oauth_setting['label'] }}">
|
||||
<x-slot:actions>
|
||||
<div x-data="{ enabled: @js((bool) $oauth_setting['enabled']), provider: @js($provider) }">
|
||||
<x-forms.button type="button" :isHighlighted="!$oauth_setting['enabled']"
|
||||
<x-forms.button canGate="update" :canResource="$settings" type="button"
|
||||
:isHighlighted="!$oauth_setting['enabled']"
|
||||
x-on:click="
|
||||
if (!enabled) {
|
||||
const invalidField = [...$el.closest('section').querySelectorAll('[required]')]
|
||||
.find(field => !field.checkValidity());
|
||||
if (invalidField) { invalidField.reportValidity(); return; }
|
||||
}
|
||||
$wire.toggleProvider(provider);
|
||||
">
|
||||
if (!enabled) {
|
||||
const invalidField = [...$el.closest('section').querySelectorAll('[required]')]
|
||||
.find(field => !field.checkValidity());
|
||||
if (invalidField) { invalidField.reportValidity(); return; }
|
||||
}
|
||||
$wire.toggleProvider(provider);
|
||||
">
|
||||
{{ $oauth_setting['enabled'] ? 'Disable' : 'Enable' }}
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</x-slot:actions>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input id="oauth_settings_map.{{ $provider }}.redirect_uri"
|
||||
placeholder="{{ route('auth.callback', $provider) }}" label="Redirect URI" />
|
||||
|
||||
<x-forms.input id="oauth_settings_map.{{ $provider }}.client_id"
|
||||
label="Client ID" required />
|
||||
<x-forms.input id="oauth_settings_map.{{ $provider }}.client_secret"
|
||||
type="password" label="Client secret" autocomplete="new-password" required />
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
@if ($provider === 'oidc')
|
||||
<x-forms.input canGate="update" :canResource="$settings"
|
||||
id="oauth_settings_map.{{ $provider }}.redirect_uri"
|
||||
placeholder="{{ route('auth.callback', $provider) }}" label="Redirect URI" />
|
||||
<x-forms.input canGate="update" :canResource="$settings"
|
||||
id="oauth_settings_map.{{ $provider }}.base_url" label="Issuer URL" required
|
||||
helper="OpenID Provider issuer URL, for example https://example.okta.com. Coolify uses it to discover the authorization, token, userinfo, and JWKS endpoints." />
|
||||
<x-forms.input canGate="update" :canResource="$settings"
|
||||
id="oauth_settings_map.{{ $provider }}.client_id" label="Client ID" required />
|
||||
<x-forms.input canGate="update" :canResource="$settings"
|
||||
id="oauth_settings_map.{{ $provider }}.client_secret" type="password"
|
||||
label="Client secret" autocomplete="new-password" required />
|
||||
<x-forms.input canGate="update" :canResource="$settings"
|
||||
id="oauth_settings_map.{{ $provider }}.scopes" label="Scopes"
|
||||
helper="Must include openid. Common scopes are openid email profile groups." />
|
||||
<x-forms.input canGate="update" :canResource="$settings"
|
||||
id="oauth_settings_map.{{ $provider }}.clock_skew_seconds" type="number"
|
||||
label="Clock skew (seconds)" />
|
||||
<div class="lg:col-span-2">
|
||||
<x-forms.input canGate="update" :canResource="$settings"
|
||||
id="oauth_settings_map.{{ $provider }}.custom_label" label="Login button label"
|
||||
placeholder="Login with SSO" />
|
||||
</div>
|
||||
@else
|
||||
<x-forms.input canGate="update" :canResource="$settings"
|
||||
id="oauth_settings_map.{{ $provider }}.redirect_uri"
|
||||
placeholder="{{ route('auth.callback', $provider) }}" label="Redirect URI" />
|
||||
<x-forms.input canGate="update" :canResource="$settings"
|
||||
id="oauth_settings_map.{{ $provider }}.client_id" label="Client ID" required />
|
||||
<x-forms.input canGate="update" :canResource="$settings"
|
||||
id="oauth_settings_map.{{ $provider }}.client_secret" type="password"
|
||||
label="Client secret" autocomplete="new-password" required />
|
||||
@endif
|
||||
|
||||
@if ($provider === 'azure')
|
||||
<x-forms.input id="oauth_settings_map.{{ $provider }}.tenant"
|
||||
label="Tenant" required />
|
||||
<x-forms.input canGate="update" :canResource="$settings"
|
||||
id="oauth_settings_map.{{ $provider }}.tenant" label="Tenant" required />
|
||||
@endif
|
||||
|
||||
@if ($provider === 'google')
|
||||
<x-forms.input id="oauth_settings_map.{{ $provider }}.tenant"
|
||||
<x-forms.input canGate="update" :canResource="$settings"
|
||||
id="oauth_settings_map.{{ $provider }}.tenant"
|
||||
helper="Optional hosted domain supplied to Google as a login hint."
|
||||
label="Hosted domain" />
|
||||
@endif
|
||||
|
||||
@if (in_array($provider, ['authentik', 'clerk', 'zitadel', 'gitlab'], true))
|
||||
<x-forms.input id="oauth_settings_map.{{ $provider }}.base_url"
|
||||
label="Base URL" :required="in_array($provider, ['authentik', 'clerk'], true)" />
|
||||
<x-forms.input canGate="update" :canResource="$settings"
|
||||
id="oauth_settings_map.{{ $provider }}.base_url" label="Base URL"
|
||||
:required="in_array($provider, ['authentik', 'clerk'], true)" />
|
||||
@endif
|
||||
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 lg:grid-cols-2">
|
||||
@if ($provider === 'oidc')
|
||||
<x-forms.checkbox canGate="update" :canResource="$settings"
|
||||
id="oauth_settings_map.{{ $provider }}.allow_registration"
|
||||
label="Allow OIDC user creation"
|
||||
helper="Allow a successful OIDC login to create a user when password registration is disabled." />
|
||||
<x-forms.checkbox canGate="update" :canResource="$settings"
|
||||
id="oauth_settings_map.{{ $provider }}.require_email_verified"
|
||||
label="Require verified email" />
|
||||
<x-forms.checkbox canGate="update" :canResource="$settings"
|
||||
id="oauth_settings_map.{{ $provider }}.use_pkce" label="Use PKCE" />
|
||||
@endif
|
||||
<x-forms.checkbox canGate="update" :canResource="$settings"
|
||||
id="oauth_settings_map.{{ $provider }}.auto_join_root_team"
|
||||
label="Auto-join new users to Root team"
|
||||
helper="Add newly-created OAuth users to the Root team as members without creating a personal team." />
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
@endforeach
|
||||
|
||||
@@ -13,12 +13,19 @@
|
||||
|
||||
<x-application.settings-section id="access-section" title="Access">
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.listbox id="is_registration_enabled" label="Registration"
|
||||
<x-forms.listbox id="is_registration_enabled" label="Registration"
|
||||
helper="Allow users to create their own account. When disabled, only administrators can create accounts."
|
||||
onChange="instantSave" :options="[
|
||||
['value' => true, 'label' => 'Anyone can register'],
|
||||
['value' => false, 'label' => 'Registration disabled'],
|
||||
]" />
|
||||
]" />
|
||||
<x-forms.listbox canGate="update" :canResource="$settings"
|
||||
id="disable_registration_when_oauth_enabled" label="Password registration with OAuth"
|
||||
helper="Hide password registration whenever at least one OAuth provider is enabled."
|
||||
onChange="instantSave" :options="[
|
||||
['value' => false, 'label' => 'Allow password registration'],
|
||||
['value' => true, 'label' => 'Disable when OAuth is enabled'],
|
||||
]" />
|
||||
<x-forms.listbox id="disable_two_step_confirmation" label="Destructive action confirmation"
|
||||
helper="Choose whether destructive actions require password and text confirmation."
|
||||
onChange="instantSave" :options="[
|
||||
|
||||
@@ -29,14 +29,7 @@
|
||||
<span
|
||||
class="min-w-0 truncate font-mono text-[12px] text-neutral-500 dark:text-fg-dim"
|
||||
title="{{ $invite->link }}">{{ $invite->link }}</span>
|
||||
<button type="button"
|
||||
class="button h-7! shrink-0 px-2!"
|
||||
title="Copy invitation link"
|
||||
aria-label="Copy invitation link"
|
||||
x-data
|
||||
x-on:click.prevent="window.copyToClipboard(@js($invite->link))">
|
||||
<x-reicon name="file-content" class="size-3.5" />
|
||||
</button>
|
||||
<x-copy-button :value="$invite->link" label="Copy invitation link" />
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<button type="button"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user