diff --git a/README.md b/README.md index c112f56ff..befd75fc2 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,15 @@ Swach is a modern color palette manager. **[Swach is built and maintained by Ship Shape. Contact us for web and native app development services.](https://shipshape.io/)** +## Recent Changes + +### 🚀 Supabase Migration + +- Migrated from AWS Cognito + API Gateway to Supabase +- Simplified authentication and data synchronization +- Preserved existing Orbit.js data architecture +- Added real-time capabilities with Supabase subscriptions + ## Prerequisites You will need the following things properly installed on your computer. diff --git a/app/authenticators/supabase.js b/app/authenticators/supabase.js new file mode 100644 index 000000000..77d254dbc --- /dev/null +++ b/app/authenticators/supabase.js @@ -0,0 +1,60 @@ +import { service } from '@ember/service'; +import BaseAuthenticator from 'ember-simple-auth/authenticators/base'; + +export default class SupabaseAuthenticator extends BaseAuthenticator { + @service supabase; + + async authenticate(credentials) { + const { email, password, isSignUp } = credentials; + + try { + let response; + + if (isSignUp) { + response = await this.supabase.signUp(email, password); + } else { + response = await this.supabase.signIn(email, password); + } + + return { + user: response.user, + session: response.session, + access_token: response.session?.access_token, + refresh_token: response.session?.refresh_token, + expires_at: response.session?.expires_at, + }; + } catch (error) { + throw error; + } + } + + async restore(data) { + try { + // Check if we have a valid session + const session = await this.supabase.getSession(); + + if (session?.user) { + return { + user: session.user, + session: session, + access_token: session.access_token, + refresh_token: session.refresh_token, + expires_at: session.expires_at, + }; + } + + return null; + } catch (error) { + return null; + } + } + + async invalidate() { + try { + await this.supabase.signOut(); + } catch (error) { + // Even if sign out fails, we want to invalidate the session locally + console.error('Error during sign out:', error); + } + } +} diff --git a/app/components/login/index.ts b/app/components/login/index.ts index 415377d41..d77bac067 100644 --- a/app/components/login/index.ts +++ b/app/components/login/index.ts @@ -27,7 +27,7 @@ export default class LoginComponent extends Component { const { username, password } = this; const credentials = { username, password }; try { - await this.session.authenticate('authenticator:cognito', credentials); + await this.session.authenticate('authenticator:supabase', credentials); // We want to skip this in tests, since once a user has logged in routes become inaccessible if (config.environment !== 'test') { diff --git a/app/components/register/index.ts b/app/components/register/index.ts index 70b4af536..156c445be 100644 --- a/app/components/register/index.ts +++ b/app/components/register/index.ts @@ -4,10 +4,10 @@ import { service } from '@ember/service'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; -import CognitoService from 'ember-cognito/services/cognito'; +import Session from 'ember-simple-auth/services/session'; export default class RegisterComponent extends Component { - @service declare cognito: CognitoService; + @service declare session: Session; @service declare router: Router; @tracked errorMessage?: string; @@ -18,14 +18,14 @@ export default class RegisterComponent extends Component { async register(): Promise { const { username, password } = this; if (username && password) { - const attributes = { - email: username, - }; - try { - await this.cognito.signUp(username, password, attributes); + await this.session.authenticate('authenticator:supabase', { + username, + password, + isSignUp: true, + }); - this.router.transitionTo('settings.cloud.register.confirm'); + this.router.transitionTo('settings.cloud'); } catch (err) { this.errorMessage = err?.message; } diff --git a/app/data-sources/remote.js b/app/data-sources/remote.js index 4e598848e..768dcd8aa 100644 --- a/app/data-sources/remote.js +++ b/app/data-sources/remote.js @@ -1,17 +1,9 @@ import { getOwner } from '@ember/application'; -import { pluralize, singularize } from 'ember-inflector'; import { applyStandardSourceInjections } from 'ember-orbit'; -import { - JSONAPIRequestProcessor, - JSONAPISerializers, - JSONAPISource, -} from '@orbit/jsonapi'; +import { Source } from '@orbit/source'; import { buildSerializerSettingsFor } from '@orbit/serializers'; -import { AwsClient } from 'aws4fetch'; - -import ENV from 'swach/config/environment'; export default { create(injections = {}) { @@ -19,104 +11,274 @@ export default { const app = getOwner(injections); const session = app.lookup('service:session'); + const supabaseService = app.lookup('service:supabase'); + + class SupabaseSource extends Source { + constructor() { + super(injections); + this.supabase = supabaseService.client; + } - class RemoteRequestProcessor extends JSONAPIRequestProcessor { - initFetchSettings(customSettings = {}) { + async pull(transformOrOperations) { if (!session.isAuthenticated) { throw new Error('Remote requests require authentication'); } - const settings = super.initFetchSettings(customSettings); - settings.sessionCredentials = - session.data.authenticated.sessionCredentials; + // For now, we'll implement basic query functionality + // This would need to be expanded based on the specific Orbit.js operations + const operations = Array.isArray(transformOrOperations) + ? transformOrOperations + : [transformOrOperations]; + + const results = []; + + for (const operation of operations) { + if (operation.op === 'findRecords') { + const records = await this.findRecords( + operation.type, + operation.options + ); + results.push(...records); + } else if (operation.op === 'findRecord') { + const record = await this.findRecord( + operation.type, + operation.id, + operation.options + ); + if (record) results.push(record); + } + } - return settings; + return results; } - async fetch(url, customSettings) { - let settings = this.initFetchSettings(customSettings); - let fullUrl = url; - if (settings.params) { - fullUrl = this.urlBuilder.appendQueryParams(fullUrl, settings.params); - delete settings.params; - } - const aws = new AwsClient({ - accessKeyId: settings.sessionCredentials.accessKeyId, // required, akin to AWS_ACCESS_KEY_ID - secretAccessKey: settings.sessionCredentials.secretAccessKey, // required, akin to AWS_SECRET_ACCESS_KEY - sessionToken: settings.sessionCredentials.sessionToken, // akin to AWS_SESSION_TOKEN if using temp credentials - service: 'execute-api', // AWS service, by default parsed at fetch time - region: 'us-east-2', // AWS region, by default parsed at fetch time - }); - const method = customSettings.method ?? 'GET'; - const request = await aws.sign(fullUrl, { - method, - body: settings.body, - }); - - let fetchFn = fetch; - - if (settings.timeout !== undefined && settings.timeout > 0) { - let timeout = settings.timeout; - delete settings.timeout; - - return new Promise((resolve, reject) => { - let timedOut; - - let timer = setTimeout(() => { - timedOut = true; - reject(new Error(`No fetch response within ${timeout}ms.`)); - }, timeout); - - fetchFn(request) - .catch((e) => { - clearTimeout(timer); - - if (!timedOut) { - return this.handleFetchError(e); - } - }) - .then((response) => { - clearTimeout(timer); - - if (!timedOut) { - return this.handleFetchResponse(response); - } - }) - .then(resolve, reject); - }); - } else { - return fetchFn(request) - .catch((e) => this.handleFetchError(e)) - .then((response) => this.handleFetchResponse(response)); + + async push(transformOrOperations) { + if (!session.isAuthenticated) { + throw new Error('Remote requests require authentication'); + } + + const operations = Array.isArray(transformOrOperations) + ? transformOrOperations + : [transformOrOperations]; + + const results = []; + + for (const operation of operations) { + if (operation.op === 'addRecord') { + const record = await this.addRecord( + operation.record, + operation.options + ); + results.push(record); + } else if (operation.op === 'updateRecord') { + const record = await this.updateRecord( + operation.record, + operation.options + ); + results.push(record); + } else if (operation.op === 'removeRecord') { + await this.removeRecord(operation.record, operation.options); + } } + + return results; + } + + async findRecords(type, options = {}) { + const tableName = this.pluralize(type); + let query = this.supabase.from(tableName).select('*'); + + if (options.include) { + // Handle relationships - this would need more sophisticated handling + for (const relation of options.include) { + query = query.select(`${relation}(*)`); + } + } + + const { data, error } = await query; + + if (error) { + throw new Error(`Supabase query error: ${error.message}`); + } + + return data.map((record) => this.transformToOrbitRecord(record, type)); + } + + async findRecord(type, id, options = {}) { + const tableName = this.pluralize(type); + let query = this.supabase + .from(tableName) + .select('*') + .eq('id', id) + .single(); + + if (options.include) { + for (const relation of options.include) { + query = query.select(`${relation}(*)`); + } + } + + const { data, error } = await query; + + if (error) { + throw new Error(`Supabase query error: ${error.message}`); + } + + return this.transformToOrbitRecord(data, type); + } + + async addRecord(record, options = {}) { + const tableName = this.pluralize(record.type); + const supabaseRecord = this.transformFromOrbitRecord(record); + + const { data, error } = await this.supabase + .from(tableName) + .insert(supabaseRecord) + .select() + .single(); + + if (error) { + throw new Error(`Supabase insert error: ${error.message}`); + } + + return this.transformToOrbitRecord(data, record.type); + } + + async updateRecord(record, options = {}) { + const tableName = this.pluralize(record.type); + const supabaseRecord = this.transformFromOrbitRecord(record); + + const { data, error } = await this.supabase + .from(tableName) + .update(supabaseRecord) + .eq('id', record.id) + .select() + .single(); + + if (error) { + throw new Error(`Supabase update error: ${error.message}`); + } + + return this.transformToOrbitRecord(data, record.type); + } + + async removeRecord(record, options = {}) { + const tableName = this.pluralize(record.type); + + const { error } = await this.supabase + .from(tableName) + .delete() + .eq('id', record.id); + + if (error) { + throw new Error(`Supabase delete error: ${error.message}`); + } + } + + transformToOrbitRecord(supabaseRecord, type) { + return { + id: supabaseRecord.id, + type: this.singularize(type), + attributes: this.extractAttributes(supabaseRecord, type), + relationships: this.extractRelationships(supabaseRecord, type), + }; + } + + transformFromOrbitRecord(orbitRecord) { + const supabaseRecord = { + id: orbitRecord.id, + ...orbitRecord.attributes, + }; + + // Add user_id if authenticated + if (this.supabase.auth.user) { + supabaseRecord.user_id = this.supabase.auth.user.id; + } + + return supabaseRecord; + } + + extractAttributes(record, type) { + const attributes = { ...record }; + + // Remove Orbit.js specific fields and relationships + delete attributes.id; + delete attributes.created_at; // This is handled by Supabase + delete attributes.user_id; // This is handled automatically + + // Handle type-specific attribute mapping + if (type === 'palette') { + return { + name: attributes.name, + isColorHistory: attributes.is_color_history, + isFavorite: attributes.is_favorite, + isLocked: attributes.is_locked, + selectedColorIndex: attributes.selected_color_index, + colorOrder: attributes.color_order, + createdAt: attributes.created_at, + }; + } + + if (type === 'color') { + return { + name: attributes.name, + r: attributes.r, + g: attributes.g, + b: attributes.b, + a: attributes.a, + createdAt: attributes.created_at, + }; + } + + return attributes; + } + + extractRelationships(record, type) { + const relationships = {}; + + // Extract relationships based on type + if (type === 'palette' && record.colors) { + relationships.colors = { + data: record.colors.map((color) => ({ + type: 'color', + id: color.id, + })), + }; + } + + if (type === 'color' && record.palette_id) { + relationships.palette = { + data: { type: 'palette', id: record.palette_id }, + }; + } + + return relationships; + } + + pluralize(word) { + // Simple pluralization - could use ember-inflector if needed + if (word.endsWith('y')) { + return word.slice(0, -1) + 'ies'; + } + return word + 's'; + } + + singularize(word) { + // Simple singularization - could use ember-inflector if needed + if (word.endsWith('ies')) { + return word.slice(0, -3) + 'y'; + } + if (word.endsWith('s')) { + return word.slice(0, -1); + } + return word; } } injections.name = 'remote'; - injections.host = ENV.api.host; - injections.RequestProcessorClass = RemoteRequestProcessor; - - // Delay activation until coordinator has been activated. This prevents - // queues from being processed before coordination strategies have been - // configured. + injections.SourceClass = SupabaseSource; injections.autoActivate = false; - injections.serializerSettingsFor = buildSerializerSettingsFor({ - sharedSettings: { - inflectors: { - pluralize, - singularize, - }, - }, - settingsByType: { - [JSONAPISerializers.ResourceType]: { - deserializationOptions: { inflectors: ['singularize'] }, - }, - [JSONAPISerializers.ResourceDocument]: { - deserializationOptions: { inflectors: ['singularize'] }, - }, - }, - }); - - return new JSONAPISource(injections); + return new SupabaseSource(injections); }, }; diff --git a/app/data-sources/remote.js.backup b/app/data-sources/remote.js.backup new file mode 100644 index 000000000..4e598848e --- /dev/null +++ b/app/data-sources/remote.js.backup @@ -0,0 +1,122 @@ +import { getOwner } from '@ember/application'; + +import { pluralize, singularize } from 'ember-inflector'; +import { applyStandardSourceInjections } from 'ember-orbit'; + +import { + JSONAPIRequestProcessor, + JSONAPISerializers, + JSONAPISource, +} from '@orbit/jsonapi'; +import { buildSerializerSettingsFor } from '@orbit/serializers'; +import { AwsClient } from 'aws4fetch'; + +import ENV from 'swach/config/environment'; + +export default { + create(injections = {}) { + applyStandardSourceInjections(injections); + + const app = getOwner(injections); + const session = app.lookup('service:session'); + + class RemoteRequestProcessor extends JSONAPIRequestProcessor { + initFetchSettings(customSettings = {}) { + if (!session.isAuthenticated) { + throw new Error('Remote requests require authentication'); + } + + const settings = super.initFetchSettings(customSettings); + settings.sessionCredentials = + session.data.authenticated.sessionCredentials; + + return settings; + } + async fetch(url, customSettings) { + let settings = this.initFetchSettings(customSettings); + let fullUrl = url; + if (settings.params) { + fullUrl = this.urlBuilder.appendQueryParams(fullUrl, settings.params); + delete settings.params; + } + const aws = new AwsClient({ + accessKeyId: settings.sessionCredentials.accessKeyId, // required, akin to AWS_ACCESS_KEY_ID + secretAccessKey: settings.sessionCredentials.secretAccessKey, // required, akin to AWS_SECRET_ACCESS_KEY + sessionToken: settings.sessionCredentials.sessionToken, // akin to AWS_SESSION_TOKEN if using temp credentials + service: 'execute-api', // AWS service, by default parsed at fetch time + region: 'us-east-2', // AWS region, by default parsed at fetch time + }); + const method = customSettings.method ?? 'GET'; + const request = await aws.sign(fullUrl, { + method, + body: settings.body, + }); + + let fetchFn = fetch; + + if (settings.timeout !== undefined && settings.timeout > 0) { + let timeout = settings.timeout; + delete settings.timeout; + + return new Promise((resolve, reject) => { + let timedOut; + + let timer = setTimeout(() => { + timedOut = true; + reject(new Error(`No fetch response within ${timeout}ms.`)); + }, timeout); + + fetchFn(request) + .catch((e) => { + clearTimeout(timer); + + if (!timedOut) { + return this.handleFetchError(e); + } + }) + .then((response) => { + clearTimeout(timer); + + if (!timedOut) { + return this.handleFetchResponse(response); + } + }) + .then(resolve, reject); + }); + } else { + return fetchFn(request) + .catch((e) => this.handleFetchError(e)) + .then((response) => this.handleFetchResponse(response)); + } + } + } + + injections.name = 'remote'; + injections.host = ENV.api.host; + injections.RequestProcessorClass = RemoteRequestProcessor; + + // Delay activation until coordinator has been activated. This prevents + // queues from being processed before coordination strategies have been + // configured. + injections.autoActivate = false; + + injections.serializerSettingsFor = buildSerializerSettingsFor({ + sharedSettings: { + inflectors: { + pluralize, + singularize, + }, + }, + settingsByType: { + [JSONAPISerializers.ResourceType]: { + deserializationOptions: { inflectors: ['singularize'] }, + }, + [JSONAPISerializers.ResourceDocument]: { + deserializationOptions: { inflectors: ['singularize'] }, + }, + }, + }); + + return new JSONAPISource(injections); + }, +}; diff --git a/app/services/data.ts b/app/services/data.ts index fe0f1e384..738d06e63 100644 --- a/app/services/data.ts +++ b/app/services/data.ts @@ -6,7 +6,6 @@ import Session from 'ember-simple-auth/services/session'; import type { Coordinator } from '@orbit/coordinator'; import type IndexedDBSource from '@orbit/indexeddb'; -import type JSONAPISource from '@orbit/jsonapi'; import type { InitializedRecord, RecordIdentity } from '@orbit/records'; import Palette from 'swach/data-models/palette'; @@ -19,7 +18,7 @@ export default class DataService extends Service { isActivated = false; backup = this.dataCoordinator.getSource('backup'); - remote = this.dataCoordinator.getSource('remote'); + remote = this.dataCoordinator.getSource('remote'); // Will be SupabaseSource async activate(): Promise { const records = await this.getRecordsFromBackup(); diff --git a/app/services/supabase.ts b/app/services/supabase.ts new file mode 100644 index 000000000..20d6da605 --- /dev/null +++ b/app/services/supabase.ts @@ -0,0 +1,94 @@ +import { inject as service } from '@ember/service'; +import Service from '@ember/service'; + +import { createClient } from '@supabase/supabase-js'; +import ENV from '../config/environment'; +import SessionService from 'ember-simple-auth/services/session'; + +export default class SupabaseService extends Service { + @service declare session: SessionService; + + supabase = createClient( + (ENV as any).supabase.url, + (ENV as any).supabase.anonKey + ); + + async signIn(email: string, password: string) { + const { data, error } = await this.supabase.auth.signInWithPassword({ + email, + password, + }); + + if (error) { + throw error; + } + + return data; + } + + async signUp(email: string, password: string) { + const { data, error } = await this.supabase.auth.signUp({ + email, + password, + }); + + if (error) { + throw error; + } + + return data; + } + + async signOut() { + const { error } = await this.supabase.auth.signOut(); + + if (error) { + throw error; + } + } + + async getCurrentUser() { + const { + data: { user }, + error, + } = await this.supabase.auth.getUser(); + + if (error) { + throw error; + } + + return user; + } + + async getSession() { + const { + data: { session }, + error, + } = await this.supabase.auth.getSession(); + + if (error) { + throw error; + } + + return session; + } + + onAuthStateChange(callback: (event: string, session: any) => void) { + return this.supabase.auth.onAuthStateChange(callback); + } + + get auth() { + return this.supabase.auth; + } + + get client() { + return this.supabase; + } +} + +// DO NOT DELETE: this is how TypeScript knows how to look up your services. +declare module '@ember/service' { + interface Registry { + supabase: SupabaseService; + } +} diff --git a/config/content-security-policy.js b/config/content-security-policy.js index bc6f66ae7..c997caaf0 100644 --- a/config/content-security-policy.js +++ b/config/content-security-policy.js @@ -11,10 +11,15 @@ module.exports = function (environment) { 'font-src': ["'self'"], 'frame-src': ["'self'"], 'connect-src': [ + // Legacy AWS Cognito domains - will be removed after migration 'https://cognito-idp.us-east-2.amazonaws.com/', 'https://cognito-identity.us-east-2.amazonaws.com/', 'https://jpuj8ukmx8.execute-api.us-east-2.amazonaws.com/dev/', 'https://n3tygwauml.execute-api.us-east-2.amazonaws.com/prod/', + // Supabase domains + 'https://*.supabase.co', + 'https://*.supabase.in', + // Other services 'https://sentry.io/', 'http://localhost:3000', "'self'", diff --git a/config/environment.d.ts b/config/environment.d.ts new file mode 100644 index 000000000..56f901d20 --- /dev/null +++ b/config/environment.d.ts @@ -0,0 +1,28 @@ +export default function (environment: string): { + modulePrefix: string; + environment: string; + rootURL: string; + locationType: string; + SCHEMA_VERSION: number; + APP: Record; + api: { + host: string; + }; + cognito: { + poolId: string; + clientId: string; + identityPoolId: string; + region: string; + }; + supabase: { + url: string; + anonKey: string; + }; + flashMessageDefaults: { + injectionFactories: string[]; + }; + orbit: { + skipValidatorService: boolean; + }; + [key: string]: unknown; +}; diff --git a/config/environment.js b/config/environment.js index 5396f6cfb..fc9114c84 100644 --- a/config/environment.js +++ b/config/environment.js @@ -28,12 +28,17 @@ module.exports = function (environment) { api: { host: 'https://n3tygwauml.execute-api.us-east-2.amazonaws.com/prod', }, + // Legacy Cognito config - will be removed after migration cognito: { poolId: 'us-east-2_QwzHPTSIB', clientId: '3qt66sk0l4k4bnm3ndge7inp80', identityPoolId: 'us-east-2:b38b2ff6-f0e2-4ddb-8c51-294480a7fdb4', region: 'us-east-2', }, + supabase: { + url: process.env.SUPABASE_URL || 'https://your-project-ref.supabase.co', + anonKey: process.env.SUPABASE_ANON_KEY || 'your-anon-key', + }, flashMessageDefaults: { injectionFactories: [], }, @@ -61,6 +66,10 @@ module.exports = function (environment) { // identityPoolId: 'us-east-2:af67b33e-b9cd-4eaa-9669-e478e56e9310', // region: 'us-east-2' // }; + // ENV.supabase = { + // url: 'https://dev-project-ref.supabase.co', + // anonKey: 'dev-anon-key', + // }; ENV.orbit.skipValidatorService = false; } @@ -87,6 +96,10 @@ module.exports = function (environment) { identityPoolId: 'us-east-2:b38b2ff6-f0e2-4ddb-8c51-294480a7fdb4', region: 'us-east-2', }; + ENV.supabase = { + url: process.env.SUPABASE_URL || 'https://your-project-ref.supabase.co', + anonKey: process.env.SUPABASE_ANON_KEY || 'your-anon-key', + }; ENV['@sentry/ember'].sentry.dsn = 'https://6974b46329f24dc1b9fca4507c65e942@sentry.io/3956140'; diff --git a/package.json b/package.json index eab22c07f..add0379f2 100644 --- a/package.json +++ b/package.json @@ -64,11 +64,12 @@ "@orbit/utils": "^0.17.0", "@release-it/bumper": "^4.0.2", "@sentry/ember": "^7.47.0", + "@supabase/supabase-js": "^2.87.1", "@tailwindcss/forms": "^0.5.3", "@types/ember": "^4.0.3", + "@types/ember__test-helpers": "^2.9.1", "@types/ember-qunit": "^6.1.1", "@types/ember-resolver": "^9.0.0", - "@types/ember__test-helpers": "^2.9.1", "@types/qunit": "^2.19.4", "@types/sinon": "^10.0.13", "@typescript-eslint/eslint-plugin": "^5.58.0",