diff --git a/README.md b/README.md index 4f23460..d97209f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Synchronization between local IndexedDB and MySQL Database. -Powered by [Dexie.js](https://dexie.org/) and [PHP CRUD API](https://github.com/mevdschee/php-crud-api). +With user authentication. Powered by [Dexie.js](https://dexie.org/) and [PHP CRUD API](https://github.com/mevdschee/php-crud-api). ## Demo @@ -54,6 +54,7 @@ Based on the installation path above. -- Required columns per table `id` VARCHAR(36) NOT NULL PRIMARY KEY, + `userId` INTEGER(8) NOT NULL DEFAULT 0, `$created` BIGINT(14) NOT NULL DEFAULT 0, `$updated` BIGINT(14) NOT NULL DEFAULT 0, `$deleted` INTEGER(1) NOT NULL DEFAULT 0, @@ -72,19 +73,22 @@ Based on the installation path above. // Import Dexie.js import Dexie from 'dexie' - // Import the sync function - import { sync } from 'dexie-mysql-sync' + // Import the sync hook + import Sync from 'dexie-mysql-sync' // Setup the local database // Adding $created and $deleted as index allows to query on these fields const db = new Dexie('databaseName') - db.version(1).stores({ tasks: '++id, title, $created, $deleted' }) + db.version(1).stores({ + tasks: '++id, title, $created, $deleted' + }) // Start the synchronization - sync(db.tasks, 'tasks') + const sync = new Sync() + sync.add(db.tasks, 'tasks') - // Export the database object - export { db } + // Export the database and sync objects + export { db, sync } ``` 3. Use the database according to the [Dexie.js documentation](https://dexie.org/), example `src/main.(js|ts|jsx)` file: @@ -98,13 +102,23 @@ Based on the installation path above. Run `npm run dev` and see the task list from `testdata.sql` being logged to the console. -The required properties `id`, `$created`, `$updated`, `$deleted` and `$synchronized` are set and updated automatically, you do not need to modify them manually. By default, UUIDv4 is used for new ids. +The required properties `id`, `userId`, `$created`, `$updated`, `$deleted` and `$synchronized` are set and updated automatically, you do not need to modify them manually. By default, UUIDv4 is used for new ids. ## Function Details -### sync(table, path, options = {}) +### useSync(endpoint) -Starts the synchronization. Multiple browser windows are supported. +Intializes the synchronization API. + +- `endpoint`: ``, *optional*, [PHP CRUD API](https://github.com/mevdschee/php-crud-api?tab=readme-ov-file#installation) endpoint, internal or external, default `/api.php` + +```js +const sync = useSync() +``` + +#### sync.add(table, path, options) + +Starts the synchronization to and from remote. Multiple browser windows are supported. - `table`: [Dexie.js Table](https://dexie.org/docs/Dexie/Dexie.%5Btable%5D) - `path`: `` @@ -118,22 +132,45 @@ Starts the synchronization. Multiple browser windows are supported. - [column selection](https://github.com/mevdschee/php-crud-api?tab=readme-ov-file#column-selection), example: `tasks?include=id,title` - [other ...](https://github.com/mevdschee/php-crud-api?tab=readme-ov-file#list) - `options`: `` *optional* - - `endpoint`: ``, [PHP CRUD API](https://github.com/mevdschee/php-crud-api?tab=readme-ov-file#installation) endpoint, internal or external, default `/api.php` - `interval`: ``, default `1000` milliseconds -The same Dexie.js table should be synchronized with only one remote target. +A local table can be synchronized with only one remote table. + +A remote table can be synchronized with one or more local tables. + +#### sync.emptyTable(table) + +Removes all records from a local table without synchronizing them as deleted to the server. + +- `table`: [Dexie.js Table](https://dexie.org/docs/Dexie/Dexie.%5Btable%5D) -### resetSync(database) +#### sync.reset() Resets all synchronizations. All local and remote documents are synchronized again. - `database`: [Dexie.js Database](https://dexie.org/docs/Dexie/Dexie) -### emptyTable(table) +#### sync.register(username, password) -Removes all records from a table without synchronizing them as deleted to the server. +Creates a new user. -- `table`: [Dexie.js Table](https://dexie.org/docs/Dexie/Dexie.%5Btable%5D) +#### sync.login(username, password) + +Logs the user in, clears all local tables and resets the synchronization. + +#### sync.password(username, password, newPassword) + +Updates the password of the user. + +#### sync.user(callback) + +Returns the use details or null. + +- `callback`: `` *optional*, callback on any user change with user details or null + +#### sync.logout() + +Logs the user out, clears all local tables and resets the synchronization. ## Flowcharts diff --git a/demo-app/package-lock.json b/demo-app/package-lock.json index a129665..f5b2ea7 100644 --- a/demo-app/package-lock.json +++ b/demo-app/package-lock.json @@ -28,7 +28,7 @@ } }, "..": { - "version": "3.1.0", + "version": "3.2.0", "license": "MIT", "dependencies": { "js-crud-api": "^0.4.0", diff --git a/demo-app/public/api.php b/demo-app/public/api.php index d14e7c9..85f792e 100644 --- a/demo-app/public/api.php +++ b/demo-app/public/api.php @@ -1,44 +1,58 @@ MYSQL_DATABASE === 'development', - // Database Credentials + // Credentials 'address' => MYSQL_HOST, 'database' => MYSQL_DATABASE, 'username' => MYSQL_USERNAME, 'password' => MYSQL_PASSWORD, - // Database Authentication - 'middlewares' => 'dbAuth,authorization', + // Middlewares + 'middlewares' => 'dbAuth,authorization,multiTenancy', + + // Database authentication 'dbAuth.mode' => 'optional', 'dbAuth.registerUser' => '1', + 'dbAuth.passwordLength' => '3', + + // Database Authorization 'authorization.tableHandler' => function ($operation, $tableName) { + + // No access to the users table if ($tableName === 'users') return false; + + // Access to all other tables return true; - } - + + }, + + // Multi Tenancy + 'multiTenancy.handler' => function ($operation, $tableName) { + + // For all tables, limit access to the current user + return ['userId' => $_SESSION['user']['id'] ?? 0]; + + }, + ]); // Initialization diff --git a/demo-app/schema.sql b/demo-app/schema.sql index 451d4b9..d9210a3 100644 --- a/demo-app/schema.sql +++ b/demo-app/schema.sql @@ -1,28 +1,32 @@ CREATE TABLE IF NOT EXISTS `users` ( - `id` INTEGER(4) NOT NULL PRIMARY KEY AUTO_INCREMENT, + + `id` INTEGER(8) NOT NULL PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(255) NOT NULL, `password` VARCHAR(255) NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -CREATE TABLE `tasks` ( +CREATE TABLE IF NOT EXISTS `tasks` ( -- Required columns per table `id` VARCHAR(36) NOT NULL PRIMARY KEY, + `userId` INTEGER(8) NOT NULL DEFAULT 0, `$created` BIGINT(14) NOT NULL DEFAULT 0, `$updated` BIGINT(14) NOT NULL DEFAULT 0, `$deleted` INTEGER(1) NOT NULL DEFAULT 0, `$synchronized` BIGINT(14) NOT NULL DEFAULT 0, -- Optional customized columns per table - `title` VARCHAR(255) NOT NULL, - `done` INTEGER(1) NOT NULL DEFAULT 0 + `title` VARCHAR(255) NOT NULL DEFAULT "", + `done` TINYINT(1) NOT NULL DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -CREATE TABLE `files` ( +CREATE TABLE IF NOT EXISTS `files` ( -- Required columns per table `id` VARCHAR(36) NOT NULL PRIMARY KEY, + `userId` INTEGER(8) NOT NULL DEFAULT 0, `$created` BIGINT(14) NOT NULL DEFAULT 0, `$updated` BIGINT(14) NOT NULL DEFAULT 0, `$deleted` INTEGER(1) NOT NULL DEFAULT 0, diff --git a/demo-app/src/components/App.jsx b/demo-app/src/components/App.jsx index c244823..24826e7 100644 --- a/demo-app/src/components/App.jsx +++ b/demo-app/src/components/App.jsx @@ -1,5 +1,6 @@ import TodoList from './TodoList.jsx' import FileList from './FileList.jsx' +import UserManagement from './UserManagement.jsx' import ResetSyncButton from './ResetSyncButton.jsx' import { App, Layout, Flex, Typography } from 'antd' import { FileSyncOutlined } from '@ant-design/icons' @@ -15,6 +16,7 @@ function ReactApp() { <FileSyncOutlined /> Dexie MySQL Sync + diff --git a/demo-app/src/components/FileList.jsx b/demo-app/src/components/FileList.jsx index 80ff3c8..5b46cb0 100644 --- a/demo-app/src/components/FileList.jsx +++ b/demo-app/src/components/FileList.jsx @@ -2,8 +2,7 @@ import FileUploadForm from './FileUploadForm' import FileDownloadLink from './FileDownloadLink' import { Flex, Button, Table, Space, Typography } from 'antd' import { DeleteOutlined, DownloadOutlined } from '@ant-design/icons' -import { useLiveQuery } from 'dexie-react-hooks' -import { db } from '../store' +import { db, useLiveQuery } from '../store' const { Title } = Typography diff --git a/demo-app/src/components/ResetSyncButton.jsx b/demo-app/src/components/ResetSyncButton.jsx index 7ccd438..fdf1b1c 100644 --- a/demo-app/src/components/ResetSyncButton.jsx +++ b/demo-app/src/components/ResetSyncButton.jsx @@ -4,14 +4,12 @@ import { SyncOutlined } from '@ant-design/icons' const { Title } = Typography -import { db } from '../store' -import { resetSync } from 'dexie-mysql-sync' - +import { sync } from '../store' export default function App() { const [ isLoading, setIsLoading ] = useState(false) async function onReset() { setIsLoading(true) - resetSync(db).then(() => { + sync.reset().then(() => { setTimeout(() => { setIsLoading(false) }, 1000) diff --git a/demo-app/src/components/TodoList.jsx b/demo-app/src/components/TodoList.jsx index e686d33..8c91e24 100644 --- a/demo-app/src/components/TodoList.jsx +++ b/demo-app/src/components/TodoList.jsx @@ -10,7 +10,7 @@ function TaskItem(task) { db.tasks.update(task.id, { done: task.done ? 0 : 1 })} /> db.tasks.update(task.id, { title: e.target.value})} disabled={task.done} /> - { task.done ? + + ) + return ( + + { !user && form } + { alert } + + ) +} + +function Login({ user }) { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [alertMessage, setAlertMessage] = useState('') + const [alertType, setAlertType] = useState('') + function handleLogin() { + setAlertMessage('') + setAlertType('') + sync.login(username, password) + .then(async user => { + setUsername('') + setPassword('') + setAlertType('success') + setAlertMessage(`User ${user.username} logged in with ID ${user.id}.`) + }) + .catch(async err => { + setAlertType('error') + setAlertMessage(err.message) + }) + } + const alert = alertMessage + ? + : '' + const form = ( + + setUsername(e.target.value)} /> + setPassword(e.target.value)} /> + + + ) + return ( + + { !user && form } + { alert } + + ) +} + +function ChangePassword({ user }) { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [alertMessage, setAlertMessage] = useState('') + const [alertType, setAlertType] = useState('') + function handleRegister() { + setAlertMessage('') + setAlertType('') + sync.password(username, password, newPassword) + .then(user => { + setUsername('') + setPassword('') + setNewPassword('') + setAlertType('success') + setAlertMessage(`Updated password for user ${user.username} with ID ${user.id}.`) + }) + .catch(err => { + setAlertType('error') + setAlertMessage(err.message) + }) + } + const alert = alertMessage + ? + : '' + const form = ( + + setUsername(e.target.value)} /> + setPassword(e.target.value)} /> + setNewPassword(e.target.value)} /> + + + ) + return ( + + { alert } + { user && form } + + ) +} + +function Logout({ user }) { + const [isLoading, setIsLoading] = useState(false) + async function handleLogout() { + setIsLoading(true) + sync.logout().finally(setIsLoading(false)) + } + return ( + + ) +} + +function LoggedOut({ user }) { + return ( + + + + + ) +} + +function LoggedIn({ user }) { + return ( + + + + + ) +} + +function UserManagement() { + const [user, setUser] = useState(null) + useEffect(() => { + sync.user(setUser) + }, []) + return ( + <> + User Management + { user ? : } + + ) +} + +export default UserManagement \ No newline at end of file diff --git a/demo-app/src/store.js b/demo-app/src/store.js index 2556260..c075609 100644 --- a/demo-app/src/store.js +++ b/demo-app/src/store.js @@ -4,20 +4,21 @@ import Dexie from 'dexie' // Import Dexie React Hook import { useLiveQuery } from 'dexie-react-hooks' -// Import the sync function -import { sync } from 'dexie-mysql-sync' +// Import the sync hook +import Sync from 'dexie-mysql-sync' // Setup the local database // Adding $created and $deleted as index allows to query on these fields const db = new Dexie('databaseName') db.version(1).stores({ tasks: '++id, title, done, $created, $deleted', - files: '++id, name, type, size, $created, $deleted' + files: '++id, name, type, size, $created, $deleted', }) // Start the synchronization -sync(db.tasks, 'tasks') -sync(db.files, 'files') +const sync = new Sync() +sync.add(db.tasks, 'tasks') +sync.add(db.files, 'files') -// Export the database object -export { db, useLiveQuery } \ No newline at end of file +// Export the database and sync objects +export { db, sync, useLiveQuery } \ No newline at end of file diff --git a/demo-app/testdata.sql b/demo-app/testdata.sql index b164410..53345f6 100644 --- a/demo-app/testdata.sql +++ b/demo-app/testdata.sql @@ -1,6 +1,3 @@ -INSERT IGNORE INTO `users` (`id`, `username`, `password`) -VALUES (1, "root", "cm9vdA=="); - INSERT INTO `tasks` (`id`, `$created`, `$updated`, `$deleted`, `$synchronized`, `title`, `done`) VALUES ('36922c1e-55b1-49e2-9391-8e6a530b997d', 1709288493292, 1709288498904, 0, 1709288499588, 'First Task', 1), ('47484759-4ddd-48b2-af58-dddee5c2bc7b', 1709288495363, 1709288499122, 0, 1709288499650, 'Second Task', 1), diff --git a/lib/sync.js b/lib/add.js similarity index 82% rename from lib/sync.js rename to lib/add.js index 05e09e5..4ffa196 100644 --- a/lib/sync.js +++ b/lib/add.js @@ -1,18 +1,19 @@ import { v4 as createUUID } from 'uuid' -import useAPI from 'js-crud-api' -import debug from './debug' import syncToRemote from './syncToRemote' import syncFromRemote from './syncFromRemote' -const syncs = {} +export default async function add(table, path, options = {}) { -export default async function sync(table, path, options = {}) { - debug('sync()', { table, path, options: { ...options }}) + const api = this.api + const debug = this.debug + const localStorageKey = `dexie-mysql-sync > ${table.name} > ${path}` + debug('sync()', { table, path, options: { ...options }}) + // Avoid that the same table is synchronized twice - if (syncs[table.name]) throw new Error('The same Dexie.js table cannot be synchronized twice.') - syncs[table.name] = { table, path, options } + if (this.syncs.filter(s => s.table.name === table.name).length) throw new Error('The same Dexie.js table cannot be synchronized twice.') + this.syncs.push({ table, path, options, localStorageKey }) // Set synchronization properties on any document creation table.hook('creating', (id, doc) => { @@ -48,9 +49,6 @@ export default async function sync(table, path, options = {}) { } }) - // Define the API - const api = useAPI(options.endpoint || '/api.php') - // Split the path to table and filter options const pathRegEx = /^(to:|from:)?(.*?)(\?.*)?$/ const remoteTable = path.replace(pathRegEx, '$2') @@ -64,7 +62,7 @@ export default async function sync(table, path, options = {}) { // Sync from remote if (!path.startsWith('to:')) { - syncFromRemote(table, path, api, remoteTable, filter) + syncFromRemote(table, path, api, remoteTable, filter, localStorageKey) } // Sync to remote diff --git a/lib/index.js b/lib/index.js index 5c3e7f3..ea274d6 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,5 +1,31 @@ -import sync from './sync' -import resetSync from './resetSync' +import useAPI from 'js-crud-api' + +import debug from './debug' +import add from './add' +import reset from './reset' import emptyTable from './emptyTable' +import register from './register' +import login from './login' +import user from './user' +import password from './password' +import logout from './logout' + +export class Sync { + constructor(endpoint = '/api.php') { + this.endpoint = endpoint + this.localStorageUserKey = `dexie-mysql-sync > user > ${endpoint}` + this.syncs = [] + this.api = useAPI(this.endpoint) + this.debug = debug + this.add = add + this.reset = reset + this.emptyTable = emptyTable + this.register = register + this.login = login + this.user = user + this.password = password + this.logout = logout + } +} -export { sync, resetSync, emptyTable } \ No newline at end of file +export default Sync \ No newline at end of file diff --git a/lib/login.js b/lib/login.js new file mode 100644 index 0000000..e819274 --- /dev/null +++ b/lib/login.js @@ -0,0 +1,17 @@ +function login(username, password) { + return new Promise((resolve, reject) => { + this.api.login(username, password) + .then(async user => { + window.localStorage.setItem(this.localStorageUserKey, JSON.stringify(user)) + window.dispatchEvent( new Event('storage') ) + for (const sync of this.syncs) { + await this.emptyTable(sync.table) + } + await this.reset() + resolve(user) + }) + .catch(reject) + }) +} + +export default login \ No newline at end of file diff --git a/lib/logout.js b/lib/logout.js new file mode 100644 index 0000000..9eefb69 --- /dev/null +++ b/lib/logout.js @@ -0,0 +1,21 @@ +function logout() { + return new Promise((resolve, reject) => { + this.api.logout() + .then(() => { + resolve() + }) + .catch(error => { + reject(error) + }) + .finally(async () => { + window.localStorage.removeItem(this.localStorageUserKey) + window.dispatchEvent( new Event('storage') ) + for (const sync of this.syncs) { + await this.emptyTable(sync.table) + } + await this.reset() + }) + }) +} + +export default logout \ No newline at end of file diff --git a/lib/password.js b/lib/password.js new file mode 100644 index 0000000..681b0b6 --- /dev/null +++ b/lib/password.js @@ -0,0 +1,5 @@ +async function password(username, password, newPassword) { + return this.api.password(username, password, newPassword) +} + +export default password \ No newline at end of file diff --git a/lib/register.js b/lib/register.js new file mode 100644 index 0000000..dc6267d --- /dev/null +++ b/lib/register.js @@ -0,0 +1,5 @@ +async function register(username, password) { + return this.api.register(username, password) +} + +export default register \ No newline at end of file diff --git a/lib/reset.js b/lib/reset.js new file mode 100644 index 0000000..ed411c6 --- /dev/null +++ b/lib/reset.js @@ -0,0 +1,24 @@ +import debug from './debug' + +export default async function reset() { + + const syncs = this.syncs + + // Loop syncs + for (const sync of syncs) { + + // Reset the synchronization status for all documents + const docs = await sync.table.filter(doc => doc.$synchronized).toArray() + debug('resetSync > table', { tableName: sync.table.name, docCount: docs.length }) + for (const doc of docs) { + await sync.table.update(doc.id, { $updated: doc.$updated, $synchronized: 0 }) + } + + // Reset the saved last sync time from the local storage + window.localStorage.removeItem(sync.localStorageKey) + + } + + return + +} \ No newline at end of file diff --git a/lib/resetSync.js b/lib/resetSync.js deleted file mode 100644 index 2c75d99..0000000 --- a/lib/resetSync.js +++ /dev/null @@ -1,26 +0,0 @@ -import debug from './debug' - -export default async function resetSync(db) { - - // Reset synchronization status for all documents - const tables = await db?.tables || [] - for (const table of tables) { - const docs = await table.filter(doc => doc.$synchronized).toArray() - debug('resetSync > table', { tableName: table.name, docCount: docs.length }) - for (const doc of docs) { - await table.update(doc.id, { $updated: doc.$updated, $synchronized: 0 }) - } - } - - // Reset all saved last sync times from the local storage - const localStorageKeys = Object.keys(window.localStorage) - for (const key of localStorageKeys) { - if (key.startsWith('dexie-mysql-sync')) { - debug('resetSync > localStorage', key) - window.localStorage.removeItem(key) - } - } - - return - -} \ No newline at end of file diff --git a/lib/syncFromRemote.js b/lib/syncFromRemote.js index 14dc281..9917e80 100644 --- a/lib/syncFromRemote.js +++ b/lib/syncFromRemote.js @@ -1,7 +1,7 @@ import debug from './debug' -export default async function syncFromRemote(table, path, api, remoteTable, filter) { - debug('sync from remote > start', { table, path, api, remoteTable, filter }) +export default async function syncFromRemote(table, path, api, remoteTable, filter, localStorageKey) { + debug('sync from remote > start', { table, path, api, remoteTable, filter, localStorageKey }) // Catch errors try { @@ -9,9 +9,6 @@ export default async function syncFromRemote(table, path, api, remoteTable, filt // Remember sync start time const syncStartTime = Date.now() - // Define local storage key for last sync time - const localStorageKey = `dexie-mysql-sync > ${table.name} > ${path}` - // Get last sync time const lastSyncTime = parseInt(window.localStorage.getItem(localStorageKey) || 0) diff --git a/lib/user.js b/lib/user.js new file mode 100644 index 0000000..b760c02 --- /dev/null +++ b/lib/user.js @@ -0,0 +1,19 @@ +function user(callback = () => {}) { + const localStorageUserKey = this.localStorageUserKey + function getUserValue() { + try { + const user = window.localStorage.getItem(localStorageUserKey) + const userJson = JSON.parse(user) + return userJson + } catch (error) { + return null + } + } + window.addEventListener('storage', () => { + const user = getUserValue() + callback(user) + }) + return getUserValue() +} + +export default user \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 58d8b5b..2d850cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dexie-mysql-sync", - "version": "3.2.0", + "version": "4.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dexie-mysql-sync", - "version": "3.2.0", + "version": "4.0.0", "license": "MIT", "dependencies": { "js-crud-api": "^0.4.0", diff --git a/package.json b/package.json index 9da0c09..1957f2e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dexie-mysql-sync", - "version": "3.2.0", + "version": "4.0.0", "description": "", "main": "lib/index.js", "type": "module",