commit f659fe6b665d94d666d1aa011a022ef0f847b630 Author: Brian Fertig Date: Wed Jul 8 16:31:44 2026 -0600 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..20eb5d6 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# --------------------------------------------------------------- +# Bowler — environment configuration +# Copy this file to .env and change every password before running: +# docker-compose up -d +# --------------------------------------------------------------- + +# Application +APP_NAME=Bowler +APP_ENV=production +APP_DEBUG=false +APP_PORT=8080 +APP_URL=http://localhost:8080 +# Generate with: php artisan key:generate --show (or any random 32 bytes, base64-encoded) +APP_KEY=base64:CHANGE_ME_GENERATE_A_REAL_KEY + +# Percona / MySQL database +DB_DATABASE=bowler +DB_USERNAME=bowler +DB_PASSWORD=change-me-db-password +DB_ROOT_PASSWORD=change-me-root-password + +# Initial site administrator (seeded on first startup) +ADMIN_NAME=Administrator +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=change-me-admin-password diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..327e110 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto +*.sh text eol=lf +Dockerfile text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..edadca5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.env +db-data/ +src/vendor/ +src/composer.lock +src/node_modules/ +src/storage/app/* +!src/storage/app/public +src/storage/app/public/* +src/storage/framework/cache/data/* +src/storage/framework/sessions/* +src/storage/framework/views/* +src/storage/logs/* +!src/storage/**/.gitignore +src/bootstrap/cache/* +!src/bootstrap/cache/.gitignore diff --git a/README.md b/README.md new file mode 100644 index 0000000..067ffc7 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# Bowler + +A multi-user website for managing shared bowler charts — tracking project status and KPIs. + +This is the Phase 1 foundation: Docker environment, Hello World landing page, login, and user administration. Bowler chart functionality comes next. + +## Requirements + +- [Docker Desktop](https://www.docker.com/products/docker-desktop/) (or Docker Engine + Compose v2) + +Nothing else — no local PHP, Composer, or Node needed. The app container installs its own dependencies on first boot. + +## Quick start + +1. Copy the environment template and set your own passwords: + + ``` + copy .env.example .env + ``` + + Edit `.env` and set: + - `DB_PASSWORD` / `DB_ROOT_PASSWORD` — Percona database credentials + - `ADMIN_EMAIL` / `ADMIN_PASSWORD` — the initial site administrator login + - `APP_KEY` — a random base64 key. Generate one with: + ``` + docker run --rm php:8.4-cli php -r "echo 'base64:' . base64_encode(random_bytes(32)) . PHP_EOL;" + ``` + + *(A ready-to-use development `.env` is already included in this repo.)* + +2. Start the stack: + + ``` + docker-compose up -d --build + ``` + + First boot takes a few minutes: the app container runs `composer install`, waits for the + database, runs migrations, and seeds the initial administrator. Watch progress with + `docker-compose logs -f app`. + +3. Open [http://localhost:8080](http://localhost:8080) and log in with `ADMIN_EMAIL` / `ADMIN_PASSWORD`. + +## What's included + +| URL | Description | +|---|---| +| `/` | Hello World landing page | +| `/login` | Login (rejects disabled accounts, throttled to 10 attempts/min) | +| `/admin/users` | User management (admins only): create users, reset passwords, enable/disable accounts | + +Admins cannot disable their own account, so you can't lock yourself out. + +## Project layout + +``` +docker-compose.yml # app (PHP 8.4 + Apache) + db (Percona latest) +.env # all credentials & app config (never commit real secrets) +docker/app/ # app image: Dockerfile + startup script +src/ # the Laravel application (bind-mounted into the container) +db-data/ # Percona data files (created on first run) +``` + +## Common operations + +```sh +docker-compose logs -f app # tail application logs +docker-compose exec app php artisan migrate # run new migrations +docker-compose exec app php artisan tinker # interactive REPL +docker-compose down # stop (data persists in db-data/) +``` + +## Roadmap + +- Bowler chart data model and CRUD +- Multiple charts with per-chart owners and collaborators +- PDF / printer export of bowler summaries diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9b7da7d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +services: + app: + build: + context: ./docker/app + container_name: bowler-app + restart: unless-stopped + ports: + - "${APP_PORT:-8080}:80" + volumes: + - ./src:/var/www/html + environment: + APP_NAME: ${APP_NAME:-Bowler} + APP_ENV: ${APP_ENV:-production} + APP_KEY: ${APP_KEY} + APP_DEBUG: ${APP_DEBUG:-false} + APP_URL: ${APP_URL:-http://localhost:8080} + LOG_CHANNEL: stderr + DB_CONNECTION: mysql + DB_HOST: db + DB_PORT: "3306" + DB_DATABASE: ${DB_DATABASE} + DB_USERNAME: ${DB_USERNAME} + DB_PASSWORD: ${DB_PASSWORD} + SESSION_DRIVER: database + CACHE_STORE: database + QUEUE_CONNECTION: database + ADMIN_NAME: ${ADMIN_NAME} + ADMIN_EMAIL: ${ADMIN_EMAIL} + ADMIN_PASSWORD: ${ADMIN_PASSWORD} + depends_on: + db: + condition: service_healthy + + db: + image: percona:latest + container_name: bowler-db + restart: unless-stopped + volumes: + - ./db-data:/var/lib/mysql + environment: + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + MYSQL_DATABASE: ${DB_DATABASE} + MYSQL_USER: ${DB_USERNAME} + MYSQL_PASSWORD: ${DB_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -p$$MYSQL_ROOT_PASSWORD --silent"] + interval: 5s + timeout: 5s + retries: 30 diff --git a/docker/app/Dockerfile b/docker/app/Dockerfile new file mode 100644 index 0000000..f350896 --- /dev/null +++ b/docker/app/Dockerfile @@ -0,0 +1,25 @@ +FROM php:8.4-apache + +# System packages + PHP extensions Laravel needs +RUN apt-get update \ + && apt-get install -y --no-install-recommends git unzip libzip-dev libicu-dev \ + && docker-php-ext-install pdo_mysql zip intl opcache \ + && a2enmod rewrite \ + && rm -rf /var/lib/apt/lists/* + +# Composer (used by the entrypoint to install dependencies on first boot) +COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer +ENV COMPOSER_ALLOW_SUPERUSER=1 + +# Serve Laravel's public/ directory +ENV APACHE_DOCUMENT_ROOT=/var/www/html/public +RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' \ + /etc/apache2/sites-available/*.conf /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf + +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +# Strip Windows line endings in case the file was checked out with CRLF +RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/entrypoint.sh + +WORKDIR /var/www/html +ENTRYPOINT ["entrypoint.sh"] +CMD ["apache2-foreground"] diff --git a/docker/app/entrypoint.sh b/docker/app/entrypoint.sh new file mode 100644 index 0000000..d3f8cf1 --- /dev/null +++ b/docker/app/entrypoint.sh @@ -0,0 +1,39 @@ +#!/bin/sh +set -e + +cd /var/www/html + +# Install PHP dependencies on first boot (the app is bind-mounted from ./src) +if [ ! -f vendor/autoload.php ]; then + echo "vendor/ missing - running composer install..." + composer install --no-interaction --prefer-dist --no-progress +fi + +# Wait for the database to accept connections +echo "Waiting for database at ${DB_HOST}:${DB_PORT}..." +until php -r ' + try { + new PDO( + "mysql:host=" . getenv("DB_HOST") . ";port=" . getenv("DB_PORT"), + getenv("DB_USERNAME"), + getenv("DB_PASSWORD"), + [PDO::ATTR_TIMEOUT => 3] + ); + } catch (Throwable $e) { + exit(1); + } +' 2>/dev/null; do + echo " ...database not ready, retrying" + sleep 2 +done +echo "Database is up." + +php artisan migrate --force +php artisan db:seed --force + +# Make runtime directories writable by the web server. +# chown can be a no-op or fail on Windows bind mounts - never fatal. +chown -R www-data:www-data storage bootstrap/cache 2>/dev/null || true +chmod -R ugo+rwX storage bootstrap/cache 2>/dev/null || true + +exec "$@" diff --git a/software.md b/software.md new file mode 100644 index 0000000..26c1dc9 --- /dev/null +++ b/software.md @@ -0,0 +1,24 @@ +## Overview + +Build a multi-user website with access controls to allow users manage a shared or several shared bowler charts for tracking project status and KPIs. Additionally create a process by which Bowler summaries can be exported to PDF or printer. + +## Platform + +The software should be created in a manner that allows a docker-compose up -d command to instantiate the software working on a local machine. Create a docker-compose.yml file that will consist of an application server hosting PHP 8.4 and a separate Percona (latest) database server. The docker compose should use local shared folders for volumes. Use an .env file to capture all Percona logins, passwords, database names, etc. as well as the login and password for the initial administrator of the website. + +Use local shared folders to write the actual software that will be served from the docker instance. + +## Software + +The primary functionality of this site is going to be to create and manage a shared bowler chart. This could be later expanded to have multiple bowler charts with different users owning or collaborating on different bowler charts. + +## Stack + +- PHP 8.4 +- Percona/MySQL +- Javascript + - Additional libraries such as React / Jquery / etc. are acceptable to include. + +## Starting Point + +Lets begin building this software by creating the docker-compose.yml file, and creating a simple Hello World page, and a Login Page that allows the user specified in the .env file to log in. That user should also be given access to a user administration page, which allows them to create additional accounts, reset passwords, and enable/disable users. Build that user management page(s) as well. We will create the rest of the software iteratively from there. \ No newline at end of file diff --git a/src/app/Http/Controllers/AuthController.php b/src/app/Http/Controllers/AuthController.php new file mode 100644 index 0000000..5c107a3 --- /dev/null +++ b/src/app/Http/Controllers/AuthController.php @@ -0,0 +1,53 @@ +validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + ]); + + $user = User::where('email', $credentials['email'])->first(); + + if ($user && ! $user->is_active) { + return back() + ->withInput($request->only('email')) + ->withErrors(['email' => 'This account has been disabled. Contact an administrator.']); + } + + if (! Auth::attempt($credentials, $request->boolean('remember'))) { + return back() + ->withInput($request->only('email')) + ->withErrors(['email' => 'These credentials do not match our records.']); + } + + $request->session()->regenerate(); + + return redirect()->intended('/'); + } + + public function logout(Request $request): RedirectResponse + { + Auth::logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect('/'); + } +} diff --git a/src/app/Http/Controllers/Controller.php b/src/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..8677cd5 --- /dev/null +++ b/src/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ +get(); + + return view('admin.users.index', ['users' => $users]); + } + + public function create(): View + { + return view('admin.users.create'); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', 'unique:users,email'], + 'password' => ['required', 'string', Password::min(8)], + ]); + + User::create([ + 'name' => $data['name'], + 'email' => $data['email'], + 'password' => $data['password'], + 'is_admin' => $request->boolean('is_admin'), + 'is_active' => true, + ]); + + return redirect() + ->route('admin.users.index') + ->with('status', "User \"{$data['name']}\" created."); + } + + public function editPassword(User $user): View + { + return view('admin.users.password', ['user' => $user]); + } + + public function updatePassword(Request $request, User $user): RedirectResponse + { + $data = $request->validate([ + 'password' => ['required', 'string', 'confirmed', Password::min(8)], + ]); + + $user->update(['password' => $data['password']]); + + return redirect() + ->route('admin.users.index') + ->with('status', "Password for \"{$user->name}\" has been reset."); + } + + public function toggleActive(Request $request, User $user): RedirectResponse + { + if ($user->id === $request->user()->id) { + return redirect() + ->route('admin.users.index') + ->withErrors(['toggle' => 'You cannot disable your own account.']); + } + + $user->update(['is_active' => ! $user->is_active]); + + $state = $user->is_active ? 'enabled' : 'disabled'; + + return redirect() + ->route('admin.users.index') + ->with('status', "User \"{$user->name}\" {$state}."); + } +} diff --git a/src/app/Http/Middleware/EnsureUserIsAdmin.php b/src/app/Http/Middleware/EnsureUserIsAdmin.php new file mode 100644 index 0000000..745daee --- /dev/null +++ b/src/app/Http/Middleware/EnsureUserIsAdmin.php @@ -0,0 +1,19 @@ +user() || ! $request->user()->is_admin) { + abort(403, 'Administrator access required.'); + } + + return $next($request); + } +} diff --git a/src/app/Models/User.php b/src/app/Models/User.php new file mode 100644 index 0000000..287b420 --- /dev/null +++ b/src/app/Models/User.php @@ -0,0 +1,43 @@ + + */ + protected $fillable = [ + 'name', + 'email', + 'password', + 'is_admin', + 'is_active', + ]; + + /** + * @var list + */ + protected $hidden = [ + 'password', + 'remember_token', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + 'is_admin' => 'boolean', + 'is_active' => 'boolean', + ]; + } +} diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..70a18d2 --- /dev/null +++ b/src/app/Providers/AppServiceProvider.php @@ -0,0 +1,18 @@ +handleCommand(new ArgvInput); + +exit($status); diff --git a/src/bootstrap/app.php b/src/bootstrap/app.php new file mode 100644 index 0000000..9a3a873 --- /dev/null +++ b/src/bootstrap/app.php @@ -0,0 +1,23 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->alias([ + 'admin' => \App\Http\Middleware\EnsureUserIsAdmin::class, + ]); + + $middleware->redirectGuestsTo('/login'); + $middleware->redirectUsersTo('/'); + }) + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create(); diff --git a/src/bootstrap/cache/.gitignore b/src/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/bootstrap/providers.php b/src/bootstrap/providers.php new file mode 100644 index 0000000..38b258d --- /dev/null +++ b/src/bootstrap/providers.php @@ -0,0 +1,5 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->boolean('is_admin')->default(false); + $table->boolean('is_active')->default(true); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/src/database/migrations/0001_01_01_000001_create_cache_table.php b/src/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 0000000..b8cabb3 --- /dev/null +++ b/src/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,29 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration'); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration'); + }); + } + + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/src/database/migrations/0001_01_01_000002_create_jobs_table.php b/src/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 0000000..a892371 --- /dev/null +++ b/src/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,51 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/src/database/seeders/DatabaseSeeder.php b/src/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..224ead4 --- /dev/null +++ b/src/database/seeders/DatabaseSeeder.php @@ -0,0 +1,39 @@ +command?->warn('ADMIN_EMAIL / ADMIN_PASSWORD not set - skipping admin seed.'); + + return; + } + + if (User::where('email', $email)->exists()) { + return; + } + + User::create([ + 'name' => env('ADMIN_NAME', 'Administrator'), + 'email' => $email, + 'password' => $password, + 'is_admin' => true, + 'is_active' => true, + ]); + + $this->command?->info("Initial administrator '{$email}' created."); + } +} diff --git a/src/public/.htaccess b/src/public/.htaccess new file mode 100644 index 0000000..3aec5e2 --- /dev/null +++ b/src/public/.htaccess @@ -0,0 +1,21 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/src/public/css/app.css b/src/public/css/app.css new file mode 100644 index 0000000..56a6eb0 --- /dev/null +++ b/src/public/css/app.css @@ -0,0 +1,241 @@ +:root { + --color-primary: #1f6feb; + --color-primary-dark: #17509e; + --color-danger: #c0392b; + --color-success-bg: #e6f4ea; + --color-success-text: #1e7d34; + --color-error-bg: #fdecea; + --color-error-text: #b3261e; + --color-text: #24292f; + --color-muted: #6b7280; + --color-border: #d0d7de; + --color-bg: #f6f8fa; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + color: var(--color-text); + background: var(--color-bg); + line-height: 1.5; +} + +.container { + max-width: 960px; + margin: 0 auto; + padding: 0 1rem; +} + +/* Header */ +.site-header { + background: #fff; + border-bottom: 1px solid var(--color-border); +} + +.header-inner { + display: flex; + align-items: center; + justify-content: space-between; + height: 3.5rem; +} + +.brand { + font-weight: 700; + font-size: 1.15rem; + color: var(--color-text); + text-decoration: none; +} + +.site-nav { + display: flex; + align-items: center; + gap: 1rem; +} + +.site-nav a { + color: var(--color-primary); + text-decoration: none; +} + +.site-nav a:hover { text-decoration: underline; } + +.nav-user { color: var(--color-muted); } + +/* Layout blocks */ +main.container { padding-top: 1.5rem; padding-bottom: 3rem; } + +.hero { + background: #fff; + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 2.5rem; + text-align: center; +} + +.hero h1 { margin-top: 0; font-size: 2.25rem; } + +.card { + background: #fff; + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 2rem; +} + +.card-narrow { max-width: 26rem; margin: 0 auto; } + +.card h1 { margin-top: 0; font-size: 1.5rem; } + +.page-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.page-head h1 { margin: 0; font-size: 1.5rem; } + +/* Alerts */ +.alert { + padding: 0.75rem 1rem; + border-radius: 6px; + margin-bottom: 1rem; +} + +.alert-success { background: var(--color-success-bg); color: var(--color-success-text); } +.alert-error { background: var(--color-error-bg); color: var(--color-error-text); } + +/* Forms */ +.field { margin-bottom: 1rem; } + +.field label { + display: block; + font-weight: 600; + margin-bottom: 0.25rem; +} + +.field input[type="text"], +.field input[type="email"], +.field input[type="password"] { + width: 100%; + padding: 0.5rem 0.65rem; + border: 1px solid var(--color-border); + border-radius: 6px; + font-size: 1rem; +} + +.field input:focus { + outline: 2px solid var(--color-primary); + outline-offset: -1px; + border-color: var(--color-primary); +} + +.field-checkbox label { font-weight: 400; } +.field-checkbox input { margin-right: 0.4rem; } + +.field-error { + color: var(--color-error-text); + font-size: 0.875rem; + margin: 0.25rem 0 0; +} + +.form-actions { display: flex; gap: 0.5rem; margin-top: 1.25rem; } + +/* Buttons */ +.btn { + display: inline-block; + padding: 0.5rem 1rem; + border: 1px solid var(--color-border); + border-radius: 6px; + background: #fff; + color: var(--color-text); + font-size: 0.95rem; + text-decoration: none; + cursor: pointer; +} + +.btn:hover { background: var(--color-bg); } + +.btn-primary { + background: var(--color-primary); + border-color: var(--color-primary); + color: #fff; +} + +.btn-primary:hover { background: var(--color-primary-dark); } + +.btn-danger { + color: var(--color-danger); + border-color: var(--color-danger); +} + +.btn-danger:hover { background: var(--color-error-bg); } + +.btn-small { padding: 0.25rem 0.6rem; font-size: 0.85rem; } + +.btn-link { + border: none; + background: none; + color: var(--color-primary); + padding: 0; + font-size: 1rem; + cursor: pointer; +} + +.btn-link:hover { text-decoration: underline; background: none; } + +.inline-form { display: inline; } + +/* Tables */ +.data-table { + width: 100%; + border-collapse: collapse; + background: #fff; + border: 1px solid var(--color-border); + border-radius: 8px; + overflow: hidden; +} + +.data-table th, +.data-table td { + text-align: left; + padding: 0.6rem 0.85rem; + border-bottom: 1px solid var(--color-border); +} + +.data-table th { + background: var(--color-bg); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-muted); +} + +.data-table tr:last-child td { border-bottom: none; } + +.row-disabled td { color: var(--color-muted); } + +.actions { white-space: nowrap; } +.actions .btn { margin-right: 0.25rem; } + +/* Badges */ +.badge { + display: inline-block; + padding: 0.15rem 0.6rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 600; +} + +.badge-active { background: var(--color-success-bg); color: var(--color-success-text); } +.badge-disabled { background: var(--color-error-bg); color: var(--color-error-text); } + +.tag { + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 4px; + font-size: 0.75rem; + padding: 0 0.35rem; + color: var(--color-muted); +} diff --git a/src/public/index.php b/src/public/index.php new file mode 100644 index 0000000..ee8f07e --- /dev/null +++ b/src/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/src/public/robots.txt b/src/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/src/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/src/resources/views/admin/users/create.blade.php b/src/resources/views/admin/users/create.blade.php new file mode 100644 index 0000000..1081242 --- /dev/null +++ b/src/resources/views/admin/users/create.blade.php @@ -0,0 +1,49 @@ +@extends('layouts.app') + +@section('title', 'New User — ' . config('app.name')) + +@section('content') +
+

Create user

+ +
+ @csrf + +
+ + + @error('name') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('email') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('password') +

{{ $message }}

+ @enderror +
+ +
+ +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/src/resources/views/admin/users/index.blade.php b/src/resources/views/admin/users/index.blade.php new file mode 100644 index 0000000..871d637 --- /dev/null +++ b/src/resources/views/admin/users/index.blade.php @@ -0,0 +1,60 @@ +@extends('layouts.app') + +@section('title', 'User Management — ' . config('app.name')) + +@section('content') +
+

User Management

+ New user +
+ + + + + + + + + + + + + + @foreach ($users as $user) + + + + + + + + + @endforeach + +
NameEmailRoleStatusCreatedActions
{{ $user->name }}@if ($user->id === auth()->id()) you@endif{{ $user->email }}{{ $user->is_admin ? 'Administrator' : 'User' }} + + {{ $user->is_active ? 'Active' : 'Disabled' }} + + {{ $user->created_at?->format('Y-m-d') }} + Reset password + @if ($user->id !== auth()->id()) +
+ @csrf + +
+ @endif +
+ + +@endsection diff --git a/src/resources/views/admin/users/password.blade.php b/src/resources/views/admin/users/password.blade.php new file mode 100644 index 0000000..c905049 --- /dev/null +++ b/src/resources/views/admin/users/password.blade.php @@ -0,0 +1,33 @@ +@extends('layouts.app') + +@section('title', 'Reset Password — ' . config('app.name')) + +@section('content') +
+

Reset password

+

Set a new password for {{ $user->name }} ({{ $user->email }}).

+ +
+ @csrf + @method('PUT') + +
+ + + @error('password') +

{{ $message }}

+ @enderror +
+ +
+ + +
+ +
+ + Cancel +
+
+
+@endsection diff --git a/src/resources/views/auth/login.blade.php b/src/resources/views/auth/login.blade.php new file mode 100644 index 0000000..128b621 --- /dev/null +++ b/src/resources/views/auth/login.blade.php @@ -0,0 +1,38 @@ +@extends('layouts.app') + +@section('title', 'Log in — ' . config('app.name')) + +@section('content') +
+

Log in

+ +
+ @csrf + +
+ + + @error('email') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('password') +

{{ $message }}

+ @enderror +
+ +
+ +
+ + +
+
+@endsection diff --git a/src/resources/views/home.blade.php b/src/resources/views/home.blade.php new file mode 100644 index 0000000..959640e --- /dev/null +++ b/src/resources/views/home.blade.php @@ -0,0 +1,21 @@ +@extends('layouts.app') + +@section('title', 'Home — ' . config('app.name')) + +@section('content') +
+

Hello World

+

Welcome to {{ config('app.name', 'Bowler') }} — shared bowler charts for tracking project status and KPIs.

+ + @guest +

Log in to get started

+ @endguest + + @auth +

You are logged in as {{ auth()->user()->name }}.

+ @if (auth()->user()->is_admin) +

Manage users

+ @endif + @endauth +
+@endsection diff --git a/src/resources/views/layouts/app.blade.php b/src/resources/views/layouts/app.blade.php new file mode 100644 index 0000000..5fbffa8 --- /dev/null +++ b/src/resources/views/layouts/app.blade.php @@ -0,0 +1,42 @@ + + + + + + @yield('title', config('app.name', 'Bowler')) + + + + + +
+ @if (session('status')) +
{{ session('status') }}
+ @endif + + @if ($errors->has('toggle')) +
{{ $errors->first('toggle') }}
+ @endif + + @yield('content') +
+ + diff --git a/src/routes/console.php b/src/routes/console.php new file mode 100644 index 0000000..d41340f --- /dev/null +++ b/src/routes/console.php @@ -0,0 +1,3 @@ +name('home'); + +Route::middleware('guest')->group(function () { + Route::get('/login', [AuthController::class, 'showLogin'])->name('login'); + Route::post('/login', [AuthController::class, 'login']) + ->middleware('throttle:10,1') + ->name('login.attempt'); +}); + +Route::post('/logout', [AuthController::class, 'logout']) + ->middleware('auth') + ->name('logout'); + +Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(function () { + Route::get('/users', [UserAdminController::class, 'index'])->name('users.index'); + Route::get('/users/create', [UserAdminController::class, 'create'])->name('users.create'); + Route::post('/users', [UserAdminController::class, 'store'])->name('users.store'); + Route::get('/users/{user}/password', [UserAdminController::class, 'editPassword'])->name('users.password.edit'); + Route::put('/users/{user}/password', [UserAdminController::class, 'updatePassword'])->name('users.password.update'); + Route::post('/users/{user}/toggle-active', [UserAdminController::class, 'toggleActive'])->name('users.toggle'); +}); diff --git a/src/storage/app/.gitignore b/src/storage/app/.gitignore new file mode 100644 index 0000000..8f4803c --- /dev/null +++ b/src/storage/app/.gitignore @@ -0,0 +1,3 @@ +* +!public/ +!.gitignore diff --git a/src/storage/app/public/.gitignore b/src/storage/app/public/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/storage/app/public/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/storage/framework/.gitignore b/src/storage/framework/.gitignore new file mode 100644 index 0000000..05c4471 --- /dev/null +++ b/src/storage/framework/.gitignore @@ -0,0 +1,9 @@ +compiled.php +config.php +down +events.scanned.php +maintenance.php +routes.php +routes.scanned.php +schedule-* +services.json diff --git a/src/storage/framework/cache/.gitignore b/src/storage/framework/cache/.gitignore new file mode 100644 index 0000000..01e4a6c --- /dev/null +++ b/src/storage/framework/cache/.gitignore @@ -0,0 +1,3 @@ +* +!data/ +!.gitignore diff --git a/src/storage/framework/cache/data/.gitignore b/src/storage/framework/cache/data/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/storage/framework/cache/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/storage/framework/sessions/.gitignore b/src/storage/framework/sessions/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/storage/framework/sessions/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/storage/framework/views/.gitignore b/src/storage/framework/views/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/storage/framework/views/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/storage/logs/.gitignore b/src/storage/logs/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/src/storage/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore