├── js ├── admin.js ├── forum.js ├── dist-typings │ ├── forum │ │ ├── addProfilePane.d.ts │ │ ├── mutateUserHero.d.ts │ │ ├── index.d.ts │ │ ├── types │ │ │ ├── UrlField.d.ts │ │ │ ├── EmailField.d.ts │ │ │ ├── SelectField.d.ts │ │ │ ├── BooleanField.d.ts │ │ │ ├── index.d.ts │ │ │ ├── TypeFactory.d.ts │ │ │ └── BaseField.d.ts │ │ ├── extend.d.ts │ │ └── panes │ │ │ ├── index.d.ts │ │ │ ├── RootMasqueradePane.d.ts │ │ │ ├── ProfileConfigurePane.d.ts │ │ │ └── ProfilePane.d.ts │ ├── admin │ │ ├── index.d.ts │ │ ├── extend.d.ts │ │ └── components │ │ │ ├── FieldList.d.ts │ │ │ ├── index.d.ts │ │ │ ├── SelectFieldOptionEditor.d.ts │ │ │ ├── MasqueradePage.d.ts │ │ │ └── FieldEdit.d.ts │ ├── common │ │ └── extend.d.ts │ ├── lib │ │ └── models │ │ │ ├── Answer.d.ts │ │ │ └── Field.d.ts │ └── @types │ │ └── shims.d.ts ├── webpack.config.js ├── src │ ├── admin │ │ ├── extend.ts │ │ ├── components │ │ │ ├── index.ts │ │ │ ├── FieldList.ts │ │ │ ├── MasqueradePage.js │ │ │ ├── SelectFieldOptionEditor.js │ │ │ └── FieldEdit.js │ │ └── index.ts │ ├── common │ │ └── extend.ts │ ├── forum │ │ ├── panes │ │ │ ├── index.ts │ │ │ ├── RootMasqueradePane.tsx │ │ │ ├── ProfilePane.tsx │ │ │ └── ProfileConfigurePane.js │ │ ├── index.ts │ │ ├── types │ │ │ ├── index.ts │ │ │ ├── UrlField.js │ │ │ ├── EmailField.js │ │ │ ├── SelectField.js │ │ │ ├── TypeFactory.js │ │ │ ├── BooleanField.js │ │ │ └── BaseField.js │ │ ├── extend.ts │ │ ├── mutateUserHero.tsx │ │ └── addProfilePane.ts │ ├── @types │ │ └── shims.d.ts │ └── lib │ │ └── models │ │ ├── Answer.ts │ │ └── Field.js ├── dist │ ├── forum.js.LICENSE.txt │ ├── forum.js │ └── admin.js ├── tsconfig.json └── package.json ├── .github ├── CODEOWNERS └── workflows │ ├── backend.yml │ └── frontend.yml ├── src ├── Events │ ├── FieldCreated.php │ ├── FieldDeleted.php │ ├── FieldUpdated.php │ └── AbstractFieldEvent.php ├── FieldType │ ├── BaseField.php │ ├── UrlField.php │ ├── EmailField.php │ ├── BooleanField.php │ └── TypeFactory.php ├── Validators │ ├── OrderFieldValidator.php │ ├── AnswerValidator.php │ └── FieldValidator.php ├── UserAttributes.php ├── Api │ ├── Serializers │ │ ├── AnswerSerializer.php │ │ └── FieldSerializer.php │ └── Controllers │ │ ├── DeleteFieldController.php │ │ ├── FieldIndexController.php │ │ ├── StoreFieldController.php │ │ ├── UpdateFieldController.php │ │ ├── OrderFieldController.php │ │ ├── UserProfileController.php │ │ └── UserConfigureController.php ├── LoadAllMasqueradeFieldsRelationship.php ├── Content │ └── ViewProfile.php ├── ForumAttributes.php ├── Data │ └── MasqueradeAnswers.php ├── Answer.php ├── Gambits │ └── AnswerGambit.php ├── Field.php ├── Middleware │ └── DemandProfileCompletion.php └── Repositories │ └── FieldRepository.php ├── .editorconfig ├── phpstan.neon ├── migrations ├── 2019_06_10_01_rename_permissions.php ├── 2019_06_10_02_rename_flagrow_tables.php ├── 2019_06_10_03_create_fields_table.php └── 2019_06_10_04_create_answers_table.php ├── LICENSE.md ├── resources ├── less │ ├── admin.less │ └── forum.less ├── locale │ └── en.yml └── logo.svg ├── composer.json ├── README.md └── extend.php /js/admin.js: -------------------------------------------------------------------------------- 1 | export * from './src/admin'; 2 | -------------------------------------------------------------------------------- /js/forum.js: -------------------------------------------------------------------------------- 1 | export * from './src/forum'; 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @FriendsOfFlarum/maintainers 2 | -------------------------------------------------------------------------------- /js/dist-typings/forum/addProfilePane.d.ts: -------------------------------------------------------------------------------- 1 | export default function addProfilePane(): void; 2 | -------------------------------------------------------------------------------- /js/dist-typings/forum/mutateUserHero.d.ts: -------------------------------------------------------------------------------- 1 | export default function mutateUserHero(): void; 2 | -------------------------------------------------------------------------------- /js/webpack.config.js: -------------------------------------------------------------------------------- 1 | const config = require('flarum-webpack-config'); 2 | 3 | module.exports = config(); 4 | -------------------------------------------------------------------------------- /js/dist-typings/admin/index.d.ts: -------------------------------------------------------------------------------- 1 | export { default as extend } from './extend'; 2 | export * from './components'; 3 | -------------------------------------------------------------------------------- /js/src/admin/extend.ts: -------------------------------------------------------------------------------- 1 | import { default as commonExtend } from '../common/extend'; 2 | 3 | export default [...commonExtend]; 4 | -------------------------------------------------------------------------------- /js/dist-typings/admin/extend.d.ts: -------------------------------------------------------------------------------- 1 | declare const _default: import("flarum/common/extenders/Store").default[]; 2 | export default _default; 3 | -------------------------------------------------------------------------------- /js/dist-typings/common/extend.d.ts: -------------------------------------------------------------------------------- 1 | declare const _default: import("flarum/common/extenders/Store").default[]; 2 | export default _default; 3 | -------------------------------------------------------------------------------- /js/dist-typings/forum/index.d.ts: -------------------------------------------------------------------------------- 1 | export { default as extend } from './extend'; 2 | export * from './panes'; 3 | export * from './types'; 4 | -------------------------------------------------------------------------------- /src/Events/FieldCreated.php: -------------------------------------------------------------------------------- 1 | unknown; 5 | fieldId: () => unknown; 6 | field: () => Field; 7 | userId: () => unknown; 8 | } 9 | -------------------------------------------------------------------------------- /src/FieldType/UrlField.php: -------------------------------------------------------------------------------- 1 | 'url', 11 | ]; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/FieldType/EmailField.php: -------------------------------------------------------------------------------- 1 | 'email', 11 | ]; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/FieldType/BooleanField.php: -------------------------------------------------------------------------------- 1 | 'boolean', 11 | ]; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /js/src/forum/panes/index.ts: -------------------------------------------------------------------------------- 1 | import ProfileConfigurePane from './ProfileConfigurePane'; 2 | import ProfilePane from './ProfilePane'; 3 | import RootMasqueradePane from './RootMasqueradePane'; 4 | 5 | export const panes = { 6 | ProfileConfigurePane, 7 | ProfilePane, 8 | RootMasqueradePane, 9 | }; 10 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/flarum/phpstan/extension.neon 3 | 4 | parameters: 5 | # The level will be increased in Flarum 2.0 6 | level: 5 7 | paths: 8 | - extend.php 9 | - src 10 | excludePaths: 11 | - *.blade.php 12 | checkMissingIterableValueType: false 13 | databaseMigrationsPath: ['migrations'] 14 | -------------------------------------------------------------------------------- /js/src/admin/components/index.ts: -------------------------------------------------------------------------------- 1 | import FieldEdit from './FieldEdit'; 2 | import FieldList from './FieldList'; 3 | import MasqueradePage from './MasqueradePage'; 4 | import SelectFieldOptionEditor from './SelectFieldOptionEditor'; 5 | 6 | export const components = { 7 | FieldList, 8 | FieldEdit, 9 | MasqueradePage, 10 | SelectFieldOptionEditor, 11 | }; 12 | -------------------------------------------------------------------------------- /src/Validators/OrderFieldValidator.php: -------------------------------------------------------------------------------- 1 | ['required', 'array'], 13 | ]; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/backend.yml: -------------------------------------------------------------------------------- 1 | name: FoF Masquerade PHP 2 | 3 | on: [workflow_dispatch, push, pull_request] 4 | 5 | jobs: 6 | run: 7 | uses: flarum/framework/.github/workflows/REUSABLE_backend.yml@1.x 8 | with: 9 | enable_backend_testing: false 10 | enable_phpstan: true 11 | php_versions: '["8.1", "8.2", "8.3"]' 12 | 13 | backend_directory: . 14 | -------------------------------------------------------------------------------- /js/dist-typings/admin/components/FieldList.d.ts: -------------------------------------------------------------------------------- 1 | import type { Vnode } from 'mithril'; 2 | import type Field from '../../lib/models/Field'; 3 | interface FieldListAttrs { 4 | existing: Field[]; 5 | new: Field; 6 | loading: boolean; 7 | onUpdate: () => void; 8 | } 9 | export default class FieldList { 10 | view(vnode: Vnode): any; 11 | } 12 | export {}; 13 | -------------------------------------------------------------------------------- /js/dist-typings/forum/panes/index.d.ts: -------------------------------------------------------------------------------- 1 | import ProfileConfigurePane from './ProfileConfigurePane'; 2 | import ProfilePane from './ProfilePane'; 3 | import RootMasqueradePane from './RootMasqueradePane'; 4 | export declare const panes: { 5 | ProfileConfigurePane: typeof ProfileConfigurePane; 6 | ProfilePane: typeof ProfilePane; 7 | RootMasqueradePane: typeof RootMasqueradePane; 8 | }; 9 | -------------------------------------------------------------------------------- /js/src/forum/index.ts: -------------------------------------------------------------------------------- 1 | import app from 'flarum/forum/app'; 2 | import addProfilePane from './addProfilePane'; 3 | import mutateUserHero from './mutateUserHero'; 4 | 5 | app.initializers.add('fof-masquerade', () => { 6 | addProfilePane(); 7 | mutateUserHero(); 8 | }); 9 | 10 | export { default as extend } from './extend'; 11 | export * from './panes'; 12 | export * from './types'; 13 | -------------------------------------------------------------------------------- /js/src/@types/shims.d.ts: -------------------------------------------------------------------------------- 1 | import 'flarum/common/models/User'; 2 | 3 | import type Field from '../lib/models/Field'; 4 | import type Answer from '../lib/models/Answer'; 5 | 6 | declare module 'flarum/common/models/User' { 7 | export default interface User { 8 | bioFields(): Field[]; 9 | masqueradeAnswers(): Answer[]; 10 | canEditMasqueradeProfile(): boolean; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /js/dist-typings/@types/shims.d.ts: -------------------------------------------------------------------------------- 1 | import 'flarum/common/models/User'; 2 | 3 | import type Field from '../lib/models/Field'; 4 | import type Answer from '../lib/models/Answer'; 5 | 6 | declare module 'flarum/common/models/User' { 7 | export default interface User { 8 | bioFields(): Field[]; 9 | masqueradeAnswers(): Answer[]; 10 | canEditMasqueradeProfile(): boolean; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /js/src/forum/types/index.ts: -------------------------------------------------------------------------------- 1 | import BaseField from './BaseField'; 2 | import BooleanField from './BooleanField'; 3 | import EmailField from './EmailField'; 4 | import SelectField from './SelectField'; 5 | import TypeFactory from './TypeFactory'; 6 | import UrlField from './UrlField'; 7 | 8 | export const types = { 9 | BaseField, 10 | BooleanField, 11 | EmailField, 12 | SelectField, 13 | TypeFactory, 14 | UrlField, 15 | }; 16 | -------------------------------------------------------------------------------- /js/dist-typings/forum/panes/RootMasqueradePane.d.ts: -------------------------------------------------------------------------------- 1 | import UserPage from 'flarum/forum/components/UserPage'; 2 | import type User from 'flarum/common/models/User'; 3 | import type Mithril from 'mithril'; 4 | export default class RootMasqueradePane extends UserPage { 5 | loading: boolean; 6 | oninit(vnode: Mithril.Vnode): void; 7 | pageContentComponent(): JSX.Element | null; 8 | show(user: User): void; 9 | content(): JSX.Element; 10 | } 11 | -------------------------------------------------------------------------------- /js/dist-typings/admin/components/index.d.ts: -------------------------------------------------------------------------------- 1 | import FieldEdit from './FieldEdit'; 2 | import FieldList from './FieldList'; 3 | import MasqueradePage from './MasqueradePage'; 4 | import SelectFieldOptionEditor from './SelectFieldOptionEditor'; 5 | export declare const components: { 6 | FieldList: typeof FieldList; 7 | FieldEdit: typeof FieldEdit; 8 | MasqueradePage: typeof MasqueradePage; 9 | SelectFieldOptionEditor: typeof SelectFieldOptionEditor; 10 | }; 11 | -------------------------------------------------------------------------------- /js/dist-typings/lib/models/Field.d.ts: -------------------------------------------------------------------------------- 1 | export default class Field extends Model { 2 | name: () => any; 3 | description: () => any; 4 | type: () => any; 5 | validation: () => any; 6 | required: () => any; 7 | prefix: () => any; 8 | icon: () => any; 9 | sort: () => any; 10 | deleted_at: () => Date | null | undefined; 11 | answer: () => false | Model; 12 | on_bio: () => any; 13 | } 14 | import Model from "flarum/common/Model"; 15 | -------------------------------------------------------------------------------- /js/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "flarum-tsconfig", 3 | "include": ["src/**/*", "../vendor/*/*/js/dist-typings/@types/**/*", "@types/**/*"], 4 | "compilerOptions": { 5 | "declarationDir": "./dist-typings", 6 | "noUnusedLocals": true, 7 | "noUnusedParameters": true, 8 | "baseUrl": ".", 9 | "paths": { 10 | "flarum/*": ["../vendor/flarum/core/js/dist-typings/*"], 11 | "@flarum/core/*": ["../vendor/flarum/core/js/dist-typings/*"] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /js/dist-typings/forum/types/BooleanField.d.ts: -------------------------------------------------------------------------------- 1 | export default class BooleanField extends BaseField { 2 | editorInput(): any[]; 3 | options(): ({ 4 | selected: {}; 5 | key: null; 6 | label: any; 7 | } | { 8 | selected: {}; 9 | key: string; 10 | label: any; 11 | } | { 12 | selected: () => boolean; 13 | key: any; 14 | label: string; 15 | })[]; 16 | answerContent(): any[] | ""; 17 | } 18 | import BaseField from "./BaseField"; 19 | -------------------------------------------------------------------------------- /js/dist-typings/admin/components/SelectFieldOptionEditor.d.ts: -------------------------------------------------------------------------------- 1 | export default class SelectFieldOptionEditor extends Component { 2 | constructor(); 3 | oninit(vnode: any): void; 4 | newOption: any; 5 | view(): any; 6 | updateRules(options: any): void; 7 | options(): any[]; 8 | updateOption(index: any, value: any): void; 9 | moveOption(index: any, moveIndex: any): void; 10 | deleteOption(index: any): void; 11 | addOption(): void; 12 | } 13 | import Component from "flarum/common/Component"; 14 | -------------------------------------------------------------------------------- /js/src/lib/models/Answer.ts: -------------------------------------------------------------------------------- 1 | import app from 'flarum/common/app'; 2 | import Model from 'flarum/common/Model'; 3 | import computed from 'flarum/common/utils/computed'; 4 | 5 | import type Field from './Field'; 6 | 7 | export default class Answer extends Model { 8 | content = Model.attribute('content'); 9 | fieldId = Model.attribute('fieldId'); 10 | // @ts-ignore 11 | field = computed('fieldId', (fieldId: string) => { 12 | return app.store.getById('masquerade-field', fieldId); 13 | }); 14 | userId = Model.attribute('user_id'); 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/frontend.yml: -------------------------------------------------------------------------------- 1 | name: FoF Masquerade JS 2 | 3 | on: [workflow_dispatch, push, pull_request] 4 | 5 | jobs: 6 | run: 7 | uses: flarum/framework/.github/workflows/REUSABLE_frontend.yml@1.x 8 | with: 9 | enable_bundlewatch: false 10 | enable_prettier: true 11 | enable_typescript: true 12 | 13 | frontend_directory: ./js 14 | backend_directory: . 15 | js_package_manager: yarn 16 | main_git_branch: master 17 | 18 | secrets: 19 | bundlewatch_github_token: ${{ secrets.BUNDLEWATCH_GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /js/dist-typings/forum/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import BaseField from './BaseField'; 2 | import BooleanField from './BooleanField'; 3 | import EmailField from './EmailField'; 4 | import SelectField from './SelectField'; 5 | import TypeFactory from './TypeFactory'; 6 | import UrlField from './UrlField'; 7 | export declare const types: { 8 | BaseField: typeof BaseField; 9 | BooleanField: typeof BooleanField; 10 | EmailField: typeof EmailField; 11 | SelectField: typeof SelectField; 12 | TypeFactory: typeof TypeFactory; 13 | UrlField: typeof UrlField; 14 | }; 15 | -------------------------------------------------------------------------------- /src/Events/AbstractFieldEvent.php: -------------------------------------------------------------------------------- 1 | function (Builder $schema) { 8 | Permission::query() 9 | ->where('permission', 'flagrow.masquerade.view-profile') 10 | ->update(['permission' => 'fof.masquerade.view-profile']); 11 | Permission::query() 12 | ->where('permission', 'flagrow.masquerade.have-profile') 13 | ->update(['permission' => 'fof.masquerade.have-profile']); 14 | }, 15 | 'down' => function (Builder $schema) { 16 | // Not doing anything but `down` has to be defined 17 | }, 18 | ]; 19 | -------------------------------------------------------------------------------- /js/src/lib/models/Field.js: -------------------------------------------------------------------------------- 1 | import Model from 'flarum/common/Model'; 2 | 3 | export default class Field extends Model { 4 | name = Model.attribute('name'); 5 | description = Model.attribute('description'); 6 | type = Model.attribute('type'); 7 | validation = Model.attribute('validation'); 8 | required = Model.attribute('required'); 9 | prefix = Model.attribute('prefix'); 10 | icon = Model.attribute('icon'); 11 | sort = Model.attribute('sort'); 12 | deleted_at = Model.attribute('deleted_at', Model.transformDate); 13 | answer = Model.hasOne('answer'); 14 | on_bio = Model.attribute('on_bio'); 15 | 16 | apiEndpoint() { 17 | return '/masquerade/fields' + (this.exists ? '/' + this.data.id : ''); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Validators/AnswerValidator.php: -------------------------------------------------------------------------------- 1 | required) { 19 | $rules[] = 'required'; 20 | } 21 | 22 | if ($field->validation) { 23 | $rules = array_merge($rules, explode('|', $field->validation)); 24 | } 25 | 26 | $this->rules = [$field->name => $rules]; 27 | 28 | return $this; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /js/src/admin/components/FieldList.ts: -------------------------------------------------------------------------------- 1 | import FieldEdit from './FieldEdit'; 2 | 3 | import type { Vnode } from 'mithril'; 4 | import type Field from '../../lib/models/Field'; 5 | 6 | interface FieldListAttrs { 7 | existing: Field[]; 8 | new: Field; 9 | loading: boolean; 10 | onUpdate: () => void; 11 | } 12 | 13 | export default class FieldList { 14 | view(vnode: Vnode) { 15 | const { existing, new: newField, loading, onUpdate } = vnode.attrs; 16 | 17 | return m( 18 | 'form.js-sortable-fields', 19 | existing.map((field: Field) => { 20 | return m(FieldEdit, { field, loading, onUpdate }); 21 | }), 22 | m(FieldEdit, { field: newField, loading, onUpdate }) 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/UserAttributes.php: -------------------------------------------------------------------------------- 1 | getActor(); 13 | 14 | if ($actor->id === $user->id) { 15 | // Own profile 16 | $attributes['canEditMasqueradeProfile'] = $actor->can('fof.masquerade.have-profile'); 17 | } else { 18 | // Other's profile 19 | $attributes['canEditMasqueradeProfile'] = $actor->can('fof.masquerade.edit-others-profile'); 20 | } 21 | 22 | return $attributes; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /js/dist-typings/forum/panes/ProfileConfigurePane.d.ts: -------------------------------------------------------------------------------- 1 | export default class ProfileConfigurePane extends Component { 2 | constructor(); 3 | oninit(vnode: any): void; 4 | loading: boolean | undefined; 5 | enforceProfileCompletion: {} | undefined; 6 | profileCompleted: boolean | {} | undefined; 7 | profileNowCompleted: boolean | undefined; 8 | answers: any; 9 | answerValues: {} | undefined; 10 | user: any; 11 | dirty: boolean | undefined; 12 | view(): JSX.Element; 13 | field(field: any): JSX.Element; 14 | load(): void; 15 | set(field: any, value: any): void; 16 | update(e: any): void; 17 | parseResponse(response: any): void; 18 | } 19 | import Component from "flarum/common/Component"; 20 | -------------------------------------------------------------------------------- /js/src/forum/extend.ts: -------------------------------------------------------------------------------- 1 | import Extend from 'flarum/common/extenders'; 2 | import User from 'flarum/common/models/User'; 3 | import Field from '../lib/models/Field'; 4 | import Answer from '../lib/models/Answer'; 5 | import RootMasqueradePane from './panes/RootMasqueradePane'; 6 | 7 | import { default as commonExtend } from '../common/extend'; 8 | 9 | export default [ 10 | ...commonExtend, 11 | 12 | new Extend.Routes() // 13 | .add('fof-masquerade', '/u/:username/masquerade', RootMasqueradePane), 14 | 15 | new Extend.Store() // 16 | .add('masquerade-answer', Answer), 17 | 18 | new Extend.Model(User) // 19 | .hasMany('bioFields') 20 | .hasMany('masqueradeAnswers') 21 | .attribute('canEditMasqueradeProfile'), 22 | ]; 23 | -------------------------------------------------------------------------------- /src/Validators/FieldValidator.php: -------------------------------------------------------------------------------- 1 | ['required', 'string'], 14 | 'description' => ['string'], 15 | 'required' => ['boolean'], 16 | 'type' => ['nullable', 'in:' . implode(',', TypeFactory::validTypes())], 17 | 'validation' => ['nullable', 'string'], 18 | 'icon' => ['string'], 19 | 'prefix' => ['nullable', 'string'], 20 | 'on_bio' => ['boolean'] 21 | ]; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /js/dist-typings/admin/components/MasqueradePage.d.ts: -------------------------------------------------------------------------------- 1 | export default class MasqueradePage extends ExtensionPage { 2 | constructor(); 3 | oninit(vnode: any): void; 4 | existing: any[] | import("flarum/common/Model").default[] | undefined; 5 | enforceProfileCompletion: string | boolean | undefined; 6 | config(): void; 7 | oncreate(vnode: any): void; 8 | onupdate(): void; 9 | content(): any; 10 | updateSort(sorting: any): void; 11 | requestSuccess(): void; 12 | loadExisting(): Promise; 13 | resetNew(): void; 14 | newField: import("flarum/common/Model").default | undefined; 15 | } 16 | import ExtensionPage from "flarum/admin/components/ExtensionPage"; 17 | -------------------------------------------------------------------------------- /js/dist-typings/forum/panes/ProfilePane.d.ts: -------------------------------------------------------------------------------- 1 | import Component, { ComponentAttrs } from 'flarum/common/Component'; 2 | import type Answer from '../../lib/models/Answer'; 3 | import type Field from 'src/lib/models/Field'; 4 | import type User from 'flarum/common/models/User'; 5 | import type Mithril from 'mithril'; 6 | export interface ProfilePaneAttrs extends ComponentAttrs { 7 | answers: Answer[]; 8 | user: User; 9 | loading: boolean; 10 | } 11 | export default class ProfilePane extends Component { 12 | answers: Answer[]; 13 | user: User; 14 | loading: boolean; 15 | oninit(vnode: Mithril.Vnode): void; 16 | view(): JSX.Element; 17 | field(field: Field, content: Answer | null): JSX.Element; 18 | load(): Promise; 19 | } 20 | -------------------------------------------------------------------------------- /migrations/2019_06_10_02_rename_flagrow_tables.php: -------------------------------------------------------------------------------- 1 | function (Builder $schema) { 7 | // Re-use the tables from the Flagrow version if they exist 8 | if ($schema->hasTable('flagrow_masquerade_fields') && !$schema->hasTable('fof_masquerade_fields')) { 9 | $schema->rename('flagrow_masquerade_fields', 'fof_masquerade_fields'); 10 | } 11 | if ($schema->hasTable('flagrow_masquerade_answers') && !$schema->hasTable('fof_masquerade_answers')) { 12 | $schema->rename('flagrow_masquerade_answers', 'fof_masquerade_answers'); 13 | } 14 | }, 15 | 'down' => function (Builder $schema) { 16 | // Not doing anything but `down` has to be defined 17 | }, 18 | ]; 19 | -------------------------------------------------------------------------------- /src/Api/Serializers/AnswerSerializer.php: -------------------------------------------------------------------------------- 1 | toArray(), [ 16 | 'fieldId' => $model->field_id 17 | ]); 18 | } 19 | 20 | public function getType($model): string 21 | { 22 | return 'masquerade-answer'; 23 | } 24 | 25 | public function field($model): ?Relationship 26 | { 27 | return $this->hasOne( 28 | $model->field, 29 | FieldSerializer::class 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /js/src/forum/types/UrlField.js: -------------------------------------------------------------------------------- 1 | import Button from 'flarum/common/components/Button'; 2 | import BaseField from './BaseField'; 3 | 4 | export default class UrlField extends BaseField { 5 | editorInputAttrs() { 6 | let attrs = super.editorInputAttrs(); 7 | 8 | attrs.type = 'url'; 9 | attrs.placeholder = 'https://example.com'; 10 | 11 | return attrs; 12 | } 13 | 14 | answerContent() { 15 | const value = this.value; 16 | 17 | if (!value) { 18 | return null; 19 | } 20 | 21 | return Button.component( 22 | { 23 | onclick: () => this.to(), 24 | className: 'Button Button--text', 25 | icon: 'fas fa-link', 26 | }, 27 | value.replace(/^https?:\/\//, '') 28 | ); 29 | } 30 | 31 | to() { 32 | const popup = window.open(); 33 | popup.location = this.value; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /js/dist-typings/forum/types/TypeFactory.d.ts: -------------------------------------------------------------------------------- 1 | export default class TypeFactory { 2 | static typeForField({ field, set, value }: { 3 | field: any; 4 | set?: undefined; 5 | value: any; 6 | }): BaseField; 7 | static fieldAttribute(field: any, attribute: any): any; 8 | static types(): { 9 | boolean: typeof BooleanField; 10 | email: typeof EmailField; 11 | select: typeof SelectField; 12 | url: typeof UrlField; 13 | }; 14 | /** 15 | * Identifies how to parse the field answer. 16 | * @returns {null|string} 17 | */ 18 | static identify(field: any): null | string; 19 | } 20 | import BaseField from "./BaseField"; 21 | import BooleanField from "./BooleanField"; 22 | import EmailField from "./EmailField"; 23 | import SelectField from "./SelectField"; 24 | import UrlField from "./UrlField"; 25 | -------------------------------------------------------------------------------- /src/Api/Controllers/DeleteFieldController.php: -------------------------------------------------------------------------------- 1 | assertAdmin(); 23 | 24 | $id = Arr::get($request->getQueryParams(), 'id'); 25 | 26 | $field = $this->fields->findOrFail($id); 27 | 28 | $this->fields->delete($actor, $field); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Api/Controllers/FieldIndexController.php: -------------------------------------------------------------------------------- 1 | fields = $fields; 21 | } 22 | 23 | protected function data(ServerRequestInterface $request, Document $document) 24 | { 25 | RequestUtil::getActor($request)->assertAdmin(); 26 | 27 | return $this->fields->all(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/LoadAllMasqueradeFieldsRelationship.php: -------------------------------------------------------------------------------- 1 | { 24 | return segment.replace(/(.{2})./g, '$1*'); 25 | }) 26 | .join('*'); 27 | 28 | return Button.component( 29 | { 30 | onclick: () => this.mailTo(), 31 | className: 'Button Button--text', 32 | icon: 'far fa-envelope', 33 | }, 34 | email 35 | ); 36 | } 37 | 38 | mailTo() { 39 | window.location = 'mailto:' + this.value; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Content/ViewProfile.php: -------------------------------------------------------------------------------- 1 | users = $users; 21 | } 22 | 23 | public function __invoke(Document $document, ServerRequestInterface $request) 24 | { 25 | $actor = RequestUtil::getActor($request); 26 | 27 | $slug = Arr::get($request->getQueryParams(), 'username'); 28 | $user = $this->users->findOrFailByUsername($slug); 29 | 30 | if ($user->id !== $actor->id) { 31 | $actor->assertCan('fof.masquerade.edit-others-profile'); 32 | } 33 | 34 | $actor->assertCan('fof.masquerade.view-profile'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Api/Controllers/StoreFieldController.php: -------------------------------------------------------------------------------- 1 | assertAdmin(); 27 | 28 | $attributes = Arr::get($request->getParsedBody(), 'data.attributes', []); 29 | 30 | return $this->fields->store($actor, $attributes); 31 | } 32 | } -------------------------------------------------------------------------------- /src/ForumAttributes.php: -------------------------------------------------------------------------------- 1 | settings = $settings; 17 | $this->fields = $fields; 18 | } 19 | 20 | public function __invoke(ForumSerializer $serializer): array 21 | { 22 | $actor = $serializer->getActor(); 23 | 24 | return [ 25 | 'masquerade.force-profile-completion' => (bool)$this->settings->get('masquerade.force-profile-completion', false), 26 | 'masquerade.profile-completed' => $actor->isGuest() ? false : $this->fields->completed($actor->id), 27 | 'canViewMasquerade' => $actor->can('fof.masquerade.view-profile'), 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /js/dist-typings/forum/types/BaseField.d.ts: -------------------------------------------------------------------------------- 1 | export default class BaseField { 2 | static isNoOptionSelectedValue(value: any): boolean; 3 | constructor({ field, set, value }: { 4 | field: any; 5 | set: any; 6 | value: any; 7 | }); 8 | field: any; 9 | set: any; 10 | value: any; 11 | readAttribute(object: any, attribute: any): any; 12 | /** 13 | * Gets all Laravel validation rules split by rule 14 | * @returns {Array} 15 | */ 16 | validationRules(): any[]; 17 | /** 18 | * Gets a Laravel validation rule by name 19 | * @param {string} ruleName 20 | * @returns {string|null} 21 | */ 22 | validationRule(ruleName: string): string | null; 23 | editorField(): JSX.Element; 24 | editorInput(): JSX.Element; 25 | editorInputAttrs(): { 26 | className: string; 27 | oninput: (event: any) => void; 28 | value: any; 29 | required: any; 30 | }; 31 | answerField(): JSX.Element; 32 | answerContent(): any; 33 | hasAnswer(): boolean; 34 | } 35 | -------------------------------------------------------------------------------- /src/Data/MasqueradeAnswers.php: -------------------------------------------------------------------------------- 1 | where('user_id', '=', $this->user->id) 17 | ->each(function (Answer $answer) use (&$data) { 18 | $data[] = ["masquerade/answer-{$answer->id}.json" => $this->encodeForExport($this->sanitize($answer))]; 19 | }); 20 | 21 | return $data; 22 | } 23 | 24 | protected function sanitize(Answer $answer): array 25 | { 26 | return Arr::except($answer->toArray(), ['id', 'user_id']); 27 | } 28 | 29 | public function anonymize(): void 30 | { 31 | $this->delete(); 32 | } 33 | 34 | public function delete(): void 35 | { 36 | Answer::query() 37 | ->where('user_id', '=', $this->user->id) 38 | ->delete(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /js/src/forum/mutateUserHero.tsx: -------------------------------------------------------------------------------- 1 | import { extend } from 'flarum/common/extend'; 2 | import app from 'flarum/forum/app'; 3 | import UserCard from 'flarum/forum/components/UserCard'; 4 | import TypeFactory from './types/TypeFactory'; 5 | 6 | import type ItemList from 'flarum/common/utils/ItemList'; 7 | import type Mithril from 'mithril'; 8 | import type User from 'flarum/common/models/User'; 9 | 10 | export default function mutateUserHero() { 11 | extend(UserCard.prototype, 'infoItems', function (items: ItemList) { 12 | const user = (this.attrs as { user: User }).user; 13 | const answers = app.forum.attribute('canViewMasquerade') ? user.bioFields() || [] : []; 14 | 15 | items.add( 16 | 'masquerade-bio', 17 |
18 | {answers.map((answer) => { 19 | const field = answer.attribute('field'); 20 | const type = TypeFactory.typeForField({ 21 | field, 22 | value: answer.attribute('content'), 23 | }); 24 | 25 | return type.answerField(); 26 | })} 27 |
28 | ); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /src/Answer.php: -------------------------------------------------------------------------------- 1 | belongsTo(User::class); 39 | } 40 | 41 | public function field(): BelongsTo 42 | { 43 | return $this->belongsTo(Field::class); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /js/src/forum/addProfilePane.ts: -------------------------------------------------------------------------------- 1 | import app from 'flarum/forum/app'; 2 | 3 | import { extend } from 'flarum/common/extend'; 4 | import LinkButton from 'flarum/common/components/LinkButton'; 5 | import UserPage from 'flarum/forum/components/UserPage'; 6 | 7 | import type ItemList from 'flarum/common/utils/ItemList'; 8 | import type Mithril from 'mithril'; 9 | 10 | export default function addProfilePane() { 11 | extend(UserPage.prototype, 'navItems', function (items: ItemList) { 12 | if (app.forum.attribute('canViewMasquerade') || this.user?.canEditMasqueradeProfile()) { 13 | const edit = this.user?.canEditMasqueradeProfile(); 14 | 15 | items.add( 16 | 'masquerade', 17 | LinkButton.component( 18 | { 19 | href: app.route('fof-masquerade', { username: this.user?.slug() }), 20 | icon: 'far fa-id-card', 21 | 'data-editProfile': edit, 22 | }, 23 | app.translator.trans(`fof-masquerade.forum.buttons.${edit ? 'edit' : 'view'}-profile`) 24 | ), 25 | 200 26 | ); 27 | } 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /src/Api/Controllers/UpdateFieldController.php: -------------------------------------------------------------------------------- 1 | assertAdmin(); 27 | 28 | $id = Arr::get($request->getQueryParams(), 'id'); 29 | 30 | $field = $this->field->findOrFail($id); 31 | 32 | $attributes = Arr::get($request->getParsedBody(), 'data.attributes', []); 33 | 34 | return $this->field->update($actor, $field, $attributes); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Api/Serializers/FieldSerializer.php: -------------------------------------------------------------------------------- 1 | toArray(); 19 | } 20 | 21 | public function getType($model): string 22 | { 23 | return 'masquerade-field'; 24 | } 25 | 26 | public function answer($model): ?Relationship 27 | { 28 | if ($this->getActor()->isGuest()) { 29 | return null; 30 | } 31 | 32 | $for = $model->for ?: $this->getActor()->id; 33 | 34 | if ($answer = $model->answers()->where('user_id', $for)->first()) { 35 | return new Relationship(new Resource( 36 | $answer, 37 | new AnswerSerializer 38 | )); 39 | } else { 40 | return null; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019-2021 FriendsOfFlarum 4 | Copyright (c) 2017-2018 Flagrow 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /js/dist-typings/admin/components/FieldEdit.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | export default class FieldEdit { 3 | view(vnode: any): JSX.Element; 4 | fieldItems(field: any, onUpdate: any): ItemList; 5 | updateExistingFieldInput(what: any, field: any, value: any): void; 6 | deleteField(field: any, onUpdate: any): void; 7 | toggleField(e: any): void; 8 | submitAddField(field: any, onUpdate: any, e: any): void; 9 | updateExistingField(field: any, onUpdate: any): void; 10 | resetNewField(): void; 11 | newField: import("flarum/common/Model").default | undefined; 12 | readyToAdd(field: any): boolean; 13 | availableTypes(): { 14 | url: import("@askvortsov/rich-icu-message-formatter").NestedStringArray; 15 | email: import("@askvortsov/rich-icu-message-formatter").NestedStringArray; 16 | boolean: import("@askvortsov/rich-icu-message-formatter").NestedStringArray; 17 | select: import("@askvortsov/rich-icu-message-formatter").NestedStringArray; 18 | null: import("@askvortsov/rich-icu-message-formatter").NestedStringArray; 19 | }; 20 | } 21 | import ItemList from "flarum/common/utils/ItemList"; 22 | -------------------------------------------------------------------------------- /js/src/admin/index.ts: -------------------------------------------------------------------------------- 1 | import app from 'flarum/admin/app'; 2 | import MasqueradePage from './components/MasqueradePage'; 3 | 4 | app.initializers.add('fof-masquerade', () => { 5 | app.extensionData 6 | .for('fof-masquerade') 7 | .registerPage(MasqueradePage) 8 | .registerPermission( 9 | { 10 | icon: 'far fa-id-card', 11 | label: app.translator.trans('fof-masquerade.admin.permissions.view-profile'), 12 | permission: 'fof.masquerade.view-profile', 13 | allowGuest: true, 14 | }, 15 | 'view' 16 | ) 17 | .registerPermission( 18 | { 19 | icon: 'far fa-id-card', 20 | label: app.translator.trans('fof-masquerade.admin.permissions.have-profile'), 21 | permission: 'fof.masquerade.have-profile', 22 | }, 23 | 'start' 24 | ) 25 | .registerPermission( 26 | { 27 | icon: 'far fa-id-card', 28 | label: app.translator.trans('fof-masquerade.admin.permissions.edit-others-profile'), 29 | permission: 'fof.masquerade.edit-others-profile', 30 | }, 31 | 'moderate' 32 | ); 33 | }); 34 | 35 | export { default as extend } from './extend'; 36 | export * from './components'; 37 | -------------------------------------------------------------------------------- /migrations/2019_06_10_03_create_fields_table.php: -------------------------------------------------------------------------------- 1 | function (Builder $schema) { 8 | if ($schema->hasTable('fof_masquerade_fields')) { 9 | return; 10 | } 11 | // This migration includes the changes made through multiple migrations in the Flagrow version (up to v0.2.1) 12 | $schema->create('fof_masquerade_fields', function (Blueprint $table) { 13 | $table->increments('id'); 14 | $table->string('name'); 15 | $table->string('description')->nullable(); 16 | $table->boolean('required')->default(false)->index(); 17 | $table->string('validation')->nullable(); 18 | $table->string('prefix')->nullable(); 19 | $table->string('icon')->nullable(); 20 | $table->integer('sort')->nullable()->index(); 21 | $table->boolean('on_bio')->default(false); 22 | $table->string('type')->nullable()->index(); 23 | $table->timestamps(); 24 | $table->softDeletes(); 25 | }); 26 | }, 27 | 'down' => function (Builder $schema) { 28 | $schema->dropIfExists('fof_masquerade_fields'); 29 | } 30 | ]; 31 | -------------------------------------------------------------------------------- /src/Api/Controllers/OrderFieldController.php: -------------------------------------------------------------------------------- 1 | validator = $validator; 24 | $this->fields = $fields; 25 | } 26 | 27 | protected function data(ServerRequestInterface $request, Document $document) 28 | { 29 | RequestUtil::getActor($request)->assertAdmin(); 30 | 31 | $attributes = $request->getParsedBody(); 32 | 33 | $this->validator->assertValid($attributes); 34 | 35 | $order = Arr::get($attributes, 'sort'); 36 | 37 | $this->fields->sorting($order); 38 | 39 | return $this->fields->all(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /migrations/2019_06_10_04_create_answers_table.php: -------------------------------------------------------------------------------- 1 | function (Builder $schema) { 8 | if ($schema->hasTable('fof_masquerade_answers')) { 9 | return; 10 | } 11 | // This migration includes the changes made through multiple migrations in the Flagrow version (up to v0.2.1) 12 | $schema->create('fof_masquerade_answers', function (Blueprint $table) { 13 | $table->increments('id'); 14 | $table->integer('field_id')->unsigned(); 15 | $table->integer('user_id')->unsigned(); 16 | $table->text('content')->nullable(); 17 | $table->timestamps(); 18 | 19 | $table->unique(['field_id', 'user_id']); 20 | 21 | $table->foreign('field_id') 22 | ->references('id') 23 | ->on('fof_masquerade_fields') 24 | ->onDelete('cascade'); 25 | 26 | $table->foreign('user_id') 27 | ->references('id') 28 | ->on('users') 29 | ->onDelete('cascade'); 30 | }); 31 | }, 32 | 'down' => function (Builder $schema) { 33 | $schema->dropIfExists('fof_masquerade_answers'); 34 | } 35 | ]; 36 | -------------------------------------------------------------------------------- /js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fof/masquerade", 3 | "private": true, 4 | "prettier": "@flarum/prettier-config", 5 | "dependencies": { 6 | "html5sortable": "^0.9.18" 7 | }, 8 | "devDependencies": { 9 | "@flarum/prettier-config": "^1.0.0", 10 | "flarum-tsconfig": "^1.0.3", 11 | "flarum-webpack-config": "^2.0.0", 12 | "prettier": "^3.0.3", 13 | "typescript-coverage-report": "^0.6.1", 14 | "webpack": "^5.94.0", 15 | "webpack-cli": "^5.1.4" 16 | }, 17 | "scripts": { 18 | "dev": "webpack --mode development --watch", 19 | "build": "webpack --mode production", 20 | "analyze": "cross-env ANALYZER=true yarn run build", 21 | "format": "prettier --write src", 22 | "format-check": "prettier --check src", 23 | "clean-typings": "npx rimraf dist-typings && mkdir dist-typings", 24 | "build-typings": "yarn run clean-typings && ([ -e src/@types ] && cp -r src/@types dist-typings/@types || true) && tsc && yarn run post-build-typings", 25 | "post-build-typings": "find dist-typings -type f -name '*.d.ts' -print0 | xargs -0 sed -i 's,../src/@types,@types,g'", 26 | "check-typings": "tsc --noEmit --emitDeclarationOnly false", 27 | "check-typings-coverage": "typescript-coverage-report" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /js/src/forum/panes/RootMasqueradePane.tsx: -------------------------------------------------------------------------------- 1 | import app from 'flarum/forum/app'; 2 | import UserPage from 'flarum/forum/components/UserPage'; 3 | import LoadingIndicator from 'flarum/common/components/LoadingIndicator'; 4 | import ProfilePane from './ProfilePane'; 5 | import ProfileConfigurePane from './ProfileConfigurePane'; 6 | 7 | import type User from 'flarum/common/models/User'; 8 | import type Mithril from 'mithril'; 9 | 10 | export default class RootMasqueradePane extends UserPage { 11 | loading = true; 12 | 13 | oninit(vnode: Mithril.Vnode) { 14 | super.oninit(vnode); 15 | 16 | if (!app.forum.attribute('canViewMasquerade')) { 17 | m.route.set(app.route('index')); 18 | } 19 | 20 | this.loadUser(m.route.param('username')); 21 | } 22 | 23 | pageContentComponent() { 24 | if (!this.user) return null; 25 | 26 | if (this.user.canEditMasqueradeProfile()) return ; 27 | else return ; 28 | } 29 | 30 | show(user: User) { 31 | super.show(user); 32 | 33 | this.loading = false; 34 | m.redraw(); 35 | } 36 | 37 | content() { 38 | return ( 39 |
40 | {this.loading && } 41 | {this.pageContentComponent()} 42 |
43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /resources/less/admin.less: -------------------------------------------------------------------------------- 1 | .ProfileConfigurePane { 2 | fieldset.Field { 3 | padding: 5px; 4 | background: @body-bg; 5 | border-radius: @border-radius; 6 | box-shadow: 0 0 3px rgba(0, 0, 0, 0.3); 7 | margin-top: 20px; 8 | 9 | legend { 10 | margin: 10px; 11 | width: 100%; 12 | 13 | .Field-toggle { 14 | cursor: pointer; 15 | margin-left: 5px; 16 | 17 | .icon { 18 | margin-left: 5px; 19 | } 20 | } 21 | } 22 | 23 | &.active .Field-toggle .icon::before { 24 | // We manipulate the icon via CSS because the whole open/close toggle system is currently CSS based 25 | // If we move to a component state-based toggle we can switch the icon in the JS template instead 26 | content: '\f0d8'; // caret-up 27 | } 28 | 29 | .Field-body { 30 | display: none; 31 | } 32 | 33 | &.active .Field-body { 34 | display: block; 35 | } 36 | } 37 | 38 | .Checkbox--switch.off .Checkbox-display { 39 | background: #d8d8d8; 40 | } 41 | 42 | @media @desktop-up { 43 | .container { 44 | max-width: 600px; 45 | margin: 0; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /resources/less/forum.less: -------------------------------------------------------------------------------- 1 | .ProfileConfigurePane { 2 | .Fields { 3 | margin: 10px 0; 4 | 5 | .FormField { 6 | .prefix { 7 | float: left; 8 | width: 30%; 9 | 10 | display: block; 11 | height: 36px; 12 | padding: 8px 13px; 13 | font-size: 13px; 14 | line-height: 1.5; 15 | color: @muted-color; 16 | background-color: @control-bg; 17 | border: 2px solid transparent; 18 | border-radius: @border-radius; 19 | -webkit-appearance: none; 20 | } 21 | } 22 | } 23 | 24 | @media @desktop-up { 25 | .container { 26 | max-width: 600px; 27 | margin: 0; 28 | } 29 | } 30 | } 31 | 32 | 33 | .Masquerade-Bio { 34 | .Masquerade-Bio-Set { 35 | width: 100%; 36 | 37 | .Masquerade-Bio-Field { 38 | display: inline-block; 39 | padding: .1em .2em; 40 | font-weight: bold; 41 | min-width: 30%; 42 | } 43 | 44 | .Masquerade-Bio-Answer { 45 | display: inline-block; 46 | width: 50%; 47 | } 48 | } 49 | } 50 | 51 | .Masquerade-NowCompleted { 52 | display: inline-block; 53 | margin-left: 20px; 54 | } 55 | -------------------------------------------------------------------------------- /js/src/forum/types/SelectField.js: -------------------------------------------------------------------------------- 1 | import app from 'flarum/forum/app'; 2 | import Select from 'flarum/common/components/Select'; 3 | import BaseField from './BaseField'; 4 | 5 | const NO_OPTION_SELECTED_KEY = 'fof_masquerade_no_option_selected'; 6 | 7 | export default class SelectField extends BaseField { 8 | editorInput() { 9 | return Select.component({ 10 | onchange: (value) => { 11 | if (value === NO_OPTION_SELECTED_KEY) { 12 | value = ''; 13 | } 14 | 15 | this.set(value); 16 | }, 17 | value: BaseField.isNoOptionSelectedValue(this.value) ? NO_OPTION_SELECTED_KEY : this.value, 18 | options: this.options(), 19 | }); 20 | } 21 | 22 | options() { 23 | let options = {}; 24 | 25 | if (!this.readAttribute(this.field, 'required')) { 26 | options[NO_OPTION_SELECTED_KEY] = app.translator.trans('fof-masquerade.forum.fields.select.none-optional'); 27 | } else if (BaseField.isNoOptionSelectedValue(this.value)) { 28 | options[NO_OPTION_SELECTED_KEY] = app.translator.trans('fof-masquerade.forum.fields.select.none-required'); 29 | } 30 | 31 | const validationIn = this.validationRule('in'); 32 | 33 | if (validationIn) { 34 | validationIn.split(',').forEach((value) => { 35 | options[value] = value; 36 | }); 37 | } 38 | 39 | if (!BaseField.isNoOptionSelectedValue(this.value) && typeof options[this.value] === 'undefined') { 40 | options[this.value] = '(invalid) ' + this.value; 41 | } 42 | 43 | return options; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/FieldType/TypeFactory.php: -------------------------------------------------------------------------------- 1 | BooleanField::class, 13 | 'email' => EmailField::class, 14 | 'select' => BaseField::class, 15 | 'url' => UrlField::class, 16 | ]; 17 | } 18 | 19 | /** 20 | * Get the field class name for a given field type 21 | * @param string|null $type 22 | * @return string 23 | */ 24 | protected static function classForType(string $type = null): string 25 | { 26 | if ($type) { 27 | // Can't run $type directly through here, as null makes Arr::get return the whole array 28 | return Arr::get(self::typeMapping(), $type, BaseField::class); 29 | } 30 | 31 | return BaseField::class; 32 | } 33 | 34 | /** 35 | * Get the field helper object for a given field 36 | * @param array $attributes Attributes of the field containing a `type` key 37 | * @return BaseField 38 | */ 39 | public static function typeForField(array $attributes): BaseField 40 | { 41 | $type = Arr::get($attributes, 'type'); 42 | 43 | $class = self::classForType($type); 44 | 45 | return resolve($class); 46 | } 47 | 48 | /** 49 | * List of all non-null allowed types 50 | * @return array 51 | */ 52 | public static function validTypes(): array 53 | { 54 | return array_keys(self::typeMapping()); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Api/Controllers/UserProfileController.php: -------------------------------------------------------------------------------- 1 | validator = $validator; 33 | $this->fields = $fields->all(); 34 | } 35 | 36 | protected function data(ServerRequestInterface $request, Document $document) 37 | { 38 | $actor = RequestUtil::getActor($request); 39 | 40 | $actor->assertCan('fof.masquerade.view-profile'); 41 | 42 | $id = Arr::get($request->getQueryParams(), 'id'); 43 | 44 | if (!$id) { 45 | throw new ModelNotFoundException(); 46 | } 47 | 48 | return $this->fields->each(function (Field $field) use ($id) { 49 | $field->for = $id; 50 | }); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Gambits/AnswerGambit.php: -------------------------------------------------------------------------------- 1 | getActor()->hasPermission('fof.masquerade.view-profile')) { 22 | return false; 23 | } 24 | 25 | return parent::apply($search, $bit); 26 | } 27 | 28 | protected function conditions(SearchState $search, array $matches, $negate) 29 | { 30 | $bit = $matches[1]; 31 | 32 | $this->constrain($search->getQuery(), $bit, $negate); 33 | } 34 | 35 | public function getFilterKey(): string 36 | { 37 | return 'answer'; 38 | } 39 | 40 | public function filter(FilterState $filterState, string $filterValue, bool $negate) 41 | { 42 | $this->constrain($filterState->getQuery(), $filterValue, $negate); 43 | } 44 | 45 | protected function constrain(Builder $query, string $bit, bool $negate) 46 | { 47 | $query->whereExists(function (Builder $query) use ($bit) { 48 | $query->select($query->raw(1)) 49 | ->from('fof_masquerade_answers') 50 | ->where('users.id', new Expression('user_id')) 51 | ->where('content', 'like', "%$bit%"); 52 | }, 'and', $negate); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /js/src/forum/types/TypeFactory.js: -------------------------------------------------------------------------------- 1 | import BaseField from './BaseField'; 2 | import BooleanField from './BooleanField'; 3 | import EmailField from './EmailField'; 4 | import SelectField from './SelectField'; 5 | import UrlField from './UrlField'; 6 | 7 | export default class TypeFactory { 8 | static typeForField({ field, set = undefined, value }) { 9 | let className = BaseField; 10 | 11 | const type = this.identify(field); 12 | 13 | if (type) { 14 | className = this.types()[type]; 15 | } 16 | 17 | return new className({ 18 | field, 19 | set, 20 | value, 21 | }); 22 | } 23 | 24 | static fieldAttribute(field, attribute) { 25 | if (typeof field[attribute] === 'function') { 26 | return field[attribute](); 27 | } 28 | 29 | return field[attribute]; 30 | } 31 | 32 | static types() { 33 | return { 34 | boolean: BooleanField, 35 | email: EmailField, 36 | select: SelectField, 37 | url: UrlField, 38 | }; 39 | } 40 | 41 | /** 42 | * Identifies how to parse the field answer. 43 | * @returns {null|string} 44 | */ 45 | static identify(field) { 46 | const validation = (this.fieldAttribute(field, 'validation') || '').split(','); 47 | let identified = null; 48 | 49 | // If the field has a type we use it 50 | const fieldType = this.fieldAttribute(field, 'type'); 51 | if (typeof this.types()[fieldType] !== 'undefined') { 52 | return fieldType; 53 | } 54 | 55 | // If it's an advanced field with no type we then guess the best type 56 | validation.forEach((rule) => { 57 | rule = rule.trim(); 58 | 59 | if (typeof this.types()[rule] !== 'undefined') { 60 | identified = rule; 61 | } 62 | }); 63 | 64 | return identified; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Field.php: -------------------------------------------------------------------------------- 1 | 'boolean', 38 | 'on_bio' => 'boolean', 39 | ]; 40 | 41 | protected $fillable = [ 42 | 'name', 43 | 'description', 44 | 'prefix', 45 | 'icon', 46 | 'type', 47 | 'required', 48 | 'validation', 49 | 'on_bio', 50 | ]; 51 | 52 | protected $visible = [ 53 | 'name', 54 | 'description', 55 | 'prefix', 56 | 'icon', 57 | 'type', 58 | 'required', 59 | 'validation', 60 | 'sort', 61 | 'on_bio', 62 | 'deleted_at', // Used to know if an API response was about deletion 63 | ]; 64 | 65 | public function answers(): HasMany 66 | { 67 | return $this->hasMany(Answer::class); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /js/src/forum/panes/ProfilePane.tsx: -------------------------------------------------------------------------------- 1 | import app from 'flarum/forum/app'; 2 | 3 | import Component, { ComponentAttrs } from 'flarum/common/Component'; 4 | import TypeFactory from '../types/TypeFactory'; 5 | 6 | import type Answer from '../../lib/models/Answer'; 7 | import type Field from 'src/lib/models/Field'; 8 | import type User from 'flarum/common/models/User'; 9 | import type Mithril from 'mithril'; 10 | 11 | export interface ProfilePaneAttrs extends ComponentAttrs { 12 | answers: Answer[]; 13 | user: User; 14 | loading: boolean; 15 | } 16 | 17 | export default class ProfilePane extends Component { 18 | answers!: Answer[]; 19 | user!: User; 20 | loading!: boolean; 21 | 22 | oninit(vnode: Mithril.Vnode) { 23 | super.oninit(vnode); 24 | this.loading = true; 25 | 26 | this.answers = []; 27 | this.user = this.attrs.user; 28 | 29 | this.load(); 30 | } 31 | 32 | view() { 33 | return ( 34 |
35 |
36 | {app.store 37 | .all('masquerade-field') 38 | .sort((a, b) => (a as Field).sort() - (b as Field).sort()) 39 | .map((field) => { 40 | const answer = this.answers.find((a) => a.field()?.id() === field.id()); 41 | 42 | return this.field(field as Field, (answer?.content() as Answer) || null); 43 | })} 44 |
45 |
46 | ); 47 | } 48 | 49 | field(field: Field, content: Answer | null) { 50 | const type = TypeFactory.typeForField({ 51 | field, 52 | value: content, 53 | }); 54 | 55 | return type.answerField(); 56 | } 57 | 58 | async load() { 59 | this.answers = this.user.masqueradeAnswers(); 60 | const userId = this.user.id(); 61 | 62 | if (!userId) return; 63 | 64 | if (this.answers) return; 65 | 66 | this.answers = []; 67 | app.store.find('users', userId, { include: 'masqueradeAnswers' }).then(() => { 68 | this.answers = this.user.masqueradeAnswers(); 69 | m.redraw(); 70 | }); 71 | 72 | m.redraw(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /js/src/forum/types/BooleanField.js: -------------------------------------------------------------------------------- 1 | import icon from 'flarum/common/helpers/icon'; 2 | import BaseField from './BaseField'; 3 | 4 | export default class BooleanField extends BaseField { 5 | editorInput() { 6 | return this.options().map((option) => 7 | m( 8 | 'div', 9 | m('label', [ 10 | m('input[type=radio]', { 11 | checked: option.selected(this.value), 12 | onclick: () => { 13 | this.set(option.key); 14 | }, 15 | }), 16 | ' ' + option.label, 17 | ]) 18 | ) 19 | ); 20 | } 21 | 22 | options() { 23 | let options = []; 24 | 25 | if (!this.readAttribute(this.field, 'required')) { 26 | options.push({ 27 | selected: (value) => BaseField.isNoOptionSelectedValue(value), 28 | key: null, 29 | label: app.translator.trans('fof-masquerade.forum.fields.select.none-optional'), 30 | }); 31 | } 32 | 33 | options.push({ 34 | selected: (value) => ['true', '1', 1, true, 'yes'].indexOf(value) !== -1, 35 | key: 'true', 36 | label: app.translator.trans('fof-masquerade.forum.fields.boolean.yes'), 37 | }); 38 | 39 | options.push({ 40 | selected: (value) => ['false', '0', 0, false, 'no'].indexOf(value) !== -1, 41 | key: 'false', 42 | label: app.translator.trans('fof-masquerade.forum.fields.boolean.no'), 43 | }); 44 | 45 | // This is probably overkill because it looks like the backend casts the value anyway 46 | if (!BaseField.isNoOptionSelectedValue(this.value) && ['true', '1', 1, true, 'yes', 'false', '0', 0, false, 'no'].indexOf(this.value) === -1) { 47 | options.push({ 48 | selected: () => true, 49 | key: this.value, 50 | label: '(invalid) ' + this.value, 51 | }); 52 | } 53 | 54 | return options; 55 | } 56 | 57 | answerContent() { 58 | if (BaseField.isNoOptionSelectedValue(this.value)) { 59 | return ''; 60 | } 61 | 62 | return [1, '1', true, 'true', 'yes'].indexOf(this.value) !== -1 63 | ? [icon('far fa-check-square'), ' ', app.translator.trans('fof-masquerade.forum.fields.boolean.yes')] 64 | : [icon('far fa-square'), ' ', app.translator.trans('fof-masquerade.forum.fields.boolean.no')]; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fof/masquerade", 3 | "description": "User profile builder extension for your Flarum forum.", 4 | "keywords": [ 5 | "extension", 6 | "flarum", 7 | "user profile", 8 | "member profile", 9 | "profile" 10 | ], 11 | "type": "flarum-extension", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Daniël Klabbers", 16 | "email": "daniel@klabbers.email", 17 | "homepage": "https://luceos.com/" 18 | }, 19 | { 20 | "name": "Clark Winkelmann", 21 | "email": "clark.winkelmann@gmail.com", 22 | "homepage": "https://clarkwinkelmann.com/" 23 | } 24 | ], 25 | "support": { 26 | "issues": "https://github.com/FriendsOfFlarum/masquerade/issues", 27 | "source": "https://github.com/FriendsOfFlarum/masquerade", 28 | "forum": "https://discuss.flarum.org/d/5791" 29 | }, 30 | "require": { 31 | "php": "^8.1", 32 | "flarum/core": "^1.8.5" 33 | }, 34 | "replace": { 35 | "flagrow/masquerade": "*" 36 | }, 37 | "extra": { 38 | "flarum-extension": { 39 | "title": "FoF Masquerade", 40 | "category": "feature", 41 | "icon": { 42 | "image": "resources/logo.svg", 43 | "backgroundColor": "#e74c3c", 44 | "backgroundSize": "90%", 45 | "backgroundRepeat": "no-repeat", 46 | "backgroundPosition": "center" 47 | }, 48 | "optional-dependencies": [ 49 | "flarum/gdpr" 50 | ] 51 | }, 52 | "flagrow": { 53 | "discuss": "https://discuss.flarum.org/d/5791" 54 | }, 55 | "flarum-cli": { 56 | "modules": { 57 | "githubActions": true 58 | } 59 | } 60 | }, 61 | "autoload": { 62 | "psr-4": { 63 | "FoF\\Masquerade\\": "src/" 64 | } 65 | }, 66 | "require-dev": { 67 | "flarum/phpstan": "*", 68 | "flarum/gdpr": "^1.0.0@beta" 69 | }, 70 | "scripts": { 71 | "analyse:phpstan": "phpstan analyse", 72 | "clear-cache:phpstan": "phpstan clear-result-cache" 73 | }, 74 | "scripts-descriptions": { 75 | "analyse:phpstan": "Run static analysis" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Middleware/DemandProfileCompletion.php: -------------------------------------------------------------------------------- 1 | settings = $settings; 39 | $this->fields = $fields; 40 | $this->url = $url; 41 | $this->slugManager = $slugManager; 42 | } 43 | 44 | public function process(Request $request, RequestHandlerInterface $handler): Response 45 | { 46 | $actor = RequestUtil::getActor($request); 47 | 48 | if (!$actor->isGuest()) { 49 | 50 | $configureProfileUrl = $this->url->to('forum')->route('user', [ 51 | 'username' => $this->slugManager->forResource(User::class)->toSlug($actor), 52 | 'filter' => 'masquerade' 53 | ]); 54 | 55 | $configureProfilePathWithoutBase = str_replace($this->url->to('forum')->base(), '', $configureProfileUrl); 56 | 57 | if ( 58 | $configureProfilePathWithoutBase !== $request->getUri()->getPath() && 59 | $actor->can('fof.masquerade.have-profile') && 60 | $this->settings->get('masquerade.force-profile-completion') && 61 | !$this->fields->completed($actor->id) 62 | ) { 63 | return new RedirectResponse($configureProfileUrl); 64 | } 65 | } 66 | 67 | return $handler->handle($request); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Masquerade by FriendsOfFlarum 2 | 3 | ![License](https://img.shields.io/badge/license-MIT-blue.svg) [![Latest Stable Version](https://img.shields.io/packagist/v/fof/masquerade.svg)](https://packagist.org/packages/fof/masquerade) 4 | 5 | The user profile generator. Includes: 6 | 7 | - New tab on user profile to show masquerade profile with answers provided to configured fields. 8 | - New tab on user profile for user to set up a masquerade profile. 9 | - Add, update, delete and order profile fields in admin. 10 | - Permission who can have a masquerade profile. 11 | - Permission who can view a masquerade profile. 12 | - Allowing you to configure forced redirection to make a (email verified) user complete the required fields. 13 | 14 | ## Installation 15 | 16 | ```bash 17 | composer require fof/masquerade:* 18 | ``` 19 | 20 | ## Update 21 | 22 | ```sh 23 | composer require fof/masquerade:* 24 | php flarum migrate 25 | php flarum cache:clear 26 | ``` 27 | 28 | ## Configuration 29 | 30 | Enable the extension. Visit the masquerade tab in the admin to configure the fields. 31 | 32 | Be aware that the "Add new field" and "Edit field " toggle the field form when clicked. 33 | 34 | Make sure you configure the masquerade permissions on the Admin Permissions tab to your needs. 35 | 36 | ## Updating from Flagrow 37 | 38 | This extension replaces [Flagrow Masquerade](https://packagist.org/packages/flagrow/masquerade). 39 | 40 | **Please backup your data before attempting the update!** 41 | 42 | First make sure you installed the latest Flagrow release will all migrations applied: 43 | 44 | ```sh 45 | composer require flagrow/masquerade 46 | # Go to admin panel and check extension is enabled 47 | php flarum migrate 48 | ``` 49 | 50 | (Instead of running the migrate command you can also disable and re-enable the extension in the admin panel) 51 | 52 | Then upgrade from the old extension to the new one: 53 | 54 | ```sh 55 | composer remove flagrow/masquerade 56 | composer require fof/masquerade:* 57 | ``` 58 | 59 | When you enable the new extension, the permissions and the data from Flagrow Masquerade will be moved to FoF Masquerade. 60 | 61 | ## Links 62 | 63 | - [Flarum Discuss post](https://discuss.flarum.org/d/5791) 64 | - [Source code on GitHub](https://github.com/FriendsOfFlarum/masquerade) 65 | - [Report an issue](https://github.com/FriendsOfFlarum/masquerade/issues) 66 | - [Download via Packagist](https://packagist.org/packages/fof/masquerade) 67 | 68 | An extension by [FriendsOfFlarum](https://github.com/FriendsOfFlarum) 69 | -------------------------------------------------------------------------------- /resources/locale/en.yml: -------------------------------------------------------------------------------- 1 | fof-masquerade: 2 | forum: 3 | alerts: 4 | profile-completion-required: > 5 | Please complete the necessary profile fields below. 6 | profile-completed: Profile completed! Proceed to homepage 7 | buttons: 8 | view-profile: View profile 9 | edit-profile: Edit profile 10 | save-profile: Save 11 | fields: 12 | select: 13 | none-optional: No option selected (optional) 14 | none-required: Please select an option (required) 15 | boolean: 16 | yes: Yes 17 | no: No 18 | admin: 19 | general-options: General options 20 | buttons: 21 | add-field: Create field 22 | edit-field: Save changes 23 | delete-field: Delete field 24 | permissions: 25 | edit-others-profile: Edit the profile of other users 26 | have-profile: Have masquerade profile 27 | view-profile: View masquerade profile 28 | fields: 29 | title: Fields 30 | add: Add field {field} 31 | edit: Edit field {field} 32 | name: Field name 33 | name-help: > 34 | The publicly visible (non-translatable) field label. 35 | description: Field description 36 | description-help: > 37 | An optional description to explain the use/need of this field. 38 | required: Mark field obligatory 39 | on_bio: Show answer to this field in user bio 40 | type: Type 41 | validation: Validation 42 | validation-help: > 43 | Check the supported Laravel validation rules. Eg: 44 | "string|min:5" or "url". 45 | prefix: Field prefix 46 | prefix-help: > 47 | Shows your prefix in front of values entered by the user for this field. 48 | icon: Icon 49 | icon-help: > 50 | Shows an icon in front of values entered by the user. For a reference of 51 | available icons, check the FontAwesome website. Use icon names with 52 | the "fa-" prefix, like "fas fa-info". 53 | force-user-to-completion: Force user to complete profile 54 | options: Options 55 | option-new: New option 56 | option-comma-warning: The label of an option cannot contain any comma (","). 57 | types: 58 | advanced: Advanced 59 | boolean: Checkbox 60 | email: Email 61 | select: Dropdown 62 | url: URL 63 | 64 | flarum-gdpr: 65 | lib: 66 | data: 67 | masqueradeanswers: 68 | export_description: Exports the user's profile field answers 69 | delete_description: Removes all profile field answers from the user's account 70 | anonymize_description: => flarum-gdpr.lib.data.masqueradeanswers.delete_description 71 | -------------------------------------------------------------------------------- /js/src/forum/types/BaseField.js: -------------------------------------------------------------------------------- 1 | import icon from 'flarum/common/helpers/icon'; 2 | 3 | /* global m */ 4 | 5 | export default class BaseField { 6 | constructor({ field, set, value }) { 7 | this.field = field; 8 | this.set = set; 9 | this.value = value; 10 | } 11 | 12 | readAttribute(object, attribute) { 13 | if (typeof object[attribute] === 'function') { 14 | return object[attribute](); 15 | } 16 | 17 | return object[attribute]; 18 | } 19 | 20 | /** 21 | * Gets all Laravel validation rules split by rule 22 | * @returns {Array} 23 | */ 24 | validationRules() { 25 | return this.readAttribute(this.field, 'validation').split('|'); 26 | } 27 | 28 | /** 29 | * Gets a Laravel validation rule by name 30 | * @param {string} ruleName 31 | * @returns {string|null} 32 | */ 33 | validationRule(ruleName) { 34 | let ruleContent = null; 35 | 36 | this.validationRules().forEach((rule) => { 37 | const split = rule.split(':', 2); 38 | 39 | if (split[0] === ruleName) { 40 | ruleContent = split[1]; 41 | } 42 | }); 43 | 44 | return ruleContent; 45 | } 46 | 47 | editorField() { 48 | return ( 49 |
50 | 53 | 54 |
55 | {this.field.prefix() ? m('.prefix', this.field.prefix()) : null} 56 | {this.editorInput()} 57 | {this.field.description() ?
{this.field.description()}
: null} 58 |
59 |
60 | ); 61 | } 62 | 63 | editorInput() { 64 | return ; 65 | } 66 | 67 | editorInputAttrs() { 68 | return { 69 | className: 'FormControl', 70 | oninput: (event) => { 71 | this.set(event.target.value); 72 | }, 73 | value: this.value, 74 | required: this.field.required(), 75 | }; 76 | } 77 | 78 | answerField() { 79 | const iconName = this.readAttribute(this.field, 'icon'); 80 | 81 | return ( 82 |
83 | 84 | {iconName && <>{icon(iconName)} } 85 | {this.readAttribute(this.field, 'name')}:{' '} 86 | 87 | {this.answerContent()} 88 |
89 | ); 90 | } 91 | 92 | answerContent() { 93 | return this.value; 94 | } 95 | 96 | hasAnswer() { 97 | const answerContent = this.answerContent(); 98 | 99 | if (answerContent === null) { 100 | return false; 101 | } 102 | 103 | if (typeof answerContent === 'object') { 104 | return !!Object.keys(answerContent).length; 105 | } 106 | 107 | return !!answerContent?.length; 108 | } 109 | 110 | static isNoOptionSelectedValue(value) { 111 | // The value can be null when coming from the API 112 | // The value can be '' when the field does not exist on the user (the empty string is set in ProfileConfigurePane) 113 | return value === null || value === ''; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /js/src/admin/components/MasqueradePage.js: -------------------------------------------------------------------------------- 1 | import sortable from 'html5sortable/dist/html5sortable.es.js'; 2 | 3 | import app from 'flarum/admin/app'; 4 | import ExtensionPage from 'flarum/admin/components/ExtensionPage'; 5 | import Switch from 'flarum/common/components/Switch'; 6 | import FieldList from './FieldList'; 7 | import saveSettings from 'flarum/admin/utils/saveSettings'; 8 | 9 | /* global m, $ */ 10 | 11 | export default class MasqueradePage extends ExtensionPage { 12 | oninit(vnode) { 13 | super.oninit(vnode); 14 | 15 | this.resetNew(); 16 | this.loading = false; 17 | this.existing = []; 18 | this.loadExisting(); 19 | this.enforceProfileCompletion = app.data.settings['masquerade.force-profile-completion'] === '1'; 20 | } 21 | 22 | config() { 23 | sortable(this.element.querySelector('.js-sortable-fields'), { 24 | handle: 'legend', 25 | })[0].addEventListener('sortupdate', () => { 26 | const sorting = this.$('.js-sortable-fields > .Field') 27 | .map(function () { 28 | return $(this).data('id'); 29 | }) 30 | .get(); 31 | 32 | this.updateSort(sorting); 33 | }); 34 | } 35 | 36 | oncreate(vnode) { 37 | super.oncreate(vnode); 38 | this.config(); 39 | } 40 | 41 | onupdate() { 42 | this.config(); 43 | } 44 | 45 | content() { 46 | return m( 47 | '.ExtensionPage-settings.ProfileConfigurePane', 48 | m('.container', [ 49 | m('h2', app.translator.trans('fof-masquerade.admin.general-options')), 50 | m( 51 | '.Form-group', 52 | Switch.component( 53 | { 54 | state: this.enforceProfileCompletion, 55 | onchange: (value) => { 56 | const saveValue = value ? '1' : '0'; 57 | saveSettings({ 58 | 'masquerade.force-profile-completion': saveValue, 59 | }); 60 | this.enforceProfileCompletion = saveValue; 61 | }, 62 | }, 63 | app.translator.trans('fof-masquerade.admin.fields.force-user-to-completion') 64 | ) 65 | ), 66 | m('h2', app.translator.trans('fof-masquerade.admin.fields.title')), 67 | m(FieldList, { existing: this.existing, new: this.newField, loading: this.loading, onUpdate: this.requestSuccess.bind(this) }), 68 | ]) 69 | ); 70 | } 71 | 72 | updateSort(sorting) { 73 | app 74 | .request({ 75 | method: 'POST', 76 | url: app.forum.attribute('apiUrl') + '/masquerade/fields/order', 77 | body: { 78 | sort: sorting, 79 | }, 80 | }) 81 | .then(this.requestSuccess.bind(this)); 82 | } 83 | 84 | requestSuccess() { 85 | this.loadExisting(); 86 | this.resetNew(); 87 | m.redraw(); 88 | } 89 | 90 | loadExisting() { 91 | this.loading = true; 92 | 93 | return app 94 | .request({ 95 | method: 'GET', 96 | url: app.forum.attribute('apiUrl') + '/masquerade/fields', 97 | }) 98 | .then((result) => { 99 | app.store.pushPayload(result); 100 | this.existing = app.store.all('masquerade-field'); 101 | this.existing.sort((a, b) => a.sort() - b.sort()); 102 | this.loading = false; 103 | m.redraw(); 104 | }); 105 | } 106 | 107 | resetNew() { 108 | this.newField = app.store.createRecord('masquerade-field', { 109 | attributes: { 110 | name: '', 111 | description: '', 112 | prefix: '', 113 | icon: '', 114 | required: false, 115 | on_bio: false, 116 | type: null, 117 | validation: '', 118 | }, 119 | }); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Api/Controllers/UserConfigureController.php: -------------------------------------------------------------------------------- 1 | validator = $validator; 39 | $this->fields = $fields; 40 | $this->users = $users; 41 | } 42 | 43 | protected function data(ServerRequestInterface $request, Document $document) 44 | { 45 | $actor = RequestUtil::getActor($request); 46 | $user = $this->users->findOrFail(Arr::get($request->getQueryParams(), 'id')); 47 | 48 | if ($actor->id !== $user->id) { 49 | $actor->assertCan('fof.masquerade.edit-others-profile'); 50 | } else { 51 | $actor->assertCan('fof.masquerade.have-profile'); 52 | } 53 | 54 | /** @var \Illuminate\Database\Eloquent\Collection $fields */ 55 | $fields = $this->fields->all(); 56 | 57 | // Checked in the FieldSerializer to find the appropriate Answer models 58 | foreach ($fields as $field) { 59 | $field->for = $user->id; 60 | } 61 | 62 | if ($request->getMethod() === 'POST') { 63 | $this->processUpdate($user, $request->getParsedBody(), $fields); 64 | } 65 | 66 | return $fields->map(function (Field $field) use ($actor) { 67 | return $field->answers()->firstOrNew([ 68 | 'user_id' => $actor->id, 69 | ]); 70 | }); 71 | } 72 | 73 | /** 74 | * @param mixed $answers 75 | * @param \Illuminate\Database\Eloquent\Collection $fields 76 | */ 77 | protected function processUpdate(User $user, $answers, &$fields) 78 | { 79 | $fields->each(function (Field $field) use ($answers, $user) { 80 | $content = Arr::get($answers, $field->id); 81 | 82 | $this->processBoolean($field, $content); 83 | 84 | $this->validator->setField($field); 85 | 86 | $this->validator->assertValid([ 87 | $field->name => $content 88 | ]); 89 | 90 | $this->fields->addOrUpdateAnswer( 91 | $field, 92 | $content, 93 | $user 94 | ); 95 | }); 96 | } 97 | 98 | protected function processBoolean(Field $field, &$content) 99 | { 100 | // For boolean field type, convert the values to true booleans 101 | // so we can't end up with the whole spectrum of values accepted by the validator in the database 102 | if ($field->type === 'boolean') { 103 | if ($content === '' || $content === null) { 104 | $content = null; 105 | } else { 106 | $content = in_array(strtolower($content), ['yes', 'true']); 107 | } 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /js/src/forum/panes/ProfileConfigurePane.js: -------------------------------------------------------------------------------- 1 | import app from 'flarum/forum/app'; 2 | 3 | import Button from 'flarum/common/components/Button'; 4 | import Link from 'flarum/common/components/Link'; 5 | import TypeFactory from '../types/TypeFactory'; 6 | import Component from 'flarum/common/Component'; 7 | 8 | export default class ProfileConfigurePane extends Component { 9 | oninit(vnode) { 10 | super.oninit(vnode); 11 | this.loading = true; 12 | 13 | this.enforceProfileCompletion = app.forum.attribute('masquerade.force-profile-completion') || false; 14 | this.profileCompleted = app.forum.attribute('masquerade.profile-completed') || false; 15 | this.profileNowCompleted = false; // Show "after required" text 16 | this.answers = []; 17 | this.answerValues = {}; 18 | this.user = this.attrs.user; 19 | this.load(); 20 | 21 | // Show disabled state if everything is saved 22 | // Unless the profile isn't complete, in which case show enabled button so it's obvious you will need to save 23 | this.dirty = !this.profileCompleted; 24 | } 25 | 26 | view() { 27 | return ( 28 |
29 | {!!(this.enforceProfileCompletion && !this.profileCompleted) && ( 30 |
{app.translator.trans('fof-masquerade.forum.alerts.profile-completion-required')}
31 | )} 32 | 33 |
34 | {app.store 35 | .all('masquerade-field') 36 | .sort((a, b) => a.sort() - b.sort()) 37 | .map((field) => { 38 | return this.field(field); 39 | })} 40 |
41 | 42 | 45 | 46 | {!!this.profileNowCompleted && ( 47 | 48 | {app.translator.trans('fof-masquerade.forum.alerts.profile-completed', { 49 | a: , 50 | })} 51 | 52 | )} 53 |
54 | ); 55 | } 56 | 57 | field(field) { 58 | const type = TypeFactory.typeForField({ 59 | field, 60 | set: this.set.bind(this, field), 61 | value: this.answerValues[field.id()], 62 | }); 63 | 64 | return type.editorField(); 65 | } 66 | 67 | load() { 68 | this.answers = this.user.masqueradeAnswers(); 69 | 70 | if (this.answers === false) { 71 | this.answers = []; 72 | app.store.find('users', this.user.id(), { include: 'masqueradeAnswers' }).then(() => { 73 | this.answers = this.user.masqueradeAnswers(); 74 | this.answerValues = {}; 75 | 76 | app.store.all('masquerade-field').forEach((field) => { 77 | const answer = this.answers.find((a) => a.field()?.id() === field.id()); 78 | 79 | this.answerValues[field.id()] = answer ? answer.content() : ''; 80 | }); 81 | 82 | this.loading = false; 83 | m.redraw(); 84 | }); 85 | } else { 86 | this.loading = false; 87 | 88 | app.store.all('masquerade-field').forEach((field) => { 89 | const answer = this.answers.find((a) => a.field()?.id() === field.id()); 90 | 91 | this.answerValues[field.id()] = answer ? answer.content() : ''; 92 | }); 93 | } 94 | 95 | m.redraw(); 96 | } 97 | 98 | set(field, value) { 99 | this.answerValues[field.id()] = value; 100 | this.dirty = true; 101 | } 102 | 103 | update(e) { 104 | e.preventDefault(); 105 | 106 | this.loading = true; 107 | 108 | app 109 | .request({ 110 | method: 'POST', 111 | url: app.forum.attribute('apiUrl') + '/masquerade/configure/' + this.user.id(), 112 | body: this.answerValues, 113 | }) 114 | .then((response) => { 115 | this.dirty = false; 116 | 117 | if (!this.profileCompleted) { 118 | this.profileCompleted = true; 119 | this.profileNowCompleted = true; 120 | } 121 | 122 | this.parseResponse(response); 123 | }) 124 | .catch(() => { 125 | this.loading = false; 126 | m.redraw(); 127 | }); 128 | } 129 | 130 | parseResponse(response) { 131 | this.answers = app.store.pushPayload(response); 132 | this.loading = false; 133 | m.redraw(); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /js/src/admin/components/SelectFieldOptionEditor.js: -------------------------------------------------------------------------------- 1 | import app from 'flarum/admin/app'; 2 | import Component from 'flarum/common/Component'; 3 | import icon from 'flarum/common/helpers/icon'; 4 | 5 | /* global m */ 6 | 7 | export default class SelectFieldOptionEditor extends Component { 8 | oninit(vnode) { 9 | super.oninit(vnode); 10 | 11 | this.newOption = ''; 12 | } 13 | 14 | view() { 15 | return m('.Form-group', [ 16 | m('label', app.translator.trans('fof-masquerade.admin.fields.options')), 17 | m( 18 | 'table', 19 | m( 20 | 'tbody', 21 | this.options().map((option, optionIndex) => 22 | m('tr', [ 23 | m( 24 | 'td', 25 | m('input[type=text].FormControl', { 26 | oninput: (event) => { 27 | this.updateOption(optionIndex, event.target.value); 28 | }, 29 | value: option, 30 | }) 31 | ), 32 | m( 33 | 'td', 34 | m( 35 | 'button.Button', 36 | { 37 | onclick: () => { 38 | this.moveOption(optionIndex, -1); 39 | }, 40 | }, 41 | icon('fas fa-chevron-up') 42 | ) 43 | ), 44 | m( 45 | 'td', 46 | m( 47 | 'button.Button', 48 | { 49 | onclick: () => { 50 | this.moveOption(optionIndex, 1); 51 | }, 52 | }, 53 | icon('fas fa-chevron-down') 54 | ) 55 | ), 56 | m( 57 | 'td', 58 | m( 59 | 'button.Button.Button--danger', 60 | { 61 | onclick: () => { 62 | this.deleteOption(optionIndex); 63 | }, 64 | }, 65 | icon('fas fa-times') 66 | ) 67 | ), 68 | ]) 69 | ) 70 | ) 71 | ), 72 | m('.helpText', app.translator.trans('fof-masquerade.admin.fields.option-comma-warning')), 73 | m( 74 | 'table', 75 | m('tbody'), 76 | m('tr', [ 77 | m( 78 | 'td', 79 | m('input[type=text].FormControl', { 80 | onchange: (event) => { 81 | this.newOption = event.target.value; 82 | }, 83 | value: this.newOption, 84 | placeholder: app.translator.trans('fof-masquerade.admin.fields.option-new'), 85 | }) 86 | ), 87 | m( 88 | 'td', 89 | m( 90 | 'button.Button.Button--primary', 91 | { 92 | onclick: () => { 93 | this.addOption(); 94 | }, 95 | }, 96 | icon('fas fa-plus') 97 | ) 98 | ), 99 | ]) 100 | ), 101 | ]); 102 | } 103 | 104 | updateRules(options) { 105 | // We ignore other existing rules, they would probably be leftovers from another field type when changing types 106 | this.attrs.onchange('in:' + options.join(',')); 107 | } 108 | 109 | options() { 110 | const rules = this.attrs.value.split('|'); 111 | 112 | let options = []; 113 | 114 | rules.forEach((rule) => { 115 | const parts = rule.split(':', 2); 116 | 117 | if (parts[0] === 'in') { 118 | options = parts[1].split(','); 119 | } 120 | }); 121 | 122 | return options; 123 | } 124 | 125 | updateOption(index, value) { 126 | let options = this.options(); 127 | 128 | options[index] = value; 129 | 130 | this.updateRules(options); 131 | } 132 | 133 | moveOption(index, moveIndex) { 134 | let options = this.options(); 135 | 136 | const newIndex = index + moveIndex; 137 | 138 | if (newIndex < 0 || newIndex > options.length - 1) { 139 | return; 140 | } 141 | 142 | const move = options.splice(index, 1); 143 | 144 | options.splice(newIndex, 0, move[0]); 145 | 146 | this.updateRules(options); 147 | } 148 | 149 | deleteOption(index) { 150 | let options = this.options(); 151 | 152 | options.splice(index, 1); 153 | 154 | this.updateRules(options); 155 | } 156 | 157 | addOption() { 158 | if (this.newOption === '') { 159 | return; 160 | } 161 | 162 | let options = this.options(); 163 | 164 | options.push(this.newOption); 165 | 166 | this.newOption = ''; 167 | 168 | this.updateRules(options); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/Repositories/FieldRepository.php: -------------------------------------------------------------------------------- 1 | cache->rememberForever(static::CACHE_KEY_ALL_FIELDS, function () { 36 | return $this->query()->get(); 37 | }); 38 | } 39 | 40 | public function clearCacheAllFields() 41 | { 42 | $this->cache->forget(static::CACHE_KEY_ALL_FIELDS); 43 | } 44 | 45 | public function findOrFail(string $id) 46 | { 47 | return $this->field->newQuery()->findOrFail($id); 48 | } 49 | 50 | public function store(User $actor, array $attributes): Field 51 | { 52 | $this->validator->assertValid($attributes); 53 | 54 | $type = TypeFactory::typeForField($attributes); 55 | $attributes = array_merge($attributes, $type->overrideAttributes()); 56 | 57 | $field = new Field($attributes); 58 | $field->save(); 59 | 60 | $this->events->dispatch(new FieldCreated($field, $actor, $attributes)); 61 | 62 | $this->clearCacheAllFields(); 63 | 64 | return $field; 65 | } 66 | 67 | public function update(User $actor, Field $field, array $attributes): Field 68 | { 69 | $this->validator->assertValid($attributes); 70 | 71 | $type = TypeFactory::typeForField($attributes); 72 | $attributes = array_merge($attributes, $type->overrideAttributes()); 73 | 74 | $field->fill($attributes); 75 | 76 | if ($field->isDirty()) { 77 | $field->save(); 78 | 79 | $this->events->dispatch(new FieldUpdated($field, $actor, $attributes)); 80 | 81 | $this->clearCacheAllFields(); 82 | } 83 | 84 | return $field; 85 | } 86 | 87 | /** 88 | * @param array $sorting 89 | */ 90 | public function sorting(array $sorting) 91 | { 92 | foreach ($sorting as $i => $fieldId) { 93 | $this->field->where('id', $fieldId)->update(['sort' => $i]); 94 | } 95 | 96 | $this->cache->forget(static::CACHE_KEY_ALL_FIELDS); 97 | } 98 | 99 | public function delete(User $actor, Field $field) 100 | { 101 | $response = $field->delete(); 102 | 103 | $this->events->dispatch(new FieldDeleted($field, $actor, [])); 104 | 105 | $this->clearCacheAllFields(); 106 | 107 | return $response; 108 | } 109 | 110 | /** 111 | * Checks whether user has uncompleted fields. 112 | * 113 | * @param int $userId 114 | * @return bool 115 | * @todo we can't flush the cache because it uses a dynamic id 116 | */ 117 | public function completed($userId) 118 | { 119 | // return $this->cache->rememberForever(sprintf( 120 | // static::CACHE_KEY_UNCOMPLETED, 121 | // $userId 122 | // ), function () use ($userId) { 123 | return $this->field 124 | ->where('required', true) 125 | ->whereDoesntHave('answers', function ($q) use ($userId) { 126 | $q->where('user_id', $userId); 127 | }) 128 | ->count() == 0; 129 | 130 | // }); 131 | } 132 | 133 | /** 134 | * @param Field $field 135 | * @param string $content 136 | * @param User $actor 137 | */ 138 | public function addOrUpdateAnswer(Field $field, $content, User $actor) 139 | { 140 | /** @var Answer $answer */ 141 | $answer = $field->answers()->firstOrNew([ 142 | 'user_id' => $actor->id, 143 | ]); 144 | 145 | $answer->content = $content; 146 | $answer->user()->associate($actor); 147 | 148 | $field->answers()->save($answer); 149 | 150 | $this->cache->forget(sprintf( 151 | static::CACHE_KEY_UNCOMPLETED, 152 | $actor->id 153 | )); 154 | } 155 | 156 | /** 157 | * @return \Illuminate\Database\Eloquent\Builder 158 | */ 159 | protected function query() 160 | { 161 | return $this->field->newQuery()->orderBy('sort', 'desc'); 162 | } 163 | 164 | protected function highestSort(): int 165 | { 166 | /** @var Field $max */ 167 | $max = Field::query()->orderBy('sort', 'desc')->first(); 168 | 169 | /** @phpstan-ignore-next-line */ 170 | return $max ? $max->sort + 1 : 0; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /extend.php: -------------------------------------------------------------------------------- 1 | js(__DIR__ . '/js/dist/forum.js') 27 | ->css(__DIR__ . '/resources/less/forum.less'), 28 | 29 | (new Extend\Frontend('admin')) 30 | ->js(__DIR__ . '/js/dist/admin.js') 31 | ->css(__DIR__ . '/resources/less/admin.less'), 32 | 33 | (new Extend\Routes('api')) 34 | ->get('/masquerade/fields', 'masquerade.api.fields.index', Api\FieldIndexController::class) 35 | ->post('/masquerade/fields/order', 'masquerade.api.fields.order', Api\OrderFieldController::class) 36 | ->post('/masquerade/fields', 'masquerade.api.fields.create', Api\StoreFieldController::class) 37 | ->patch('/masquerade/fields/{id:[0-9]+}', 'masquerade.api.fields.update', Api\UpdateFieldController::class) 38 | ->delete('/masquerade/fields[/{id:[0-9]+}]', 'masquerade.api.fields.delete', Api\DeleteFieldController::class) 39 | ->get('/masquerade/profile/{id:[0-9]+}', 'masquerade.api.profile', Api\UserProfileController::class) 40 | ->get('/masquerade/configure/{id:[0-9]+}', 'masquerade.api.configure', Api\UserConfigureController::class) 41 | ->post('/masquerade/configure/{id:[0-9]+}', 'masquerade.api.configure.save', Api\UserConfigureController::class), 42 | 43 | (new Extend\Middleware('forum')) 44 | ->add(Middleware\DemandProfileCompletion::class), 45 | 46 | (new Extend\Locales(__DIR__ . '/resources/locale')), 47 | 48 | (new Extend\ApiController(ShowForumController::class)) 49 | ->prepareDataForSerialization(LoadAllMasqueradeFieldsRelationship::class) 50 | ->addInclude('masqueradeFields'), 51 | 52 | (new Extend\ApiController(ShowUserController::class)) 53 | ->addInclude('bioFields.field') 54 | ->addInclude('masqueradeAnswers'), 55 | 56 | (new Extend\ApiController(UpdateUserController::class)) 57 | ->addInclude('bioFields.field') 58 | ->addInclude('masqueradeAnswers'), 59 | 60 | (new Extend\ApiController(CreateUserController::class)) 61 | ->addInclude('bioFields.field') 62 | ->addInclude('masqueradeAnswers'), 63 | 64 | (new Extend\ApiController(ListUsersController::class)) 65 | ->addInclude('bioFields.field') 66 | ->addInclude('masqueradeAnswers'), 67 | 68 | (new Extend\ApiController(ListPostsController::class)) 69 | ->addInclude('user.bioFields.field') 70 | ->addInclude('user.masqueradeAnswers'), 71 | 72 | (new Extend\ApiController(ShowDiscussionController::class)) 73 | ->addInclude('posts.user.bioFields.field') 74 | ->addInclude('posts.user.masqueradeAnswers'), 75 | 76 | (new Extend\Model(User::class)) 77 | ->relationship('bioFields', function (User $model) { 78 | return $model->hasMany(Answer::class) 79 | ->whereHas('field', function ($q) { 80 | $q->where('on_bio', true); 81 | }); 82 | }) 83 | ->hasMany('masqueradeAnswers', Answer::class), 84 | 85 | (new Extend\ApiSerializer(BasicUserSerializer::class)) 86 | ->hasMany('bioFields', AnswerSerializer::class) 87 | ->hasMany('masqueradeAnswers', AnswerSerializer::class) 88 | ->attributes(function (BasicUserSerializer $serializer, User $user): array { 89 | $actor = $serializer->getActor(); 90 | 91 | if ($actor->cannot('fof.masquerade.view-profile')) { 92 | // When the relationships are auto-loaded later, 93 | // this one will be skipped because it has already been set to null 94 | $user->setRelation('bioFields', null); 95 | $user->setRelation('masqueradeAnswers', null); 96 | } 97 | 98 | return []; 99 | }), 100 | 101 | (new Extend\ApiSerializer(ForumSerializer::class)) 102 | ->attributes(ForumAttributes::class) 103 | ->hasMany('masqueradeFields', FieldSerializer::class), 104 | 105 | (new Extend\ApiSerializer(UserSerializer::class)) 106 | ->attributes(UserAttributes::class), 107 | 108 | (new Extend\SimpleFlarumSearch(UserSearcher::class)) 109 | ->addGambit(Gambits\AnswerGambit::class), 110 | 111 | (new Extend\Filter(UserFilterer::class)) 112 | ->addFilter(Gambits\AnswerGambit::class), 113 | 114 | (new Extend\Conditional()) 115 | ->whenExtensionEnabled('flarum-gdpr', fn () => [ 116 | (new UserData()) 117 | ->addType(Data\MasqueradeAnswers::class), 118 | ]), 119 | ]; 120 | -------------------------------------------------------------------------------- /resources/logo.svg: -------------------------------------------------------------------------------- 1 | masquerade-symbol -------------------------------------------------------------------------------- /js/src/admin/components/FieldEdit.js: -------------------------------------------------------------------------------- 1 | import app from 'flarum/admin/app'; 2 | import Button from 'flarum/common/components/Button'; 3 | import Switch from 'flarum/common/components/Switch'; 4 | import Select from 'flarum/common/components/Select'; 5 | import withAttr from 'flarum/common/utils/withAttr'; 6 | import SelectFieldOptionEditor from './SelectFieldOptionEditor'; 7 | import icon from 'flarum/common/helpers/icon'; 8 | import ItemList from 'flarum/common/utils/ItemList'; 9 | 10 | export default class FieldEdit { 11 | view(vnode) { 12 | const { field, loading, onUpdate } = vnode.attrs; 13 | const exists = field.id(); 14 | 15 | return ( 16 |
17 | 18 | {exists ? ( 19 |
30 | ); 31 | } 32 | 33 | fieldItems(field, onUpdate) { 34 | const fields = new ItemList(); 35 | 36 | fields.add( 37 | 'name', 38 |
39 | 40 | 41 | {app.translator.trans('fof-masquerade.admin.fields.name-help')} 42 |
, 43 | 100 44 | ); 45 | 46 | fields.add( 47 | 'description', 48 |
49 | 50 | 55 | {app.translator.trans('fof-masquerade.admin.fields.description-help')} 56 |
, 57 | 90 58 | ); 59 | 60 | fields.add( 61 | 'icon', 62 |
63 | 64 | 65 | 66 | {app.translator.trans('fof-masquerade.admin.fields.icon-help', { 67 | a: , 68 | })} 69 | 70 |
, 71 | 80 72 | ); 73 | 74 | fields.add( 75 | 'on_bio', 76 |
77 | 78 | {app.translator.trans('fof-masquerade.admin.fields.on_bio')} 79 | 80 |
, 81 | 70 82 | ); 83 | 84 | fields.add( 85 | 'required', 86 |
87 | 88 | {app.translator.trans('fof-masquerade.admin.fields.required')} 89 | 90 |
, 91 | 60 92 | ); 93 | 94 | fields.add( 95 | 'type', 96 |
97 | 98 | 135 | 136 | {app.translator.trans('fof-masquerade.admin.fields.validation-help', { 137 | a: , 138 | })} 139 | 140 |
, 141 | 30 142 | ); 143 | } 144 | 145 | fields.add( 146 | 'actions', 147 |
148 |
149 | 157 | {field.id() ? ( 158 | 161 | ) : null} 162 |
163 |
, 164 | 20 165 | ); 166 | 167 | return fields; 168 | } 169 | 170 | updateExistingFieldInput(what, field, value) { 171 | field.pushAttributes({ 172 | [what]: value, 173 | }); 174 | } 175 | 176 | deleteField(field, onUpdate) { 177 | field.delete().then(onUpdate); 178 | } 179 | 180 | toggleField(e) { 181 | $(e.target).parents('.Field').toggleClass('active'); 182 | } 183 | 184 | submitAddField(field, onUpdate, e) { 185 | e.preventDefault(); 186 | 187 | field.save(field.data.attributes).then(() => { 188 | onUpdate(); 189 | this.resetNewField(); 190 | }); 191 | 192 | m.redraw(); 193 | } 194 | 195 | updateExistingField(field, onUpdate) { 196 | if (!field.id()) return; 197 | 198 | field.save(field.data.attributes).then(onUpdate); 199 | } 200 | 201 | resetNewField() { 202 | this.newField = app.store.createRecord('masquerade-field', { 203 | attributes: { 204 | name: '', 205 | description: '', 206 | prefix: '', 207 | icon: '', 208 | required: false, 209 | on_bio: false, 210 | type: null, 211 | validation: '', 212 | }, 213 | }); 214 | m.redraw(); 215 | } 216 | 217 | readyToAdd(field) { 218 | return !!field.name(); 219 | } 220 | 221 | availableTypes() { 222 | return { 223 | url: app.translator.trans('fof-masquerade.admin.types.url'), 224 | email: app.translator.trans('fof-masquerade.admin.types.email'), 225 | boolean: app.translator.trans('fof-masquerade.admin.types.boolean'), 226 | select: app.translator.trans('fof-masquerade.admin.types.select'), 227 | null: app.translator.trans('fof-masquerade.admin.types.advanced'), 228 | }; 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /js/dist/forum.js: -------------------------------------------------------------------------------- 1 | /*! For license information please see forum.js.LICENSE.txt */ 2 | (()=>{var t={24:(t,e,r)=>{var n=r(735).default;function o(){"use strict";t.exports=o=function(){return r},t.exports.__esModule=!0,t.exports.default=t.exports;var e,r={},i=Object.prototype,a=i.hasOwnProperty,s=Object.defineProperty||function(t,e,r){t[e]=r.value},u="function"==typeof Symbol?Symbol:{},l=u.iterator||"@@iterator",c=u.asyncIterator||"@@asyncIterator",f=u.toStringTag||"@@toStringTag";function d(t,e,r){return Object.defineProperty(t,e,{value:r,enumerable:!0,configurable:!0,writable:!0}),t[e]}try{d({},"")}catch(e){d=function(t,e,r){return t[e]=r}}function p(t,e,r,n){var o=e&&e.prototype instanceof g?e:g,i=Object.create(o.prototype),a=new k(n||[]);return s(i,"_invoke",{value:L(t,r,a)}),i}function h(t,e,r){try{return{type:"normal",arg:t.call(e,r)}}catch(t){return{type:"throw",arg:t}}}r.wrap=p;var m="suspendedStart",v="suspendedYield",y="executing",w="completed",b={};function g(){}function x(){}function q(){}var _={};d(_,l,(function(){return this}));var O=Object.getPrototypeOf,E=O&&O(O(j([])));E&&E!==i&&a.call(E,l)&&(_=E);var F=q.prototype=g.prototype=Object.create(_);function A(t){["next","throw","return"].forEach((function(e){d(t,e,(function(t){return this._invoke(e,t)}))}))}function P(t,e){function r(o,i,s,u){var l=h(t[o],t,i);if("throw"!==l.type){var c=l.arg,f=c.value;return f&&"object"==n(f)&&a.call(f,"__await")?e.resolve(f.__await).then((function(t){r("next",t,s,u)}),(function(t){r("throw",t,s,u)})):e.resolve(f).then((function(t){c.value=t,s(c)}),(function(t){return r("throw",t,s,u)}))}u(l.arg)}var o;s(this,"_invoke",{value:function(t,n){function i(){return new e((function(e,o){r(t,n,e,o)}))}return o=o?o.then(i,i):i()}})}function L(t,r,n){var o=m;return function(i,a){if(o===y)throw Error("Generator is already running");if(o===w){if("throw"===i)throw a;return{value:e,done:!0}}for(n.method=i,n.arg=a;;){var s=n.delegate;if(s){var u=S(s,n);if(u){if(u===b)continue;return u}}if("next"===n.method)n.sent=n._sent=n.arg;else if("throw"===n.method){if(o===m)throw o=w,n.arg;n.dispatchException(n.arg)}else"return"===n.method&&n.abrupt("return",n.arg);o=y;var l=h(t,r,n);if("normal"===l.type){if(o=n.done?w:v,l.arg===b)continue;return{value:l.arg,done:n.done}}"throw"===l.type&&(o=w,n.method="throw",n.arg=l.arg)}}}function S(t,r){var n=r.method,o=t.iterator[n];if(o===e)return r.delegate=null,"throw"===n&&t.iterator.return&&(r.method="return",r.arg=e,S(t,r),"throw"===r.method)||"return"!==n&&(r.method="throw",r.arg=new TypeError("The iterator does not provide a '"+n+"' method")),b;var i=h(o,t.iterator,r.arg);if("throw"===i.type)return r.method="throw",r.arg=i.arg,r.delegate=null,b;var a=i.arg;return a?a.done?(r[t.resultName]=a.value,r.next=t.nextLoc,"return"!==r.method&&(r.method="next",r.arg=e),r.delegate=null,b):a:(r.method="throw",r.arg=new TypeError("iterator result is not an object"),r.delegate=null,b)}function C(t){var e={tryLoc:t[0]};1 in t&&(e.catchLoc=t[1]),2 in t&&(e.finallyLoc=t[2],e.afterLoc=t[3]),this.tryEntries.push(e)}function M(t){var e=t.completion||{};e.type="normal",delete e.arg,t.completion=e}function k(t){this.tryEntries=[{tryLoc:"root"}],t.forEach(C,this),this.reset(!0)}function j(t){if(t||""===t){var r=t[l];if(r)return r.call(t);if("function"==typeof t.next)return t;if(!isNaN(t.length)){var o=-1,i=function r(){for(;++o=0;--o){var i=this.tryEntries[o],s=i.completion;if("root"===i.tryLoc)return n("end");if(i.tryLoc<=this.prev){var u=a.call(i,"catchLoc"),l=a.call(i,"finallyLoc");if(u&&l){if(this.prev=0;--r){var n=this.tryEntries[r];if(n.tryLoc<=this.prev&&a.call(n,"finallyLoc")&&this.prev=0;--e){var r=this.tryEntries[e];if(r.finallyLoc===t)return this.complete(r.completion,r.afterLoc),M(r),b}},catch:function(t){for(var e=this.tryEntries.length-1;e>=0;--e){var r=this.tryEntries[e];if(r.tryLoc===t){var n=r.completion;if("throw"===n.type){var o=n.arg;M(r)}return o}}throw Error("illegal catch attempt")},delegateYield:function(t,r,n){return this.delegate={iterator:j(t),resultName:r,nextLoc:n},"next"===this.method&&(this.arg=e),b}},r}t.exports=o,t.exports.__esModule=!0,t.exports.default=t.exports},735:t=>{function e(r){return t.exports=e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},t.exports.__esModule=!0,t.exports.default=t.exports,e(r)}t.exports=e,t.exports.__esModule=!0,t.exports.default=t.exports},183:(t,e,r)=>{var n=r(24)();t.exports=n;try{regeneratorRuntime=n}catch(t){"object"==typeof globalThis?globalThis.regeneratorRuntime=n:Function("r","regeneratorRuntime = r")(n)}}},e={};function r(n){var o=e[n];if(void 0!==o)return o.exports;var i=e[n]={exports:{}};return t[n](i,i.exports,r),i.exports}r.n=t=>{var e=t&&t.__esModule?()=>t.default:()=>t;return r.d(e,{a:e}),e},r.d=(t,e)=>{for(var n in e)r.o(e,n)&&!r.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:e[n]})},r.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),r.r=t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})};var n={};(()=>{"use strict";r.r(n),r.d(n,{extend:()=>X,panes:()=>Z,types:()=>tt});const t=flarum.core.compat["forum/app"];var e=r.n(t);const o=flarum.core.compat["common/extend"],i=flarum.core.compat["common/components/LinkButton"];var a=r.n(i);const s=flarum.core.compat["forum/components/UserPage"];var u=r.n(s);const l=flarum.core.compat["forum/components/UserCard"];var c=r.n(l);const f=flarum.core.compat["common/helpers/icon"];var d=r.n(f),p=function(){function t(t){var e=t.field,r=t.set,n=t.value;this.field=e,this.set=r,this.value=n}var e=t.prototype;return e.readAttribute=function(t,e){return"function"==typeof t[e]?t[e]():t[e]},e.validationRules=function(){return this.readAttribute(this.field,"validation").split("|")},e.validationRule=function(t){var e=null;return this.validationRules().forEach((function(r){var n=r.split(":",2);n[0]===t&&(e=n[1])})),e},e.editorField=function(){return m("div",{class:"Form-group Field"},m("label",null,this.field.icon()?[d()(this.field.icon())," "]:null," ",this.field.name()," ",this.field.required()?"*":null),m("div",{class:"FormField"},this.field.prefix()?m(".prefix",this.field.prefix()):null,this.editorInput(),this.field.description()?m("div",{class:"helpText"},this.field.description()):null))},e.editorInput=function(){return m("input",this.editorInputAttrs())},e.editorInputAttrs=function(){var t=this;return{className:"FormControl",oninput:function(e){t.set(e.target.value)},value:this.value,required:this.field.required()}},e.answerField=function(){var t=this.readAttribute(this.field,"icon");return m("div",{className:"Masquerade-Bio-Set"+(this.hasAnswer()?"":" Masquerade-Bio-Set--empty")},m("span",{class:"Masquerade-Bio-Field"},t&&m("[",null,d()(t)," "),this.readAttribute(this.field,"name"),":"," "),m("span",{class:"Masquerade-Bio-Answer"},this.answerContent()))},e.answerContent=function(){return this.value},e.hasAnswer=function(){var t=this.answerContent();return!(null===t||("object"==typeof t?!Object.keys(t).length:null==t||!t.length))},t.isNoOptionSelectedValue=function(t){return null===t||""===t},t}();function h(t,e){return h=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,e){return t.__proto__=e,t},h(t,e)}function v(t,e){t.prototype=Object.create(e.prototype),t.prototype.constructor=t,h(t,e)}var y=function(t){function e(){return t.apply(this,arguments)||this}v(e,t);var r=e.prototype;return r.editorInput=function(){var t=this;return this.options().map((function(e){return m("div",m("label",[m("input[type=radio]",{checked:e.selected(t.value),onclick:function(){t.set(e.key)}})," "+e.label]))}))},r.options=function(){var t=[];return this.readAttribute(this.field,"required")||t.push({selected:function(t){return p.isNoOptionSelectedValue(t)},key:null,label:app.translator.trans("fof-masquerade.forum.fields.select.none-optional")}),t.push({selected:function(t){return-1!==["true","1",1,!0,"yes"].indexOf(t)},key:"true",label:app.translator.trans("fof-masquerade.forum.fields.boolean.yes")}),t.push({selected:function(t){return-1!==["false","0",0,!1,"no"].indexOf(t)},key:"false",label:app.translator.trans("fof-masquerade.forum.fields.boolean.no")}),p.isNoOptionSelectedValue(this.value)||-1!==["true","1",1,!0,"yes","false","0",0,!1,"no"].indexOf(this.value)||t.push({selected:function(){return!0},key:this.value,label:"(invalid) "+this.value}),t},r.answerContent=function(){return p.isNoOptionSelectedValue(this.value)?"":-1!==[1,"1",!0,"true","yes"].indexOf(this.value)?[d()("far fa-check-square")," ",app.translator.trans("fof-masquerade.forum.fields.boolean.yes")]:[d()("far fa-square")," ",app.translator.trans("fof-masquerade.forum.fields.boolean.no")]},e}(p);const w=flarum.core.compat["common/components/Button"];var b=r.n(w),g=function(t){function e(){return t.apply(this,arguments)||this}v(e,t);var r=e.prototype;return r.editorInputAttrs=function(){var e=t.prototype.editorInputAttrs.call(this);return e.type="email",e.placeholder="you@example.com",e},r.answerContent=function(){var t=this,e=this.value;if(!e)return null;var r=e.split(/@|\./).map((function(t){return t.replace(/(.{2})./g,"$1*")})).join("*");return b().component({onclick:function(){return t.mailTo()},className:"Button Button--text",icon:"far fa-envelope"},r)},r.mailTo=function(){window.location="mailto:"+this.value},e}(p);const x=flarum.core.compat["common/components/Select"];var q=r.n(x),_="fof_masquerade_no_option_selected",O=function(t){function r(){return t.apply(this,arguments)||this}v(r,t);var n=r.prototype;return n.editorInput=function(){var t=this;return q().component({onchange:function(e){e===_&&(e=""),t.set(e)},value:p.isNoOptionSelectedValue(this.value)?_:this.value,options:this.options()})},n.options=function(){var t={};this.readAttribute(this.field,"required")?p.isNoOptionSelectedValue(this.value)&&(t[_]=e().translator.trans("fof-masquerade.forum.fields.select.none-required")):t[_]=e().translator.trans("fof-masquerade.forum.fields.select.none-optional");var r=this.validationRule("in");return r&&r.split(",").forEach((function(e){t[e]=e})),p.isNoOptionSelectedValue(this.value)||void 0!==t[this.value]||(t[this.value]="(invalid) "+this.value),t},r}(p),E=function(t){function e(){return t.apply(this,arguments)||this}v(e,t);var r=e.prototype;return r.editorInputAttrs=function(){var e=t.prototype.editorInputAttrs.call(this);return e.type="url",e.placeholder="https://example.com",e},r.answerContent=function(){var t=this,e=this.value;return e?b().component({onclick:function(){return t.to()},className:"Button Button--text",icon:"fas fa-link"},e.replace(/^https?:\/\//,"")):null},r.to=function(){window.open().location=this.value},e}(p),F=function(){function t(){}return t.typeForField=function(t){var e=t.field,r=t.set,n=void 0===r?void 0:r,o=t.value,i=p,a=this.identify(e);return a&&(i=this.types()[a]),new i({field:e,set:n,value:o})},t.fieldAttribute=function(t,e){return"function"==typeof t[e]?t[e]():t[e]},t.types=function(){return{boolean:y,email:g,select:O,url:E}},t.identify=function(t){var e=this,r=(this.fieldAttribute(t,"validation")||"").split(","),n=null,o=this.fieldAttribute(t,"type");return void 0!==this.types()[o]?o:(r.forEach((function(t){t=t.trim(),void 0!==e.types()[t]&&(n=t)})),n)},t}();const A=flarum.core.compat["common/extenders"];var P=r.n(A);const L=flarum.core.compat["common/models/User"];var S=r.n(L);const C=flarum.core.compat["common/app"];var M=r.n(C);const k=flarum.core.compat["common/Model"];var j=r.n(k);const N=flarum.core.compat["common/utils/computed"];var I=r.n(N),V=function(t){function e(){for(var e,r=arguments.length,n=new Array(r),o=0;o{var e={n:t=>{var n=t&&t.__esModule?()=>t.default:()=>t;return e.d(n,{a:n}),n},d:(t,n)=>{for(var r in n)e.o(n,r)&&!e.o(t,r)&&Object.defineProperty(t,r,{enumerable:!0,get:n[r]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t),r:e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})}},t={};(()=>{"use strict";e.r(t),e.d(t,{components:()=>Te,extend:()=>Ee});const n=flarum.core.compat["admin/app"];var r=e.n(n);function o(e,t){return o=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},o(e,t)}function a(e,t){e.prototype=Object.create(t.prototype),e.prototype.constructor=e,o(e,t)}function i(e,t,n){if(void 0===n)return e&&e.h5s&&e.h5s.data&&e.h5s.data[t];e.h5s=e.h5s||{},e.h5s.data=e.h5s.data||{},e.h5s.data[t]=n}var s=function(e,t){if(!(e instanceof NodeList||e instanceof HTMLCollection||e instanceof Array))throw new Error("You must provide a nodeList/HTMLCollection/Array of elements to be filtered.");return"string"!=typeof t?Array.from(e):Array.from(e).filter((function(e){return 1===e.nodeType&&e.matches(t)}))},l=new Map,d=function(){function e(){this._config=new Map,this._placeholder=void 0,this._data=new Map}return Object.defineProperty(e.prototype,"config",{get:function(){var e={};return this._config.forEach((function(t,n){e[n]=t})),e},set:function(e){if("object"!=typeof e)throw new Error("You must provide a valid configuration object to the config setter.");var t=Object.assign({},e);this._config=new Map(Object.entries(t))},enumerable:!1,configurable:!0}),e.prototype.setConfig=function(e,t){if(!this._config.has(e))throw new Error("Trying to set invalid configuration item: "+e);this._config.set(e,t)},e.prototype.getConfig=function(e){if(!this._config.has(e))throw new Error("Invalid configuration item requested: "+e);return this._config.get(e)},Object.defineProperty(e.prototype,"placeholder",{get:function(){return this._placeholder},set:function(e){if(!(e instanceof HTMLElement)&&null!==e)throw new Error("A placeholder must be an html element or null.");this._placeholder=e},enumerable:!1,configurable:!0}),e.prototype.setData=function(e,t){if("string"!=typeof e)throw new Error("The key must be a string.");this._data.set(e,t)},e.prototype.getData=function(e){if("string"!=typeof e)throw new Error("The key must be a string.");return this._data.get(e)},e.prototype.deleteData=function(e){if("string"!=typeof e)throw new Error("The key must be a string.");return this._data.delete(e)},e}(),u=function(e){if(!(e instanceof HTMLElement))throw new Error("Please provide a sortable to the store function.");return l.has(e)||l.set(e,new d),l.get(e)};function c(e,t,n){if(e instanceof Array)for(var r=0;r0&&t.matches(e)})).length>0;if(e===t)return!0;if(void 0!==u(e).getConfig("connectWith")&&null!==u(e).getConfig("connectWith"))return u(e).getConfig("connectWith")===u(t).getConfig("connectWith")}return!1},D={items:null,connectWith:null,disableIEFix:null,acceptFrom:null,copy:!1,placeholder:null,placeholderClass:"sortable-placeholder",draggingClass:"sortable-dragging",hoverClass:!1,dropTargetContainerClass:!1,debounce:0,throttleTime:100,maxItems:0,itemSerializer:void 0,containerSerializer:void 0,customDragImage:null,orientation:"vertical"},H=function(e){f(e,"dragstart"),f(e,"dragend"),f(e,"dragover"),f(e,"dragenter"),f(e,"drop"),f(e,"mouseenter"),f(e,"mouseleave")},B=function(e,t){e&&f(e,"dragleave"),t&&t!==e&&f(t,"dragleave")},Y=function(e){var t;(t=e).h5s&&delete t.h5s.data,h(e,"aria-dropeffect")},j=function(e){h(e,"aria-grabbed"),h(e,"aria-copied"),h(e,"draggable"),h(e,"role")};function k(e,t){if(t.composedPath)return t.composedPath().find((function(e){return e.isSortable}));for(;!0!==e.isSortable;)e=e.parentElement;return e}function R(e,t){var n=i(e,"opts"),r=s(e.children,n.items).filter((function(e){return e.contains(t)||e.shadowRoot&&e.shadowRoot.contains(t)}));return r.length>0?r[0]:t}var z=function(e){var t=i(e,"opts"),n=s(e.children,t.items),r=N(n,t.handle);p(e,"aria-dropeffect","move"),i(e,"_disabled","false"),p(r,"draggable","true"),!1===t.disableIEFix&&"function"==typeof(document||window.document).createElement("span").dragDrop&&c(r,"mousedown",(function(){if(-1!==n.indexOf(this))this.dragDrop();else{for(var e=this.parentElement;-1===n.indexOf(e);)e=e.parentElement;e.dragDrop()}}))};function U(e,t){var n=String(t);return t=t||{},"string"==typeof e&&(e=document.querySelectorAll(e)),e instanceof HTMLElement&&(e=[e]),e=Array.prototype.slice.call(e),/serialize/.test(n)?e.map((function(e){var t=i(e,"opts");return function(e,t,n){if(void 0===t&&(t=function(e,t){return e}),void 0===n&&(n=function(e){return e}),!(e instanceof HTMLElement)||1==!e.isSortable)throw new Error("You need to provide a sortableContainer to be serialized.");if("function"!=typeof t||"function"!=typeof n)throw new Error("You need to provide a valid serializer for items and the container.");var r=i(e,"opts").items,o=s(e.children,r),a=o.map((function(t){return{parent:e,node:t,html:t.outerHTML,index:F(t,o)}}));return{container:n({node:e,itemCount:a.length}),items:a.map((function(n){return t(n,e)}))}}(e,t.itemSerializer,t.containerSerializer)})):(e.forEach((function(e){if(/enable|disable|destroy/.test(n))return U[n](e);["connectWith","disableIEFix"].forEach((function(e){Object.prototype.hasOwnProperty.call(t,e)&&null!==t[e]&&console.warn('HTML5Sortable: You are using the deprecated configuration "'+e+'". This will be removed in an upcoming version, make sure to migrate to the new options when updating.')})),t=Object.assign({},D,u(e).config,t),u(e).config=t,i(e,"opts",t),e.isSortable=!0,function(e){var t=i(e,"opts"),n=s(e.children,t.items),r=N(n,t.handle);i(e,"_disabled","false"),H(n),B(y,x),f(r,"mousedown"),f(e,"dragover"),f(e,"dragenter"),f(e,"drop")}(e);var r,o=s(e.children,t.items);if(null!==t.placeholder&&void 0!==t.placeholder){var a=document.createElement(e.tagName);t.placeholder instanceof HTMLElement?a.appendChild(t.placeholder):a.innerHTML=t.placeholder,r=a.children[0]}u(e).placeholder=function(e,t,n){var r;if(void 0===n&&(n="sortable-placeholder"),!(e instanceof HTMLElement))throw new Error("You must provide a valid element as a sortable.");if(!(t instanceof HTMLElement)&&void 0!==t)throw new Error("You must provide a valid element as a placeholder or set ot to undefined.");return void 0===t&&(["UL","OL"].includes(e.tagName)?t=document.createElement("li"):["TABLE","TBODY"].includes(e.tagName)?(t=document.createElement("tr")).innerHTML='':t=document.createElement("div")),"string"==typeof n&&(r=t.classList).add.apply(r,n.split(" ")),t}(e,r,t.placeholderClass),i(e,"items",t.items),t.acceptFrom?i(e,"acceptFrom",t.acceptFrom):t.connectWith&&i(e,"connectWith",t.connectWith),z(e),p(o,"role","option"),p(o,"aria-grabbed","false"),function(e,t){if("string"==typeof u(e).getConfig("hoverClass")){var n=u(e).getConfig("hoverClass").split(" ");!0===t?(c(e,"mousemove",function(e,t){var n=this;if(void 0===t&&(t=250),"number"!=typeof t)throw new Error("You must provide a number as the second argument for throttle.");var r=null;return function(){for(var o=[],a=0;a=t)&&(r=i,e.apply(n,o))}}((function(t){0===t.buttons&&s(e.children,u(e).getConfig("items")).forEach((function(e){var r,o;e!==t.target?(r=e.classList).remove.apply(r,n):(o=e.classList).add.apply(o,n)}))}),u(e).getConfig("throttleTime"))),c(e,"mouseleave",(function(){s(e.children,u(e).getConfig("items")).forEach((function(e){var t;(t=e.classList).remove.apply(t,n)}))}))):(f(e,"mousemove"),f(e,"mouseleave"))}}(e,!0),c(e,"dragstart",(function(e){var n=M(e);if(!0!==n.isSortable&&(e.stopImmediatePropagation(),(!t.handle||n.matches(t.handle))&&"false"!==n.getAttribute("draggable"))){var r=k(n,e),o=R(r,n);T=s(r.children,t.items),w=T.indexOf(o),E=F(o,r.children),y=r,function(e,t,n){if(!(e instanceof Event))throw new Error("setDragImage requires a DragEvent as the first argument.");if(!(t instanceof HTMLElement))throw new Error("setDragImage requires the dragged element as the second argument.");if(n||(n=P),e.dataTransfer&&e.dataTransfer.setDragImage){var r=n(t,q(t),e);if(!(r.element instanceof HTMLElement)||"number"!=typeof r.posX||"number"!=typeof r.posY)throw new Error("The customDragImage function you provided must return and object with the properties element[string], posX[integer], posY[integer].");e.dataTransfer.effectAllowed="copyMove",e.dataTransfer.setData("text/plain",M(e).id),e.dataTransfer.setDragImage(r.element,r.posX,r.posY)}}(e,o,t.customDragImage),v=S(o),b=I(o),o.classList.add(t.draggingClass),g=function(e,t){var n=e;return!0===u(t).getConfig("copy")&&(p(n=e.cloneNode(!0),"aria-copied","true"),e.parentElement.appendChild(n),n.style.display="none",n.oldDisplay=e.style.display),n}(o,r),p(g,"aria-grabbed","true"),r.dispatchEvent(new CustomEvent("sortstart",{detail:{origin:{elementIndex:E,index:w,container:y},item:g,originalTarget:n}}))}})),c(e,"dragenter",(function(n){var r=M(n),o=k(r,n);o&&o!==x&&(C=s(o.children,i(o,"items")).filter((function(t){return t!==u(e).placeholder})),t.dropTargetContainerClass&&o.classList.add(t.dropTargetContainerClass),o.dispatchEvent(new CustomEvent("sortenter",{detail:{origin:{elementIndex:E,index:w,container:y},destination:{container:o,itemsBeforeUpdate:C},item:g,originalTarget:r}})),c(o,"dragleave",(function(e){var n=e.relatedTarget||e.fromElement;e.currentTarget.contains(n)||(t.dropTargetContainerClass&&o.classList.remove(t.dropTargetContainerClass),o.dispatchEvent(new CustomEvent("sortleave",{detail:{origin:{elementIndex:E,index:w,container:o},item:g,originalTarget:r}})))}))),x=o})),c(e,"dragend",(function(n){if(g){g.classList.remove(t.draggingClass),p(g,"aria-grabbed","false"),"true"===g.getAttribute("aria-copied")&&"true"!==i(g,"dropped")&&g.remove(),g.style.display=g.oldDisplay,delete g.oldDisplay;var r=Array.from(l.values()).map((function(e){return e.placeholder})).filter((function(e){return e instanceof HTMLElement})).filter(L)[0];r&&r.remove(),e.dispatchEvent(new CustomEvent("sortstop",{detail:{origin:{elementIndex:E,index:w,container:y},item:g}})),x=null,g=null,v=null,b=null}})),c(e,"drop",(function(n){if(A(e,g.parentElement)){n.preventDefault(),n.stopPropagation(),i(g,"dropped","true");var r=Array.from(l.values()).map((function(e){return e.placeholder})).filter((function(e){return e instanceof HTMLElement})).filter(L)[0];_(r,g),r.remove(),e.dispatchEvent(new CustomEvent("sortstop",{detail:{origin:{elementIndex:E,index:w,container:y},item:g}}));var o=u(e).placeholder,a=s(y.children,t.items).filter((function(e){return e!==o})),d=!0===this.isSortable?this:this.parentElement,c=s(d.children,i(d,"items")).filter((function(e){return e!==o})),f=F(g,Array.from(g.parentElement.children).filter((function(e){return e!==o}))),m=F(g,c);t.dropTargetContainerClass&&d.classList.remove(t.dropTargetContainerClass),E===f&&y===d||e.dispatchEvent(new CustomEvent("sortupdate",{detail:{origin:{elementIndex:E,index:w,container:y,itemsBeforeUpdate:T,items:a},destination:{index:m,elementIndex:f,container:d,itemsBeforeUpdate:C,items:c},item:g}}))}}));var d,m,h,Y=(d=function(e,n,r,o){if(g)if(t.forcePlaceholderSize&&(u(e).placeholder.style.height=v+"px",u(e).placeholder.style.width=b+"px"),Array.from(e.children).indexOf(n)>-1){var a=S(n),i=I(n),d=F(u(e).placeholder,n.parentElement.children),c=F(n,n.parentElement.children);if(a>v||i>b){var f=a-v,m=i-b,p=q(n).top,h=q(n).left;if(dc&&("vertical"===t.orientation&&o>p+a-f||"horizontal"===t.orientation&&r>h+i-m))return}void 0===g.oldDisplay&&(g.oldDisplay=g.style.display),"none"!==g.style.display&&(g.style.display="none");var y=!1;try{var w=q(n).top+n.offsetHeight/2,E=q(n).left+n.offsetWidth/2;y="vertical"===t.orientation&&o>=w||"horizontal"===t.orientation&&r>=E}catch(e){y=d=parseInt(r.maxItems)&&g.parentElement!==n||(e.preventDefault(),e.stopPropagation(),e.dataTransfer.dropEffect=!0===u(n).getConfig("copy")?"copy":"move",Y(n,t,e.pageX,e.pageY))}};c(o.concat(e),"dragover",j),c(o.concat(e),"dragenter",j)})),e)}U.destroy=function(e){!function(e){var t=i(e,"opts")||{},n=s(e.children,t.items),r=N(n,t.handle);f(e,"dragover"),f(e,"dragenter"),f(e,"dragstart"),f(e,"dragend"),f(e,"drop"),Y(e),f(r,"mousedown"),H(n),j(n),B(y,x),e.isSortable=!1}(e)},U.enable=function(e){z(e)},U.disable=function(e){!function(e){var t=i(e,"opts"),n=s(e.children,t.items),r=N(n,t.handle);p(e,"aria-dropeffect","none"),i(e,"_disabled","true"),p(r,"draggable","false"),f(r,"mousedown")}(e)},U.__testing={_data:i,_removeItemEvents:H,_removeItemData:j,_removeSortableData:Y,_removeContainerEvents:B};const W=U,X=flarum.core.compat["admin/components/ExtensionPage"];var V=e.n(X);const G=flarum.core.compat["common/components/Switch"];var J=e.n(G);const K=flarum.core.compat["common/components/Button"];var Q=e.n(K);const Z=flarum.core.compat["common/components/Select"];var ee=e.n(Z);const te=flarum.core.compat["common/utils/withAttr"];var ne=e.n(te);const re=flarum.core.compat["common/Component"];var oe=e.n(re);const ae=flarum.core.compat["common/helpers/icon"];var ie=e.n(ae),se=function(e){function t(){return e.apply(this,arguments)||this}a(t,e);var n=t.prototype;return n.oninit=function(t){e.prototype.oninit.call(this,t),this.newOption=""},n.view=function(){var e=this;return m(".Form-group",[m("label",r().translator.trans("fof-masquerade.admin.fields.options")),m("table",m("tbody",this.options().map((function(t,n){return m("tr",[m("td",m("input[type=text].FormControl",{oninput:function(t){e.updateOption(n,t.target.value)},value:t})),m("td",m("button.Button",{onclick:function(){e.moveOption(n,-1)}},ie()("fas fa-chevron-up"))),m("td",m("button.Button",{onclick:function(){e.moveOption(n,1)}},ie()("fas fa-chevron-down"))),m("td",m("button.Button.Button--danger",{onclick:function(){e.deleteOption(n)}},ie()("fas fa-times")))])})))),m(".helpText",r().translator.trans("fof-masquerade.admin.fields.option-comma-warning")),m("table",m("tbody"),m("tr",[m("td",m("input[type=text].FormControl",{onchange:function(t){e.newOption=t.target.value},value:this.newOption,placeholder:r().translator.trans("fof-masquerade.admin.fields.option-new")})),m("td",m("button.Button.Button--primary",{onclick:function(){e.addOption()}},ie()("fas fa-plus")))]))])},n.updateRules=function(e){this.attrs.onchange("in:"+e.join(","))},n.options=function(){var e=this.attrs.value.split("|"),t=[];return e.forEach((function(e){var n=e.split(":",2);"in"===n[0]&&(t=n[1].split(","))})),t},n.updateOption=function(e,t){var n=this.options();n[e]=t,this.updateRules(n)},n.moveOption=function(e,t){var n=this.options(),r=e+t;if(!(r<0||r>n.length-1)){var o=n.splice(e,1);n.splice(r,0,o[0]),this.updateRules(n)}},n.deleteOption=function(e){var t=this.options();t.splice(e,1),this.updateRules(t)},n.addOption=function(){if(""!==this.newOption){var e=this.options();e.push(this.newOption),this.newOption="",this.updateRules(e)}},t}(oe());const le=flarum.core.compat["common/utils/ItemList"];var de=e.n(le),ue=function(){function e(){}var t=e.prototype;return t.view=function(e){var t=this,n=e.attrs,o=n.field,a=(n.loading,n.onUpdate),i=o.id();return m("fieldset",{className:"Field","data-id":o.id(),key:o.id()},m("legend",null,i?m(Q(),{className:"Button Button--icon Button--danger",icon:"fas fa-trash",onclick:function(){return t.deleteField(o,a)}}):null,m("span",{className:"Field-toggle",onclick:function(e){return t.toggleField(e)}},r().translator.trans("fof-masquerade.admin.fields."+(i?"edit":"add"),{field:o.name()}),ie()("fas fa-caret-down"))),m("div",{className:"Field-body"},this.fieldItems(o,a).toArray()))},t.fieldItems=function(e,t){var n=this,o=new(de());return o.add("name",m("div",{className:"Form-group"},m("label",null,r().translator.trans("fof-masquerade.admin.fields.name")),m("input",{className:"FormControl",value:e.name(),oninput:ne()("value",this.updateExistingFieldInput.bind(this,"name",e))}),m("span",{className:"helpText"},r().translator.trans("fof-masquerade.admin.fields.name-help"))),100),o.add("description",m("div",{className:"Form-group"},m("label",null,r().translator.trans("fof-masquerade.admin.fields.description")),m("input",{className:"FormControl",value:e.description(),oninput:ne()("value",this.updateExistingFieldInput.bind(this,"description",e))}),m("span",{className:"helpText"},r().translator.trans("fof-masquerade.admin.fields.description-help"))),90),o.add("icon",m("div",{className:"Form-group"},m("label",null,r().translator.trans("fof-masquerade.admin.fields.icon")),m("input",{className:"FormControl",value:e.icon(),oninput:ne()("value",this.updateExistingFieldInput.bind(this,"icon",e))}),m("span",{className:"helpText"},r().translator.trans("fof-masquerade.admin.fields.icon-help",{a:m("a",{href:"https://fontawesome.com/icons?m=free",target:"_blank"})}))),80),o.add("on_bio",m("div",{className:"Form-group"},m(J(),{state:e.on_bio(),onchange:this.updateExistingFieldInput.bind(this,"on_bio",e)},r().translator.trans("fof-masquerade.admin.fields.on_bio"))),70),o.add("required",m("div",{className:"Form-group"},m(J(),{state:e.required(),onchange:this.updateExistingFieldInput.bind(this,"required",e)},r().translator.trans("fof-masquerade.admin.fields.required"))),60),o.add("type",m("div",{className:"Form-group"},m("label",null,r().translator.trans("fof-masquerade.admin.fields.type")),m(ee(),{onchange:function(t){"null"===t&&(t=null),n.updateExistingFieldInput("type",e,t)},options:this.availableTypes(),value:e.type()})),50),"select"===e.type()&&o.add("select_options",m(se,{onchange:function(t){n.updateExistingFieldInput("validation",e,t)},value:e.validation()}),40),null===e.type()&&o.add("validation",m("div",{className:"Form-group"},m("label",null,r().translator.trans("fof-masquerade.admin.fields.validation")),m("input",{className:"FormControl",value:e.validation(),oninput:ne()("value",this.updateExistingFieldInput.bind(this,"validation",e))}),m("span",{className:"helpText"},r().translator.trans("fof-masquerade.admin.fields.validation-help",{a:m("a",{href:"https://laravel.com/docs/5.2/validation#available-validation-rules",target:"_blank"})}))),30),o.add("actions",m("div",{className:"Form-group"},m("div",{className:"ButtonGroup"},m(Q(),{className:"Button Button--primary",loading:this.loading,disabled:!this.readyToAdd(e),onclick:e.id()?this.updateExistingField.bind(this,e,t):this.submitAddField.bind(this,e,t)},r().translator.trans("fof-masquerade.admin.buttons."+(e.id()?"edit":"add")+"-field")),e.id()?m(Q(),{className:"Button Button--danger",loading:this.loading,onclick:this.deleteField.bind(this,e,t)},r().translator.trans("fof-masquerade.admin.buttons.delete-field")):null)),20),o},t.updateExistingFieldInput=function(e,t,n){var r;t.pushAttributes(((r={})[e]=n,r))},t.deleteField=function(e,t){e.delete().then(t)},t.toggleField=function(e){$(e.target).parents(".Field").toggleClass("active")},t.submitAddField=function(e,t,n){var r=this;n.preventDefault(),e.save(e.data.attributes).then((function(){t(),r.resetNewField()})),m.redraw()},t.updateExistingField=function(e,t){e.id()&&e.save(e.data.attributes).then(t)},t.resetNewField=function(){this.newField=r().store.createRecord("masquerade-field",{attributes:{name:"",description:"",prefix:"",icon:"",required:!1,on_bio:!1,type:null,validation:""}}),m.redraw()},t.readyToAdd=function(e){return!!e.name()},t.availableTypes=function(){return{url:r().translator.trans("fof-masquerade.admin.types.url"),email:r().translator.trans("fof-masquerade.admin.types.email"),boolean:r().translator.trans("fof-masquerade.admin.types.boolean"),select:r().translator.trans("fof-masquerade.admin.types.select"),null:r().translator.trans("fof-masquerade.admin.types.advanced")}},e}(),ce=function(){function e(){}return e.prototype.view=function(e){var t=e.attrs,n=t.existing,r=t.new,o=t.loading,a=t.onUpdate;return m("form.js-sortable-fields",n.map((function(e){return m(ue,{field:e,loading:o,onUpdate:a})})),m(ue,{field:r,loading:o,onUpdate:a}))},e}();const fe=flarum.core.compat["admin/utils/saveSettings"];var me=e.n(fe),pe=function(e){function t(){return e.apply(this,arguments)||this}a(t,e);var n=t.prototype;return n.oninit=function(t){e.prototype.oninit.call(this,t),this.resetNew(),this.loading=!1,this.existing=[],this.loadExisting(),this.enforceProfileCompletion="1"===r().data.settings["masquerade.force-profile-completion"]},n.config=function(){var e=this;W(this.element.querySelector(".js-sortable-fields"),{handle:"legend"})[0].addEventListener("sortupdate",(function(){var t=e.$(".js-sortable-fields > .Field").map((function(){return $(this).data("id")})).get();e.updateSort(t)}))},n.oncreate=function(t){e.prototype.oncreate.call(this,t),this.config()},n.onupdate=function(){this.config()},n.content=function(){var e=this;return m(".ExtensionPage-settings.ProfileConfigurePane",m(".container",[m("h2",r().translator.trans("fof-masquerade.admin.general-options")),m(".Form-group",J().component({state:this.enforceProfileCompletion,onchange:function(t){var n=t?"1":"0";me()({"masquerade.force-profile-completion":n}),e.enforceProfileCompletion=n}},r().translator.trans("fof-masquerade.admin.fields.force-user-to-completion"))),m("h2",r().translator.trans("fof-masquerade.admin.fields.title")),m(ce,{existing:this.existing,new:this.newField,loading:this.loading,onUpdate:this.requestSuccess.bind(this)})]))},n.updateSort=function(e){r().request({method:"POST",url:r().forum.attribute("apiUrl")+"/masquerade/fields/order",body:{sort:e}}).then(this.requestSuccess.bind(this))},n.requestSuccess=function(){this.loadExisting(),this.resetNew(),m.redraw()},n.loadExisting=function(){var e=this;return this.loading=!0,r().request({method:"GET",url:r().forum.attribute("apiUrl")+"/masquerade/fields"}).then((function(t){r().store.pushPayload(t),e.existing=r().store.all("masquerade-field"),e.existing.sort((function(e,t){return e.sort()-t.sort()})),e.loading=!1,m.redraw()}))},n.resetNew=function(){this.newField=r().store.createRecord("masquerade-field",{attributes:{name:"",description:"",prefix:"",icon:"",required:!1,on_bio:!1,type:null,validation:""}})},t}(V());const he=flarum.core.compat["common/extenders"];var ge=e.n(he);const ve=flarum.core.compat["common/Model"];var be=e.n(ve),ye=function(e){function t(){for(var t,n=arguments.length,r=new Array(n),o=0;o