Compare commits
19 commits
ce1b920fb6
...
6da83d87fa
| Author | SHA1 | Date | |
|---|---|---|---|
| 6da83d87fa | |||
| 02c947708f | |||
| edcaf80b98 | |||
| 4e3d8f74b8 | |||
| 28c1edd958 | |||
| b3c9b0630f | |||
| ad27a0f03c | |||
| 88a3997bad | |||
| 167096b4fe | |||
| 8bc9ef728b | |||
| 529a352906 | |||
| 19d930b7e8 | |||
| 34e26f81f5 | |||
| a37c8da555 | |||
| 0777f0da2f | |||
| b5697e1e1f | |||
| a8a6177a1d | |||
| 6ebf57bf56 | |||
| 116d1582f3 |
48 changed files with 2704 additions and 200 deletions
|
|
@ -92,7 +92,8 @@ pattern.
|
||||||
- Rebuild the development database with:
|
- Rebuild the development database with:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
php artisan migrate:fresh --seed
|
direnv exec "$(git rev-parse --show-toplevel)" \
|
||||||
|
php artisan migrate:fresh --seed
|
||||||
```
|
```
|
||||||
|
|
||||||
- Once a production database exists, replace this policy with additive,
|
- Once a production database exists, replace this policy with additive,
|
||||||
|
|
@ -101,7 +102,12 @@ pattern.
|
||||||
## Before completing backend work
|
## Before completing backend work
|
||||||
|
|
||||||
- Run the focused test during development.
|
- Run the focused test during development.
|
||||||
- Run `php artisan test` before completion.
|
- Run the full test suite before completion:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
direnv exec "$(git rev-parse --show-toplevel)" php artisan test
|
||||||
|
```
|
||||||
|
|
||||||
- Run the Composer static-analysis scripts.
|
- Run the Composer static-analysis scripts.
|
||||||
- Fix failures caused by the change. Report unrelated baseline failures
|
- Fix failures caused by the change. Report unrelated baseline failures
|
||||||
precisely rather than hiding them or expanding scope without authorization.
|
precisely rather than hiding them or expanding scope without authorization.
|
||||||
|
|
|
||||||
|
|
@ -33,13 +33,14 @@ Use npm and keep `package-lock.json` committed. Run commands from
|
||||||
Install dependencies in a fresh checkout or worktree:
|
Install dependencies in a fresh checkout or worktree:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm install
|
direnv exec "$(git rev-parse --show-toplevel)" npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
Start the development server on the port assigned by the shell hook:
|
Start the development server on the port assigned by the shell hook:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run dev -- --port "$VITE_PORT"
|
direnv exec "$(git rev-parse --show-toplevel)" \
|
||||||
|
npm run dev -- --port "$VITE_PORT"
|
||||||
```
|
```
|
||||||
|
|
||||||
`process-compose` does not start or proxy the frontend.
|
`process-compose` does not start or proxy the frontend.
|
||||||
|
|
@ -47,10 +48,10 @@ npm run dev -- --port "$VITE_PORT"
|
||||||
The available validation commands are:
|
The available validation commands are:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run format
|
direnv exec "$(git rev-parse --show-toplevel)" npm run format
|
||||||
npm run lint
|
direnv exec "$(git rev-parse --show-toplevel)" npm run lint
|
||||||
npm run type-check
|
direnv exec "$(git rev-parse --show-toplevel)" npm run type-check
|
||||||
npm run build
|
direnv exec "$(git rev-parse --show-toplevel)" npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
`npm run format` and `npm run lint` rewrite files. Review the resulting diff.
|
`npm run format` and `npm run lint` rewrite files. Review the resulting diff.
|
||||||
|
|
|
||||||
83
ai/shared.md
83
ai/shared.md
|
|
@ -45,28 +45,43 @@ those changes with the most relevant parser, formatter, dry run, or check.
|
||||||
is the user's stack. Do not start, restart, or stop it unless the user asks.
|
is the user's stack. Do not start, restart, or stop it unless the user asks.
|
||||||
- A worktree owns its own isolated stack. The flake shell hook assigns a
|
- A worktree owns its own isolated stack. The flake shell hook assigns a
|
||||||
deterministic port offset and creates worktree-local PostgreSQL state.
|
deterministic port offset and creates worktree-local PostgreSQL state.
|
||||||
- Start a worktree stack from its root with `process-compose up`. For
|
- Start a worktree stack from its root. For non-interactive use, start it
|
||||||
non-interactive use, run `process-compose up -D` and stop it with
|
detached and stop it when finished, as shown below.
|
||||||
`process-compose down`.
|
|
||||||
- Do not use `process-compose -t=false` for a detached stack. It can leave an
|
- Do not use `process-compose -t=false` for a detached stack. It can leave an
|
||||||
orphaned PostgreSQL process holding the data directory.
|
orphaned PostgreSQL process holding the data directory.
|
||||||
- Non-interactive agent shells do not automatically load direnv. A bare
|
- Non-interactive agent shells do not automatically load direnv. Bare project
|
||||||
`process-compose`, `php artisan`, or database command from a worktree can
|
commands can use missing tools, default ports, or paths from the main
|
||||||
silently use default ports and target the main checkout.
|
checkout.
|
||||||
- Prefix worktree stack and database commands with `direnv exec <worktree>`.
|
- Run project tooling that depends on the repository development environment
|
||||||
Examples:
|
through direnv. This includes PHP, Composer, Artisan, npm, tests, builds,
|
||||||
|
database clients, and services.
|
||||||
|
- Resolve the direnv target from the worktree containing the agent's current
|
||||||
|
working directory. Never target the main checkout or a different worktree:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
direnv exec <worktree> process-compose up -D
|
direnv exec "$(git rev-parse --show-toplevel)" <command>
|
||||||
direnv exec <worktree> process-compose down
|
```
|
||||||
direnv exec <worktree> php artisan migrate:fresh --seed
|
|
||||||
|
- Git and environment-neutral read-only file inspection do not need the
|
||||||
|
direnv wrapper.
|
||||||
|
- Worktree stack examples:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
direnv exec "$(git rev-parse --show-toplevel)" process-compose up -D
|
||||||
|
direnv exec "$(git rev-parse --show-toplevel)" process-compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
- Run backend commands from `backend/`, or explicitly change into it in the
|
- Run backend commands from `backend/`, or explicitly change into it in the
|
||||||
command.
|
command. The direnv target remains the worktree root.
|
||||||
- Run frontend commands from `frontend/website/`.
|
- Run frontend commands from `frontend/website/`.
|
||||||
- `process-compose` does not start the frontend. Start it separately with
|
- `process-compose` does not start the frontend. Start it separately with
|
||||||
`npm run dev -- --port "$VITE_PORT"` when needed.
|
the worktree's assigned port when needed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
direnv exec "$(git rev-parse --show-toplevel)" \
|
||||||
|
npm run dev -- --port "$VITE_PORT"
|
||||||
|
```
|
||||||
|
|
||||||
- When a normally valid check fails because a required service is down,
|
- When a normally valid check fails because a required service is down,
|
||||||
surface the environmental failure. Do not skip the check or silently switch
|
surface the environmental failure. Do not skip the check or silently switch
|
||||||
to a different database or service.
|
to a different database or service.
|
||||||
|
|
@ -143,8 +158,11 @@ those changes with the most relevant parser, formatter, dry run, or check.
|
||||||
another checkout. Dependency paths and generated files must remain
|
another checkout. Dependency paths and generated files must remain
|
||||||
worktree-local.
|
worktree-local.
|
||||||
- The shell hook installs backend dependencies but does not install frontend
|
- The shell hook installs backend dependencies but does not install frontend
|
||||||
dependencies. Run `npm install` from `frontend/website/` when provisioning a
|
dependencies. From `frontend/website/`, provision them with:
|
||||||
fresh worktree.
|
|
||||||
|
```sh
|
||||||
|
direnv exec "$(git rev-parse --show-toplevel)" npm install
|
||||||
|
```
|
||||||
|
|
||||||
Do not push anything. Make commits as the TDD workflow requires.
|
Do not push anything. Make commits as the TDD workflow requires.
|
||||||
|
|
||||||
|
|
@ -155,12 +173,17 @@ gate affected by the change.
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
- Run tests from `backend/` with `php artisan test`.
|
- Run tests from `backend/`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
direnv exec "$(git rev-parse --show-toplevel)" php artisan test
|
||||||
|
```
|
||||||
|
|
||||||
- Run the Composer checks defined by `backend/composer.json`:
|
- Run the Composer checks defined by `backend/composer.json`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
composer types:check
|
direnv exec "$(git rev-parse --show-toplevel)" composer types:check
|
||||||
composer test
|
direnv exec "$(git rev-parse --show-toplevel)" composer test
|
||||||
```
|
```
|
||||||
|
|
||||||
- Do not claim a green gate when a command fails. If the failure predates the
|
- Do not claim a green gate when a command fails. If the failure predates the
|
||||||
|
|
@ -171,10 +194,10 @@ gate affected by the change.
|
||||||
- Run these commands from `frontend/website/`:
|
- Run these commands from `frontend/website/`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run format
|
direnv exec "$(git rev-parse --show-toplevel)" npm run format
|
||||||
npm run lint
|
direnv exec "$(git rev-parse --show-toplevel)" npm run lint
|
||||||
npm run type-check
|
direnv exec "$(git rev-parse --show-toplevel)" npm run type-check
|
||||||
npm run build
|
direnv exec "$(git rev-parse --show-toplevel)" npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
- The formatter and linters rewrite files. Review their changes before
|
- The formatter and linters rewrite files. Review their changes before
|
||||||
|
|
@ -184,8 +207,20 @@ gate affected by the change.
|
||||||
|
|
||||||
### Environment and integration
|
### Environment and integration
|
||||||
|
|
||||||
- For Nix or shell-hook changes, run `nix fmt` and `nix flake check`.
|
- For Nix or shell-hook changes, run:
|
||||||
- For service configuration changes, run `process-compose --dry-run`.
|
|
||||||
|
```sh
|
||||||
|
direnv exec "$(git rev-parse --show-toplevel)" nix fmt
|
||||||
|
direnv exec "$(git rev-parse --show-toplevel)" nix flake check
|
||||||
|
```
|
||||||
|
|
||||||
|
- For service configuration changes, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
direnv exec "$(git rev-parse --show-toplevel)" \
|
||||||
|
process-compose --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
- When a change affects runtime wiring, start the worktree's stack and verify
|
- When a change affects runtime wiring, start the worktree's stack and verify
|
||||||
the relevant endpoint or service against that worktree.
|
the relevant endpoint or service against that worktree.
|
||||||
- If you started the stack only for validation, stop it before finishing.
|
- If you started the stack only for validation, stop it before finishing.
|
||||||
|
|
|
||||||
10
backend/app/Auth/Clock.php
Normal file
10
backend/app/Auth/Clock.php
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
interface Clock
|
||||||
|
{
|
||||||
|
public function now(): DateTimeImmutable;
|
||||||
|
}
|
||||||
16
backend/app/Auth/CreateSessionDto.php
Normal file
16
backend/app/Auth/CreateSessionDto.php
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use App\User\User;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
final readonly class CreateSessionDto
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $token,
|
||||||
|
public User $user,
|
||||||
|
public DateTimeImmutable $createdAt,
|
||||||
|
public DateTimeImmutable $expiresAt,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
62
backend/app/Auth/EloquentSessionRepository.php
Normal file
62
backend/app/Auth/EloquentSessionRepository.php
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use App\User\UserRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
|
||||||
|
class EloquentSessionRepository implements SessionRepository
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private UserRepository $userRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function create(CreateSessionDto $dto): Session
|
||||||
|
{
|
||||||
|
SessionModel::create([
|
||||||
|
'token' => $dto->token,
|
||||||
|
'user_id' => $dto->user->getId(),
|
||||||
|
'created_at' => $dto->createdAt,
|
||||||
|
'expires_at' => $dto->expiresAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new Session(
|
||||||
|
token: $dto->token,
|
||||||
|
user: $dto->user,
|
||||||
|
createdAt: $dto->createdAt,
|
||||||
|
expiresAt: $dto->expiresAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByToken(string $token): ?Session
|
||||||
|
{
|
||||||
|
$model = SessionModel::find($token);
|
||||||
|
if ($model === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->userRepository->find($model->user_id);
|
||||||
|
if ($user === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Session(
|
||||||
|
token: $model->token,
|
||||||
|
user: $user,
|
||||||
|
createdAt: $this->toUtc($model->created_at),
|
||||||
|
expiresAt: $this->toUtc($model->expires_at),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteByToken(string $token): void
|
||||||
|
{
|
||||||
|
SessionModel::where('token', $token)->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toUtc(DateTimeImmutable $dateTime): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return DateTimeImmutable::createFromInterface($dateTime)
|
||||||
|
->setTimezone(new DateTimeZone('UTC'));
|
||||||
|
}
|
||||||
|
}
|
||||||
41
backend/app/Auth/Session.php
Normal file
41
backend/app/Auth/Session.php
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use App\User\User;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
final readonly class Session
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private string $token,
|
||||||
|
private User $user,
|
||||||
|
private DateTimeImmutable $createdAt,
|
||||||
|
private DateTimeImmutable $expiresAt,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getToken(): string
|
||||||
|
{
|
||||||
|
return $this->token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUser(): User
|
||||||
|
{
|
||||||
|
return $this->user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getExpiresAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isExpired(DateTimeImmutable $now): bool
|
||||||
|
{
|
||||||
|
return $now >= $this->expiresAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
backend/app/Auth/SessionModel.php
Normal file
50
backend/app/Auth/SessionModel.php
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property string $token
|
||||||
|
* @property int $user_id
|
||||||
|
* @property DateTimeImmutable $created_at
|
||||||
|
* @property DateTimeImmutable $expires_at
|
||||||
|
*
|
||||||
|
* @method static Builder<static>|SessionModel newModelQuery()
|
||||||
|
* @method static Builder<static>|SessionModel newQuery()
|
||||||
|
* @method static Builder<static>|SessionModel query()
|
||||||
|
*
|
||||||
|
* @mixin \Eloquent
|
||||||
|
*/
|
||||||
|
#[Fillable([
|
||||||
|
'token',
|
||||||
|
'user_id',
|
||||||
|
'created_at',
|
||||||
|
'expires_at',
|
||||||
|
])]
|
||||||
|
class SessionModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'sessions';
|
||||||
|
|
||||||
|
protected $primaryKey = 'token';
|
||||||
|
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/app/Auth/SessionRepository.php
Normal file
12
backend/app/Auth/SessionRepository.php
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
interface SessionRepository
|
||||||
|
{
|
||||||
|
public function create(CreateSessionDto $dto): Session;
|
||||||
|
|
||||||
|
public function findByToken(string $token): ?Session;
|
||||||
|
|
||||||
|
public function deleteByToken(string $token): void;
|
||||||
|
}
|
||||||
17
backend/app/Auth/SystemClock.php
Normal file
17
backend/app/Auth/SystemClock.php
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
|
||||||
|
class SystemClock implements Clock
|
||||||
|
{
|
||||||
|
public function now(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new DateTimeImmutable(
|
||||||
|
'now',
|
||||||
|
new DateTimeZone('UTC'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
backend/app/Http/Middleware/AuthMiddleware.php
Normal file
51
backend/app/Http/Middleware/AuthMiddleware.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Auth\Clock;
|
||||||
|
use App\Auth\SessionRepository;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class AuthMiddleware
|
||||||
|
{
|
||||||
|
public const string COOKIE_NAME = 'auth_token';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private SessionRepository $sessionRepository,
|
||||||
|
private Clock $clock,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Closure(Request): Response $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$token = $request->cookie(self::COOKIE_NAME);
|
||||||
|
if (! is_string($token) || $token === '') {
|
||||||
|
return $this->unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = $this->sessionRepository->findByToken($token);
|
||||||
|
if ($session === null) {
|
||||||
|
return $this->unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($session->isExpired($this->clock->now())) {
|
||||||
|
$this->sessionRepository->deleteByToken($token);
|
||||||
|
|
||||||
|
return $this->unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->attributes->set('user', $session->getUser());
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function unauthorized(): JsonResponse
|
||||||
|
{
|
||||||
|
return new JsonResponse(['error' => 'unauthenticated'], 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
|
||||||
use Illuminate\Notifications\Notifiable;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property string $name
|
|
||||||
* @property string $email
|
|
||||||
* @property Carbon|null $email_verified_at
|
|
||||||
* @property string $password
|
|
||||||
* @property string|null $remember_token
|
|
||||||
* @property Carbon|null $created_at
|
|
||||||
* @property Carbon|null $updated_at
|
|
||||||
*/
|
|
||||||
#[Fillable(['name', 'email', 'password'])]
|
|
||||||
#[Hidden(['password', 'remember_token'])]
|
|
||||||
class User extends Authenticatable
|
|
||||||
{
|
|
||||||
use Notifiable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the attributes that should be cast.
|
|
||||||
*
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'email_verified_at' => 'datetime',
|
|
||||||
'password' => 'hashed',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,6 +2,12 @@
|
||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Auth\Clock;
|
||||||
|
use App\Auth\EloquentSessionRepository;
|
||||||
|
use App\Auth\SessionRepository;
|
||||||
|
use App\Auth\SystemClock;
|
||||||
|
use App\User\EloquentUserRepository;
|
||||||
|
use App\User\UserRepository;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Support\Facades\Date;
|
use Illuminate\Support\Facades\Date;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
@ -15,7 +21,15 @@ class AppServiceProvider extends ServiceProvider
|
||||||
*/
|
*/
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
//
|
$this->app->bind(
|
||||||
|
UserRepository::class,
|
||||||
|
EloquentUserRepository::class,
|
||||||
|
);
|
||||||
|
$this->app->bind(
|
||||||
|
SessionRepository::class,
|
||||||
|
EloquentSessionRepository::class,
|
||||||
|
);
|
||||||
|
$this->app->bind(Clock::class, SystemClock::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
40
backend/app/Shared/ValueObject/EmailAddress.php
Normal file
40
backend/app/Shared/ValueObject/EmailAddress.php
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Shared\ValueObject;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final readonly class EmailAddress
|
||||||
|
{
|
||||||
|
private const string ERROR_MESSAGE = 'Invalid email address:';
|
||||||
|
|
||||||
|
private string $normalized;
|
||||||
|
|
||||||
|
public function __construct(string $email)
|
||||||
|
{
|
||||||
|
$trimmedEmail = trim($email);
|
||||||
|
if (
|
||||||
|
$trimmedEmail === ''
|
||||||
|
|| ! str_contains($trimmedEmail, '@')
|
||||||
|
) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
self::ERROR_MESSAGE." $email",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[$localPart, $domain] = explode('@', $trimmedEmail, 2);
|
||||||
|
$normalizedEmail = $localPart.'@'.mb_strtolower($domain);
|
||||||
|
if (filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) === false) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
self::ERROR_MESSAGE." $email",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->normalized = $normalizedEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function value(): string
|
||||||
|
{
|
||||||
|
return $this->normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/app/User/CreateUserDto.php
Normal file
12
backend/app/User/CreateUserDto.php
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\User;
|
||||||
|
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
|
||||||
|
final readonly class CreateUserDto
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public EmailAddress $email,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
35
backend/app/User/EloquentUserRepository.php
Normal file
35
backend/app/User/EloquentUserRepository.php
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\User;
|
||||||
|
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
|
||||||
|
class EloquentUserRepository implements UserRepository
|
||||||
|
{
|
||||||
|
public function create(CreateUserDto $dto): User
|
||||||
|
{
|
||||||
|
$model = UserModel::create([
|
||||||
|
'email' => $dto->email->value(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->toDomain($model);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find(int $id): ?User
|
||||||
|
{
|
||||||
|
$model = UserModel::find($id);
|
||||||
|
if ($model === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->toDomain($model);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toDomain(UserModel $model): User
|
||||||
|
{
|
||||||
|
return new User(
|
||||||
|
id: $model->id,
|
||||||
|
email: new EmailAddress($model->email),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/app/User/User.php
Normal file
23
backend/app/User/User.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\User;
|
||||||
|
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
|
||||||
|
final readonly class User
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private int $id,
|
||||||
|
private EmailAddress $email,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getId(): int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEmail(): EmailAddress
|
||||||
|
{
|
||||||
|
return $this->email;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/app/User/UserModel.php
Normal file
25
backend/app/User/UserModel.php
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\User;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property string $email
|
||||||
|
*
|
||||||
|
* @method static Builder<static>|UserModel newModelQuery()
|
||||||
|
* @method static Builder<static>|UserModel newQuery()
|
||||||
|
* @method static Builder<static>|UserModel query()
|
||||||
|
*
|
||||||
|
* @mixin \Eloquent
|
||||||
|
*/
|
||||||
|
#[Fillable(['email'])]
|
||||||
|
class UserModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'users';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
}
|
||||||
10
backend/app/User/UserRepository.php
Normal file
10
backend/app/User/UserRepository.php
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\User;
|
||||||
|
|
||||||
|
interface UserRepository
|
||||||
|
{
|
||||||
|
public function create(CreateUserDto $dto): User;
|
||||||
|
|
||||||
|
public function find(int $id): ?User;
|
||||||
|
}
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
|
|
||||||
return [
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Authentication Defaults
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| This option defines the default authentication "guard" and password
|
|
||||||
| reset "broker" for your application. You may change these values
|
|
||||||
| as required, but they're a perfect start for most applications.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'defaults' => [
|
|
||||||
'guard' => env('AUTH_GUARD', 'web'),
|
|
||||||
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Authentication Guards
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Next, you may define every authentication guard for your application.
|
|
||||||
| Of course, a great default configuration has been defined for you
|
|
||||||
| which utilizes session storage plus the Eloquent user provider.
|
|
||||||
|
|
|
||||||
| All authentication guards have a user provider, which defines how the
|
|
||||||
| users are actually retrieved out of your database or other storage
|
|
||||||
| system used by the application. Typically, Eloquent is utilized.
|
|
||||||
|
|
|
||||||
| Supported: "session"
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'guards' => [
|
|
||||||
'web' => [
|
|
||||||
'driver' => 'session',
|
|
||||||
'provider' => 'users',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| User Providers
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| All authentication guards have a user provider, which defines how the
|
|
||||||
| users are actually retrieved out of your database or other storage
|
|
||||||
| system used by the application. Typically, Eloquent is utilized.
|
|
||||||
|
|
|
||||||
| If you have multiple user tables or models you may configure multiple
|
|
||||||
| providers to represent the model / table. These providers may then
|
|
||||||
| be assigned to any extra authentication guards you have defined.
|
|
||||||
|
|
|
||||||
| Supported: "database", "eloquent"
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'providers' => [
|
|
||||||
'users' => [
|
|
||||||
'driver' => 'eloquent',
|
|
||||||
'model' => env('AUTH_MODEL', User::class),
|
|
||||||
],
|
|
||||||
|
|
||||||
// 'users' => [
|
|
||||||
// 'driver' => 'database',
|
|
||||||
// 'table' => 'users',
|
|
||||||
// ],
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Resetting Passwords
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| These configuration options specify the behavior of Laravel's password
|
|
||||||
| reset functionality, including the table utilized for token storage
|
|
||||||
| and the user provider that is invoked to actually retrieve users.
|
|
||||||
|
|
|
||||||
| The expiry time is the number of minutes that each reset token will be
|
|
||||||
| considered valid. This security feature keeps tokens short-lived so
|
|
||||||
| they have less time to be guessed. You may change this as needed.
|
|
||||||
|
|
|
||||||
| The throttle setting is the number of seconds a user must wait before
|
|
||||||
| generating more password reset tokens. This prevents the user from
|
|
||||||
| quickly generating a very large amount of password reset tokens.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'passwords' => [
|
|
||||||
'users' => [
|
|
||||||
'provider' => 'users',
|
|
||||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
|
||||||
'expire' => 60,
|
|
||||||
'throttle' => 60,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Password Confirmation Timeout
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Here you may define the number of seconds before a password confirmation
|
|
||||||
| window expires and users are asked to re-enter their password via the
|
|
||||||
| confirmation screen. By default, the timeout lasts for three hours.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
|
||||||
|
|
||||||
];
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('users', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('email')->unique();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('users');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('sessions', function (Blueprint $table): void {
|
||||||
|
$table->string('token', 64)->primary();
|
||||||
|
$table->foreignId('user_id')
|
||||||
|
->constrained('users')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
$table->timestamp('created_at');
|
||||||
|
$table->timestamp('expires_at')->index();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('sessions');
|
||||||
|
}
|
||||||
|
};
|
||||||
18
backend/tests/Fakes/FakeClock.php
Normal file
18
backend/tests/Fakes/FakeClock.php
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Fakes;
|
||||||
|
|
||||||
|
use App\Auth\Clock;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
class FakeClock implements Clock
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private DateTimeImmutable $currentTime,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function now(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->currentTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
backend/tests/Fakes/FakeSessionRepository.php
Normal file
38
backend/tests/Fakes/FakeSessionRepository.php
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Fakes;
|
||||||
|
|
||||||
|
use App\Auth\CreateSessionDto;
|
||||||
|
use App\Auth\Session;
|
||||||
|
use App\Auth\SessionRepository;
|
||||||
|
|
||||||
|
class FakeSessionRepository implements SessionRepository
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<string, Session>
|
||||||
|
*/
|
||||||
|
private array $sessions = [];
|
||||||
|
|
||||||
|
public function create(CreateSessionDto $dto): Session
|
||||||
|
{
|
||||||
|
$session = new Session(
|
||||||
|
token: $dto->token,
|
||||||
|
user: $dto->user,
|
||||||
|
createdAt: $dto->createdAt,
|
||||||
|
expiresAt: $dto->expiresAt,
|
||||||
|
);
|
||||||
|
$this->sessions[$dto->token] = $session;
|
||||||
|
|
||||||
|
return $session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByToken(string $token): ?Session
|
||||||
|
{
|
||||||
|
return $this->sessions[$token] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteByToken(string $token): void
|
||||||
|
{
|
||||||
|
unset($this->sessions[$token]);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
backend/tests/Feature/Auth/AuthMiddlewareTest.php
Normal file
119
backend/tests/Feature/Auth/AuthMiddlewareTest.php
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use App\Auth\Clock;
|
||||||
|
use App\Auth\CreateSessionDto;
|
||||||
|
use App\Auth\SessionRepository;
|
||||||
|
use App\Http\Middleware\AuthMiddleware;
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
use App\User\CreateUserDto;
|
||||||
|
use App\User\User;
|
||||||
|
use App\User\UserRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Tests\Fakes\FakeClock;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AuthMiddlewareTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private DateTimeImmutable $now;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->now = $this->utc('2026-07-31T12:00:00');
|
||||||
|
$this->app->instance(Clock::class, new FakeClock($this->now));
|
||||||
|
|
||||||
|
Route::middleware(AuthMiddleware::class)->get(
|
||||||
|
'/test/authenticated-user',
|
||||||
|
function (Request $request): JsonResponse {
|
||||||
|
$user = $request->attributes->get('user');
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return new JsonResponse(['error' => 'missing user'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'id' => $user->getId(),
|
||||||
|
'email' => $user->getEmail()->value(),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_valid_cookie_reaches_the_protected_route(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUserAndSession(
|
||||||
|
token: 'valid-token',
|
||||||
|
expiresAt: $this->now->modify('+7 days'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->withCredentials()
|
||||||
|
->withUnencryptedCookie(
|
||||||
|
AuthMiddleware::COOKIE_NAME,
|
||||||
|
'valid-token',
|
||||||
|
)->getJson('/test/authenticated-user');
|
||||||
|
|
||||||
|
$response->assertOk()->assertExactJson([
|
||||||
|
'id' => $user->getId(),
|
||||||
|
'email' => 'user@example.com',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_missing_cookie_is_rejected(): void
|
||||||
|
{
|
||||||
|
$response = $this->getJson('/test/authenticated-user');
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertStatus(401)
|
||||||
|
->assertExactJson(['error' => 'unauthenticated']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_expired_cookie_is_rejected_and_deleted(): void
|
||||||
|
{
|
||||||
|
$this->createUserAndSession(
|
||||||
|
token: 'expired-token',
|
||||||
|
expiresAt: $this->now->modify('-1 day'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->withCredentials()
|
||||||
|
->withUnencryptedCookie(
|
||||||
|
AuthMiddleware::COOKIE_NAME,
|
||||||
|
'expired-token',
|
||||||
|
)->getJson('/test/authenticated-user');
|
||||||
|
|
||||||
|
$response->assertStatus(401);
|
||||||
|
$this->assertNull(
|
||||||
|
app(SessionRepository::class)->findByToken('expired-token'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createUserAndSession(
|
||||||
|
string $token,
|
||||||
|
DateTimeImmutable $expiresAt,
|
||||||
|
): User {
|
||||||
|
$user = app(UserRepository::class)->create(new CreateUserDto(
|
||||||
|
email: new EmailAddress('user@example.com'),
|
||||||
|
));
|
||||||
|
app(SessionRepository::class)->create(new CreateSessionDto(
|
||||||
|
token: $token,
|
||||||
|
user: $user,
|
||||||
|
createdAt: $this->now,
|
||||||
|
expiresAt: $expiresAt,
|
||||||
|
));
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function utc(string $time): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new DateTimeImmutable($time, new DateTimeZone('UTC'));
|
||||||
|
}
|
||||||
|
}
|
||||||
85
backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php
Normal file
85
backend/tests/Feature/Auth/EloquentSessionRepositoryTest.php
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use App\Auth\CreateSessionDto;
|
||||||
|
use App\Auth\SessionRepository;
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
use App\User\CreateUserDto;
|
||||||
|
use App\User\UserRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class EloquentSessionRepositoryTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_it_creates_and_finds_a_session(): void
|
||||||
|
{
|
||||||
|
$user = app(UserRepository::class)->create(new CreateUserDto(
|
||||||
|
email: new EmailAddress('user@example.com'),
|
||||||
|
));
|
||||||
|
$createdAt = $this->utc('2026-07-31T12:00:00');
|
||||||
|
$expiresAt = $this->utc('2026-08-07T12:00:00');
|
||||||
|
$repository = app(SessionRepository::class);
|
||||||
|
|
||||||
|
$session = $repository->create(new CreateSessionDto(
|
||||||
|
token: 'session-token',
|
||||||
|
user: $user,
|
||||||
|
createdAt: $createdAt,
|
||||||
|
expiresAt: $expiresAt,
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->assertSame('session-token', $session->getToken());
|
||||||
|
$this->assertSame($user, $session->getUser());
|
||||||
|
$this->assertDatabaseHas('sessions', [
|
||||||
|
'token' => 'session-token',
|
||||||
|
'user_id' => $user->getId(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$foundSession = $repository->findByToken('session-token');
|
||||||
|
|
||||||
|
$this->assertNotNull($foundSession);
|
||||||
|
$this->assertSame(
|
||||||
|
$user->getId(),
|
||||||
|
$foundSession->getUser()->getId(),
|
||||||
|
);
|
||||||
|
$this->assertEquals($createdAt, $foundSession->getCreatedAt());
|
||||||
|
$this->assertEquals($expiresAt, $foundSession->getExpiresAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_null_for_an_unknown_token(): void
|
||||||
|
{
|
||||||
|
$repository = app(SessionRepository::class);
|
||||||
|
|
||||||
|
$this->assertNull($repository->findByToken('unknown-token'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_deletes_a_session_by_token(): void
|
||||||
|
{
|
||||||
|
$user = app(UserRepository::class)->create(new CreateUserDto(
|
||||||
|
email: new EmailAddress('user@example.com'),
|
||||||
|
));
|
||||||
|
$repository = app(SessionRepository::class);
|
||||||
|
$repository->create(new CreateSessionDto(
|
||||||
|
token: 'session-token',
|
||||||
|
user: $user,
|
||||||
|
createdAt: $this->utc('2026-07-31T12:00:00'),
|
||||||
|
expiresAt: $this->utc('2026-08-07T12:00:00'),
|
||||||
|
));
|
||||||
|
|
||||||
|
$repository->deleteByToken('session-token');
|
||||||
|
|
||||||
|
$this->assertNull($repository->findByToken('session-token'));
|
||||||
|
$this->assertDatabaseMissing('sessions', [
|
||||||
|
'token' => 'session-token',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function utc(string $time): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new DateTimeImmutable($time, new DateTimeZone('UTC'));
|
||||||
|
}
|
||||||
|
}
|
||||||
48
backend/tests/Feature/User/EloquentUserRepositoryTest.php
Normal file
48
backend/tests/Feature/User/EloquentUserRepositoryTest.php
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\User;
|
||||||
|
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
use App\User\CreateUserDto;
|
||||||
|
use App\User\UserRepository;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class EloquentUserRepositoryTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_it_creates_and_finds_a_user(): void
|
||||||
|
{
|
||||||
|
$repository = app(UserRepository::class);
|
||||||
|
$user = $repository->create(new CreateUserDto(
|
||||||
|
email: new EmailAddress('Founder@EXAMPLE.COM'),
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->assertGreaterThan(0, $user->getId());
|
||||||
|
$this->assertSame(
|
||||||
|
'Founder@example.com',
|
||||||
|
$user->getEmail()->value(),
|
||||||
|
);
|
||||||
|
$this->assertDatabaseHas('users', [
|
||||||
|
'id' => $user->getId(),
|
||||||
|
'email' => 'Founder@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$foundUser = $repository->find($user->getId());
|
||||||
|
|
||||||
|
$this->assertNotNull($foundUser);
|
||||||
|
$this->assertSame($user->getId(), $foundUser->getId());
|
||||||
|
$this->assertSame(
|
||||||
|
$user->getEmail()->value(),
|
||||||
|
$foundUser->getEmail()->value(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_null_for_an_unknown_user(): void
|
||||||
|
{
|
||||||
|
$repository = app(UserRepository::class);
|
||||||
|
|
||||||
|
$this->assertNull($repository->find(999));
|
||||||
|
}
|
||||||
|
}
|
||||||
159
backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php
Normal file
159
backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Auth\Middleware;
|
||||||
|
|
||||||
|
use App\Auth\CreateSessionDto;
|
||||||
|
use App\Http\Middleware\AuthMiddleware;
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
use App\User\User;
|
||||||
|
use Closure;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Tests\Fakes\FakeClock;
|
||||||
|
use Tests\Fakes\FakeSessionRepository;
|
||||||
|
|
||||||
|
class AuthMiddlewareTest extends TestCase
|
||||||
|
{
|
||||||
|
private FakeSessionRepository $sessionRepository;
|
||||||
|
|
||||||
|
private DateTimeImmutable $now;
|
||||||
|
|
||||||
|
private AuthMiddleware $middleware;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->now = new DateTimeImmutable(
|
||||||
|
'2026-07-31T12:00:00',
|
||||||
|
new DateTimeZone('UTC'),
|
||||||
|
);
|
||||||
|
$this->sessionRepository = new FakeSessionRepository;
|
||||||
|
$this->middleware = new AuthMiddleware(
|
||||||
|
sessionRepository: $this->sessionRepository,
|
||||||
|
clock: new FakeClock($this->now),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_missing_cookie_returns_unauthenticated(): void
|
||||||
|
{
|
||||||
|
$capturedRequest = null;
|
||||||
|
|
||||||
|
$response = $this->middleware->handle(
|
||||||
|
$this->requestWithToken(null),
|
||||||
|
$this->captureNextRequest($capturedRequest),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(401, $response->getStatusCode());
|
||||||
|
$this->assertSame(
|
||||||
|
['error' => 'unauthenticated'],
|
||||||
|
json_decode($response->getContent(), true),
|
||||||
|
);
|
||||||
|
$this->assertNull($capturedRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_empty_cookie_returns_unauthenticated(): void
|
||||||
|
{
|
||||||
|
$capturedRequest = null;
|
||||||
|
|
||||||
|
$response = $this->middleware->handle(
|
||||||
|
$this->requestWithToken(''),
|
||||||
|
$this->captureNextRequest($capturedRequest),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(401, $response->getStatusCode());
|
||||||
|
$this->assertNull($capturedRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unknown_token_returns_unauthenticated(): void
|
||||||
|
{
|
||||||
|
$capturedRequest = null;
|
||||||
|
|
||||||
|
$response = $this->middleware->handle(
|
||||||
|
$this->requestWithToken('unknown-token'),
|
||||||
|
$this->captureNextRequest($capturedRequest),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(401, $response->getStatusCode());
|
||||||
|
$this->assertNull($capturedRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_expired_session_is_deleted(): void
|
||||||
|
{
|
||||||
|
$this->sessionRepository->create(new CreateSessionDto(
|
||||||
|
token: 'expired-token',
|
||||||
|
user: $this->user(),
|
||||||
|
createdAt: $this->now->modify('-8 days'),
|
||||||
|
expiresAt: $this->now->modify('-1 day'),
|
||||||
|
));
|
||||||
|
$capturedRequest = null;
|
||||||
|
|
||||||
|
$response = $this->middleware->handle(
|
||||||
|
$this->requestWithToken('expired-token'),
|
||||||
|
$this->captureNextRequest($capturedRequest),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(401, $response->getStatusCode());
|
||||||
|
$this->assertNull($capturedRequest);
|
||||||
|
$this->assertNull(
|
||||||
|
$this->sessionRepository->findByToken('expired-token'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_valid_session_attaches_user_and_calls_next(): void
|
||||||
|
{
|
||||||
|
$user = $this->user();
|
||||||
|
$this->sessionRepository->create(new CreateSessionDto(
|
||||||
|
token: 'valid-token',
|
||||||
|
user: $user,
|
||||||
|
createdAt: $this->now,
|
||||||
|
expiresAt: $this->now->modify('+7 days'),
|
||||||
|
));
|
||||||
|
$capturedRequest = null;
|
||||||
|
|
||||||
|
$response = $this->middleware->handle(
|
||||||
|
$this->requestWithToken('valid-token'),
|
||||||
|
$this->captureNextRequest($capturedRequest),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(200, $response->getStatusCode());
|
||||||
|
$this->assertNotNull($capturedRequest);
|
||||||
|
$this->assertSame(
|
||||||
|
$user,
|
||||||
|
$capturedRequest->attributes->get('user'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requestWithToken(?string $token): Request
|
||||||
|
{
|
||||||
|
$request = Request::create('/anything', 'GET');
|
||||||
|
if ($token !== null) {
|
||||||
|
$request->cookies->set(AuthMiddleware::COOKIE_NAME, $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Request|null $capturedRequest
|
||||||
|
* @return Closure(Request): JsonResponse
|
||||||
|
*/
|
||||||
|
private function captureNextRequest(
|
||||||
|
?Request &$capturedRequest,
|
||||||
|
): Closure {
|
||||||
|
return function (Request $request) use (&$capturedRequest) {
|
||||||
|
$capturedRequest = $request;
|
||||||
|
|
||||||
|
return new JsonResponse(['ok' => true]);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function user(): User
|
||||||
|
{
|
||||||
|
return new User(
|
||||||
|
id: 7,
|
||||||
|
email: new EmailAddress('user@example.com'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
backend/tests/Unit/Auth/SessionTest.php
Normal file
61
backend/tests/Unit/Auth/SessionTest.php
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Auth;
|
||||||
|
|
||||||
|
use App\Auth\Session;
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
use App\User\User;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class SessionTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_it_exposes_its_values(): void
|
||||||
|
{
|
||||||
|
$user = new User(
|
||||||
|
id: 7,
|
||||||
|
email: new EmailAddress('user@example.com'),
|
||||||
|
);
|
||||||
|
$createdAt = $this->utc('2026-07-31T12:00:00');
|
||||||
|
$expiresAt = $this->utc('2026-08-07T12:00:00');
|
||||||
|
$session = new Session(
|
||||||
|
token: 'session-token',
|
||||||
|
user: $user,
|
||||||
|
createdAt: $createdAt,
|
||||||
|
expiresAt: $expiresAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame('session-token', $session->getToken());
|
||||||
|
$this->assertSame($user, $session->getUser());
|
||||||
|
$this->assertSame($createdAt, $session->getCreatedAt());
|
||||||
|
$this->assertSame($expiresAt, $session->getExpiresAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_expires_at_the_expiry_time(): void
|
||||||
|
{
|
||||||
|
$expiresAt = $this->utc('2026-08-07T12:00:00');
|
||||||
|
$session = new Session(
|
||||||
|
token: 'session-token',
|
||||||
|
user: new User(
|
||||||
|
id: 7,
|
||||||
|
email: new EmailAddress('user@example.com'),
|
||||||
|
),
|
||||||
|
createdAt: $this->utc('2026-07-31T12:00:00'),
|
||||||
|
expiresAt: $expiresAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertFalse(
|
||||||
|
$session->isExpired($expiresAt->modify('-1 second')),
|
||||||
|
);
|
||||||
|
$this->assertTrue($session->isExpired($expiresAt));
|
||||||
|
$this->assertTrue(
|
||||||
|
$session->isExpired($expiresAt->modify('+1 second')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function utc(string $time): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return new DateTimeImmutable($time, new DateTimeZone('UTC'));
|
||||||
|
}
|
||||||
|
}
|
||||||
27
backend/tests/Unit/Shared/ValueObject/EmailAddressTest.php
Normal file
27
backend/tests/Unit/Shared/ValueObject/EmailAddressTest.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Shared\ValueObject;
|
||||||
|
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class EmailAddressTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_it_rejects_an_invalid_email_address(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage(
|
||||||
|
'Invalid email address: invalid-email',
|
||||||
|
);
|
||||||
|
|
||||||
|
new EmailAddress('invalid-email');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_trims_and_normalizes_the_domain(): void
|
||||||
|
{
|
||||||
|
$email = new EmailAddress(' Founder@EXAMPLE.COM ');
|
||||||
|
|
||||||
|
$this->assertSame('Founder@example.com', $email->value());
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/tests/Unit/User/UserTest.php
Normal file
19
backend/tests/Unit/User/UserTest.php
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\User;
|
||||||
|
|
||||||
|
use App\Shared\ValueObject\EmailAddress;
|
||||||
|
use App\User\User;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class UserTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_it_exposes_its_identity_and_email(): void
|
||||||
|
{
|
||||||
|
$email = new EmailAddress('user@example.com');
|
||||||
|
$user = new User(id: 42, email: $email);
|
||||||
|
|
||||||
|
$this->assertSame(42, $user->getId());
|
||||||
|
$this->assertSame($email, $user->getEmail());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -43,6 +43,7 @@
|
||||||
mailpit
|
mailpit
|
||||||
process-compose
|
process-compose
|
||||||
mkcert
|
mkcert
|
||||||
|
nssTools
|
||||||
caddy
|
caddy
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
12
frontend/website/cypress.config.ts
Normal file
12
frontend/website/cypress.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { defineConfig } from 'cypress'
|
||||||
|
|
||||||
|
const frontendPort = process.env.VITE_PORT ?? '5173'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
allowCypressEnv: false,
|
||||||
|
e2e: {
|
||||||
|
baseUrl: `http://127.0.0.1:${frontendPort}`,
|
||||||
|
supportFile: false,
|
||||||
|
},
|
||||||
|
video: false,
|
||||||
|
})
|
||||||
78
frontend/website/cypress/e2e/guest-auth.cy.ts
Normal file
78
frontend/website/cypress/e2e/guest-auth.cy.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
describe('guest authentication pages', () => {
|
||||||
|
it('shows a public home route to guests', () => {
|
||||||
|
cy.visit('/')
|
||||||
|
|
||||||
|
cy.location('pathname').should('equal', '/')
|
||||||
|
cy.get('h1').should('have.text', 'Make big goals feel possible.')
|
||||||
|
cy.contains('a', 'Log in').should('have.attr', 'href', '/login')
|
||||||
|
cy.contains('a', 'Get started').should('have.attr', 'href', '/signup')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects guests away from the protected dashboard', () => {
|
||||||
|
cy.visit('/dashboard')
|
||||||
|
|
||||||
|
cy.location('pathname').should('equal', '/login')
|
||||||
|
cy.location('search').should('include', 'redirect=/dashboard')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the login form and links to signup', () => {
|
||||||
|
cy.visit('/login')
|
||||||
|
|
||||||
|
cy.get('h1').should('have.text', 'Welcome back')
|
||||||
|
cy.get('label[for="login-email"]').should('have.text', 'Email address')
|
||||||
|
cy.get('#login-email').should('have.attr', 'autocomplete', 'email')
|
||||||
|
cy.get('label[for="login-password"]').should('have.text', 'Password')
|
||||||
|
cy.get('#login-password').should('have.attr', 'autocomplete', 'current-password')
|
||||||
|
cy.get('button[type="submit"]').should('have.text', 'Log in')
|
||||||
|
|
||||||
|
cy.contains('a', 'Create an account').click()
|
||||||
|
cy.location('pathname').should('equal', '/signup')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the signup form and links to login', () => {
|
||||||
|
cy.visit('/signup')
|
||||||
|
|
||||||
|
cy.get('h1').should('have.text', 'Start your journey')
|
||||||
|
cy.get('label[for="signup-name"]').should('have.text', 'Full name')
|
||||||
|
cy.get('#signup-name').should('have.attr', 'autocomplete', 'name')
|
||||||
|
cy.get('label[for="signup-email"]').should('have.text', 'Email address')
|
||||||
|
cy.get('#signup-email').should('have.attr', 'autocomplete', 'email')
|
||||||
|
cy.get('label[for="signup-password"]').should('have.text', 'Password')
|
||||||
|
cy.get('#signup-password').should('have.attr', 'autocomplete', 'new-password')
|
||||||
|
cy.get('label[for="signup-password-confirmation"]').should(
|
||||||
|
'have.text',
|
||||||
|
'Confirm password',
|
||||||
|
)
|
||||||
|
cy.get('#signup-password-confirmation').should(
|
||||||
|
'have.attr',
|
||||||
|
'autocomplete',
|
||||||
|
'new-password',
|
||||||
|
)
|
||||||
|
cy.get('button[type="submit"]').should('have.text', 'Create account')
|
||||||
|
|
||||||
|
cy.contains('a', 'Log in').click()
|
||||||
|
cy.location('pathname').should('equal', '/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps UI-only form submissions on their current route', () => {
|
||||||
|
cy.visit('/login')
|
||||||
|
cy.get('form').submit()
|
||||||
|
cy.location('pathname').should('equal', '/login')
|
||||||
|
|
||||||
|
cy.visit('/signup')
|
||||||
|
cy.get('form').submit()
|
||||||
|
cy.location('pathname').should('equal', '/signup')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fits the signup page within a mobile viewport', () => {
|
||||||
|
cy.viewport(390, 844)
|
||||||
|
cy.visit('/signup')
|
||||||
|
|
||||||
|
cy.get('main').should('be.visible')
|
||||||
|
cy.document().then((document) => {
|
||||||
|
expect(document.documentElement.scrollWidth).to.be.at.most(
|
||||||
|
document.documentElement.clientWidth,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"build-only": "vite build",
|
"build-only": "vite build",
|
||||||
"type-check": "vue-tsc --build",
|
"type-check": "vue-tsc --build",
|
||||||
|
"test:e2e": "cypress run",
|
||||||
"lint": "run-s \"lint:*\"",
|
"lint": "run-s \"lint:*\"",
|
||||||
"lint:oxlint": "oxlint . --fix",
|
"lint:oxlint": "oxlint . --fix",
|
||||||
"lint:eslint": "eslint . --fix --cache",
|
"lint:eslint": "eslint . --fix --cache",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,3 @@
|
||||||
<script setup lang="ts"></script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h1>You did it!</h1>
|
<RouterView />
|
||||||
<p>
|
|
||||||
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
|
|
||||||
documentation
|
|
||||||
</p>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|
|
||||||
68
frontend/website/src/components/AuthForm.vue
Normal file
68
frontend/website/src/components/AuthForm.vue
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
submitLabel: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="auth-form" @submit.prevent>
|
||||||
|
<div class="auth-form__fields">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">{{ submitLabel }}</button>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form__fields {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 3.35rem;
|
||||||
|
padding: 0.9rem 1.25rem;
|
||||||
|
border: 1px solid #183a31;
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
color: #fffdf6;
|
||||||
|
background: #183a31;
|
||||||
|
box-shadow: 0 0.6rem 1.2rem rgb(24 58 49 / 16%);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 750;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
border-color 160ms ease,
|
||||||
|
transform 160ms ease,
|
||||||
|
box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
border-color: #285c4e;
|
||||||
|
background: #285c4e;
|
||||||
|
box-shadow: 0 0.8rem 1.4rem rgb(24 58 49 / 20%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 3px solid rgb(90 136 120 / 38%);
|
||||||
|
outline-offset: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
button {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
426
frontend/website/src/components/AuthLayout.vue
Normal file
426
frontend/website/src/components/AuthLayout.vue
Normal file
|
|
@ -0,0 +1,426 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import BrandWordmark from '@/components/BrandWordmark.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
eyebrow: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="auth-page">
|
||||||
|
<section class="story-panel" aria-label="About Attainly">
|
||||||
|
<div class="story-panel__glow story-panel__glow--top" aria-hidden="true"></div>
|
||||||
|
<div class="story-panel__glow story-panel__glow--bottom" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div class="story-panel__content">
|
||||||
|
<BrandWordmark class="story-panel__wordmark" theme="light" />
|
||||||
|
|
||||||
|
<div class="story-copy">
|
||||||
|
<p class="story-copy__eyebrow">Progress with purpose</p>
|
||||||
|
<h2>Big goals,<br />made doable.</h2>
|
||||||
|
<p>Turn what matters into clear, daily progress, one focused step at a time.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-card" aria-hidden="true">
|
||||||
|
<div class="progress-card__header">
|
||||||
|
<span>Today'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>
|
||||||
70
frontend/website/src/components/AuthTextField.vue
Normal file
70
frontend/website/src/components/AuthTextField.vue
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
type: 'email' | 'password' | 'text'
|
||||||
|
autocomplete: string
|
||||||
|
placeholder: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="text-field">
|
||||||
|
<label :for="id">{{ label }}</label>
|
||||||
|
<input
|
||||||
|
:id="id"
|
||||||
|
:type="type"
|
||||||
|
:name="id"
|
||||||
|
:autocomplete="autocomplete"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.text-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
color: #293b35;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 3.2rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border: 1px solid #d8dcd7;
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
color: #1b2924;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 2px rgb(27 41 36 / 3%);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
outline: none;
|
||||||
|
transition:
|
||||||
|
border-color 160ms ease,
|
||||||
|
box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder {
|
||||||
|
color: #a3aaa6;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:hover {
|
||||||
|
border-color: #b4bdb8;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
border-color: #4d7d6d;
|
||||||
|
box-shadow: 0 0 0 3px rgb(77 125 109 / 16%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
input {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
71
frontend/website/src/components/BrandWordmark.vue
Normal file
71
frontend/website/src/components/BrandWordmark.vue
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
theme: 'dark' | 'light'
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterLink class="wordmark" :class="`wordmark--${theme}`" to="/" aria-label="Attainly home">
|
||||||
|
<span class="wordmark__mark" aria-hidden="true">
|
||||||
|
<span></span>
|
||||||
|
</span>
|
||||||
|
<span>Attainly</span>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wordmark {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark--light {
|
||||||
|
color: #fffdf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark--dark {
|
||||||
|
color: #183029;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark:focus-visible {
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
outline: 3px solid #d4e58a;
|
||||||
|
outline-offset: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark__mark {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid currentcolor;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(255 255 255 / 9%);
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark__mark::before,
|
||||||
|
.wordmark__mark span {
|
||||||
|
position: absolute;
|
||||||
|
width: 0.65rem;
|
||||||
|
height: 0.65rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark__mark::before {
|
||||||
|
background: #c9da73;
|
||||||
|
transform: translate(-0.22rem, 0.2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark__mark span {
|
||||||
|
border: 2px solid currentcolor;
|
||||||
|
transform: translate(0.25rem, -0.25rem);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -3,6 +3,7 @@ import { createPinia } from 'pinia'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import './styles/main.css'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,59 @@
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [],
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
component: () => import('@/views/HomeView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: () => import('@/views/LoginView.vue'),
|
||||||
|
meta: {
|
||||||
|
guestOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/signup',
|
||||||
|
name: 'signup',
|
||||||
|
component: () => import('@/views/SignupView.vue'),
|
||||||
|
meta: {
|
||||||
|
guestOnly: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
name: 'dashboard',
|
||||||
|
component: () => import('@/views/DashboardView.vue'),
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||||
|
return {
|
||||||
|
name: 'login',
|
||||||
|
query: {
|
||||||
|
redirect: to.fullPath,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.guestOnly && authStore.isAuthenticated) {
|
||||||
|
return {
|
||||||
|
name: 'dashboard',
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|
|
||||||
10
frontend/website/src/stores/auth.ts
Normal file
10
frontend/website/src/stores/auth.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const isAuthenticated = ref(false)
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAuthenticated,
|
||||||
|
}
|
||||||
|
})
|
||||||
50
frontend/website/src/styles/main.css
Normal file
50
frontend/website/src/styles/main.css
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
:root {
|
||||||
|
color: #1b2924;
|
||||||
|
background: #f3f1eb;
|
||||||
|
font-family:
|
||||||
|
Inter,
|
||||||
|
ui-sans-serif,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
sans-serif;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100%;
|
||||||
|
background: #f3f1eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
a {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100svh;
|
||||||
|
}
|
||||||
67
frontend/website/src/views/DashboardView.vue
Normal file
67
frontend/website/src/views/DashboardView.vue
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import BrandWordmark from '@/components/BrandWordmark.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="dashboard-page">
|
||||||
|
<header>
|
||||||
|
<BrandWordmark theme="dark" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<p>Dashboard</p>
|
||||||
|
<h1>Your next step starts here.</h1>
|
||||||
|
<span>
|
||||||
|
Your goals and today'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>
|
||||||
599
frontend/website/src/views/HomeView.vue
Normal file
599
frontend/website/src/views/HomeView.vue
Normal file
|
|
@ -0,0 +1,599 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import BrandWordmark from '@/components/BrandWordmark.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="home-page">
|
||||||
|
<div class="home-page__glow home-page__glow--left" aria-hidden="true"></div>
|
||||||
|
<div class="home-page__glow home-page__glow--right" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<BrandWordmark theme="dark" />
|
||||||
|
|
||||||
|
<nav aria-label="Account navigation">
|
||||||
|
<RouterLink class="site-header__login" to="/login">Log in</RouterLink>
|
||||||
|
<RouterLink class="button button--small" to="/signup">Get started</RouterLink>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<div class="hero__copy">
|
||||||
|
<p class="hero__eyebrow">
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
|
Progress with purpose
|
||||||
|
</p>
|
||||||
|
<h1>Make big goals feel possible.</h1>
|
||||||
|
<p class="hero__description">
|
||||||
|
Attainly turns meaningful ambitions into clear, manageable steps so you always know what
|
||||||
|
to do next.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="hero__actions">
|
||||||
|
<RouterLink class="button" to="/signup">
|
||||||
|
Get started
|
||||||
|
<span aria-hidden="true">→</span>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink class="hero__login" to="/login"> I already have an account </RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="hero__benefits" aria-label="Attainly benefits">
|
||||||
|
<li><span aria-hidden="true">✓</span> Clear daily steps</li>
|
||||||
|
<li><span aria-hidden="true">✓</span> Flexible scheduling</li>
|
||||||
|
<li><span aria-hidden="true">✓</span> Visible progress</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="goal-preview" aria-label="Example goal progress">
|
||||||
|
<div class="goal-preview__accent" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div class="goal-preview__top">
|
||||||
|
<div>
|
||||||
|
<p>Current goal</p>
|
||||||
|
<h2>Read 24 books this year</h2>
|
||||||
|
</div>
|
||||||
|
<span>68%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="goal-preview__progress"
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Example goal progress"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
aria-valuenow="68"
|
||||||
|
>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="goal-preview__summary">
|
||||||
|
<span>16 books completed</span>
|
||||||
|
<span>8 remaining</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="today-card">
|
||||||
|
<div class="today-card__header">
|
||||||
|
<div>
|
||||||
|
<p>Today'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>
|
||||||
35
frontend/website/src/views/LoginView.vue
Normal file
35
frontend/website/src/views/LoginView.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import AuthForm from '@/components/AuthForm.vue'
|
||||||
|
import AuthLayout from '@/components/AuthLayout.vue'
|
||||||
|
import AuthTextField from '@/components/AuthTextField.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AuthLayout
|
||||||
|
eyebrow="Welcome back"
|
||||||
|
title="Welcome back"
|
||||||
|
description="Pick up where you left off and keep your momentum going."
|
||||||
|
>
|
||||||
|
<AuthForm submit-label="Log in">
|
||||||
|
<AuthTextField
|
||||||
|
id="login-email"
|
||||||
|
label="Email address"
|
||||||
|
type="email"
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
<AuthTextField
|
||||||
|
id="login-password"
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
/>
|
||||||
|
</AuthForm>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
New to Attainly?
|
||||||
|
<RouterLink to="/signup">Create an account</RouterLink>
|
||||||
|
</template>
|
||||||
|
</AuthLayout>
|
||||||
|
</template>
|
||||||
49
frontend/website/src/views/SignupView.vue
Normal file
49
frontend/website/src/views/SignupView.vue
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import AuthForm from '@/components/AuthForm.vue'
|
||||||
|
import AuthLayout from '@/components/AuthLayout.vue'
|
||||||
|
import AuthTextField from '@/components/AuthTextField.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AuthLayout
|
||||||
|
eyebrow="Build your momentum"
|
||||||
|
title="Start your journey"
|
||||||
|
description="Create your space to turn ambitious goals into steady progress."
|
||||||
|
>
|
||||||
|
<AuthForm submit-label="Create account">
|
||||||
|
<AuthTextField
|
||||||
|
id="signup-name"
|
||||||
|
label="Full name"
|
||||||
|
type="text"
|
||||||
|
autocomplete="name"
|
||||||
|
placeholder="Your full name"
|
||||||
|
/>
|
||||||
|
<AuthTextField
|
||||||
|
id="signup-email"
|
||||||
|
label="Email address"
|
||||||
|
type="email"
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
<AuthTextField
|
||||||
|
id="signup-password"
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="Create a password"
|
||||||
|
/>
|
||||||
|
<AuthTextField
|
||||||
|
id="signup-password-confirmation"
|
||||||
|
label="Confirm password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="Repeat your password"
|
||||||
|
/>
|
||||||
|
</AuthForm>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
Already have an account?
|
||||||
|
<RouterLink to="/login">Log in</RouterLink>
|
||||||
|
</template>
|
||||||
|
</AuthLayout>
|
||||||
|
</template>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue