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:
```sh
direnv exec "$(git rev-parse --show-toplevel)" \
php artisan migrate:fresh --seed
```
@ -101,7 +102,12 @@ pattern.
## Before completing backend work
- 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.
- Fix failures caused by the change. Report unrelated baseline failures
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:
```sh
npm install
direnv exec "$(git rev-parse --show-toplevel)" npm install
```
Start the development server on the port assigned by the shell hook:
```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.
@ -47,10 +48,10 @@ npm run dev -- --port "$VITE_PORT"
The available validation commands are:
```sh
npm run format
npm run lint
npm run type-check
npm run build
direnv exec "$(git rev-parse --show-toplevel)" npm run format
direnv exec "$(git rev-parse --show-toplevel)" npm run lint
direnv exec "$(git rev-parse --show-toplevel)" npm run type-check
direnv exec "$(git rev-parse --show-toplevel)" npm run build
```
`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.
- A worktree owns its own isolated stack. The flake shell hook assigns a
deterministic port offset and creates worktree-local PostgreSQL state.
- Start a worktree stack from its root with `process-compose up`. For
non-interactive use, run `process-compose up -D` and stop it with
`process-compose down`.
- Start a worktree stack from its root. For non-interactive use, start it
detached and stop it when finished, as shown below.
- Do not use `process-compose -t=false` for a detached stack. It can leave an
orphaned PostgreSQL process holding the data directory.
- Non-interactive agent shells do not automatically load direnv. A bare
`process-compose`, `php artisan`, or database command from a worktree can
silently use default ports and target the main checkout.
- Prefix worktree stack and database commands with `direnv exec <worktree>`.
Examples:
- Non-interactive agent shells do not automatically load direnv. Bare project
commands can use missing tools, default ports, or paths from the main
checkout.
- Run project tooling that depends on the repository development environment
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
direnv exec <worktree> process-compose up -D
direnv exec <worktree> process-compose down
direnv exec <worktree> php artisan migrate:fresh --seed
direnv exec "$(git rev-parse --show-toplevel)" <command>
```
- 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
command.
command. The direnv target remains the worktree root.
- Run frontend commands from `frontend/website/`.
- `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,
surface the environmental failure. Do not skip the check or silently switch
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
worktree-local.
- The shell hook installs backend dependencies but does not install frontend
dependencies. Run `npm install` from `frontend/website/` when provisioning a
fresh worktree.
dependencies. From `frontend/website/`, provision them with:
```sh
direnv exec "$(git rev-parse --show-toplevel)" npm install
```
Do not push anything. Make commits as the TDD workflow requires.
@ -155,12 +173,17 @@ gate affected by the change.
### 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`:
```sh
composer types:check
composer test
direnv exec "$(git rev-parse --show-toplevel)" composer types:check
direnv exec "$(git rev-parse --show-toplevel)" composer test
```
- 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/`:
```sh
npm run format
npm run lint
npm run type-check
npm run build
direnv exec "$(git rev-parse --show-toplevel)" npm run format
direnv exec "$(git rev-parse --show-toplevel)" npm run lint
direnv exec "$(git rev-parse --show-toplevel)" npm run type-check
direnv exec "$(git rev-parse --show-toplevel)" npm run build
```
- The formatter and linters rewrite files. Review their changes before
@ -184,8 +207,20 @@ gate affected by the change.
### Environment and integration
- For Nix or shell-hook changes, run `nix fmt` and `nix flake check`.
- For service configuration changes, run `process-compose --dry-run`.
- For Nix or shell-hook changes, 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
the relevant endpoint or service against that worktree.
- 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;
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 Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
@ -15,7 +21,15 @@ class AppServiceProvider extends ServiceProvider
*/
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
process-compose
mkcert
nssTools
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",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"test:e2e": "cypress run",
"lint": "run-s \"lint:*\"",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache",

View file

@ -1,11 +1,3 @@
<script setup lang="ts"></script>
<template>
<h1>You did it!</h1>
<p>
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
documentation
</p>
<RouterView />
</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 router from './router'
import './styles/main.css'
const app = createApp(App)

View file

@ -1,8 +1,59 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
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

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>