mirror of https://github.com/pixelfed/pixelfed
Update in-app registration
parent
88137649a3
commit
1cead5cf60
@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use DateInterval;
|
||||
use Laravel\Passport\Passport;
|
||||
use League\OAuth2\Server\Entities\ClientEntityInterface;
|
||||
use League\OAuth2\Server\Exception\OAuthServerException;
|
||||
use League\OAuth2\Server\Grant\AbstractGrant;
|
||||
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
|
||||
use League\OAuth2\Server\RequestAccessTokenEvent;
|
||||
use League\OAuth2\Server\RequestEvent;
|
||||
use League\OAuth2\Server\RequestRefreshTokenEvent;
|
||||
use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
/**
|
||||
* Issues an access token + refresh token pair for a given user against a
|
||||
* client the app registered through /api/v1/apps. Only ever enabled on the
|
||||
* private authorization server built by AppRegisterTokenFactory, so it is
|
||||
* never reachable through /oauth/token.
|
||||
*/
|
||||
class AppRegisterGrant extends AbstractGrant
|
||||
{
|
||||
public const IDENTIFIER = 'app_register';
|
||||
|
||||
public function __construct(RefreshTokenRepositoryInterface $refreshTokenRepository)
|
||||
{
|
||||
$this->setRefreshTokenRepository($refreshTokenRepository);
|
||||
$this->refreshTokenTTL = new DateInterval('P1M');
|
||||
}
|
||||
|
||||
public function respondToAccessTokenRequest(
|
||||
ServerRequestInterface $request,
|
||||
ResponseTypeInterface $responseType,
|
||||
DateInterval $accessTokenTTL
|
||||
): ResponseTypeInterface {
|
||||
$client = $this->validateRegisteredClient($request);
|
||||
|
||||
$userIdentifier = $this->getRequestParameter('user_id', $request);
|
||||
|
||||
if ($userIdentifier === null || $userIdentifier === '') {
|
||||
throw OAuthServerException::invalidRequest('user_id');
|
||||
}
|
||||
|
||||
$userIdentifier = (string) $userIdentifier;
|
||||
|
||||
$scopes = $this->scopeRepository->finalizeScopes(
|
||||
$this->validateScopes($this->getRequestParameter('scope', $request, $this->defaultScope)),
|
||||
$this->getIdentifier(),
|
||||
$client,
|
||||
$userIdentifier
|
||||
);
|
||||
|
||||
$accessToken = $this->issueAccessToken($accessTokenTTL, $client, $userIdentifier, $scopes);
|
||||
|
||||
$this->getEmitter()->emit(
|
||||
new RequestAccessTokenEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request, $accessToken)
|
||||
);
|
||||
|
||||
Passport::token()->newQuery()->whereKey($accessToken->getIdentifier())->update([
|
||||
'name' => $this->getRequestParameter('name', $request) ?: $client->getName(),
|
||||
]);
|
||||
|
||||
$responseType->setAccessToken($accessToken);
|
||||
|
||||
$refreshToken = $this->issueRefreshToken($accessToken);
|
||||
|
||||
if ($refreshToken !== null) {
|
||||
$this->getEmitter()->emit(
|
||||
new RequestRefreshTokenEvent(RequestEvent::REFRESH_TOKEN_ISSUED, $request, $refreshToken)
|
||||
);
|
||||
$responseType->setRefreshToken($refreshToken);
|
||||
}
|
||||
|
||||
return $responseType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same checks as AbstractGrant::validateClient minus the grant_types
|
||||
* gate. Clients created by /api/v1/apps have no explicit grant_types
|
||||
* column, so Passport computes the list and "app_register" is never in
|
||||
* it. We only need: client exists, is confidential, secret matches.
|
||||
*/
|
||||
protected function validateRegisteredClient(ServerRequestInterface $request): ClientEntityInterface
|
||||
{
|
||||
[$clientId, $clientSecret] = $this->getClientCredentials($request);
|
||||
|
||||
$client = $this->clientRepository->getClientEntity($clientId);
|
||||
|
||||
if (! $client instanceof ClientEntityInterface || ! $client->isConfidential()) {
|
||||
$this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request));
|
||||
|
||||
throw OAuthServerException::invalidClient($request);
|
||||
}
|
||||
|
||||
if (
|
||||
$clientSecret === '' ||
|
||||
! $this->clientRepository->validateClient($clientId, $clientSecret, $this->getIdentifier())
|
||||
) {
|
||||
$this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request));
|
||||
|
||||
throw OAuthServerException::invalidClient($request);
|
||||
}
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
public function getIdentifier(): string
|
||||
{
|
||||
return self::IDENTIFIER;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Laravel\Passport\Bridge\AccessTokenRepository;
|
||||
use Laravel\Passport\Bridge\ClientRepository;
|
||||
use Laravel\Passport\Bridge\RefreshTokenRepository;
|
||||
use Laravel\Passport\Bridge\ScopeRepository;
|
||||
use Laravel\Passport\Passport;
|
||||
use League\OAuth2\Server\AuthorizationServer;
|
||||
use League\OAuth2\Server\CryptKey;
|
||||
use League\OAuth2\Server\Exception\OAuthServerException;
|
||||
use League\OAuth2\Server\ResponseTypes\BearerTokenResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Mirrors Laravel\Passport\PersonalAccessTokenFactory: a dedicated
|
||||
* AuthorizationServer instance (not the shared singleton behind
|
||||
* /oauth/token) with only AppRegisterGrant enabled. Tokens it issues are
|
||||
* indistinguishable from ones issued by the authorization code flow, so
|
||||
* the standard refresh_token grant works on them.
|
||||
*/
|
||||
class AppRegisterTokenFactory
|
||||
{
|
||||
protected ?AuthorizationServer $server = null;
|
||||
|
||||
/**
|
||||
* Returns the decoded /oauth/token style payload:
|
||||
* token_type, expires_in, access_token, refresh_token.
|
||||
*
|
||||
* @param string[] $scopes
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @throws OAuthServerException
|
||||
*/
|
||||
public function issue(
|
||||
User $user,
|
||||
string $clientId,
|
||||
string $clientSecret,
|
||||
array $scopes,
|
||||
?string $name = null
|
||||
): array {
|
||||
$response = $this->server()->respondToAccessTokenRequest(
|
||||
$this->createRequest($user, $clientId, $clientSecret, $scopes, $name),
|
||||
app(ResponseInterface::class)
|
||||
);
|
||||
|
||||
return json_decode((string) $response->getBody(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap pre-check so the controller can reject bad client credentials
|
||||
* before it creates the user. Same repository call the grant makes.
|
||||
*/
|
||||
public function validateClient(string $clientId, string $clientSecret): bool
|
||||
{
|
||||
if ($clientId === '' || $clientSecret === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(ClientRepository::class)->validateClient(
|
||||
$clientId,
|
||||
$clientSecret,
|
||||
AppRegisterGrant::IDENTIFIER
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $scopes
|
||||
*/
|
||||
protected function createRequest(
|
||||
User $user,
|
||||
string $clientId,
|
||||
string $clientSecret,
|
||||
array $scopes,
|
||||
?string $name
|
||||
): ServerRequestInterface {
|
||||
return (new PsrHttpFactory)->createRequest(Request::create(config('app.url'), 'POST', [
|
||||
'grant_type' => AppRegisterGrant::IDENTIFIER,
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
'user_id' => (string) $user->getKey(),
|
||||
'scope' => implode(' ', $scopes),
|
||||
'name' => $name,
|
||||
]));
|
||||
}
|
||||
|
||||
protected function server(): AuthorizationServer
|
||||
{
|
||||
if ($this->server) {
|
||||
return $this->server;
|
||||
}
|
||||
|
||||
$server = new AuthorizationServer(
|
||||
app(ClientRepository::class),
|
||||
app(AccessTokenRepository::class),
|
||||
app(ScopeRepository::class),
|
||||
$this->privateKey(),
|
||||
Passport::tokenEncryptionKey(app('encrypter')),
|
||||
new BearerTokenResponse
|
||||
);
|
||||
|
||||
$server->setDefaultScope(Passport::$defaultScope);
|
||||
$server->revokeRefreshTokens(Passport::$revokeRefreshTokenAfterUse);
|
||||
|
||||
$grant = new AppRegisterGrant(app(RefreshTokenRepository::class));
|
||||
$grant->setRefreshTokenTTL(Passport::refreshTokensExpireIn());
|
||||
|
||||
$server->enableGrantType($grant, Passport::tokensExpireIn());
|
||||
|
||||
return $this->server = $server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same resolution as PassportServiceProvider::makeCryptKey('private').
|
||||
*/
|
||||
protected function privateKey(): CryptKey
|
||||
{
|
||||
$key = str_replace('\\n', "\n", config('passport.private_key') ?? '');
|
||||
|
||||
if (! $key) {
|
||||
$key = 'file://'.Passport::keyPath('oauth-private.key');
|
||||
}
|
||||
|
||||
return new CryptKey($key, null, Passport::$validateKeyPermissions);
|
||||
}
|
||||
}
|
||||
@ -1,146 +1,149 @@
|
||||
@extends('layouts.blank')
|
||||
|
||||
@section('content')
|
||||
<div class="container">
|
||||
<div class="row min-vh-100 align-items-center justify-content-center">
|
||||
<div class="col-12 col-md-6 col-lg-5">
|
||||
<div class="text-center mb-5">
|
||||
<img src="/img/pixelfed-icon-white.svg" width="90">
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="row min-vh-100 align-items-center justify-content-center">
|
||||
<div class="col-12 col-md-6 col-lg-5">
|
||||
<div class="text-center mb-5">
|
||||
<img src="/img/pixelfed-icon-white.svg" width="90">
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="text-center">Resend Verification</h3>
|
||||
<p class="lead text-center mb-4">Enter your email so we can send another verification code via email</p>
|
||||
|
||||
<form method="POST" action="/i/app-email-resend">
|
||||
@csrf
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email address</label>
|
||||
<input type="email"
|
||||
class="form-control @error('email') is-invalid @enderror"
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
placeholder="Enter your email address here"
|
||||
autocomplete="email"
|
||||
@if(request()->filled('email'))
|
||||
value="{{rawurldecode(request()->input('email'))}}"
|
||||
@endif
|
||||
>
|
||||
@error('email')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
@if((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register'))
|
||||
<div class="form-group text-center">
|
||||
{!! Captcha::display() !!}
|
||||
</div>
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="text-center">Resend Verification</h3>
|
||||
<p class="lead text-center mb-4">Enter your email so we can send another verification code via email</p>
|
||||
|
||||
<form method="POST" action="/i/app-email-resend">
|
||||
@csrf
|
||||
<input type="hidden" name="redirect_uri" value="{{ old('redirect_uri', $redirectUri) }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email address</label>
|
||||
<input type="email"
|
||||
class="form-control @error('email') is-invalid @enderror"
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
placeholder="Enter your email address here"
|
||||
autocomplete="email"
|
||||
@if(request()->filled('email'))
|
||||
value="{{rawurldecode(request()->input('email'))}}"
|
||||
@endif
|
||||
>
|
||||
@error('email')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
Send Verification Code
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mt-4">
|
||||
<p class="text-center">Click <a href="/i/app-email-verify">here</a> to send a new request.</p>
|
||||
@if((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register'))
|
||||
<div class="form-group text-center">
|
||||
{!! Captcha::display() !!}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
Send Verification Code
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mt-4">
|
||||
<p class="text-center">Click <a href="/i/app-email-verify">here</a> to send a new request.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #111827;
|
||||
--card-bg: #1f2937;
|
||||
--text-color: #f3f4f6;
|
||||
--text-muted: #9ca3af;
|
||||
--input-bg: #374151;
|
||||
--input-border: #4b5563;
|
||||
--input-focus: #3b82f6;
|
||||
--card-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.3);
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #111827;
|
||||
--card-bg: #1f2937;
|
||||
--text-color: #f3f4f6;
|
||||
--text-muted: #9ca3af;
|
||||
--input-bg: #374151;
|
||||
--input-border: #4b5563;
|
||||
--input-focus: #3b82f6;
|
||||
--card-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--card-bg);
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.benefits-list {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.benefits-list i {
|
||||
color: #3b82f6;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
background-color: var(--input-bg);
|
||||
border-color: var(--input-border);
|
||||
color: var(--text-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
background-color: var(--input-bg);
|
||||
border-color: var(--input-focus);
|
||||
color: var(--text-color);
|
||||
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
transition: transform 0.2s;
|
||||
background-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
background-color: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
a {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
a:hover {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--card-bg);
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.benefits-list {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.benefits-list i {
|
||||
color: #3b82f6;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
background-color: var(--input-bg);
|
||||
border-color: var(--input-border);
|
||||
color: var(--text-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
background-color: var(--input-bg);
|
||||
border-color: var(--input-focus);
|
||||
color: var(--text-color);
|
||||
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
transition: transform 0.2s;
|
||||
background-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
background-color: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
a {
|
||||
color: #60a5fa;
|
||||
}
|
||||
a:hover {
|
||||
color: #93c5fd;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
</style>
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@ -1,142 +1,146 @@
|
||||
@extends('layouts.blank')
|
||||
|
||||
@section('content')
|
||||
<div class="container">
|
||||
<div class="row min-vh-100 align-items-center justify-content-center">
|
||||
<div class="col-12 col-md-6 col-lg-5">
|
||||
<div class="text-center mb-5">
|
||||
<img src="/img/pixelfed-icon-white.svg" width="90">
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="row min-vh-100 align-items-center justify-content-center">
|
||||
<div class="col-12 col-md-6 col-lg-5">
|
||||
<div class="text-center mb-5">
|
||||
<img src="/img/pixelfed-icon-white.svg" width="90">
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-center">Join Pixelfed</h2>
|
||||
<p class="lead text-center mb-4">Enter Your Email</p>
|
||||
|
||||
<form method="POST">
|
||||
@csrf
|
||||
<input type="hidden" name="redirect_uri" value="{{ old('redirect_uri', $redirectUri) }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email address</label>
|
||||
<input type="email"
|
||||
class="form-control @error('email') is-invalid @enderror"
|
||||
id="email"
|
||||
name="email"
|
||||
value="{{ old('email') }}"
|
||||
placeholder="Enter your email address here"
|
||||
required
|
||||
autocomplete="email">
|
||||
@error('email')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-center">Join Pixelfed</h2>
|
||||
<p class="lead text-center mb-4">Enter Your Email</p>
|
||||
|
||||
<form method="POST">
|
||||
@csrf
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email address</label>
|
||||
<input type="email"
|
||||
class="form-control @error('email') is-invalid @enderror"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="Enter your email address here"
|
||||
required
|
||||
autocomplete="email">
|
||||
@error('email')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
@if((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register'))
|
||||
<div class="form-group text-center">
|
||||
{!! Captcha::display() !!}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
Send Verification Code
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mt-4">
|
||||
<p class="text-center">If you need to resend the email verification, click <a href="/i/app-email-resend">here</a>.</p>
|
||||
@if((bool) config_cache('captcha.enabled') && (bool) config_cache('captcha.active.register'))
|
||||
<div class="form-group text-center">
|
||||
{!! Captcha::display() !!}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
Send Verification Code
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mt-4">
|
||||
<p class="text-center">If you need to resend the email verification, click <a href="{{ $resendUrl }}">here</a>.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #111827;
|
||||
--card-bg: #1f2937;
|
||||
--text-color: #f3f4f6;
|
||||
--text-muted: #9ca3af;
|
||||
--input-bg: #374151;
|
||||
--input-border: #4b5563;
|
||||
--input-focus: #3b82f6;
|
||||
--card-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.3);
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #111827;
|
||||
--card-bg: #1f2937;
|
||||
--text-color: #f3f4f6;
|
||||
--text-muted: #9ca3af;
|
||||
--input-bg: #374151;
|
||||
--input-border: #4b5563;
|
||||
--input-focus: #3b82f6;
|
||||
--card-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--card-bg);
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.benefits-list {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.benefits-list i {
|
||||
color: #3b82f6;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
background-color: var(--input-bg);
|
||||
border-color: var(--input-border);
|
||||
color: var(--text-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
background-color: var(--input-bg);
|
||||
border-color: var(--input-focus);
|
||||
color: var(--text-color);
|
||||
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
transition: transform 0.2s;
|
||||
background-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
background-color: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
a {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
a:hover {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--card-bg);
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.benefits-list {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.benefits-list i {
|
||||
color: #3b82f6;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
background-color: var(--input-bg);
|
||||
border-color: var(--input-border);
|
||||
color: var(--text-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
background-color: var(--input-bg);
|
||||
border-color: var(--input-focus);
|
||||
color: var(--text-color);
|
||||
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
transition: transform 0.2s;
|
||||
background-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
background-color: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
a {
|
||||
color: #60a5fa;
|
||||
}
|
||||
a:hover {
|
||||
color: #93c5fd;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
</style>
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
Loading…
Reference in New Issue