Skip to content
Merged

Next #120

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion adapters/install-adapters.sh
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<your_app_name>-terraform-state"
key = "state.tfstate" # Define a specific path for the state file
region = "eu-central-1"
profile = "myaws"
dynamodb_table = "<your_app_name>-terraform-lock-table"
use_lockfile = true
}
}
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -419,4 +419,44 @@ mongoose, or just use raw SQL queries against your tables.

Demo:

![alt text](dashDemo.gif)
![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
<template>
<Link to="/setup2fa">Setup 2FA</Link>
</template>
```

```ts
import { Link } from '@/afcl';
```
92 changes: 92 additions & 0 deletions adminforth/documentation/docs/tutorial/05-Plugins/11-oauth.md
Original file line number Diff line number Diff line change
@@ -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
19 changes: 18 additions & 1 deletion adminforth/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

import AdminForthAuth from './auth.js';
import MongoConnector from './dataConnectors/mongo.js';
import PostgresConnector from './dataConnectors/postgres.js';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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({
Expand Down
12 changes: 12 additions & 0 deletions adminforth/modules/restApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {};
await Promise.all(
Object.entries(translateRoutines).map(async ([key, value]) => {
Expand Down Expand Up @@ -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,
Expand Down
48 changes: 46 additions & 2 deletions adminforth/spa/src/afcl/Select.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,32 @@
/>
</div>
</div>
<div v-if="showDropdown" ref="dropdownEl" :style="dropdownStyle" :class="{'shadow-none': isTop}"
class="absolute z-10 mt-1 w-full bg-white shadow-lg dark:shadow-black dark:bg-gray-700
<teleport to="body" v-if="teleportToBody && showDropdown">
<div ref="dropdownEl" :style="getDropdownPosition" :class="{'shadow-none': isTop}"
class="fixed z-50 w-full bg-white shadow-lg dark:shadow-black dark:bg-gray-700
dark:border-gray-600 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm max-h-48">
<div
v-for="item in filteredItems"
:key="item.value"
class="px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-400"
:class="{ 'bg-lightPrimaryOpacity dark:bg-darkPrimaryOpacity': selectedItems.includes(item) }"
@click="toogleItem(item)"
>
<slot name="item" :option="item"></slot>
<label v-if="!$slots.item" :for="item.value">{{ item.label }}</label>
</div>
<div v-if="!filteredItems.length" class="px-4 py-2 cursor-pointer text-gray-400 dark:text-gray-300">
{{ options.length ? $t('No results found') : $t('No items here') }}
</div>

<div v-if="$slots['extra-item']" class="px-4 py-2 dark:text-gray-400">
<slot name="extra-item"></slot>
</div>
</div>
</teleport>

<div v-if="!teleportToBody && showDropdown" ref="dropdownEl" :style="dropdownStyle" :class="{'shadow-none': isTop}"
class="absolute z-10 mt-1 w-full bg-white shadow-lg dark:shadow-black dark:bg-gray-700
dark:border-gray-600 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm max-h-48">
<div
v-for="item in filteredItems"
Expand Down Expand Up @@ -106,6 +130,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
teleportToBody: {
type: Boolean,
default: false,
},
});

const emit = defineEmits(['update:modelValue']);
Expand Down Expand Up @@ -242,4 +270,20 @@ onUnmounted(() => {
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;
});

</script>
14 changes: 7 additions & 7 deletions adminforth/spa/src/components/ResourceListTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
};
}
Expand Down
Loading