Compare commits
No commits in common. "6da83d87fa3fd10a3f568edd0b64fe3c3a092484" and "ce1b920fb68838484ff450244b74e5f3432c5f74" have entirely different histories.
6da83d87fa
...
ce1b920fb6
48 changed files with 200 additions and 2704 deletions
|
|
@ -92,7 +92,6 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -102,12 +101,7 @@ pattern.
|
||||||
## Before completing backend work
|
## Before completing backend work
|
||||||
|
|
||||||
- Run the focused test during development.
|
- Run the focused test during development.
|
||||||
- Run the full test suite before completion:
|
- Run `php artisan test` 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.
|
||||||
|
|
|
||||||
|
|
@ -33,13 +33,12 @@ 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
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" npm install
|
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
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" \
|
|
||||||
npm run dev -- --port "$VITE_PORT"
|
npm run dev -- --port "$VITE_PORT"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -48,10 +47,10 @@ direnv exec "$(git rev-parse --show-toplevel)" \
|
||||||
The available validation commands are:
|
The available validation commands are:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" npm run format
|
npm run format
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" npm run lint
|
npm run lint
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" npm run type-check
|
npm run type-check
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" npm run build
|
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.
|
||||||
|
|
|
||||||
83
ai/shared.md
83
ai/shared.md
|
|
@ -45,43 +45,28 @@ 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. For non-interactive use, start it
|
- Start a worktree stack from its root with `process-compose up`. For
|
||||||
detached and stop it when finished, as shown below.
|
non-interactive use, run `process-compose up -D` and stop it with
|
||||||
|
`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. Bare project
|
- Non-interactive agent shells do not automatically load direnv. A bare
|
||||||
commands can use missing tools, default ports, or paths from the main
|
`process-compose`, `php artisan`, or database command from a worktree can
|
||||||
checkout.
|
silently use default ports and target the main checkout.
|
||||||
- Run project tooling that depends on the repository development environment
|
- Prefix worktree stack and database commands with `direnv exec <worktree>`.
|
||||||
through direnv. This includes PHP, Composer, Artisan, npm, tests, builds,
|
Examples:
|
||||||
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 "$(git rev-parse --show-toplevel)" <command>
|
direnv exec <worktree> process-compose up -D
|
||||||
```
|
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. The direnv target remains the worktree root.
|
command.
|
||||||
- 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
|
||||||
the worktree's assigned port when needed:
|
`npm run dev -- --port "$VITE_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.
|
||||||
|
|
@ -158,11 +143,8 @@ 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. From `frontend/website/`, provision them with:
|
dependencies. Run `npm install` from `frontend/website/` when provisioning a
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
@ -173,17 +155,12 @@ gate affected by the change.
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
- Run tests from `backend/`:
|
- Run tests from `backend/` with `php artisan test`.
|
||||||
|
|
||||||
```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
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" composer types:check
|
composer types:check
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" composer test
|
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
|
||||||
|
|
@ -194,10 +171,10 @@ gate affected by the change.
|
||||||
- Run these commands from `frontend/website/`:
|
- Run these commands from `frontend/website/`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" npm run format
|
npm run format
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" npm run lint
|
npm run lint
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" npm run type-check
|
npm run type-check
|
||||||
direnv exec "$(git rev-parse --show-toplevel)" npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
- The formatter and linters rewrite files. Review their changes before
|
- The formatter and linters rewrite files. Review their changes before
|
||||||
|
|
@ -207,20 +184,8 @@ gate affected by the change.
|
||||||
|
|
||||||
### Environment and integration
|
### Environment and integration
|
||||||
|
|
||||||
- For Nix or shell-hook changes, run:
|
- For Nix or shell-hook changes, run `nix fmt` and `nix flake check`.
|
||||||
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
|
|
||||||
interface Clock
|
|
||||||
{
|
|
||||||
public function now(): DateTimeImmutable;
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<?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,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
<?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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
<?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',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
interface SessionRepository
|
|
||||||
{
|
|
||||||
public function create(CreateSessionDto $dto): Session;
|
|
||||||
|
|
||||||
public function findByToken(string $token): ?Session;
|
|
||||||
|
|
||||||
public function deleteByToken(string $token): void;
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use DateTimeZone;
|
|
||||||
|
|
||||||
class SystemClock implements Clock
|
|
||||||
{
|
|
||||||
public function now(): DateTimeImmutable
|
|
||||||
{
|
|
||||||
return new DateTimeImmutable(
|
|
||||||
'now',
|
|
||||||
new DateTimeZone('UTC'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
40
backend/app/Models/User.php
Normal file
40
backend/app/Models/User.php
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,12 +2,6 @@
|
||||||
|
|
||||||
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;
|
||||||
|
|
@ -21,15 +15,7 @@ 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\User;
|
|
||||||
|
|
||||||
use App\Shared\ValueObject\EmailAddress;
|
|
||||||
|
|
||||||
final readonly class CreateUserDto
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public EmailAddress $email,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
<?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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\User;
|
|
||||||
|
|
||||||
interface UserRepository
|
|
||||||
{
|
|
||||||
public function create(CreateUserDto $dto): User;
|
|
||||||
|
|
||||||
public function find(int $id): ?User;
|
|
||||||
}
|
|
||||||
117
backend/config/auth.php
Normal file
117
backend/config/auth.php
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
<?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),
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<?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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
<?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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
<?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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
<?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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
<?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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
<?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'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
<?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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
<?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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
<?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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -43,7 +43,6 @@
|
||||||
mailpit
|
mailpit
|
||||||
process-compose
|
process-compose
|
||||||
mkcert
|
mkcert
|
||||||
nssTools
|
|
||||||
caddy
|
caddy
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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,
|
|
||||||
})
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
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,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -9,7 +9,6 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,11 @@
|
||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterView />
|
<h1>You did it!</h1>
|
||||||
|
<p>
|
||||||
|
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
|
||||||
|
documentation
|
||||||
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
|
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,426 +0,0 @@
|
||||||
<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'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'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>
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -3,7 +3,6 @@ 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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,8 @@
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { ref } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
|
||||||
const isAuthenticated = ref(false)
|
|
||||||
|
|
||||||
return {
|
|
||||||
isAuthenticated,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
: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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
<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'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>
|
|
||||||
|
|
@ -1,599 +0,0 @@
|
||||||
<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'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>
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
<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>
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue