diff --git a/app/Http/Controllers/AdminChannelCreationController.php b/app/Http/Controllers/AdminChannelCreationController.php new file mode 100644 index 0000000..a1af04d --- /dev/null +++ b/app/Http/Controllers/AdminChannelCreationController.php @@ -0,0 +1,73 @@ +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 $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(), + ]); + } +} diff --git a/app/Http/Controllers/AdminDashboardController.php b/app/Http/Controllers/AdminDashboardController.php index cf28eae..d7154bb 100644 --- a/app/Http/Controllers/AdminDashboardController.php +++ b/app/Http/Controllers/AdminDashboardController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Models\Broadcast; use App\Models\Channel; +use App\Models\PlatformSetting; use App\Models\User; use App\Models\Vod; use Illuminate\Support\Facades\Storage; @@ -20,6 +21,10 @@ class AdminDashboardController extends Controller 'channels' => Channel::query()->count(), 'live_channels' => Channel::query()->where('is_live', true)->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() ->where('status', Broadcast::STATUS_LIVE) @@ -57,6 +62,25 @@ class AdminDashboardController extends Controller '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, + ]), ]); } diff --git a/app/Http/Controllers/CreatorDashboardController.php b/app/Http/Controllers/CreatorDashboardController.php index 4423f1d..2bb3d45 100644 --- a/app/Http/Controllers/CreatorDashboardController.php +++ b/app/Http/Controllers/CreatorDashboardController.php @@ -25,6 +25,7 @@ class CreatorDashboardController extends Controller return Inertia::render('dashboard', [ 'channel' => $channel ? $this->channelPayload($channel) : null, + 'canCreateChannel' => $request->user()->canCreateChannel(), 'plainStreamKey' => session('plain_stream_key'), 'streaming' => [ 'ingestServer' => $streamKeys->ingestServer(), @@ -57,6 +58,7 @@ class CreatorDashboardController extends Controller public function storeChannel(Request $request, StreamKeyManager $streamKeys): RedirectResponse { 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([ 'display_name' => ['required', 'string', 'max:80'], diff --git a/app/Models/PlatformSetting.php b/app/Models/PlatformSetting.php new file mode 100644 index 0000000..6ce4492 --- /dev/null +++ b/app/Models/PlatformSetting.php @@ -0,0 +1,26 @@ + false, + ]; + + public static function current(): self + { + return static::query()->first() ?? static::query()->create(); + } + + protected function casts(): array + { + return [ + 'channel_creation_open' => 'boolean', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 853e77e..bd787b8 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -13,13 +13,17 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; 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'])] class User extends Authenticatable { /** @use HasFactory */ use HasFactory, Notifiable, TwoFactorAuthenticatable; + protected $attributes = [ + 'can_create_channel' => false, + ]; + /** * Get the attributes that should be cast. * @@ -32,6 +36,7 @@ class User extends Authenticatable 'password' => 'hashed', 'two_factor_confirmed_at' => 'datetime', 'is_admin' => 'boolean', + 'can_create_channel' => 'boolean', 'suspended_at' => 'datetime', ]; } @@ -50,4 +55,9 @@ class User extends Authenticatable { return $this->suspended_at !== null; } + + public function canCreateChannel(): bool + { + return $this->can_create_channel || PlatformSetting::current()->channel_creation_open; + } } diff --git a/database/migrations/2026_05_15_235625_add_can_create_channel_to_users_table.php b/database/migrations/2026_05_15_235625_add_can_create_channel_to_users_table.php new file mode 100644 index 0000000..ce67b40 --- /dev/null +++ b/database/migrations/2026_05_15_235625_add_can_create_channel_to_users_table.php @@ -0,0 +1,22 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_05_15_235629_create_platform_settings_table.php b/database/migrations/2026_05_15_235629_create_platform_settings_table.php new file mode 100644 index 0000000..67189ce --- /dev/null +++ b/database/migrations/2026_05_15_235629_create_platform_settings_table.php @@ -0,0 +1,22 @@ +id(); + $table->boolean('channel_creation_open')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('platform_settings'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 3010971..c855e22 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -3,7 +3,9 @@ namespace Database\Seeders; use App\Models\Category; +use App\Models\PlatformSetting; use App\Models\User; +use App\Services\Streaming\StreamKeyManager; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder @@ -24,15 +26,35 @@ class DatabaseSeeder extends Seeder ['name' => $category['name']], )); - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', + PlatformSetting::current(); + + $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([ - 'name' => 'Admin User', - 'email' => 'admin@example.com', - 'is_admin' => true, - ]); + if (! $channel->streamKey()->exists()) { + app(StreamKeyManager::class)->rotate($channel); + } } } diff --git a/resources/js/pages/admin/dashboard.tsx b/resources/js/pages/admin/dashboard.tsx index 9374895..a2c4cac 100644 --- a/resources/js/pages/admin/dashboard.tsx +++ b/resources/js/pages/admin/dashboard.tsx @@ -1,6 +1,23 @@ 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 { + updateDefault as updateChannelCreationDefault, + updateUser as updateUserCreatorAccess, +} from '@/actions/App/Http/Controllers/AdminChannelCreationController'; import { Button } from '@/components/ui/button'; import { 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 = { stats: { users: number; channels: number; live_channels: number; vods: number; + creator_access_grants: number; + }; + channelCreationPolicy: { + channel_creation_open: boolean; }; liveBroadcasts: LiveBroadcast[]; channels: AdminChannel[]; + users: AdminUser[]; }; type ModerationAction = @@ -61,11 +98,37 @@ type ModerationAction = export default function AdminDashboard({ stats, + channelCreationPolicy, liveBroadcasts, channels, + users, }: Props) { const [action, setAction] = useState(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() { if (!action) { return; @@ -112,7 +175,7 @@ export default function AdminDashboard({ -
+
+
+
+
+
+ +
+

+ Channel creation access +

+

+ Control who can create a creator channel. +

+
+
+
+ + +
+
+
+ {users.map((user) => ( + + setUserCreatorAccess(user, canCreateChannel) + } + /> + ))} + {users.length === 0 && ( + + )} +
+
+
@@ -317,7 +444,7 @@ function Stat({ value, live = false, }: { - icon: typeof Radio; + icon: LucideIcon; label: string; value: number; 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 ( +
+
+
+ {user.name} + {user.is_admin && ( + + ADMIN + + )} + {user.suspended_at && ( + + SUSPENDED + + )} + + {effectiveAccess ? 'CAN CREATE' : 'NEEDS APPROVAL'} + + {defaultOpen && !user.can_create_channel && ( + + DEFAULT + + )} +
+
+ {user.email} + {user.channel && ( + <> + {' - '} + + {user.channel.display_name} + + + )} +
+
+ +
+ ); +} + function Thumb({ url }: { url: string | null }) { return (
diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index 4b8164b..1638aae 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -56,6 +56,7 @@ type CreatorChannel = { type Props = { channel: CreatorChannel | null; + canCreateChannel: boolean; plainStreamKey: string | null; streaming: { ingestServer: string; @@ -67,6 +68,7 @@ type Props = { export default function Dashboard({ channel, + canCreateChannel, plainStreamKey, streaming, categories, @@ -190,88 +192,112 @@ export default function Dashboard({
{!channel ? ( -
- -
+ - - - createForm.setData( - 'display_name', - event.target.value, - ) - } - placeholder="Nyone Live" - /> - - - - createForm.setData( - 'slug', - event.target.value.toLowerCase(), - ) - } - placeholder="nyone-live" - /> - - - - createForm.setData( - 'category_id', - value, - ) - } - /> - - -