first commit

This commit is contained in:
Brian Fertig 2026-07-08 16:31:44 -06:00
commit f659fe6b66
43 changed files with 1291 additions and 0 deletions

25
.env.example Normal file
View File

@ -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

3
.gitattributes vendored Normal file
View File

@ -0,0 +1,3 @@
* text=auto
*.sh text eol=lf
Dockerfile text eol=lf

15
.gitignore vendored Normal file
View File

@ -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

76
README.md Normal file
View File

@ -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

49
docker-compose.yml Normal file
View File

@ -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

25
docker/app/Dockerfile Normal file
View File

@ -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"]

39
docker/app/entrypoint.sh Normal file
View File

@ -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 "$@"

24
software.md Normal file
View File

@ -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.

View File

@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AuthController extends Controller
{
public function showLogin(): View
{
return view('auth.login');
}
public function login(Request $request): RedirectResponse
{
$credentials = $request->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('/');
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rules\Password;
use Illuminate\View\View;
class UserAdminController extends Controller
{
public function index(): View
{
$users = User::orderBy('name')->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}.");
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsAdmin
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->user() || ! $request->user()->is_admin) {
abort(403, 'Administrator access required.');
}
return $next($request);
}
}

43
src/app/Models/User.php Normal file
View File

@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
use Notifiable;
/**
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
'is_admin',
'is_active',
];
/**
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'is_admin' => 'boolean',
'is_active' => 'boolean',
];
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
//
}
}

14
src/artisan Normal file
View File

@ -0,0 +1,14 @@
#!/usr/bin/env php
<?php
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

23
src/bootstrap/app.php Normal file
View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->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();

2
src/bootstrap/cache/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -0,0 +1,5 @@
<?php
return [
App\Providers\AppServiceProvider::class,
];

24
src/composer.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "bowler/bowler",
"type": "project",
"description": "Multi-user bowler chart tracking for project status and KPIs.",
"license": "proprietary",
"require": {
"php": "^8.4",
"laravel/framework": "^12.0"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true
},
"minimum-stability": "stable",
"prefer-stable": true
}

View File

@ -0,0 +1,45 @@
<?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) {
$table->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');
}
};

View File

@ -0,0 +1,29 @@
<?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('cache', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,51 @@
<?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('jobs', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,39 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the initial administrator account from environment variables.
* Idempotent: runs on every container start without creating duplicates.
*/
public function run(): void
{
$email = env('ADMIN_EMAIL');
$password = env('ADMIN_PASSWORD');
if (! $email || ! $password) {
$this->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.");
}
}

21
src/public/.htaccess Normal file
View File

@ -0,0 +1,21 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
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]
</IfModule>

241
src/public/css/app.css Normal file
View File

@ -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);
}

20
src/public/index.php Normal file
View File

@ -0,0 +1,20 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());

2
src/public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Disallow:

View File

@ -0,0 +1,49 @@
@extends('layouts.app')
@section('title', 'New User — ' . config('app.name'))
@section('content')
<div class="card card-narrow">
<h1>Create user</h1>
<form method="POST" action="{{ route('admin.users.store') }}">
@csrf
<div class="field">
<label for="name">Name</label>
<input id="name" type="text" name="name" value="{{ old('name') }}" required autofocus>
@error('name')
<p class="field-error">{{ $message }}</p>
@enderror
</div>
<div class="field">
<label for="email">Email</label>
<input id="email" type="email" name="email" value="{{ old('email') }}" required>
@error('email')
<p class="field-error">{{ $message }}</p>
@enderror
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" type="password" name="password" required autocomplete="new-password">
@error('password')
<p class="field-error">{{ $message }}</p>
@enderror
</div>
<div class="field field-checkbox">
<label>
<input type="checkbox" name="is_admin" value="1" {{ old('is_admin') ? 'checked' : '' }}>
Administrator (can manage users)
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Create user</button>
<a class="btn" href="{{ route('admin.users.index') }}">Cancel</a>
</div>
</form>
</div>
@endsection

View File

@ -0,0 +1,60 @@
@extends('layouts.app')
@section('title', 'User Management — ' . config('app.name'))
@section('content')
<div class="page-head">
<h1>User Management</h1>
<a class="btn btn-primary" href="{{ route('admin.users.create') }}">New user</a>
</div>
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach ($users as $user)
<tr class="{{ $user->is_active ? '' : 'row-disabled' }}">
<td>{{ $user->name }}@if ($user->id === auth()->id()) <span class="tag">you</span>@endif</td>
<td>{{ $user->email }}</td>
<td>{{ $user->is_admin ? 'Administrator' : 'User' }}</td>
<td>
<span class="badge {{ $user->is_active ? 'badge-active' : 'badge-disabled' }}">
{{ $user->is_active ? 'Active' : 'Disabled' }}
</span>
</td>
<td>{{ $user->created_at?->format('Y-m-d') }}</td>
<td class="actions">
<a class="btn btn-small" href="{{ route('admin.users.password.edit', $user) }}">Reset password</a>
@if ($user->id !== auth()->id())
<form method="POST" action="{{ route('admin.users.toggle', $user) }}" class="inline-form js-confirm"
data-confirm="{{ $user->is_active ? 'Disable' : 'Enable' }} {{ $user->name }}?">
@csrf
<button type="submit" class="btn btn-small {{ $user->is_active ? 'btn-danger' : '' }}">
{{ $user->is_active ? 'Disable' : 'Enable' }}
</button>
</form>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
<script>
document.querySelectorAll('form.js-confirm').forEach(function (form) {
form.addEventListener('submit', function (event) {
if (!window.confirm(form.dataset.confirm || 'Are you sure?')) {
event.preventDefault();
}
});
});
</script>
@endsection

View File

@ -0,0 +1,33 @@
@extends('layouts.app')
@section('title', 'Reset Password — ' . config('app.name'))
@section('content')
<div class="card card-narrow">
<h1>Reset password</h1>
<p>Set a new password for <strong>{{ $user->name }}</strong> ({{ $user->email }}).</p>
<form method="POST" action="{{ route('admin.users.password.update', $user) }}">
@csrf
@method('PUT')
<div class="field">
<label for="password">New password</label>
<input id="password" type="password" name="password" required autofocus autocomplete="new-password">
@error('password')
<p class="field-error">{{ $message }}</p>
@enderror
</div>
<div class="field">
<label for="password_confirmation">Confirm new password</label>
<input id="password_confirmation" type="password" name="password_confirmation" required autocomplete="new-password">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Reset password</button>
<a class="btn" href="{{ route('admin.users.index') }}">Cancel</a>
</div>
</form>
</div>
@endsection

View File

@ -0,0 +1,38 @@
@extends('layouts.app')
@section('title', 'Log in — ' . config('app.name'))
@section('content')
<div class="card card-narrow">
<h1>Log in</h1>
<form method="POST" action="{{ route('login.attempt') }}">
@csrf
<div class="field">
<label for="email">Email</label>
<input id="email" type="email" name="email" value="{{ old('email') }}" required autofocus autocomplete="username">
@error('email')
<p class="field-error">{{ $message }}</p>
@enderror
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" type="password" name="password" required autocomplete="current-password">
@error('password')
<p class="field-error">{{ $message }}</p>
@enderror
</div>
<div class="field field-checkbox">
<label>
<input type="checkbox" name="remember" value="1">
Remember me
</label>
</div>
<button type="submit" class="btn btn-primary">Log in</button>
</form>
</div>
@endsection

View File

@ -0,0 +1,21 @@
@extends('layouts.app')
@section('title', 'Home — ' . config('app.name'))
@section('content')
<div class="hero">
<h1>Hello World</h1>
<p>Welcome to {{ config('app.name', 'Bowler') }} shared bowler charts for tracking project status and KPIs.</p>
@guest
<p><a class="btn btn-primary" href="{{ route('login') }}">Log in to get started</a></p>
@endguest
@auth
<p>You are logged in as <strong>{{ auth()->user()->name }}</strong>.</p>
@if (auth()->user()->is_admin)
<p><a class="btn btn-primary" href="{{ route('admin.users.index') }}">Manage users</a></p>
@endif
@endauth
</div>
@endsection

View File

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title', config('app.name', 'Bowler'))</title>
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
</head>
<body>
<header class="site-header">
<div class="container header-inner">
<a href="{{ route('home') }}" class="brand">{{ config('app.name', 'Bowler') }}</a>
<nav class="site-nav">
@auth
@if (auth()->user()->is_admin)
<a href="{{ route('admin.users.index') }}">User Management</a>
@endif
<span class="nav-user">{{ auth()->user()->name }}</span>
<form method="POST" action="{{ route('logout') }}" class="inline-form">
@csrf
<button type="submit" class="btn btn-link">Log out</button>
</form>
@else
<a href="{{ route('login') }}">Log in</a>
@endauth
</nav>
</div>
</header>
<main class="container">
@if (session('status'))
<div class="alert alert-success">{{ session('status') }}</div>
@endif
@if ($errors->has('toggle'))
<div class="alert alert-error">{{ $errors->first('toggle') }}</div>
@endif
@yield('content')
</main>
</body>
</html>

3
src/routes/console.php Normal file
View File

@ -0,0 +1,3 @@
<?php
// Console commands can be registered here as the application grows.

27
src/routes/web.php Normal file
View File

@ -0,0 +1,27 @@
<?php
use App\Http\Controllers\AuthController;
use App\Http\Controllers\UserAdminController;
use Illuminate\Support\Facades\Route;
Route::view('/', 'home')->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');
});

3
src/storage/app/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
*
!public/
!.gitignore

2
src/storage/app/public/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

9
src/storage/framework/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
compiled.php
config.php
down
events.scanned.php
maintenance.php
routes.php
routes.scanned.php
schedule-*
services.json

View File

@ -0,0 +1,3 @@
*
!data/
!.gitignore

View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -0,0 +1,2 @@
*
!.gitignore

2
src/storage/logs/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore