First Round
This commit is contained in:
parent
f659fe6b66
commit
0bde948081
|
|
@ -1,6 +1,7 @@
|
|||
.env
|
||||
db-data/
|
||||
src/vendor/
|
||||
src/public/css/app.css
|
||||
src/composer.lock
|
||||
src/node_modules/
|
||||
src/storage/app/*
|
||||
|
|
|
|||
19
README.md
19
README.md
|
|
@ -60,6 +60,25 @@ src/ # the Laravel application (bind-mounted into the containe
|
|||
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
|
||||
|
||||
```sh
|
||||
|
|
|
|||
|
|
@ -31,6 +31,19 @@ services:
|
|||
db:
|
||||
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:
|
||||
image: percona:latest
|
||||
container_name: bowler-db
|
||||
|
|
|
|||
|
|
@ -2,11 +2,21 @@ 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 \
|
||||
&& 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 \
|
||||
&& a2enmod rewrite \
|
||||
&& 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)
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
|
||||
ENV COMPOSER_ALLOW_SUPERUSER=1
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ if [ ! -f vendor/autoload.php ]; then
|
|||
composer install --no-interaction --prefer-dist --no-progress
|
||||
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
|
||||
echo "Waiting for database at ${DB_HOST}:${DB_PORT}..."
|
||||
until php -r '
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ class UserAdminController extends Controller
|
|||
{
|
||||
public function index(): View
|
||||
{
|
||||
$users = User::orderBy('name')->get();
|
||||
$users = User::with('groups')->orderBy('name')->get();
|
||||
|
||||
return view('admin.users.index', ['users' => $users]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
|
|
@ -28,6 +29,14 @@ class User extends Authenticatable
|
|||
'remember_token',
|
||||
];
|
||||
|
||||
public function groups(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Group::class)
|
||||
->withPivot('role')
|
||||
->withTimestamps()
|
||||
->orderBy('name');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
|
@ -0,0 +1,4 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/* Scan Blade templates for utility classes */
|
||||
@source "../views";
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 "{{ $group->name }}"? 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
|
||||
|
|
@ -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
|
||||
|
|
@ -3,46 +3,49 @@
|
|||
@section('title', 'New User — ' . config('app.name'))
|
||||
|
||||
@section('content')
|
||||
<div class="card card-narrow">
|
||||
<h1>Create user</h1>
|
||||
<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 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>
|
||||
<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="field-error">{{ $message }}</p>
|
||||
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="email" name="email" value="{{ old('email') }}" required>
|
||||
<div class="mb-4">
|
||||
<label for="email" class="mb-1 block font-semibold">Email</label>
|
||||
<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')
|
||||
<p class="field-error">{{ $message }}</p>
|
||||
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" name="password" required autocomplete="new-password">
|
||||
<div class="mb-4">
|
||||
<label for="password" class="mb-1 block font-semibold">Password</label>
|
||||
<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')
|
||||
<p class="field-error">{{ $message }}</p>
|
||||
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="field field-checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="is_admin" value="1" {{ old('is_admin') ? 'checked' : '' }}>
|
||||
<div class="mb-6">
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" name="is_admin" value="1" {{ old('is_admin') ? 'checked' : '' }} class="rounded border-gray-300">
|
||||
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 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 user</button>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,50 +3,68 @@
|
|||
@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 class="mb-4 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">User Management</h1>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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">Email</th>
|
||||
<th class="px-4 py-3">Role</th>
|
||||
<th class="px-4 py-3">Groups</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Created</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@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>
|
||||
document.querySelectorAll('form.js-confirm').forEach(function (form) {
|
||||
|
|
|
|||
|
|
@ -3,30 +3,32 @@
|
|||
@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>
|
||||
<div class="mx-auto max-w-md rounded-lg border border-gray-200 bg-white p-8">
|
||||
<h1 class="mb-2 text-2xl font-bold">Reset password</h1>
|
||||
<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) }}">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="field">
|
||||
<label for="password">New password</label>
|
||||
<input id="password" type="password" name="password" required autofocus autocomplete="new-password">
|
||||
<div class="mb-4">
|
||||
<label for="password" class="mb-1 block font-semibold">New password</label>
|
||||
<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')
|
||||
<p class="field-error">{{ $message }}</p>
|
||||
<p class="mt-1 text-sm text-red-700">{{ $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 class="mb-6">
|
||||
<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"
|
||||
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 class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Reset password</button>
|
||||
<a class="btn" href="{{ route('admin.users.index') }}">Cancel</a>
|
||||
<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">Reset password</button>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,36 +3,38 @@
|
|||
@section('title', 'Log in — ' . config('app.name'))
|
||||
|
||||
@section('content')
|
||||
<div class="card card-narrow">
|
||||
<h1>Log in</h1>
|
||||
<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">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">
|
||||
<div class="mb-4">
|
||||
<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"
|
||||
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')
|
||||
<p class="field-error">{{ $message }}</p>
|
||||
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" name="password" required autocomplete="current-password">
|
||||
<div class="mb-4">
|
||||
<label for="password" class="mb-1 block font-semibold">Password</label>
|
||||
<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')
|
||||
<p class="field-error">{{ $message }}</p>
|
||||
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="field field-checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="remember" value="1">
|
||||
<div class="mb-6">
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input type="checkbox" name="remember" value="1" class="rounded border-gray-300">
|
||||
Remember me
|
||||
</label>
|
||||
</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>
|
||||
</div>
|
||||
@endsection
|
||||
|
|
|
|||
|
|
@ -3,18 +3,22 @@
|
|||
@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>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-10 text-center">
|
||||
<h1 class="text-4xl font-bold tracking-tight">Hello World</h1>
|
||||
<p class="mt-4 text-gray-600">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>
|
||||
<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
|
||||
|
||||
@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)
|
||||
<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
|
||||
@endauth
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,34 +6,35 @@
|
|||
<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">
|
||||
<body class="min-h-screen bg-gray-50 text-gray-900 antialiased">
|
||||
<header class="border-b border-gray-200 bg-white">
|
||||
<div class="mx-auto flex h-14 max-w-5xl items-center justify-between px-4">
|
||||
<a href="{{ route('home') }}" class="text-lg font-bold text-gray-900">{{ config('app.name', 'Bowler') }}</a>
|
||||
<nav class="flex items-center gap-4">
|
||||
@auth
|
||||
@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
|
||||
<span class="nav-user">{{ auth()->user()->name }}</span>
|
||||
<form method="POST" action="{{ route('logout') }}" class="inline-form">
|
||||
<span class="text-gray-500">{{ auth()->user()->name }}</span>
|
||||
<form method="POST" action="{{ route('logout') }}" class="inline">
|
||||
@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>
|
||||
@else
|
||||
<a href="{{ route('login') }}">Log in</a>
|
||||
<a href="{{ route('login') }}" class="text-blue-600 hover:underline">Log in</a>
|
||||
@endauth
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<main class="mx-auto max-w-5xl px-4 pb-12 pt-6">
|
||||
@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
|
||||
|
||||
@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
|
||||
|
||||
@yield('content')
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\AuthController;
|
||||
use App\Http\Controllers\GroupAdminController;
|
||||
use App\Http\Controllers\UserAdminController;
|
||||
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::put('/users/{user}/password', [UserAdminController::class, 'updatePassword'])->name('users.password.update');
|
||||
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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue