diff --git a/adapters/install-adapters.sh b/adapters/install-adapters.sh index 062f7b2ec..fa69ae317 100644 --- a/adapters/install-adapters.sh +++ b/adapters/install-adapters.sh @@ -1,4 +1,4 @@ -ADAPTERS="adminforth-completion-adapter-open-ai-chat-gpt adminforth-email-adapter-aws-ses" +ADAPTERS="adminforth-completion-adapter-open-ai-chat-gpt adminforth-email-adapter-aws-ses adminforth-google-oauth-adapter adminforth-github-oauth-adapter" # for each plugin for adapter in $ADAPTERS; do diff --git a/adminforth/documentation/blog/2024-11-14-compose-ec2-deployment-ci/index.md b/adminforth/documentation/blog/2024-11-14-compose-ec2-deployment-ci/index.md index 79faf2c3d..e2555d6b4 100644 --- a/adminforth/documentation/blog/2024-11-14-compose-ec2-deployment-ci/index.md +++ b/adminforth/documentation/blog/2024-11-14-compose-ec2-deployment-ci/index.md @@ -365,20 +365,19 @@ terraform apply -auto-approve ## Step 6 - Migrate state to the cloud -First deployment had to create S3 bucket and DynamoDB table for storing Terraform state. Now we need to migrate the state to the cloud. +First deployment had to create S3 bucket for storing Terraform state. Now we need to migrate the state to the cloud. Add to the end of `main.tf`: ```hcl title="main.tf" -# Configure the backend to use the S3 bucket and DynamoDB table +# Configure the backend to use the S3 bucket terraform { backend "s3" { bucket = "-terraform-state" key = "state.tfstate" # Define a specific path for the state file region = "eu-central-1" profile = "myaws" - dynamodb_table = "-terraform-lock-table" use_lockfile = true } } @@ -423,7 +422,7 @@ jobs: - name: Set up Terraform uses: hashicorp/setup-terraform@v2 with: - terraform_version: 1.4.6 + terraform_version: 1.10.1 - run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner." - name: Start building env: diff --git a/adminforth/documentation/docs/tutorial/03-Customization/06-customPages.md b/adminforth/documentation/docs/tutorial/03-Customization/06-customPages.md index 06b848a32..2a18a2c98 100644 --- a/adminforth/documentation/docs/tutorial/03-Customization/06-customPages.md +++ b/adminforth/documentation/docs/tutorial/03-Customization/06-customPages.md @@ -419,4 +419,44 @@ mongoose, or just use raw SQL queries against your tables. Demo: -![alt text](dashDemo.gif) \ No newline at end of file +![alt text](dashDemo.gif) + +## Custom pages without menu item + +Sometimes you might need to add custom page but don't want to add it to the menu. + +In this case you can add custom page using `customization.customPages` option: + +```ts title="/index.ts" +new AdminForth({ + // ... + customization: { + customPages: [ + { + path: '/setup2fa', // route path + component: { + file: '@@/pages/TwoFactorsSetup.vue', + meta: { + title: 'Setup 2FA', // meta title for this page + customLayout: true // don't include default layout like menu/header + } + } + } + ] + } +}) +``` + +This will register custom page with path `/setup2fa` and will not include it in the menu. + +You can navigate user to this page using any router link, e.g.: + +```html + +``` + +```ts +import { Link } from '@/afcl'; +``` \ No newline at end of file diff --git a/adminforth/documentation/docs/tutorial/05-Plugins/11-oauth.md b/adminforth/documentation/docs/tutorial/05-Plugins/11-oauth.md new file mode 100644 index 000000000..4b444e6ae --- /dev/null +++ b/adminforth/documentation/docs/tutorial/05-Plugins/11-oauth.md @@ -0,0 +1,92 @@ +# OAuth Authentication + +The OAuth plugin enables OAuth2-based authentication in AdminForth, allowing users to sign in using their Google, GitHub, or other OAuth2 provider accounts. + +## Installation + +To install the plugin: + +```bash +npm install @adminforth/oauth --save +npm install @adminforth/google-oauth-adapter --save # for Google OAuth +``` + +## Configuration + +### 1. OAuth Provider Setup + +You need to get the client ID and client secret from your OAuth2 provider. + +For Google: +1. Go to the [Google Cloud Console](https://console.cloud.google.com) +2. Create a new project or select an existing one +3. Go to "APIs & Services" → "Credentials" +4. Create credentials for OAuth 2.0 client IDs +5. Select application type: "Web application" +6. Add your application's name and redirect URI +7. Set the redirect URI to `http://your-domain/oauth/callback` +8. Add the credentials to your `.env` file: + +```bash +GOOGLE_CLIENT_ID=your_google_client_id +GOOGLE_CLIENT_SECRET=your_google_client_secret +``` + +### 2. Plugin Configuration + +Configure the plugin in your user resource file: + +```typescript title="./resources/adminuser.ts" +import OAuth2Plugin from '@adminforth/oauth'; +import AdminForthAdapterGoogleOauth2 from '@adminforth/google-oauth-adapter'; + +// ... existing resource configuration ... + +plugins: [ + new OAuth2Plugin({ + adapters: [ + new AdminForthAdapterGoogleOauth2({ + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + redirectUri: 'http://localhost:3000/oauth/callback', + }), + ], + emailField: 'email', // Required: field that stores the user's email + emailConfirmedField: 'email_confirmed' // Optional: field to track email verification + }), +] +``` + +### 3. Email Confirmation + +The plugin supports automatic email confirmation for OAuth users. To enable this: + +1. Add the `email_confirmed` field to your database schema: + +```prisma title='./schema.prisma' +model adminuser { + // ... existing fields ... + email_confirmed Boolean @default(false) +} +``` + +2. Run the migration: + +```bash +npx prisma migrate dev --name add-email-confirmed-to-adminuser +``` + +3. Configure the plugin with `emailConfirmedField`: + +```typescript title="./resources/adminuser.ts" +new OAuth2Plugin({ + // ... adapters configuration ... + emailField: 'email', + emailConfirmedField: 'email_confirmed' // Enable email confirmation tracking +}), +``` + +When using OAuth: +- New users will have their email automatically confirmed (`email_confirmed = true`) +- Existing users will have their email marked as confirmed upon successful OAuth login +- The `email_confirmed` field must be a boolean type diff --git a/adminforth/index.ts b/adminforth/index.ts index b000b2335..00e7eadad 100644 --- a/adminforth/index.ts +++ b/adminforth/index.ts @@ -1,4 +1,3 @@ - import AdminForthAuth from './auth.js'; import MongoConnector from './dataConnectors/mongo.js'; import PostgresConnector from './dataConnectors/postgres.js'; @@ -414,6 +413,15 @@ class AdminForth implements IAdminForth { return { error: err }; } + for (const column of resource.columns) { + const fieldName = column.name; + if (fieldName in record) { + if (!column.showIn?.create || column.backendOnly) { + return { error: `Field "${fieldName}" cannot be modified as it is restricted from creation` }; + } + } + } + // execute hook if needed for (const hook of listify(resource.hooks?.create?.beforeSave)) { console.log('🪲 Hook beforeSave', hook); @@ -490,6 +498,15 @@ class AdminForth implements IAdminForth { delete record[column.name]; } + for (const column of resource.columns) { + const fieldName = column.name; + if (fieldName in record) { + if (!column.showIn?.edit || column.editReadonly || column.backendOnly) { + return { error: `Field "${fieldName}" cannot be modified as it is restricted from editing` }; + } + } + } + // execute hook if needed for (const hook of listify(resource.hooks?.edit?.beforeSave)) { const resp = await hook({ diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 38e46055b..d643f7f4f 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -458,6 +458,14 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } }); + if (resource.options.fieldGroups) { + resource.options.fieldGroups.forEach((group, i) => { + if (group.groupName) { + translateRoutines[`fieldGroup${i}`] = tr(group.groupName, `resource.${resource.resourceId}.fieldGroup`); + } + }); + } + const translated: Record = {}; await Promise.all( Object.entries(translateRoutines).map(async ([key, value]) => { @@ -531,6 +539,10 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { ), options: { ...resource.options, + fieldGroups: resource.options.fieldGroups?.map((group, i) => ({ + ...group, + groupName: translated[`fieldGroup${i}`] || group.groupName, + })), bulkActions: allowedBulkActions.map( (action, i) => ({ ...action, diff --git a/adminforth/spa/src/afcl/Select.vue b/adminforth/spa/src/afcl/Select.vue index a71daa121..7ab2998d6 100644 --- a/adminforth/spa/src/afcl/Select.vue +++ b/adminforth/spa/src/afcl/Select.vue @@ -32,8 +32,32 @@ /> -
+
+
+ + +
+
+ {{ options.length ? $t('No results found') : $t('No items here') }} +
+ +
+ +
+
+ + +
{ removeClickListener(); }); +const getDropdownPosition = computed(() => { + if (!inputEl.value) return {}; + const rect = inputEl.value.getBoundingClientRect(); + const style: { left: string; top: string; width: string } = { + left: `${rect.left}px`, + top: `${rect.bottom + 8}px`, + width: `${rect.width}px` + }; + + if (isTop.value && dropdownHeight.value) { + style.top = `${rect.top - dropdownHeight.value - 8}px`; + } + + return style; +}); + \ No newline at end of file diff --git a/adminforth/spa/src/components/ResourceListTable.vue b/adminforth/spa/src/components/ResourceListTable.vue index e91c6c87e..48030b79e 100644 --- a/adminforth/spa/src/components/ResourceListTable.vue +++ b/adminforth/spa/src/components/ResourceListTable.vue @@ -271,7 +271,7 @@ import { computed, onMounted, ref, watch, type Ref } from 'vue'; import { callAdminForthApi } from '@/utils'; - +import { useI18n } from 'vue-i18n'; import ValueRenderer from '@/components/ValueRenderer.vue'; import { getCustomComponent } from '@/utils'; import { useCoreStore } from '@/stores/core'; @@ -293,7 +293,7 @@ import type { AdminForthResourceCommon } from '@/types/Common'; import adminforth from '@/adminforth'; const coreStore = useCoreStore(); - +const { t } = useI18n(); const props = defineProps<{ page: number, resource: AdminForthResourceCommon, @@ -456,9 +456,9 @@ async function onClick(e,row) { async function deleteRecord(row) { const data = await adminforth.confirm({ - message: 'Are you sure you want to delete this item?', - yes: 'Delete', - no: 'Cancel', + message: t('Are you sure you want to delete this item?'), + yes: t('Delete'), + no: t('Cancel'), }); if (data) { try { @@ -472,13 +472,13 @@ async function deleteRecord(row) { }); if (!res.error){ emits('update:records', true) - showSuccesTost('Record deleted successfully') + showSuccesTost(t('Record deleted successfully')) } else { showErrorTost(res.error) } } catch (e) { - showErrorTost(`Something went wrong, please try again later`); + showErrorTost(t('Something went wrong, please try again later')); console.error(e); }; } diff --git a/adminforth/spa/src/views/CreateView.vue b/adminforth/spa/src/views/CreateView.vue index 29caeb842..ec1b9eca9 100644 --- a/adminforth/spa/src/views/CreateView.vue +++ b/adminforth/spa/src/views/CreateView.vue @@ -86,7 +86,7 @@ import { computed } from 'vue'; import { showErrorTost } from '@/composables/useFrontendApi'; import ThreeDotsMenu from '@/components/ThreeDotsMenu.vue'; import adminforth from '@/adminforth'; - +import { useI18n } from 'vue-i18n'; const isValid = ref(false); const validating = ref(false); @@ -101,6 +101,8 @@ const record = ref({}); const coreStore = useCoreStore(); +const { t } = useI18n(); + const initialValues = ref({}); @@ -114,15 +116,14 @@ onMounted(async () => { await coreStore.fetchResourceFull({ resourceId: route.params.resourceId }); + initialValues.value = (coreStore.resource?.columns || []).reduce((acc, column) => { + if (column.suggestOnCreate !== undefined) { + acc[column.name] = column.suggestOnCreate; + } + return acc; + }, {}); if (route.query.values) { - initialValues.value = JSON.parse(decodeURIComponent(route.query.values)); - } else { - initialValues.value = (coreStore.resource?.columns || []).reduce((acc, column) => { - if (column.suggestOnCreate !== undefined) { - acc[column.name] = column.suggestOnCreate; - } - return acc; - }, {}); + initialValues.value = { ...initialValues.value, ...JSON.parse(decodeURIComponent(route.query.values)) }; } record.value = initialValues.value; loading.value = false; @@ -162,7 +163,7 @@ async function saveRecord() { } }); adminforth.alert({ - message: 'Record created successfully', + message: t('Record created successfully!'), variant: 'success' }); } diff --git a/adminforth/spa/src/views/ShowView.vue b/adminforth/spa/src/views/ShowView.vue index 2f4798429..a9911f27c 100644 --- a/adminforth/spa/src/views/ShowView.vue +++ b/adminforth/spa/src/views/ShowView.vue @@ -120,12 +120,12 @@ import { showSuccesTost, showErrorTost } from '@/composables/useFrontendApi'; import ThreeDotsMenu from '@/components/ThreeDotsMenu.vue'; import ShowTable from '@/components/ShowTable.vue'; import adminforth from "@/adminforth"; - +import { useI18n } from 'vue-i18n'; const route = useRoute(); const router = useRouter(); const loading = ref(true); - +const { t } = useI18n(); const coreStore = useCoreStore(); onMounted(async () => { @@ -179,9 +179,9 @@ const otherColumns = computed(() => { async function deleteRecord(row) { const data = await adminforth.confirm({ - message: 'Are you sure you want to delete this item?', - yes: 'Delete', - no: 'Cancel', + message: t('Are you sure you want to delete this item?'), + yes: t('Delete'), + no: t('Cancel'), }); if (data) { try { @@ -194,7 +194,7 @@ async function deleteRecord(row) { }}); if (!res.error){ router.push({ name: 'resource-list', params: { resourceId: route.params.resourceId } }); - showSuccesTost('Record deleted successfully') + showSuccesTost(t('Record deleted successfully')) } else { showErrorTost(res.error) } diff --git a/adminforth/types/Adapters.ts b/adminforth/types/Adapters.ts index 62a18bf2a..8fff98f6b 100644 --- a/adminforth/types/Adapters.ts +++ b/adminforth/types/Adapters.ts @@ -24,3 +24,9 @@ export interface CompletionAdapter { error?: string; }>; } + +export interface OAuth2Adapter { + getAuthUrl(): string; + getTokenFromCode(code: string): Promise<{ email: string }>; + getIcon(): string; +} diff --git a/adminforth/types/Common.ts b/adminforth/types/Common.ts index c62970b03..56429139d 100644 --- a/adminforth/types/Common.ts +++ b/adminforth/types/Common.ts @@ -797,7 +797,7 @@ export interface AdminForthResourceColumnInputCommon { */ debounceTimeMs?: number, /** - * If true - will force EQ operator for filter instead of ILIKE. + * If false - will force EQ operator for filter instead of ILIKE. */ substringSearch?: boolean, }, diff --git a/dev-demo/custom/ApartsPie.vue b/dev-demo/custom/ApartsPie.vue new file mode 100644 index 000000000..683c83c54 --- /dev/null +++ b/dev-demo/custom/ApartsPie.vue @@ -0,0 +1,49 @@ + + + \ No newline at end of file diff --git a/dev-demo/index.ts b/dev-demo/index.ts index 6fd5591d1..978729e16 100644 --- a/dev-demo/index.ts +++ b/dev-demo/index.ts @@ -426,6 +426,35 @@ app.get(`${ADMIN_BASE_URL}/api/dashboard/`, ) ); +app.get(`${ADMIN_BASE_URL}/api/aparts-by-room-percentages/`, + admin.express.authorize( + async (req, res) => { + const roomPercentages = await admin.resource('aparts').dataConnector.db.prepare( + `SELECT + number_of_rooms, + COUNT(*) as count + FROM apartments + GROUP BY number_of_rooms + ORDER BY number_of_rooms; + ` + ).all() + + + const totalAparts = roomPercentages.reduce((acc, { count }) => acc + count, 0); + + res.json( + roomPercentages.map( + ({ number_of_rooms, count }) => ({ + amount: Math.round(count / totalAparts * 100), + label: `${number_of_rooms} rooms`, + }) + ) + ); + } + ) +); + + // serve after you added all api admin.express.serve(app) admin.discoverDatabases().then(async () => { diff --git a/dev-demo/migrations/20250214093026_add_email_confirmed/migration.sql b/dev-demo/migrations/20250214093026_add_email_confirmed/migration.sql new file mode 100644 index 000000000..63795db32 --- /dev/null +++ b/dev-demo/migrations/20250214093026_add_email_confirmed/migration.sql @@ -0,0 +1,21 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "email_verified" BOOLEAN DEFAULT false; + +-- CreateTable +CREATE TABLE "apartment_buyers" ( + "id" TEXT NOT NULL PRIMARY KEY, + "created_at" DATETIME NOT NULL, + "name" TEXT NOT NULL, + "age" INTEGER, + "gender" TEXT, + "info" TEXT, + "contact_info" TEXT, + "language" TEXT, + "ideal_price" DECIMAL, + "ideal_space" REAL, + "ideal_subway_distance" REAL, + "contacted" BOOLEAN NOT NULL DEFAULT false, + "contact_date" DATETIME, + "contact_time" DATETIME, + "realtor_id" TEXT +); diff --git a/dev-demo/resources/apartment_buyers.ts b/dev-demo/resources/apartment_buyers.ts index 60adb0c1b..902a83a66 100644 --- a/dev-demo/resources/apartment_buyers.ts +++ b/dev-demo/resources/apartment_buyers.ts @@ -7,7 +7,7 @@ import AdminForth, { AdminUser, } from "../../adminforth"; import { v1 as uuid } from "uuid"; -import RichEditorPlugin from "../../plugins/adminforth-rich-editor"; +// import RichEditorPlugin from "../../plugins/adminforth-rich-editor"; export default { dataSource: 'mysql', @@ -148,9 +148,9 @@ export default { }, ], plugins: [ - new RichEditorPlugin({ - htmlFieldName: 'info', - }), + // new RichEditorPlugin({ + // htmlFieldName: 'info', + // }), ], options: { fieldGroups: [ diff --git a/dev-demo/resources/apartments.ts b/dev-demo/resources/apartments.ts index dfbb37ec6..d82a44d31 100644 --- a/dev-demo/resources/apartments.ts +++ b/dev-demo/resources/apartments.ts @@ -63,7 +63,7 @@ export default { { name: "id", label: "Identifier", // if you wish you can redefine label - showIn: ["filter", "show", "list"], // show in filter and in show page + showIn: {filter: true, show: true, list: true, create: false, edit: false}, // show in filter and in show page primaryKey: true, // @ts-ignore fillOnCreate: ({ initialRecord, adminUser }) => @@ -77,7 +77,7 @@ export default { name: "title", // type: AdminForthDataTypes.JSON, required: true, - showIn: ["list", "create", "edit", "filter", "show"], // the default is full set + showIn: {list: true, create: true, edit: true, filter: true, show: true}, // the default is full set maxLength: 255, // you can set max length for string fields minLength: 3, // you can set min length for string fields components: { @@ -187,7 +187,7 @@ export default { }, { name: "price", - showIn: ["create", "edit", "filter", "show"], + showIn: {create: true, edit: true, filter: true, show: true}, allowMinMaxQuery: true, // use better experience for filtering e.g. date range, set it only if you have index on this column or if there will be low number of rows editingNote: "Price is in USD", // you can appear note on editing or creating page }, @@ -216,7 +216,7 @@ export default { { value: 4, label: "4 rooms" }, { value: 5, label: "5 rooms" }, ], - showIn: ["filter", "show", "create", "edit"], + showIn: {filter: true, show: true, create: true, edit: true}, }, { name: "room_sizes", @@ -230,7 +230,7 @@ export default { name: "description", sortable: false, type: AdminForthDataTypes.RICHTEXT, - showIn: ["filter", "show", "edit", "create"], + showIn: {filter: true, show: true, edit: true, create: true}, }, { name: "listed", @@ -238,7 +238,7 @@ export default { }, { name: "user_id", - showIn: ["filter", "show", "edit", "list", "create"], + showIn: {filter: true, show: true, edit: true, list: true, create: true}, foreignResource: { resourceId: "users", hooks: { @@ -319,29 +319,29 @@ export default { // debounceTime: 250, // } // }), - new RichEditorPlugin({ - htmlFieldName: "description", - completion: { - adapter: new CompletionAdapterOpenAIChatGPT({ - openAiApiKey: process.env.OPENAI_API_KEY as string, - }), - expert: { - debounceTime: 250, - }, - }, - // requires to have table 'description_images' with upload plugin installed on attachment field + // new RichEditorPlugin({ + // htmlFieldName: "description", + // completion: { + // adapter: new CompletionAdapterOpenAIChatGPT({ + // openAiApiKey: process.env.OPENAI_API_KEY as string, + // }), + // expert: { + // debounceTime: 250, + // }, + // }, + // // requires to have table 'description_images' with upload plugin installed on attachment field - ...(process.env.AWS_ACCESS_KEY_ID - ? { - attachments: { - attachmentResource: "description_images", - attachmentFieldName: "image_path", - attachmentRecordIdFieldName: "record_id", - attachmentResourceIdFieldName: "resource_id", - }, - } - : {}), - }), + // ...(process.env.AWS_ACCESS_KEY_ID + // ? { + // attachments: { + // attachmentResource: "description_images", + // attachmentFieldName: "image_path", + // attachmentRecordIdFieldName: "record_id", + // attachmentResourceIdFieldName: "resource_id", + // }, + // } + // : {}), + // }), ], options: { diff --git a/dev-demo/resources/users.ts b/dev-demo/resources/users.ts index 7b681a742..07113d9df 100644 --- a/dev-demo/resources/users.ts +++ b/dev-demo/resources/users.ts @@ -11,7 +11,9 @@ import TwoFactorsAuthPlugin from "../../plugins/adminforth-two-factors-auth"; import EmailResetPasswordPlugin from "../../plugins/adminforth-email-password-reset/index.js"; import { v1 as uuid } from "uuid"; import EmailAdapterAwsSes from "../../adapters/adminforth-email-adapter-aws-ses/index.js"; - +import { OAuthPlugin } from "../../plugins/adminforth-oauth"; +import { AdminForthAdapterGoogleOauth2 } from "../../adapters/adminforth-google-oauth-adapter"; +import { AdminForthAdapterGithubOauth2 } from "../../adapters/adminforth-github-oauth-adapter"; export default { dataSource: "maindb", table: "users", @@ -83,6 +85,22 @@ export default { // }), // }, }), + new OAuthPlugin({ + adapters: [ + new AdminForthAdapterGithubOauth2({ + clientID: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + redirectUri: 'http://localhost:3000/oauth/callback', + }), + new AdminForthAdapterGoogleOauth2({ + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + redirectUri: 'http://localhost:3000/oauth/callback', + }) + ], + emailField: 'email', + emailConfirmedField: 'email_confirmed' + }), ], options: { allowedActions: { @@ -132,6 +150,16 @@ export default { showIn: [], backendOnly: true, // will never go to frontend }, + { + name: 'email_confirmed', + type: AdminForthDataTypes.BOOLEAN, + showIn: { + list: true, + show: true, + edit: false, + create: false + } + }, { name: "role", enum: [ diff --git a/dev-demo/schema.prisma b/dev-demo/schema.prisma index 8538c8d63..2bcd0ca0e 100644 --- a/dev-demo/schema.prisma +++ b/dev-demo/schema.prisma @@ -17,6 +17,7 @@ model users { last_login_ip String? email_confirmed Boolean? @default(false) parentUserId String? + email_verified Boolean? @default(false) } model apartments { @@ -100,7 +101,7 @@ model apartment_buyers { ideal_space Float? ideal_subway_distance Float? contacted Boolean @default(false) - contact_date Date? - contact_time Time? + contact_date DateTime? + contact_time DateTime? realtor_id String? } \ No newline at end of file diff --git a/plugins/install-plugins.sh b/plugins/install-plugins.sh index 6dfeca05d..78e0e97b0 100644 --- a/plugins/install-plugins.sh +++ b/plugins/install-plugins.sh @@ -1,6 +1,6 @@ PLUGINS="adminforth-audit-log adminforth-email-password-reset adminforth-foreign-inline-list \ adminforth-i18n adminforth-import-export adminforth-text-complete adminforth-open-signup \ -adminforth-rich-editor adminforth-two-factors-auth adminforth-upload" +adminforth-rich-editor adminforth-two-factors-auth adminforth-upload adminforth-oauth" # for each plugin for plugin in $PLUGINS; do