Admin can now control channel creation from the admin dashboard
This commit is contained in:
73
app/Http/Controllers/AdminChannelCreationController.php
Normal file
73
app/Http/Controllers/AdminChannelCreationController.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\AdminAuditLog;
|
||||||
|
use App\Models\PlatformSetting;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AdminChannelCreationController extends Controller
|
||||||
|
{
|
||||||
|
public function updateDefault(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'channel_creation_open' => ['required', 'boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$settings = PlatformSetting::current();
|
||||||
|
|
||||||
|
$settings->forceFill([
|
||||||
|
'channel_creation_open' => (bool) $validated['channel_creation_open'],
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$this->audit($request, $settings, 'platform.channel_creation_default.updated', [
|
||||||
|
'channel_creation_open' => $settings->channel_creation_open,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with(
|
||||||
|
'success',
|
||||||
|
$settings->channel_creation_open
|
||||||
|
? 'All verified users can create channels.'
|
||||||
|
: 'Channel creation now requires admin approval.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateUser(Request $request, User $user): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'can_create_channel' => ['required', 'boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->forceFill([
|
||||||
|
'can_create_channel' => (bool) $validated['can_create_channel'],
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$this->audit($request, $user, 'user.creator_access.updated', [
|
||||||
|
'can_create_channel' => $user->can_create_channel,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with(
|
||||||
|
'success',
|
||||||
|
$user->can_create_channel
|
||||||
|
? 'User can create a channel.'
|
||||||
|
: 'User channel creation access removed.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $metadata
|
||||||
|
*/
|
||||||
|
private function audit(Request $request, object $subject, string $action, array $metadata = []): void
|
||||||
|
{
|
||||||
|
AdminAuditLog::query()->create([
|
||||||
|
'admin_user_id' => $request->user()?->id,
|
||||||
|
'subject_type' => $subject::class,
|
||||||
|
'subject_id' => $subject->id,
|
||||||
|
'action' => $action,
|
||||||
|
'metadata' => $metadata === [] ? null : $metadata,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Models\Broadcast;
|
use App\Models\Broadcast;
|
||||||
use App\Models\Channel;
|
use App\Models\Channel;
|
||||||
|
use App\Models\PlatformSetting;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Vod;
|
use App\Models\Vod;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
@@ -20,6 +21,10 @@ class AdminDashboardController extends Controller
|
|||||||
'channels' => Channel::query()->count(),
|
'channels' => Channel::query()->count(),
|
||||||
'live_channels' => Channel::query()->where('is_live', true)->count(),
|
'live_channels' => Channel::query()->where('is_live', true)->count(),
|
||||||
'vods' => Vod::query()->count(),
|
'vods' => Vod::query()->count(),
|
||||||
|
'creator_access_grants' => User::query()->where('can_create_channel', true)->count(),
|
||||||
|
],
|
||||||
|
'channelCreationPolicy' => [
|
||||||
|
'channel_creation_open' => PlatformSetting::current()->channel_creation_open,
|
||||||
],
|
],
|
||||||
'liveBroadcasts' => Broadcast::query()
|
'liveBroadcasts' => Broadcast::query()
|
||||||
->where('status', Broadcast::STATUS_LIVE)
|
->where('status', Broadcast::STATUS_LIVE)
|
||||||
@@ -57,6 +62,25 @@ class AdminDashboardController extends Controller
|
|||||||
'name' => $channel->user->name,
|
'name' => $channel->user->name,
|
||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
|
'users' => User::query()
|
||||||
|
->with('channel')
|
||||||
|
->latest('id')
|
||||||
|
->limit(50)
|
||||||
|
->get()
|
||||||
|
->map(fn (User $user) => [
|
||||||
|
'id' => $user->id,
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
'is_admin' => $user->is_admin,
|
||||||
|
'can_create_channel' => $user->can_create_channel,
|
||||||
|
'suspended_at' => $user->suspended_at?->toIso8601String(),
|
||||||
|
'created_at' => $user->created_at?->toIso8601String(),
|
||||||
|
'channel' => $user->channel ? [
|
||||||
|
'id' => $user->channel->id,
|
||||||
|
'slug' => $user->channel->slug,
|
||||||
|
'display_name' => $user->channel->display_name,
|
||||||
|
] : null,
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class CreatorDashboardController extends Controller
|
|||||||
|
|
||||||
return Inertia::render('dashboard', [
|
return Inertia::render('dashboard', [
|
||||||
'channel' => $channel ? $this->channelPayload($channel) : null,
|
'channel' => $channel ? $this->channelPayload($channel) : null,
|
||||||
|
'canCreateChannel' => $request->user()->canCreateChannel(),
|
||||||
'plainStreamKey' => session('plain_stream_key'),
|
'plainStreamKey' => session('plain_stream_key'),
|
||||||
'streaming' => [
|
'streaming' => [
|
||||||
'ingestServer' => $streamKeys->ingestServer(),
|
'ingestServer' => $streamKeys->ingestServer(),
|
||||||
@@ -57,6 +58,7 @@ class CreatorDashboardController extends Controller
|
|||||||
public function storeChannel(Request $request, StreamKeyManager $streamKeys): RedirectResponse
|
public function storeChannel(Request $request, StreamKeyManager $streamKeys): RedirectResponse
|
||||||
{
|
{
|
||||||
abort_if($request->user()->channel()->exists(), 422, 'This account already owns a channel.');
|
abort_if($request->user()->channel()->exists(), 422, 'This account already owns a channel.');
|
||||||
|
abort_unless($request->user()->canCreateChannel(), 403, 'An admin must enable channel creation for this account.');
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'display_name' => ['required', 'string', 'max:80'],
|
'display_name' => ['required', 'string', 'max:80'],
|
||||||
|
|||||||
26
app/Models/PlatformSetting.php
Normal file
26
app/Models/PlatformSetting.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable(['channel_creation_open'])]
|
||||||
|
class PlatformSetting extends Model
|
||||||
|
{
|
||||||
|
protected $attributes = [
|
||||||
|
'channel_creation_open' => false,
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function current(): self
|
||||||
|
{
|
||||||
|
return static::query()->first() ?? static::query()->create();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'channel_creation_open' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,13 +13,17 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
|
|||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
|
|
||||||
#[Fillable(['name', 'email', 'password', 'is_admin', 'suspended_at'])]
|
#[Fillable(['name', 'email', 'password', 'is_admin', 'can_create_channel', 'suspended_at'])]
|
||||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasFactory, Notifiable, TwoFactorAuthenticatable;
|
use HasFactory, Notifiable, TwoFactorAuthenticatable;
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'can_create_channel' => false,
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the attributes that should be cast.
|
* Get the attributes that should be cast.
|
||||||
*
|
*
|
||||||
@@ -32,6 +36,7 @@ class User extends Authenticatable
|
|||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
'two_factor_confirmed_at' => 'datetime',
|
'two_factor_confirmed_at' => 'datetime',
|
||||||
'is_admin' => 'boolean',
|
'is_admin' => 'boolean',
|
||||||
|
'can_create_channel' => 'boolean',
|
||||||
'suspended_at' => 'datetime',
|
'suspended_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -50,4 +55,9 @@ class User extends Authenticatable
|
|||||||
{
|
{
|
||||||
return $this->suspended_at !== null;
|
return $this->suspended_at !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function canCreateChannel(): bool
|
||||||
|
{
|
||||||
|
return $this->can_create_channel || PlatformSetting::current()->channel_creation_open;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('users', function (Blueprint $table) {
|
||||||
|
$table->boolean('can_create_channel')->default(false)->index()->after('is_admin');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('can_create_channel');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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::create('platform_settings', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->boolean('channel_creation_open')->default(false);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('platform_settings');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,7 +3,9 @@
|
|||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
|
use App\Models\PlatformSetting;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Streaming\StreamKeyManager;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
class DatabaseSeeder extends Seeder
|
class DatabaseSeeder extends Seeder
|
||||||
@@ -24,15 +26,35 @@ class DatabaseSeeder extends Seeder
|
|||||||
['name' => $category['name']],
|
['name' => $category['name']],
|
||||||
));
|
));
|
||||||
|
|
||||||
User::factory()->create([
|
PlatformSetting::current();
|
||||||
'name' => 'Test User',
|
|
||||||
'email' => 'test@example.com',
|
$admin = User::query()->firstOrNew(['email' => 'admin@nyone.net']);
|
||||||
|
$admin->forceFill([
|
||||||
|
'name' => 'Admin User',
|
||||||
|
'password' => 'password',
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'is_admin' => true,
|
||||||
|
'can_create_channel' => false,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$official = User::query()->firstOrNew(['email' => 'chief@nyone.net']);
|
||||||
|
$official->forceFill([
|
||||||
|
'name' => 'Chief Nyone',
|
||||||
|
'password' => 'password',
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'is_admin' => false,
|
||||||
|
'can_create_channel' => true,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$channel = $official->channel()->updateOrCreate([], [
|
||||||
|
'category_id' => Category::query()->where('slug', 'creative')->value('id'),
|
||||||
|
'slug' => 'nyone',
|
||||||
|
'display_name' => 'Nyone Official',
|
||||||
|
'description' => 'Official updates and live sessions from Nyone.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
User::factory()->create([
|
if (! $channel->streamKey()->exists()) {
|
||||||
'name' => 'Admin User',
|
app(StreamKeyManager::class)->rotate($channel);
|
||||||
'email' => 'admin@example.com',
|
}
|
||||||
'is_admin' => true,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
import { Head, Link, router } from '@inertiajs/react';
|
import { Head, Link, router } from '@inertiajs/react';
|
||||||
import { Ban, Radio, Shield, StopCircle, Undo2, Users } from 'lucide-react';
|
import {
|
||||||
|
Ban,
|
||||||
|
KeyRound,
|
||||||
|
Lock,
|
||||||
|
Radio,
|
||||||
|
Shield,
|
||||||
|
StopCircle,
|
||||||
|
Undo2,
|
||||||
|
Unlock,
|
||||||
|
UserCheck,
|
||||||
|
Users,
|
||||||
|
UserX,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
updateDefault as updateChannelCreationDefault,
|
||||||
|
updateUser as updateUserCreatorAccess,
|
||||||
|
} from '@/actions/App/Http/Controllers/AdminChannelCreationController';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -42,15 +59,35 @@ type AdminChannel = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AdminUser = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
can_create_channel: boolean;
|
||||||
|
suspended_at: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
channel: {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
display_name: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
stats: {
|
stats: {
|
||||||
users: number;
|
users: number;
|
||||||
channels: number;
|
channels: number;
|
||||||
live_channels: number;
|
live_channels: number;
|
||||||
vods: number;
|
vods: number;
|
||||||
|
creator_access_grants: number;
|
||||||
|
};
|
||||||
|
channelCreationPolicy: {
|
||||||
|
channel_creation_open: boolean;
|
||||||
};
|
};
|
||||||
liveBroadcasts: LiveBroadcast[];
|
liveBroadcasts: LiveBroadcast[];
|
||||||
channels: AdminChannel[];
|
channels: AdminChannel[];
|
||||||
|
users: AdminUser[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ModerationAction =
|
type ModerationAction =
|
||||||
@@ -61,11 +98,37 @@ type ModerationAction =
|
|||||||
|
|
||||||
export default function AdminDashboard({
|
export default function AdminDashboard({
|
||||||
stats,
|
stats,
|
||||||
|
channelCreationPolicy,
|
||||||
liveBroadcasts,
|
liveBroadcasts,
|
||||||
channels,
|
channels,
|
||||||
|
users,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [action, setAction] = useState<ModerationAction>(null);
|
const [action, setAction] = useState<ModerationAction>(null);
|
||||||
|
|
||||||
|
function setChannelCreationDefault(channelCreationOpen: boolean) {
|
||||||
|
router.patch(
|
||||||
|
updateChannelCreationDefault(),
|
||||||
|
{
|
||||||
|
channel_creation_open: channelCreationOpen,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUserCreatorAccess(user: AdminUser, canCreateChannel: boolean) {
|
||||||
|
router.patch(
|
||||||
|
updateUserCreatorAccess(user.id),
|
||||||
|
{
|
||||||
|
can_create_channel: canCreateChannel,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function confirmAction() {
|
function confirmAction() {
|
||||||
if (!action) {
|
if (!action) {
|
||||||
return;
|
return;
|
||||||
@@ -112,7 +175,7 @@ export default function AdminDashboard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
<Stat icon={Users} label="Users" value={stats.users} />
|
<Stat icon={Users} label="Users" value={stats.users} />
|
||||||
<Stat
|
<Stat
|
||||||
icon={Radio}
|
icon={Radio}
|
||||||
@@ -125,9 +188,73 @@ export default function AdminDashboard({
|
|||||||
value={stats.live_channels}
|
value={stats.live_channels}
|
||||||
live
|
live
|
||||||
/>
|
/>
|
||||||
|
<Stat
|
||||||
|
icon={UserCheck}
|
||||||
|
label="Creator grants"
|
||||||
|
value={stats.creator_access_grants}
|
||||||
|
/>
|
||||||
<Stat icon={Shield} label="VODs" value={stats.vods} />
|
<Stat icon={Shield} label="VODs" value={stats.vods} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-md border bg-card p-5 shadow-sm">
|
||||||
|
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<KeyRound className="mt-0.5 size-5 text-primary" />
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold">
|
||||||
|
Channel creation access
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Control who can create a creator channel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant={
|
||||||
|
channelCreationPolicy.channel_creation_open
|
||||||
|
? 'outline'
|
||||||
|
: 'default'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setChannelCreationDefault(false)}
|
||||||
|
>
|
||||||
|
<Lock className="size-4" />
|
||||||
|
Approval only
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={
|
||||||
|
channelCreationPolicy.channel_creation_open
|
||||||
|
? 'default'
|
||||||
|
: 'outline'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setChannelCreationDefault(true)}
|
||||||
|
>
|
||||||
|
<Unlock className="size-4" />
|
||||||
|
Open to all
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{users.map((user) => (
|
||||||
|
<CreatorAccessRow
|
||||||
|
key={user.id}
|
||||||
|
user={user}
|
||||||
|
defaultOpen={
|
||||||
|
channelCreationPolicy.channel_creation_open
|
||||||
|
}
|
||||||
|
onChange={(canCreateChannel) =>
|
||||||
|
setUserCreatorAccess(user, canCreateChannel)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{users.length === 0 && (
|
||||||
|
<EmptyState text="No users found." />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="rounded-md border bg-card p-5 shadow-sm">
|
<section className="rounded-md border bg-card p-5 shadow-sm">
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -317,7 +444,7 @@ function Stat({
|
|||||||
value,
|
value,
|
||||||
live = false,
|
live = false,
|
||||||
}: {
|
}: {
|
||||||
icon: typeof Radio;
|
icon: LucideIcon;
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: number;
|
||||||
live?: boolean;
|
live?: boolean;
|
||||||
@@ -333,6 +460,78 @@ function Stat({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CreatorAccessRow({
|
||||||
|
user,
|
||||||
|
defaultOpen,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
user: AdminUser;
|
||||||
|
defaultOpen: boolean;
|
||||||
|
onChange: (canCreateChannel: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const effectiveAccess = defaultOpen || user.can_create_channel;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 rounded-md border bg-background p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="truncate font-medium">{user.name}</span>
|
||||||
|
{user.is_admin && (
|
||||||
|
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
||||||
|
ADMIN
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{user.suspended_at && (
|
||||||
|
<span className="rounded-md border border-destructive/40 bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
|
||||||
|
SUSPENDED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
effectiveAccess
|
||||||
|
? 'border-success/40 bg-success/10 text-success rounded-md border px-2 py-0.5 text-xs font-medium'
|
||||||
|
: 'border-warning/40 bg-warning/10 text-warning rounded-md border px-2 py-0.5 text-xs font-medium'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{effectiveAccess ? 'CAN CREATE' : 'NEEDS APPROVAL'}
|
||||||
|
</span>
|
||||||
|
{defaultOpen && !user.can_create_channel && (
|
||||||
|
<span className="rounded-md border px-2 py-0.5 text-xs text-muted-foreground">
|
||||||
|
DEFAULT
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 truncate text-sm text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
{user.channel && (
|
||||||
|
<>
|
||||||
|
{' - '}
|
||||||
|
<Link
|
||||||
|
href={showChannel(user.channel.slug)}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{user.channel.display_name}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={user.can_create_channel ? 'outline' : 'default'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onChange(!user.can_create_channel)}
|
||||||
|
>
|
||||||
|
{user.can_create_channel ? (
|
||||||
|
<UserX className="size-4" />
|
||||||
|
) : (
|
||||||
|
<UserCheck className="size-4" />
|
||||||
|
)}
|
||||||
|
{user.can_create_channel ? 'Remove grant' : 'Grant'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Thumb({ url }: { url: string | null }) {
|
function Thumb({ url }: { url: string | null }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid aspect-video overflow-hidden rounded-md bg-zinc-950 text-white md:aspect-square">
|
<div className="grid aspect-video overflow-hidden rounded-md bg-zinc-950 text-white md:aspect-square">
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ type CreatorChannel = {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
channel: CreatorChannel | null;
|
channel: CreatorChannel | null;
|
||||||
|
canCreateChannel: boolean;
|
||||||
plainStreamKey: string | null;
|
plainStreamKey: string | null;
|
||||||
streaming: {
|
streaming: {
|
||||||
ingestServer: string;
|
ingestServer: string;
|
||||||
@@ -67,6 +68,7 @@ type Props = {
|
|||||||
|
|
||||||
export default function Dashboard({
|
export default function Dashboard({
|
||||||
channel,
|
channel,
|
||||||
|
canCreateChannel,
|
||||||
plainStreamKey,
|
plainStreamKey,
|
||||||
streaming,
|
streaming,
|
||||||
categories,
|
categories,
|
||||||
@@ -190,88 +192,112 @@ export default function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!channel ? (
|
{!channel ? (
|
||||||
<section className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
|
canCreateChannel ? (
|
||||||
<Panel
|
<section className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||||
title="Create your channel"
|
<Panel
|
||||||
description="Each account can own one public channel in this version."
|
title="Create your channel"
|
||||||
>
|
description="Each account can own one public channel in this version."
|
||||||
<form
|
|
||||||
onSubmit={createChannel}
|
|
||||||
className="grid gap-4"
|
|
||||||
>
|
>
|
||||||
<Field
|
<form
|
||||||
label="Display name"
|
onSubmit={createChannel}
|
||||||
error={createForm.errors.display_name}
|
className="grid gap-4"
|
||||||
>
|
>
|
||||||
<Input
|
<Field
|
||||||
value={createForm.data.display_name}
|
label="Display name"
|
||||||
onChange={(event) =>
|
error={createForm.errors.display_name}
|
||||||
createForm.setData(
|
>
|
||||||
'display_name',
|
<Input
|
||||||
event.target.value,
|
value={createForm.data.display_name}
|
||||||
)
|
onChange={(event) =>
|
||||||
}
|
createForm.setData(
|
||||||
placeholder="Nyone Live"
|
'display_name',
|
||||||
/>
|
event.target.value,
|
||||||
</Field>
|
)
|
||||||
<Field
|
}
|
||||||
label="Slug"
|
placeholder="Nyone Live"
|
||||||
error={createForm.errors.slug}
|
/>
|
||||||
>
|
</Field>
|
||||||
<Input
|
<Field
|
||||||
value={createForm.data.slug}
|
label="Slug"
|
||||||
onChange={(event) =>
|
error={createForm.errors.slug}
|
||||||
createForm.setData(
|
>
|
||||||
'slug',
|
<Input
|
||||||
event.target.value.toLowerCase(),
|
value={createForm.data.slug}
|
||||||
)
|
onChange={(event) =>
|
||||||
}
|
createForm.setData(
|
||||||
placeholder="nyone-live"
|
'slug',
|
||||||
/>
|
event.target.value.toLowerCase(),
|
||||||
</Field>
|
)
|
||||||
<Field label="Category">
|
}
|
||||||
<CategorySelect
|
placeholder="nyone-live"
|
||||||
categories={categories}
|
/>
|
||||||
value={createForm.data.category_id}
|
</Field>
|
||||||
onChange={(value) =>
|
<Field label="Category">
|
||||||
createForm.setData(
|
<CategorySelect
|
||||||
'category_id',
|
categories={categories}
|
||||||
value,
|
value={createForm.data.category_id}
|
||||||
)
|
onChange={(value) =>
|
||||||
}
|
createForm.setData(
|
||||||
/>
|
'category_id',
|
||||||
</Field>
|
value,
|
||||||
<Field
|
)
|
||||||
label="Description"
|
}
|
||||||
error={createForm.errors.description}
|
/>
|
||||||
>
|
</Field>
|
||||||
<textarea
|
<Field
|
||||||
value={createForm.data.description}
|
label="Description"
|
||||||
onChange={(event) =>
|
error={createForm.errors.description}
|
||||||
createForm.setData(
|
>
|
||||||
'description',
|
<textarea
|
||||||
event.target.value,
|
value={createForm.data.description}
|
||||||
)
|
onChange={(event) =>
|
||||||
}
|
createForm.setData(
|
||||||
className="min-h-28 rounded-md border bg-background px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
'description',
|
||||||
/>
|
event.target.value,
|
||||||
</Field>
|
)
|
||||||
<Button
|
}
|
||||||
type="submit"
|
className="min-h-28 rounded-md border bg-background px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||||
disabled={createForm.processing}
|
/>
|
||||||
>
|
</Field>
|
||||||
<Save className="size-4" />
|
<Button
|
||||||
Create channel
|
type="submit"
|
||||||
</Button>
|
disabled={createForm.processing}
|
||||||
</form>
|
>
|
||||||
</Panel>
|
<Save className="size-4" />
|
||||||
<Panel title="OBS readiness">
|
Create channel
|
||||||
<ReadinessRow done label="Channel metadata" />
|
</Button>
|
||||||
<ReadinessRow label="Private stream key" />
|
</form>
|
||||||
<ReadinessRow label="Prepared broadcast" />
|
</Panel>
|
||||||
<ReadinessRow label="Live ingest signal" />
|
<Panel title="OBS readiness">
|
||||||
</Panel>
|
<ReadinessRow done label="Channel metadata" />
|
||||||
</section>
|
<ReadinessRow label="Private stream key" />
|
||||||
|
<ReadinessRow label="Prepared broadcast" />
|
||||||
|
<ReadinessRow label="Live ingest signal" />
|
||||||
|
</Panel>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||||
|
<Panel
|
||||||
|
title="Channel creation requires approval"
|
||||||
|
description="An admin can grant creator access from the admin console."
|
||||||
|
>
|
||||||
|
<div className="border-warning/40 bg-warning/10 text-warning flex items-start gap-3 rounded-md border p-3 text-sm">
|
||||||
|
<ShieldAlert className="mt-0.5 size-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
This account can browse and follow
|
||||||
|
channels, but cannot create a channel
|
||||||
|
yet.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
<Panel title="OBS readiness">
|
||||||
|
<ReadinessRow label="Channel metadata" />
|
||||||
|
<ReadinessRow label="Private stream key" />
|
||||||
|
<ReadinessRow label="Prepared broadcast" />
|
||||||
|
<ReadinessRow label="Live ingest signal" />
|
||||||
|
</Panel>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
|
<div className="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
|
||||||
<div className="grid min-w-0 gap-6">
|
<div className="grid min-w-0 gap-6">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\AdminChannelCreationController;
|
||||||
use App\Http\Controllers\AdminDashboardController;
|
use App\Http\Controllers\AdminDashboardController;
|
||||||
use App\Http\Controllers\AdminModerationController;
|
use App\Http\Controllers\AdminModerationController;
|
||||||
use App\Http\Controllers\ChannelController;
|
use App\Http\Controllers\ChannelController;
|
||||||
@@ -36,8 +37,10 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
|
|
||||||
Route::middleware('admin')->prefix('admin')->name('admin.')->group(function () {
|
Route::middleware('admin')->prefix('admin')->name('admin.')->group(function () {
|
||||||
Route::get('/', AdminDashboardController::class)->name('dashboard');
|
Route::get('/', AdminDashboardController::class)->name('dashboard');
|
||||||
|
Route::patch('channel-creation', [AdminChannelCreationController::class, 'updateDefault'])->name('channel-creation.update');
|
||||||
Route::post('channels/{channel:slug}/suspend', [AdminModerationController::class, 'suspendChannel'])->name('channels.suspend');
|
Route::post('channels/{channel:slug}/suspend', [AdminModerationController::class, 'suspendChannel'])->name('channels.suspend');
|
||||||
Route::post('channels/{channel:slug}/restore', [AdminModerationController::class, 'restoreChannel'])->name('channels.restore');
|
Route::post('channels/{channel:slug}/restore', [AdminModerationController::class, 'restoreChannel'])->name('channels.restore');
|
||||||
|
Route::patch('users/{user}/creator-access', [AdminChannelCreationController::class, 'updateUser'])->name('users.creator-access.update');
|
||||||
Route::post('users/{user}/suspend', [AdminModerationController::class, 'suspendUser'])->name('users.suspend');
|
Route::post('users/{user}/suspend', [AdminModerationController::class, 'suspendUser'])->name('users.suspend');
|
||||||
Route::post('broadcasts/{broadcast}/stop', [AdminModerationController::class, 'stopBroadcast'])->name('broadcasts.stop');
|
Route::post('broadcasts/{broadcast}/stop', [AdminModerationController::class, 'stopBroadcast'])->name('broadcasts.stop');
|
||||||
Route::delete('vods/{vod}', [AdminModerationController::class, 'deleteVod'])->name('vods.destroy');
|
Route::delete('vods/{vod}', [AdminModerationController::class, 'deleteVod'])->name('vods.destroy');
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace Tests\Feature;
|
|||||||
|
|
||||||
use App\Models\Broadcast;
|
use App\Models\Broadcast;
|
||||||
use App\Models\Channel;
|
use App\Models\Channel;
|
||||||
|
use App\Models\PlatformSetting;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Inertia\Testing\AssertableInertia as Assert;
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
@@ -34,4 +35,65 @@ class AdminDashboardTest extends TestCase
|
|||||||
->where('channels.0.thumbnail_url', '/storage/channels/demo/thumb.jpg'),
|
->where('channels.0.thumbnail_url', '/storage/channels/demo/thumb.jpg'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_admin_dashboard_exposes_channel_creation_controls(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create(['is_admin' => true]);
|
||||||
|
$creator = User::factory()->create(['can_create_channel' => true]);
|
||||||
|
Channel::factory()->for($creator)->create(['display_name' => 'Creator One']);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->get(route('admin.dashboard'))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('admin/dashboard')
|
||||||
|
->where('channelCreationPolicy.channel_creation_open', false)
|
||||||
|
->where('users.0.id', $creator->id)
|
||||||
|
->where('users.0.can_create_channel', true)
|
||||||
|
->where('users.0.channel.display_name', 'Creator One'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_update_channel_creation_default(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create(['is_admin' => true]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->patch(route('admin.channel-creation.update'), [
|
||||||
|
'channel_creation_open' => true,
|
||||||
|
])
|
||||||
|
->assertRedirect()
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertTrue(PlatformSetting::current()->channel_creation_open);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_grant_user_channel_creation_access(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->create(['is_admin' => true]);
|
||||||
|
$user = User::factory()->create(['can_create_channel' => false]);
|
||||||
|
|
||||||
|
$this->actingAs($admin)
|
||||||
|
->patch(route('admin.users.creator-access.update', $user), [
|
||||||
|
'can_create_channel' => true,
|
||||||
|
])
|
||||||
|
->assertRedirect()
|
||||||
|
->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertTrue($user->refresh()->can_create_channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_non_admin_cannot_update_user_channel_creation_access(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$target = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->patch(route('admin.users.creator-access.update', $target), [
|
||||||
|
'can_create_channel' => true,
|
||||||
|
])
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
$this->assertFalse($target->refresh()->can_create_channel);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace Tests\Feature;
|
|||||||
|
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Channel;
|
use App\Models\Channel;
|
||||||
|
use App\Models\PlatformSetting;
|
||||||
use App\Models\StreamKey;
|
use App\Models\StreamKey;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@@ -13,9 +14,9 @@ class CreatorChannelTest extends TestCase
|
|||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
public function test_user_can_create_one_channel_and_receives_one_time_stream_key(): void
|
public function test_user_with_creator_access_can_create_one_channel_and_receives_one_time_stream_key(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create(['can_create_channel' => true]);
|
||||||
$category = Category::factory()->create();
|
$category = Category::factory()->create();
|
||||||
|
|
||||||
$response = $this->actingAs($user)->post(route('creator.channel.store'), [
|
$response = $this->actingAs($user)->post(route('creator.channel.store'), [
|
||||||
@@ -39,9 +40,48 @@ class CreatorChannelTest extends TestCase
|
|||||||
$this->assertDatabaseCount(StreamKey::class, 1);
|
$this->assertDatabaseCount(StreamKey::class, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_user_cannot_create_more_than_one_channel(): void
|
public function test_user_without_creator_access_cannot_create_channel(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->post(route('creator.channel.store'), [
|
||||||
|
'display_name' => 'Nyone Live',
|
||||||
|
'slug' => 'nyone-live',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertForbidden();
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing(Channel::class, [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_channel_creation_can_be_opened_by_default(): void
|
||||||
|
{
|
||||||
|
PlatformSetting::current()->forceFill([
|
||||||
|
'channel_creation_open' => true,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->post(route('creator.channel.store'), [
|
||||||
|
'display_name' => 'Nyone Live',
|
||||||
|
'slug' => 'nyone-live',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertRedirect(route('dashboard', absolute: false))
|
||||||
|
->assertSessionHas('plain_stream_key');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas(Channel::class, [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'slug' => 'nyone-live',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_user_cannot_create_more_than_one_channel(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create(['can_create_channel' => true]);
|
||||||
Channel::factory()->for($user)->create();
|
Channel::factory()->for($user)->create();
|
||||||
|
|
||||||
$response = $this->actingAs($user)->post(route('creator.channel.store'), [
|
$response = $this->actingAs($user)->post(route('creator.channel.store'), [
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ class DashboardTest extends TestCase
|
|||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_creator_dashboard_exposes_channel_creation_access(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create(['can_create_channel' => true]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('dashboard'))
|
||||||
|
->assertOk()
|
||||||
|
->assertInertia(fn (Assert $page) => $page
|
||||||
|
->component('dashboard')
|
||||||
|
->where('canCreateChannel', true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_home_page_exposes_channel_display_media_props(): void
|
public function test_home_page_exposes_channel_display_media_props(): void
|
||||||
{
|
{
|
||||||
$channel = Channel::factory()->create([
|
$channel = Channel::factory()->create([
|
||||||
|
|||||||
Reference in New Issue
Block a user