Update in-app registration

pull/7243/head
Daniel Supernault 1 week ago
parent 88137649a3
commit 1cead5cf60
No known key found for this signature in database
GPG Key ID: 23740873EE6F76A1

@ -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);
}
}

@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Auth\AppRegisterTokenFactory;
use App\Mail\InAppRegisterEmailVerify;
use App\Models\AppRegister;
use App\Models\User;
@ -16,7 +17,9 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Laravel\Passport\Passport;
use Laravel\Passport\RefreshToken;
use League\OAuth2\Server\Exception\OAuthServerException;
use Purify;
class AppRegisterController extends Controller
@ -25,6 +28,28 @@ class AppRegisterController extends Controller
private const VERIFY_CODE_TTL_SECONDS = 3600;
private const RESEND_MAX_USES = 5;
/**
* Where the web steps send the browser when no redirect_uri is given.
* Keeps the original app working unchanged.
*/
private const LEGACY_REDIRECT_URI = 'pixelfed://verifyEmail';
private const DEFAULT_SCOPES = ['read', 'write', 'follow', 'push'];
private const BLOCKED_REDIRECT_SCHEMES = [
'http',
'https',
'javascript',
'data',
'file',
'ftp',
'blob',
'vbscript',
'about',
];
public function index(Request $request): RedirectResponse|View
{
abort_unless(config('auth.in_app_registration'), 404);
@ -33,7 +58,12 @@ class AppRegisterController extends Controller
return redirect('/');
}
return view('auth.iar');
$redirectUri = $this->resolveRedirectUri($request);
return view('auth.iar', [
'redirectUri' => $redirectUri,
'resendUrl' => $this->resendUrl($redirectUri),
]);
}
public function store(Request $request): RedirectResponse
@ -44,6 +74,8 @@ class AppRegisterController extends Controller
return redirect('/');
}
$redirectUri = $this->resolveRedirectUri($request);
$rules = [
'email' => 'required|email:rfc,dns,spoof,strict|unique:users,email|unique:app_registers,email',
];
@ -55,23 +87,22 @@ class AppRegisterController extends Controller
$this->validate($request, $rules);
$email = strtolower($request->input('email'));
$code = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$code = $this->generateCode();
DB::beginTransaction();
$exists = AppRegister::whereEmail($email)->count();
if ($exists) {
$errorParams = http_build_query([
DB::rollBack();
return $this->appRedirect($redirectUri, [
'status' => 'error',
'message' => 'Too many attempts, please try again later.',
]);
DB::rollBack();
return redirect()->away("pixelfed://verifyEmail?{$errorParams}");
}
$registration = AppRegister::create([
AppRegister::create([
'email' => $email,
'verify_code' => $code,
'uses' => 1,
@ -82,23 +113,20 @@ class AppRegisterController extends Controller
Mail::to($email)->send(new InAppRegisterEmailVerify($code));
} catch (\Exception $e) {
DB::rollBack();
$errorParams = http_build_query([
return $this->appRedirect($redirectUri, [
'status' => 'error',
'message' => 'Failed to send verification code',
]);
return redirect()->away("pixelfed://verifyEmail?{$errorParams}");
}
DB::commit();
$queryParams = http_build_query([
'email' => $request->email,
'expires_in' => 3600,
return $this->appRedirect($redirectUri, [
'status' => 'success',
'email' => $email,
'expires_in' => self::VERIFY_CODE_TTL_SECONDS,
]);
return redirect()->away("pixelfed://verifyEmail?{$queryParams}");
}
public function verifyCode(Request $request): JsonResponse|RedirectResponse
@ -141,7 +169,9 @@ class AppRegisterController extends Controller
return redirect('/');
}
return view('auth.iar-resend');
return view('auth.iar-resend', [
'redirectUri' => $this->resolveRedirectUri($request),
]);
}
public function resendVerificationStore(Request $request): RedirectResponse
@ -152,6 +182,8 @@ class AppRegisterController extends Controller
return redirect('/');
}
$redirectUri = $this->resolveRedirectUri($request);
$rules = [
'email' => 'required|email:rfc,dns,spoof,strict|unique:users,email|exists:app_registers,email',
];
@ -163,28 +195,24 @@ class AppRegisterController extends Controller
$this->validate($request, $rules);
$email = strtolower($request->input('email'));
$code = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$code = $this->generateCode();
DB::beginTransaction();
$exists = AppRegister::whereEmail($email)->first();
if (! $exists || $exists->uses > 5) {
$errorMessage = ! $exists
? 'Email not found'
: 'Too many attempts have been made, please contact the admins.';
if (! $exists || $exists->uses > self::RESEND_MAX_USES) {
DB::rollBack();
$errorParams = http_build_query([
return $this->appRedirect($redirectUri, [
'status' => 'error',
'message' => $errorMessage,
'message' => ! $exists
? 'Email not found'
: 'Too many attempts have been made, please contact the admins.',
]);
DB::rollBack();
return redirect()->away("pixelfed://verifyEmail?{$errorParams}");
}
$registration = $exists->update([
$exists->update([
'verify_code' => $code,
'uses' => ($exists->uses + 1),
'failed_attempts' => 0,
@ -195,23 +223,20 @@ class AppRegisterController extends Controller
Mail::to($email)->send(new InAppRegisterEmailVerify($code));
} catch (\Exception $e) {
DB::rollBack();
$errorParams = http_build_query([
return $this->appRedirect($redirectUri, [
'status' => 'error',
'message' => 'Failed to send verification code',
]);
return redirect()->away("pixelfed://verifyEmail?{$errorParams}");
}
DB::commit();
$queryParams = http_build_query([
'email' => $request->email,
'expires_in' => 3600,
return $this->appRedirect($redirectUri, [
'status' => 'success',
'email' => $email,
'expires_in' => self::VERIFY_CODE_TTL_SECONDS,
]);
return redirect()->away("pixelfed://verifyEmail?{$queryParams}");
}
public function onboarding(Request $request): JsonResponse|RedirectResponse
@ -228,19 +253,48 @@ class AppRegisterController extends Controller
'username' => $this->validateUsernameRule(),
'name' => 'nullable|string|max:'.config('pixelfed.max_name_length'),
'password' => 'required|string|min:'.config('pixelfed.min_password_length'),
'client_id' => 'nullable|string|max:80|required_with:client_secret',
'client_secret' => 'nullable|string|max:255|required_with:client_id',
'scope' => 'nullable|string|max:255',
]);
$email = strtolower($request->input('email'));
$code = $request->input('verify_code');
$code = (string) $request->input('verify_code');
$username = $request->input('username');
$name = $request->input('name');
$password = $request->input('password');
$clientId = $request->input('client_id');
$clientSecret = $request->input('client_secret');
$tokenFactory = app(AppRegisterTokenFactory::class);
$scopes = null;
if ($clientId) {
$scopes = $this->resolveScopes($request->input('scope'));
if ($scopes === null) {
return response()->json([
'status' => 'error',
'code' => 'invalid_scope',
'message' => 'Invalid scope.',
], 422);
}
if (! $tokenFactory->validateClient((string) $clientId, (string) $clientSecret)) {
return response()->json([
'status' => 'error',
'code' => 'invalid_client',
'message' => 'Invalid client credentials.',
], 401);
}
}
$result = $this->checkVerificationCode($email, (string) $code);
$result = $this->checkVerificationCode($email, $code);
if ($result['locked']) {
return response()->json([
'status' => 'error',
'code' => 'locked',
'message' => 'Too many verification attempts. Please request a new code.',
], 429);
}
@ -248,12 +302,13 @@ class AppRegisterController extends Controller
if (! $result['valid']) {
return response()->json([
'status' => 'error',
'code' => 'invalid_code',
'message' => 'Invalid or expired verification code.',
]);
}
$user = User::create([
'name' => Purify::clean($name),
'name' => $name ? Purify::clean($name) : null,
'username' => $username,
'email' => $email,
'password' => Hash::make($password),
@ -263,7 +318,48 @@ class AppRegisterController extends Controller
]);
$user->refresh();
$token = $user->createToken('Pixelfed App', ['read', 'write', 'follow', 'push']);
AppRegister::whereEmail($email)->delete();
if (! $clientId) {
return $this->legacyOnboardingResponse($user);
}
try {
$tokens = $tokenFactory->issue($user, (string) $clientId, (string) $clientSecret, $scopes);
} catch (OAuthServerException $e) {
return response()->json([
'status' => 'error',
'code' => 'account_created_token_failed',
'message' => 'Your account was created but we could not sign you in automatically. Please sign in with your email and password.',
]);
}
return response()->json([
'status' => 'success',
'domain' => config('pixelfed.domain.app'),
'token_type' => 'Bearer',
'access_token' => $tokens['access_token'],
'refresh_token' => $tokens['refresh_token'] ?? null,
'expires_in' => $tokens['expires_in'],
'scope' => $scopes,
'client_id' => (string) $clientId,
'user' => [
'pid' => (string) $user->profile_id,
'username' => $user->username,
],
'account' => AccountService::get($user->profile_id, true),
]);
}
/**
* Original personal-access-token path, kept byte-for-byte in behaviour
* for the previous app. Note the refresh_token here is a raw row id and
* is not usable with the refresh_token grant.
*/
protected function legacyOnboardingResponse(User $user): JsonResponse
{
$token = $user->createToken('Pixelfed App', self::DEFAULT_SCOPES);
$tokenModel = $token->token;
$clientId = $tokenModel->client_id;
$clientSecret = DB::table('oauth_clients')->where('id', $clientId)->value('secret');
@ -276,7 +372,6 @@ class AppRegisterController extends Controller
$expiresAt = $tokenModel->expires_at ?? now()->addDays(config('instance.oauth.token_expiration', 356));
$expiresIn = now()->diffInSeconds($expiresAt);
AppRegister::whereEmail($email)->delete();
return response()->json([
'status' => 'success',
@ -287,7 +382,7 @@ class AppRegisterController extends Controller
'refresh_token' => $refreshToken->id,
'client_id' => $clientId,
'client_secret' => $clientSecret,
'scope' => ['read', 'write', 'follow', 'push'],
'scope' => self::DEFAULT_SCOPES,
'user' => [
'pid' => (string) $user->profile_id,
'username' => $user->username,
@ -307,6 +402,100 @@ class AppRegisterController extends Controller
];
}
protected function generateCode(): string
{
return str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
}
/**
* Space or plus separated scope string from the app. Returns null when
* any requested scope is unknown to Passport. Empty means defaults.
*
* @return string[]|null
*/
protected function resolveScopes(?string $scope): ?array
{
$scopes = collect(explode(' ', str_replace('+', ' ', trim((string) $scope))))
->map(fn ($s) => trim($s))
->filter()
->unique()
->values()
->all();
if (! count($scopes)) {
return self::DEFAULT_SCOPES;
}
foreach ($scopes as $s) {
if ($s === '*' || ! Passport::hasScope($s)) {
return null;
}
}
return $scopes;
}
/**
* Validates the optional redirect_uri the app passes to the web steps.
* Only custom schemes on the configured allowlist are accepted, so this
* can never become an open redirect to a web origin. Query and fragment
* are stripped since we append our own params.
*/
protected function resolveRedirectUri(Request $request): string
{
$uri = $request->input('redirect_uri');
if (! is_string($uri) || trim($uri) === '') {
return self::LEGACY_REDIRECT_URI;
}
$uri = trim($uri);
abort_if(
strlen($uri) > 512 || preg_match('/[\s\x00-\x1F\x7F]/', $uri),
400,
'Invalid redirect_uri.'
);
$scheme = strtolower((string) parse_url($uri, PHP_URL_SCHEME));
abort_if(
$scheme === '' ||
in_array($scheme, self::BLOCKED_REDIRECT_SCHEMES, true) ||
! in_array($scheme, $this->allowedRedirectSchemes(), true),
400,
'Invalid redirect_uri.'
);
return preg_replace('/[?#].*$/', '', $uri);
}
/**
* @return string[]
*/
protected function allowedRedirectSchemes(): array
{
return collect(explode(',', (string) config('auth.in_app_registration_redirect_schemes', 'pixelfed')))
->map(fn ($s) => strtolower(trim($s)))
->filter()
->values()
->all();
}
protected function resendUrl(string $redirectUri): string
{
if ($redirectUri === self::LEGACY_REDIRECT_URI) {
return '/i/app-email-resend';
}
return '/i/app-email-resend?'.http_build_query(['redirect_uri' => $redirectUri]);
}
protected function appRedirect(string $redirectUri, array $params): RedirectResponse
{
return redirect()->away($redirectUri.'?'.http_build_query($params));
}
protected function checkVerificationCode(string $email, string $code): array
{
return DB::transaction(function () use ($email, $code) {

@ -121,6 +121,7 @@ return [
],
'in_app_registration' => (bool) env('APP_REGISTER', true),
'in_app_registration_redirect_schemes' => env('IN_APP_REGISTRATION_REDIRECT_SCHEMES', 'pixelfed'),
/*
|--------------------------------------------------------------------------

@ -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…
Cancel
Save