diff --git a/app/Community/Components/UserCard.php b/app/Community/Components/UserCard.php index 240686c8a8..e7d04aca57 100644 --- a/app/Community/Components/UserCard.php +++ b/app/Community/Components/UserCard.php @@ -63,6 +63,7 @@ function () use ($username): ?array { return $foundUser ? [ ...$foundUser->toArray(), 'isMuted' => $foundUser->isMuted(), + 'visibleRoleName' => $foundUser->visible_role?->name, ] : null; } ); @@ -72,7 +73,7 @@ private function buildAllCardViewValues(string $username, array $rawUserData): a { $cardBioData = $this->buildCardBioData($rawUserData); $cardRankData = $this->buildCardRankData($username, $rawUserData['RAPoints'], $rawUserData['RASoftcorePoints'], $rawUserData['Untracked'] ? true : false); - $cardRoleData = $this->buildCardRoleData($username, $rawUserData['Permissions']); + $cardRoleData = $this->buildCardRoleData($username, $rawUserData['visibleRoleName']); return array_merge($cardBioData, $cardRankData, $cardRoleData); } @@ -147,10 +148,10 @@ private function buildCardRankData(string $username, int $hardcorePoints, int $s ); } - private function buildCardRoleData(string $username, int $permissions): array + private function buildCardRoleData(string $username, ?string $visibleRoleName): array { - $canShowUserRole = $permissions >= Permissions::JuniorDeveloper; - $roleLabel = Permissions::toString($permissions); + $canShowUserRole = $visibleRoleName !== null; + $roleLabel = $visibleRoleName ? __('permission.role.' . $visibleRoleName) : null; $useExtraNamePadding = $canShowUserRole diff --git a/app/Community/Concerns/ActsAsCommunityMember.php b/app/Community/Concerns/ActsAsCommunityMember.php index 5419f23575..786fe90eb2 100644 --- a/app/Community/Concerns/ActsAsCommunityMember.php +++ b/app/Community/Concerns/ActsAsCommunityMember.php @@ -16,9 +16,11 @@ use App\Models\UserComment; use App\Models\UserGameListEntry; use App\Models\UserRelation; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\Auth; +use Spatie\Permission\Models\Role as SpatieRole; // TODO organize accessors, relations, and scopes @@ -28,6 +30,34 @@ public static function bootActsAsCommunityMember(): void { } + public function getVisibleRoleAttribute(): ?SpatieRole + { + // Load the user's displayable roles. + if ($this->relationLoaded('roles')) { + /** @var Collection $displayableRoles */ + $displayableRoles = $this->roles->where('display', '>', 0); + } else { + /** @var Collection $displayableRoles */ + $displayableRoles = $this->displayableRoles()->get(); + } + + // If user has an explicitly set visible_role_id, we'll try to show it. + // However, we need to verify it's still a valid displayable role for the + // user (it's possible they lost the role at some point). + if ($this->visible_role_id !== null) { + /** @var SpatieRole|null $explicitRole */ + $explicitRole = $displayableRoles->find($this->visible_role_id); + if ($explicitRole) { + return $explicitRole; + } + } + + // Otherwise, fall back to highest ordered displayable role. + // For most users, this will return null. + /** @var SpatieRole|null */ + return $displayableRoles->first(); + } + /** * @return HasMany */ @@ -50,6 +80,15 @@ public function gameListEntries(?string $type = null): HasMany return $query; } + /** + * @return BelongsToMany + */ + public function displayableRoles(): BelongsToMany + { + /** @var BelongsToMany */ + return $this->roles()->where('display', '>', 0); + } + /** * @return BelongsToMany */ diff --git a/app/Community/Controllers/UserSettingsController.php b/app/Community/Controllers/UserSettingsController.php index f6a396cf35..d27d48a338 100644 --- a/app/Community/Controllers/UserSettingsController.php +++ b/app/Community/Controllers/UserSettingsController.php @@ -18,14 +18,17 @@ use App\Community\Requests\UpdatePasswordRequest; use App\Community\Requests\UpdateProfileRequest; use App\Community\Requests\UpdateWebsitePrefsRequest; +use App\Data\RoleData; use App\Data\UserData; use App\Data\UserPermissionsData; use App\Enums\Permissions; use App\Enums\UserPreference; use App\Http\Controller; +use App\Models\Role; use App\Models\User; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Inertia\Inertia; use Inertia\Response as InertiaResponse; @@ -39,6 +42,10 @@ public function show(): InertiaResponse /** @var User $user */ $user = Auth::user(); + $user->load(['roles' => function ($query) { + $query->where('display', '>', 0); + }]); + $userSettings = UserData::fromUser($user)->include( 'apiKey', 'deleteRequested', @@ -54,7 +61,18 @@ public function show(): InertiaResponse 'updateMotto' ); - $props = new UserSettingsPagePropsData($userSettings, $can); + /** @var Collection $displayableRoles */ + $displayableRoles = $user->roles; + + $mappedRoles = $displayableRoles->map(fn ($role) => RoleData::fromRole($role)) + ->values() + ->all(); + + $props = new UserSettingsPagePropsData( + $userSettings, + $can, + $mappedRoles, + ); return Inertia::render('settings', $props); } diff --git a/app/Community/Data/UpdateProfileData.php b/app/Community/Data/UpdateProfileData.php index 8599563040..a8361d2c05 100644 --- a/app/Community/Data/UpdateProfileData.php +++ b/app/Community/Data/UpdateProfileData.php @@ -12,6 +12,7 @@ class UpdateProfileData extends Data public function __construct( public string $motto, public bool $userWallActive, + public ?int $visibleRoleId, ) { } @@ -20,6 +21,7 @@ public static function fromRequest(UpdateProfileRequest $request): self return new self( motto: $request->motto, userWallActive: $request->userWallActive, + visibleRoleId: $request->visibleRoleId, ); } @@ -28,6 +30,7 @@ public function toArray(): array return [ 'Motto' => $this->motto, 'UserWallActive' => $this->userWallActive, + 'visible_role_id' => $this->visibleRoleId, ]; } } diff --git a/app/Community/Data/UserSettingsPagePropsData.php b/app/Community/Data/UserSettingsPagePropsData.php index 4888f21a90..4b613b1a83 100644 --- a/app/Community/Data/UserSettingsPagePropsData.php +++ b/app/Community/Data/UserSettingsPagePropsData.php @@ -4,6 +4,7 @@ namespace App\Community\Data; +use App\Data\RoleData; use App\Data\UserData; use App\Data\UserPermissionsData; use Spatie\LaravelData\Data; @@ -15,6 +16,8 @@ class UserSettingsPagePropsData extends Data public function __construct( public UserData $userSettings, public UserPermissionsData $can, + /** @var RoleData[] */ + public array $displayableRoles, ) { } } diff --git a/app/Community/Requests/UpdateProfileRequest.php b/app/Community/Requests/UpdateProfileRequest.php index 4295d04b6d..b3d67f3b25 100644 --- a/app/Community/Requests/UpdateProfileRequest.php +++ b/app/Community/Requests/UpdateProfileRequest.php @@ -31,6 +31,7 @@ public function rules(): array return [ 'motto' => 'nullable|string|max:50', 'userWallActive' => 'nullable|boolean', + 'visibleRoleId' => 'nullable|integer', ]; } diff --git a/app/Data/RoleData.php b/app/Data/RoleData.php new file mode 100644 index 0000000000..4bab498ee6 --- /dev/null +++ b/app/Data/RoleData.php @@ -0,0 +1,27 @@ +id, + name: $role->name, + ); + } +} diff --git a/app/Data/UserData.php b/app/Data/UserData.php index 78ff7ea65f..399fd358c0 100644 --- a/app/Data/UserData.php +++ b/app/Data/UserData.php @@ -4,7 +4,6 @@ namespace App\Data; -use App\Enums\Permissions; use App\Models\User; use App\Platform\Enums\PlayerPreferredMode; use Illuminate\Support\Carbon; @@ -24,6 +23,8 @@ public function __construct( public Lazy|string|null $apiKey = null, public Lazy|string|null $deleteRequested = null, public Lazy|Carbon|null $deletedAt = null, + /** @var RoleData[] */ + public Lazy|array|null $displayableRoles = null, public Lazy|string|null $emailAddress = null, public Lazy|int $id = 0, public Lazy|bool $isMuted = false, @@ -39,7 +40,7 @@ public function __construct( public Lazy|int|null $unreadMessageCount = null, public Lazy|string|null $username = null, public Lazy|bool|null $userWallActive = null, - public Lazy|string|null $visibleRole = null, + public Lazy|RoleData|null $visibleRole = null, public Lazy|int|null $websitePrefs = null, #[TypeScriptType([ @@ -64,8 +65,6 @@ public static function fromRecentForumTopic(array $topic): self public static function fromUser(User $user): self { - $legacyPermissions = (int) $user->getAttribute('Permissions'); - return new self( // == eager fields displayName: $user->display_name, @@ -75,6 +74,7 @@ public static function fromUser(User $user): self apiKey: Lazy::create(fn () => $user->APIKey), deletedAt: Lazy::create(fn () => $user->Deleted ? Carbon::parse($user->Deleted) : null), deleteRequested: Lazy::create(fn () => $user->DeleteRequested), + displayableRoles: Lazy::create(fn () => $user->displayableRoles), emailAddress: Lazy::create(fn () => $user->EmailAddress), mutedUntil: Lazy::create(fn () => $user->muted_until), id: Lazy::create(fn () => $user->id), @@ -97,7 +97,7 @@ public static function fromUser(User $user): self unreadMessageCount: Lazy::create(fn () => $user->UnreadMessageCount), username: Lazy::create(fn () => $user->username), userWallActive: Lazy::create(fn () => $user->UserWallActive), - visibleRole: Lazy::create(fn () => $legacyPermissions > 1 ? Permissions::toString($legacyPermissions) : null), + visibleRole: Lazy::create(fn () => $user->visible_role ? RoleData::fromRole($user->visible_role) : null), websitePrefs: Lazy::create(fn () => $user->websitePrefs), ); } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 6594de3272..7dfb3a7641 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -48,14 +48,15 @@ public function share(Request $request): array 'isNew', 'legacyPermissions', 'locale', + 'mutedUntil', 'playerPreferredMode', 'points', 'pointsSoftcore', - 'mutedUntil', 'preferences', 'roles', 'unreadMessageCount', 'username', + 'visibleRole', 'websitePrefs', ), ] : null, diff --git a/app/Models/User.php b/app/Models/User.php index c9f1dada92..2feb61c1f9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -164,6 +164,7 @@ class User extends Authenticatable implements CommunityMember, Developer, HasLoc 'Untracked', 'User', // fillable for registration 'UserWallActive', + 'visible_role_id', 'websitePrefs', ]; diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 43ab192424..cd09ceed1a 100755 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -44,6 +44,7 @@ public function definition(): array 'Untracked' => 0, 'UserWallActive' => 1, 'muted_until' => null, + 'visible_role_id' => null, // nullable 'APIKey' => 'apiKey', diff --git a/database/migrations/2025_01_20_000000_update_useraccounts_table.php b/database/migrations/2025_01_20_000000_update_useraccounts_table.php new file mode 100644 index 0000000000..7f772a07e9 --- /dev/null +++ b/database/migrations/2025_01_20_000000_update_useraccounts_table.php @@ -0,0 +1,32 @@ +unsignedBigInteger('visible_role_id')->nullable()->after('display_name'); + + $table->foreign('visible_role_id') + ->references('id') + ->on('auth_roles') + ->onDelete('set null'); + + $table->index('visible_role_id'); + }); + } + + public function down(): void + { + Schema::table('UserAccounts', function (Blueprint $table) { + $table->dropForeign(['visible_role_id']); + $table->dropIndex(['visible_role_id']); + $table->dropColumn('visible_role_id'); + }); + } +}; diff --git a/lang/en/permission.php b/lang/en/permission.php index c53da952b8..6b67d72834 100755 --- a/lang/en/permission.php +++ b/lang/en/permission.php @@ -5,7 +5,7 @@ return [ 'role' => [ Role::ROOT => 'Root', - Role::ADMINISTRATOR => __('Administrator'), + Role::ADMINISTRATOR => __('Admin'), // moderation & platform staff roles @@ -23,7 +23,7 @@ Role::ARTIST => __('Artist'), Role::WRITER => __('Writer'), Role::GAME_EDITOR => __('Game Editor'), - Role::PLAY_TESTER => __('Play Tester'), + Role::PLAY_TESTER => __('Playtester'), // community staff roles diff --git a/lang/en_US.json b/lang/en_US.json index 6a9885b319..15040b69a9 100644 --- a/lang/en_US.json +++ b/lang/en_US.json @@ -641,5 +641,27 @@ "news-category.technical": "Technical", "Don't ask for links to copyrighted ROMs. Don't share links to copyrighted ROMs.": "Don't ask for links to copyrighted ROMs. Don't share links to copyrighted ROMs.", "Start new topic": "Start new topic", - "enter your new topic's title...": "enter your new topic's title..." + "enter your new topic's title...": "enter your new topic's title...", + "administrator": "Admin", + "game-hash-manager": "Hash Manager", + "release-manager": "Release Manager", + "developer": "Developer", + "developer-junior": "Junior Developer", + "artist": "Artist", + "writer": "Writer", + "play-tester": "Playtester", + "moderator": "Moderator", + "forum-manager": "Forum Manager", + "ticket-manager": "Ticket Manager", + "news-manager": "News Manager", + "event-manager": "Event Manager", + "founder": "Founder", + "architect": "Architect", + "engineer": "Engineer", + "developer-retired": "Developer (retired)", + "game-editor": "Game Editor", + "team-account": "Team Account", + "dev-compliance": "Developer Compliance", + "code-reviewer": "Code Reviewer", + "community-manager": "Community Manager" } \ No newline at end of file diff --git a/resources/js/features/settings/components/ProfileSectionCard/ProfileSectionCard.test.tsx b/resources/js/features/settings/components/ProfileSectionCard/ProfileSectionCard.test.tsx index e18febf6d2..4e06928361 100644 --- a/resources/js/features/settings/components/ProfileSectionCard/ProfileSectionCard.test.tsx +++ b/resources/js/features/settings/components/ProfileSectionCard/ProfileSectionCard.test.tsx @@ -8,6 +8,11 @@ import { createUser } from '@/test/factories'; import { ProfileSectionCard } from './ProfileSectionCard'; describe('Component: ProfileSectionCard', () => { + beforeEach(() => { + window.HTMLElement.prototype.hasPointerCapture = vi.fn(); + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + }); + it('renders without crashing', () => { // ARRANGE const { container } = render(, { @@ -26,7 +31,7 @@ describe('Component: ProfileSectionCard', () => { render(, { pageProps: { can: {}, - userSettings: createUser({ visibleRole: null }), + userSettings: createUser(), }, }); @@ -38,13 +43,19 @@ describe('Component: ProfileSectionCard', () => { // ARRANGE render(, { pageProps: { + displayableRoles: [{ id: 6, name: 'developer' }], + auth: { + user: createAuthenticatedUser({ + visibleRole: { id: 6, name: 'developer' }, + }), + }, can: {}, - userSettings: createUser({ visibleRole: 'Some Role' }), + userSettings: createUser(), }, }); // ASSERT - expect(screen.getByLabelText(/visible role/i)).toHaveTextContent(/some role/i); + expect(screen.getByLabelText(/visible role/i)).toHaveTextContent(/developer/i); }); it('given the user is unable to change their motto, tells them', () => { @@ -54,7 +65,7 @@ describe('Component: ProfileSectionCard', () => { can: { updateMotto: false, }, - userSettings: createUser({ visibleRole: 'Some Role' }), + userSettings: createUser(), }, }); @@ -75,7 +86,7 @@ describe('Component: ProfileSectionCard', () => { user: createAuthenticatedUser({ username: 'Scott', id: 1 }), }, can: {}, - userSettings: createUser({ visibleRole: null }), + userSettings: createUser(), }, }); @@ -98,7 +109,7 @@ describe('Component: ProfileSectionCard', () => { user: createAuthenticatedUser({ username: 'Scott', id: 1 }), }, can: {}, - userSettings: createUser({ visibleRole: null }), + userSettings: createUser(), }, }); @@ -121,7 +132,6 @@ describe('Component: ProfileSectionCard', () => { }, can: {}, userSettings: createUser({ - visibleRole: null, motto: mockMotto, userWallActive: mockUserWallActive, }), @@ -142,14 +152,16 @@ describe('Component: ProfileSectionCard', () => { render(, { pageProps: { + displayableRoles: [], // !! no selectable roles auth: { - user: createAuthenticatedUser({ username: 'Scott' }), + user: createAuthenticatedUser({ + username: 'Scott', + }), }, can: { updateMotto: true, }, userSettings: createUser({ - visibleRole: null, motto: mockMotto, userWallActive: mockUserWallActive, }), @@ -172,4 +184,85 @@ describe('Component: ProfileSectionCard', () => { userWallActive: false, }); }); + + it('given the user has selectable visible roles, properly submits their selection', async () => { + // ARRANGE + const putSpy = vi.spyOn(axios, 'put').mockResolvedValueOnce({ success: true }); + + const mockMotto = 'my motto'; + const mockUserWallActive = true; + + const displayableRoles: App.Data.Role[] = [ + { id: 2, name: 'administrator' }, + { id: 6, name: 'developer' }, + ]; + + const visibleRole = displayableRoles[1]; + + render(, { + pageProps: { + displayableRoles, // !! two of them + auth: { + user: createAuthenticatedUser({ + visibleRole, // !! developer + username: 'Scott', + }), + }, + can: { + updateMotto: true, + }, + userSettings: createUser({ + motto: mockMotto, + userWallActive: mockUserWallActive, + }), + }, + }); + + // ACT + await userEvent.click(screen.getByRole('combobox', { name: /role/i })); + await userEvent.click(screen.getByRole('option', { name: /admin/i })); + + await userEvent.click(screen.getByRole('button', { name: /update/i })); + + // ASSERT + expect(putSpy).toHaveBeenCalledWith(route('api.settings.profile.update'), { + motto: 'my motto', + userWallActive: true, + visibleRoleId: 2, + }); + }); + + it('given multiple visible roles are available, displays them sorted alphabetically by translated name', async () => { + // ARRANGE + const displayableRoles: App.Data.Role[] = [ + { id: 1, name: 'zdev' }, + { id: 2, name: 'adev' }, + { id: 3, name: 'mdev' }, + ]; + + render(, { + pageProps: { + displayableRoles, + auth: { + user: createAuthenticatedUser({ + visibleRole: displayableRoles[0], + }), + }, + can: { + updateMotto: true, + }, + userSettings: createUser(), + }, + }); + + // ACT + await userEvent.click(screen.getByRole('combobox', { name: /role/i })); + + // ASSERT + const optionEls = screen.getAllByRole('option'); + + expect(optionEls[0]).toHaveTextContent(/adev/i); + expect(optionEls[1]).toHaveTextContent(/mdev/i); + expect(optionEls[2]).toHaveTextContent(/zdev/i); + }); }); diff --git a/resources/js/features/settings/components/ProfileSectionCard/ProfileSectionCard.tsx b/resources/js/features/settings/components/ProfileSectionCard/ProfileSectionCard.tsx index 13934404e9..c04224541d 100644 --- a/resources/js/features/settings/components/ProfileSectionCard/ProfileSectionCard.tsx +++ b/resources/js/features/settings/components/ProfileSectionCard/ProfileSectionCard.tsx @@ -1,7 +1,6 @@ import { useMutation } from '@tanstack/react-query'; import axios from 'axios'; import type { FC } from 'react'; -import { useId } from 'react'; import { useTranslation } from 'react-i18next'; import { LuAlertCircle } from 'react-icons/lu'; @@ -20,6 +19,7 @@ import { usePageProps } from '@/common/hooks/usePageProps'; import { SectionFormCard } from '../SectionFormCard'; import { useProfileSectionForm } from './useProfileSectionForm'; +import { VisibleRoleField } from './VisibleRoleField'; export const ProfileSectionCard: FC = () => { const { auth, can, userSettings } = usePageProps(); @@ -33,6 +33,7 @@ export const ProfileSectionCard: FC = () => { } = useProfileSectionForm({ motto: userSettings.motto ?? '', userWallActive: userSettings.userWallActive ?? false, + visibleRoleId: auth?.user.visibleRole ? auth.user.visibleRole.id : null, }); const deleteAllCommentsMutation = useMutation({ @@ -41,8 +42,6 @@ export const ProfileSectionCard: FC = () => { }, }); - const visibleRoleFieldId = useId(); - const handleDeleteAllCommentsClick = () => { if (!confirm(t('Are you sure you want to permanently delete all comments on your wall?'))) { return; @@ -63,18 +62,7 @@ export const ProfileSectionCard: FC = () => { isSubmitting={formMutation.isPending} >
-
- -

- {userSettings.visibleRole ? ( - `${userSettings.visibleRole}` - ) : ( - {t('none')} - )} -

-
+ { + const { auth, displayableRoles } = usePageProps(); + + const { t } = useTranslation(); + + const form = useFormContext(); + + const visibleRoleFieldId = useId(); + + if (!displayableRoles?.length || displayableRoles.length <= 1) { + return ( +
+ +

+ {auth?.user.visibleRole ? ( + t(auth.user.visibleRole.name as TranslationKey) + ) : ( + {t('none')} + )} +

+
+ ); + } + + const sortedDisplayableRoles = displayableRoles.sort((a, b) => { + const aName = t(a.name as TranslationKey); + const bName = t(b.name as TranslationKey); + + return aName.localeCompare(bName); + }); + + return ( + ( + + + {t('Visible Role')} + + +
+ + + + + + + + {sortedDisplayableRoles.map((role) => ( + + {t(role.name as TranslationKey)} + + ))} + + + +
+
+ )} + /> + ); +}; diff --git a/resources/js/features/settings/components/ProfileSectionCard/useProfileSectionForm.ts b/resources/js/features/settings/components/ProfileSectionCard/useProfileSectionForm.ts index 654fde9243..2438ad3a3e 100644 --- a/resources/js/features/settings/components/ProfileSectionCard/useProfileSectionForm.ts +++ b/resources/js/features/settings/components/ProfileSectionCard/useProfileSectionForm.ts @@ -10,9 +10,15 @@ import { toastMessage } from '@/common/components/+vendor/BaseToaster'; const profileFormSchema = z.object({ motto: z.string().max(50), userWallActive: z.boolean(), + visibleRoleId: z + .string() // The incoming value is a string from a select field. + .nullable() + .transform((val) => Number(val)) // We need to convert it to a numeric ID. + .pipe(z.number().nullable()) + .optional(), }); -type FormValues = z.infer; +export type FormValues = z.infer; export function useProfileSectionForm(initialValues: FormValues) { const { t } = useTranslation(); @@ -24,7 +30,12 @@ export function useProfileSectionForm(initialValues: FormValues) { const mutation = useMutation({ mutationFn: (formValues: FormValues) => { - return axios.put(route('api.settings.profile.update'), formValues); + const payload = { ...formValues }; + if (!payload.visibleRoleId) { + delete payload.visibleRoleId; + } + + return axios.put(route('api.settings.profile.update'), payload); }, }); diff --git a/resources/js/types/generated.d.ts b/resources/js/types/generated.d.ts index 162ecaacd8..0232db2b11 100644 --- a/resources/js/types/generated.d.ts +++ b/resources/js/types/generated.d.ts @@ -130,6 +130,7 @@ declare namespace App.Community.Data { export type UserSettingsPageProps = { userSettings: App.Data.User; can: App.Data.UserPermissions; + displayableRoles: Array; }; } declare namespace App.Community.Enums { @@ -243,6 +244,10 @@ declare namespace App.Data { nextPageUrl: string | null; }; }; + export type Role = { + id: number; + name: string; + }; export type StaticData = { numGames: number; numAchievements: number; @@ -264,6 +269,7 @@ declare namespace App.Data { apiKey?: string | null; deleteRequested?: string | null; deletedAt?: string | null; + displayableRoles?: Array | null; emailAddress?: string | null; id?: number; isMuted?: boolean; @@ -279,7 +285,7 @@ declare namespace App.Data { unreadMessageCount?: number | null; username?: string | null; userWallActive?: boolean | null; - visibleRole?: string | null; + visibleRole?: App.Data.Role | null; websitePrefs?: number | null; preferences?: { shouldAlwaysBypassContentWarnings: boolean; prefersAbsoluteDates: boolean }; roles?: App.Models.UserRole[]; diff --git a/resources/js/types/i18next.d.ts b/resources/js/types/i18next.d.ts index 73c1bb261b..058abddce9 100644 --- a/resources/js/types/i18next.d.ts +++ b/resources/js/types/i18next.d.ts @@ -9,6 +9,8 @@ import type enUS from '../../../lang/en_US.json'; declare const TranslatedStringBrand: unique symbol; export type TranslatedString = string & { [TranslatedStringBrand]: never }; +export type TranslationKey = keyof typeof enUS; + declare module 'i18next' { interface CustomTypeOptions { resources: { @@ -18,6 +20,6 @@ declare module 'i18next' { // Extend t() to return TranslatedString. interface TFunction { - (key: TKey | TKey[], options?: object): TranslatedString; + (key: TKey | TKey[], options?: object): TranslatedString; } } diff --git a/resources/views/components/forum/topic-comment/author-box.blade.php b/resources/views/components/forum/topic-comment/author-box.blade.php index 62acd0aa7d..2ec82ec2b3 100644 --- a/resources/views/components/forum/topic-comment/author-box.blade.php +++ b/resources/views/components/forum/topic-comment/author-box.blade.php @@ -31,10 +31,9 @@ {!! userAvatar($author, icon: false, tooltip: true) !!}
- {{-- TODO display visible role --}} - @if ($author->getAttribute('Permissions') > Permissions::Registered) + @if ($author->visible_role?->name)

- {{ Permissions::toString($author->getAttribute('Permissions')) }} + {{ __('permission.role.' . $author->visible_role->name) }}

@endif diff --git a/resources/views/components/user/profile/primary-meta.blade.php b/resources/views/components/user/profile/primary-meta.blade.php index fa1eea3af1..afc2b44108 100644 --- a/resources/views/components/user/profile/primary-meta.blade.php +++ b/resources/views/components/user/profile/primary-meta.blade.php @@ -1,6 +1,7 @@ @@ -15,10 +16,7 @@ $me = Auth::user() ?? null; $hasVisibleRole = ( - ( - $userMassData['Permissions'] !== Permissions::Registered - && $userMassData['Permissions'] !== Permissions::Unregistered - ) + $user->visible_role?->name || ($me?->can('manage', App\Models\User::class) && $userMassData['Permissions'] !== Permissions::Registered) ); @@ -40,11 +38,18 @@ class="rounded-sm h-[64px] w-[64px] sm:max-h-[128px] sm:max-w-[128px] sm:min-w-[ {{-- Username --}}

{{ $user->display_name }}

- {{-- Legacy Role --}} - {{-- TODO: Support N roles. --}} + {{-- Visible Role --}} @if ($hasVisibleRole)
-

{{ $roleLabel }}

+

+ @if ($userMassData['Permissions'] === Permissions::Spam) + Spam + @elseif ($userMassData['Permissions'] === Permissions::Banned) + Banned + @else + {{ __('permission.role.' . $user->visible_role->name) }} + @endif +

@endif diff --git a/resources/views/pages-legacy/viewtopic.blade.php b/resources/views/pages-legacy/viewtopic.blade.php index 37f96c9f1a..43b0494c01 100644 --- a/resources/views/pages-legacy/viewtopic.blade.php +++ b/resources/views/pages-legacy/viewtopic.blade.php @@ -48,7 +48,7 @@ // Fetch comments $numTotalComments = $forumTopic->comments()->count(); $allForumTopicCommentsForTopic = $forumTopic->comments() - ->with(['user', 'forumTopic']) + ->with(['user.displayableRoles', 'forumTopic']) ->where('forum_topic_id', $requestedTopicID) ->orderBy('created_at', 'asc') ->offset($offset) diff --git a/tests/Feature/Community/Components/UserCardTest.php b/tests/Feature/Community/Components/UserCardTest.php index d58feaa4d9..0cebab8289 100644 --- a/tests/Feature/Community/Components/UserCardTest.php +++ b/tests/Feature/Community/Components/UserCardTest.php @@ -6,7 +6,9 @@ use App\Community\Enums\Rank; use App\Enums\Permissions; +use App\Models\Role; use App\Models\User; +use Database\Seeders\RolesTableSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -41,21 +43,24 @@ public function testItRendersRegisteredUserData(): void public function testItDisplaysUserRoleWhenAppropriate(): void { - User::factory()->create([ + $this->seed(RolesTableSeeder::class); + + /** @var User $user */ + $user = User::factory()->create([ 'User' => 'mockUser', 'Motto' => 'mockMotto', 'RAPoints' => 5000, 'RASoftcorePoints' => 50, 'TrueRAPoints' => 6500, 'Untracked' => false, - 'Permissions' => Permissions::JuniorDeveloper, 'Created' => '2023-07-01 00:00:00', 'LastLogin' => '2023-07-10 00:00:00', ]); + $user->assignRole(Role::DEVELOPER_JUNIOR); $view = $this->blade(''); - $view->assertSeeText(Permissions::toString(Permissions::JuniorDeveloper)); + $view->assertSeeText('Junior Developer'); } public function testItDoesntDisplayIfUserIsBanned(): void