@bigbinary/neeto-google-drive-frontend
v2.0.15
Published
Reusable Google Drive integration for Neeto products.
Readme
neeto-google-drive-nano
Start with Quick start. It is the shortest path to a working, per-owner Google Drive connection.
This nano provides a reusable Rails engine and frontend package for connecting a Neeto product resource to Google Drive.
Examples of an owner and its exported resource:
| Product | Integration owner | Exported resource |
| --------- | ----------------- | ----------------- |
| neetoForm | Form | Submission |
| neetoCal | Meeting | Booking |
Each owner gets its own Google account connection. Google Drive credentials are not shared with Google Sheets or with another owner.
What the nano provides
Backend engine
The Rails engine owns:
- Google OAuth with the
drive.fileanduserinfo.emailscopes. - Encrypted access-token and refresh-token storage.
- Per-owner connection status, account details, settings, and main-folder ID.
- Google Drive file, folder, upload, query, and token-refresh operations.
- Connect, configure, disconnect, and OAuth callback API endpoints.
It also handles these edge cases:
- It reuses an omitted refresh token only when reconnecting the same Google account.
- It creates a new main folder when the stored folder was deleted or trashed.
- It synchronizes a folder name changed directly in Google Drive.
- It prevents concurrent requests from leaving duplicate main folders.
- It disconnects invalid authorization without deleting existing Drive files.
Frontend package
The frontend package exports:
import {
GoogleDriveIcon,
GoogleDriveIntegration,
GoogleDriveSettings,
useGoogleDrive,
} from "@bigbinary/neeto-google-drive-frontend";The connection UI includes:
- Sign in with Google.
- Connected-account email.
- Copy and open main-folder link.
- Configure and disconnect actions.
- OAuth and authorization error messages.
The settings UI includes:
- Main-folder name.
- Per-resource folder toggle and naming-field builder.
- Save, cancel, loading, and missing-integration states.
- An optional
renderAdditionalSettingsslot for host-owned options.
Product-specific settings such as attachment-field selection or a PDF export
toggle are not built into the nano. The host renders them through
renderAdditionalSettings and stores their values in additional_settings.
What the host product must provide
The nano does not know how a form submission or calendar booking works. The host product owns:
- Tenant-scoped owner lookup and authorization.
- Product-specific naming fields and any extra settings UI.
- The background job that decides when to export.
- Resource formatting, attachment lookup, and PDF generation.
- Product routes, page layout, breadcrumbs, help links, and lifecycle cleanup.
There is no reconnect email in the nano. A host can add one, but it is not required for the integration to work.
Quick start
1. Install the backend engine
Add the gem:
source "NEETO_GEM_SERVER_URL" do
gem "neeto-google-drive-engine"
endInstall it:
bundle installMount the engine in config/routes.rb:
Rails.application.routes.draw do
mount NeetoGoogleDriveEngine::Engine, at: "/neeto_google_drive"
endCopy and run the migration:
bundle exec rails neeto_google_drive_engine:install:migrations
bundle exec rails db:migrate2. Configure Google OAuth
Create config/initializers/neeto_google_drive_engine.rb:
# frozen_string_literal: true
NeetoGoogleDriveEngine.application_name = "NeetoForm"
NeetoGoogleDriveEngine.oauth_app_url =
Rails.application.vault.google_drive[:oauth_app_url]
NeetoGoogleDriveEngine.oauth_client_id =
Rails.application.vault.google_drive[:client_id]
NeetoGoogleDriveEngine.oauth_client_secret =
Rails.application.vault.google_drive[:client_secret]
NeetoGoogleDriveEngine.oauth_state_private_key =
Rails.application.vault.jwt[:connect_private_key]
NeetoGoogleDriveEngine.oauth_state_public_key =
Rails.application.vault.jwt[:connect_public_key]
NeetoGoogleDriveEngine.user_class = "User"Register this complete redirect URI in the Google OAuth client:
<oauth_app_url>/neeto_google_drive/api/v1/oauthFor example:
https://connect.neetoform.net/neeto_google_drive/api/v1/oauthUse a dedicated Google Drive OAuth configuration. Do not reuse a Google Sheets token or make Drive credentials shared across owners.
The OAuth state is signed with RS256 and expires after two hours. The callback rejects invalid, expired, or tenant-inaccessible owner data.
3. Add the owner contract
Include NeetoGoogleDriveEngine::HasGoogleDrive in the model that owns the
connection:
class Form < ApplicationRecord
include NeetoGoogleDriveEngine::HasGoogleDrive
include GoogleDriveIntegrable
endThe host adapter can implement these methods:
module GoogleDriveIntegrable
extend ActiveSupport::Concern
def google_drive_default_folder_name
name
end
def google_drive_default_resource_folder_name_fields
[]
end
def google_drive_default_additional_settings
{}
end
def google_drive_naming_fields
raise NotImplementedError
end
def google_drive_additional_settings_data
{}
end
def google_drive_redirect_url(query)
raise NotImplementedError
end
endMethod responsibilities:
| Method | Required | Purpose |
| -------------------------------------------------- | -------- | ---------------------------------------------------- |
| google_drive_naming_fields | Yes | Fields available in the resource-folder name builder |
| google_drive_redirect_url | Yes | Absolute product URL used after OAuth |
| google_drive_default_folder_name | No | Initial main-folder name; defaults to name |
| google_drive_default_resource_folder_name_fields | No | Initially selected naming-field IDs |
| google_drive_default_additional_settings | No | Default host-owned settings hash; defaults to {} |
| google_drive_additional_settings_data | No | Metadata for the host settings UI; defaults to {} |
A naming field has this shape:
{ id: "customer_name", label: "Customer name", kind: "text" }Only id and label are required by the frontend.
Product-specific options (attachments, PDF, notes, and so on) belong in
additional_settings. Use google_drive_additional_settings_data to send UI
metadata such as available attachment fields to renderAdditionalSettings.
4. Scope and authorize the engine controllers
The engine intentionally leaves product authorization to the host.
Override the API controller:
# app/overrides/controllers/neeto_google_drive_engine/api/v1/base_controller_override.rb
NeetoGoogleDriveEngine::Api::V1::BaseController.class_eval do
private
def load_owner
@owner = current_user.organization.forms.find(params[:owner_id])
end
def authorize_google_drive!
authorize(:form_setting, :can_manage_integrations?)
end
endConfigure OAuth owner lookup separately because the OAuth callback does not use the regular authenticated API session:
NeetoGoogleDriveEngine.oauth_owner_resolver = lambda do |user, owner_id|
user.organization.forms.find(owner_id)
endNever use an unscoped Form.find, Meeting.find, or other owner lookup in the
controller override or OAuth resolver.
5. Install and render the frontend
Install the package:
yarn add @bigbinary/neeto-google-drive-frontendRender the connection screen:
import { GoogleDriveIntegration } from "@bigbinary/neeto-google-drive-frontend";
const GoogleDriveConnection = ({ canManageIntegrations, formId }) => (
<GoogleDriveIntegration
canManageIntegrations={canManageIntegrations}
helpUrl="https://help.neetoform.com/google-drive"
managePath={`/admin/form/${formId}/settings/integrations/google-drive/manage`}
ownerId={formId}
resourceName="submission"
resourceNamePlural="submissions"
/>
);Render the settings screen:
import { GoogleDriveSettings } from "@bigbinary/neeto-google-drive-frontend";
const GoogleDriveManage = ({
canManageIntegrations,
formId,
navigateToIntegration,
}) => (
<GoogleDriveSettings
canManageIntegrations={canManageIntegrations}
ownerId={formId}
resourceName="submission"
renderAdditionalSettings={FormGoogleDriveAdditionalSettings}
renderMissingIntegration={() => <MissingIntegration />}
onCancel={navigateToIntegration}
onSave={navigateToIntegration}
/>
);GoogleDriveIntegration requires resourceName and resourceNamePlural.
GoogleDriveSettings requires only resourceName. Pass "booking" /
"bookings" to the integration and "booking" to the settings in neetoCal.
renderAdditionalSettings is optional. Pass it only when the product needs
extra options. The component receives Formik props plus additionalSettingsData
from the owner. Read and write host values under values.additionalSettings
(for example saveResourcePdf or selectedAttachmentFieldIds).
Products that do not need extra options, such as a first neetoCal version, omit
renderAdditionalSettings and get only the shared folder settings.
The host must load the nano's neetoGoogleDrive translations into its i18next
resources.
Backend API
The mounted engine exposes:
| Method | Endpoint | Purpose |
| -------- | --------------------------------------------------- | --------------------------------------------------------- |
| GET | /neeto_google_drive/api/v1/google_drive/:owner_id | Connection state, authorization URL, settings, and fields |
| PATCH | /neeto_google_drive/api/v1/google_drive/:owner_id | Update settings and rename the connected main folder |
| DELETE | /neeto_google_drive/api/v1/google_drive/:owner_id | Disconnect without deleting Drive files |
| GET | /neeto_google_drive/api/v1/oauth | Google OAuth callback |
The GET response contains:
{
"authorizationUrl": "https://accounts.google.com/...",
"connected": true,
"connectedEmail": "[email protected]",
"folderUrl": "https://drive.google.com/drive/folders/...",
"error": "",
"settings": {
"folderName": "Customer feedback",
"createResourceFolder": true,
"resourceFolderNameFields": ["full_name", "submission_date"],
"additionalSettings": {
"selectedAttachmentFieldIds": ["resume"],
"saveResourcePdf": true
}
},
"namingFields": [],
"additionalSettingsData": {
"attachmentFields": [{ "id": "resume", "label": "Resume" }]
}
}additionalSettings and additionalSettingsData are host-owned. The nano
persists and returns them without interpreting their keys.
The exact JSON key casing follows the host application's response transformation.
Data owned by the engine
NeetoGoogleDriveEngine::GoogleDrive stores:
- Polymorphic
owner. - Active or inactive status.
- Connected Google account UID and email.
- Main-folder ID and name.
- Export settings and the latest authorization error.
NeetoGoogleDriveEngine::Credential stores:
- Encrypted access token.
- Encrypted refresh token.
- Token expiry time.
- One-to-one link to the integration.
google_drive.export_configuration returns a JSON-safe settings snapshot for a
background job:
{
"create_resource_folder" => true,
"resource_folder_name_fields" => ["full_name", "submission_date"],
"additional_settings" => {
"selected_attachment_field_ids" => ["resume"],
"save_resource_pdf" => true
}
}The host job reads product-specific keys from additional_settings. Pass this
snapshot and the current credential ID to the job. The credential ID lets a
stale queued job stop after the owner disconnects or changes accounts.
Using the Drive client from a host exporter
The host exporter can use the nano-owned client:
client = NeetoGoogleDriveEngine::Client.new(
owner.google_drive.credential
)Available operations:
client.get_file(file_id)
client.create_folder(
name: folder_name,
parent_id: parent_id,
app_properties: metadata
)
client.rename_file(file_id, name)
client.delete_file(file_id)
client.list_files(
parent_id: parent_id,
mime_type: mime_type,
app_properties: metadata
)
client.upload_file(
name: filename,
parent_id: parent_id,
upload_source: file.path,
content_type: content_type,
app_properties: metadata
)Pass structured filters to list_files:
files = client.list_files(
parent_id: target_folder.id,
mime_type: NeetoGoogleDriveEngine::Client::FOLDER_MIME_TYPE,
app_properties: {
"neeto_google_drive_resource" => "submission_folder",
"neeto_form_submission_id" => submission.id
}
)Do not construct Google Drive query strings in the host. The client escapes values and owns Google-specific query syntax.
Use nano-owned metadata keys for data shared across products:
metadata = NeetoGoogleDriveEngine::FileMetadata
{
metadata::OWNER_ID_PROPERTY => owner.id,
metadata::OWNER_TYPE_PROPERTY => owner.class.name,
metadata::RESOURCE_PROPERTY => "main_folder"
}Keep product-specific keys, such as neeto_form_submission_id, in the host
product.
neetoForm example: implemented
neetoForm uses Form as the connection owner and Submission as the exported
resource.
Model adapter
module GoogleDriveIntegrable
extend ActiveSupport::Concern
SUBMISSION_DATE_FIELD = "submission_date"
SUBMISSION_ID_FIELD = "submission_id"
ATTACHMENT_RECORD_KINDS =
%w[file_upload take_photo digital_signature].freeze
def google_drive_default_folder_name
title
end
def google_drive_default_resource_folder_name_fields
naming_records = answer_records.where.not(kind: ATTACHMENT_RECORD_KINDS)
default_record =
naming_records.find_by(kind: :full_name) || naming_records.first
[default_record&.id&.to_s, SUBMISSION_DATE_FIELD].compact
end
def google_drive_naming_fields
answer_records
.where.not(kind: ATTACHMENT_RECORD_KINDS)
.map do |record|
{
id: record.id.to_s,
label: record.formatted_label,
kind: record.kind
}
end
.concat(
[
{
id: SUBMISSION_DATE_FIELD,
label: "Submission date",
kind: "system"
},
{
id: SUBMISSION_ID_FIELD,
label: "Submission ID",
kind: "system"
}
]
)
end
def google_drive_default_additional_settings
{
selected_attachment_field_ids: google_drive_attachment_fields.pluck(:id),
save_resource_pdf: true
}
end
def google_drive_additional_settings_data
{ attachment_fields: google_drive_attachment_fields }
end
def google_drive_attachment_fields
answer_records
.where(kind: ATTACHMENT_RECORD_KINDS)
.map { |record| { id: record.id.to_s, label: record.formatted_label } }
end
def google_drive_redirect_url(query)
url = URI(google_drive_integration_page_url)
url.query = query.to_query
url.to_s
end
endAttachment fields and the PDF toggle are Form-owned. The nano only stores them
inside additional_settings and returns attachment field metadata through
google_drive_additional_settings_data for Form's renderAdditionalSettings
UI.
Background export
neetoForm snapshots the credential and settings when the submission completes:
if form.google_drive_exportable?
google_drive = form.google_drive
Integrations::GoogleDrive::SubmissionExporterJob.perform_async(
submission.id,
google_drive.credential.id,
google_drive.export_configuration
)
endThe host job owns:
- Submission completion checks.
- ActiveStorage attachment lookup.
- Submission-folder naming.
- Locale-aware PDF generation.
- Retry scheduling and product logging.
neetoCal example: planned
This is a proposed adapter based on neetoCal's current model and route conventions. It is not implemented in neetoCal yet.
Use Meeting as the connection owner and Booking as the exported resource. Do
not attach the connection to each Booking.
Planned initializer
NeetoGoogleDriveEngine.application_name = "NeetoCal"
NeetoGoogleDriveEngine.oauth_app_url =
Rails.application.vault.google_drive[:oauth_app_url]
NeetoGoogleDriveEngine.oauth_client_id =
Rails.application.vault.google_drive[:client_id]
NeetoGoogleDriveEngine.oauth_client_secret =
Rails.application.vault.google_drive[:client_secret]
NeetoGoogleDriveEngine.oauth_state_private_key =
Rails.application.vault.jwt[:connect_private_key]
NeetoGoogleDriveEngine.oauth_state_public_key =
Rails.application.vault.jwt[:connect_public_key]
NeetoGoogleDriveEngine.user_class = "User"Planned model adapter
module Meetings::GoogleDriveConcern
extend ActiveSupport::Concern
BOOKER_NAME_FIELD = "booker_name"
BOOKING_DATE_FIELD = "booking_date"
BOOKING_ID_FIELD = "booking_id"
included do
include NeetoGoogleDriveEngine::HasGoogleDrive
end
def google_drive_default_folder_name
name
end
def google_drive_default_resource_folder_name_fields
[BOOKER_NAME_FIELD, BOOKING_DATE_FIELD]
end
def google_drive_naming_fields
[
{ id: BOOKER_NAME_FIELD, label: "Booker name", kind: "system" },
{ id: BOOKING_DATE_FIELD, label: "Booking date", kind: "system" },
{ id: BOOKING_ID_FIELD, label: "Booking ID", kind: "system" }
]
end
def google_drive_redirect_url(query)
url = URI(
"#{organization.root_url}/admin/scheduling-links/" \
"#{sid}/settings/integrations/google-drive"
)
url.query = query.to_query
url.to_s
end
endPlanned controller configuration
Use neetoCal's organization scoping and existing Google Sheet permission until a dedicated Google Drive permission is introduced:
NeetoGoogleDriveEngine::Api::V1::BaseController.class_eval do
private
def load_owner
@owner = @organization.meetings.find(params[:owner_id])
end
def authorize_google_drive!
authorize(Meeting, :can_manage_google_sheet?)
end
endNeetoGoogleDriveEngine.oauth_owner_resolver = lambda do |user, owner_id|
user.organization.meetings.find(owner_id)
endPlanned frontend adapter
import {
GoogleDriveIntegration,
GoogleDriveSettings,
} from "@bigbinary/neeto-google-drive-frontend";
const BookingDriveConnection = ({
canManageGoogleSheet,
managePath,
meetingId,
}) => (
<GoogleDriveIntegration
canManageIntegrations={canManageGoogleSheet}
managePath={managePath}
ownerId={meetingId}
resourceName="booking"
resourceNamePlural="bookings"
/>
);
const BookingDriveSettings = ({
canManageGoogleSheet,
meetingId,
navigateToIntegration,
}) => (
<GoogleDriveSettings
canManageIntegrations={canManageGoogleSheet}
ownerId={meetingId}
resourceName="booking"
onCancel={navigateToIntegration}
onSave={navigateToIntegration}
/>
);Omit renderAdditionalSettings until Cal needs product-specific options.
Connection copy uses resourceName / resourceNamePlural; settings labels use
resourceName.
When neetoCal adds booking files or a booking PDF, render those controls in a
Cal-owned renderAdditionalSettings component, store their values in
additional_settings, and implement the export logic in neetoCal.
Frontend props
GoogleDriveIntegration
| Prop | Required | Purpose |
| ----------------------- | -------- | -------------------------------------------------- |
| ownerId | Yes | ID of the model that owns the integration |
| canManageIntegrations | Yes | Enables the integration query for authorized users |
| managePath | Yes | Host route opened by Manage Google Drive |
| resourceName | Yes | Singular UI noun |
| resourceNamePlural | Yes | Plural UI noun |
| helpUrl | No | Learn-more link |
GoogleDriveSettings
| Prop | Required | Purpose |
| -------------------------- | -------- | ----------------------------------------------- |
| ownerId | Yes | ID of the model that owns the integration |
| canManageIntegrations | Yes | Enables the settings query for authorized users |
| onCancel | Yes | Host navigation after Cancel |
| onSave | No | Host navigation after a successful save |
| renderMissingIntegration | No | Host UI when the owner is disconnected |
| renderAdditionalSettings | No | Host component for product-specific options |
| resourceName | Yes | Singular noun used in settings labels |
useGoogleDrive
Use the hook when the host only needs connection status:
const { isConnected, isLoading } = useGoogleDrive({
ownerId: meetingId,
canManageIntegrations: canManageGoogleSheet,
});It also returns update, disconnect, loading, and disconnect-dialog state.
Connection and folder behavior
Connect
- The UI fetches a signed Google authorization URL.
- Google redirects to the engine OAuth callback.
- The callback verifies state, scopes, user, and tenant-scoped owner.
- The engine stores encrypted credentials for that owner.
- The engine creates or restores the owner's main Drive folder.
Reconnect
- Reconnecting the same account preserves settings and the main-folder ID.
- An omitted refresh token is reused only for the same Google account UID.
- Connecting another account clears the old folder ID.
- The new account receives its own main folder.
Disconnect
- The encrypted credential is destroyed.
- The integration becomes inactive.
- Settings and folder metadata remain stored.
- Existing Google Drive folders and files are not deleted.
- No reconnect email is sent.
Error handling
The nano exposes reusable error categories:
NeetoGoogleDriveEngine::AUTHORIZATION_ERRORS
NeetoGoogleDriveEngine::TRANSIENT_ERRORS
NeetoGoogleDriveEngine::EXPORT_MAX_ATTEMPTSA host job can use them like this:
def perform
export_resource!
rescue *NeetoGoogleDriveEngine::TRANSIENT_ERRORS => exception
raise TransientError.new(exception)
rescue *NeetoGoogleDriveEngine::AUTHORIZATION_ERRORS
NeetoGoogleDriveEngine::DisconnectService.new(
google_drive,
error: "authorization_expired"
).process!
endThe nano stores the error for the connection UI. User notification channels remain host-owned.
Testing
Run the backend suite:
bundle exec rails testBuild the frontend package:
yarn buildThe backend suite covers:
- Integration and credential models.
- OAuth state and token exchange.
- Same-account and different-account reconnects.
- Drive query escaping and token refresh.
- Main-folder creation, recovery, rename, and concurrency behavior.
Host products should separately test their authorization overrides, export job, resource formatter, attachments, and PDFs.
Local frontend development
Follow the frontend package development guide to build and consume the package locally.
Publishing
Follow the building and releasing packages guide.
