Compare commits

...

19 commits

48 changed files with 2704 additions and 200 deletions

View file

@ -92,6 +92,7 @@ pattern.
- Rebuild the development database with: - Rebuild the development database with:
```sh ```sh
direnv exec "$(git rev-parse --show-toplevel)" \
php artisan migrate:fresh --seed php artisan migrate:fresh --seed
``` ```
@ -101,7 +102,12 @@ pattern.
## Before completing backend work ## Before completing backend work
- Run the focused test during development. - Run the focused test during development.
- Run `php artisan test` before completion. - Run the full test suite before completion:
```sh
direnv exec "$(git rev-parse --show-toplevel)" php artisan test
```
- Run the Composer static-analysis scripts. - Run the Composer static-analysis scripts.
- Fix failures caused by the change. Report unrelated baseline failures - Fix failures caused by the change. Report unrelated baseline failures
precisely rather than hiding them or expanding scope without authorization. precisely rather than hiding them or expanding scope without authorization.

View file

@ -33,13 +33,14 @@ Use npm and keep `package-lock.json` committed. Run commands from
Install dependencies in a fresh checkout or worktree: Install dependencies in a fresh checkout or worktree:
```sh ```sh
npm install direnv exec "$(git rev-parse --show-toplevel)" npm install
``` ```
Start the development server on the port assigned by the shell hook: Start the development server on the port assigned by the shell hook:
```sh ```sh
npm run dev -- --port "$VITE_PORT" direnv exec "$(git rev-parse --show-toplevel)" \
npm run dev -- --port "$VITE_PORT"
``` ```
`process-compose` does not start or proxy the frontend. `process-compose` does not start or proxy the frontend.
@ -47,10 +48,10 @@ npm run dev -- --port "$VITE_PORT"
The available validation commands are: The available validation commands are:
```sh ```sh
npm run format direnv exec "$(git rev-parse --show-toplevel)" npm run format
npm run lint direnv exec "$(git rev-parse --show-toplevel)" npm run lint
npm run type-check direnv exec "$(git rev-parse --show-toplevel)" npm run type-check
npm run build direnv exec "$(git rev-parse --show-toplevel)" npm run build
``` ```
`npm run format` and `npm run lint` rewrite files. Review the resulting diff. `npm run format` and `npm run lint` rewrite files. Review the resulting diff.

View file

@ -45,28 +45,43 @@ those changes with the most relevant parser, formatter, dry run, or check.
is the user's stack. Do not start, restart, or stop it unless the user asks. is the user's stack. Do not start, restart, or stop it unless the user asks.
- A worktree owns its own isolated stack. The flake shell hook assigns a - A worktree owns its own isolated stack. The flake shell hook assigns a
deterministic port offset and creates worktree-local PostgreSQL state. deterministic port offset and creates worktree-local PostgreSQL state.
- Start a worktree stack from its root with `process-compose up`. For - Start a worktree stack from its root. For non-interactive use, start it
non-interactive use, run `process-compose up -D` and stop it with detached and stop it when finished, as shown below.
`process-compose down`.
- Do not use `process-compose -t=false` for a detached stack. It can leave an - Do not use `process-compose -t=false` for a detached stack. It can leave an
orphaned PostgreSQL process holding the data directory. orphaned PostgreSQL process holding the data directory.
- Non-interactive agent shells do not automatically load direnv. A bare - Non-interactive agent shells do not automatically load direnv. Bare project
`process-compose`, `php artisan`, or database command from a worktree can commands can use missing tools, default ports, or paths from the main
silently use default ports and target the main checkout. checkout.
- Prefix worktree stack and database commands with `direnv exec <worktree>`. - Run project tooling that depends on the repository development environment
Examples: through direnv. This includes PHP, Composer, Artisan, npm, tests, builds,
database clients, and services.
- Resolve the direnv target from the worktree containing the agent's current
working directory. Never target the main checkout or a different worktree:
```sh ```sh
direnv exec <worktree> process-compose up -D direnv exec "$(git rev-parse --show-toplevel)" <command>
direnv exec <worktree> process-compose down ```
direnv exec <worktree> php artisan migrate:fresh --seed
- Git and environment-neutral read-only file inspection do not need the
direnv wrapper.
- Worktree stack examples:
```sh
direnv exec "$(git rev-parse --show-toplevel)" process-compose up -D
direnv exec "$(git rev-parse --show-toplevel)" process-compose down
``` ```
- Run backend commands from `backend/`, or explicitly change into it in the - Run backend commands from `backend/`, or explicitly change into it in the
command. command. The direnv target remains the worktree root.
- Run frontend commands from `frontend/website/`. - Run frontend commands from `frontend/website/`.
- `process-compose` does not start the frontend. Start it separately with - `process-compose` does not start the frontend. Start it separately with
`npm run dev -- --port "$VITE_PORT"` when needed. the worktree's assigned port when needed:
```sh
direnv exec "$(git rev-parse --show-toplevel)" \
npm run dev -- --port "$VITE_PORT"
```
- When a normally valid check fails because a required service is down, - When a normally valid check fails because a required service is down,
surface the environmental failure. Do not skip the check or silently switch surface the environmental failure. Do not skip the check or silently switch
to a different database or service. to a different database or service.
@ -143,8 +158,11 @@ those changes with the most relevant parser, formatter, dry run, or check.
another checkout. Dependency paths and generated files must remain another checkout. Dependency paths and generated files must remain
worktree-local. worktree-local.
- The shell hook installs backend dependencies but does not install frontend - The shell hook installs backend dependencies but does not install frontend
dependencies. Run `npm install` from `frontend/website/` when provisioning a dependencies. From `frontend/website/`, provision them with:
fresh worktree.
```sh
direnv exec "$(git rev-parse --show-toplevel)" npm install
```
Do not push anything. Make commits as the TDD workflow requires. Do not push anything. Make commits as the TDD workflow requires.
@ -155,12 +173,17 @@ gate affected by the change.
### Backend ### Backend
- Run tests from `backend/` with `php artisan test`. - Run tests from `backend/`:
```sh
direnv exec "$(git rev-parse --show-toplevel)" php artisan test
```
- Run the Composer checks defined by `backend/composer.json`: - Run the Composer checks defined by `backend/composer.json`:
```sh ```sh
composer types:check direnv exec "$(git rev-parse --show-toplevel)" composer types:check
composer test direnv exec "$(git rev-parse --show-toplevel)" composer test
``` ```
- Do not claim a green gate when a command fails. If the failure predates the - Do not claim a green gate when a command fails. If the failure predates the
@ -171,10 +194,10 @@ gate affected by the change.
- Run these commands from `frontend/website/`: - Run these commands from `frontend/website/`:
```sh ```sh
npm run format direnv exec "$(git rev-parse --show-toplevel)" npm run format
npm run lint direnv exec "$(git rev-parse --show-toplevel)" npm run lint
npm run type-check direnv exec "$(git rev-parse --show-toplevel)" npm run type-check
npm run build direnv exec "$(git rev-parse --show-toplevel)" npm run build
``` ```
- The formatter and linters rewrite files. Review their changes before - The formatter and linters rewrite files. Review their changes before
@ -184,8 +207,20 @@ gate affected by the change.
### Environment and integration ### Environment and integration
- For Nix or shell-hook changes, run `nix fmt` and `nix flake check`. - For Nix or shell-hook changes, run:
- For service configuration changes, run `process-compose --dry-run`.
```sh
direnv exec "$(git rev-parse --show-toplevel)" nix fmt
direnv exec "$(git rev-parse --show-toplevel)" nix flake check
```
- For service configuration changes, run:
```sh
direnv exec "$(git rev-parse --show-toplevel)" \
process-compose --dry-run
```
- When a change affects runtime wiring, start the worktree's stack and verify - When a change affects runtime wiring, start the worktree's stack and verify
the relevant endpoint or service against that worktree. the relevant endpoint or service against that worktree.
- If you started the stack only for validation, stop it before finishing. - If you started the stack only for validation, stop it before finishing.

View file

@ -0,0 +1,10 @@
<?php
namespace App\Auth;
use DateTimeImmutable;
interface Clock
{
public function now(): DateTimeImmutable;
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Auth;
use App\User\User;
use DateTimeImmutable;
final readonly class CreateSessionDto
{
public function __construct(
public string $token,
public User $user,
public DateTimeImmutable $createdAt,
public DateTimeImmutable $expiresAt,
) {}
}

View file

@ -0,0 +1,62 @@
<?php
namespace App\Auth;
use App\User\UserRepository;
use DateTimeImmutable;
use DateTimeZone;
class EloquentSessionRepository implements SessionRepository
{
public function __construct(
private UserRepository $userRepository,
) {}
public function create(CreateSessionDto $dto): Session
{
SessionModel::create([
'token' => $dto->token,
'user_id' => $dto->user->getId(),
'created_at' => $dto->createdAt,
'expires_at' => $dto->expiresAt,
]);
return new Session(
token: $dto->token,
user: $dto->user,
createdAt: $dto->createdAt,
expiresAt: $dto->expiresAt,
);
}
public function findByToken(string $token): ?Session
{
$model = SessionModel::find($token);
if ($model === null) {
return null;
}
$user = $this->userRepository->find($model->user_id);
if ($user === null) {
return null;
}
return new Session(
token: $model->token,
user: $user,
createdAt: $this->toUtc($model->created_at),
expiresAt: $this->toUtc($model->expires_at),
);
}
public function deleteByToken(string $token): void
{
SessionModel::where('token', $token)->delete();
}
private function toUtc(DateTimeImmutable $dateTime): DateTimeImmutable
{
return DateTimeImmutable::createFromInterface($dateTime)
->setTimezone(new DateTimeZone('UTC'));
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace App\Auth;
use App\User\User;
use DateTimeImmutable;
final readonly class Session
{
public function __construct(
private string $token,
private User $user,
private DateTimeImmutable $createdAt,
private DateTimeImmutable $expiresAt,
) {}
public function getToken(): string
{
return $this->token;
}
public function getUser(): User
{
return $this->user;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getExpiresAt(): DateTimeImmutable
{
return $this->expiresAt;
}
public function isExpired(DateTimeImmutable $now): bool
{
return $now >= $this->expiresAt;
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace App\Auth;
use DateTimeImmutable;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* @property string $token
* @property int $user_id
* @property DateTimeImmutable $created_at
* @property DateTimeImmutable $expires_at
*
* @method static Builder<static>|SessionModel newModelQuery()
* @method static Builder<static>|SessionModel newQuery()
* @method static Builder<static>|SessionModel query()
*
* @mixin \Eloquent
*/
#[Fillable([
'token',
'user_id',
'created_at',
'expires_at',
])]
class SessionModel extends Model
{
protected $table = 'sessions';
protected $primaryKey = 'token';
public $incrementing = false;
protected $keyType = 'string';
public $timestamps = false;
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'created_at' => 'datetime',
'expires_at' => 'datetime',
];
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace App\Auth;
interface SessionRepository
{
public function create(CreateSessionDto $dto): Session;
public function findByToken(string $token): ?Session;
public function deleteByToken(string $token): void;
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Auth;
use DateTimeImmutable;
use DateTimeZone;
class SystemClock implements Clock
{
public function now(): DateTimeImmutable
{
return new DateTimeImmutable(
'now',
new DateTimeZone('UTC'),
);
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Http\Middleware;
use App\Auth\Clock;
use App\Auth\SessionRepository;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AuthMiddleware
{
public const string COOKIE_NAME = 'auth_token';
public function __construct(
private SessionRepository $sessionRepository,
private Clock $clock,
) {}
/**
* @param Closure(Request): Response $next
*/
public function handle(Request $request, Closure $next): Response
{
$token = $request->cookie(self::COOKIE_NAME);
if (! is_string($token) || $token === '') {
return $this->unauthorized();
}
$session = $this->sessionRepository->findByToken($token);
if ($session === null) {
return $this->unauthorized();
}
if ($session->isExpired($this->clock->now())) {
$this->sessionRepository->deleteByToken($token);
return $this->unauthorized();
}
$request->attributes->set('user', $session->getUser());
return $next($request);
}
private function unauthorized(): JsonResponse
{
return new JsonResponse(['error' => 'unauthenticated'], 401);
}
}

View file

@ -1,40 +0,0 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property string $name
* @property string $email
* @property Carbon|null $email_verified_at
* @property string $password
* @property string|null $remember_token
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
use Notifiable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}

View file

@ -2,6 +2,12 @@
namespace App\Providers; namespace App\Providers;
use App\Auth\Clock;
use App\Auth\EloquentSessionRepository;
use App\Auth\SessionRepository;
use App\Auth\SystemClock;
use App\User\EloquentUserRepository;
use App\User\UserRepository;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -15,7 +21,15 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
// $this->app->bind(
UserRepository::class,
EloquentUserRepository::class,
);
$this->app->bind(
SessionRepository::class,
EloquentSessionRepository::class,
);
$this->app->bind(Clock::class, SystemClock::class);
} }
/** /**

View file

@ -0,0 +1,40 @@
<?php
namespace App\Shared\ValueObject;
use InvalidArgumentException;
final readonly class EmailAddress
{
private const string ERROR_MESSAGE = 'Invalid email address:';
private string $normalized;
public function __construct(string $email)
{
$trimmedEmail = trim($email);
if (
$trimmedEmail === ''
|| ! str_contains($trimmedEmail, '@')
) {
throw new InvalidArgumentException(
self::ERROR_MESSAGE." $email",
);
}
[$localPart, $domain] = explode('@', $trimmedEmail, 2);
$normalizedEmail = $localPart.'@'.mb_strtolower($domain);
if (filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) === false) {
throw new InvalidArgumentException(
self::ERROR_MESSAGE." $email",
);
}
$this->normalized = $normalizedEmail;
}
public function value(): string
{
return $this->normalized;
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace App\User;
use App\Shared\ValueObject\EmailAddress;
final readonly class CreateUserDto
{
public function __construct(
public EmailAddress $email,
) {}
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\User;
use App\Shared\ValueObject\EmailAddress;
class EloquentUserRepository implements UserRepository
{
public function create(CreateUserDto $dto): User
{
$model = UserModel::create([
'email' => $dto->email->value(),
]);
return $this->toDomain($model);
}
public function find(int $id): ?User
{
$model = UserModel::find($id);
if ($model === null) {
return null;
}
return $this->toDomain($model);
}
private function toDomain(UserModel $model): User
{
return new User(
id: $model->id,
email: new EmailAddress($model->email),
);
}
}

23
backend/app/User/User.php Normal file
View file

@ -0,0 +1,23 @@
<?php
namespace App\User;
use App\Shared\ValueObject\EmailAddress;
final readonly class User
{
public function __construct(
private int $id,
private EmailAddress $email,
) {}
public function getId(): int
{
return $this->id;
}
public function getEmail(): EmailAddress
{
return $this->email;
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\User;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property string $email
*
* @method static Builder<static>|UserModel newModelQuery()
* @method static Builder<static>|UserModel newQuery()
* @method static Builder<static>|UserModel query()
*
* @mixin \Eloquent
*/
#[Fillable(['email'])]
class UserModel extends Model
{
protected $table = 'users';
public $timestamps = false;
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\User;
interface UserRepository
{
public function create(CreateUserDto $dto): User;
public function find(int $id): ?User;
}

View file

@ -1,117 +0,0 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

View file

@ -0,0 +1,21 @@
<?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('users', function (Blueprint $table): void {
$table->id();
$table->string('email')->unique();
});
}
public function down(): void
{
Schema::dropIfExists('users');
}
};

View file

@ -0,0 +1,25 @@
<?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('sessions', function (Blueprint $table): void {
$table->string('token', 64)->primary();
$table->foreignId('user_id')
->constrained('users')
->cascadeOnDelete();
$table->timestamp('created_at');
$table->timestamp('expires_at')->index();
});
}
public function down(): void
{
Schema::dropIfExists('sessions');
}
};

View file

@ -0,0 +1,18 @@
<?php
namespace Tests\Fakes;
use App\Auth\Clock;
use DateTimeImmutable;
class FakeClock implements Clock
{
public function __construct(
private DateTimeImmutable $currentTime,
) {}
public function now(): DateTimeImmutable
{
return $this->currentTime;
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace Tests\Fakes;
use App\Auth\CreateSessionDto;
use App\Auth\Session;
use App\Auth\SessionRepository;
class FakeSessionRepository implements SessionRepository
{
/**
* @var array<string, Session>
*/
private array $sessions = [];
public function create(CreateSessionDto $dto): Session
{
$session = new Session(
token: $dto->token,
user: $dto->user,
createdAt: $dto->createdAt,
expiresAt: $dto->expiresAt,
);
$this->sessions[$dto->token] = $session;
return $session;
}
public function findByToken(string $token): ?Session
{
return $this->sessions[$token] ?? null;
}
public function deleteByToken(string $token): void
{
unset($this->sessions[$token]);
}
}

View file

@ -0,0 +1,119 @@
<?php
namespace Tests\Feature\Auth;
use App\Auth\Clock;
use App\Auth\CreateSessionDto;
use App\Auth\SessionRepository;
use App\Http\Middleware\AuthMiddleware;
use App\Shared\ValueObject\EmailAddress;
use App\User\CreateUserDto;
use App\User\User;
use App\User\UserRepository;
use DateTimeImmutable;
use DateTimeZone;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Tests\Fakes\FakeClock;
use Tests\TestCase;
class AuthMiddlewareTest extends TestCase
{
use RefreshDatabase;
private DateTimeImmutable $now;
protected function setUp(): void
{
parent::setUp();
$this->now = $this->utc('2026-07-31T12:00:00');
$this->app->instance(Clock::class, new FakeClock($this->now));
Route::middleware(AuthMiddleware::class)->get(
'/test/authenticated-user',
function (Request $request): JsonResponse {
$user = $request->attributes->get('user');
if (! $user instanceof User) {
return new JsonResponse(['error' => 'missing user'], 500);
}
return new JsonResponse([
'id' => $user->getId(),
'email' => $user->getEmail()->value(),
]);
},
);
}
public function test_valid_cookie_reaches_the_protected_route(): void
{
$user = $this->createUserAndSession(
token: 'valid-token',
expiresAt: $this->now->modify('+7 days'),
);
$response = $this->withCredentials()
->withUnencryptedCookie(
AuthMiddleware::COOKIE_NAME,
'valid-token',
)->getJson('/test/authenticated-user');
$response->assertOk()->assertExactJson([
'id' => $user->getId(),
'email' => 'user@example.com',
]);
}
public function test_missing_cookie_is_rejected(): void
{
$response = $this->getJson('/test/authenticated-user');
$response
->assertStatus(401)
->assertExactJson(['error' => 'unauthenticated']);
}
public function test_expired_cookie_is_rejected_and_deleted(): void
{
$this->createUserAndSession(
token: 'expired-token',
expiresAt: $this->now->modify('-1 day'),
);
$response = $this->withCredentials()
->withUnencryptedCookie(
AuthMiddleware::COOKIE_NAME,
'expired-token',
)->getJson('/test/authenticated-user');
$response->assertStatus(401);
$this->assertNull(
app(SessionRepository::class)->findByToken('expired-token'),
);
}
private function createUserAndSession(
string $token,
DateTimeImmutable $expiresAt,
): User {
$user = app(UserRepository::class)->create(new CreateUserDto(
email: new EmailAddress('user@example.com'),
));
app(SessionRepository::class)->create(new CreateSessionDto(
token: $token,
user: $user,
createdAt: $this->now,
expiresAt: $expiresAt,
));
return $user;
}
private function utc(string $time): DateTimeImmutable
{
return new DateTimeImmutable($time, new DateTimeZone('UTC'));
}
}

View file

@ -0,0 +1,85 @@
<?php
namespace Tests\Feature\Auth;
use App\Auth\CreateSessionDto;
use App\Auth\SessionRepository;
use App\Shared\ValueObject\EmailAddress;
use App\User\CreateUserDto;
use App\User\UserRepository;
use DateTimeImmutable;
use DateTimeZone;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class EloquentSessionRepositoryTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_and_finds_a_session(): void
{
$user = app(UserRepository::class)->create(new CreateUserDto(
email: new EmailAddress('user@example.com'),
));
$createdAt = $this->utc('2026-07-31T12:00:00');
$expiresAt = $this->utc('2026-08-07T12:00:00');
$repository = app(SessionRepository::class);
$session = $repository->create(new CreateSessionDto(
token: 'session-token',
user: $user,
createdAt: $createdAt,
expiresAt: $expiresAt,
));
$this->assertSame('session-token', $session->getToken());
$this->assertSame($user, $session->getUser());
$this->assertDatabaseHas('sessions', [
'token' => 'session-token',
'user_id' => $user->getId(),
]);
$foundSession = $repository->findByToken('session-token');
$this->assertNotNull($foundSession);
$this->assertSame(
$user->getId(),
$foundSession->getUser()->getId(),
);
$this->assertEquals($createdAt, $foundSession->getCreatedAt());
$this->assertEquals($expiresAt, $foundSession->getExpiresAt());
}
public function test_it_returns_null_for_an_unknown_token(): void
{
$repository = app(SessionRepository::class);
$this->assertNull($repository->findByToken('unknown-token'));
}
public function test_it_deletes_a_session_by_token(): void
{
$user = app(UserRepository::class)->create(new CreateUserDto(
email: new EmailAddress('user@example.com'),
));
$repository = app(SessionRepository::class);
$repository->create(new CreateSessionDto(
token: 'session-token',
user: $user,
createdAt: $this->utc('2026-07-31T12:00:00'),
expiresAt: $this->utc('2026-08-07T12:00:00'),
));
$repository->deleteByToken('session-token');
$this->assertNull($repository->findByToken('session-token'));
$this->assertDatabaseMissing('sessions', [
'token' => 'session-token',
]);
}
private function utc(string $time): DateTimeImmutable
{
return new DateTimeImmutable($time, new DateTimeZone('UTC'));
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace Tests\Feature\User;
use App\Shared\ValueObject\EmailAddress;
use App\User\CreateUserDto;
use App\User\UserRepository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class EloquentUserRepositoryTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_and_finds_a_user(): void
{
$repository = app(UserRepository::class);
$user = $repository->create(new CreateUserDto(
email: new EmailAddress('Founder@EXAMPLE.COM'),
));
$this->assertGreaterThan(0, $user->getId());
$this->assertSame(
'Founder@example.com',
$user->getEmail()->value(),
);
$this->assertDatabaseHas('users', [
'id' => $user->getId(),
'email' => 'Founder@example.com',
]);
$foundUser = $repository->find($user->getId());
$this->assertNotNull($foundUser);
$this->assertSame($user->getId(), $foundUser->getId());
$this->assertSame(
$user->getEmail()->value(),
$foundUser->getEmail()->value(),
);
}
public function test_it_returns_null_for_an_unknown_user(): void
{
$repository = app(UserRepository::class);
$this->assertNull($repository->find(999));
}
}

View file

@ -0,0 +1,159 @@
<?php
namespace Tests\Unit\Auth\Middleware;
use App\Auth\CreateSessionDto;
use App\Http\Middleware\AuthMiddleware;
use App\Shared\ValueObject\EmailAddress;
use App\User\User;
use Closure;
use DateTimeImmutable;
use DateTimeZone;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use PHPUnit\Framework\TestCase;
use Tests\Fakes\FakeClock;
use Tests\Fakes\FakeSessionRepository;
class AuthMiddlewareTest extends TestCase
{
private FakeSessionRepository $sessionRepository;
private DateTimeImmutable $now;
private AuthMiddleware $middleware;
protected function setUp(): void
{
$this->now = new DateTimeImmutable(
'2026-07-31T12:00:00',
new DateTimeZone('UTC'),
);
$this->sessionRepository = new FakeSessionRepository;
$this->middleware = new AuthMiddleware(
sessionRepository: $this->sessionRepository,
clock: new FakeClock($this->now),
);
}
public function test_missing_cookie_returns_unauthenticated(): void
{
$capturedRequest = null;
$response = $this->middleware->handle(
$this->requestWithToken(null),
$this->captureNextRequest($capturedRequest),
);
$this->assertSame(401, $response->getStatusCode());
$this->assertSame(
['error' => 'unauthenticated'],
json_decode($response->getContent(), true),
);
$this->assertNull($capturedRequest);
}
public function test_empty_cookie_returns_unauthenticated(): void
{
$capturedRequest = null;
$response = $this->middleware->handle(
$this->requestWithToken(''),
$this->captureNextRequest($capturedRequest),
);
$this->assertSame(401, $response->getStatusCode());
$this->assertNull($capturedRequest);
}
public function test_unknown_token_returns_unauthenticated(): void
{
$capturedRequest = null;
$response = $this->middleware->handle(
$this->requestWithToken('unknown-token'),
$this->captureNextRequest($capturedRequest),
);
$this->assertSame(401, $response->getStatusCode());
$this->assertNull($capturedRequest);
}
public function test_expired_session_is_deleted(): void
{
$this->sessionRepository->create(new CreateSessionDto(
token: 'expired-token',
user: $this->user(),
createdAt: $this->now->modify('-8 days'),
expiresAt: $this->now->modify('-1 day'),
));
$capturedRequest = null;
$response = $this->middleware->handle(
$this->requestWithToken('expired-token'),
$this->captureNextRequest($capturedRequest),
);
$this->assertSame(401, $response->getStatusCode());
$this->assertNull($capturedRequest);
$this->assertNull(
$this->sessionRepository->findByToken('expired-token'),
);
}
public function test_valid_session_attaches_user_and_calls_next(): void
{
$user = $this->user();
$this->sessionRepository->create(new CreateSessionDto(
token: 'valid-token',
user: $user,
createdAt: $this->now,
expiresAt: $this->now->modify('+7 days'),
));
$capturedRequest = null;
$response = $this->middleware->handle(
$this->requestWithToken('valid-token'),
$this->captureNextRequest($capturedRequest),
);
$this->assertSame(200, $response->getStatusCode());
$this->assertNotNull($capturedRequest);
$this->assertSame(
$user,
$capturedRequest->attributes->get('user'),
);
}
private function requestWithToken(?string $token): Request
{
$request = Request::create('/anything', 'GET');
if ($token !== null) {
$request->cookies->set(AuthMiddleware::COOKIE_NAME, $token);
}
return $request;
}
/**
* @param Request|null $capturedRequest
* @return Closure(Request): JsonResponse
*/
private function captureNextRequest(
?Request &$capturedRequest,
): Closure {
return function (Request $request) use (&$capturedRequest) {
$capturedRequest = $request;
return new JsonResponse(['ok' => true]);
};
}
private function user(): User
{
return new User(
id: 7,
email: new EmailAddress('user@example.com'),
);
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace Tests\Unit\Auth;
use App\Auth\Session;
use App\Shared\ValueObject\EmailAddress;
use App\User\User;
use DateTimeImmutable;
use DateTimeZone;
use PHPUnit\Framework\TestCase;
class SessionTest extends TestCase
{
public function test_it_exposes_its_values(): void
{
$user = new User(
id: 7,
email: new EmailAddress('user@example.com'),
);
$createdAt = $this->utc('2026-07-31T12:00:00');
$expiresAt = $this->utc('2026-08-07T12:00:00');
$session = new Session(
token: 'session-token',
user: $user,
createdAt: $createdAt,
expiresAt: $expiresAt,
);
$this->assertSame('session-token', $session->getToken());
$this->assertSame($user, $session->getUser());
$this->assertSame($createdAt, $session->getCreatedAt());
$this->assertSame($expiresAt, $session->getExpiresAt());
}
public function test_it_expires_at_the_expiry_time(): void
{
$expiresAt = $this->utc('2026-08-07T12:00:00');
$session = new Session(
token: 'session-token',
user: new User(
id: 7,
email: new EmailAddress('user@example.com'),
),
createdAt: $this->utc('2026-07-31T12:00:00'),
expiresAt: $expiresAt,
);
$this->assertFalse(
$session->isExpired($expiresAt->modify('-1 second')),
);
$this->assertTrue($session->isExpired($expiresAt));
$this->assertTrue(
$session->isExpired($expiresAt->modify('+1 second')),
);
}
private function utc(string $time): DateTimeImmutable
{
return new DateTimeImmutable($time, new DateTimeZone('UTC'));
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace Tests\Unit\Shared\ValueObject;
use App\Shared\ValueObject\EmailAddress;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
class EmailAddressTest extends TestCase
{
public function test_it_rejects_an_invalid_email_address(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage(
'Invalid email address: invalid-email',
);
new EmailAddress('invalid-email');
}
public function test_it_trims_and_normalizes_the_domain(): void
{
$email = new EmailAddress(' Founder@EXAMPLE.COM ');
$this->assertSame('Founder@example.com', $email->value());
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace Tests\Unit\User;
use App\Shared\ValueObject\EmailAddress;
use App\User\User;
use PHPUnit\Framework\TestCase;
class UserTest extends TestCase
{
public function test_it_exposes_its_identity_and_email(): void
{
$email = new EmailAddress('user@example.com');
$user = new User(id: 42, email: $email);
$this->assertSame(42, $user->getId());
$this->assertSame($email, $user->getEmail());
}
}

View file

@ -43,6 +43,7 @@
mailpit mailpit
process-compose process-compose
mkcert mkcert
nssTools
caddy caddy
]; ];

View file

@ -0,0 +1,12 @@
import { defineConfig } from 'cypress'
const frontendPort = process.env.VITE_PORT ?? '5173'
export default defineConfig({
allowCypressEnv: false,
e2e: {
baseUrl: `http://127.0.0.1:${frontendPort}`,
supportFile: false,
},
video: false,
})

View file

@ -0,0 +1,78 @@
describe('guest authentication pages', () => {
it('shows a public home route to guests', () => {
cy.visit('/')
cy.location('pathname').should('equal', '/')
cy.get('h1').should('have.text', 'Make big goals feel possible.')
cy.contains('a', 'Log in').should('have.attr', 'href', '/login')
cy.contains('a', 'Get started').should('have.attr', 'href', '/signup')
})
it('redirects guests away from the protected dashboard', () => {
cy.visit('/dashboard')
cy.location('pathname').should('equal', '/login')
cy.location('search').should('include', 'redirect=/dashboard')
})
it('shows the login form and links to signup', () => {
cy.visit('/login')
cy.get('h1').should('have.text', 'Welcome back')
cy.get('label[for="login-email"]').should('have.text', 'Email address')
cy.get('#login-email').should('have.attr', 'autocomplete', 'email')
cy.get('label[for="login-password"]').should('have.text', 'Password')
cy.get('#login-password').should('have.attr', 'autocomplete', 'current-password')
cy.get('button[type="submit"]').should('have.text', 'Log in')
cy.contains('a', 'Create an account').click()
cy.location('pathname').should('equal', '/signup')
})
it('shows the signup form and links to login', () => {
cy.visit('/signup')
cy.get('h1').should('have.text', 'Start your journey')
cy.get('label[for="signup-name"]').should('have.text', 'Full name')
cy.get('#signup-name').should('have.attr', 'autocomplete', 'name')
cy.get('label[for="signup-email"]').should('have.text', 'Email address')
cy.get('#signup-email').should('have.attr', 'autocomplete', 'email')
cy.get('label[for="signup-password"]').should('have.text', 'Password')
cy.get('#signup-password').should('have.attr', 'autocomplete', 'new-password')
cy.get('label[for="signup-password-confirmation"]').should(
'have.text',
'Confirm password',
)
cy.get('#signup-password-confirmation').should(
'have.attr',
'autocomplete',
'new-password',
)
cy.get('button[type="submit"]').should('have.text', 'Create account')
cy.contains('a', 'Log in').click()
cy.location('pathname').should('equal', '/login')
})
it('keeps UI-only form submissions on their current route', () => {
cy.visit('/login')
cy.get('form').submit()
cy.location('pathname').should('equal', '/login')
cy.visit('/signup')
cy.get('form').submit()
cy.location('pathname').should('equal', '/signup')
})
it('fits the signup page within a mobile viewport', () => {
cy.viewport(390, 844)
cy.visit('/signup')
cy.get('main').should('be.visible')
cy.document().then((document) => {
expect(document.documentElement.scrollWidth).to.be.at.most(
document.documentElement.clientWidth,
)
})
})
})

View file

@ -9,6 +9,7 @@
"preview": "vite preview", "preview": "vite preview",
"build-only": "vite build", "build-only": "vite build",
"type-check": "vue-tsc --build", "type-check": "vue-tsc --build",
"test:e2e": "cypress run",
"lint": "run-s \"lint:*\"", "lint": "run-s \"lint:*\"",
"lint:oxlint": "oxlint . --fix", "lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache", "lint:eslint": "eslint . --fix --cache",

View file

@ -1,11 +1,3 @@
<script setup lang="ts"></script>
<template> <template>
<h1>You did it!</h1> <RouterView />
<p>
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
documentation
</p>
</template> </template>
<style scoped></style>

View file

@ -0,0 +1,68 @@
<script setup lang="ts">
defineProps<{
submitLabel: string
}>()
</script>
<template>
<form class="auth-form" @submit.prevent>
<div class="auth-form__fields">
<slot></slot>
</div>
<button type="submit">{{ submitLabel }}</button>
</form>
</template>
<style scoped>
.auth-form {
display: grid;
gap: 1.75rem;
}
.auth-form__fields {
display: grid;
gap: 1.1rem;
}
button {
width: 100%;
min-height: 3.35rem;
padding: 0.9rem 1.25rem;
border: 1px solid #183a31;
border-radius: 0.85rem;
color: #fffdf6;
background: #183a31;
box-shadow: 0 0.6rem 1.2rem rgb(24 58 49 / 16%);
font-size: 0.9rem;
font-weight: 750;
cursor: pointer;
transition:
background-color 160ms ease,
border-color 160ms ease,
transform 160ms ease,
box-shadow 160ms ease;
}
button:hover {
border-color: #285c4e;
background: #285c4e;
box-shadow: 0 0.8rem 1.4rem rgb(24 58 49 / 20%);
transform: translateY(-1px);
}
button:active {
transform: translateY(0);
}
button:focus-visible {
outline: 3px solid rgb(90 136 120 / 38%);
outline-offset: 0.2rem;
}
@media (prefers-reduced-motion: reduce) {
button {
transition: none;
}
}
</style>

View file

@ -0,0 +1,426 @@
<script setup lang="ts">
import BrandWordmark from '@/components/BrandWordmark.vue'
defineProps<{
eyebrow: string
title: string
description: string
}>()
</script>
<template>
<main class="auth-page">
<section class="story-panel" aria-label="About Attainly">
<div class="story-panel__glow story-panel__glow--top" aria-hidden="true"></div>
<div class="story-panel__glow story-panel__glow--bottom" aria-hidden="true"></div>
<div class="story-panel__content">
<BrandWordmark class="story-panel__wordmark" theme="light" />
<div class="story-copy">
<p class="story-copy__eyebrow">Progress with purpose</p>
<h2>Big goals,<br />made doable.</h2>
<p>Turn what matters into clear, daily progress, one focused step at a time.</p>
</div>
<div class="progress-card" aria-hidden="true">
<div class="progress-card__header">
<span>Today&apos;s focus</span>
<span class="progress-card__date">Day 12</span>
</div>
<div class="progress-card__track">
<span class="progress-card__line"></span>
<div class="progress-step progress-step--complete">
<span class="progress-step__marker"></span>
<span>
<small>Step 01</small>
Plan the path
</span>
</div>
<div class="progress-step progress-step--current">
<span class="progress-step__marker"></span>
<span>
<small>Step 02</small>
Take the next step
</span>
</div>
<div class="progress-step">
<span class="progress-step__marker"></span>
<span>
<small>Step 03</small>
See how far you&apos;ve come
</span>
</div>
</div>
</div>
</div>
</section>
<section class="form-panel">
<div class="auth-card">
<header class="auth-card__header">
<p class="auth-card__eyebrow">{{ eyebrow }}</p>
<h1>{{ title }}</h1>
<p>{{ description }}</p>
</header>
<slot></slot>
<footer class="auth-card__footer">
<slot name="footer"></slot>
</footer>
</div>
<p class="form-panel__note">A calmer way to keep moving forward.</p>
</section>
</main>
</template>
<style scoped>
.auth-page {
display: grid;
grid-template-columns: minmax(25rem, 0.92fr) minmax(32rem, 1.08fr);
min-height: 100vh;
min-height: 100svh;
}
.story-panel {
position: relative;
display: flex;
min-height: 100%;
overflow: hidden;
color: #f8f5eb;
background: #183a31;
}
.story-panel::after {
position: absolute;
right: -5rem;
bottom: -7rem;
width: 20rem;
height: 20rem;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 50%;
content: '';
}
.story-panel__glow {
position: absolute;
border-radius: 50%;
filter: blur(1px);
pointer-events: none;
}
.story-panel__glow--top {
top: -10rem;
right: -8rem;
width: 25rem;
height: 25rem;
background: rgb(205 222 121 / 10%);
}
.story-panel__glow--bottom {
bottom: -14rem;
left: -12rem;
width: 30rem;
height: 30rem;
background: rgb(211 142 101 / 13%);
}
.story-panel__content {
position: relative;
z-index: 1;
display: flex;
flex: 1;
flex-direction: column;
width: min(100%, 40rem);
min-height: 100%;
margin: 0 auto;
padding: clamp(2.25rem, 5vw, 4.5rem);
}
.story-panel__wordmark {
align-self: flex-start;
}
.story-copy {
margin: auto 0 clamp(3rem, 8vh, 6.5rem);
}
.story-copy__eyebrow,
.auth-card__eyebrow {
margin: 0 0 1rem;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.story-copy__eyebrow {
color: #d7e788;
}
.story-copy h2 {
max-width: 9ch;
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(3.25rem, 6.3vw, 5.75rem);
font-weight: 500;
line-height: 0.92;
letter-spacing: -0.055em;
}
.story-copy > p:last-child {
max-width: 29rem;
margin: 1.75rem 0 0;
color: rgb(248 245 235 / 72%);
font-size: 1.02rem;
line-height: 1.75;
}
.progress-card {
width: min(100%, 27rem);
padding: 1.4rem;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 1.4rem;
background: rgb(255 255 255 / 7%);
box-shadow: 0 1.5rem 4rem rgb(3 19 14 / 20%);
backdrop-filter: blur(0.75rem);
}
.progress-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
font-size: 0.8rem;
font-weight: 750;
}
.progress-card__date {
padding: 0.35rem 0.6rem;
border-radius: 2rem;
color: #d7e788;
background: rgb(215 231 136 / 12%);
font-size: 0.66rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.progress-card__track {
position: relative;
display: grid;
gap: 1rem;
}
.progress-card__line {
position: absolute;
top: 1rem;
bottom: 1rem;
left: 0.82rem;
width: 1px;
background: rgb(255 255 255 / 13%);
}
.progress-step {
position: relative;
display: flex;
align-items: center;
gap: 0.85rem;
color: rgb(248 245 235 / 45%);
font-size: 0.78rem;
font-weight: 650;
}
.progress-step small {
display: block;
margin-bottom: 0.15rem;
color: rgb(248 245 235 / 28%);
font-size: 0.58rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.progress-step__marker {
z-index: 1;
display: grid;
flex: 0 0 auto;
width: 1.65rem;
height: 1.65rem;
place-items: center;
border: 1px solid rgb(255 255 255 / 18%);
border-radius: 50%;
background: #24483e;
font-size: 0.7rem;
}
.progress-step--complete {
color: rgb(248 245 235 / 65%);
}
.progress-step--complete .progress-step__marker {
border-color: #d7e788;
color: #183a31;
background: #d7e788;
}
.progress-step--current {
color: #fffdf6;
}
.progress-step--current .progress-step__marker {
border: 0.35rem solid #fffdf6;
outline: 2px solid #d7e788;
outline-offset: 0.15rem;
background: #d7e788;
}
.form-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: clamp(2rem, 6vw, 5.5rem);
background:
radial-gradient(circle at 90% 10%, rgb(215 231 136 / 20%), transparent 22rem), #f3f1eb;
}
.auth-card {
width: min(100%, 31rem);
padding: clamp(2rem, 4.5vw, 3.75rem);
border: 1px solid rgb(27 41 36 / 8%);
border-radius: 1.75rem;
background: rgb(255 254 250 / 92%);
box-shadow: 0 1.5rem 4rem rgb(40 54 47 / 10%);
}
.auth-card__header {
margin-bottom: 2rem;
}
.auth-card__eyebrow {
color: #9b5f40;
}
.auth-card__header h1 {
margin: 0;
color: #183029;
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(2.35rem, 4vw, 3.15rem);
font-weight: 500;
line-height: 1.05;
letter-spacing: -0.045em;
}
.auth-card__header > p:last-child {
margin: 1rem 0 0;
color: #67736e;
font-size: 0.95rem;
line-height: 1.65;
}
.auth-card__footer {
margin-top: 1.75rem;
color: #68736f;
font-size: 0.88rem;
text-align: center;
}
.auth-card__footer :deep(a) {
color: #285c4e;
font-weight: 750;
text-underline-offset: 0.2rem;
}
.auth-card__footer :deep(a:hover) {
color: #173a30;
}
.auth-card__footer :deep(a:focus-visible) {
border-radius: 0.2rem;
outline: 3px solid rgb(90 136 120 / 35%);
outline-offset: 0.2rem;
}
.form-panel__note {
margin: 1.5rem 0 0;
color: #89918e;
font-size: 0.75rem;
letter-spacing: 0.02em;
}
@media (max-width: 56rem) {
.auth-page {
grid-template-columns: 1fr;
}
.story-panel {
min-height: auto;
}
.story-panel__content {
min-height: auto;
padding: 2rem clamp(1.5rem, 6vw, 4rem) 2.5rem;
}
.story-copy {
margin: 3.75rem 0 0;
}
.story-copy h2 {
max-width: none;
font-size: clamp(2.8rem, 10vw, 4.5rem);
}
.story-copy h2 br {
display: none;
}
.story-copy > p:last-child {
max-width: 36rem;
margin-top: 1.15rem;
}
.progress-card {
display: none;
}
.form-panel {
padding: clamp(2rem, 8vw, 4rem) clamp(1.25rem, 6vw, 4rem);
}
}
@media (max-width: 35rem) {
.story-panel__content {
padding: 1.5rem 1.25rem 2rem;
}
.story-copy {
margin-top: 2.75rem;
}
.story-copy > p:last-child {
font-size: 0.92rem;
line-height: 1.6;
}
.form-panel {
justify-content: flex-start;
padding: 0;
background: #fffefa;
}
.auth-card {
width: 100%;
padding: 2.5rem 1.25rem;
border: 0;
border-radius: 0;
box-shadow: none;
}
.form-panel__note {
display: none;
}
}
</style>

View file

@ -0,0 +1,70 @@
<script setup lang="ts">
defineProps<{
id: string
label: string
type: 'email' | 'password' | 'text'
autocomplete: string
placeholder: string
}>()
</script>
<template>
<div class="text-field">
<label :for="id">{{ label }}</label>
<input
:id="id"
:type="type"
:name="id"
:autocomplete="autocomplete"
:placeholder="placeholder"
/>
</div>
</template>
<style scoped>
.text-field {
display: grid;
gap: 0.55rem;
}
label {
color: #293b35;
font-size: 0.78rem;
font-weight: 750;
}
input {
width: 100%;
min-height: 3.2rem;
padding: 0.85rem 1rem;
border: 1px solid #d8dcd7;
border-radius: 0.8rem;
color: #1b2924;
background: #fff;
box-shadow: 0 1px 2px rgb(27 41 36 / 3%);
font-size: 0.92rem;
outline: none;
transition:
border-color 160ms ease,
box-shadow 160ms ease;
}
input::placeholder {
color: #a3aaa6;
}
input:hover {
border-color: #b4bdb8;
}
input:focus {
border-color: #4d7d6d;
box-shadow: 0 0 0 3px rgb(77 125 109 / 16%);
}
@media (prefers-reduced-motion: reduce) {
input {
transition: none;
}
}
</style>

View file

@ -0,0 +1,71 @@
<script setup lang="ts">
defineProps<{
theme: 'dark' | 'light'
}>()
</script>
<template>
<RouterLink class="wordmark" :class="`wordmark--${theme}`" to="/" aria-label="Attainly home">
<span class="wordmark__mark" aria-hidden="true">
<span></span>
</span>
<span>Attainly</span>
</RouterLink>
</template>
<style scoped>
.wordmark {
display: inline-flex;
align-items: center;
gap: 0.75rem;
font-size: 1.25rem;
font-weight: 750;
letter-spacing: -0.02em;
text-decoration: none;
}
.wordmark--light {
color: #fffdf6;
}
.wordmark--dark {
color: #183029;
}
.wordmark:focus-visible {
border-radius: 0.35rem;
outline: 3px solid #d4e58a;
outline-offset: 0.4rem;
}
.wordmark__mark {
position: relative;
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
border: 1px solid currentcolor;
border-radius: 50%;
background: rgb(255 255 255 / 9%);
opacity: 0.95;
}
.wordmark__mark::before,
.wordmark__mark span {
position: absolute;
width: 0.65rem;
height: 0.65rem;
border-radius: 50%;
content: '';
}
.wordmark__mark::before {
background: #c9da73;
transform: translate(-0.22rem, 0.2rem);
}
.wordmark__mark span {
border: 2px solid currentcolor;
transform: translate(0.25rem, -0.25rem);
}
</style>

View file

@ -3,6 +3,7 @@ import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import './styles/main.css'
const app = createApp(App) const app = createApp(App)

View file

@ -1,8 +1,59 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [], routes: [
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue'),
},
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: {
guestOnly: true,
},
},
{
path: '/signup',
name: 'signup',
component: () => import('@/views/SignupView.vue'),
meta: {
guestOnly: true,
},
},
{
path: '/dashboard',
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
meta: {
requiresAuth: true,
},
},
],
})
router.beforeEach((to) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
return {
name: 'login',
query: {
redirect: to.fullPath,
},
}
}
if (to.meta.guestOnly && authStore.isAuthenticated) {
return {
name: 'dashboard',
}
}
}) })
export default router export default router

View file

@ -0,0 +1,10 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('auth', () => {
const isAuthenticated = ref(false)
return {
isAuthenticated,
}
})

View file

@ -0,0 +1,50 @@
:root {
color: #1b2924;
background: #f3f1eb;
font-family:
Inter,
ui-sans-serif,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
min-width: 320px;
min-height: 100%;
background: #f3f1eb;
}
body {
min-width: 320px;
min-height: 100%;
margin: 0;
}
button,
input {
font: inherit;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
a {
color: inherit;
}
#app {
min-height: 100vh;
min-height: 100svh;
}

View file

@ -0,0 +1,67 @@
<script setup lang="ts">
import BrandWordmark from '@/components/BrandWordmark.vue'
</script>
<template>
<main class="dashboard-page">
<header>
<BrandWordmark theme="dark" />
</header>
<section>
<p>Dashboard</p>
<h1>Your next step starts here.</h1>
<span>
Your goals and today&apos;s assignments will appear here once account authentication is
connected.
</span>
</section>
</main>
</template>
<style scoped>
.dashboard-page {
min-height: 100vh;
min-height: 100svh;
padding: 2rem clamp(1.5rem, 6vw, 5rem);
color: #183029;
background: #f4f1e7;
}
header {
width: min(100%, 76rem);
margin: 0 auto;
}
section {
width: min(100%, 42rem);
margin: clamp(6rem, 18vh, 12rem) auto 0;
text-align: center;
}
section p {
margin: 0 0 1rem;
color: #926044;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(3rem, 7vw, 5rem);
font-weight: 500;
line-height: 1;
letter-spacing: -0.055em;
}
section span {
display: block;
max-width: 34rem;
margin: 1.5rem auto 0;
color: #68776f;
line-height: 1.7;
}
</style>

View file

@ -0,0 +1,599 @@
<script setup lang="ts">
import BrandWordmark from '@/components/BrandWordmark.vue'
</script>
<template>
<main class="home-page">
<div class="home-page__glow home-page__glow--left" aria-hidden="true"></div>
<div class="home-page__glow home-page__glow--right" aria-hidden="true"></div>
<header class="site-header">
<BrandWordmark theme="dark" />
<nav aria-label="Account navigation">
<RouterLink class="site-header__login" to="/login">Log in</RouterLink>
<RouterLink class="button button--small" to="/signup">Get started</RouterLink>
</nav>
</header>
<section class="hero">
<div class="hero__copy">
<p class="hero__eyebrow">
<span aria-hidden="true"></span>
Progress with purpose
</p>
<h1>Make big goals feel possible.</h1>
<p class="hero__description">
Attainly turns meaningful ambitions into clear, manageable steps so you always know what
to do next.
</p>
<div class="hero__actions">
<RouterLink class="button" to="/signup">
Get started
<span aria-hidden="true"></span>
</RouterLink>
<RouterLink class="hero__login" to="/login"> I already have an account </RouterLink>
</div>
<ul class="hero__benefits" aria-label="Attainly benefits">
<li><span aria-hidden="true"></span> Clear daily steps</li>
<li><span aria-hidden="true"></span> Flexible scheduling</li>
<li><span aria-hidden="true"></span> Visible progress</li>
</ul>
</div>
<div class="goal-preview" aria-label="Example goal progress">
<div class="goal-preview__accent" aria-hidden="true"></div>
<div class="goal-preview__top">
<div>
<p>Current goal</p>
<h2>Read 24 books this year</h2>
</div>
<span>68%</span>
</div>
<div
class="goal-preview__progress"
role="progressbar"
aria-label="Example goal progress"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="68"
>
<span></span>
</div>
<div class="goal-preview__summary">
<span>16 books completed</span>
<span>8 remaining</span>
</div>
<div class="today-card">
<div class="today-card__header">
<div>
<p>Today&apos;s steps</p>
<span>Friday, July 31</span>
</div>
<span class="today-card__count">2 of 3</span>
</div>
<ul>
<li class="is-complete">
<span class="task-marker" aria-hidden="true"></span>
Read 20 pages
</li>
<li class="is-complete">
<span class="task-marker" aria-hidden="true"></span>
Add reading notes
</li>
<li>
<span class="task-marker" aria-hidden="true"></span>
Choose the next chapter
</li>
</ul>
</div>
<div class="streak-card" aria-hidden="true">
<span></span>
<div>
<strong>12 day streak</strong>
<small>Keep the momentum going</small>
</div>
</div>
</div>
</section>
</main>
</template>
<style scoped>
.home-page {
position: relative;
min-height: 100vh;
min-height: 100svh;
overflow: hidden;
color: #183029;
background: linear-gradient(rgb(252 249 241 / 95%), rgb(242 239 229 / 94%)), #f4f1e7;
}
.home-page::before {
position: absolute;
inset: 0;
background-image: radial-gradient(rgb(24 58 49 / 8%) 0.7px, transparent 0.7px);
background-size: 24px 24px;
content: '';
mask-image: linear-gradient(to bottom, black, transparent 70%);
pointer-events: none;
}
.home-page__glow {
position: absolute;
border-radius: 50%;
filter: blur(1px);
pointer-events: none;
}
.home-page__glow--left {
bottom: -14rem;
left: -10rem;
width: 34rem;
height: 34rem;
background: rgb(205 218 115 / 16%);
}
.home-page__glow--right {
top: 8rem;
right: -12rem;
width: 32rem;
height: 32rem;
background: rgb(197 119 78 / 10%);
}
.site-header,
.hero {
position: relative;
z-index: 1;
width: min(calc(100% - 3rem), 76rem);
margin: 0 auto;
}
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 6.5rem;
}
.site-header nav {
display: flex;
align-items: center;
gap: 1.5rem;
}
.site-header__login,
.hero__login {
color: #365047;
font-size: 0.86rem;
font-weight: 700;
text-underline-offset: 0.25rem;
}
.site-header__login {
text-decoration: none;
}
.site-header__login:hover,
.hero__login:hover {
color: #183a31;
}
.site-header__login:focus-visible,
.hero__login:focus-visible,
.button:focus-visible {
border-radius: 0.3rem;
outline: 3px solid rgb(86 127 112 / 34%);
outline-offset: 0.25rem;
}
.button {
display: inline-flex;
min-height: 3.35rem;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 0.9rem 1.35rem;
border-radius: 0.85rem;
color: #fffdf7;
background: #183a31;
box-shadow: 0 0.8rem 1.6rem rgb(24 58 49 / 17%);
font-size: 0.88rem;
font-weight: 750;
text-decoration: none;
transition:
background-color 160ms ease,
transform 160ms ease,
box-shadow 160ms ease;
}
.button:hover {
background: #285c4e;
box-shadow: 0 1rem 1.8rem rgb(24 58 49 / 22%);
transform: translateY(-1px);
}
.button--small {
min-height: 2.75rem;
padding: 0.7rem 1.05rem;
border-radius: 0.7rem;
box-shadow: none;
font-size: 0.8rem;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(30rem, 1.1fr);
align-items: center;
gap: clamp(4rem, 8vw, 8rem);
min-height: calc(100vh - 6.5rem);
min-height: calc(100svh - 6.5rem);
padding: 3rem 0 6rem;
}
.hero__copy {
max-width: 38rem;
}
.hero__eyebrow {
display: flex;
align-items: center;
gap: 0.7rem;
margin: 0 0 1.4rem;
color: #926044;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.hero__eyebrow span {
width: 1.8rem;
height: 1px;
background: #bd7b55;
}
h1 {
max-width: 10ch;
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: clamp(4rem, 6.4vw, 6.5rem);
font-weight: 500;
line-height: 0.92;
letter-spacing: -0.065em;
}
.hero__description {
max-width: 34rem;
margin: 1.8rem 0 0;
color: #62716b;
font-size: 1.06rem;
line-height: 1.75;
}
.hero__actions {
display: flex;
align-items: center;
gap: 1.5rem;
margin-top: 2rem;
}
.hero__benefits {
display: flex;
flex-wrap: wrap;
gap: 0.85rem 1.4rem;
margin: 2.5rem 0 0;
padding: 0;
color: #68776f;
font-size: 0.74rem;
font-weight: 650;
list-style: none;
}
.hero__benefits li {
display: flex;
align-items: center;
gap: 0.4rem;
}
.hero__benefits span {
display: grid;
width: 1.1rem;
height: 1.1rem;
place-items: center;
border-radius: 50%;
color: #285c4e;
background: #dfe8c0;
font-size: 0.62rem;
}
.goal-preview {
position: relative;
width: min(100%, 34rem);
margin-left: auto;
padding: clamp(1.5rem, 4vw, 2.4rem);
border: 1px solid rgb(24 58 49 / 10%);
border-radius: 1.8rem;
background: rgb(255 254 250 / 92%);
box-shadow: 0 2.5rem 6rem rgb(49 61 53 / 15%);
}
.goal-preview__accent {
position: absolute;
top: -1.8rem;
right: -1.8rem;
width: 6rem;
height: 6rem;
border: 1rem solid rgb(199 216 113 / 28%);
border-radius: 50%;
}
.goal-preview__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.goal-preview__top p,
.today-card__header p {
margin: 0 0 0.45rem;
color: #8b9691;
font-size: 0.65rem;
font-weight: 800;
letter-spacing: 0.11em;
text-transform: uppercase;
}
.goal-preview__top h2 {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.35rem;
font-weight: 500;
letter-spacing: -0.025em;
}
.goal-preview__top > span {
color: #285c4e;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.8rem;
}
.goal-preview__progress {
height: 0.45rem;
margin-top: 1.5rem;
overflow: hidden;
border-radius: 1rem;
background: #e8e9e2;
}
.goal-preview__progress span {
display: block;
width: 68%;
height: 100%;
border-radius: inherit;
background: #afc55d;
}
.goal-preview__summary {
display: flex;
justify-content: space-between;
margin-top: 0.65rem;
color: #8a938f;
font-size: 0.67rem;
}
.today-card {
margin-top: 2rem;
padding: 1.25rem;
border: 1px solid #e6e8e1;
border-radius: 1.1rem;
background: #fbfaf6;
}
.today-card__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.today-card__header > div > span {
color: #51645c;
font-size: 0.83rem;
font-weight: 700;
}
.today-card__count {
padding: 0.4rem 0.65rem;
border-radius: 2rem;
color: #4c685e;
background: #e7ebda;
font-size: 0.65rem;
font-weight: 750;
}
.today-card ul {
display: grid;
gap: 0.75rem;
margin: 1.2rem 0 0;
padding: 0;
color: #344a42;
font-size: 0.78rem;
font-weight: 650;
list-style: none;
}
.today-card li {
display: flex;
align-items: center;
gap: 0.7rem;
}
.today-card .is-complete {
color: #98a09d;
text-decoration: line-through;
}
.task-marker {
display: grid;
width: 1.35rem;
height: 1.35rem;
flex: 0 0 auto;
place-items: center;
border: 1px solid #cfd5d1;
border-radius: 50%;
color: #fff;
font-size: 0.62rem;
}
.is-complete .task-marker {
border-color: #819d66;
background: #819d66;
}
.streak-card {
position: absolute;
right: -2rem;
bottom: 3.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.85rem 1rem;
border: 1px solid rgb(24 58 49 / 9%);
border-radius: 0.9rem;
background: #fffdf7;
box-shadow: 0 1rem 2rem rgb(49 61 53 / 15%);
}
.streak-card > span {
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
border-radius: 0.65rem;
color: #8e5d40;
background: #f0dfd0;
}
.streak-card strong,
.streak-card small {
display: block;
}
.streak-card strong {
font-family: Georgia, 'Times New Roman', serif;
font-size: 0.82rem;
font-weight: 600;
}
.streak-card small {
margin-top: 0.12rem;
color: #8c9591;
font-size: 0.57rem;
}
@media (max-width: 68rem) {
.hero {
grid-template-columns: 1fr 1fr;
gap: 3rem;
}
.streak-card {
right: -1rem;
}
}
@media (max-width: 53rem) {
.hero {
grid-template-columns: 1fr;
min-height: auto;
padding-top: 4rem;
}
.hero__copy {
text-align: center;
}
.hero__eyebrow,
.hero__actions,
.hero__benefits {
justify-content: center;
}
h1,
.hero__description {
margin-right: auto;
margin-left: auto;
}
.goal-preview {
margin: 0 auto;
}
}
@media (max-width: 35rem) {
.site-header,
.hero {
width: min(calc(100% - 2rem), 76rem);
}
.site-header {
min-height: 5.5rem;
}
.site-header nav {
gap: 0.9rem;
}
.site-header__login {
display: none;
}
.hero {
gap: 3.5rem;
padding: 2.5rem 0 4rem;
}
h1 {
font-size: clamp(3.3rem, 16vw, 4.5rem);
}
.hero__description {
font-size: 0.95rem;
}
.hero__actions {
flex-direction: column;
gap: 1.15rem;
}
.hero__actions .button {
width: 100%;
}
.goal-preview__accent,
.streak-card {
display: none;
}
.goal-preview {
padding: 1.25rem;
border-radius: 1.35rem;
}
}
@media (prefers-reduced-motion: reduce) {
.button {
transition: none;
}
}
</style>

View file

@ -0,0 +1,35 @@
<script setup lang="ts">
import AuthForm from '@/components/AuthForm.vue'
import AuthLayout from '@/components/AuthLayout.vue'
import AuthTextField from '@/components/AuthTextField.vue'
</script>
<template>
<AuthLayout
eyebrow="Welcome back"
title="Welcome back"
description="Pick up where you left off and keep your momentum going."
>
<AuthForm submit-label="Log in">
<AuthTextField
id="login-email"
label="Email address"
type="email"
autocomplete="email"
placeholder="you@example.com"
/>
<AuthTextField
id="login-password"
label="Password"
type="password"
autocomplete="current-password"
placeholder="Enter your password"
/>
</AuthForm>
<template #footer>
New to Attainly?
<RouterLink to="/signup">Create an account</RouterLink>
</template>
</AuthLayout>
</template>

View file

@ -0,0 +1,49 @@
<script setup lang="ts">
import AuthForm from '@/components/AuthForm.vue'
import AuthLayout from '@/components/AuthLayout.vue'
import AuthTextField from '@/components/AuthTextField.vue'
</script>
<template>
<AuthLayout
eyebrow="Build your momentum"
title="Start your journey"
description="Create your space to turn ambitious goals into steady progress."
>
<AuthForm submit-label="Create account">
<AuthTextField
id="signup-name"
label="Full name"
type="text"
autocomplete="name"
placeholder="Your full name"
/>
<AuthTextField
id="signup-email"
label="Email address"
type="email"
autocomplete="email"
placeholder="you@example.com"
/>
<AuthTextField
id="signup-password"
label="Password"
type="password"
autocomplete="new-password"
placeholder="Create a password"
/>
<AuthTextField
id="signup-password-confirmation"
label="Confirm password"
type="password"
autocomplete="new-password"
placeholder="Repeat your password"
/>
</AuthForm>
<template #footer>
Already have an account?
<RouterLink to="/login">Log in</RouterLink>
</template>
</AuthLayout>
</template>