first commit
This commit is contained in:
commit
f659fe6b66
|
|
@ -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
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
* text=auto
|
||||
*.sh text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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 "$@"
|
||||
|
|
@ -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.
|
||||
|
|
@ -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('/');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
|
|
@ -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}.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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();
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
];
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Disallow:
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<?php
|
||||
|
||||
// Console commands can be registered here as the application grows.
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
*
|
||||
!public/
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
compiled.php
|
||||
config.php
|
||||
down
|
||||
events.scanned.php
|
||||
maintenance.php
|
||||
routes.php
|
||||
routes.scanned.php
|
||||
schedule-*
|
||||
services.json
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
*
|
||||
!data/
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
Loading…
Reference in New Issue