feat(auth): enterprise split login + signup (Fortify registration) via frontend-design
- login/register redesigned as a split brand-panel + clean form (Apple-ish enterprise look), cross-linked; brand panel uses accent gradient + tokens - Fortify registration enabled; /register full-page Livewire + RegisterTest Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
be99f413f7
commit
88957f8f57
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Auth;
|
||||
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Full-page signup view (R1/R2). The POST is handled by Fortify's registration
|
||||
* feature (register.store → App\Actions\Fortify\CreateNewUser).
|
||||
*/
|
||||
#[Layout('layouts.portal')]
|
||||
class Register extends Component
|
||||
{
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.auth.register');
|
||||
}
|
||||
}
|
||||
|
|
@ -167,7 +167,7 @@ return [
|
|||
// v1 customer portal: login + TOTP two-factor only. Public registration,
|
||||
// password reset, profile/password management and passkeys are out of
|
||||
// v1 scope (design handoff §2) — enable later as separate screens.
|
||||
// Features::registration(),
|
||||
Features::registration(),
|
||||
// Features::resetPasswords(),
|
||||
// Features::emailVerification(),
|
||||
// Features::updateProfileInformation(),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,26 @@ return [
|
|||
'password_label' => 'Passwort',
|
||||
'remember' => 'Angemeldet bleiben',
|
||||
'sign_in' => 'Anmelden',
|
||||
'no_account' => 'Noch kein Konto?',
|
||||
'sign_up' => 'Registrieren',
|
||||
|
||||
// Register
|
||||
'register_title' => 'Konto erstellen',
|
||||
'register_subtitle' => 'Starten Sie mit Ihrer verwalteten CluPilot-Cloud.',
|
||||
'name' => 'Name',
|
||||
'password_confirm' => 'Passwort bestätigen',
|
||||
'create_account' => 'Konto erstellen',
|
||||
'have_account' => 'Bereits registriert?',
|
||||
|
||||
// Brand panel
|
||||
'brand_headline' => 'Ihre Cloud. Vollständig verwaltet.',
|
||||
'brand_sub' => 'Nextcloud für Ihr Unternehmen — sicher betrieben, automatisch aktualisiert, gesichert.',
|
||||
'brand_point_1' => 'Automatische Backups & Updates',
|
||||
'brand_point_2' => 'Eigene Domain & Branding',
|
||||
'brand_point_3' => 'Gehostet in der EU, DSGVO-konform',
|
||||
'brand_footer' => '© CluPilot — Managed Nextcloud.',
|
||||
'register_brand_headline' => 'In Minuten startklar.',
|
||||
'register_brand_sub' => 'Konto erstellen, Paket wählen — den Rest übernehmen wir vollautomatisch.',
|
||||
|
||||
// Two-factor
|
||||
'twofa_title' => 'Zwei-Faktor-Bestätigung',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,26 @@ return [
|
|||
'password_label' => 'Password',
|
||||
'remember' => 'Stay signed in',
|
||||
'sign_in' => 'Sign in',
|
||||
'no_account' => 'No account yet?',
|
||||
'sign_up' => 'Sign up',
|
||||
|
||||
// Register
|
||||
'register_title' => 'Create your account',
|
||||
'register_subtitle' => 'Get started with your managed CluPilot cloud.',
|
||||
'name' => 'Name',
|
||||
'password_confirm' => 'Confirm password',
|
||||
'create_account' => 'Create account',
|
||||
'have_account' => 'Already registered?',
|
||||
|
||||
// Brand panel
|
||||
'brand_headline' => 'Your cloud. Fully managed.',
|
||||
'brand_sub' => 'Nextcloud for your business — securely operated, auto-updated, backed up.',
|
||||
'brand_point_1' => 'Automatic backups & updates',
|
||||
'brand_point_2' => 'Custom domain & branding',
|
||||
'brand_point_3' => 'Hosted in the EU, GDPR-compliant',
|
||||
'brand_footer' => '© CluPilot — Managed Nextcloud.',
|
||||
'register_brand_headline' => 'Ready in minutes.',
|
||||
'register_brand_sub' => 'Create an account, pick a plan — we handle the rest, fully automated.',
|
||||
|
||||
// Two-factor
|
||||
'twofa_title' => 'Two-factor confirmation',
|
||||
|
|
|
|||
|
|
@ -1,41 +1,58 @@
|
|||
<div class="flex min-h-screen items-center justify-center p-6">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-6 text-center">
|
||||
<span class="font-mono text-xl font-semibold tracking-tight text-ink">CluPilot</span>
|
||||
<div class="flex min-h-screen bg-bg">
|
||||
{{-- Brand panel (enterprise, hidden on small screens) --}}
|
||||
<aside class="relative hidden w-[44%] overflow-hidden bg-gradient-to-br from-accent via-accent-active to-accent-press lg:flex lg:flex-col lg:justify-between lg:p-12 xl:p-16">
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-0 opacity-[0.15]"
|
||||
style="background-image: radial-gradient(circle at 1px 1px, #fff 1px, transparent 0); background-size: 28px 28px;"></div>
|
||||
<div aria-hidden="true" class="pointer-events-none absolute -right-24 -top-24 size-96 rounded-full bg-white/10 blur-3xl"></div>
|
||||
|
||||
<div class="relative flex items-center gap-2.5">
|
||||
<x-ui.logo class="size-9" />
|
||||
<span class="font-mono text-xl font-semibold tracking-tight text-white">CluPilot</span>
|
||||
</div>
|
||||
|
||||
<x-ui.card>
|
||||
<h1 class="text-lg font-semibold text-ink">{{ __('auth.login_title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('auth.login_subtitle') }}</p>
|
||||
<div class="relative">
|
||||
<h2 class="max-w-md text-4xl font-semibold leading-tight tracking-tight text-white xl:text-5xl">{{ __('auth.brand_headline') }}</h2>
|
||||
<p class="mt-4 max-w-sm text-lg text-white/80">{{ __('auth.brand_sub') }}</p>
|
||||
<ul class="mt-10 space-y-3.5">
|
||||
@foreach (['brand_point_1', 'brand_point_2', 'brand_point_3'] as $point)
|
||||
<li class="flex items-center gap-3 text-white/90">
|
||||
<span class="grid size-6 shrink-0 place-items-center rounded-pill bg-white/15"><x-ui.icon name="check" class="size-3.5 text-white" /></span>
|
||||
<span>{{ __('auth.'.$point) }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p class="relative text-sm text-white/60">{{ __('auth.brand_footer') }}</p>
|
||||
</aside>
|
||||
|
||||
{{-- Form panel --}}
|
||||
<main class="flex flex-1 items-center justify-center px-6 py-12">
|
||||
<div class="w-full max-w-sm animate-rise">
|
||||
<div class="mb-8 flex items-center gap-2.5 lg:hidden">
|
||||
<x-ui.logo class="size-8" />
|
||||
<span class="font-mono text-lg font-semibold tracking-tight text-ink">CluPilot</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('auth.login_title') }}</h1>
|
||||
<p class="mt-1.5 text-sm text-muted">{{ __('auth.login_subtitle') }}</p>
|
||||
|
||||
@if (session('status'))
|
||||
<x-ui.alert variant="info" class="mt-4">{{ session('status') }}</x-ui.alert>
|
||||
<x-ui.alert variant="info" class="mt-5">{{ session('status') }}</x-ui.alert>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('login.store') }}" class="mt-5 space-y-4">
|
||||
<form method="POST" action="{{ route('login.store') }}" class="mt-7 space-y-5">
|
||||
@csrf
|
||||
<x-ui.input
|
||||
name="email"
|
||||
type="email"
|
||||
:label="__('auth.email')"
|
||||
autocomplete="email"
|
||||
autofocus
|
||||
required
|
||||
value="{{ old('email') }}"
|
||||
/>
|
||||
<x-ui.input
|
||||
name="password"
|
||||
type="password"
|
||||
:label="__('auth.password_label')"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
<x-ui.input name="email" type="email" :label="__('auth.email')" autocomplete="email" autofocus required value="{{ old('email') }}" />
|
||||
<x-ui.input name="password" type="password" :label="__('auth.password_label')" autocomplete="current-password" required />
|
||||
<x-ui.checkbox name="remember" :label="__('auth.remember')" value="1" />
|
||||
|
||||
<x-ui.button type="submit" variant="primary" class="w-full">
|
||||
{{ __('auth.sign_in') }}
|
||||
</x-ui.button>
|
||||
<x-ui.button type="submit" variant="primary" size="md" class="w-full">{{ __('auth.sign_in') }}</x-ui.button>
|
||||
</form>
|
||||
</x-ui.card>
|
||||
|
||||
<p class="mt-8 text-center text-sm text-muted">
|
||||
{{ __('auth.no_account') }}
|
||||
<a href="{{ route('register') }}" wire:navigate class="font-semibold text-accent-text hover:underline">{{ __('auth.sign_up') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
<div class="flex min-h-screen bg-bg">
|
||||
{{-- Brand panel (enterprise, hidden on small screens) --}}
|
||||
<aside class="relative hidden w-[44%] overflow-hidden bg-gradient-to-br from-accent via-accent-active to-accent-press lg:flex lg:flex-col lg:justify-between lg:p-12 xl:p-16">
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-0 opacity-[0.15]"
|
||||
style="background-image: radial-gradient(circle at 1px 1px, #fff 1px, transparent 0); background-size: 28px 28px;"></div>
|
||||
<div aria-hidden="true" class="pointer-events-none absolute -left-24 -bottom-24 size-96 rounded-full bg-white/10 blur-3xl"></div>
|
||||
|
||||
<div class="relative flex items-center gap-2.5">
|
||||
<x-ui.logo class="size-9" />
|
||||
<span class="font-mono text-xl font-semibold tracking-tight text-white">CluPilot</span>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<h2 class="max-w-md text-4xl font-semibold leading-tight tracking-tight text-white xl:text-5xl">{{ __('auth.register_brand_headline') }}</h2>
|
||||
<p class="mt-4 max-w-sm text-lg text-white/80">{{ __('auth.register_brand_sub') }}</p>
|
||||
<ul class="mt-10 space-y-3.5">
|
||||
@foreach (['brand_point_1', 'brand_point_2', 'brand_point_3'] as $point)
|
||||
<li class="flex items-center gap-3 text-white/90">
|
||||
<span class="grid size-6 shrink-0 place-items-center rounded-pill bg-white/15"><x-ui.icon name="check" class="size-3.5 text-white" /></span>
|
||||
<span>{{ __('auth.'.$point) }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p class="relative text-sm text-white/60">{{ __('auth.brand_footer') }}</p>
|
||||
</aside>
|
||||
|
||||
{{-- Form panel --}}
|
||||
<main class="flex flex-1 items-center justify-center px-6 py-12">
|
||||
<div class="w-full max-w-sm animate-rise">
|
||||
<div class="mb-8 flex items-center gap-2.5 lg:hidden">
|
||||
<x-ui.logo class="size-8" />
|
||||
<span class="font-mono text-lg font-semibold tracking-tight text-ink">CluPilot</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('auth.register_title') }}</h1>
|
||||
<p class="mt-1.5 text-sm text-muted">{{ __('auth.register_subtitle') }}</p>
|
||||
|
||||
<form method="POST" action="{{ route('register.store') }}" class="mt-7 space-y-5">
|
||||
@csrf
|
||||
<x-ui.input name="name" type="text" :label="__('auth.name')" autocomplete="name" autofocus required value="{{ old('name') }}" />
|
||||
<x-ui.input name="email" type="email" :label="__('auth.email')" autocomplete="email" required value="{{ old('email') }}" />
|
||||
<x-ui.input name="password" type="password" :label="__('auth.password_label')" autocomplete="new-password" required />
|
||||
<x-ui.input name="password_confirmation" type="password" :label="__('auth.password_confirm')" autocomplete="new-password" required />
|
||||
<x-ui.button type="submit" variant="primary" size="md" class="w-full">{{ __('auth.create_account') }}</x-ui.button>
|
||||
</form>
|
||||
|
||||
<p class="mt-8 text-center text-sm text-muted">
|
||||
{{ __('auth.have_account') }}
|
||||
<a href="{{ route('login') }}" wire:navigate class="font-semibold text-accent-text hover:underline">{{ __('auth.sign_in') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -31,6 +31,7 @@ Route::prefix('legal')->name('legal.')->group(function () {
|
|||
// handles the POST actions (login.store, two-factor.login.store) with views off.
|
||||
Route::middleware('guest')->group(function () {
|
||||
Route::get('/login', Login::class)->name('login');
|
||||
Route::get('/register', \App\Livewire\Auth\Register::class)->name('register');
|
||||
Route::get('/two-factor-challenge', TwoFactorChallenge::class)->name('two-factor.login');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
it('shows the signup page to guests', function () {
|
||||
$this->get(route('register'))->assertOk()->assertSee(__('auth.register_title'));
|
||||
});
|
||||
|
||||
it('links between login and signup', function () {
|
||||
$this->get(route('login'))->assertOk()->assertSee(route('register'));
|
||||
$this->get(route('register'))->assertOk()->assertSee(route('login'));
|
||||
});
|
||||
|
||||
it('registers a new account and signs in', function () {
|
||||
$this->post(route('register.store'), [
|
||||
'name' => 'New Co',
|
||||
'email' => 'new@signup.test',
|
||||
'password' => 'password1234',
|
||||
'password_confirmation' => 'password1234',
|
||||
])->assertRedirect();
|
||||
|
||||
$user = User::query()->where('email', 'new@signup.test')->first();
|
||||
expect($user)->not->toBeNull()->and($user->isOperator())->toBeFalse();
|
||||
$this->assertAuthenticatedAs($user);
|
||||
});
|
||||
|
||||
it('rejects a mismatched password confirmation', function () {
|
||||
$this->post(route('register.store'), [
|
||||
'name' => 'X',
|
||||
'email' => 'x@signup.test',
|
||||
'password' => 'password1234',
|
||||
'password_confirmation' => 'different',
|
||||
])->assertSessionHasErrors('password');
|
||||
|
||||
expect(User::query()->where('email', 'x@signup.test')->exists())->toBeFalse();
|
||||
});
|
||||
Loading…
Reference in New Issue