First Round

This commit is contained in:
Brian Fertig 2026-07-09 09:39:26 -06:00
parent f659fe6b66
commit 0bde948081
24 changed files with 651 additions and 350 deletions

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
.env .env
db-data/ db-data/
src/vendor/ src/vendor/
src/public/css/app.css
src/composer.lock src/composer.lock
src/node_modules/ src/node_modules/
src/storage/app/* src/storage/app/*

View File

@ -60,6 +60,25 @@ src/ # the Laravel application (bind-mounted into the containe
db-data/ # Percona data files (created on first run) db-data/ # Percona data files (created on first run)
``` ```
## Styling (Tailwind CSS)
The UI is styled with [Tailwind CSS](https://tailwindcss.com) v4 using the **standalone CLI**
baked into the app image — no Node.js/npm required anywhere. The source file is
`src/resources/css/app.css`; the compiled stylesheet `src/public/css/app.css` is generated
automatically on every container start.
While editing Blade views, run the optional watcher so CSS rebuilds live:
```sh
docker compose --profile dev up -d # starts the app + a tailwind --watch container
```
Or rebuild once by hand:
```sh
docker compose exec app tailwindcss -i resources/css/app.css -o public/css/app.css --minify
```
## Common operations ## Common operations
```sh ```sh

View File

@ -31,6 +31,19 @@ services:
db: db:
condition: service_healthy condition: service_healthy
# Optional dev helper: rebuilds Tailwind CSS as views change.
# Start with: docker compose --profile dev up -d
tailwind:
build:
context: ./docker/app
container_name: bowler-tailwind
profiles: ["dev"]
working_dir: /var/www/html
volumes:
- ./src:/var/www/html
entrypoint: ["tailwindcss"]
command: ["-i", "resources/css/app.css", "-o", "public/css/app.css", "--watch=always"]
db: db:
image: percona:latest image: percona:latest
container_name: bowler-db container_name: bowler-db

View File

@ -2,11 +2,21 @@ FROM php:8.4-apache
# System packages + PHP extensions Laravel needs # System packages + PHP extensions Laravel needs
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends git unzip libzip-dev libicu-dev \ && apt-get install -y --no-install-recommends git unzip curl ca-certificates libzip-dev libicu-dev \
&& docker-php-ext-install pdo_mysql zip intl opcache \ && docker-php-ext-install pdo_mysql zip intl opcache \
&& a2enmod rewrite \ && a2enmod rewrite \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Tailwind CSS standalone CLI (no Node.js required)
ARG TARGETARCH=amd64
RUN case "$TARGETARCH" in \
arm64) TW_ARCH=arm64 ;; \
*) TW_ARCH=x64 ;; \
esac \
&& curl -fsSL -o /usr/local/bin/tailwindcss \
"https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-${TW_ARCH}" \
&& chmod +x /usr/local/bin/tailwindcss
# Composer (used by the entrypoint to install dependencies on first boot) # Composer (used by the entrypoint to install dependencies on first boot)
COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
ENV COMPOSER_ALLOW_SUPERUSER=1 ENV COMPOSER_ALLOW_SUPERUSER=1

View File

@ -9,6 +9,12 @@ if [ ! -f vendor/autoload.php ]; then
composer install --no-interaction --prefer-dist --no-progress composer install --no-interaction --prefer-dist --no-progress
fi fi
# Compile Tailwind CSS
if [ -f resources/css/app.css ]; then
echo "Building Tailwind CSS..."
tailwindcss -i resources/css/app.css -o public/css/app.css --minify
fi
# Wait for the database to accept connections # Wait for the database to accept connections
echo "Waiting for database at ${DB_HOST}:${DB_PORT}..." echo "Waiting for database at ${DB_HOST}:${DB_PORT}..."
until php -r ' until php -r '

View File

@ -0,0 +1,119 @@
<?php
namespace App\Http\Controllers;
use App\Models\Group;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class GroupAdminController extends Controller
{
public function index(): View
{
$groups = Group::withCount('users')->orderBy('name')->get();
return view('admin.groups.index', ['groups' => $groups]);
}
public function create(): View
{
return view('admin.groups.create');
}
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255', 'unique:groups,name'],
'description' => ['nullable', 'string', 'max:500'],
]);
Group::create($data);
return redirect()
->route('admin.groups.index')
->with('status', "Group \"{$data['name']}\" created.");
}
public function edit(Group $group): View
{
return view('admin.groups.edit', ['group' => $group]);
}
public function update(Request $request, Group $group): RedirectResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255', 'unique:groups,name,' . $group->id],
'description' => ['nullable', 'string', 'max:500'],
]);
$group->update($data);
return redirect()
->route('admin.groups.index')
->with('status', "Group \"{$group->name}\" updated.");
}
public function destroy(Group $group): RedirectResponse
{
$name = $group->name;
$group->delete();
return redirect()
->route('admin.groups.index')
->with('status', "Group \"{$name}\" deleted.");
}
public function members(Group $group): View
{
$members = $group->users()->get();
$memberIds = $members->pluck('id');
$available = User::whereNotIn('id', $memberIds)->orderBy('name')->get();
return view('admin.groups.members', [
'group' => $group,
'members' => $members,
'available' => $available,
]);
}
public function addMember(Request $request, Group $group): RedirectResponse
{
$data = $request->validate([
'user_id' => ['required', 'integer', 'exists:users,id'],
'role' => ['required', 'in:member,owner'],
]);
// Sync without detaching so existing members are preserved
if (! $group->users()->where('user_id', $data['user_id'])->exists()) {
$group->users()->attach($data['user_id'], ['role' => $data['role']]);
}
return redirect()
->route('admin.groups.members', $group)
->with('status', 'Member added.');
}
public function updateMemberRole(Request $request, Group $group, User $user): RedirectResponse
{
$data = $request->validate([
'role' => ['required', 'in:member,owner'],
]);
$group->users()->updateExistingPivot($user->id, ['role' => $data['role']]);
return redirect()
->route('admin.groups.members', $group)
->with('status', "Role for {$user->name} updated.");
}
public function removeMember(Group $group, User $user): RedirectResponse
{
$group->users()->detach($user->id);
return redirect()
->route('admin.groups.members', $group)
->with('status', "{$user->name} removed from group.");
}
}

View File

@ -12,7 +12,7 @@ class UserAdminController extends Controller
{ {
public function index(): View public function index(): View
{ {
$users = User::orderBy('name')->get(); $users = User::with('groups')->orderBy('name')->get();
return view('admin.users.index', ['users' => $users]); return view('admin.users.index', ['users' => $users]);
} }

19
src/app/Models/Group.php Normal file
View File

@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Group extends Model
{
protected $fillable = ['name', 'description'];
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class)
->withPivot('role')
->withTimestamps()
->orderBy('name');
}
}

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
@ -28,6 +29,14 @@ class User extends Authenticatable
'remember_token', 'remember_token',
]; ];
public function groups(): BelongsToMany
{
return $this->belongsToMany(Group::class)
->withPivot('role')
->withTimestamps()
->orderBy('name');
}
/** /**
* @return array<string, string> * @return array<string, string>
*/ */

View File

@ -0,0 +1,31 @@
<?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('groups', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->string('description')->nullable();
$table->timestamps();
});
Schema::create('group_user', function (Blueprint $table) {
$table->foreignId('group_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->enum('role', ['member', 'owner'])->default('member');
$table->primary(['group_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('group_user');
Schema::dropIfExists('groups');
}
};

View File

@ -0,0 +1,22 @@
<?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::table('group_user', function (Blueprint $table) {
$table->timestamps();
});
}
public function down(): void
{
Schema::table('group_user', function (Blueprint $table) {
$table->dropTimestamps();
});
}
};

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,4 @@
@import "tailwindcss";
/* Scan Blade templates for utility classes */
@source "../views";

View File

@ -0,0 +1,36 @@
@extends('layouts.app')
@section('title', 'New Group — ' . config('app.name'))
@section('content')
<div class="mx-auto max-w-md rounded-lg border border-gray-200 bg-white p-8">
<h1 class="mb-6 text-2xl font-bold">Create group</h1>
<form method="POST" action="{{ route('admin.groups.store') }}">
@csrf
<div class="mb-4">
<label for="name" class="mb-1 block font-semibold">Name</label>
<input id="name" type="text" name="name" value="{{ old('name') }}" required autofocus
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="mb-6">
<label for="description" class="mb-1 block font-semibold">Description <span class="font-normal text-gray-500">(optional)</span></label>
<textarea id="description" name="description" rows="3"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">{{ old('description') }}</textarea>
@error('description')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Create group</button>
<a href="{{ route('admin.groups.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>
</div>
@endsection

View File

@ -0,0 +1,37 @@
@extends('layouts.app')
@section('title', 'Edit Group — ' . config('app.name'))
@section('content')
<div class="mx-auto max-w-md rounded-lg border border-gray-200 bg-white p-8">
<h1 class="mb-6 text-2xl font-bold">Edit group</h1>
<form method="POST" action="{{ route('admin.groups.update', $group) }}">
@csrf
@method('PUT')
<div class="mb-4">
<label for="name" class="mb-1 block font-semibold">Name</label>
<input id="name" type="text" name="name" value="{{ old('name', $group->name) }}" required autofocus
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="mb-6">
<label for="description" class="mb-1 block font-semibold">Description <span class="font-normal text-gray-500">(optional)</span></label>
<textarea id="description" name="description" rows="3"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">{{ old('description', $group->description) }}</textarea>
@error('description')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Save changes</button>
<a href="{{ route('admin.groups.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>
</div>
@endsection

View File

@ -0,0 +1,67 @@
@extends('layouts.app')
@section('title', 'Groups — ' . config('app.name'))
@section('content')
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">Groups</h1>
<a href="{{ route('admin.groups.create') }}" class="rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">New group</a>
</div>
@if ($groups->isEmpty())
<div class="rounded-lg border border-gray-200 bg-white px-6 py-10 text-center text-gray-500">
No groups yet. <a href="{{ route('admin.groups.create') }}" class="text-blue-600 hover:underline">Create the first one.</a>
</div>
@else
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table class="w-full text-left">
<thead>
<tr class="bg-gray-50 text-xs font-semibold uppercase tracking-wide text-gray-500">
<th class="px-4 py-3">Name</th>
<th class="px-4 py-3">Description</th>
<th class="px-4 py-3">Members</th>
<th class="px-4 py-3">Created</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach ($groups as $group)
<tr>
<td class="px-4 py-3 font-medium">{{ $group->name }}</td>
<td class="px-4 py-3 text-gray-600">{{ $group->description ?? '—' }}</td>
<td class="px-4 py-3">
<a href="{{ route('admin.groups.members', $group) }}" class="text-blue-600 hover:underline">
{{ $group->users_count }} {{ Str::plural('member', $group->users_count) }}
</a>
</td>
<td class="px-4 py-3">{{ $group->created_at?->format('Y-m-d') }}</td>
<td class="whitespace-nowrap px-4 py-3">
<a href="{{ route('admin.groups.members', $group) }}"
class="mr-1 inline-block rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm text-gray-900 hover:bg-gray-50">Members</a>
<a href="{{ route('admin.groups.edit', $group) }}"
class="mr-1 inline-block rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm text-gray-900 hover:bg-gray-50">Edit</a>
<form method="POST" action="{{ route('admin.groups.destroy', $group) }}" class="js-confirm inline"
data-confirm="Delete group &quot;{{ $group->name }}&quot;? This cannot be undone.">
@csrf
@method('DELETE')
<button type="submit"
class="cursor-pointer rounded-md border border-red-600 px-2.5 py-1 text-sm text-red-700 hover:bg-red-50">Delete</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
<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,105 @@
@extends('layouts.app')
@section('title', $group->name . ' Members — ' . config('app.name'))
@section('content')
<div class="mb-1 flex items-center gap-2 text-sm text-gray-500">
<a href="{{ route('admin.groups.index') }}" class="hover:underline">Groups</a>
<span>/</span>
<span class="font-medium text-gray-900">{{ $group->name }}</span>
</div>
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">{{ $group->name }} Members</h1>
<a href="{{ route('admin.groups.edit', $group) }}"
class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-900 hover:bg-gray-50">Edit group</a>
</div>
<div class="grid gap-6 lg:grid-cols-2">
{{-- Current members --}}
<div>
<h2 class="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-500">Current members ({{ $members->count() }})</h2>
<div class="rounded-lg border border-gray-200 bg-white">
@if ($members->isEmpty())
<p class="px-4 py-6 text-center text-gray-500">No members yet.</p>
@else
<ul class="divide-y divide-gray-200">
@foreach ($members as $member)
<li class="flex items-center justify-between gap-2 px-4 py-3">
<div>
<span class="font-medium">{{ $member->name }}</span>
<span class="ml-2 text-sm text-gray-500">{{ $member->email }}</span>
</div>
<div class="flex items-center gap-2">
{{-- Role toggle --}}
<form method="POST" action="{{ route('admin.groups.members.role', [$group, $member]) }}">
@csrf
@method('PUT')
<select name="role" onchange="this.form.submit()"
class="cursor-pointer rounded border border-gray-300 px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/40">
<option value="member" {{ $member->pivot->role === 'member' ? 'selected' : '' }}>Member</option>
<option value="owner" {{ $member->pivot->role === 'owner' ? 'selected' : '' }}>Owner</option>
</select>
</form>
{{-- Remove --}}
<form method="POST" action="{{ route('admin.groups.members.remove', [$group, $member]) }}" class="js-confirm"
data-confirm="Remove {{ $member->name }} from this group?">
@csrf
@method('DELETE')
<button type="submit"
class="cursor-pointer rounded border border-red-600 px-2 py-1 text-xs text-red-700 hover:bg-red-50">Remove</button>
</form>
</div>
</li>
@endforeach
</ul>
@endif
</div>
</div>
{{-- Add member --}}
<div>
<h2 class="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-500">Add member</h2>
<div class="rounded-lg border border-gray-200 bg-white p-4">
@if ($available->isEmpty())
<p class="text-center text-gray-500">All users are already in this group.</p>
@else
<form method="POST" action="{{ route('admin.groups.members.add', $group) }}">
@csrf
<div class="mb-3">
<label for="user_id" class="mb-1 block font-semibold">User</label>
<select id="user_id" name="user_id" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
<option value="">Select a user…</option>
@foreach ($available as $user)
<option value="{{ $user->id }}">{{ $user->name }} {{ $user->email }}</option>
@endforeach
</select>
@error('user_id')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="mb-4">
<label for="role" class="mb-1 block font-semibold">Role</label>
<select id="role" name="role"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
<option value="member">Member</option>
<option value="owner">Owner</option>
</select>
</div>
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Add to group</button>
</form>
@endif
</div>
</div>
</div>
<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

@ -3,46 +3,49 @@
@section('title', 'New User — ' . config('app.name')) @section('title', 'New User — ' . config('app.name'))
@section('content') @section('content')
<div class="card card-narrow"> <div class="mx-auto max-w-md rounded-lg border border-gray-200 bg-white p-8">
<h1>Create user</h1> <h1 class="mb-6 text-2xl font-bold">Create user</h1>
<form method="POST" action="{{ route('admin.users.store') }}"> <form method="POST" action="{{ route('admin.users.store') }}">
@csrf @csrf
<div class="field"> <div class="mb-4">
<label for="name">Name</label> <label for="name" class="mb-1 block font-semibold">Name</label>
<input id="name" type="text" name="name" value="{{ old('name') }}" required autofocus> <input id="name" type="text" name="name" value="{{ old('name') }}" required autofocus
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('name') @error('name')
<p class="field-error">{{ $message }}</p> <p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror @enderror
</div> </div>
<div class="field"> <div class="mb-4">
<label for="email">Email</label> <label for="email" class="mb-1 block font-semibold">Email</label>
<input id="email" type="email" name="email" value="{{ old('email') }}" required> <input id="email" type="email" name="email" value="{{ old('email') }}" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('email') @error('email')
<p class="field-error">{{ $message }}</p> <p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror @enderror
</div> </div>
<div class="field"> <div class="mb-4">
<label for="password">Password</label> <label for="password" class="mb-1 block font-semibold">Password</label>
<input id="password" type="password" name="password" required autocomplete="new-password"> <input id="password" type="password" name="password" required autocomplete="new-password"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('password') @error('password')
<p class="field-error">{{ $message }}</p> <p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror @enderror
</div> </div>
<div class="field field-checkbox"> <div class="mb-6">
<label> <label class="inline-flex items-center gap-2">
<input type="checkbox" name="is_admin" value="1" {{ old('is_admin') ? 'checked' : '' }}> <input type="checkbox" name="is_admin" value="1" {{ old('is_admin') ? 'checked' : '' }} class="rounded border-gray-300">
Administrator (can manage users) Administrator (can manage users)
</label> </label>
</div> </div>
<div class="form-actions"> <div class="flex gap-2">
<button type="submit" class="btn btn-primary">Create user</button> <button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Create user</button>
<a class="btn" href="{{ route('admin.users.index') }}">Cancel</a> <a href="{{ route('admin.users.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div> </div>
</form> </form>
</div> </div>

View File

@ -3,50 +3,68 @@
@section('title', 'User Management — ' . config('app.name')) @section('title', 'User Management — ' . config('app.name'))
@section('content') @section('content')
<div class="page-head"> <div class="mb-4 flex items-center justify-between">
<h1>User Management</h1> <h1 class="text-2xl font-bold">User Management</h1>
<a class="btn btn-primary" href="{{ route('admin.users.create') }}">New user</a> <a href="{{ route('admin.users.create') }}" class="rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">New user</a>
</div> </div>
<table class="data-table"> <div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<thead> <table class="w-full text-left">
<tr> <thead>
<th>Name</th> <tr class="bg-gray-50 text-xs font-semibold uppercase tracking-wide text-gray-500">
<th>Email</th> <th class="px-4 py-3">Name</th>
<th>Role</th> <th class="px-4 py-3">Email</th>
<th>Status</th> <th class="px-4 py-3">Role</th>
<th>Created</th> <th class="px-4 py-3">Groups</th>
<th>Actions</th> <th class="px-4 py-3">Status</th>
</tr> <th class="px-4 py-3">Created</th>
</thead> <th class="px-4 py-3">Actions</th>
<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> </tr>
@endforeach </thead>
</tbody> <tbody class="divide-y divide-gray-200">
</table> @foreach ($users as $user)
<tr class="{{ $user->is_active ? '' : 'text-gray-400' }}">
<td class="px-4 py-3">
{{ $user->name }}
@if ($user->id === auth()->id())
<span class="ml-1 rounded border border-gray-300 bg-gray-50 px-1.5 text-xs text-gray-500">you</span>
@endif
</td>
<td class="px-4 py-3">{{ $user->email }}</td>
<td class="px-4 py-3">{{ $user->is_admin ? 'Administrator' : 'User' }}</td>
<td class="px-4 py-3">
@forelse ($user->groups as $group)
<a href="{{ route('admin.groups.members', $group) }}"
class="mr-1 inline-block rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-700 hover:bg-blue-100">{{ $group->name }}</a>
@empty
<span class="text-gray-400"></span>
@endforelse
</td>
<td class="px-4 py-3">
<span class="inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold {{ $user->is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' }}">
{{ $user->is_active ? 'Active' : 'Disabled' }}
</span>
</td>
<td class="px-4 py-3">{{ $user->created_at?->format('Y-m-d') }}</td>
<td class="whitespace-nowrap px-4 py-3">
<a href="{{ route('admin.users.password.edit', $user) }}"
class="mr-1 inline-block rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm text-gray-900 hover:bg-gray-50">Reset password</a>
@if ($user->id !== auth()->id())
<form method="POST" action="{{ route('admin.users.toggle', $user) }}" class="js-confirm inline"
data-confirm="{{ $user->is_active ? 'Disable' : 'Enable' }} {{ $user->name }}?">
@csrf
<button type="submit"
class="cursor-pointer rounded-md border px-2.5 py-1 text-sm {{ $user->is_active ? 'border-red-600 text-red-700 hover:bg-red-50' : 'border-gray-300 bg-white text-gray-900 hover:bg-gray-50' }}">
{{ $user->is_active ? 'Disable' : 'Enable' }}
</button>
</form>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<script> <script>
document.querySelectorAll('form.js-confirm').forEach(function (form) { document.querySelectorAll('form.js-confirm').forEach(function (form) {

View File

@ -3,30 +3,32 @@
@section('title', 'Reset Password — ' . config('app.name')) @section('title', 'Reset Password — ' . config('app.name'))
@section('content') @section('content')
<div class="card card-narrow"> <div class="mx-auto max-w-md rounded-lg border border-gray-200 bg-white p-8">
<h1>Reset password</h1> <h1 class="mb-2 text-2xl font-bold">Reset password</h1>
<p>Set a new password for <strong>{{ $user->name }}</strong> ({{ $user->email }}).</p> <p class="mb-6 text-gray-600">Set a new password for <strong>{{ $user->name }}</strong> ({{ $user->email }}).</p>
<form method="POST" action="{{ route('admin.users.password.update', $user) }}"> <form method="POST" action="{{ route('admin.users.password.update', $user) }}">
@csrf @csrf
@method('PUT') @method('PUT')
<div class="field"> <div class="mb-4">
<label for="password">New password</label> <label for="password" class="mb-1 block font-semibold">New password</label>
<input id="password" type="password" name="password" required autofocus autocomplete="new-password"> <input id="password" type="password" name="password" required autofocus autocomplete="new-password"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('password') @error('password')
<p class="field-error">{{ $message }}</p> <p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror @enderror
</div> </div>
<div class="field"> <div class="mb-6">
<label for="password_confirmation">Confirm new password</label> <label for="password_confirmation" class="mb-1 block font-semibold">Confirm new password</label>
<input id="password_confirmation" type="password" name="password_confirmation" required autocomplete="new-password"> <input id="password_confirmation" type="password" name="password_confirmation" required autocomplete="new-password"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
</div> </div>
<div class="form-actions"> <div class="flex gap-2">
<button type="submit" class="btn btn-primary">Reset password</button> <button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Reset password</button>
<a class="btn" href="{{ route('admin.users.index') }}">Cancel</a> <a href="{{ route('admin.users.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div> </div>
</form> </form>
</div> </div>

View File

@ -3,36 +3,38 @@
@section('title', 'Log in — ' . config('app.name')) @section('title', 'Log in — ' . config('app.name'))
@section('content') @section('content')
<div class="card card-narrow"> <div class="mx-auto max-w-md rounded-lg border border-gray-200 bg-white p-8">
<h1>Log in</h1> <h1 class="mb-6 text-2xl font-bold">Log in</h1>
<form method="POST" action="{{ route('login.attempt') }}"> <form method="POST" action="{{ route('login.attempt') }}">
@csrf @csrf
<div class="field"> <div class="mb-4">
<label for="email">Email</label> <label for="email" class="mb-1 block font-semibold">Email</label>
<input id="email" type="email" name="email" value="{{ old('email') }}" required autofocus autocomplete="username"> <input id="email" type="email" name="email" value="{{ old('email') }}" required autofocus autocomplete="username"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('email') @error('email')
<p class="field-error">{{ $message }}</p> <p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror @enderror
</div> </div>
<div class="field"> <div class="mb-4">
<label for="password">Password</label> <label for="password" class="mb-1 block font-semibold">Password</label>
<input id="password" type="password" name="password" required autocomplete="current-password"> <input id="password" type="password" name="password" required autocomplete="current-password"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
@error('password') @error('password')
<p class="field-error">{{ $message }}</p> <p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror @enderror
</div> </div>
<div class="field field-checkbox"> <div class="mb-6">
<label> <label class="inline-flex items-center gap-2">
<input type="checkbox" name="remember" value="1"> <input type="checkbox" name="remember" value="1" class="rounded border-gray-300">
Remember me Remember me
</label> </label>
</div> </div>
<button type="submit" class="btn btn-primary">Log in</button> <button type="submit" class="w-full cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Log in</button>
</form> </form>
</div> </div>
@endsection @endsection

View File

@ -3,18 +3,22 @@
@section('title', 'Home — ' . config('app.name')) @section('title', 'Home — ' . config('app.name'))
@section('content') @section('content')
<div class="hero"> <div class="rounded-lg border border-gray-200 bg-white p-10 text-center">
<h1>Hello World</h1> <h1 class="text-4xl font-bold tracking-tight">Hello World</h1>
<p>Welcome to {{ config('app.name', 'Bowler') }} shared bowler charts for tracking project status and KPIs.</p> <p class="mt-4 text-gray-600">Welcome to {{ config('app.name', 'Bowler') }} shared bowler charts for tracking project status and KPIs.</p>
@guest @guest
<p><a class="btn btn-primary" href="{{ route('login') }}">Log in to get started</a></p> <p class="mt-6">
<a href="{{ route('login') }}" class="inline-block rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Log in to get started</a>
</p>
@endguest @endguest
@auth @auth
<p>You are logged in as <strong>{{ auth()->user()->name }}</strong>.</p> <p class="mt-6">You are logged in as <strong>{{ auth()->user()->name }}</strong>.</p>
@if (auth()->user()->is_admin) @if (auth()->user()->is_admin)
<p><a class="btn btn-primary" href="{{ route('admin.users.index') }}">Manage users</a></p> <p class="mt-4">
<a href="{{ route('admin.users.index') }}" class="inline-block rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Manage users</a>
</p>
@endif @endif
@endauth @endauth
</div> </div>

View File

@ -6,34 +6,35 @@
<title>@yield('title', config('app.name', 'Bowler'))</title> <title>@yield('title', config('app.name', 'Bowler'))</title>
<link rel="stylesheet" href="{{ asset('css/app.css') }}"> <link rel="stylesheet" href="{{ asset('css/app.css') }}">
</head> </head>
<body> <body class="min-h-screen bg-gray-50 text-gray-900 antialiased">
<header class="site-header"> <header class="border-b border-gray-200 bg-white">
<div class="container header-inner"> <div class="mx-auto flex h-14 max-w-5xl items-center justify-between px-4">
<a href="{{ route('home') }}" class="brand">{{ config('app.name', 'Bowler') }}</a> <a href="{{ route('home') }}" class="text-lg font-bold text-gray-900">{{ config('app.name', 'Bowler') }}</a>
<nav class="site-nav"> <nav class="flex items-center gap-4">
@auth @auth
@if (auth()->user()->is_admin) @if (auth()->user()->is_admin)
<a href="{{ route('admin.users.index') }}">User Management</a> <a href="{{ route('admin.users.index') }}" class="text-blue-600 hover:underline">Users</a>
<a href="{{ route('admin.groups.index') }}" class="text-blue-600 hover:underline">Groups</a>
@endif @endif
<span class="nav-user">{{ auth()->user()->name }}</span> <span class="text-gray-500">{{ auth()->user()->name }}</span>
<form method="POST" action="{{ route('logout') }}" class="inline-form"> <form method="POST" action="{{ route('logout') }}" class="inline">
@csrf @csrf
<button type="submit" class="btn btn-link">Log out</button> <button type="submit" class="cursor-pointer text-blue-600 hover:underline">Log out</button>
</form> </form>
@else @else
<a href="{{ route('login') }}">Log in</a> <a href="{{ route('login') }}" class="text-blue-600 hover:underline">Log in</a>
@endauth @endauth
</nav> </nav>
</div> </div>
</header> </header>
<main class="container"> <main class="mx-auto max-w-5xl px-4 pb-12 pt-6">
@if (session('status')) @if (session('status'))
<div class="alert alert-success">{{ session('status') }}</div> <div class="mb-4 rounded-md bg-green-50 px-4 py-3 text-green-800">{{ session('status') }}</div>
@endif @endif
@if ($errors->has('toggle')) @if ($errors->has('toggle'))
<div class="alert alert-error">{{ $errors->first('toggle') }}</div> <div class="mb-4 rounded-md bg-red-50 px-4 py-3 text-red-800">{{ $errors->first('toggle') }}</div>
@endif @endif
@yield('content') @yield('content')

View File

@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\AuthController; use App\Http\Controllers\AuthController;
use App\Http\Controllers\GroupAdminController;
use App\Http\Controllers\UserAdminController; use App\Http\Controllers\UserAdminController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -24,4 +25,15 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun
Route::get('/users/{user}/password', [UserAdminController::class, 'editPassword'])->name('users.password.edit'); 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::put('/users/{user}/password', [UserAdminController::class, 'updatePassword'])->name('users.password.update');
Route::post('/users/{user}/toggle-active', [UserAdminController::class, 'toggleActive'])->name('users.toggle'); Route::post('/users/{user}/toggle-active', [UserAdminController::class, 'toggleActive'])->name('users.toggle');
Route::get('/groups', [GroupAdminController::class, 'index'])->name('groups.index');
Route::get('/groups/create', [GroupAdminController::class, 'create'])->name('groups.create');
Route::post('/groups', [GroupAdminController::class, 'store'])->name('groups.store');
Route::get('/groups/{group}/edit', [GroupAdminController::class, 'edit'])->name('groups.edit');
Route::put('/groups/{group}', [GroupAdminController::class, 'update'])->name('groups.update');
Route::delete('/groups/{group}', [GroupAdminController::class, 'destroy'])->name('groups.destroy');
Route::get('/groups/{group}/members', [GroupAdminController::class, 'members'])->name('groups.members');
Route::post('/groups/{group}/members', [GroupAdminController::class, 'addMember'])->name('groups.members.add');
Route::put('/groups/{group}/members/{user}/role', [GroupAdminController::class, 'updateMemberRole'])->name('groups.members.role');
Route::delete('/groups/{group}/members/{user}', [GroupAdminController::class, 'removeMember'])->name('groups.members.remove');
}); });