Admin can now control channel creation from the admin dashboard

This commit is contained in:
2026-05-16 03:43:25 +03:30
parent 34445f3032
commit f46f32aa4c
14 changed files with 639 additions and 95 deletions

View 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(),
]);
}
}

View File

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

View File

@@ -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'],

View 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',
];
}
}

View File

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

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('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');
});
}
};

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::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');
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'), [

View File

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